Skip to content

Commit 4b17eb5

Browse files
authored
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
1 parent 6a70ace commit 4b17eb5

File tree

14 files changed

+421
-13
lines changed

14 files changed

+421
-13
lines changed

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
3636
] }
3737
async-trait = "0.1.89"
3838
async-tungstenite = { version = "0.31.0", default-features = false, features = [
39-
"futures-03-sink",
39+
"futures-03-sink"
4040
] }
4141
async-walkdir = "2.1.0"
4242
async_zip = "0.0.18"
@@ -48,7 +48,7 @@ censor = "0.3.0"
4848
chardetng = "0.1.17"
4949
chrono = "0.4.42"
5050
cidre = { version = "0.11.3", default-features = false, features = [
51-
"macos_15_0",
51+
"macos_15_0"
5252
] }
5353
clap = "4.5.48"
5454
clickhouse = "0.14.0"
@@ -129,7 +129,7 @@ reqwest = { version = "0.12.24", default-features = false }
129129
rgb = "0.8.52"
130130
rust_decimal = { version = "1.39.0", features = [
131131
"serde-with-float",
132-
"serde-with-str",
132+
"serde-with-str"
133133
] }
134134
rust_iso3166 = "0.1.14"
135135
rust-s3 = { version = "0.37.0", default-features = false, features = [

apps/frontend/src/templates/docs/finance/PaymentStatement.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
9090
Purpose of Payment
9191
</Text>
9292
<Text class="m-0 text-sm leading-relaxed text-secondary">
93-
This payout reflects revenue earned by the creator through their activity on the Modrinth
94-
platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate
95-
commissions tied to the creator's published projects, in accordance with the Rewards Program
96-
Terms.
93+
This payout reflects the creator's earnings from their activity on the Modrinth platform.
94+
Such earnings are based on advertising revenue derived from user engagement with the
95+
creator's published projects and/or affiliate commissions in accordance with the Rewards
96+
Program Terms.
9797
</Text>
9898
</Section>
9999

apps/labrinth/.env.docker-compose

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
142142
ANROK_API_KEY=none
143143
ANROK_API_URL=none
144144

145+
GOTENBERG_URL=http://labrinth-gotenberg:13000
146+
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
147+
145148
ARCHON_URL=none

apps/labrinth/.env.local

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
143143
ANROK_API_KEY=none
144144
ANROK_API_URL=none
145145

146+
GOTENBERG_URL=http://localhost:13000
147+
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
148+
146149
ARCHON_URL=none

apps/labrinth/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use tracing::{info, warn};
1414
extern crate clickhouse as clickhouse_crate;
1515
use clickhouse_crate::Client;
1616
use util::cors::default_cors;
17+
use util::gotenberg::GotenbergClient;
1718

1819
use crate::background_task::update_versions;
1920
use crate::database::ReadOnlyPgPool;
@@ -63,6 +64,7 @@ pub struct LabrinthConfig {
6364
pub stripe_client: stripe::Client,
6465
pub anrok_client: anrok::Client,
6566
pub email_queue: web::Data<EmailQueue>,
67+
pub gotenberg_client: GotenbergClient,
6668
}
6769

6870
#[allow(clippy::too_many_arguments)]
@@ -77,6 +79,7 @@ pub fn app_setup(
7779
stripe_client: stripe::Client,
7880
anrok_client: anrok::Client,
7981
email_queue: EmailQueue,
82+
gotenberg_client: GotenbergClient,
8083
enable_background_tasks: bool,
8184
) -> LabrinthConfig {
8285
info!(
@@ -279,6 +282,7 @@ pub fn app_setup(
279282
rate_limiter: limiter,
280283
stripe_client,
281284
anrok_client,
285+
gotenberg_client,
282286
email_queue: web::Data::new(email_queue),
283287
}
284288
}
@@ -304,6 +308,7 @@ pub fn app_config(
304308
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
305309
.app_data(web::Data::new(labrinth_config.file_host.clone()))
306310
.app_data(web::Data::new(labrinth_config.search_config.clone()))
311+
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
307312
.app_data(labrinth_config.session_queue.clone())
308313
.app_data(labrinth_config.payouts_queue.clone())
309314
.app_data(labrinth_config.email_queue.clone())
@@ -477,6 +482,9 @@ pub fn check_env_vars() -> bool {
477482

478483
failed |= check_var::<String>("FLAME_ANVIL_URL");
479484

485+
failed |= check_var::<String>("GOTENBERG_URL");
486+
failed |= check_var::<String>("GOTENBERG_CALLBACK_BASE");
487+
480488
failed |= check_var::<String>("STRIPE_API_KEY");
481489
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
482490

apps/labrinth/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use labrinth::queue::email::EmailQueue;
1212
use labrinth::search;
1313
use labrinth::util::anrok;
1414
use labrinth::util::env::parse_var;
15+
use labrinth::util::gotenberg::GotenbergClient;
1516
use labrinth::util::ratelimit::rate_limit_middleware;
1617
use labrinth::{check_env_vars, clickhouse, database, file_hosting};
1718
use std::ffi::CStr;
@@ -200,6 +201,9 @@ async fn main() -> std::io::Result<()> {
200201
let email_queue =
201202
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
202203

204+
let gotenberg_client =
205+
GotenbergClient::from_env().expect("Failed to create Gotenberg client");
206+
203207
if let Some(task) = args.run_background_task {
204208
info!("Running task {task:?} and exiting");
205209
task.run(
@@ -249,6 +253,7 @@ async fn main() -> std::io::Result<()> {
249253
stripe_client,
250254
anrok_client.clone(),
251255
email_queue,
256+
gotenberg_client,
252257
!args.no_background_tasks,
253258
);
254259

apps/labrinth/src/models/v3/oauth_clients.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ pub struct OAuthClientAuthorization {
5757
pub created: DateTime<Utc>,
5858
}
5959

60-
#[serde_as]
6160
#[derive(Deserialize, Serialize)]
61+
#[serde_as]
6262
pub struct GetOAuthClientsRequest {
6363
#[serde_as(
6464
as = "serde_with::StringWithSeparator::<serde_with::formats::CommaSeparator, String>"
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use actix_web::{
2+
HttpMessage, HttpResponse, error::ParseError, http::header, post, web,
3+
};
4+
use serde::Deserialize;
5+
use tracing::trace;
6+
7+
use crate::routes::ApiError;
8+
use crate::util::gotenberg::{
9+
GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
10+
};
11+
use crate::util::guards::internal_network_guard;
12+
13+
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
14+
cfg.service(success).service(error);
15+
}
16+
17+
#[post("/gotenberg/success", guard = "internal_network_guard")]
18+
pub async fn success(
19+
web::Header(header::ContentDisposition {
20+
disposition,
21+
parameters: disposition_parameters,
22+
}): web::Header<header::ContentDisposition>,
23+
web::Header(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
24+
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
25+
ModrinthGeneratedPdfType,
26+
>,
27+
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
28+
body: web::Bytes,
29+
) -> Result<HttpResponse, ApiError> {
30+
trace!(
31+
%trace,
32+
%disposition,
33+
?disposition_parameters,
34+
r#type = r#type.as_str(),
35+
?maybe_payment_id,
36+
body.len = body.len(),
37+
"Received Gotenberg generated PDF"
38+
);
39+
40+
Ok(HttpResponse::Ok().finish())
41+
}
42+
43+
#[allow(dead_code)]
44+
#[derive(Debug, Deserialize)]
45+
pub struct ErrorBody {
46+
status: Option<String>,
47+
message: Option<String>,
48+
}
49+
50+
#[post("/gotenberg/error", guard = "internal_network_guard")]
51+
pub async fn error(
52+
web::Header(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
53+
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
54+
ModrinthGeneratedPdfType,
55+
>,
56+
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
57+
web::Json(error_body): web::Json<ErrorBody>,
58+
) -> Result<HttpResponse, ApiError> {
59+
trace!(
60+
%trace,
61+
r#type = r#type.as_str(),
62+
?maybe_payment_id,
63+
?error_body,
64+
"Received Gotenberg error webhook"
65+
);
66+
67+
Ok(HttpResponse::Ok().finish())
68+
}
69+
70+
#[derive(Debug)]
71+
struct GotenbergTrace(String);
72+
73+
impl header::TryIntoHeaderValue for GotenbergTrace {
74+
type Error = header::InvalidHeaderValue;
75+
76+
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
77+
header::HeaderValue::from_str(&self.0)
78+
}
79+
}
80+
81+
impl header::Header for GotenbergTrace {
82+
fn name() -> header::HeaderName {
83+
header::HeaderName::from_static("gotenberg-trace")
84+
}
85+
86+
fn parse<M: HttpMessage>(m: &M) -> Result<Self, ParseError> {
87+
m.headers()
88+
.get(Self::name())
89+
.ok_or(ParseError::Header)?
90+
.to_str()
91+
.map_err(|_| ParseError::Header)
92+
.map(ToOwned::to_owned)
93+
.map(GotenbergTrace)
94+
}
95+
}
96+
97+
#[derive(Debug)]
98+
struct ModrinthGeneratedPdfType(GeneratedPdfType);
99+
100+
impl header::TryIntoHeaderValue for ModrinthGeneratedPdfType {
101+
type Error = header::InvalidHeaderValue;
102+
103+
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
104+
header::HeaderValue::from_str(self.0.as_str())
105+
}
106+
}
107+
108+
impl header::Header for ModrinthGeneratedPdfType {
109+
fn name() -> header::HeaderName {
110+
MODRINTH_GENERATED_PDF_TYPE
111+
}
112+
113+
fn parse<M: HttpMessage>(m: &M) -> Result<Self, ParseError> {
114+
m.headers()
115+
.get(Self::name())
116+
.ok_or(ParseError::Header)?
117+
.to_str()
118+
.map_err(|_| ParseError::Header)?
119+
.parse()
120+
.map_err(|_| ParseError::Header)
121+
.map(ModrinthGeneratedPdfType)
122+
}
123+
}
124+
125+
#[derive(Debug)]
126+
struct ModrinthPaymentId(String);
127+
128+
impl header::TryIntoHeaderValue for ModrinthPaymentId {
129+
type Error = header::InvalidHeaderValue;
130+
131+
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
132+
header::HeaderValue::from_str(&self.0)
133+
}
134+
}
135+
136+
impl header::Header for ModrinthPaymentId {
137+
fn name() -> header::HeaderName {
138+
MODRINTH_PAYMENT_ID
139+
}
140+
141+
fn parse<M: HttpMessage>(m: &M) -> Result<Self, ParseError> {
142+
m.headers()
143+
.get(Self::name())
144+
.ok_or(ParseError::Header)?
145+
.to_str()
146+
.map_err(|_| ParseError::Header)
147+
.map(ToOwned::to_owned)
148+
.map(ModrinthPaymentId)
149+
}
150+
}

apps/labrinth/src/routes/internal/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod billing;
44
pub mod external_notifications;
55
pub mod flows;
66
pub mod gdpr;
7+
pub mod gotenberg;
78
pub mod medal;
89
pub mod moderation;
910
pub mod pats;
@@ -26,6 +27,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
2627
.configure(moderation::config)
2728
.configure(billing::config)
2829
.configure(gdpr::config)
30+
.configure(gotenberg::config)
2931
.configure(statuses::config)
3032
.configure(medal::config)
3133
.configure(external_notifications::config)

0 commit comments

Comments
 (0)