Supporting documents for Mural payouts (#4721)

* wip: gotenberg

* Generate and provide supporting docs for Mural payouts

* Correct docs

* shear

* update cargo lock because r-a complains otherwise

* Remove local Gotenberg queue and use Redis instead

* Store platform_id in database correctly

* Address PR comments

* Fix up CI

* fix rebase

* Add timeout to default env vars
This commit is contained in:
aecsocket
2025-11-08 15:27:31 -08:00
committed by GitHub
parent f8a5a77daa
commit 9706f1597b
15 changed files with 409 additions and 81 deletions

View File

@@ -1,21 +1,35 @@
use actix_web::{
HttpMessage, HttpResponse, error::ParseError, http::header, post, web,
};
use serde::Deserialize;
use std::fmt;
use actix_web::{HttpMessage, error::ParseError, http::header, post, web};
use ariadne::ids::base62_impl::parse_base62;
use base64::Engine;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::trace;
use crate::database::redis::RedisPool;
use crate::models::ids::PayoutId;
use crate::routes::ApiError;
use crate::util::error::Context;
use crate::util::gotenberg::{
GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
PAYMENT_STATEMENTS_NAMESPACE,
};
use crate::util::guards::internal_network_guard;
/// Document generated by Gotenberg and returned to us.
#[derive(Debug, Serialize, Deserialize)]
pub struct GotenbergDocument {
/// Body of the document as a base 64 string.
pub body: String,
}
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(success).service(error);
cfg.service(success_callback).service(error_callback);
}
#[post("/gotenberg/success", guard = "internal_network_guard")]
pub async fn success(
pub async fn success_callback(
web::Header(header::ContentDisposition {
disposition,
parameters: disposition_parameters,
@@ -26,7 +40,8 @@ pub async fn success(
>,
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
body: web::Bytes,
) -> Result<HttpResponse, ApiError> {
redis: web::Data<RedisPool>,
) -> Result<(), ApiError> {
trace!(
%trace,
%disposition,
@@ -37,25 +52,65 @@ pub async fn success(
"Received Gotenberg generated PDF"
);
Ok(HttpResponse::Ok().finish())
let payout_id = maybe_payment_id
.wrap_request_err("no payout ID for document")?
.0
.0;
let mut redis = redis
.connect()
.await
.wrap_internal_err("failed to get Redis connection")?;
let body = base64::engine::general_purpose::STANDARD.encode(&body);
let redis_msg = serde_json::to_string(&Ok::<
GotenbergDocument,
GotenbergError,
>(GotenbergDocument { body }))
.wrap_internal_err("failed to serialize document to JSON")?;
redis
.lpush(
PAYMENT_STATEMENTS_NAMESPACE,
&payout_id.to_string(),
&redis_msg,
)
.await
.wrap_internal_err("failed to send document over Redis")?;
Ok(())
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ErrorBody {
status: Option<String>,
message: Option<String>,
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
pub struct GotenbergError {
pub status: Option<String>,
pub message: Option<String>,
}
impl fmt::Display for GotenbergError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.status, &self.message) {
(None, None) => write!(f, "(unknown error)"),
(Some(status), None) => write!(f, "status {status}"),
(None, Some(message)) => write!(f, "(no status) {message}"),
(Some(status), Some(message)) => {
write!(f, "status {status}: {message}")
}
}
}
}
#[post("/gotenberg/error", guard = "internal_network_guard")]
pub async fn error(
pub async fn error_callback(
web::Header(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
ModrinthGeneratedPdfType,
>,
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
web::Json(error_body): web::Json<ErrorBody>,
) -> Result<HttpResponse, ApiError> {
web::Json(error_body): web::Json<GotenbergError>,
redis: web::Data<RedisPool>,
) -> Result<(), ApiError> {
trace!(
%trace,
r#type = r#type.as_str(),
@@ -64,7 +119,31 @@ pub async fn error(
"Received Gotenberg error webhook"
);
Ok(HttpResponse::Ok().finish())
let payout_id = maybe_payment_id
.wrap_request_err("no payout ID for document")?
.0
.0;
let mut redis = redis
.connect()
.await
.wrap_internal_err("failed to get Redis connection")?;
let redis_msg = serde_json::to_string(&Err::<
GotenbergDocument,
GotenbergError,
>(error_body))
.wrap_internal_err("failed to serialize error to JSON")?;
redis
.lpush(
PAYMENT_STATEMENTS_NAMESPACE,
&payout_id.to_string(),
&redis_msg,
)
.await
.wrap_internal_err("failed to send error over Redis")?;
Ok(())
}
#[derive(Debug)]
@@ -122,14 +201,14 @@ impl header::Header for ModrinthGeneratedPdfType {
}
}
#[derive(Debug)]
struct ModrinthPaymentId(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModrinthPaymentId(pub PayoutId);
impl header::TryIntoHeaderValue for ModrinthPaymentId {
type Error = header::InvalidHeaderValue;
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
header::HeaderValue::from_str(&self.0)
header::HeaderValue::from_str(&self.0.to_string())
}
}
@@ -144,7 +223,7 @@ impl header::Header for ModrinthPaymentId {
.ok_or(ParseError::Header)?
.to_str()
.map_err(|_| ParseError::Header)
.map(ToOwned::to_owned)
.map(ModrinthPaymentId)
.and_then(|s| parse_base62(s).map_err(|_| ParseError::Header))
.map(|id| Self(PayoutId(id)))
}
}