Files
portal/src/upload.rs
T

253 lines
9.3 KiB
Rust
Raw Normal View History

//! 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<String, String>` 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 {
2026-08-12 22:55:36 +02:00
/// 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<Self> {
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<AppState>,
session: tower_sessions::Session,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (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<u8>)> = 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<u8> = 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::<User>(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<String, crate::content::Question>,
question_id: &str,
alternative: &str,
) -> Option<String> {
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<String, crate::content::Question> {
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);
}
}