60b94e1183
Step 5, read path — Option 2 of the two shapes discussed: the derived
blob is consulted LAST, after the sidecar, not first.
Read order is now
moka -> ext-{file_id}.jpg -> {blob_hash}.webp on disk -> derived blob
For every thumbnail already on disk the new branch is never reached, so
the database stays off the hot path and a fault in it cannot break a
working gallery. It answers only what disk cannot: a thumbnail rendered
by another instance, or a box whose sidecar was never populated. Legacy
content keeps serving from disk until `derived_import` migrates it.
That inverts the plan's stated order deliberately. Derived-blob-first is
right for the END state, because it is what lets the sidecar be deleted;
sidecar-first is right transitionally, because the risky reordering
should happen after the table has been seen serving real reads. The flip
belongs in the release that removes the sidecar, and the comment at the
branch says so.
The existing precedence is preserved and now documented: the file-keyed
client upload (ext-) is checked BEFORE the content-keyed server render.
That ordering is a security property, not a preference — content-keyed
artifacts are shared across every file with that content, so checking
the file-keyed one first is what keeps one user's uploaded preview from
ever being served for another user's identical file.
Shape notes:
* `find_derived_blob` lands on DedupPort/DedupService as the read
counterpart of `store_derived_blob`, so ThumbnailService needs no pool
field — and therefore ThumbnailService::new, DI and three tests are
untouched.
* It carries `content_type`, which is what will retire the byte-sniffing
in the handlers once reads are table-primary.
* The parameter is `Option<&DedupService>`, concrete rather than
`&dyn DedupPort`: DedupPort uses native `async fn` and so is not
dyn-compatible, and ThumbnailPort is never used as a trait object
(checked) — both handlers hold the concrete Arc. `None` means
sidecar-only, which is exactly today's behaviour and what the abstract
port impl passes.
fmt, clippy --all-features --all-targets, 35 unit tests clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
8.2 KiB
Rust
253 lines
8.2 KiB
Rust
//! Nextcloud-compatible preview/thumbnail endpoint.
|
|
//!
|
|
//! Maps Nextcloud preview requests to OxiCloud's thumbnail service.
|
|
|
|
use axum::{
|
|
body::Body,
|
|
extract::{Query, State},
|
|
http::{StatusCode, header},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
|
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
|
use crate::application::ports::storage_ports::FileReadPort;
|
|
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
|
|
use crate::common::di::AppState;
|
|
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
|
use crate::interfaces::middleware::auth::AuthUser;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PreviewParams {
|
|
#[serde(rename = "fileId")]
|
|
file_id: String,
|
|
x: Option<u32>,
|
|
y: Option<u32>,
|
|
#[serde(rename = "forceIcon")]
|
|
force_icon: Option<u8>,
|
|
}
|
|
|
|
/// Handle Nextcloud preview requests.
|
|
///
|
|
/// Maps:
|
|
/// - `/index.php/core/preview?fileId=X` to thumbnail generation
|
|
/// - Size selection based on request dimensions and forceIcon param
|
|
pub async fn handle_preview(
|
|
State(state): State<Arc<AppState>>,
|
|
user: AuthUser,
|
|
Query(params): Query<PreviewParams>,
|
|
req: axum::extract::Request,
|
|
) -> impl IntoResponse {
|
|
// Parse the Nextcloud file ID — the NC app may append an instance suffix
|
|
// (e.g. "00000326ocnca"), so strip non-digit characters first.
|
|
let digit_end = params
|
|
.file_id
|
|
.as_bytes()
|
|
.iter()
|
|
.position(|b| !b.is_ascii_digit())
|
|
.unwrap_or(params.file_id.len());
|
|
let nc_file_id: i64 = match params.file_id[..digit_end].parse() {
|
|
Ok(id) => id,
|
|
Err(_) => {
|
|
return Response::builder()
|
|
.status(StatusCode::BAD_REQUEST)
|
|
.body(Body::from("Invalid file ID"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
|
|
// Look up the OxiCloud file UUID from the Nextcloud ID
|
|
let object_id = match state.nextcloud.as_ref() {
|
|
Some(nc) => match nc.file_ids.get_oxicloud_id(nc_file_id).await {
|
|
Ok(id) => id,
|
|
Err(_) => {
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("File not found"))
|
|
.unwrap();
|
|
}
|
|
},
|
|
None => {
|
|
return Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("Nextcloud integration not configured"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
|
|
// Get file details
|
|
let file = match state
|
|
.applications
|
|
.file_retrieval_service
|
|
.get_file(&object_id)
|
|
.await
|
|
{
|
|
Ok(file) => file,
|
|
Err(_) => {
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("File not found"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
|
|
// Verify the authenticated user can Read this file. Anti-enum: any
|
|
// AuthZ denial surfaces as 404 (same shape as "unknown file" above),
|
|
// and the engine emits an `authz.denied` audit line internally.
|
|
let file_uuid = match Uuid::parse_str(&file.id) {
|
|
Ok(u) => u,
|
|
Err(_) => {
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("File not found"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
if state
|
|
.authorization
|
|
.require(
|
|
Subject::User(user.id),
|
|
Permission::Read,
|
|
Resource::File(file_uuid),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("File not found"))
|
|
.unwrap();
|
|
}
|
|
|
|
// Determine thumbnail size based on request params
|
|
let thumb_size = if params.force_icon == Some(1) {
|
|
ThumbnailSize::Icon
|
|
} else {
|
|
// Map requested dimensions to our thumbnail sizes
|
|
let max_dim = params.x.unwrap_or(400).max(params.y.unwrap_or(400));
|
|
if max_dim <= 150 {
|
|
ThumbnailSize::Icon
|
|
} else if max_dim <= 400 {
|
|
ThumbnailSize::Preview
|
|
} else {
|
|
ThumbnailSize::Large
|
|
}
|
|
};
|
|
|
|
// Conditional revalidation — the ETag is derived from (object id, size)
|
|
// only, so it is computable right here, BEFORE the blob-hash query and
|
|
// the thumbnail cache/disk read. NC clients revalidate gallery previews
|
|
// constantly; the REST thumbnail endpoint has honoured `If-None-Match`
|
|
// since PHOTOS-ETAG — this endpoint set an immutable ETag but never
|
|
// compared it, so every revalidation re-ran the whole pipeline and
|
|
// re-shipped the body (ROUND10). Authz already passed above; a 304
|
|
// must never skip the Read check.
|
|
let etag = {
|
|
let s = thumb_size.as_str();
|
|
let mut e = String::with_capacity(9 + object_id.len() + s.len());
|
|
e.push_str("\"thumb-");
|
|
e.push_str(&object_id);
|
|
e.push('-');
|
|
e.push_str(s);
|
|
e.push('"');
|
|
e
|
|
};
|
|
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
|
|
&& let Ok(client_etag) = inm.to_str()
|
|
&& (client_etag == etag || client_etag == "*")
|
|
{
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_MODIFIED)
|
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
|
.header(header::ETAG, etag)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
}
|
|
|
|
// Check if file is an image
|
|
if !state
|
|
.core
|
|
.thumbnail_service
|
|
.is_supported_image(&file.mime_type)
|
|
{
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("Preview not available for this file type"))
|
|
.unwrap();
|
|
}
|
|
|
|
// Resolve the blob hash (content-addressable storage)
|
|
let blob_hash = match state
|
|
.repositories
|
|
.file_read_repository
|
|
.get_blob_hash(&object_id)
|
|
.await
|
|
{
|
|
Ok(hash) => hash,
|
|
Err(_) => {
|
|
return Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Body::from("File blob not found"))
|
|
.unwrap();
|
|
}
|
|
};
|
|
if let Some(data) = state
|
|
.core
|
|
.thumbnail_service
|
|
// NextCloud clients don't advertise WebP and expect JPEG — pin to JPEG
|
|
// (served from the shared lazy `.jpg` fallback).
|
|
.get_cached_thumbnail(
|
|
&object_id,
|
|
Some(&blob_hash),
|
|
thumb_size.into(),
|
|
ThumbnailFormat::Jpeg,
|
|
Some(&state.core.dedup_service),
|
|
)
|
|
.await
|
|
{
|
|
return Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
|
.header(header::CONTENT_LENGTH, data.len())
|
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
|
.header(header::ETAG, etag)
|
|
.body(Body::from(data))
|
|
.unwrap();
|
|
}
|
|
|
|
// Generate/get thumbnail — the blob is read inside the service once a
|
|
// decode permit is held, so preview stampedes cannot stack source
|
|
// images in RAM.
|
|
match state
|
|
.core
|
|
.thumbnail_service
|
|
.get_thumbnail_from_blob(
|
|
&object_id,
|
|
&blob_hash,
|
|
thumb_size.into(),
|
|
ThumbnailFormat::Jpeg,
|
|
state.core.dedup_service.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(data) => Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
|
.header(header::CONTENT_LENGTH, data.len())
|
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
|
.header(header::ETAG, etag)
|
|
.body(Body::from(data))
|
|
.unwrap(),
|
|
Err(err) => {
|
|
tracing::error!("Thumbnail generation failed for {}: {}", object_id, err);
|
|
Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("Failed to generate thumbnail"))
|
|
.unwrap()
|
|
}
|
|
}
|
|
}
|