Backend:
- POST /api/v1/upload: multipart file upload with caption + hashtags
- Validates file size against DB config limits (image/video separate)
- Checks user ban status and event upload lock
- Saves original to disk under {media_path}/originals/{slug}/
- Tracks user total_upload_bytes for quota enforcement
- Extracts hashtags from caption text and explicit CSV field
- Upserts hashtags and links them to uploads
- PATCH /api/v1/upload/{id}: edit caption and hashtags (owner only)
- DELETE /api/v1/upload/{id}: soft-delete (owner only)
- GET /api/v1/stream: SSE endpoint with 30s keepalive
- Broadcasts new-upload events to all connected clients
- Uses tokio broadcast channel for fan-out
Services:
- CompressionWorker: Tokio semaphore-bounded (concurrency=2) background processor
- Images: resize to 800px wide JPEG preview via image crate
- PNG originals: lossless compression via oxipng
- Videos: ffmpeg thumbnail extraction (1 frame at 1s, scaled to 800px)
- Updates upload record with preview_path/thumbnail_path on completion
Models:
- Upload with full CRUD (create, find, update caption, soft delete, set paths)
- Hashtag with upsert, link/unlink, extract_hashtags() text parser
- UploadDto for API serialization with like/comment counts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
7.6 KiB
Rust
238 lines
7.6 KiB
Rust
use axum::extract::{Multipart, Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::middleware::AuthUser;
|
|
use crate::error::AppError;
|
|
use crate::models::hashtag::{self, Hashtag};
|
|
use crate::models::upload::{Upload, UploadDto};
|
|
use crate::models::user::User;
|
|
use crate::state::AppState;
|
|
|
|
pub async fn upload(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
mut multipart: Multipart,
|
|
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
|
// Check if user is banned
|
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
if user.is_banned {
|
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
|
}
|
|
|
|
// Check if uploads are locked
|
|
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
if event.uploads_locked_at.is_some() {
|
|
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
|
}
|
|
|
|
// Read config limits from DB
|
|
let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await;
|
|
let max_video_mb: i64 = get_config_i64(&state.pool, "max_video_size_mb", 500).await;
|
|
|
|
let mut file_data: Option<Vec<u8>> = None;
|
|
let mut file_name: Option<String> = None;
|
|
let mut content_type: Option<String> = None;
|
|
let mut caption: Option<String> = None;
|
|
let mut hashtags_csv: Option<String> = None;
|
|
|
|
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequest(e.to_string()))? {
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
match name.as_str() {
|
|
"file" => {
|
|
file_name = field.file_name().map(|s| s.to_string());
|
|
content_type = field.content_type().map(|s| s.to_string());
|
|
file_data = Some(
|
|
field.bytes().await
|
|
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
|
.to_vec(),
|
|
);
|
|
}
|
|
"caption" => {
|
|
caption = Some(
|
|
field.text().await
|
|
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
|
);
|
|
}
|
|
"hashtags" => {
|
|
hashtags_csv = Some(
|
|
field.text().await
|
|
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
|
|
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
|
let size = data.len() as i64;
|
|
|
|
// Validate file size
|
|
let max_bytes = if mime.starts_with("video/") {
|
|
max_video_mb * 1024 * 1024
|
|
} else {
|
|
max_image_mb * 1024 * 1024
|
|
};
|
|
if size > max_bytes {
|
|
return Err(AppError::BadRequest(format!(
|
|
"Datei ist zu groß. Maximum: {} MB.",
|
|
max_bytes / (1024 * 1024)
|
|
)));
|
|
}
|
|
|
|
// Determine file extension
|
|
let ext = file_name
|
|
.as_deref()
|
|
.and_then(|n| n.rsplit('.').next())
|
|
.unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" });
|
|
|
|
let upload_id = Uuid::new_v4();
|
|
let event_slug = &state.config.event_slug;
|
|
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
|
|
let absolute_path = state.config.media_path.join(&relative_path);
|
|
|
|
// Ensure directory exists and write file
|
|
if let Some(parent) = absolute_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await.map_err(|e| AppError::Internal(e.into()))?;
|
|
}
|
|
tokio::fs::write(&absolute_path, &data).await.map_err(|e| AppError::Internal(e.into()))?;
|
|
|
|
// Update user's total upload bytes
|
|
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
|
.bind(auth.user_id)
|
|
.bind(size)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Insert upload record
|
|
let upload = Upload::create(
|
|
&state.pool,
|
|
auth.event_id,
|
|
auth.user_id,
|
|
&relative_path,
|
|
&mime,
|
|
size,
|
|
caption.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// Process hashtags from caption and explicit CSV
|
|
let mut tags: Vec<String> = Vec::new();
|
|
if let Some(ref cap) = caption {
|
|
tags.extend(hashtag::extract_hashtags(cap));
|
|
}
|
|
if let Some(ref csv) = hashtags_csv {
|
|
for tag in csv.split(',') {
|
|
let t = tag.trim().trim_start_matches('#').to_lowercase();
|
|
if !t.is_empty() {
|
|
tags.push(t);
|
|
}
|
|
}
|
|
}
|
|
tags.sort();
|
|
tags.dedup();
|
|
|
|
for tag in &tags {
|
|
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
|
|
Hashtag::link_to_upload(&state.pool, upload.id, h.id).await?;
|
|
}
|
|
|
|
// Spawn compression task
|
|
state
|
|
.compression
|
|
.process(upload.id, relative_path, mime.clone());
|
|
|
|
// Broadcast SSE event
|
|
let dto = UploadDto {
|
|
id: upload.id,
|
|
user_id: auth.user_id,
|
|
uploader_name: user.display_name,
|
|
preview_url: None,
|
|
thumbnail_url: None,
|
|
mime_type: mime,
|
|
caption,
|
|
hashtags: tags,
|
|
like_count: 0,
|
|
comment_count: 0,
|
|
liked_by_me: false,
|
|
created_at: upload.created_at,
|
|
};
|
|
|
|
let _ = state.sse_tx.send(crate::state::SseEvent {
|
|
event_type: "new-upload".to_string(),
|
|
data: serde_json::to_string(&dto).unwrap_or_default(),
|
|
});
|
|
|
|
Ok((StatusCode::CREATED, Json(dto)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct EditUploadRequest {
|
|
pub caption: Option<String>,
|
|
pub hashtags: Option<Vec<String>>,
|
|
}
|
|
|
|
pub async fn edit_upload(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(upload_id): Path<Uuid>,
|
|
Json(body): Json<EditUploadRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let upload = Upload::find_by_id(&state.pool, upload_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
if upload.user_id != auth.user_id {
|
|
return Err(AppError::Forbidden("Nur eigene Uploads bearbeiten.".into()));
|
|
}
|
|
|
|
if let Some(ref caption) = body.caption {
|
|
Upload::update_caption(&state.pool, upload_id, Some(caption)).await?;
|
|
}
|
|
|
|
if let Some(ref hashtags) = body.hashtags {
|
|
Hashtag::unlink_all_from_upload(&state.pool, upload_id).await?;
|
|
for tag in hashtags {
|
|
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
|
|
Hashtag::link_to_upload(&state.pool, upload_id, h.id).await?;
|
|
}
|
|
}
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
pub async fn delete_upload(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
Path(upload_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let upload = Upload::find_by_id(&state.pool, upload_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
if upload.user_id != auth.user_id {
|
|
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
|
}
|
|
|
|
Upload::soft_delete(&state.pool, upload_id).await?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
|
|
let row: Option<(String,)> =
|
|
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
|
.bind(key)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.unwrap_or(None);
|
|
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
|
}
|