114 lines
4.0 KiB
Rust
114 lines
4.0 KiB
Rust
//! 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;
|
||
|
|
|
||
|
|
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<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 - 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<AppState>,
|
||
|
|
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(),
|
||
|
|
))?;
|
||
|
|
|
||
|
|
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()))
|
||
|
|
}
|