From 924ac1b6d02744d1ca3b69fe7da26698d5b6ac61 Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 16:46:54 +0100 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8r=20oppgave=206.2:=20Upload-endepunk?= =?UTF-8?q?t=20for=20mediefiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /intentions/upload_media mottar multipart form data, lagrer filen i CAS med SHA-256 hashing, og oppretter en media-node. Valgfri source_id oppretter en has_media-edge fra kildenoden til media-noden. Endepunktet følger etablert skrivestimønster: STDB først (instant), async PG-persistering i bakgrunnen. Maks filstørrelse 100 MB. Deduplisering via CAS — identiske filer gir ingen ekstra diskbruk. Verifisert med curl mot produksjonsserver: upload uten og med source_id, deduplisering, og PG-persistering fungerer korrekt. Co-Authored-By: Claude Opus 4.6 --- maskinrommet/Cargo.lock | 27 ++++ maskinrommet/Cargo.toml | 2 +- maskinrommet/src/intentions.rs | 232 ++++++++++++++++++++++++++++++++- maskinrommet/src/main.rs | 1 + tasks.md | 3 +- 5 files changed, 261 insertions(+), 4 deletions(-) diff --git a/maskinrommet/Cargo.lock b/maskinrommet/Cargo.lock index aea54b5..2ba2eb8 100644 --- a/maskinrommet/Cargo.lock +++ b/maskinrommet/Cargo.lock @@ -72,6 +72,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -317,6 +318,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1015,6 +1025,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/maskinrommet/Cargo.toml b/maskinrommet/Cargo.toml index 30308cd..016b356 100644 --- a/maskinrommet/Cargo.toml +++ b/maskinrommet/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] } serde = { version = "1", features = ["derive"] } diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index fdd3d90..f051ca0 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -9,7 +9,7 @@ // // Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md -use axum::{extract::State, http::StatusCode, Json}; +use axum::{extract::{Multipart, State}, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -17,6 +17,9 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::AppState; +/// Maks filstørrelse for upload: 100 MB. +const MAX_UPLOAD_SIZE: usize = 100 * 1024 * 1024; + // ============================================================================= // Felles // ============================================================================= @@ -776,6 +779,233 @@ pub async fn create_communication( Ok(Json(CreateCommunicationResponse { node_id, edge_ids })) } +// ============================================================================= +// upload_media +// ============================================================================= + +#[derive(Serialize)] +pub struct UploadMediaResponse { + /// ID til den opprettede media-noden. + pub media_node_id: Uuid, + /// SHA-256 hash (CAS-nøkkel). + pub cas_hash: String, + /// Filstørrelse i bytes. + pub size_bytes: u64, + /// `true` hvis filen allerede fantes i CAS (deduplisert). + pub already_existed: bool, + /// Edge-ID for `has_media`-edge (kun hvis source_id ble oppgitt). + #[serde(skip_serializing_if = "Option::is_none")] + pub has_media_edge_id: Option, +} + +/// POST /intentions/upload_media +/// +/// Mottar en fil via multipart form data, lagrer i CAS, oppretter en +/// media-node med CAS-metadata. Hvis `source_id` er oppgitt, opprettes +/// en `has_media`-edge fra kildenoden til den nye media-noden. +/// +/// Multipart-felter: +/// - `file` (påkrevd): Binærfilen som skal lastes opp. +/// - `source_id` (valgfritt): Node-ID å koble media til via `has_media`-edge. +/// - `visibility` (valgfritt): Synlighet for media-noden. Default: "hidden". +/// - `title` (valgfritt): Tittel for media-noden (default: filnavn). +/// +/// Ref: docs/primitiver/nodes.md (media), docs/retninger/universell_input.md +pub async fn upload_media( + State(state): State, + user: AuthUser, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + let mut file_data: Option> = None; + let mut file_name: Option = None; + let mut content_type: Option = None; + let mut source_id: Option = None; + let mut visibility = "hidden".to_string(); + let mut title: Option = None; + + // -- Parse multipart-felter -- + while let Some(field) = multipart.next_field().await.map_err(|e| { + bad_request(&format!("Ugyldig multipart-data: {e}")) + })? { + let field_name = field.name().unwrap_or("").to_string(); + + match field_name.as_str() { + "file" => { + file_name = field.file_name().map(|s| s.to_string()); + content_type = field.content_type().map(|s| s.to_string()); + let bytes = field.bytes().await.map_err(|e| { + bad_request(&format!("Kunne ikke lese fil: {e}")) + })?; + if bytes.len() > MAX_UPLOAD_SIZE { + return Err(bad_request(&format!( + "Filen er for stor: {} bytes (maks {} bytes)", + bytes.len(), + MAX_UPLOAD_SIZE + ))); + } + if bytes.is_empty() { + return Err(bad_request("Filen er tom")); + } + file_data = Some(bytes.to_vec()); + } + "source_id" => { + let text = field.text().await.map_err(|e| { + bad_request(&format!("Kunne ikke lese source_id: {e}")) + })?; + let id = Uuid::parse_str(&text).map_err(|_| { + bad_request(&format!("Ugyldig source_id UUID: '{text}'")) + })?; + source_id = Some(id); + } + "visibility" => { + let text = field.text().await.map_err(|e| { + bad_request(&format!("Kunne ikke lese visibility: {e}")) + })?; + if !VALID_VISIBILITIES.contains(&text.as_str()) { + return Err(bad_request(&format!( + "Ugyldig visibility: '{text}'. Gyldige verdier: {VALID_VISIBILITIES:?}" + ))); + } + visibility = text; + } + "title" => { + let text = field.text().await.map_err(|e| { + bad_request(&format!("Kunne ikke lese title: {e}")) + })?; + title = Some(text); + } + _ => { + // Ignorer ukjente felter + } + } + } + + let data = file_data.ok_or_else(|| bad_request("Mangler 'file'-felt i multipart-data"))?; + + // -- Valider source_id hvis oppgitt -- + if let Some(src_id) = source_id { + let exists = node_exists(&state.db, src_id).await.map_err(|e| { + tracing::error!("PG-feil ved nodesjekk: {e}"); + internal_error("Databasefeil ved validering av source_id") + })?; + if !exists { + return Err(bad_request(&format!("source_id {} finnes ikke", src_id))); + } + } + + // -- Lagre i CAS -- + let cas_result = state.cas.store(&data).await.map_err(|e| { + tracing::error!("CAS-lagring feilet: {e}"); + internal_error(&format!("Kunne ikke lagre fil i CAS: {e}")) + })?; + + // -- Opprett media-node -- + let media_node_id = Uuid::now_v7(); + let media_node_id_str = media_node_id.to_string(); + let created_by_str = user.node_id.to_string(); + let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); + let node_title = title.unwrap_or_else(|| file_name.unwrap_or_default()); + + let metadata = serde_json::json!({ + "cas_hash": cas_result.hash, + "mime": mime, + "size_bytes": cas_result.size, + }); + let metadata_str = metadata.to_string(); + + // Skriv til SpacetimeDB (instant) + state + .stdb + .create_node( + &media_node_id_str, + "media", + &node_title, + "", + &visibility, + &metadata_str, + &created_by_str, + ) + .await + .map_err(|e| stdb_error("create_node (media)", e))?; + + tracing::info!( + media_node_id = %media_node_id, + cas_hash = %cas_result.hash, + size = cas_result.size, + mime = %mime, + already_existed = cas_result.already_existed, + created_by = %user.node_id, + "Media-node opprettet i STDB" + ); + + // Spawn async PG-skriving for media-noden + spawn_pg_insert_node( + state.db.clone(), + media_node_id, + "media".to_string(), + node_title, + String::new(), + visibility, + metadata, + user.node_id, + ); + + // -- Opprett has_media-edge hvis source_id er oppgitt -- + let has_media_edge_id = if let Some(src_id) = source_id { + let edge_id = Uuid::now_v7(); + let edge_id_str = edge_id.to_string(); + let src_id_str = src_id.to_string(); + let edge_metadata = serde_json::json!({}); + let edge_metadata_str = edge_metadata.to_string(); + + state + .stdb + .create_edge( + &edge_id_str, + &src_id_str, // source = innholdsnoden + &media_node_id_str, // target = media-noden + "has_media", + &edge_metadata_str, + false, + &created_by_str, + ) + .await + .map_err(|e| stdb_error("create_edge (has_media)", e))?; + + tracing::info!( + edge_id = %edge_id, + source_id = %src_id, + media_node_id = %media_node_id, + "has_media-edge opprettet i STDB" + ); + + // has_media er ikke tilgangsgivende — enkel PG-insert + spawn_pg_insert_edge( + state.db.clone(), + state.stdb.clone(), + edge_id, + src_id, + media_node_id, + "has_media".to_string(), + edge_metadata, + false, + user.node_id, + ); + + Some(edge_id) + } else { + None + }; + + Ok(Json(UploadMediaResponse { + media_node_id, + cas_hash: cas_result.hash, + size_bytes: cas_result.size, + already_existed: cas_result.already_existed, + has_media_edge_id, + })) +} + // ============================================================================= // Bakgrunns-PG-operasjoner // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 61fc5ee..64ef110 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -130,6 +130,7 @@ async fn main() { .route("/intentions/update_node", post(intentions::update_node)) .route("/intentions/delete_node", post(intentions::delete_node)) .route("/intentions/create_communication", post(intentions::create_communication)) + .route("/intentions/upload_media", post(intentions::upload_media)) .route("/query/nodes", get(queries::query_nodes)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/tasks.md b/tasks.md index b901de0..efd00b3 100644 --- a/tasks.md +++ b/tasks.md @@ -87,8 +87,7 @@ Uavhengige faser kan fortsatt plukkes. ## Fase 6: CAS og mediefiler - [x] 6.1 CAS-lagring: filsystem med content-addressable hashing (SHA-256). Katalogstruktur med hash-prefix. Deduplisering. -- [~] 6.2 Upload-endepunkt: `POST /intentions/upload_media` → hash fil, lagre i CAS, opprett media-node med `has_media`-edge. - > Påbegynt: 2026-03-17T16:38 +- [x] 6.2 Upload-endepunkt: `POST /intentions/upload_media` → hash fil, lagre i CAS, opprett media-node med `has_media`-edge. - [ ] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse. - [ ] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`.