Fullfør oppgave 6.2: Upload-endepunkt for mediefiler
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 <noreply@anthropic.com>
This commit is contained in:
parent
ae3d6739be
commit
924ac1b6d0
5 changed files with 261 additions and 4 deletions
27
maskinrommet/Cargo.lock
generated
27
maskinrommet/Cargo.lock
generated
|
|
@ -72,6 +72,7 @@ dependencies = [
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|
@ -317,6 +318,15 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -1015,6 +1025,23 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//
|
//
|
||||||
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
|
// 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 serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -17,6 +17,9 @@ use uuid::Uuid;
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Maks filstørrelse for upload: 100 MB.
|
||||||
|
const MAX_UPLOAD_SIZE: usize = 100 * 1024 * 1024;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Felles
|
// Felles
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -776,6 +779,233 @@ pub async fn create_communication(
|
||||||
Ok(Json(CreateCommunicationResponse { node_id, edge_ids }))
|
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<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<UploadMediaResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
|
let mut file_name: Option<String> = None;
|
||||||
|
let mut content_type: Option<String> = None;
|
||||||
|
let mut source_id: Option<Uuid> = None;
|
||||||
|
let mut visibility = "hidden".to_string();
|
||||||
|
let mut title: Option<String> = 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
|
// Bakgrunns-PG-operasjoner
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ async fn main() {
|
||||||
.route("/intentions/update_node", post(intentions::update_node))
|
.route("/intentions/update_node", post(intentions::update_node))
|
||||||
.route("/intentions/delete_node", post(intentions::delete_node))
|
.route("/intentions/delete_node", post(intentions::delete_node))
|
||||||
.route("/intentions/create_communication", post(intentions::create_communication))
|
.route("/intentions/create_communication", post(intentions::create_communication))
|
||||||
|
.route("/intentions/upload_media", post(intentions::upload_media))
|
||||||
.route("/query/nodes", get(queries::query_nodes))
|
.route("/query/nodes", get(queries::query_nodes))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -87,8 +87,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
## Fase 6: CAS og mediefiler
|
## Fase 6: CAS og mediefiler
|
||||||
|
|
||||||
- [x] 6.1 CAS-lagring: filsystem med content-addressable hashing (SHA-256). Katalogstruktur med hash-prefix. Deduplisering.
|
- [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.
|
- [x] 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
|
|
||||||
- [ ] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse.
|
- [ ] 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`.
|
- [ ] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue