//! 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; pub 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 the `GARAGE_*` env vars. `None` (not an /// error) when unset - 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 - takes the `file` field, plus the `question` and /// `alternative` it belongs to, and hands back its object key. /// /// The two names are not decoration: they are what makes this an /// authorized route rather than an open one. An upload is half a /// submission, so it asks the policy exactly what the submission will /// ask - `Submit` on that page - and it only accepts a page that /// actually declares a `type: file` field. Without that, anybody who /// could reach the host could put twenty megabytes in the bucket as /// often as they liked, on a site whose forms are public. /// /// Server-side hygiene beyond the HTML `accept` hint (a UX signal, not /// a boundary): a hard size cap, and a refusal rather than a silent /// success when Garage isn't configured. pub async fn upload( State(state): State, session: tower_sessions::Session, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { let garage = state.garage.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "uploads are not configured".to_string(), ))?; let mut question_id = String::new(); let mut alternative = String::new(); let mut file: Option<(String, String, Vec)> = None; while let Some(mut field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { match field.name() { Some("question") | Some("alternative") => { let name = field.name().unwrap_or_default().to_string(); let text = field .text() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; if name == "question" { question_id = text; } else { alternative = text; } } Some("file") => { let content_type = field .content_type() .unwrap_or("application/octet-stream") .to_string(); let filename = field.file_name().unwrap_or("upload").to_string(); // Chunk by chunk, refused the moment the cap is // crossed: an oversized body is never buffered whole // before being turned away. The route's body limit // (main.rs) is the outer fence. let mut bytes: Vec = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES { return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string())); } bytes.extend_from_slice(&chunk); } file = Some((filename, content_type, bytes)); } _ => continue, } } let (filename, content_type, bytes) = file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?; // Nothing has been written yet. Everything below decides whether // anything will be. if !may_upload(&state, &session, &question_id, &alternative).await { return Err((StatusCode::FORBIDDEN, "not authorized to upload here".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}")))?; Ok(Json(UploadResponse { key })) } /// Whether this caller may attach a file to that page's alternative: /// the alternative has to exist, has to declare a `type: file` field, /// and the policy has to let this principal submit that page. The same /// question `submit_answer` asks, asked before the bytes land rather /// than after. async fn may_upload( state: &AppState, session: &tower_sessions::Session, question_id: &str, alternative: &str, ) -> bool { use crate::auth::{User, SESSION_USER_KEY}; let questions = state.questions.load(); let Some(page) = page_taking_files(&questions, question_id, alternative) else { return false; }; let user = session.get::(SESSION_USER_KEY).await.ok().flatten(); let principal = crate::access::Principal::from_user(user.as_ref()); crate::access::decide_and_publish( &state.nats, &state.access.load(), &principal, crate::access::Action::Submit, &crate::access::Resource::Page { id: page }, serde_json::json!({ "alternative": alternative }), ) .allow } /// The resolved page id, when that page has an alternative of this /// name that is open and actually asks for a file. `None` for a page /// that does not exist, an alternative that does not, one that is /// disabled, or one that asks for no file at all - an upload that /// names none of the site's own file fields is not an upload this site /// asked for. fn page_taking_files( questions: &std::collections::HashMap, question_id: &str, alternative: &str, ) -> Option { let question = crate::content::resolve_question(questions, question_id)?; question .alternatives .iter() .find(|a| a.name == alternative && !a.disabled)? .features .iter() .any(|f| f.requirements.iter().any(|r| r.kind == "file")) .then(|| question.id.clone()) } #[cfg(test)] mod tests { use super::*; fn questions(yaml: &str) -> std::collections::HashMap { crate::content::build_questions(&[("index.yaml".to_string(), yaml.to_string())]).unwrap() } const PAGE: &str = "name: Home alternatives: - name: Send drawings record_as: enquiries features: - name: Files requirements: - { name: drawings, type: file, optional: true } - name: Just ask features: - name: Words requirements: - { name: question, type: textarea } "; // The bytes are only ever written for a page of this site that // asked for a file. Everything else is somebody using the host as // a disk. #[test] fn an_upload_must_name_a_page_that_asked_for_a_file() { let qs = questions(PAGE); assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string())); // An alternative on the same page that takes no file. assert_eq!(page_taking_files(&qs, "/", "Just ask"), None); // Names that are not there at all. assert_eq!(page_taking_files(&qs, "/", "Nonexistent"), None); assert_eq!(page_taking_files(&qs, "/nowhere", "Send drawings"), None); assert_eq!(page_taking_files(&qs, "", ""), None); } #[test] fn a_closed_alternative_takes_nothing() { let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n")); assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None); } } /// GET /attachment/{bucket}/{record}/{field} - the other half of an /// upload. A `type: file` field stores an object key, which on its own /// is a string a desk can read and nobody can open; this streams the /// object back. /// /// The key is never the credential. It is not even taken from the /// caller: the caller names a record, and the key is read out of that /// record's own field, so a file can only be fetched through a record /// somebody is allowed to see. Guessing keys gets you nowhere, and a /// key that leaks out of a desk is not a link to anything. pub async fn attachment( State(state): State, session: tower_sessions::Session, axum::extract::Path((bucket, record, field)): axum::extract::Path<(String, String, String)>, ) -> Result { use axum::response::IntoResponse; let garage = state.garage.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "uploads are not configured".to_string(), ))?; // Refusals read the same as a miss all the way down, so this // cannot be used to learn which buckets, records or fields exist. let missing = || (StatusCode::NOT_FOUND, "not found".to_string()); if !may_read_bucket(&state, &session, &bucket).await { return Err(missing()); } let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?; let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?; let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?; let key = answer .responses .get(&field) .and_then(|v| v.as_str()) .filter(|k| !k.is_empty()) .ok_or_else(missing)?; let object = garage .client .get_object() .bucket(&garage.bucket) .key(key) .send() .await .map_err(|_| missing())?; let content_type = object .content_type() .unwrap_or("application/octet-stream") .to_string(); let body = object .body .collect() .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))? .into_bytes(); // The stored key is `-`; the person who gets // the file should see the name they sent, not the uuid. let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key); let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], ""); Ok(( [ (axum::http::header::CONTENT_TYPE, content_type), ( axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ), ], body, ) .into_response()) } /// Whether this caller may see this bucket at all: whether any feature /// in the loaded content lists it and the policy lets them read that /// feature. The desk that shows the record is what grants the file. async fn may_read_bucket( state: &AppState, session: &tower_sessions::Session, bucket: &str, ) -> bool { use crate::auth::{User, SESSION_USER_KEY}; let user = session.get::(SESSION_USER_KEY).await.ok().flatten(); let principal = crate::access::Principal::from_user(user.as_ref()); let access = state.access.load(); state.questions.load().values().any(|question| { question.alternatives.iter().any(|alternative| { alternative.features.iter().any(|feature| { feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket) && access.allows( &principal, crate::access::Action::Read, &crate::access::Resource::Feature { page: question.id.clone(), alternative: alternative.name.clone(), feature: feature.name.clone(), }, ) }) }) }) }