Compare commits

...
Author SHA1 Message Date
Alejandro GonzálezandAlejandro González bf35ae3897 feat: update to use new Delphi issue schema 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 1b3c47a4a6 chore: run cargo fmt 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 512ea30a62 chore: fix typo on frontend generated state file message 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 587c00a69e chore: run cargo sqlx prepare 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González ea408e0da8 tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 90fbde78b1 chore(labrinth): run cargo sqlx prepare to fix Docker build errors 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 5ebaf42838 feat(labrinth/delphi): support new Delphi report severity field 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González eecfcfcfc1 tweak(labrinth): expose base62 file IDs more consistently for Delphi 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 0959b6ed6a fix: accept null decompiled source payloads from Delphi 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 77adb14d32 chore: expose file IDs in version queries 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 8ed92121a8 feat(labrinth/delphi): use PAT token authorization with project read scopes 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 0d3c19a6b6 chore: fix unused import Clippy lint 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 836f63ab1d feat: add Delphi to Docker Compose with-delphi profile 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 5ae5f43f19 chore: address some review comments 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 708a805a18 feat(labrinth): overhaul malware scanner report storage and routes 2025-10-26 15:01:10 +01:00
Alejandro GonzálezandAlejandro González 89d35e52c2 chore: fix typo in status message 2025-10-26 15:01:10 +01:00
22 changed files with 1150 additions and 164 deletions
+1 -1
View File
@@ -167,7 +167,7 @@ export default defineNuxtConfig({
(state.errors ?? []).length === 0
) {
console.log(
'Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.',
'Tags already recently generated. Delete apps/frontend/src/generated/state.json to force regeneration.',
)
return
}
+1 -1
View File
@@ -129,7 +129,7 @@ PYRO_API_KEY=none
BREX_API_URL=https://platform.brexapis.com/v2/
BREX_API_KEY=none
DELPHI_URL=none
DELPHI_URL=http://labrinth-delphi:59999
DELPHI_SLACK_WEBHOOK=none
AVALARA_1099_API_URL=https://www.track1099.com/api
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n version_id AS \"version_id: crate::database::models::DBVersionId\",\n versions.mod_id AS \"project_id: crate::database::models::DBProjectId\",\n files.url AS \"url\"\n FROM files INNER JOIN versions ON files.version_id = versions.id\n WHERE files.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: crate::database::models::DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_id: crate::database::models::DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "url",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "0ed2e6e3149352d12a673fddc50f9530c311eef084abb6fce35de5f37d79bcea"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_report_issues (report_id, issue_type, status)\n VALUES ($1, $2, $3)\n ON CONFLICT (report_id, issue_type) DO UPDATE SET status = $3\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"approved",
"rejected"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "10a332091be118f580d50ceb7a8724e9a4d5b9765d52305f99f859f939c2e854"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, internal_class_name, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Jsonb",
{
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "26a43c77d3c1e875889141d209f01763de4c51bd16c100549261b11c5a4142b8"
}
@@ -0,0 +1,126 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n delphi_report_issues.id AS \"id\", report_id,\n issue_type,\n delphi_report_issues.status AS \"status: DelphiReportIssueStatus\",\n\n file_id, delphi_version, artifact_url, created, severity AS \"severity: DelphiSeverity\",\n json_array(SELECT to_jsonb(delphi_report_issue_details)\n FROM delphi_report_issue_details\n WHERE issue_id = delphi_report_issues.id\n ) AS \"details: sqlx::types::Json<Vec<DBDelphiReportIssueDetails>>\",\n versions.mod_id AS \"project_id?\", mods.published AS \"project_published?\"\n FROM delphi_report_issues\n INNER JOIN delphi_reports ON delphi_reports.id = report_id\n LEFT OUTER JOIN files ON files.id = file_id\n LEFT OUTER JOIN versions ON versions.id = files.version_id\n LEFT OUTER JOIN mods ON mods.id = versions.mod_id\n WHERE\n (issue_type = $1 OR $1 IS NULL)\n AND (delphi_report_issues.status = $2 OR $2 IS NULL)\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'pending_status_first' THEN delphi_report_issues.status ELSE 'pending'::delphi_report_issue_status END ASC,\n CASE WHEN $3 = 'severity_asc' THEN delphi_reports.severity ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN delphi_reports.severity ELSE 'low'::delphi_severity END DESC\n OFFSET $5\n LIMIT $4\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "report_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "issue_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "status: DelphiReportIssueStatus",
"type_info": {
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"approved",
"rejected"
]
}
}
}
},
{
"ordinal": 4,
"name": "file_id",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "delphi_version",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "artifact_url",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "severity: DelphiSeverity",
"type_info": {
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
},
{
"ordinal": 9,
"name": "details: sqlx::types::Json<Vec<DBDelphiReportIssueDetails>>",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "project_id?",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "project_published?",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"approved",
"rejected"
]
}
}
},
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
false,
null,
true,
true
]
},
"hash": "c0ef7e1f2ddc02604c14a94235afc053676964380451b3f461e3276f3a26bbff"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM delphi_report_issue_details WHERE issue_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c7c72cf1f98cbc2b647ab840bdfadf1de8aaf214b32a2aab299a0d87fd2dc453"
}
@@ -0,0 +1,37 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_reports (file_id, delphi_version, artifact_url, severity)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (file_id, delphi_version) DO UPDATE SET\n delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP, severity = $4\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int4",
"Varchar",
{
"Custom": {
"name": "delphi_severity",
"kind": {
"Enum": [
"low",
"medium",
"high",
"severe"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(delphi_version) FROM delphi_reports",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "fe571872262fe7d119b4b6eb1e55d818fde0499d8e5a08e9e22bee42014877f3"
}
@@ -0,0 +1,46 @@
CREATE TYPE delphi_severity AS ENUM ('low', 'medium', 'high', 'severe');
CREATE TYPE delphi_report_issue_status AS ENUM ('pending', 'approved', 'rejected');
-- A Delphi analysis report for a project version
CREATE TABLE delphi_reports (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
file_id BIGINT REFERENCES files (id)
ON DELETE SET NULL
ON UPDATE CASCADE,
delphi_version INTEGER NOT NULL,
artifact_url VARCHAR(2048) NOT NULL,
created TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
severity DELPHI_SEVERITY NOT NULL,
UNIQUE (file_id, delphi_version)
);
CREATE INDEX delphi_version ON delphi_reports (delphi_version);
-- An issue found in a Delphi report. Every issue belongs to a report,
-- and a report can have zero, one, or more issues attached to it
CREATE TABLE delphi_report_issues (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
report_id BIGINT NOT NULL REFERENCES delphi_reports (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
issue_type TEXT NOT NULL,
status DELPHI_REPORT_ISSUE_STATUS NOT NULL,
UNIQUE (report_id, issue_type)
);
CREATE INDEX delphi_report_issue_by_status_and_type ON delphi_report_issues (status, issue_type);
-- The details of a Delphi report issue, which contain data about a
-- Java class affected by it. Every Delphi report issue details object
-- belongs to a specific issue, and an issue can have zero, one, or
-- more details attached to it. (Some issues may be artifact-wide,
-- or otherwise not really specific to any particular class.)
CREATE TABLE delphi_report_issue_details (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
issue_id BIGINT NOT NULL REFERENCES delphi_report_issues (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
internal_class_name TEXT NOT NULL,
decompiled_source TEXT,
data JSONB NOT NULL,
severity DELPHI_SEVERITY NOT NULL
);
@@ -0,0 +1,326 @@
use std::{
collections::HashMap,
fmt::{self, Display, Formatter},
ops::Deref,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
use crate::database::models::{
DBFileId, DBProjectId, DatabaseError, DelphiReportId,
DelphiReportIssueDetailsId, DelphiReportIssueId,
};
/// A Delphi malware analysis report for a project version file.
///
/// Malware analysis reports usually belong to a specific project file,
/// but they can get orphaned if the versions they belong to are deleted.
/// Thus, deleting versions does not delete these reports.
#[derive(Serialize)]
pub struct DBDelphiReport {
pub id: DelphiReportId,
pub file_id: Option<DBFileId>,
/// A sequential, monotonically increasing version number for the
/// Delphi version that generated this report.
pub delphi_version: i32,
pub artifact_url: String,
pub created: DateTime<Utc>,
pub severity: DelphiSeverity,
}
impl DBDelphiReport {
pub async fn upsert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportId, DatabaseError> {
Ok(DelphiReportId(sqlx::query_scalar!(
"
INSERT INTO delphi_reports (file_id, delphi_version, artifact_url, severity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (file_id, delphi_version) DO UPDATE SET
delphi_version = $2, artifact_url = $3, created = CURRENT_TIMESTAMP, severity = $4
RETURNING id
",
self.file_id as Option<DBFileId>,
self.delphi_version,
self.artifact_url,
self.severity as DelphiSeverity,
)
.fetch_one(&mut **transaction)
.await?))
}
}
/// A severity level reported by Delphi.
#[derive(
Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type,
)]
#[serde(rename_all = "UPPERCASE")]
#[sqlx(type_name = "delphi_severity", rename_all = "snake_case")]
pub enum DelphiSeverity {
Low,
Medium,
High,
Severe,
}
/// An issue found in a Delphi report. Every issue belongs to a report,
/// and a report can have zero, one, or more issues attached to it.
#[derive(Deserialize, Serialize)]
pub struct DBDelphiReportIssue {
pub id: DelphiReportIssueId,
pub report_id: DelphiReportId,
pub issue_type: String,
pub status: DelphiReportIssueStatus,
}
/// An status a Delphi report issue can have.
#[derive(
Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type,
)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "delphi_report_issue_status", rename_all = "snake_case")]
pub enum DelphiReportIssueStatus {
/// The issue is pending review by the moderation team.
Pending,
/// The issue has been approved (i.e., reviewed as a valid, true positive).
/// The affected artifact has thus been verified to be potentially malicious.
Approved,
/// The issue has been rejected (i.e., reviewed as a false positive).
/// The affected artifact has thus been verified to be clean, other issues
/// with it notwithstanding.
Rejected,
}
impl Display for DelphiReportIssueStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}
/// An order in which Delphi report issues can be sorted during queries.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum DelphiReportListOrder {
CreatedAsc,
CreatedDesc,
PendingStatusFirst,
SeverityAsc,
SeverityDesc,
}
impl Display for DelphiReportListOrder {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}
/// A result returned from a Delphi report issue query, slightly
/// denormalized with related entity information for ease of
/// consumption by clients.
#[derive(Serialize)]
pub struct DelphiReportIssueResult {
pub issue: DBDelphiReportIssue,
pub report: DBDelphiReport,
pub details: Vec<DBDelphiReportIssueDetails>,
pub project_id: Option<DBProjectId>,
pub project_published: Option<DateTime<Utc>>,
}
impl DBDelphiReportIssue {
pub async fn upsert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportIssueId, DatabaseError> {
Ok(DelphiReportIssueId(
sqlx::query_scalar!(
"
INSERT INTO delphi_report_issues (report_id, issue_type, status)
VALUES ($1, $2, $3)
ON CONFLICT (report_id, issue_type) DO UPDATE SET status = $3
RETURNING id
",
self.report_id as DelphiReportId,
self.issue_type,
self.status as DelphiReportIssueStatus,
)
.fetch_one(&mut **transaction)
.await?,
))
}
pub async fn find_all_by(
ty: Option<String>,
status: Option<DelphiReportIssueStatus>,
order_by: Option<DelphiReportListOrder>,
count: Option<u16>,
offset: Option<i64>,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DelphiReportIssueResult>, DatabaseError> {
Ok(sqlx::query!(
r#"
SELECT
delphi_report_issues.id AS "id", report_id,
issue_type,
delphi_report_issues.status AS "status: DelphiReportIssueStatus",
file_id, delphi_version, artifact_url, created, severity AS "severity: DelphiSeverity",
json_array(SELECT to_jsonb(delphi_report_issue_details)
FROM delphi_report_issue_details
WHERE issue_id = delphi_report_issues.id
) AS "details: sqlx::types::Json<Vec<DBDelphiReportIssueDetails>>",
versions.mod_id AS "project_id?", mods.published AS "project_published?"
FROM delphi_report_issues
INNER JOIN delphi_reports ON delphi_reports.id = report_id
LEFT OUTER JOIN files ON files.id = file_id
LEFT OUTER JOIN versions ON versions.id = files.version_id
LEFT OUTER JOIN mods ON mods.id = versions.mod_id
WHERE
(issue_type = $1 OR $1 IS NULL)
AND (delphi_report_issues.status = $2 OR $2 IS NULL)
ORDER BY
CASE WHEN $3 = 'created_asc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN delphi_reports.created ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'pending_status_first' THEN delphi_report_issues.status ELSE 'pending'::delphi_report_issue_status END ASC,
CASE WHEN $3 = 'severity_asc' THEN delphi_reports.severity ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN delphi_reports.severity ELSE 'low'::delphi_severity END DESC
OFFSET $5
LIMIT $4
"#,
ty,
status as Option<DelphiReportIssueStatus>,
order_by.map(|order_by| order_by.to_string()),
count.map(|count| count as i64),
offset,
)
.map(|row| DelphiReportIssueResult {
issue: DBDelphiReportIssue {
id: DelphiReportIssueId(row.id),
report_id: DelphiReportId(row.report_id),
issue_type: row.issue_type,
status: row.status,
},
report: DBDelphiReport {
id: DelphiReportId(row.report_id),
file_id: row.file_id.map(DBFileId),
delphi_version: row.delphi_version,
artifact_url: row.artifact_url,
created: row.created,
severity: row.severity,
},
details: row
.details
.into_iter()
.flat_map(|details_list| details_list.0)
.collect(),
project_id: row.project_id.map(DBProjectId),
project_published: row.project_published,
})
.fetch_all(exec)
.await?)
}
}
/// The details of a Delphi report issue, which contain data about a
/// Java class affected by it. Every Delphi report issue details object
/// belongs to a specific issue, and an issue can have zero, one, or
/// more details attached to it. (Some issues may be artifact-wide,
/// or otherwise not really specific to any particular class.)
#[derive(Debug, Deserialize, Serialize)]
pub struct DBDelphiReportIssueDetails {
pub id: DelphiReportIssueDetailsId,
pub issue_id: DelphiReportIssueId,
pub internal_class_name: InternalJavaClassName,
pub decompiled_source: Option<DecompiledJavaClassSource>,
pub data: Json<HashMap<String, serde_json::Value>>,
pub severity: DelphiSeverity,
}
impl DBDelphiReportIssueDetails {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DelphiReportIssueDetailsId, DatabaseError> {
Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!(
"
INSERT INTO delphi_report_issue_details (issue_id, internal_class_name, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
",
self.issue_id as DelphiReportIssueId,
self.internal_class_name.0,
self.decompiled_source.as_ref().map(|decompiled_source| &decompiled_source.0),
&self.data as &Json<HashMap<String, serde_json::Value>>,
self.severity as DelphiSeverity,
)
.fetch_one(&mut **transaction)
.await?))
}
pub async fn remove_all_by_issue_id(
issue_id: DelphiReportIssueId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<u64, DatabaseError> {
Ok(sqlx::query!(
"DELETE FROM delphi_report_issue_details WHERE issue_id = $1",
issue_id as DelphiReportIssueId,
)
.execute(&mut **transaction)
.await?
.rows_affected())
}
}
/// A [Java class name] with dots replaced by forward slashes (/).
///
/// Because class names are usually the [binary names] passed to a classloader, top level interfaces and classes
/// have a binary name that matches its canonical, fully qualified name, such canonical names are prefixed by the
/// package path the class is in, and packages usually match the directory structure within a JAR for typical
/// classloaders, this usually (but not necessarily) corresponds to the path to the class file within its JAR.
///
/// [Java class name]: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Class.html#getName()
/// [binary names]: https://docs.oracle.com/javase/specs/jls/se21/html/jls-13.html#jls-13.1
#[derive(
Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, sqlx::Type,
)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct InternalJavaClassName(String);
impl Deref for InternalJavaClassName {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Display for InternalJavaClassName {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// The decompiled source code of a Java class.
#[derive(
Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, sqlx::Type,
)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct DecompiledJavaClassSource(String);
impl Deref for DecompiledJavaClassSource {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Display for DecompiledJavaClassSource {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
+17 -14
View File
@@ -140,8 +140,8 @@ macro_rules! db_id_interface {
};
}
macro_rules! short_id_type {
($name:ident) => {
macro_rules! id_type {
($name:ident as $type:ty) => {
#[derive(
Copy,
Clone,
@@ -154,7 +154,7 @@ macro_rules! short_id_type {
Hash,
)]
#[sqlx(transparent)]
pub struct $name(pub i32);
pub struct $name(pub $type);
};
}
@@ -268,14 +268,17 @@ db_id_interface!(
generator: generate_affiliate_code_id @ "affiliate_codes",
);
short_id_type!(CategoryId);
short_id_type!(GameId);
short_id_type!(LinkPlatformId);
short_id_type!(LoaderFieldEnumId);
short_id_type!(LoaderFieldEnumValueId);
short_id_type!(LoaderFieldId);
short_id_type!(LoaderId);
short_id_type!(NotificationActionId);
short_id_type!(ProjectTypeId);
short_id_type!(ReportTypeId);
short_id_type!(StatusId);
id_type!(CategoryId as i32);
id_type!(GameId as i32);
id_type!(LinkPlatformId as i32);
id_type!(LoaderFieldEnumId as i32);
id_type!(LoaderFieldEnumValueId as i32);
id_type!(LoaderFieldId as i32);
id_type!(LoaderId as i32);
id_type!(NotificationActionId as i32);
id_type!(ProjectTypeId as i32);
id_type!(ReportTypeId as i32);
id_type!(StatusId as i32);
id_type!(DelphiReportId as i64);
id_type!(DelphiReportIssueId as i64);
id_type!(DelphiReportIssueDetailsId as i64);
+1
View File
@@ -4,6 +4,7 @@ pub mod affiliate_code_item;
pub mod categories;
pub mod charge_item;
pub mod collection_item;
pub mod delphi_report_item;
pub mod flow_item;
pub mod friend_item;
pub mod ids;
@@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{
};
use crate::database::redis::RedisPool;
use crate::models::projects::{FileType, VersionStatus};
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
@@ -164,6 +165,17 @@ impl VersionFileBuilder {
.await?;
}
if let Err(err) = crate::routes::internal::delphi::run(
&mut **transaction,
DelphiRunParameters {
file_id: file_id.into(),
},
)
.await
{
tracing::error!("Error submitting new file to Delphi: {err}");
}
Ok(file_id)
}
}
+6 -1
View File
@@ -5,7 +5,7 @@ use crate::database::models::loader_fields::VersionField;
use crate::database::models::project_item::{LinkUrl, ProjectQueryResult};
use crate::database::models::version_item::VersionQueryResult;
use crate::models::ids::{
OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
@@ -716,6 +716,7 @@ impl From<VersionQueryResult> for Version {
.files
.into_iter()
.map(|f| VersionFile {
id: Some(FileId(f.id.0 as u64)),
url: f.url,
filename: f.filename,
hashes: f.hashes,
@@ -840,6 +841,10 @@ impl VersionStatus {
/// A single project file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize, Clone)]
pub struct VersionFile {
/// The ID of the file. Every file has an ID once created, but it
/// is not known until it indeed has been created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<FileId>,
/// A map of hashes of the file. The key is the hashing algorithm
/// and the value is the string version of the hash.
pub hashes: std::collections::HashMap<String, String>,
+1 -102
View File
@@ -1,12 +1,9 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::models::analytics::Download;
use crate::models::ids::ProjectId;
use crate::models::pats::Scopes;
use crate::models::threads::MessageBody;
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::moderation::AUTOMOD_ID;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
@@ -17,17 +14,14 @@ use modrinth_maxmind::MaxMind;
use serde::Deserialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::fmt::Write;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tracing::info;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("admin")
.service(count_download)
.service(force_reindex)
.service(delphi_result_ingest),
.service(force_reindex),
);
}
@@ -163,98 +157,3 @@ pub async fn force_reindex(
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
pub struct DelphiIngest {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
pub version_id: crate::models::ids::VersionId,
pub issues: HashMap<String, HashMap<String, String>>,
}
#[post("/_delphi", guard = "admin_key_guard")]
pub async fn delphi_result_ingest(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
body: web::Json<DelphiIngest>,
) -> Result<HttpResponse, ApiError> {
if body.issues.is_empty() {
info!("No issues found for file {}", body.url);
return Ok(HttpResponse::NoContent().finish());
}
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let project = crate::database::models::DBProject::get_id(
body.project_id.into(),
&**pool,
&redis,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Project {} does not exist",
body.project_id
))
})?;
let mut header = format!("Suspicious traces found at {}", body.url);
for (issue, trace) in &body.issues {
for (path, code) in trace {
write!(
&mut header,
"\n issue {issue} found at file {path}: \n ```\n{code}\n```"
)
.unwrap();
}
}
crate::util::webhook::send_slack_project_webhook(
body.project_id,
&pool,
&redis,
webhook_url,
Some(header),
)
.await
.ok();
let mut thread_header = format!(
"Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})",
body.version_id, body.project_id, body.version_id
);
for (issue, trace) in &body.issues {
for path in trace.keys() {
write!(
&mut thread_header,
"\n\n- issue {issue} found at file {path}"
)
.unwrap();
}
if trace.is_empty() {
write!(&mut thread_header, "\n\n- issue {issue} found").unwrap();
}
}
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(crate::database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: thread_header,
private: true,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().finish())
}
+408
View File
@@ -0,0 +1,408 @@
use std::{collections::HashMap, fmt::Write, sync::LazyLock, time::Instant};
use actix_web::{HttpRequest, HttpResponse, get, post, put, web};
use chrono::{DateTime, Utc};
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use sqlx::PgPool;
use tokio::sync::Mutex;
use tracing::info;
use crate::{
auth::check_is_moderator_from_headers,
database::{
models::{
DBFileId, DelphiReportId, DelphiReportIssueDetailsId,
DelphiReportIssueId,
delphi_report_item::{
DBDelphiReport, DBDelphiReportIssue,
DBDelphiReportIssueDetails, DecompiledJavaClassSource,
DelphiReportIssueStatus, DelphiReportListOrder, DelphiSeverity,
InternalJavaClassName,
},
},
redis::RedisPool,
},
models::{
ids::{ProjectId, VersionId},
pats::Scopes,
},
queue::session::AuthQueue,
routes::ApiError,
util::guards::admin_key_guard,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("delphi")
.service(ingest_report)
.service(_run)
.service(version)
.service(issues)
.service(update_issue)
.service(issue_type_schema),
);
}
static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.default_headers({
HeaderMap::from_iter([(
USER_AGENT,
HeaderValue::from_static(concat!(
"Labrinth/",
env!("COMPILATION_DATE")
)),
)])
})
.build()
.unwrap()
});
#[derive(Deserialize)]
struct DelphiReportIssueDetails {
pub internal_class_name: InternalJavaClassName,
pub decompiled_source: Option<DecompiledJavaClassSource>,
pub data: HashMap<String, serde_json::Value>,
pub severity: DelphiSeverity,
}
#[derive(Deserialize)]
struct DelphiReport {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
#[serde(rename = "version_id")]
pub _version_id: crate::models::ids::VersionId,
pub file_id: crate::models::ids::FileId,
/// A sequential, monotonically increasing version number for the
/// Delphi version that generated this report.
pub delphi_version: i32,
pub issues: HashMap<String, Vec<DelphiReportIssueDetails>>,
pub severity: DelphiSeverity,
}
impl DelphiReport {
async fn send_to_slack(
&self,
pool: &PgPool,
redis: &RedisPool,
) -> Result<(), ApiError> {
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
let mut message_header =
format!("⚠️ Suspicious traces found at {}", self.url);
for (issue, trace) in &self.issues {
for DelphiReportIssueDetails {
internal_class_name,
decompiled_source,
..
} in trace
{
write!(
&mut message_header,
"\n issue {issue} found at class `{internal_class_name}`:\n```\n{}\n```",
decompiled_source.as_ref().map_or(
"No decompiled source available",
|decompiled_source| &**decompiled_source
)
)
.ok();
}
}
crate::util::webhook::send_slack_project_webhook(
self.project_id,
pool,
redis,
webhook_url,
Some(message_header),
)
.await
}
}
#[derive(Deserialize)]
pub struct DelphiRunParameters {
pub file_id: crate::models::ids::FileId,
}
#[post("ingest", guard = "admin_key_guard")]
async fn ingest_report(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
web::Json(report): web::Json<DelphiReport>,
) -> Result<HttpResponse, ApiError> {
if report.issues.is_empty() {
info!("No issues found for file {}", report.url);
return Ok(HttpResponse::NoContent().finish());
}
report.send_to_slack(&pool, &redis).await.ok();
let mut transaction = pool.begin().await?;
let report_id = DBDelphiReport {
id: DelphiReportId(0), // This will be set by the database
file_id: Some(DBFileId(report.file_id.0 as i64)),
delphi_version: report.delphi_version,
artifact_url: report.url.clone(),
created: DateTime::<Utc>::MIN_UTC, // This will be set by the database
severity: report.severity,
}
.upsert(&mut transaction)
.await?;
for (issue_type, issue_details) in report.issues {
let issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
issue_type,
status: DelphiReportIssueStatus::Pending,
}
.upsert(&mut transaction)
.await?;
// This is required to handle the case where the same Delphi version is re-run on the same file
DBDelphiReportIssueDetails::remove_all_by_issue_id(
issue_id,
&mut transaction,
)
.await?;
for issue_detail in issue_details {
DBDelphiReportIssueDetails {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id,
internal_class_name: issue_detail.internal_class_name,
decompiled_source: issue_detail.decompiled_source,
data: issue_detail.data.into(),
severity: issue_detail.severity,
}
.insert(&mut transaction)
.await?;
}
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().finish())
}
pub async fn run(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
run_parameters: DelphiRunParameters,
) -> Result<HttpResponse, ApiError> {
let file_data = sqlx::query!(
r#"
SELECT
version_id AS "version_id: crate::database::models::DBVersionId",
versions.mod_id AS "project_id: crate::database::models::DBProjectId",
files.url AS "url"
FROM files INNER JOIN versions ON files.version_id = versions.id
WHERE files.id = $1
"#,
run_parameters.file_id.0 as i64
)
.fetch_one(exec)
.await?;
tracing::debug!(
"Running Delphi for project {}, version {}, file {}",
file_data.project_id.0,
file_data.version_id.0,
run_parameters.file_id.0
);
DELPHI_CLIENT
.post(dotenvy::var("DELPHI_URL")?)
.json(&serde_json::json!({
"url": file_data.url,
"project_id": ProjectId(file_data.project_id.0 as u64),
"version_id": VersionId(file_data.version_id.0 as u64),
"file_id": run_parameters.file_id,
}))
.send()
.await
.and_then(|res| res.error_for_status())
.map_err(ApiError::Delphi)?;
Ok(HttpResponse::NoContent().finish())
}
#[post("run")]
async fn _run(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
run_parameters: web::Query<DelphiRunParameters>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
run(&**pool, run_parameters.into_inner()).await
}
#[get("version")]
async fn version(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
Ok(HttpResponse::Ok().json(
sqlx::query_scalar!("SELECT MAX(delphi_version) FROM delphi_reports")
.fetch_one(&**pool)
.await?,
))
}
#[derive(Deserialize)]
struct DelphiIssuesSearchOptions {
#[serde(rename = "type")]
ty: Option<String>,
status: Option<DelphiReportIssueStatus>,
order_by: Option<DelphiReportListOrder>,
count: Option<u16>,
offset: Option<u64>,
}
#[get("issues")]
async fn issues(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Query(search_options): web::Query<DelphiIssuesSearchOptions>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
Ok(HttpResponse::Ok().json(
DBDelphiReportIssue::find_all_by(
search_options.ty,
search_options.status,
search_options.order_by,
search_options.count,
search_options
.offset
.map(|offset| offset.try_into())
.transpose()
.map_err(|err| {
ApiError::InvalidInput(format!("Invalid offset: {err}"))
})?,
&**pool,
)
.await?,
))
}
#[put("issue/{issue_id}")]
async fn update_issue(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
issue_id: web::Path<DelphiReportIssueId>,
web::Json(update_data): web::Json<DBDelphiReportIssue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let new_id = issue_id.into_inner();
let mut transaction = pool.begin().await?;
let modified_same_issue = (DBDelphiReportIssue {
id: new_id, // Doesn't matter, upsert done for values of other fields
report_id: update_data.report_id,
issue_type: update_data.issue_type,
status: update_data.status,
})
.upsert(&mut transaction)
.await?
== new_id;
transaction.commit().await?;
if modified_same_issue {
Ok(HttpResponse::NoContent().finish())
} else {
Ok(HttpResponse::Created().finish())
}
}
#[get("issue_type/schema")]
async fn issue_type_schema(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
// This route is expected to be called often by the frontend, and Delphi is not necessarily
// built to scale beyond malware analysis, so cache the result of its quasi-constant-valued
// schema route to alleviate the load on it
static CACHED_ISSUE_TYPE_SCHEMA: Mutex<
Option<(serde_json::Map<String, serde_json::Value>, Instant)>,
> = Mutex::const_new(None);
match &mut *CACHED_ISSUE_TYPE_SCHEMA.lock().await {
Some((schema, last_fetch)) if last_fetch.elapsed().as_secs() < 60 => {
Ok(HttpResponse::Ok().json(schema))
}
cache_entry => Ok(HttpResponse::Ok().json(
&cache_entry
.insert((
DELPHI_CLIENT
.get(format!("{}/schema", dotenvy::var("DELPHI_URL")?))
.send()
.await
.and_then(|res| res.error_for_status())
.map_err(ApiError::Delphi)?
.json::<serde_json::Map<String, serde_json::Value>>()
.await
.map_err(ApiError::Delphi)?,
Instant::now(),
))
.0,
)),
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
pub(crate) mod admin;
pub mod affiliate;
pub mod billing;
pub mod delphi;
pub mod external_notifications;
pub mod flows;
pub mod gdpr;
@@ -31,6 +32,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(statuses::config)
.configure(medal::config)
.configure(external_notifications::config)
.configure(affiliate::config),
.configure(affiliate::config)
.configure(delphi::config),
);
}
+4
View File
@@ -155,6 +155,8 @@ pub enum ApiError {
RateLimitError(u128, u32),
#[error("Error while interacting with payment processor: {0}")]
Stripe(#[from] stripe::StripeError),
#[error("Error while interacting with Delphi: {0}")]
Delphi(reqwest::Error),
}
impl ApiError {
@@ -194,6 +196,7 @@ impl ApiError {
ApiError::Stripe(..) => "stripe_error",
ApiError::TaxProcessor(..) => "tax_processor_error",
ApiError::Slack(..) => "slack_error",
ApiError::Delphi(..) => "delphi_error",
},
description: self.to_string(),
}
@@ -236,6 +239,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Delphi(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -339,9 +339,6 @@ async fn project_create_inner(
redis: &RedisPool,
session_queue: &AuthQueue,
) -> Result<HttpResponse, CreateError> {
// The base URL for files uploaded to S3
let cdn_url = dotenvy::var("CDN_URL")?;
// The currently logged in user
let (_, current_user) = get_user_from_headers(
&req,
@@ -577,7 +574,6 @@ async fn project_create_inner(
uploaded_files,
&mut created_version.files,
&mut created_version.dependencies,
&cdn_url,
&content_disposition,
project_id,
created_version.version_id.into(),
@@ -38,7 +38,6 @@ use sha1::Digest;
use sqlx::postgres::PgPool;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tracing::error;
use validator::Validate;
fn default_requested_status() -> VersionStatus {
@@ -158,8 +157,6 @@ async fn version_create_inner(
session_queue: &AuthQueue,
moderation_queue: &AutomatedModerationQueue,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let mut initial_version_data = None;
let mut version_builder = None;
let mut selected_loaders = None;
@@ -355,7 +352,6 @@ async fn version_create_inner(
uploaded_files,
&mut version.files,
&mut version.dependencies,
&cdn_url,
&content_disposition,
version.project_id.into(),
version.version_id.into(),
@@ -451,6 +447,7 @@ async fn version_create_inner(
.files
.iter()
.map(|file| VersionFile {
id: None,
hashes: file
.hashes
.iter()
@@ -590,8 +587,6 @@ async fn upload_file_to_version_inner(
version_id: models::DBVersionId,
session_queue: &AuthQueue,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let mut initial_file_data: Option<InitialFileData> = None;
let mut file_builders: Vec<VersionFileBuilder> = Vec::new();
@@ -741,7 +736,6 @@ async fn upload_file_to_version_inner(
uploaded_files,
&mut file_builders,
&mut dependencies,
&cdn_url,
&content_disposition,
project_id,
version_id.into(),
@@ -795,7 +789,6 @@ pub async fn upload_file(
uploaded_files: &mut Vec<UploadedFile>,
version_files: &mut Vec<VersionFileBuilder>,
dependencies: &mut Vec<DependencyBuilder>,
cdn_url: &str,
content_disposition: &actix_web::http::header::ContentDisposition,
project_id: ProjectId,
version_id: VersionId,
@@ -942,21 +935,17 @@ pub async fn upload_file(
|| force_primary
|| total_files_len == 1;
let file_path_encode = format!(
"data/{}/versions/{}/{}",
project_id,
version_id,
let file_path = format!(
"data/{project_id}/versions/{version_id}/{}",
urlencoding::encode(file_name)
);
let file_path =
format!("data/{}/versions/{}/{}", project_id, version_id, &file_name);
let upload_data = file_host
.upload_file(content_type, &file_path, FileHostPublicity::Public, data)
.await?;
uploaded_files.push(UploadedFile {
name: file_path,
name: file_path.clone(),
publicity: FileHostPublicity::Public,
});
@@ -980,33 +969,9 @@ pub async fn upload_file(
return Err(CreateError::InvalidInput(msg.to_string()));
}
let url = format!("{cdn_url}/{file_path_encode}");
let client = reqwest::Client::new();
let delphi_url = dotenvy::var("DELPHI_URL")?;
match client
.post(delphi_url)
.json(&serde_json::json!({
"url": url,
"project_id": project_id,
"version_id": version_id,
}))
.send()
.await
{
Ok(res) => {
if !res.status().is_success() {
error!("Failed to upload file to Delphi: {url}");
}
}
Err(e) => {
error!("Failed to upload file to Delphi: {url}: {e}");
}
}
version_files.push(VersionFileBuilder {
filename: file_name.to_string(),
url: format!("{cdn_url}/{file_path_encode}"),
url: format!("{}/{file_path}", dotenvy::var("CDN_URL")?),
hashes: vec![
models::version_item::HashBuilder {
algorithm: "sha1".to_string(),
+15
View File
@@ -103,6 +103,21 @@ services:
watch:
- path: ./apps/labrinth
action: rebuild
delphi:
profiles:
- with-delphi
image: ghcr.io/modrinth/delphi:main
container_name: labrinth-delphi
ports:
- '59999:59999'
environment:
LABRINTH_ENDPOINT: http://labrinth:8000/_internal/delphi/ingest
LABRINTH_ADMIN_KEY: feedbeef
healthcheck:
test: ['CMD', 'wget', '-q', '-O/dev/null', 'http://localhost:59999/health']
interval: 3s
timeout: 5s
retries: 3
volumes:
meilisearch-data:
db-data: