From 4b17eb5d3534a8d2dfdd491a276f6de646906b5e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?=
<108630700+fetchfern@users.noreply.github.com>
Date: Mon, 20 Oct 2025 00:56:26 +0100
Subject: [PATCH] Gotenberg/PDF gen implementation (#4574)
* Gotenberg/PDF gen implementation
* Security, PDF type enum, propagate client
* chore: query cache, clippy, fmt
* clippy fixes + tombi
* Update env example, add GOTENBERG_CALLBACK_URL
* Remove test code
* Fix .env, docker-compose
* Update purpose of payment
* Add internal networking guards to gotenberg webhooks
* Fix error
* Fix lint
---
Cargo.toml | 6 +-
.../docs/finance/PaymentStatement.vue | 8 +-
apps/labrinth/.env.docker-compose | 3 +
apps/labrinth/.env.local | 3 +
apps/labrinth/src/lib.rs | 8 +
apps/labrinth/src/main.rs | 5 +
apps/labrinth/src/models/v3/oauth_clients.rs | 2 +-
.../labrinth/src/routes/internal/gotenberg.rs | 150 ++++++++++++
apps/labrinth/src/routes/internal/mod.rs | 2 +
apps/labrinth/src/util/gotenberg.rs | 219 ++++++++++++++++++
apps/labrinth/src/util/guards.rs | 8 +
apps/labrinth/src/util/mod.rs | 1 +
apps/labrinth/tests/common/mod.rs | 4 +
docker-compose.yml | 15 +-
14 files changed, 421 insertions(+), 13 deletions(-)
create mode 100644 apps/labrinth/src/routes/internal/gotenberg.rs
create mode 100644 apps/labrinth/src/util/gotenberg.rs
diff --git a/Cargo.toml b/Cargo.toml
index 5f677806..a661c69d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,7 +36,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
] }
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
- "futures-03-sink",
+ "futures-03-sink"
] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
@@ -48,7 +48,7 @@ censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.11.3", default-features = false, features = [
- "macos_15_0",
+ "macos_15_0"
] }
clap = "4.5.48"
clickhouse = "0.14.0"
@@ -129,7 +129,7 @@ reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.39.0", features = [
"serde-with-float",
- "serde-with-str",
+ "serde-with-str"
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.37.0", default-features = false, features = [
diff --git a/apps/frontend/src/templates/docs/finance/PaymentStatement.vue b/apps/frontend/src/templates/docs/finance/PaymentStatement.vue
index dea235ba..5b84b1bf 100644
--- a/apps/frontend/src/templates/docs/finance/PaymentStatement.vue
+++ b/apps/frontend/src/templates/docs/finance/PaymentStatement.vue
@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
Purpose of Payment
- This payout reflects revenue earned by the creator through their activity on the Modrinth
- platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate
- commissions tied to the creator's published projects, in accordance with the Rewards Program
- Terms.
+ This payout reflects the creator's earnings from their activity on the Modrinth platform.
+ Such earnings are based on advertising revenue derived from user engagement with the
+ creator's published projects and/or affiliate commissions in accordance with the Rewards
+ Program Terms.
diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose
index 16c1f003..d272a7e4 100644
--- a/apps/labrinth/.env.docker-compose
+++ b/apps/labrinth/.env.docker-compose
@@ -142,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
+GOTENBERG_URL=http://labrinth-gotenberg:13000
+GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
+
ARCHON_URL=none
diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local
index 43aede0e..45372c45 100644
--- a/apps/labrinth/.env.local
+++ b/apps/labrinth/.env.local
@@ -143,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
+GOTENBERG_URL=http://localhost:13000
+GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
+
ARCHON_URL=none
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index eb90acb7..899297c4 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -14,6 +14,7 @@ use tracing::{info, warn};
extern crate clickhouse as clickhouse_crate;
use clickhouse_crate::Client;
use util::cors::default_cors;
+use util::gotenberg::GotenbergClient;
use crate::background_task::update_versions;
use crate::database::ReadOnlyPgPool;
@@ -63,6 +64,7 @@ pub struct LabrinthConfig {
pub stripe_client: stripe::Client,
pub anrok_client: anrok::Client,
pub email_queue: web::Data,
+ pub gotenberg_client: GotenbergClient,
}
#[allow(clippy::too_many_arguments)]
@@ -77,6 +79,7 @@ pub fn app_setup(
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
+ gotenberg_client: GotenbergClient,
enable_background_tasks: bool,
) -> LabrinthConfig {
info!(
@@ -279,6 +282,7 @@ pub fn app_setup(
rate_limiter: limiter,
stripe_client,
anrok_client,
+ gotenberg_client,
email_queue: web::Data::new(email_queue),
}
}
@@ -304,6 +308,7 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
.app_data(web::Data::new(labrinth_config.file_host.clone()))
.app_data(web::Data::new(labrinth_config.search_config.clone()))
+ .app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
.app_data(labrinth_config.session_queue.clone())
.app_data(labrinth_config.payouts_queue.clone())
.app_data(labrinth_config.email_queue.clone())
@@ -477,6 +482,9 @@ pub fn check_env_vars() -> bool {
failed |= check_var::("FLAME_ANVIL_URL");
+ failed |= check_var::("GOTENBERG_URL");
+ failed |= check_var::("GOTENBERG_CALLBACK_BASE");
+
failed |= check_var::("STRIPE_API_KEY");
failed |= check_var::("STRIPE_WEBHOOK_SECRET");
diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs
index 237aa9ff..f2dbf027 100644
--- a/apps/labrinth/src/main.rs
+++ b/apps/labrinth/src/main.rs
@@ -12,6 +12,7 @@ use labrinth::queue::email::EmailQueue;
use labrinth::search;
use labrinth::util::anrok;
use labrinth::util::env::parse_var;
+use labrinth::util::gotenberg::GotenbergClient;
use labrinth::util::ratelimit::rate_limit_middleware;
use labrinth::{check_env_vars, clickhouse, database, file_hosting};
use std::ffi::CStr;
@@ -200,6 +201,9 @@ async fn main() -> std::io::Result<()> {
let email_queue =
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
+ let gotenberg_client =
+ GotenbergClient::from_env().expect("Failed to create Gotenberg client");
+
if let Some(task) = args.run_background_task {
info!("Running task {task:?} and exiting");
task.run(
@@ -249,6 +253,7 @@ async fn main() -> std::io::Result<()> {
stripe_client,
anrok_client.clone(),
email_queue,
+ gotenberg_client,
!args.no_background_tasks,
);
diff --git a/apps/labrinth/src/models/v3/oauth_clients.rs b/apps/labrinth/src/models/v3/oauth_clients.rs
index 875752bc..6790bb1f 100644
--- a/apps/labrinth/src/models/v3/oauth_clients.rs
+++ b/apps/labrinth/src/models/v3/oauth_clients.rs
@@ -57,8 +57,8 @@ pub struct OAuthClientAuthorization {
pub created: DateTime,
}
-#[serde_as]
#[derive(Deserialize, Serialize)]
+#[serde_as]
pub struct GetOAuthClientsRequest {
#[serde_as(
as = "serde_with::StringWithSeparator::"
diff --git a/apps/labrinth/src/routes/internal/gotenberg.rs b/apps/labrinth/src/routes/internal/gotenberg.rs
new file mode 100644
index 00000000..744a1256
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/gotenberg.rs
@@ -0,0 +1,150 @@
+use actix_web::{
+ HttpMessage, HttpResponse, error::ParseError, http::header, post, web,
+};
+use serde::Deserialize;
+use tracing::trace;
+
+use crate::routes::ApiError;
+use crate::util::gotenberg::{
+ GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
+};
+use crate::util::guards::internal_network_guard;
+
+pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
+ cfg.service(success).service(error);
+}
+
+#[post("/gotenberg/success", guard = "internal_network_guard")]
+pub async fn success(
+ web::Header(header::ContentDisposition {
+ disposition,
+ parameters: disposition_parameters,
+ }): web::Header,
+ web::Header(GotenbergTrace(trace)): web::Header,
+ web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
+ ModrinthGeneratedPdfType,
+ >,
+ maybe_payment_id: Option>,
+ body: web::Bytes,
+) -> Result {
+ trace!(
+ %trace,
+ %disposition,
+ ?disposition_parameters,
+ r#type = r#type.as_str(),
+ ?maybe_payment_id,
+ body.len = body.len(),
+ "Received Gotenberg generated PDF"
+ );
+
+ Ok(HttpResponse::Ok().finish())
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Deserialize)]
+pub struct ErrorBody {
+ status: Option,
+ message: Option,
+}
+
+#[post("/gotenberg/error", guard = "internal_network_guard")]
+pub async fn error(
+ web::Header(GotenbergTrace(trace)): web::Header,
+ web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
+ ModrinthGeneratedPdfType,
+ >,
+ maybe_payment_id: Option>,
+ web::Json(error_body): web::Json,
+) -> Result {
+ trace!(
+ %trace,
+ r#type = r#type.as_str(),
+ ?maybe_payment_id,
+ ?error_body,
+ "Received Gotenberg error webhook"
+ );
+
+ Ok(HttpResponse::Ok().finish())
+}
+
+#[derive(Debug)]
+struct GotenbergTrace(String);
+
+impl header::TryIntoHeaderValue for GotenbergTrace {
+ type Error = header::InvalidHeaderValue;
+
+ fn try_into_value(self) -> Result {
+ header::HeaderValue::from_str(&self.0)
+ }
+}
+
+impl header::Header for GotenbergTrace {
+ fn name() -> header::HeaderName {
+ header::HeaderName::from_static("gotenberg-trace")
+ }
+
+ fn parse(m: &M) -> Result {
+ m.headers()
+ .get(Self::name())
+ .ok_or(ParseError::Header)?
+ .to_str()
+ .map_err(|_| ParseError::Header)
+ .map(ToOwned::to_owned)
+ .map(GotenbergTrace)
+ }
+}
+
+#[derive(Debug)]
+struct ModrinthGeneratedPdfType(GeneratedPdfType);
+
+impl header::TryIntoHeaderValue for ModrinthGeneratedPdfType {
+ type Error = header::InvalidHeaderValue;
+
+ fn try_into_value(self) -> Result {
+ header::HeaderValue::from_str(self.0.as_str())
+ }
+}
+
+impl header::Header for ModrinthGeneratedPdfType {
+ fn name() -> header::HeaderName {
+ MODRINTH_GENERATED_PDF_TYPE
+ }
+
+ fn parse(m: &M) -> Result {
+ m.headers()
+ .get(Self::name())
+ .ok_or(ParseError::Header)?
+ .to_str()
+ .map_err(|_| ParseError::Header)?
+ .parse()
+ .map_err(|_| ParseError::Header)
+ .map(ModrinthGeneratedPdfType)
+ }
+}
+
+#[derive(Debug)]
+struct ModrinthPaymentId(String);
+
+impl header::TryIntoHeaderValue for ModrinthPaymentId {
+ type Error = header::InvalidHeaderValue;
+
+ fn try_into_value(self) -> Result {
+ header::HeaderValue::from_str(&self.0)
+ }
+}
+
+impl header::Header for ModrinthPaymentId {
+ fn name() -> header::HeaderName {
+ MODRINTH_PAYMENT_ID
+ }
+
+ fn parse(m: &M) -> Result {
+ m.headers()
+ .get(Self::name())
+ .ok_or(ParseError::Header)?
+ .to_str()
+ .map_err(|_| ParseError::Header)
+ .map(ToOwned::to_owned)
+ .map(ModrinthPaymentId)
+ }
+}
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index 00730373..32f5a0bf 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -4,6 +4,7 @@ pub mod billing;
pub mod external_notifications;
pub mod flows;
pub mod gdpr;
+pub mod gotenberg;
pub mod medal;
pub mod moderation;
pub mod pats;
@@ -26,6 +27,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(moderation::config)
.configure(billing::config)
.configure(gdpr::config)
+ .configure(gotenberg::config)
.configure(statuses::config)
.configure(medal::config)
.configure(external_notifications::config)
diff --git a/apps/labrinth/src/util/gotenberg.rs b/apps/labrinth/src/util/gotenberg.rs
new file mode 100644
index 00000000..ec8c6b3d
--- /dev/null
+++ b/apps/labrinth/src/util/gotenberg.rs
@@ -0,0 +1,219 @@
+use crate::routes::ApiError;
+use crate::util::error::Context;
+use actix_web::http::header::HeaderName;
+use serde::{Deserialize, Serialize};
+use std::str::FromStr;
+
+pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName =
+ HeaderName::from_static("modrinth-generated-pdf-type");
+pub const MODRINTH_PAYMENT_ID: HeaderName =
+ HeaderName::from_static("modrinth-payment-id");
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct PaymentStatement {
+ pub payment_id: String,
+ pub recipient_address_line_1: Option,
+ pub recipient_address_line_2: Option,
+ pub recipient_address_line_3: Option,
+ pub recipient_email: String,
+ pub payment_date: String,
+ pub gross_amount_cents: i64,
+ pub net_amount_cents: i64,
+ pub fees_cents: i64,
+ pub currency_code: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum GeneratedPdfType {
+ PaymentStatement,
+}
+
+impl GeneratedPdfType {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ GeneratedPdfType::PaymentStatement => "payment-statement",
+ }
+ }
+}
+
+impl FromStr for GeneratedPdfType {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result {
+ match s {
+ "payment-statement" => Ok(GeneratedPdfType::PaymentStatement),
+ _ => Err(s.to_owned()),
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct GotenbergClient {
+ client: reqwest::Client,
+ gotenberg_url: String,
+ site_url: String,
+ callback_base: String,
+}
+
+impl GotenbergClient {
+ /// Initialize the client from environment variables.
+ pub fn from_env() -> Result {
+ let client = reqwest::Client::builder()
+ .user_agent("Modrinth")
+ .build()
+ .wrap_internal_err("failed to build reqwest client")?;
+
+ let gotenberg_url = dotenvy::var("GOTENBERG_URL")
+ .wrap_internal_err("GOTENBERG_URL is not set")?;
+ let site_url = dotenvy::var("SITE_URL")
+ .wrap_internal_err("SITE_URL is not set")?;
+ let callback_base = dotenvy::var("GOTENBERG_CALLBACK_BASE")
+ .wrap_internal_err("GOTENBERG_CALLBACK_BASE is not set")?;
+
+ Ok(Self {
+ client,
+ gotenberg_url: gotenberg_url.trim_end_matches('/').to_owned(),
+ site_url: site_url.trim_end_matches('/').to_owned(),
+ callback_base: callback_base.trim_end_matches('/').to_owned(),
+ })
+ }
+
+ /// Generate a PDF payment statement via Gotenberg.
+ ///
+ /// This will:
+ /// - Fetch the HTML template from `{SITE_URL}/_internal/templates/doc/payment-statement`.
+ /// - Perform simple template substitution with fields from `PaymentStatement`.
+ /// - Submit the HTML to Gotenberg HTML route with webhook headers.
+ pub async fn generate_payment_statement(
+ &self,
+ statement: &PaymentStatement,
+ ) -> Result<(), ApiError> {
+ let template_url = format!(
+ "{}/_internal/templates/doc/payment-statement",
+ self.site_url
+ );
+
+ let template_html = {
+ let resp = self
+ .client
+ .get(template_url)
+ .send()
+ .await
+ .wrap_internal_err(
+ "failed to request payment statement template",
+ )?;
+ let resp = resp.error_for_status().wrap_internal_err(
+ "failed to fetch payment statement template (bad status)",
+ )?;
+ resp.text().await.wrap_internal_err(
+ "failed to read payment statement template body",
+ )?
+ };
+
+ let filled_html = fill_statement_template(&template_html, statement);
+
+ let form = reqwest::multipart::Form::new().part(
+ "files",
+ reqwest::multipart::Part::text(filled_html)
+ .file_name("index.html")
+ .mime_str("text/html")
+ .wrap_internal_err("invalid mime type for html part")?,
+ );
+
+ let success_webhook = format!("{}/success", self.callback_base);
+ let error_webhook = format!("{}/error", self.callback_base);
+
+ self
+ .client
+ .post(format!(
+ "{}/forms/chromium/convert/html",
+ self.gotenberg_url
+ ))
+ .header("Gotenberg-Webhook-Url", success_webhook)
+ .header("Gotenberg-Webhook-Error-Url", error_webhook)
+ .header(
+ "Gotenberg-Webhook-Extra-Http-Headers",
+ serde_json::json!({
+ "Modrinth-Payment-Id": statement.payment_id,
+ "Modrinth-Generated-Pdf-Type": GeneratedPdfType::PaymentStatement.as_str(),
+ }).to_string(),
+ )
+ .header(
+ "Modrinth-Payment-Id",
+ &statement.payment_id,
+ )
+ .header(
+ "Gotenberg-Output-Filename",
+ format!("payment-statement-{}", statement.payment_id),
+ )
+ .multipart(form)
+ .send()
+ .await
+ .wrap_internal_err("failed to submit HTML to Gotenberg")?
+ .error_for_status()
+ .wrap_internal_err("Gotenberg returned an error status")?;
+
+ Ok(())
+ }
+}
+
+fn fill_statement_template(html: &str, s: &PaymentStatement) -> String {
+ let variables: Vec<(&str, String)> = vec![
+ ("statement.payment_id", s.payment_id.clone()),
+ (
+ "statement.recipient_address_line_1",
+ s.recipient_address_line_1.clone().unwrap_or_default(),
+ ),
+ (
+ "statement.recipient_address_line_2",
+ s.recipient_address_line_2.clone().unwrap_or_default(),
+ ),
+ (
+ "statement.recipient_address_line_3",
+ s.recipient_address_line_3.clone().unwrap_or_default(),
+ ),
+ ("statement.recipient_email", s.recipient_email.clone()),
+ ("statement.payment_date", s.payment_date.clone()),
+ (
+ "statement.gross_amount",
+ format_money(s.gross_amount_cents, &s.currency_code),
+ ),
+ (
+ "statement.net_amount",
+ format_money(s.net_amount_cents, &s.currency_code),
+ ),
+ (
+ "statement.fees",
+ format_money(s.fees_cents, &s.currency_code),
+ ),
+ ];
+
+ let mut out = String::with_capacity(html.len());
+ let mut remaining = html;
+ while let Some((before, rest)) = remaining.split_once('{') {
+ out.push_str(before);
+ if let Some((key, after)) = rest.split_once('}') {
+ let key = key.trim();
+ if let Some((_, val)) = variables.iter().find(|(k, _)| *k == key) {
+ out.push_str(val);
+ }
+ // if key not found, insert empty string
+ remaining = after;
+ } else {
+ // unmatched '{', push the rest and break
+ out.push_str(rest);
+ remaining = "";
+ break;
+ }
+ }
+ out.push_str(remaining);
+ out
+}
+
+fn format_money(amount_cents: i64, currency: &str) -> String {
+ rusty_money::Money::from_minor(
+ amount_cents,
+ rusty_money::iso::find(currency).unwrap_or(rusty_money::iso::USD),
+ )
+ .to_string()
+}
diff --git a/apps/labrinth/src/util/guards.rs b/apps/labrinth/src/util/guards.rs
index ec46a4f8..08b3df46 100644
--- a/apps/labrinth/src/util/guards.rs
+++ b/apps/labrinth/src/util/guards.rs
@@ -1,4 +1,5 @@
use actix_web::guard::GuardContext;
+use actix_web::http::header::X_FORWARDED_FOR;
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
@@ -42,3 +43,10 @@ pub fn external_notification_key_guard(ctx: &GuardContext) -> bool {
}),
}
}
+
+pub fn internal_network_guard(ctx: &GuardContext) -> bool {
+ ctx.head()
+ .peer_addr
+ .is_some_and(|sock| matches!(sock.ip().to_canonical(), std::net::IpAddr::V4(v4) if v4.is_private()))
+ && ctx.head().headers().get(X_FORWARDED_FOR).is_none()
+}
diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs
index f2c1c68a..fa907500 100644
--- a/apps/labrinth/src/util/mod.rs
+++ b/apps/labrinth/src/util/mod.rs
@@ -9,6 +9,7 @@ pub mod date;
pub mod env;
pub mod error;
pub mod ext;
+pub mod gotenberg;
pub mod guards;
pub mod img;
pub mod ip;
diff --git a/apps/labrinth/tests/common/mod.rs b/apps/labrinth/tests/common/mod.rs
index 35ca4dc6..79958f7a 100644
--- a/apps/labrinth/tests/common/mod.rs
+++ b/apps/labrinth/tests/common/mod.rs
@@ -1,5 +1,6 @@
use labrinth::queue::email::EmailQueue;
use labrinth::util::anrok;
+use labrinth::util::gotenberg::GotenbergClient;
use labrinth::{LabrinthConfig, file_hosting};
use labrinth::{check_env_vars, clickhouse};
use modrinth_maxmind::MaxMind;
@@ -46,6 +47,8 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
+ let gotenberg_client =
+ GotenbergClient::from_env().expect("Failed to create Gotenberg client");
labrinth::app_setup(
pool.clone(),
@@ -58,6 +61,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
stripe_client,
anrok_client,
email_queue,
+ gotenberg_client,
false,
)
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 955e6586..ebca3de9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,7 +12,7 @@ services:
POSTGRES_PASSWORD: labrinth
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
- test: ['CMD', 'pg_isready']
+ test: [ 'CMD', 'pg_isready' ]
interval: 3s
timeout: 5s
retries: 3
@@ -28,7 +28,7 @@ services:
MEILI_MASTER_KEY: modrinth
MEILI_HTTP_PAYLOAD_SIZE_LIMIT: 107374182400
healthcheck:
- test: ['CMD', 'curl', '--fail', 'http://localhost:7700/health']
+ test: [ 'CMD', 'curl', '--fail', 'http://localhost:7700/health' ]
interval: 3s
timeout: 5s
retries: 3
@@ -41,7 +41,7 @@ services:
volumes:
- redis-data:/data
healthcheck:
- test: ['CMD', 'redis-cli', 'PING']
+ test: [ 'CMD', 'redis-cli', 'PING' ]
interval: 3s
timeout: 5s
retries: 3
@@ -54,7 +54,7 @@ services:
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: default
healthcheck:
- test: ['CMD-SHELL', 'clickhouse-client --query "SELECT 1"']
+ test: [ 'CMD-SHELL', 'clickhouse-client --query "SELECT 1"' ]
interval: 3s
timeout: 5s
retries: 3
@@ -67,10 +67,15 @@ services:
environment:
MP_ENABLE_SPAMASSASSIN: postmark
healthcheck:
- test: ['CMD', 'wget', '-q', '-O/dev/null', 'http://localhost:8025/api/v1/info']
+ test: [ 'CMD', 'wget', '-q', '-O/dev/null', 'http://localhost:8025/api/v1/info' ]
interval: 3s
timeout: 5s
retries: 3
+ gotenberg:
+ image: gotenberg/gotenberg:8
+ container_name: labrinth-gotenberg
+ ports:
+ - "3000:13000"
labrinth:
profiles:
- with-labrinth