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:
Bendik Aagaard Lynghaug
2026-07-29 19:38:40 +02:00
commit aa1a7fa572
21 changed files with 7873 additions and 0 deletions
+113
View File
@@ -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()))
}