Initial commit: content-driven onboarding portal
Leptos/Axum app that renders a Question/Alternative/Feature schema loaded from a sibling content repo (portal-content). Kanidm OIDC login, content-driven authorization (Question.qualifies), a generic NATS KV-backed resource + state-transition mechanism (no bespoke "applicant" concept baked into the runtime - it's all content), a SHA-256 DAG chain tying submissions and decisions together, and the "YES - Rasterized Lines" piece (ported from the live uhhm.no site) as the landing hero.
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
//! 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()))
|
||||
}
|
||||
Reference in New Issue
Block a user