Files
portal/src/upload.rs
T

366 lines
14 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);
}
}
/// 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<AppState>,
session: tower_sessions::Session,
axum::extract::Path((bucket, record, field)): axum::extract::Path<(String, String, String)>,
) -> Result<axum::response::Response, (StatusCode, String)> {
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());
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 as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
// Through a list the caller may read, and one that shows this
// record: a list of "my cases" opens my files and nobody else's.
if !may_read_record(&state, &session, &bucket, &as_listed).await {
return Err(missing());
}
let answer: crate::answers::Answer = serde_json::from_value(as_listed).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 `<uuid>-<original name>`; 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_record(
state: &AppState,
session: &tower_sessions::Session,
bucket: &str,
record: &serde_json::Value,
) -> bool {
use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref());
let params = crate::resource::with_person(Default::default(), 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)
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, &params))
&& access.allows(
&principal,
crate::access::Action::Read,
&crate::access::Resource::Feature {
page: question.id.clone(),
alternative: alternative.name.clone(),
feature: feature.name.clone(),
},
)
})
})
})
}