feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:
- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
commit; soft_delete returns the paths; an hourly reaper reclaims disk for
rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).
Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).
Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use axum::extract::{FromRequestParts, State};
|
use axum::extract::FromRequestParts;
|
||||||
use axum::http::request::Parts;
|
use axum::http::request::Parts;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use uuid::Uuid;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
|
use crate::services::media_token;
|
||||||
use crate::services::rate_limiter::client_ip;
|
use crate::services::rate_limiter::client_ip;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ pub struct FeedUpload {
|
|||||||
pub uploader_name: String,
|
pub uploader_name: String,
|
||||||
pub preview_url: Option<String>,
|
pub preview_url: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
|
/// Signed gateway URL for the full-resolution original. Always present.
|
||||||
|
pub original_url: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
pub like_count: i64,
|
pub like_count: i64,
|
||||||
@@ -55,6 +58,33 @@ struct FeedRow {
|
|||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The
|
||||||
|
/// preview/thumbnail URLs are present only when the derivative exists so the
|
||||||
|
/// client can show a skeleton while compression is still running; the original
|
||||||
|
/// URL is always present.
|
||||||
|
fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload {
|
||||||
|
FeedUpload {
|
||||||
|
liked_by_me: liked,
|
||||||
|
preview_url: r
|
||||||
|
.preview_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| media_token::signed_url(secret, "preview", r.id, now)),
|
||||||
|
thumbnail_url: r
|
||||||
|
.thumbnail_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)),
|
||||||
|
original_url: Some(media_token::signed_url(secret, "original", r.id, now)),
|
||||||
|
id: r.id,
|
||||||
|
user_id: r.user_id,
|
||||||
|
uploader_name: r.uploader_name,
|
||||||
|
mime_type: r.mime_type,
|
||||||
|
caption: r.caption,
|
||||||
|
like_count: r.like_count,
|
||||||
|
comment_count: r.comment_count,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn feed(
|
pub async fn feed(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@@ -135,24 +165,13 @@ pub async fn feed(
|
|||||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||||
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
||||||
|
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let secret = &state.config.jwt_secret;
|
||||||
let uploads = rows
|
let uploads = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
|
let liked = liked_set.contains(&r.id);
|
||||||
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
|
to_feed_upload(r, liked, secret, now)
|
||||||
FeedUpload {
|
|
||||||
liked_by_me: liked_set.contains(&r.id),
|
|
||||||
id: r.id,
|
|
||||||
user_id: r.user_id,
|
|
||||||
uploader_name: r.uploader_name,
|
|
||||||
preview_url,
|
|
||||||
thumbnail_url,
|
|
||||||
mime_type: r.mime_type,
|
|
||||||
caption: r.caption,
|
|
||||||
like_count: r.like_count,
|
|
||||||
comment_count: r.comment_count,
|
|
||||||
created_at: r.created_at,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -202,20 +221,13 @@ pub async fn feed_delta(
|
|||||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||||
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
||||||
|
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let secret = &state.config.jwt_secret;
|
||||||
let uploads = rows
|
let uploads = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| FeedUpload {
|
.map(|r| {
|
||||||
liked_by_me: liked_set.contains(&r.id),
|
let liked = liked_set.contains(&r.id);
|
||||||
id: r.id,
|
to_feed_upload(r, liked, secret, now)
|
||||||
user_id: r.user_id,
|
|
||||||
uploader_name: r.uploader_name,
|
|
||||||
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
|
|
||||||
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
|
|
||||||
mime_type: r.mime_type,
|
|
||||||
caption: r.caption,
|
|
||||||
like_count: r.like_count,
|
|
||||||
comment_count: r.comment_count,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -324,24 +324,21 @@ pub async fn host_delete_upload(
|
|||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||||
if !deleted {
|
|
||||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = state.sse_tx.send(SseEvent::new(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
"upload-deleted",
|
"upload-deleted",
|
||||||
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
upload_id = %upload.id,
|
upload_id = %upload_id,
|
||||||
"host: host_delete_upload"
|
"host: host_delete_upload"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
117
backend/src/handlers/media.rs
Normal file
117
backend/src/handlers/media.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! Authenticated media gateway.
|
||||||
|
//!
|
||||||
|
//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a
|
||||||
|
//! signature check plus a DB lookup, so:
|
||||||
|
//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3,
|
||||||
|
//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2,
|
||||||
|
//! - the export archives (which have no `upload` row) are unreachable — C1,
|
||||||
|
//! - HTML/SVG can't be served as an active document — the response carries
|
||||||
|
//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time
|
||||||
|
//! allowlist) — C2 sink.
|
||||||
|
//!
|
||||||
|
//! Access is authorized by the embedded HMAC signature (see `media_token`), not
|
||||||
|
//! a Bearer header, because `<img>`/`<video>` cannot send one.
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::response::Response;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::upload::Upload;
|
||||||
|
use crate::models::user::User;
|
||||||
|
use crate::services::media_token;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MediaQuery {
|
||||||
|
pub exp: i64,
|
||||||
|
pub sig: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((kind, id)): Path<(String, Uuid)>,
|
||||||
|
Query(q): Query<MediaQuery>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
if !media_token::verify(
|
||||||
|
&state.config.jwt_secret,
|
||||||
|
&kind,
|
||||||
|
id,
|
||||||
|
q.exp,
|
||||||
|
&q.sig,
|
||||||
|
Utc::now().timestamp(),
|
||||||
|
) {
|
||||||
|
return Err(AppError::Unauthorized(
|
||||||
|
"Ungültige oder abgelaufene Medien-URL.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
|
||||||
|
let upload = Upload::find_by_id(&state.pool, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
|
||||||
|
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
|
||||||
|
let uploader = User::find_by_id(&state.pool, upload.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||||
|
if uploader.uploads_hidden {
|
||||||
|
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (rel_path, content_type) = match kind.as_str() {
|
||||||
|
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
|
||||||
|
"preview" => (
|
||||||
|
upload
|
||||||
|
.preview_path
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
),
|
||||||
|
"thumbnail" => (
|
||||||
|
upload
|
||||||
|
.thumbnail_path
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
),
|
||||||
|
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::CONTENT_LENGTH, metadata.len())
|
||||||
|
// URLs are signed + time-boxed; cache only privately, aligned to the
|
||||||
|
// 1h URL-stability bucket.
|
||||||
|
.header(header::CACHE_CONTROL, "private, max-age=3600")
|
||||||
|
// Defense-in-depth against any active content slipping past the
|
||||||
|
// upload-time allowlist: never sniff, never script.
|
||||||
|
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||||
|
)
|
||||||
|
.body(Body::from_stream(stream))
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod admin;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod me;
|
pub mod me;
|
||||||
|
pub mod media;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod test_admin;
|
pub mod test_admin;
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ pub async fn upload(
|
|||||||
uploader_name: user.display_name,
|
uploader_name: user.display_name,
|
||||||
preview_url: None,
|
preview_url: None,
|
||||||
thumbnail_url: None,
|
thumbnail_url: None,
|
||||||
|
original_url: Some(crate::services::media_token::signed_url(
|
||||||
|
&state.config.jwt_secret,
|
||||||
|
"original",
|
||||||
|
upload.id,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
)),
|
||||||
mime_type: canonical_mime,
|
mime_type: canonical_mime,
|
||||||
caption,
|
caption,
|
||||||
hashtags: tags,
|
hashtags: tags,
|
||||||
@@ -349,7 +355,15 @@ pub async fn delete_upload(
|
|||||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
|
||||||
|
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell other clients to drop the photo immediately (mirrors host delete).
|
||||||
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
"upload-deleted",
|
||||||
|
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -417,56 +431,9 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming download of the original file behind an upload. Used by:
|
// The original-file download is now served by the authenticated, signed media
|
||||||
/// - the per-post "Original anzeigen" context action (`window.open`)
|
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
|
||||||
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
// alias was the H2 vulnerability and has been removed.
|
||||||
/// Data Mode = Original
|
|
||||||
///
|
|
||||||
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
|
||||||
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
|
||||||
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
|
||||||
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
|
||||||
/// and `window.open`, defeating the purpose of having the alias.
|
|
||||||
pub async fn get_original(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(upload_id): Path<Uuid>,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
|
||||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
||||||
|
|
||||||
let absolute = state.config.media_path.join(&upload.original_path);
|
|
||||||
if !absolute.exists() {
|
|
||||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header, Response, StatusCode};
|
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&absolute)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
|
||||||
let metadata = file
|
|
||||||
.metadata()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
|
||||||
let stream = ReaderStream::new(file);
|
|
||||||
|
|
||||||
let filename = absolute
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("original");
|
|
||||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
|
||||||
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, upload.mime_type)
|
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
|
||||||
.header(header::CONTENT_LENGTH, metadata.len())
|
|
||||||
.body(Body::from_stream(stream))
|
|
||||||
.map_err(|e| AppError::Internal(e.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use anyhow::Result;
|
|||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::routing::{delete, get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@@ -46,6 +45,7 @@ async fn main() -> Result<()> {
|
|||||||
pool,
|
pool,
|
||||||
state.rate_limiter.clone(),
|
state.rate_limiter.clone(),
|
||||||
state.sse_tickets.clone(),
|
state.sse_tickets.clone(),
|
||||||
|
config.media_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure media directories exist
|
// Ensure media directories exist
|
||||||
@@ -64,10 +64,6 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/upload/{id}",
|
"/api/v1/upload/{id}",
|
||||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/v1/upload/{id}/original",
|
|
||||||
get(handlers::upload::get_original),
|
|
||||||
)
|
|
||||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||||
@@ -129,13 +125,14 @@ async fn main() -> Result<()> {
|
|||||||
api
|
api
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serve media files from disk
|
// Media is served exclusively through the authenticated, signed gateway —
|
||||||
let media_service = ServeDir::new(&config.media_path);
|
// there is no raw static mount. The gateway verifies an HMAC signature and
|
||||||
|
// consults the DB (deleted / ban-hidden / type), so private artifacts and
|
||||||
|
// the export archives are never reachable by guessing a path.
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
.nest_service("/media", media_service)
|
.route("/media/{kind}/{id}", get(handlers::media::serve))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ pub struct Upload {
|
|||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On-disk artifact paths returned by the soft-delete methods so the caller can
|
||||||
|
/// unlink the files after the DB commit.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct DeletedPaths {
|
||||||
|
pub original: String,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UploadDto {
|
pub struct UploadDto {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -26,6 +35,8 @@ pub struct UploadDto {
|
|||||||
pub uploader_name: String,
|
pub uploader_name: String,
|
||||||
pub preview_url: Option<String>,
|
pub preview_url: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
|
/// Signed gateway URL for the full-resolution original. Always present.
|
||||||
|
pub original_url: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
pub hashtags: Vec<String>,
|
pub hashtags: Vec<String>,
|
||||||
@@ -125,18 +136,21 @@ impl Upload {
|
|||||||
///
|
///
|
||||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||||
/// delete action double-decrementing the counter.
|
/// delete action double-decrementing the counter.
|
||||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
///
|
||||||
|
/// Returns the artifact paths of the row that was deleted (`None` if nothing
|
||||||
|
/// matched) so the caller can unlink the files after the commit.
|
||||||
|
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
"UPDATE upload
|
"UPDATE upload
|
||||||
SET deleted_at = NOW()
|
SET deleted_at = NOW()
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING user_id, original_size_bytes",
|
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some((user_id, bytes)) = row {
|
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||||
@@ -146,31 +160,34 @@ impl Upload {
|
|||||||
.bind(bytes)
|
.bind(bytes)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
Some(DeletedPaths { original, preview, thumbnail })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
|
||||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||||
/// can return a clean 404 instead of silently no-op'ing.
|
/// can return a clean 404, and the artifact paths otherwise.
|
||||||
pub async fn soft_delete_in_event(
|
pub async fn soft_delete_in_event(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
"UPDATE upload
|
"UPDATE upload
|
||||||
SET deleted_at = NOW()
|
SET deleted_at = NOW()
|
||||||
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
||||||
RETURNING user_id, original_size_bytes",
|
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let deleted = if let Some((user_id, bytes)) = row {
|
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||||
@@ -180,12 +197,12 @@ impl Upload {
|
|||||||
.bind(bytes)
|
.bind(bytes)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
true
|
Some(DeletedPaths { original, preview, thumbnail })
|
||||||
} else {
|
} else {
|
||||||
false
|
None
|
||||||
};
|
};
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(deleted)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_caption(
|
pub async fn update_caption(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||||
//! accumulate).
|
//! accumulate).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -76,6 +77,7 @@ pub fn spawn_periodic_tasks(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
rate_limiter: RateLimiter,
|
rate_limiter: RateLimiter,
|
||||||
sse_tickets: SseTicketStore,
|
sse_tickets: SseTicketStore,
|
||||||
|
media_path: PathBuf,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||||
@@ -86,6 +88,9 @@ pub fn spawn_periodic_tasks(
|
|||||||
cleanup_sessions(&pool).await;
|
cleanup_sessions(&pool).await;
|
||||||
rate_limiter.prune();
|
rate_limiter.prune();
|
||||||
sse_tickets.prune();
|
sse_tickets.prune();
|
||||||
|
// Reclaim disk for uploads soft-deleted more than the grace period
|
||||||
|
// ago, and hard-delete those rows (FKs cascade).
|
||||||
|
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
73
backend/src/services/media_fs.rs
Normal file
73
backend/src/services/media_fs.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Filesystem lifecycle for media artifacts.
|
||||||
|
//!
|
||||||
|
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
|
||||||
|
//! a fixed-size disk until something removes them. This module is that
|
||||||
|
//! something: best-effort unlinking on delete, plus a periodic reaper that
|
||||||
|
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
|
||||||
|
//! unlink missed — e.g. a crash between the DB commit and the unlink).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::models::upload::DeletedPaths;
|
||||||
|
|
||||||
|
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
|
||||||
|
/// not an error (it may already be gone, or never existed for videos without a
|
||||||
|
/// preview); anything else is logged but never propagated — losing the bytes
|
||||||
|
/// must not fail the user's delete.
|
||||||
|
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
|
||||||
|
let candidates = [
|
||||||
|
Some(&paths.original),
|
||||||
|
paths.preview.as_ref(),
|
||||||
|
paths.thumbnail.as_ref(),
|
||||||
|
];
|
||||||
|
for rel in candidates.into_iter().flatten() {
|
||||||
|
let abs = media_path.join(rel);
|
||||||
|
match tokio::fs::remove_file(&abs).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
|
||||||
|
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
|
||||||
|
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
|
||||||
|
/// belonging to a live upload.
|
||||||
|
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
|
||||||
|
let rows: Vec<DeletedPaths> = match sqlx::query_as::<_, DeletedPaths>(
|
||||||
|
"SELECT original_path AS original, preview_path AS preview, thumbnail_path AS thumbnail
|
||||||
|
FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "media reaper: query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for paths in &rows {
|
||||||
|
unlink_media(media_path, paths).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match sqlx::query(
|
||||||
|
"DELETE FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
|
||||||
|
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
122
backend/src/services/media_token.rs
Normal file
122
backend/src/services/media_token.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//! Stateless signed URLs for the authenticated media gateway.
|
||||||
|
//!
|
||||||
|
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
|
||||||
|
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
|
||||||
|
//! feed/upload DTOs are serialized for an already-authenticated event member, so
|
||||||
|
//! that is where fresh signatures are minted; the gateway handler verifies them
|
||||||
|
//! without any DB/session state.
|
||||||
|
//!
|
||||||
|
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
|
||||||
|
//! key is the app's `jwt_secret`.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Validity window of a signed URL.
|
||||||
|
const TTL_SECS: i64 = 24 * 3600;
|
||||||
|
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
|
||||||
|
/// window so the browser caches image bytes across feed polls instead of
|
||||||
|
/// re-downloading on every refresh.
|
||||||
|
const BUCKET_SECS: i64 = 3600;
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
|
||||||
|
const BLOCK: usize = 64;
|
||||||
|
let mut key_block = [0u8; BLOCK];
|
||||||
|
if key.len() > BLOCK {
|
||||||
|
let digest = Sha256::digest(key);
|
||||||
|
key_block[..32].copy_from_slice(&digest);
|
||||||
|
} else {
|
||||||
|
key_block[..key.len()].copy_from_slice(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ipad = [0x36u8; BLOCK];
|
||||||
|
let mut opad = [0x5cu8; BLOCK];
|
||||||
|
for i in 0..BLOCK {
|
||||||
|
ipad[i] ^= key_block[i];
|
||||||
|
opad[i] ^= key_block[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = Sha256::new();
|
||||||
|
inner.update(ipad);
|
||||||
|
inner.update(msg);
|
||||||
|
let inner_digest = inner.finalize();
|
||||||
|
|
||||||
|
let mut outer = Sha256::new();
|
||||||
|
outer.update(opad);
|
||||||
|
outer.update(inner_digest);
|
||||||
|
outer.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
|
||||||
|
let msg = format!("{kind}:{id}:{exp}");
|
||||||
|
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
|
||||||
|
let mut hex = String::with_capacity(64);
|
||||||
|
for b in mac {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(hex, "{b:02x}");
|
||||||
|
}
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
|
||||||
|
/// `kind` is `original` | `preview` | `thumbnail`.
|
||||||
|
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
|
||||||
|
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
|
||||||
|
let sig = sign(secret, kind, id, exp);
|
||||||
|
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signed URL: signature must match and the expiry must not have
|
||||||
|
/// passed. Signature comparison is constant-time.
|
||||||
|
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
|
||||||
|
if exp < now {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let expected = sign(secret, kind, id, exp);
|
||||||
|
constant_time_eq(expected.as_bytes(), sig.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut diff = 0u8;
|
||||||
|
for (x, y) in a.iter().zip(b.iter()) {
|
||||||
|
diff |= x ^ y;
|
||||||
|
}
|
||||||
|
diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_verify_roundtrip() {
|
||||||
|
let secret = "test_secret_at_least_32_chars_long_xxxx";
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = 1_700_000_000;
|
||||||
|
let url = signed_url(secret, "original", id, now);
|
||||||
|
// Extract exp + sig from the query string.
|
||||||
|
let (_, query) = url.split_once('?').unwrap();
|
||||||
|
let mut exp = 0i64;
|
||||||
|
let mut sig = String::new();
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let (k, v) = pair.split_once('=').unwrap();
|
||||||
|
match k {
|
||||||
|
"exp" => exp = v.parse().unwrap(),
|
||||||
|
"sig" => sig = v.to_string(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(verify(secret, "original", id, exp, &sig, now));
|
||||||
|
// Tampered kind / id / sig must fail.
|
||||||
|
assert!(!verify(secret, "preview", id, exp, &sig, now));
|
||||||
|
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
|
||||||
|
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
|
||||||
|
// Wrong secret must fail.
|
||||||
|
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
|
||||||
|
// Expired must fail.
|
||||||
|
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,7 @@ pub mod config;
|
|||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
|
pub mod media_fs;
|
||||||
|
pub mod media_token;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod sse_tickets;
|
pub mod sse_tickets;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
function tileUrl(upload: FeedUpload): string {
|
function tileUrl(upload: FeedUpload): string {
|
||||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||||
if (upload.preview_url) return upload.preview_url;
|
if (upload.preview_url) return upload.preview_url;
|
||||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
return $dataMode === 'original' ? (upload.original_url ?? '') : '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
|
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
|
||||||
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
|
// the full file via the signed `original_url` from the media gateway.
|
||||||
//
|
//
|
||||||
// Stored per-device in localStorage (not per-user) because data plans are a property
|
// Stored per-device in localStorage (not per-user) because data plans are a property
|
||||||
// of the device the guest is currently holding, not their identity.
|
// of the device the guest is currently holding, not their identity.
|
||||||
@@ -40,17 +40,24 @@ if (browser) {
|
|||||||
* Build the URL for a feed upload given the current data mode and the URL variants
|
* Build the URL for a feed upload given the current data mode and the URL variants
|
||||||
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
|
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
|
||||||
* follows the same fallback rule:
|
* follows the same fallback rule:
|
||||||
* Original mode → original API route. Falls back to preview if no upload id is
|
* Original mode → signed original URL, falling back to preview/thumbnail.
|
||||||
* available (defensive — shouldn't happen in practice).
|
|
||||||
* Saver mode → preview URL (compressed), falling back to thumbnail and then
|
* Saver mode → preview URL (compressed), falling back to thumbnail and then
|
||||||
* original.
|
* the signed original.
|
||||||
|
*
|
||||||
|
* All URLs are minted (and signed) by the backend; the client never constructs
|
||||||
|
* a media path itself.
|
||||||
*/
|
*/
|
||||||
export function pickMediaUrl(
|
export function pickMediaUrl(
|
||||||
mode: DataMode,
|
mode: DataMode,
|
||||||
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
|
upload: {
|
||||||
|
id: string;
|
||||||
|
preview_url: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
original_url: string | null;
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
if (mode === 'original') {
|
if (mode === 'original') {
|
||||||
return `/api/v1/upload/${upload.id}/original`;
|
return upload.original_url ?? upload.preview_url ?? upload.thumbnail_url ?? '';
|
||||||
}
|
}
|
||||||
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
|
return upload.preview_url ?? upload.thumbnail_url ?? upload.original_url ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface FeedUpload {
|
|||||||
uploader_name: string;
|
uploader_name: string;
|
||||||
preview_url: string | null;
|
preview_url: string | null;
|
||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
|
original_url: string | null;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
caption: string | null;
|
caption: string | null;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
label: 'Original anzeigen',
|
label: 'Original anzeigen',
|
||||||
icon: '⤓',
|
icon: '⤓',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
|
if (target.original_url) window.open(target.original_url, '_blank');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user