Commit Graph

605 Commits

Author SHA1 Message Date
Edouard Vanbelle cb7b653a15 feat(webdav): bind dead prop to res. id rather path 2026-06-30 23:35:25 +02:00
Edouard Vanbelle d52c1a2397 fix(webdav): partial fix: remove dead props on del
ensure that dead properties are correctly deleted on resource deletion

    **IMPORTANT**: this is a partial fix:
    dead properties are not deleted if resource is deleted from API !
    code need to be reviewed to attach property directly to resource + use on delete cascade
2026-06-30 21:00:02 +02:00
Edouard Vanbelle 5631b7e062 fix(sql macro): permit code to compile 2026-06-30 19:46:19 +02:00
M.Schmidt 6ca6649e50 fix(webdav): achieve 100% litmus compliance (59/59 tests)
Complete RFC 4918 litmus test suite compliance (basic 16/16,
copymove 13/13, props 30/30) by fixing six categories of failures:

- PropPatchOp: parse PROPPATCH in document order (RFC 4918 §9.2)
  so that remove-then-set and set-then-remove yield different results
- Null namespace: resolve_name now correctly maps xmlns="" to empty-ns
  QualifiedName instead of falling through to DAV: namespace
- Unicode chars: handle quick-xml 0.39's Event::GeneralRef for character
  references (𐀀) — the parser emits these as GeneralRef, not Text
- MOVE preserves dead props: call rename_resource on MOVE, clearing
  stale destination data even when source has no registered properties
- Malformed PROPFIND: return 400 when body lacks a complete <propfind>
  element (test 2: bare <foo>, test 3: invalid xmlns:prefix="" binding)
- PROPPATCH document order: process ops via Vec<PropPatchOp> instead of
  separate (sets, removes) to honour interleaved remove/set sequences
2026-06-30 08:58:13 +02:00
M.Schmidt 43cf4a2b2b fix(webdav): RFC 4918 handler compliance (COPY/MOVE/MKCOL/PROPFIND/PROPPATCH)
- COPY: return 403 on self-copy, 409 when destination parent missing,
  409 (not 500) when overwriting a locked or conflicting resource
- MOVE: same parent-missing and conflict handling as COPY
- MKCOL: return 405 when collection already exists, 409 when parent
  is missing (no auto-creation of ancestors)
- PUT: return 201 Created for new resources, 204 No Content for overwrites
- PROPFIND: use client-facing URI path (not internal home-folder path)
  for DAV:href values so responses match the request URI
- PROPFIND: include dead properties from DeadPropertyStore in responses
- PROPPATCH: persist set/remove operations to DeadPropertyStore
2026-06-29 22:33:09 +02:00
M.Schmidt 1cf48b0dfa fix(webdav): RFC 4918 PROPFIND adapter compliance
- Return unknown properties in a separate 404 propstat (RFC 4918 §9.2)
  via folder_prop_is_known/file_prop_is_known classifiers
- Detect unclosed <propfind> element and return 400 Bad Request
- Add write_folder/file_entry_with_dead_props to include stored dead
  properties in PROPFIND responses
- Change write_folder/file_requested_props to accept &[&QualifiedName]
  to avoid clone overhead when using partition output
2026-06-28 23:51:38 +02:00
M.Schmidt b5afa1a855 feat(webdav): implement RFC 4918 dead property store
Add an in-memory DeadPropertyStore backed by RwLock<HashMap> that
stores arbitrary client-supplied XML properties per resource path.
Wire it into AppState so PROPPATCH can persist dead props and PROPFIND
can retrieve them across requests.
2026-06-26 21:19:03 +02:00
M.Schmidt 5a001c4120 fix(webdav): remove spurious calendar_rest_handler module declaration
The declaration was brought in by a cherry-pick but that handler does
not exist on this branch, causing a compilation error.
2026-06-25 22:44:27 +02:00
M.Schmidt 5913fff047 feat(webdav): RFC 4918 prop classification and 404 propstat 2026-06-24 23:08:51 +02:00
M.Schmidt f9999cdd0f fix(webdav): RFC 4918 litmus compliance 2026-06-22 21:37:14 +02:00
Edouard Vanbelle 934d6b4af0 feat(drive): NC chunked upload respect drive's quota
personal drive are based on user's quota
2026-06-29 23:21:17 +02:00
Edouard Vanbelle 583af517c2 fix(drive): fix drive_id on copy to other drive
- fix alsot drive_id exposition
2026-06-29 23:21:17 +02:00
Edouard Vanbelle ee92d365b9 feat(drive): ensure drive_id updated on file|folder moved to another drive 2026-06-29 21:11:40 +02:00
Edouard Vanbelle 324fcd486f fix(oidc): fix user's picture copy when OIDC login 2026-06-28 23:20:02 +02:00
Edouard Vanbelle 66f2aaa250 feat(drive): add drive policie
- add policy forbid_external_sharing
    - add policy forbid_sharing
    - add polocy forbid_cross_drive_move
    - add policy forbid_owner_role_change
2026-06-26 14:04:36 +02:00
Edouard Vanbelle ddb131da8b feat(drive): add policy forbid_public_links 2026-06-26 13:59:02 +02:00
Edouard Vanbelle cfd783cbd3 feat(drive): personal drives have the user's quota in commun 2026-06-26 13:59:02 +02:00
Dionisio Pozo 996ace8a1d Merge pull request #524 from EdouardVanbelle/fix/cascading-deletion-and-file-life-cycle
fix(fileLifeCycle): add missing on_file_deleted() with cascading deletion
2026-06-26 12:35:25 +02:00
Edouard Vanbelle f4032e9e23 feat(recent): update recent list server side
initially the recent was done client side
    recent files are now directly updated on serverside when accessing a file

    note: nextcloud and webdav voluntary not included
2026-06-26 01:44:19 +02:00
Edouard Vanbelle fefba83cfc fix(fileLifeCycle): add missing on_file_deleted with cascading del
on_file_deleted() was missing on folder deletion or on cascading deletion
2026-06-25 23:36:50 +02:00
Edouard Vanbelle 81d50bde33 fix(GC): race condition in dedup_service
Stamp `orphaned_at = now()` on the ref-0 row so it sits inside the
     GC grace window for the duration of this test. Without it,
     `orphaned_at IS NULL` is treated by `garbage_collect` as
     "pre-migration, immediately reapable" — and any sibling test in
     the shared pool that calls `garbage_collect()` (e.g.
     `garbage_collect_respects_grace_and_cross_checks`) would race
     with the pin below and delete the row first.
2026-06-25 19:00:19 +02:00
Edouard Vanbelle 2604ac3309 refactor: format syntaxt 2026-06-25 00:07:16 +02:00
Edouard Vanbelle 8387473b26 bigfix(gc): fix leak with GC and blob lifecycle
call fire_blob_hooks to respect lifecycle

    dedup_service::garbage_collect_with_grac must call fire_blob_hooks() once blob are dropped
    so other services like thumbnail can proceed to their cleanup
2026-06-24 23:51:25 +02:00
Edouard Vanbelle 90474aa885 chore(test): blob lifecyclc with thumbnail cleanup
renable thumbnail test, ensure that blob lifecycle correctly
    trigger thumbnail cleanup on blob deletion

    need to call `/api/admin/internal/trigger-gc?force=true`
2026-06-24 23:50:37 +02:00
Edouard Vanbelle 6b8e2ba49c test(drive): check quota calculus 2026-06-24 23:01:34 +02:00
Edouard Vanbelle a9a8604322 feat(drive): add quota calculus per drive
- quota per drive
    - add 2 internal API endpoints to test purpose
        disabled by default, enable it via `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`

        this enable:

            /api/admin/internal/trigger-sweep
                to sweep the trash and recalculated quota

            /api/admin/internal/trigger-gc
                to garbage orphan blobs

        use full for end to end tests and validate lifecycles
2026-06-24 22:28:31 +02:00
Edouard Vanbelle b73f176024 feat(drive): permanent deletion per drive 2026-06-24 21:56:27 +02:00
Edouard Vanbelle 7d24015fc4 feat(drive): add drive deletion
- conditions: drive must be empty
    - deletion forbidden on main personal drive
2026-06-24 21:17:27 +02:00
Edouard Vanbelle 754e6239da fix(drive): correct integration test 2026-06-24 08:02:40 +02:00
Edouard Vanbelle 289cf19270 feat(drive): user can rename drive
- only owners can rename root folders name (aka the drive name)
    - add UI to rename drive's name
2026-06-24 02:03:21 +02:00
Edouard Vanbelle 6034dd47d9 feat(drive): improve drive edition from owners 2026-06-24 02:03:17 +02:00
Edouard Vanbelle d77846119f feat(drive): UI: add drive edition for admin 2026-06-24 01:20:44 +02:00
Edouard Vanbelle a5b24a7453 feat(drive): add /api/drive
- permit shared drive creation from oxicloud admin (for now)
    - prepare other personal drive creation (Not implemented), need to validate
    quota policies and strategy first
    - add hurl test to verify permissions
2026-06-23 23:50:57 +02:00
Edouard Vanbelle 184520c17a feat(drive): plug trash to drives 2026-06-23 22:04:20 +02:00
Edouard Vanbelle 4eb9707774 feat(drive): check user grants on drive
use drive's grant whant handling a file or folder
2026-06-23 20:26:31 +02:00
Edouard Vanbelle 2fe9c9ffeb feat(drive): tell UI the caller role per Drive 2026-06-23 00:09:18 +02:00
Edouard Vanbelle a82bae4136 feat(drive): api to handle Drive grants 2026-06-22 23:53:00 +02:00
Claude 601fbdedf9 Revert "perf(blob): one syscall per new chunk in write_blob_bytes"
This reverts ee51b32. The create_new change showed no measurable throughput
benefit — three 9-rep interleaved runs on the same ext4 device swung −12%..+21%
at the 256 KiB CDC size (a negative stat on a warm dentry cache is ~µs, below
the shared-disk noise floor). Applying the same "no change without a measured
win" bar used for the pool-sizing revert: the idiom/TOCTOU angle is real but the
race is already prevented upstream by the PG pin-or-classify serialisation, so
it's defence-in-depth only — not enough to keep an unmeasured change. Reverts
the production change, the bench, and its doc together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 09:42:37 +00:00
Claude ee51b32ba9 perf(blob): one syscall per new chunk in write_blob_bytes (create_new)
Replace the try_exists(stat) + File::create(O_CREAT|O_TRUNC) pair with a single
OpenOptions::create_new (O_CREAT|O_EXCL), treating AlreadyExists as the existing
idempotent skip. One metadata syscall per new chunk instead of two (each a
spawn_blocking round-trip), and O_EXCL closes the check-then-create TOCTOU the
old pair left open (a racing writer could be truncated).

Honest measurement caveat (benches/BLOB-WRITE.md): the wall-clock throughput
effect is BELOW the noise floor of the test environment — three 9-rep
interleaved runs on the same ext4 device swing −12%..+21% at the 256 KiB CDC
size, because a negative stat on a warm dentry cache is ~µs, dwarfed by the
chunk's create+write+flush. So this is justified as a code-quality / correctness
change (canonical idiom, strictly fewer syscalls, closes a TOCTOU, zero
downside), NOT as a benchmarked perf win.

The sibling idea — reusing the File handle for the fsync sweep — is deliberately
NOT done: sync_blobs is a single end-of-stream sweep over all the upload's new
hashes, so retaining handles would hold thousands of FDs open (>ulimit) on a
large upload. The re-open sweep is a deliberate FD-frugal design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 09:40:57 +00:00
Claude 18781022be Revert "perf(pools): size image/rayon pools to the CFS quota"
This reverts the image-pool migration (commit 5629ba6). The bench
(bench_pool_concurrency / POOL-CONCURRENCY.md) measured the one pool it could
isolate — the thumbnail decode semaphore — and found flat throughput, p99 AND
peak RSS (137 MiB) from K=1..16: shrink-on-load already makes each decode
RAM-cheap, so sizing it to the CFS quota gains nothing measurable. Adding code
without a measured benefit isn't worth it.

Kept: the effective_parallelism() helper (it has a *measured* win in the Tokio
runtime — benches/RUNTIME.md) and the benchmark itself (reusable). The ffmpeg
video fan-out has a plausible a-priori case (one OS process per permit) but is
left as a future, deliberately-measured change rather than shipped on
speculation. Doc updated to record the decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 09:04:37 +00:00
Claude 5629ba6200 perf(pools): size image/rayon pools to the CFS quota (effective_parallelism)
The thumbnail decode semaphore, the transcode rayon pool, and the ffmpeg
video-thumbnail fan-out all sized from std::thread::available_parallelism(),
which honours CPU affinity but ignores the CFS bandwidth quota (--cpus /
cgroup cpu.max). Under a container quota they therefore permit one CPU-heavy
task per *host* core onto cores the scheduler can't grant — the same
over-subscription the runtime worker pool had.

Switch all three to common::runtime::effective_parallelism() (= min(affinity,
CFS quota)), and fix the doc comments that wrongly claimed available_parallelism
respects cgroup quotas. No change off-quota (effective == available there); the
env overrides (OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY,
OXICLOUD_VIDEO_THUMBNAIL_CONCURRENCY) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 08:46:24 +00:00
Claude 9c8dede4fd perf(runtime): size the Tokio runtime to the cgroup CPU quota + bound blocking pool
Replace the bare #[tokio::main] with an explicit runtime::Builder so both
pools are sized, logged at startup, and operator-tunable.

#[tokio::main] hides two defaults that misbehave under container limits:
  - worker_threads = available_parallelism(), which honours CPU affinity
    (sched_getaffinity: cpuset, taskset) but IGNORES the CFS bandwidth quota
    (docker --cpus, cgroup v2 cpu.max, v1 cpu.cfs_quota_us). On a 2-core-quota
    container on a 64-core host it spawns 64 workers time-slicing 2 cores.
  - max_blocking_threads = 512, a multi-GB RSS blast radius for this heavy
    spawn_blocking user (thumbnails, transcode, zip, PDF/text extraction,
    Argon2 ~19 MB/hash).

New common::runtime module folds the CFS quota back in: effective_parallelism()
= min(available_parallelism, cgroup quota). runtime_pool_sizes() defaults
workers to that and caps the blocking pool at max(32, 8*workers), both
overridable via OXICLOUD_WORKER_THREADS (or TOKIO_WORKER_THREADS) and
OXICLOUD_MAX_BLOCKING_THREADS. The cgroup v1/v2 parsers are pure + unit-tested.

Note: this corrects the premise that "rayon respects the quota, tokio doesn't"
— the image/rayon pools also use available_parallelism(), so they over-spawn
under a CFS quota too; effective_parallelism() is the reusable fix. Unset env
on an uncontended host reproduces the previous worker count exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 08:28:19 +00:00
Claude 6e26d1c694 refactor: remove dead OptimizedFileContent::Mmap variant
OptimizedFileContent::Mmap was constructed nowhere — the documented "Tier 2:
memory-mapped I/O (10-100 MB)" path was never wired, so optimized_inner only
ever returns Bytes (<10 MB) or Stream (>=10 MB). The variant survived only as
an enum case plus two dead match arms in the file and share download handlers.

Remove the variant and its arms, and fix the now-misleading retrieval-service
tier docs (everything >=10 MB streams via CDC chunk reassembly with the
backend read-ahead; there is no mmap tier). Behaviour is unchanged — the
deleted arms were unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 08:10:54 +00:00
Claude 5b8b740233 perf(blob): small read-ahead for the local backend (read_prefetch 1 -> 2)
The local backend inherited the trait's conservative read_prefetch() = 1
(strictly sequential chunk reassembly), while S3/Azure already use 8. The
prior rationale was that concurrent opens over scattered content-addressed
chunk files turn one sequential read into competing random I/O ("slower
cold"). Benchmarked that assumption with examples/bench_blob_prefetch
(sweeps the buffered(N) depth over a real LocalBlobBackend under disk-bound
vs network-bound consumers and warm vs cold page cache).

Result on SSD-class storage (median MB/s vs N=1):
  warm  disk-bound   N=2 +11.8%   N=8 +3.9%   N=16 -4.4%
  cold  disk-bound   N=2  +7.2%   (no cold regression on SSD)
  network-bound (throttled)  ~0% at any N — the socket, not the disk, caps it

So N=8 is wrong for local (leaves gain on the table, risks HDD seek thrash)
and the network-bound win the analysis assumed doesn't materialize: buffered()
here overlaps the per-chunk File::open (cheap on local disk), not the data
read. N=2 captures most of the disk-bound gain — which covers localhost/LAN
downloads AND the internal blob reads that drain as fast as the disk delivers
(thumbnail render, transcode, ZIP export, content extraction) — at the lowest
fan-out. Env-tunable via OXICLOUD_LOCAL_READ_PREFETCH (set 1 on seek-bound
HDDs to restore the old behaviour; raise on fast NVMe). Signature unchanged,
so all ~16 LocalBlobBackend::new call sites are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5yYZ9s868mJwqT2Qz7ez
2026-06-22 08:10:46 +00:00
Bradley Nelson e3823ce470 test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
2026-06-22 00:05:06 -06:00
DioCrafts 0c40c69f9b perf(photos): ETag/304 conditional revalidation on the timeline
GET /api/photos sent only X-Next-Cursor — no ETag — so every gallery
re-mount rebuilt up to 500 PhotoDtos, serde-serialized the whole vector,
and shipped the full body even when nothing changed.

The handler now emits a lightweight content-derived ETag
(hash of before + limit + max(modified_at) + row count) and honours
If-None-Match, with Cache-Control: private, no-cache so the SPA's default
fetch cache mode always revalidates. An unchanged "navigate away and back"
becomes an empty 304 instead of a full rebuild + reserialize + transfer.
The DB query still runs (the cheap part); the win is skipping the DTO
build, serialization, and body bytes.

Proven end-to-end (throwaway Postgres + server, 7 images):
  1st GET (no If-None-Match)      -> 200  4586 bytes + ETag
  2nd GET (If-None-Match matches) -> 304     0 bytes
  3rd GET (If-None-Match stale)   -> 200  4586 bytes (correctly invalidated)
~655 B/photo, so a full 500-row first page saves ~320 KB + a 500-DTO
build/serialize per unchanged revalidation. Unlike a cold load this is the
common gallery-navigation path, so it hits real user-facing latency.

Regression test: tests/api/photos_etag.hurl (added to the api-test suite).
Methodology in benches/PHOTOS-ETAG.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:22:49 +02:00
DioCrafts f68972e368 perf(blob): one manifest query in read_blob_bytes instead of two
read_blob_bytes read the same storage.chunk_manifests PK row twice per
full-blob read — blob_size (SELECT total_size) then read_blob_stream
(SELECT chunk_hashes) — even though both columns live in one row. Fold
them into a single `SELECT chunk_hashes, total_size` and share the chunk
stream builder via a new stream_chunks helper. The legacy (no-manifest)
path is unchanged. Output is identical; the read just costs one fewer DB
round-trip.

Benchmark (examples/bench_blob_manifest.rs, isolates the manifest lookup
against the real Postgres): ~1.9x throughput and p50/p99 roughly halved on
that sub-step; the win is the removed round-trip under pool pressure during
upload bursts. Note this is the manifest sub-step only — end-to-end
read_blob_bytes is dominated by the actual chunk reads, and it is a
background path (thumbnail generation / EXIF / indexing), not normal gallery
serving. Methodology + honest framing in benches/BLOB-MANIFEST.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:01:56 +02:00
DioCrafts 5722481c4a feat(thumbnails): server-side video thumbnails via ffmpeg
Videos now get a thumbnail generated eagerly server-side on upload, through
the same WebP/blob-hash pipeline as photos — instead of the old browser path
that only ran when the Photos grid first rendered a video tile, re-downloaded
the whole video to seek a frame, and PUT 3 JPEGs back (and produced nothing at
all for HEVC/.mov, which a browser <video> cannot decode).

- New VideoFramePort (application) + FfmpegVideoFrameService / NoopVideoFrameService
  (infrastructure): shell out to the system ffmpeg (no compile-time libav dep),
  extract one representative frame as PNG, bounded by its own semaphore + a
  per-process timeout + kill_on_drop. Noop when ffmpeg is absent/disabled, so
  videos degrade gracefully to no thumbnail.
- ThumbnailRefreshHook.on_file_created routes video/* to
  generate_video_thumbnails_background: stream the (decrypted, reassembled) blob
  to a size- and time-bounded temp file on the data volume, extract a frame, and
  reuse the shared render_and_persist_all_webp helper — so video thumbnails are
  WebP, blob-hash keyed (dedup'd) and content-negotiated, exactly like photos.
- GET thumbnail serves the video's WebP to every client (byte-sniffed
  Content-Type); a genuine miss returns 204.
- Config: OXICLOUD_ENABLE_VIDEO_THUMBNAILS (default true, needs ffmpeg detected
  at startup) + OXICLOUD_FFMPEG_PATH / _CONCURRENCY / _TIMEOUT_SECS / _MAX_MB.
- Dockerfile installs ffmpeg in the runtime image.
- Frontend: drop the client-side generateVideoThumb/frameFromVideo re-download
  path; the server is now the source of truth.

Benchmark (examples/bench_video_thumbnails.rs, needs ffmpeg): 4/4 codecs incl.
HEVC/.mov produce a thumbnail server-side (was 0% for HEVC); ~50-70 ms/frame in
the background; ~3.9 KB preview WebP; up to ~23x less per-first-view transfer on
the test corpus (far more on real multi-MB clips). Methodology in
benches/VIDEO-THUMB.md.

Hardening from an adversarial review: video render holds the decode_semaphore
like the image path; the ffmpeg scale filter bounds both dimensions; the blob
stream has a timeout; the temp file lives on the data volume; the size cap uses
saturating_mul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:23:04 +02:00
Paul Meier d1bbe8ba45 fix(oidc): redirect callback to /login so the SPA receives oidc_code
After a successful OIDC callback the backend redirected the browser to
`{frontend_url}/?oidc_code=…` (the site root). But the SvelteKit SPA only
reads `oidc_code` on the `/login` route: the root route immediately
`goto`s `/files`, and the layout's auth guard bounces an unauthenticated
visitor to `/login?redirect=…` — both of which drop the `oidc_code` query
param. The exchange step (`POST /api/auth/oidc/exchange`) therefore never
runs, so the user lands back on the login form with no session even though
the IdP round-trip and callback succeeded.

Redirect to `{frontend_url}/login?oidc_code=…` instead — the route that
actually performs the exchange. `/login` is public, so the guard doesn't
interfere; after a successful exchange the page navigates on to the app.

This was masked until now by #510 (the duplicate-callback 403 always fired
first); with that fixed, the callback reaches the frontend and this second
bug surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:18:23 -05:00
DioCrafts e7b85e56e2 feat(thumbnails): WebP output with Accept content negotiation
Thumbnails are now generated eagerly as lossy WebP (the primary codec) and
served to clients that advertise `Accept: image/webp`; JPEG is kept as a lazy
fallback for older clients and NextCloud, generated on first request and then
cached like WebP.

- ThumbnailFormat{Webp,Jpeg} enum threaded through encode/render/generate, the
  on-disk path ({hash}.webp / {hash}.jpg), the moka cache key
  (file_id, size, format), and cleanup (both formats removed).
- file_handler: parse Accept -> format, format-keyed ETag, `Vary: Accept` on
  every response (incl. 304) so shared caches never serve the wrong codec;
  Content-Type is byte-sniffed (infer) so it always matches the bytes.
- preview_handler (NextCloud) pins JPEG.
- webp = "0.3" (vendored libwebp via cc, no system dependency).

WEBP_QUALITY=82, chosen via a quality sweep (bench Table E1): SSIM within
~0.005 of JPEG q80 (imperceptible at thumbnail scale) for ~62% fewer bytes. On
the photo-realistic bench corpus the full set (3 sizes x 3 photos) drops 65.6%
(213->73 KB); real photos with edges/text land nearer ~25-40%. Encode is +5ms,
paid once in the eager background generator (off the request path).

The bench corpus is now photo-realistic (per-channel sums of low-frequency
sinusoids) instead of white noise, which had distorted codec byte ratios.
Methodology + numbers in benches/WEBP.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:16:08 +02:00