//! Plain multipart upload → Garage (S3-compatible), for `type: file` //! requirement fields. A raw Axum handler, not a Leptos server fn - //! those aren't built for binary bodies. The stored object key becomes //! that field's value in `responses_json` (`app.rs`'s file-input //! widget), so nothing about `submit_answer`'s generic //! `Map` flow needs to know a file was involved. #![cfg(feature = "ssr")] use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region}; use aws_sdk_s3::primitives::ByteStream; use axum::extract::{Multipart, State}; use axum::http::StatusCode; use axum::Json; use serde::Serialize; use crate::server::AppState; const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024; #[derive(Clone)] pub struct Garage { client: aws_sdk_s3::Client, bucket: String, } impl Garage { /// Builds a client from `GARAGE_S3_ENDPOINT`/`GARAGE_ACCESS_KEY`/ /// `GARAGE_SECRET_KEY`/`GARAGE_UPLOADS_BUCKET` - same secret names /// dodrenett's own Woodpecker pipeline already uses. `None` (not an /// error) when unset, since uploads are optional: everything else /// works without Garage configured. pub fn from_env() -> Option { let endpoint = std::env::var("GARAGE_S3_ENDPOINT") .unwrap_or_else(|_| "http://garage:3900".to_string()); let access_key = std::env::var("GARAGE_ACCESS_KEY").ok()?; let secret_key = std::env::var("GARAGE_SECRET_KEY").ok()?; let bucket = std::env::var("GARAGE_UPLOADS_BUCKET") .unwrap_or_else(|_| "portal-attachments".to_string()); let credentials = Credentials::new(access_key, secret_key, None, None, "portal"); let config = aws_sdk_s3::Config::builder() .behavior_version(BehaviorVersion::latest()) .region(Region::new("garage")) .endpoint_url(endpoint) .credentials_provider(credentials) // Garage expects path-style bucket addressing, not the // virtual-hosted `bucket.host` style AWS defaults to. .force_path_style(true) .build(); Some(Self { client: aws_sdk_s3::Client::from_conf(config), bucket, }) } } #[derive(Serialize)] pub struct UploadResponse { key: String, } /// POST /upload - streams the first `file` field straight through to /// Garage and hands back its object key. Server-side hygiene beyond the /// HTML `accept` hint (a UX-only signal, not a boundary): a hard size /// cap, and the upload is rejected outright if Garage isn't configured /// rather than silently succeeding nowhere. pub async fn upload( State(state): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { let garage = state.garage.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "uploads are not configured".to_string(), ))?; while let Some(field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { if field.name() != Some("file") { continue; } let content_type = field .content_type() .unwrap_or("application/octet-stream") .to_string(); let filename = field.file_name().unwrap_or("upload").to_string(); let bytes = field .bytes() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; if bytes.len() > MAX_UPLOAD_BYTES { return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string())); } let key = format!("{}-{filename}", uuid::Uuid::new_v4()); garage .client .put_object() .bucket(&garage.bucket) .key(&key) .body(ByteStream::from(bytes)) .content_type(content_type) .send() .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?; return Ok(Json(UploadResponse { key })); } Err((StatusCode::BAD_REQUEST, "no file field in request".to_string())) }