diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index 5fc47af..545b9cc 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -188,9 +188,12 @@ jobs: if: forgejo.event_name == 'push' && startsWith(forgejo.ref, 'refs/tags/v') run: | set -eu - token="${REGISTRY_TOKEN:-${FORGEJO_TOKEN:-${GITHUB_TOKEN:-}}}" + token="${REGISTRY_TOKEN:-}" username="${REGISTRY_USERNAME:-${FORGEJO_ACTOR:-${GITHUB_ACTOR:-}}}" - test -n "$token" + if [ -z "$token" ]; then + echo 'REGISTRY_TOKEN must contain a Forgejo token with write:package access' >&2 + exit 1 + fi test -n "$username" printf '%s' "$token" \ | crane auth login "$REGISTRY" --username "$username" --password-stdin diff --git a/Cargo.lock b/Cargo.lock index 6413ebf..67338ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,13 +618,12 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "axum", "blake3", "image", - "mime_guess", "natord", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4589156..0c54545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.8" +version = "0.1.9" edition = "2024" build = "build.rs" @@ -9,7 +9,6 @@ anyhow = "1.0.104" axum = { version = "0.8.9", features = ["json", "query", "tokio"] } blake3 = "1.8.7" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } -mime_guess = "2.0.5" natord = "1.0.9" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.md b/README.md index 5d57228..9e4bd3a 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,64 @@ # Pixhelf -基于 Axum 和 Preact 的自托管图片画廊。Preact 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。 +Pixhelf 是一个给自己的照片目录准备的轻量画廊。把图片放进文件夹,启动服务, +就能在浏览器里按相册浏览、搜索和排序;手机和桌面都能舒服地看图。 -## 运行 +它支持 JPEG、PNG 和 WebP,会自动留意图库变化,并在后台生成 WebP 缩略图。 +原图始终只读,缓存单独存放。Preact 前端会直接嵌进 Rust 程序里,部署时不用 +再带一套静态文件。 -默认从当前工作目录的 `./pic` 读取图片,并监听 `0.0.0.0:3002`: +## 用 Docker 跑起来 -```bash -./target/release/pixhelf -``` - -HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图片只生成一套最长边 720px、质量 82 的高质量 WebP 缩略图;其余缩略图由后台任务持续处理。新增、修改或删除图片会在定期扫描后自动同步,图库为空时也可正常启动并等待后续图片。 - -首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。 - -缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:已经解码的显示资源会直接复用,不再回退到缩略图;桌面端使用原图,移动端在原图像素不超过屏幕目标时也直接使用原图,大图则在当前图片稳定后从中性色缩略图升级到与视口及 DPR 匹配的高质量位图。全分辨率大图只在放大时按需加载,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。缩略图不做额外亮度或饱和度处理,升级时由高质量图层覆盖淡入,避免透明度叠加造成画面先暗后亮。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。 - -## 构建 - -需要当前稳定版 Rust、Node.js 20+ 和 npm: - -```bash -cd frontend -npm ci -cd .. -cargo build --release -``` - -`cargo build` 会自动执行 React 生产构建,并将结果嵌入 `target/release/pixhelf`。 - -发布工作流使用 musl 静态链接,并在同一台 AMD64 构建机上交叉编译两个版本: - -- `pixhelf-amd64-linux`:`x86_64-unknown-linux-musl` -- `pixhelf-arm64-linux`:`aarch64-unknown-linux-musl` - -两个 Release 产物都是无扩展名的独立可执行文件,下载后需要执行 -`chmod +x pixhelf-amd64-linux` 或 `chmod +x pixhelf-arm64-linux`。 - -## Docker - -每个版本同时发布 `linux/amd64` 和 `linux/arm64` 镜像。Docker 会自动选择与宿主机匹配的架构: +镜像放在 [eureka6688/pixhelf](https://hub.docker.com/r/eureka6688/pixhelf)。 +在项目目录里执行: ```bash mkdir -p pic docker compose up -d ``` -默认读取当前目录的 `./pic`,监听宿主机的 `3002` 端口,并使用命名卷保存缩略图缓存。 -可以通过环境变量指定版本、图库目录和端口: +把图片放进 `./pic`,然后打开 。 + +`compose.yml` 直接使用 `eureka6688/pixhelf:latest`,图库以只读方式挂载, +生成的缩略图则保存在 `pixhelf-cache` 命名卷里。 + +## 自己构建 + +想从源码构建,需要稳定版 Rust、Node.js 20+ 和 npm: ```bash -PIXHELF_TAG=0.1.8 \ -PIXHELF_GALLERY=/srv/photos \ -PIXHELF_PORT=8080 \ -docker compose up -d +cd frontend +npm ci +cd .. +cargo build --release --locked ``` -镜像地址为 `git.pixhelf.com/adminroot/pixhelf`。发布工作流优先使用 Actions Secret -`REGISTRY_TOKEN` 和 `REGISTRY_USERNAME` 登录 Forgejo Container Registry;未配置时会尝试使用 -当前工作流的临时令牌和触发用户。 +这一步会先构建 Preact 前端,再把资源嵌入 `target/release/pixhelf`。 +最终运行这一个文件就够了。 -推送 `master` 或在 Forgejo Actions 页面手动运行 `Build and release` 工作流时,会完整构建两种 -架构并验证多架构 OCI 镜像,但不会推送镜像或创建 Release;只有推送 `v*` 标签才会正式发布。 +发布版本也提供两个静态可执行文件,按机器架构选择即可: -## 参数 +- `pixhelf-amd64-linux` +- `pixhelf-arm64-linux` + +下载后记得添加执行权限: + +```bash +chmod +x pixhelf-amd64-linux +``` + +## 可调参数 + +大多数时候默认值就够用。需要更换目录、端口或扫描频率时,可以使用: ```text --gallery-dir PATH 图库目录,默认 ./pic --cache-dir PATH 缩略图缓存,默认 ./.pixhelf-cache/thumbnails --listen HOST:PORT 监听地址,默认 0.0.0.0:3002 ---initial-batch N 启动前生成数量,默认 60 ---workers N 后台压缩并发数,默认按 CPU 自动选择 2-4 ---scan-interval SEC 图库重新扫描间隔,默认 10 秒 +--initial-batch N 启动时优先生成数量,默认 60 +--workers N 后台任务数,默认按 CPU 自动选择 2-4 +--scan-interval SEC 图库扫描周期,默认 10 秒 +-h, --help 显示帮助 ``` -支持 JPEG、PNG 和 WebP 文件。其他文件不会进入图库索引。 +启动前请确认图库目录已经存在,也别把缓存目录放进图库里。 diff --git a/build.rs b/build.rs index 8fe703d..873a922 100644 --- a/build.rs +++ b/build.rs @@ -9,6 +9,7 @@ fn main() { "frontend/package.json", "frontend/package-lock.json", "frontend/tsconfig.json", + "frontend/tsconfig.app.json", "frontend/vite.config.ts", "frontend/src", ] { @@ -34,7 +35,11 @@ fn main() { fn asset_version(frontend_dir: &std::path::Path) -> String { let mut hash = 0xcbf29ce484222325u64; - for relative in ["dist/assets/app.js", "dist/assets/app.css"] { + for relative in [ + "dist/index.html", + "dist/assets/app.js", + "dist/assets/app.css", + ] { let path = frontend_dir.join(relative); let content = fs::read(&path) .unwrap_or_else(|error| panic!("cannot read built asset {}: {error}", path.display())); diff --git a/compose.yml b/compose.yml index 5cd1776..6bf5884 100644 --- a/compose.yml +++ b/compose.yml @@ -1,11 +1,11 @@ services: pixhelf: - image: git.pixhelf.com/adminroot/pixhelf:${PIXHELF_TAG:-latest} + image: eureka6688/pixhelf:latest restart: unless-stopped ports: - - "${PIXHELF_PORT:-3002}:3002" + - "3002:3002" volumes: - - "${PIXHELF_GALLERY:-./pic}:/data/pic:ro" + - "./pic:/data/pic:ro" - pixhelf-cache:/data/.pixhelf-cache volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 132e882..dc26522 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixhelf-frontend", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "preact": "^10.27.1" }, diff --git a/frontend/package.json b/frontend/package.json index 1410646..3598def 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.8", + "version": "0.1.9", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 97fc0f1..9d19245 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import { getDecodedViewerOriginal, preloadOriginalImage, prepareViewerImages, + viewerThumbnailUrl, } from "./viewerAssets"; const PAGE_SIZE = 60; @@ -46,6 +47,7 @@ const MOBILE_NAV_EXIT_MS = 240; const VIEWER_RETURN_SETTLE_MS = 32; const VIEWER_RETURN_TIMEOUT_MS = 480; const VIEWER_RETURN_ANIMATION_MS = 260; +const READY_THUMBNAIL_CACHE_LIMIT = 2048; const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN"); const INITIAL_BOOTSTRAP = takeInitialBootstrap(); @@ -446,6 +448,15 @@ const CARD_LOAD_CALLBACKS = new WeakMap void>(); const READY_THUMBNAIL_IDS = new Set(); let cardLoadObserver: IntersectionObserver | null = null; +function rememberReadyThumbnail(id: string): void { + READY_THUMBNAIL_IDS.delete(id); + READY_THUMBNAIL_IDS.add(id); + if (READY_THUMBNAIL_IDS.size > READY_THUMBNAIL_CACHE_LIMIT) { + const oldest = READY_THUMBNAIL_IDS.values().next().value; + if (oldest !== undefined) READY_THUMBNAIL_IDS.delete(oldest); + } +} + function observeCardLoad(element: Element, load: () => void): () => void { if (!("IntersectionObserver" in window)) { load(); @@ -1575,13 +1586,18 @@ const MasonryGallery = memo(function MasonryGallery({ useLayoutEffect(() => { const anchor = resizeAnchorRef.current; const masonry = ref.current; + let correctionFrame = 0; resizeAnchorRef.current = null; if (anchor && masonry && preserveViewport) { restoreMasonryViewportAnchor(masonry, anchor); + correctionFrame = window.requestAnimationFrame(() => { + restoreMasonryViewportAnchor(masonry, anchor); + }); } stableViewportAnchorRef.current = preserveViewport && masonry ? captureMasonryViewportAnchor(masonry) : null; + return () => window.cancelAnimationFrame(correctionFrame); }, [columnCount, gap, images, preserveViewport, width]); useEffect(() => { @@ -1722,13 +1738,13 @@ const ImageCard = memo(function ImageCard({ return observeCardLoad(card, () => setLoadRequested(true)); }, [eager, loadRequested]); - const thumbnailUrl = `/api/images/${encodeURIComponent(image.id)}/thumbnail`; + const thumbnailUrl = viewerThumbnailUrl(image); const retryQuery = attempt ? `retry=${attempt}` : ""; const imageUrl = retryQuery ? `${thumbnailUrl}?${retryQuery}` : thumbnailUrl; const markLoaded = useCallback(() => { window.clearTimeout(retryTimerRef.current); - READY_THUMBNAIL_IDS.add(image.id); + rememberReadyThumbnail(image.id); setLoaded(true); setFailed(false); setRetrying(false); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ddc503d..4306d45 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,7 @@ import type { + Album, BootstrapData, + GalleryImage, GallerySummary, ImagesPage, SortMode, @@ -8,7 +10,77 @@ import type { const REQUEST_TIMEOUT_MS = 15_000; -async function getJson(url: string, signal?: AbortSignal): Promise { +type JsonObject = Record; +type JsonValidator = (value: unknown) => value is T; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isPositiveInteger(value: unknown): value is number { + return isNonNegativeInteger(value) && value > 0; +} + +function isAlbum(value: unknown): value is Album { + return isObject(value) + && typeof value.path === "string" + && typeof value.name === "string" + && isNonNegativeInteger(value.count); +} + +function isGalleryImage(value: unknown): value is GalleryImage { + return isObject(value) + && typeof value.id === "string" + && typeof value.name === "string" + && isPositiveInteger(value.width) + && isPositiveInteger(value.height); +} + +function isGallerySummary(value: unknown): value is GallerySummary { + return isObject(value) + && isNonNegativeInteger(value.total) + && Array.isArray(value.albums) + && value.albums.every(isAlbum) + && typeof value.revision === "string"; +} + +function isImagesPage(value: unknown): value is ImagesPage { + return isObject(value) + && Array.isArray(value.items) + && value.items.every(isGalleryImage) + && isNonNegativeInteger(value.total) + && isNonNegativeInteger(value.offset) + && isPositiveInteger(value.limit) + && (value.nextOffset === null || isNonNegativeInteger(value.nextOffset)); +} + +function isThumbnailStatus(value: unknown): value is ThumbnailStatus { + return isObject(value) + && isNonNegativeInteger(value.total) + && isNonNegativeInteger(value.ready) + && isNonNegativeInteger(value.queued) + && isNonNegativeInteger(value.processing) + && isNonNegativeInteger(value.failed) + && typeof value.initialBatchReady === "boolean" + && typeof value.backgroundComplete === "boolean"; +} + +function isBootstrapData(value: unknown): value is BootstrapData { + return isObject(value) + && isGallerySummary(value.summary) + && isThumbnailStatus(value.status) + && isImagesPage(value.images); +} + +async function getJson( + url: string, + validate: JsonValidator, + signal?: AbortSignal, +): Promise { const controller = new AbortController(); let timedOut = false; const abortFromCaller = () => controller.abort(signal?.reason); @@ -35,12 +107,15 @@ async function getJson(url: string, signal?: AbortSignal): Promise { if (!response.headers.get("content-type")?.includes("application/json")) { throw new Error("服务器返回了无效数据"); } + let payload: unknown; try { - return (await response.json()) as T; + payload = await response.json(); } catch (error) { if (controller.signal.aborted) throw error; throw new Error("服务器返回了无效数据", { cause: error }); } + if (!validate(payload)) throw new Error("服务器返回了无效数据"); + return payload; } catch (error) { if (timedOut) throw new Error("请求超时,请稍后重试", { cause: error }); throw error; @@ -57,7 +132,8 @@ export function takeInitialBootstrap(): BootstrapData | null { try { const source = element.textContent?.trim(); if (!source || source.startsWith("__PIXHELF_")) return null; - return JSON.parse(source) as BootstrapData; + const payload: unknown = JSON.parse(source); + return isBootstrapData(payload) ? payload : null; } catch { return null; } finally { @@ -66,11 +142,11 @@ export function takeInitialBootstrap(): BootstrapData | null { } export function getGallery(signal?: AbortSignal): Promise { - return getJson("/api/gallery", signal); + return getJson("/api/gallery", isGallerySummary, signal); } export function getStatus(signal?: AbortSignal): Promise { - return getJson("/api/status", signal); + return getJson("/api/status", isThumbnailStatus, signal); } export function getImages( @@ -92,5 +168,5 @@ export function getImages( if (options.album) params.set("album", options.album); if (options.search) params.set("search", options.search); if (options.seed) params.set("seed", options.seed); - return getJson(`/api/images?${params}`, signal); + return getJson(`/api/images?${params}`, isImagesPage, signal); } diff --git a/frontend/src/viewerAssets.ts b/frontend/src/viewerAssets.ts index 27a9528..7b929e5 100644 --- a/frontend/src/viewerAssets.ts +++ b/frontend/src/viewerAssets.ts @@ -5,6 +5,7 @@ const ORIGINAL_CACHE_LIMIT = 4; const VIEWPORT_RENDER_CACHE_LIMIT = 4; const THUMBNAIL_PRELOAD_DISTANCE = 10; const ORIGINAL_PRELOAD_DISTANCE = 2; +const ASSET_LOAD_TIMEOUT_MS = 30_000; type AssetPriority = "high" | "low"; @@ -117,9 +118,11 @@ function loadImageAsset( lastUsed: ++assetClock, }; let settled = false; + let timeout = 0; const settle = (status: Exclude) => { if (settled) return; settled = true; + window.clearTimeout(timeout); asset.status = status; asset.lastUsed = ++assetClock; resolveAsset(); @@ -136,6 +139,11 @@ function loadImageAsset( element.addEventListener("error", () => settle("failed"), { once: true }); cache.set(url, asset); element.src = url; + timeout = window.setTimeout(() => { + if (settled) return; + element.removeAttribute("src"); + settle("failed"); + }, ASSET_LOAD_TIMEOUT_MS); pruneCache(cache, limit, url); return asset; } @@ -237,6 +245,7 @@ function startNextViewportRenderJob(): void { activeViewportRenderJob = job; void (async () => { const { asset, attempt, controller, image, key, resolve } = job; + const timeout = window.setTimeout(() => controller.abort(), ASSET_LOAD_TIMEOUT_MS); try { const requestOptions: RequestInit & { priority: "high" | "low" } = { cache: attempt ? "reload" : "force-cache", @@ -246,6 +255,9 @@ function startNextViewportRenderJob(): void { }; const response = await fetch(viewerOriginalUrl(image, attempt), requestOptions); if (!response.ok) throw new Error(`original request failed: ${response.status}`); + if (!response.headers.get("content-type")?.startsWith("image/")) { + throw new Error("original response is not an image"); + } const blob = await response.blob(); if (!blob.size) throw new Error("original response is empty"); const bitmap = await createImageBitmap(blob, { @@ -263,6 +275,7 @@ function startNextViewportRenderJob(): void { } catch { asset.status = "failed"; } finally { + window.clearTimeout(timeout); if (job.cancelled) { asset.bitmap?.close(); asset.bitmap = null; diff --git a/src/gallery.rs b/src/gallery.rs index d241a95..3363eab 100644 --- a/src/gallery.rs +++ b/src/gallery.rs @@ -6,7 +6,7 @@ use std::{ time::UNIX_EPOCH, }; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, bail}; use image::{ImageDecoder, ImageReader, metadata::Orientation}; use serde::Serialize; use tracing::warn; @@ -27,6 +27,46 @@ pub struct ImageRecord { pub(crate) modified_ns: u128, } +impl ImageRecord { + pub(crate) fn matches_metadata(&self, metadata: &fs::Metadata) -> bool { + let fingerprint = FileFingerprint::from_metadata(metadata); + metadata.is_file() + && self.size == fingerprint.size + && self.modified_ns == fingerprint.modified_ns + } + + pub(crate) fn ensure_source_is_current(&self) -> Result<()> { + let metadata = fs::metadata(&self.path) + .with_context(|| format!("cannot inspect {}", self.path.display()))?; + if !self.matches_metadata(&metadata) { + bail!("source image changed since the gallery scan"); + } + Ok(()) + } +} + +#[derive(Clone, Copy)] +struct FileFingerprint { + size: u64, + modified_ms: u64, + modified_ns: u128, +} + +impl FileFingerprint { + fn from_metadata(metadata: &fs::Metadata) -> Self { + let modified = metadata + .modified() + .unwrap_or(UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Self { + size: metadata.len(), + modified_ms: modified.as_millis().min(u128::from(u64::MAX)) as u64, + modified_ns: modified.as_nanos(), + } + } +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Album { @@ -35,16 +75,20 @@ pub struct Album { pub count: usize, } -#[derive(Clone, Default)] +#[derive(Default)] pub struct GalleryIndex { pub images: Vec>, pub albums: Vec, pub revision: String, + images_by_id: Vec>, } impl GalleryIndex { - pub fn same_revision(&self, other: &Self) -> bool { - self.revision == other.revision + pub fn image(&self, id: &str) -> Option<&Arc> { + self.images_by_id + .binary_search_by(|image| image.id.as_str().cmp(id)) + .ok() + .map(|index| &self.images_by_id[index]) } } @@ -94,17 +138,11 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result) -> Result) -> Result) -> Result Result<()> { let config = Config::from_env_and_args()?; info!(gallery = %config.gallery_dir.display(), "scanning gallery"); - let gallery_dir = config.gallery_dir.clone(); - let index = tokio::task::spawn_blocking(move || scan_gallery(&gallery_dir, None)) - .await - .context("gallery scan task stopped")??; + let index = scan_gallery_async(config.gallery_dir.clone(), None).await?; if index.images.is_empty() { warn!( gallery = %config.gallery_dir.display(), @@ -48,7 +45,7 @@ async fn main() -> Result<()> { .take(config.initial_batch) .map(|record| record.id.clone()) .collect(); - let index = Arc::new(RwLock::new(index)); + let index = Arc::new(RwLock::new(Arc::new(index))); let thumbnails = ThumbnailManager::new(config.cache_dir.clone())?; { let current = index.read().await; @@ -86,38 +83,40 @@ async fn main() -> Result<()> { async fn rescan_gallery(state: AppState, config: Config) { loop { tokio::time::sleep(config.scan_interval).await; - let previous = state.index.read().await.clone(); + let previous = { + let index = state.index.read().await; + Arc::clone(&index) + }; + let old_count = previous.images.len(); let root = config.gallery_dir.clone(); - let scan = tokio::task::spawn_blocking(move || { - let updated = scan_gallery(&root, Some(&previous)); - (previous, updated) - }) - .await; - - let (previous, updated) = match scan { - Ok((previous, Ok(updated))) => (previous, updated), - Ok((_, Err(error))) => { + let updated = match scan_gallery_async(root, Some(Arc::clone(&previous))).await { + Ok(updated) => updated, + Err(error) => { error!(%error, "gallery rescan failed"); continue; } - Err(error) => { - error!(%error, "gallery rescan task stopped"); - continue; - } }; - if previous.same_revision(&updated) { + if previous.revision == updated.revision { continue; } - let old_count = previous.images.len(); let new_count = updated.images.len(); state.thumbnails.reconcile(&updated.images); - *state.index.write().await = updated; + *state.index.write().await = Arc::new(updated); state.thumbnails.cleanup_stale().await; info!(old_count, new_count, "gallery changes indexed"); } } +async fn scan_gallery_async( + root: PathBuf, + previous: Option>, +) -> Result { + tokio::task::spawn_blocking(move || scan_gallery(&root, previous.as_deref())) + .await + .context("gallery scan task stopped")? +} + async fn shutdown_signal() { #[cfg(unix)] { diff --git a/src/thumbs.rs b/src/thumbs.rs index 3997dfb..945461b 100644 --- a/src/thumbs.rs +++ b/src/thumbs.rs @@ -27,7 +27,6 @@ use crate::gallery::ImageRecord; const THUMBNAIL_EDGE: u32 = 720; const WEBP_QUALITY: f32 = 82.0; const CACHE_VERSION: &str = "720-webp-q82-v1"; -const THUMBNAIL_FILTER: FilterType = FilterType::Triangle; const LEGACY_VIEWER_CACHE_VERSION: &str = "viewer-3200-webp-q88-fast-v1"; const MAX_ATTEMPTS: u8 = 3; @@ -501,24 +500,7 @@ fn shard_path(root: &Path, id: &str) -> PathBuf { } fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> { - generate_image_cache( - record, - output, - THUMBNAIL_EDGE, - THUMBNAIL_FILTER, - WEBP_QUALITY, - "thumbnail", - ) -} - -fn generate_image_cache( - record: &ImageRecord, - output: &Path, - max_edge: u32, - filter: FilterType, - quality: f32, - label: &str, -) -> Result<()> { + record.ensure_source_is_current()?; let reader = ImageReader::open(&record.path) .with_context(|| format!("cannot open {}", record.path.display()))? .with_guessed_format()?; @@ -527,13 +509,22 @@ fn generate_image_cache( let mut image = DynamicImage::from_decoder(decoder)?; image.apply_orientation(orientation); let (width, height) = image.dimensions(); - let image = if width.max(height) > max_edge { - image.resize(max_edge, max_edge, filter) + let image = if width.max(height) > THUMBNAIL_EDGE { + image.resize(THUMBNAIL_EDGE, THUMBNAIL_EDGE, FilterType::Triangle) } else { image }; - write_webp(&image, output, quality) - .with_context(|| format!("cannot store {label}: {}", output.display())) + write_webp(&image, output, WEBP_QUALITY) + .with_context(|| format!("cannot store thumbnail: {}", output.display()))?; + if let Err(error) = record.ensure_source_is_current() { + if let Err(remove_error) = fs::remove_file(output) + && remove_error.kind() != std::io::ErrorKind::NotFound + { + warn!(path = %output.display(), %remove_error, "cannot remove stale thumbnail"); + } + return Err(error); + } + Ok(()) } fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> { @@ -712,7 +703,7 @@ mod tests { } #[tokio::test] - async fn failed_thumbnail_can_be_retried() { + async fn changed_source_requires_a_fresh_gallery_record() { let temp = tempfile::tempdir().unwrap(); let gallery = temp.path().join("gallery"); fs::create_dir(&gallery).unwrap(); @@ -724,14 +715,19 @@ mod tests { let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap(); manager.reconcile(&index.images); manager.start_workers(1); - let id = &index.images[0].id; + let old_id = index.images[0].id.clone(); assert!(matches!( - manager.ensure_ready(id).await, + manager.ensure_ready(&old_id).await, Err(ThumbnailError::Generation(_)) )); image::RgbImage::new(20, 10).save(&image_path).unwrap(); - let thumbnail = manager.ensure_ready(id).await.unwrap(); + let updated = scan_gallery(&gallery, Some(&index)).unwrap(); + let new_id = updated.images[0].id.clone(); + assert_ne!(new_id, old_id); + manager.reconcile(&updated.images); + + let thumbnail = manager.ensure_ready(&new_id).await.unwrap(); assert!(valid_cache_file(&thumbnail)); } diff --git a/src/web.rs b/src/web.rs index a7213ce..da9853e 100644 --- a/src/web.rs +++ b/src/web.rs @@ -14,6 +14,7 @@ use tower::ServiceExt; use tower_http::{ compression::{CompressionLayer, CompressionLevel}, services::ServeFile, + set_header::SetResponseHeaderLayer, trace::TraceLayer, }; use tracing::error; @@ -36,11 +37,13 @@ const APP_CSS: &[u8] = include_bytes!(concat!( "/frontend/dist/assets/app.css" )); const ASSET_VERSION: &str = env!("PIXHELF_ASSET_VERSION"); -const BOOTSTRAP_PAGE_SIZE: usize = 60; +const DEFAULT_PAGE_SIZE: usize = 60; +const MAX_PAGE_SIZE: usize = 200; +const MAX_QUERY_VALUE_BYTES: usize = 4096; #[derive(Clone)] pub struct AppState { - pub index: Arc>, + pub index: Arc>>, pub thumbnails: Arc, } @@ -56,7 +59,60 @@ struct ImagesQuery { limit: Option, } +#[derive(Debug)] +struct ImageRequest { + album: Option, + search: Option, + sort: ImageSort, + seed: String, + offset: usize, + limit: usize, +} + +impl ImagesQuery { + fn normalize(self) -> Result { + if [&self.album, &self.search, &self.seed] + .into_iter() + .flatten() + .any(|value| value.len() > MAX_QUERY_VALUE_BYTES) + { + return Err(StatusCode::BAD_REQUEST); + } + + Ok(ImageRequest { + album: self.album.filter(|album| !album.is_empty()), + search: self + .search + .as_deref() + .map(str::trim) + .filter(|search| !search.is_empty()) + .map(str::to_lowercase), + sort: self.sort, + seed: self.seed.unwrap_or_default(), + offset: self.offset.unwrap_or(0), + limit: self + .limit + .unwrap_or(DEFAULT_PAGE_SIZE) + .clamp(1, MAX_PAGE_SIZE), + }) + } +} + +impl Default for ImageRequest { + fn default() -> Self { + Self { + album: None, + search: None, + sort: ImageSort::default(), + seed: String::new(), + offset: 0, + limit: DEFAULT_PAGE_SIZE, + } + } +} + #[derive(Clone, Copy, Debug, Default, Deserialize)] +#[repr(u8)] #[serde(rename_all = "kebab-case")] enum ImageSort { #[default] @@ -123,6 +179,18 @@ pub fn router(state: AppState) -> Router { .route("/assets/{version}/app.css", get(app_css)) .fallback(frontend) .layer(CompressionLayer::new().quality(CompressionLevel::Precise(5))) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + )) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -138,9 +206,11 @@ async fn gallery_summary(State(state): State, headers: HeaderMap) -> R return not_modified(&etag, "private, no-cache"); } - let mut response = Json(summary_from_index(&index)).into_response(); - let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); - response + with_cache_headers( + Json(summary_from_index(&index)).into_response(), + &etag, + "private, no-cache", + ) } fn summary_from_index(index: &GalleryIndex) -> GallerySummary { @@ -156,27 +226,28 @@ async fn images( Query(query): Query, headers: HeaderMap, ) -> Response { + let query = match query.normalize() { + Ok(query) => query, + Err(status) => return status.into_response(), + }; let index = state.index.read().await; let etag = images_etag(&index.revision, &query); if is_not_modified(&headers, &etag) { return not_modified(&etag, "private, no-cache"); } - let mut response = Json(image_page(&index, &query)).into_response(); - let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); - response + with_cache_headers( + Json(image_page(&index, &query)).into_response(), + &etag, + "private, no-cache", + ) } -fn image_page(index: &GalleryIndex, query: &ImagesQuery) -> ImagesResponse { - let search = query - .search - .as_deref() - .map(str::trim) - .filter(|search| !search.is_empty()) - .map(str::to_lowercase); - let album = query.album.as_deref().filter(|album| !album.is_empty()); - let requested_offset = query.offset.unwrap_or(0); - let limit = query.limit.unwrap_or(60).clamp(1, 200); +fn image_page(index: &GalleryIndex, query: &ImageRequest) -> ImagesResponse { + let album = query.album.as_deref(); + let search = query.search.as_deref(); + let requested_offset = query.offset; + let limit = query.limit; match query.sort { ImageSort::NameAsc if album.is_none() && search.is_none() => { @@ -188,14 +259,14 @@ fn image_page(index: &GalleryIndex, query: &ImagesQuery) -> ImagesResponse { ImageSort::NameAsc => page_from_ordered_records( index.images.iter().map(Arc::as_ref), album, - search.as_deref(), + search, requested_offset, limit, ), ImageSort::NameDesc => page_from_ordered_records( index.images.iter().rev().map(Arc::as_ref), album, - search.as_deref(), + search, requested_offset, limit, ), @@ -204,15 +275,13 @@ fn image_page(index: &GalleryIndex, query: &ImagesQuery) -> ImagesResponse { .images .iter() .map(Arc::as_ref) - .filter(|record| record_matches(record, album, search.as_deref())) + .filter(|record| record_matches(record, album, search)) .collect(); match query.sort { ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)), ImageSort::Explore => { - let seed_key = - *blake3::hash(query.seed.as_deref().unwrap_or_default().as_bytes()) - .as_bytes(); + let seed_key = *blake3::hash(query.seed.as_bytes()).as_bytes(); matches.sort_by_cached_key(|record| exploration_rank(&seed_key, &record.id)); } ImageSort::NameAsc | ImageSort::NameDesc => unreachable!(), @@ -336,20 +405,27 @@ async fn original( request: Request, ) -> Result { // Resolve the opaque id through the index instead of accepting a filesystem path - // from the request. Cloning the path also avoids holding the read lock while the - // file is streamed to a slow client. - let path = { + // from the request. Cloning the record releases the read lock before filesystem I/O. + let image = { let index = state.index.read().await; - index - .images - .iter() - .find(|image| image.id == id) - .map(|image| image.path.clone()) - .ok_or(StatusCode::NOT_FOUND)? + index.image(&id).cloned().ok_or(StatusCode::NOT_FOUND)? }; + let metadata = match tokio::fs::metadata(&image.path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(StatusCode::NOT_FOUND); + } + Err(error) => { + error!(path = %image.path.display(), %error, "cannot inspect original image"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + if !image.matches_metadata(&metadata) { + return Err(StatusCode::NOT_FOUND); + } serve_file( - path, + image.path.clone(), request, &format!("\"original-{id}\""), "public, max-age=31536000, immutable", @@ -363,15 +439,8 @@ async fn serve_file( etag: &str, cache_control: &'static str, ) -> Result { - if request - .headers() - .get(header::IF_NONE_MATCH) - .and_then(|value| value.to_str().ok()) - == Some(etag) - { - let mut response = StatusCode::NOT_MODIFIED.into_response(); - add_cache_headers(&mut response, etag, cache_control)?; - return Ok(response); + if is_not_modified(request.headers(), etag) { + return Ok(not_modified(etag, cache_control)); } let response = ServeFile::new(path) @@ -379,36 +448,34 @@ async fn serve_file( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (parts, body) = response.into_parts(); - let mut response = Response::from_parts(parts, Body::new(body)); - add_cache_headers(&mut response, etag, cache_control)?; - Ok(response) + Ok(with_cache_headers( + Response::from_parts(parts, Body::new(body)), + etag, + cache_control, + )) } -fn add_cache_headers( - response: &mut Response, - etag: &str, - cache_control: &'static str, -) -> Result<(), StatusCode> { +fn with_cache_headers(mut response: Response, etag: &str, cache_control: &'static str) -> Response { + let etag = match HeaderValue::from_str(etag) { + Ok(etag) => etag, + Err(error) => { + error!(%error, "cannot create ETag response header"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(cache_control), ); - response.headers_mut().insert( - header::ETAG, - HeaderValue::from_str(etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, - ); - response.headers_mut().insert( - header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - Ok(()) + response.headers_mut().insert(header::ETAG, etag); + response } fn revision_etag(kind: &str, revision: &str) -> String { format!("\"{kind}-{revision}\"") } -fn images_etag(revision: &str, query: &ImagesQuery) -> String { +fn images_etag(revision: &str, query: &ImageRequest) -> String { let mut hasher = blake3::Hasher::new(); hasher.update(b"pixhelf-images-query-v1\0"); hasher.update(query.album.as_deref().unwrap_or_default().as_bytes()); @@ -416,9 +483,9 @@ fn images_etag(revision: &str, query: &ImagesQuery) -> String { hasher.update(query.search.as_deref().unwrap_or_default().as_bytes()); hasher.update(b"\0"); hasher.update(&[query.sort as u8]); - hasher.update(query.seed.as_deref().unwrap_or_default().as_bytes()); - hasher.update(&query.offset.unwrap_or_default().to_le_bytes()); - hasher.update(&query.limit.unwrap_or(60).to_le_bytes()); + hasher.update(query.seed.as_bytes()); + hasher.update(&query.offset.to_le_bytes()); + hasher.update(&query.limit.to_le_bytes()); let query_hash = hasher.finalize().to_hex(); format!("\"images-{revision}-{}\"", &query_hash[..16]) } @@ -431,14 +498,16 @@ fn is_not_modified(headers: &HeaderMap, etag: &str) -> bool { values .split(',') .map(str::trim) - .any(|value| value == etag || value == "*") + .any(|value| value == "*" || value.strip_prefix("W/").unwrap_or(value) == etag) }) } fn not_modified(etag: &str, cache_control: &'static str) -> Response { - let mut response = StatusCode::NOT_MODIFIED.into_response(); - let _ = add_cache_headers(&mut response, etag, cache_control); - response + with_cache_headers( + StatusCode::NOT_MODIFIED.into_response(), + etag, + cache_control, + ) } async fn app_js(Path(version): Path, headers: HeaderMap) -> Response { @@ -480,8 +549,7 @@ fn embedded_asset( response .headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - let _ = add_cache_headers(&mut response, &etag, "public, max-age=31536000, immutable"); - response + with_cache_headers(response, &etag, "public, max-age=31536000, immutable") } async fn frontend(State(state): State, request: Request) -> Response { @@ -501,13 +569,7 @@ async fn frontend(State(state): State, request: Request) -> Response { return not_modified(&etag, "private, no-cache"); } let summary = summary_from_index(&index); - let images = image_page( - &index, - &ImagesQuery { - limit: Some(BOOTSTRAP_PAGE_SIZE), - ..ImagesQuery::default() - }, - ); + let images = image_page(&index, &ImageRequest::default()); let preload = images.items.first().map_or_else(String::new, |image| { let url = format!("/api/images/{}/thumbnail", image.id); @@ -554,8 +616,7 @@ async fn frontend(State(state): State, request: Request) -> Response { header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); - let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); - response + with_cache_headers(response, &etag, "private, no-cache") } fn escape_script_json(json: String) -> String { @@ -574,7 +635,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let thumbnails = ThumbnailManager::new(temp.path().to_owned()).unwrap(); router(AppState { - index: Arc::new(RwLock::new(GalleryIndex::default())), + index: Arc::new(RwLock::new(Arc::new(GalleryIndex::default()))), thumbnails, }) } @@ -628,6 +689,30 @@ mod tests { ); } + #[test] + fn normalizes_image_queries_once() { + let query = ImagesQuery { + album: Some("album".into()), + search: Some(" PHOTO ".into()), + offset: Some(usize::MAX), + limit: Some(usize::MAX), + ..ImagesQuery::default() + } + .normalize() + .unwrap(); + + assert_eq!(query.album.as_deref(), Some("album")); + assert_eq!(query.search.as_deref(), Some("photo")); + assert_eq!(query.offset, usize::MAX); + assert_eq!(query.limit, MAX_PAGE_SIZE); + + let oversized = ImagesQuery { + search: Some("x".repeat(MAX_QUERY_VALUE_BYTES + 1)), + ..ImagesQuery::default() + }; + assert_eq!(oversized.normalize().unwrap_err(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn rejects_unknown_sort_modes() { assert_eq!( @@ -658,7 +743,14 @@ mod tests { request("/api/images/missing/original").await.status(), StatusCode::NOT_FOUND ); - assert_eq!(request("/missing").await.status(), StatusCode::NOT_FOUND); + let response = request("/missing").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers()[header::X_CONTENT_TYPE_OPTIONS], + "nosniff" + ); + assert_eq!(response.headers()[header::X_FRAME_OPTIONS], "DENY"); + assert_eq!(response.headers()[header::REFERRER_POLICY], "no-referrer"); } #[tokio::test] @@ -674,7 +766,7 @@ mod tests { let thumbnails = ThumbnailManager::new(temp.path().join("cache")).unwrap(); thumbnails.reconcile(&index.images); let app = router(AppState { - index: Arc::new(RwLock::new(index)), + index: Arc::new(RwLock::new(Arc::new(index))), thumbnails, }); @@ -699,6 +791,35 @@ mod tests { assert!(!body.is_empty()); } + #[tokio::test] + async fn stale_image_ids_do_not_serve_replaced_files() { + let temp = tempfile::tempdir().unwrap(); + let gallery = temp.path().join("gallery"); + std::fs::create_dir(&gallery).unwrap(); + let image_path = gallery.join("image.png"); + image::RgbImage::new(20, 10).save(&image_path).unwrap(); + let index = crate::gallery::scan_gallery(&gallery, None).unwrap(); + let id = index.images[0].id.clone(); + let thumbnails = ThumbnailManager::new(temp.path().join("cache")).unwrap(); + thumbnails.reconcile(&index.images); + let app = router(AppState { + index: Arc::new(RwLock::new(Arc::new(index))), + thumbnails, + }); + std::fs::write(&image_path, "replacement with a different size").unwrap(); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/images/{id}/original")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn versioned_assets_are_immutable() { let response = request(&format!("/assets/{ASSET_VERSION}/app.js")).await; @@ -752,5 +873,17 @@ mod tests { response.headers()[header::CACHE_CONTROL], "private, no-cache" ); + + let weak_response = empty_router() + .oneshot( + Request::builder() + .uri("/api/gallery") + .header(header::IF_NONE_MATCH, "W/\"gallery-\"") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(weak_response.status(), StatusCode::NOT_MODIFIED); } }