Commit Graph

188 Commits

Author SHA1 Message Date
Edouard Vanbelle 64ff982571 feat(thumbnails): uploaded previews survive a copy
Completes step 9. The PUT wrote `ext-{file_id}.jpg` and nothing else —
keyed by file id, on local disk. No copy path duplicates it and no other
instance can see it, so a copied file lost the preview its owner
uploaded. Silently: the server falls back to rendering one from the
source, or to 204 for a PDF, which has no render path at all. A
user-supplied preview is not derivable from the content, so once lost it
is gone.

The PUT now also records a storage.file_attached_blobs row, which
copy_file_satellites already duplicates, so both copy paths carry it.
Best-effort: the sidecar has already succeeded by then and the user can
see their thumbnail, so failing the request would report an error for an
operation that visibly worked.

Read path consults attachments ahead of every content-derived tier: an
uploaded preview is an explicit choice about THIS file and must beat
anything rendered from its content. Cached under the per-file key — a
content key would leak those bytes to every other file sharing the
content, which is the poisoning the file-keyed table exists to prevent.

store_attached_blob is ON CONFLICT DO UPDATE, unlike its derived twin:
re-uploading a preview is a deliberate replacement, where a re-derived
thumbnail is the same bytes again. The superseded blob's reference is
released, or it would be pinned forever with nothing pointing at it.

Deletion goes through a trigger, not a hook. file_id is ON DELETE
CASCADE, and on_file_deleted fires AFTER delete_file — by then the
cascade has run and there is nothing left to enumerate. This matters
most for folder deletion, where PG cascades folders to files to
attachments and Rust never sees the rows at all. storage.decrement_blob_ref
keys off OLD.blob_hash and is otherwise table-agnostic, so it is reused
verbatim rather than transcribed into a second trigger that can drift.
DELETE only: a replacement updates in place and is handled in Rust, so
adding UPDATE would double-decrement.

Extracted read_blob_to_bytes, shared by the attached and derived tiers —
the only difference between them is which table produced the hash.

tests/api/attached_thumbnail_copy.hurl guards it. The file is red and
the uploaded thumbnail is green, so a render could never produce the
uploaded bytes; the pre-upload render is captured first and required to
change, which stops three identical renders from satisfying the
byte-equality. Then both copy paths must serve the upload, and after the
original is purged and GC runs, both copies must still serve it — each
holds its own reference, because the rows are duplicated rather than
shared.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle 2c0ba37290 test(api): read the DTO from the folder listing, not the download route
GET /api/files/{id} is the download route — it returned the PNG bytes,
so the jsonpath capture failed on a UTF-8 decode. /{id}/metadata is the
EXIF endpoint and carries no FileDto either. Listing the folder gives
the DTO, and since the folder holds exactly this one file, count == 1
also proves the WebDAV PUT overwrote in place rather than creating a
second file beside it.

Also drops an unused bytes capture and records why the body is not
asserted after the overwrite: the moka tier is keyed on file_id and
invalidated from the spawned task in on_file_updated, so a request
landing first sees the previous bytes under the new ETag. Asserting on
bytes would be a race.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle 19a8186c66 test(api): upload into an explicit folder in the ETag test
The upload omitted folder_id, which the handler needs to resolve the
file's owner — it answers 500, not a root upload. Every other upload in
the suite passes it; this was the only one that did not, which is why
nothing caught it earlier.

The folder also gives the WebDAV overwrite a deterministic path
(/webdav/hurl-etag-src/<name>) instead of depending on where a
folder-less upload would have landed. Teardown now removes it and
purges it from trash, keeping the shared database clean for the files
that run after.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle a3a93b90ec fix(thumbnails): key the ETag on content hash, not file id
The thumbnail ETag was "thumb-{file_id}-{size}-{format}", sent with
Cache-Control: public, max-age=31536000, immutable. Replacing a file's
content preserves its id — file_upload_service rebuilds the entity with
parts.id and a new hash, then fires on_file_updated, which deletes and
regenerates the thumbnails — so the server produced a new thumbnail while
still advertising the old ETag. Because `immutable` tells a conforming
browser not to revalidate at all inside the freshness window, clients kept
rendering the previous image for up to a year, unfixably.

Keyed on the content hash the directive becomes honest: a thumbnail is a
pure function of (source bytes, size, format), so that triple identifies
the response. New content yields a new ETag.

The same change fixes the opposite direction. A copy, or any dedup twin,
had a different id and therefore a different ETag, so clients refetched
bytes they already held even though both are served from the same derived
blob. Now identical content agrees on an ETag and revalidates to 304
across files, users and copies.

Both thumbnail endpoints were affected: the REST handler and the
NextCloud preview handler.

Cost is one PK lookup ahead of the 304 decision, where the id-keyed
version needed none — paid for by no longer serving stale images. It is
partly recovered: both handlers already resolved the same hash further
down for the render path, and that second lookup is now gone, so the
cache-miss path is unchanged and only the 304 path pays. The resolved
hash is also handed to get_cached_thumbnail instead of None, saving the
service its own lookup.

No new disclosure: content_hash is already on FileDto and returned by
GET /api/files/{id}.

Tests: thumbnail_etag_content_keyed.hurl covers invalidation — overwrite
in place via WebDAV PUT, assert the ETag changed, assert a client holding
the stale one gets 200 rather than 304. derived_blob_copy.hurl gains the
sharing direction: a copy answers with the SAME ETag and revalidates to
304, which is the one externally observable consequence of content-keying
and was not previously testable.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle 46dc25a9a8 test(api): scope derived_blob_copy claims to what it can observe
The byte-identity assertions were documented as proving that a copy
shares the original's content_derived_blobs row. They prove no such
thing: rendering is deterministic in the source bytes and the variant,
so a copy that re-rendered from scratch returns identical bytes. The
copy is in fact a moka hit — that cache is keyed on
(source_hash, size, format), which the copy shares — so it never
reaches the derived tier here at all.

Nor is there an assertion that would fix it. Duplication is impossible
by construction: the PK is (source_hash, kind, variant), a copy carries
the same source_hash, and store_derived_blob is ON CONFLICT DO NOTHING.
The schema enforces the property, so no runtime behaviour can violate
it and there is nothing to catch.

Same limitation narrows step 11: it proves the SOURCE content survived
GC, not the derived blob — a reaped derived blob is re-rendered
transparently from the live source.

What the file does prove is unchanged and is the part that was broken:
both copy paths take a real blob reference (ref_count 1 -> 2 -> 3), and
purging the original does not destroy the copies. No assertions changed.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle 9f8ec141f3 feat(storage): single-source the copy fan-out via copy_file_satellites
Step 8 of docs/plan/derived-blobs.md. "What follows a file on copy" was
written twice — the copy_file CTE and storage.copy_folder_tree — and had
already drifted: the tree path bumped storage.blobs only, missing
manifests, which was silent data loss on any multi-chunk file. Fixing it
meant writing the same logic a second time. Step 9 adds a file-keyed
satellite table, which would mean a third and fourth.

Two SQL functions:

  storage.add_blob_references(TEXT[]) — the manifest-first reference
  contract for SQL callers, returning hashes that matched no registry
  row. Set-based so the tree path keeps its single-statement cost; a
  per-row helper would have made a 10k-file copy 10k calls.

  storage.copy_file_satellites(UUID[], UUID[]) — dead properties plus
  the blob reference. The body is the copy-semantics declaration: what
  is absent (comments, favorites, content-keyed derived rows) is listed
  with its reason, so the taxonomy is executable rather than documented
  elsewhere and drifting.

Both copy paths now call it. The single-file path becomes a real
transaction, which also fixes the reference being best-effort: a failed
add_reference used to log a warning and leave a copy holding no
reference at all — the exact shape that gets its content reaped. It
cannot be a CTE arm, because data-modifying CTEs share one snapshot and
the function must read the row the INSERT just wrote.

Verified against a scratch PG with all migrations applied: multi-chunk
manifest 1→2, single-chunk alias bumped at manifest level only (the
NOT EXISTS guard), chunks behind a manifest untouched, dead properties
duplicated, length mismatch rejected, repeats counted.

tests/api/derived_blob_copy.hurl covers it end-to-end and answers the
question the copy raises: content_derived_blobs is NOT copied. A copy
carries the same blob_hash, so it resolves the same derived row — the
test asserts byte-identical thumbnails from both copy paths, then
deletes the original, runs GC, and requires both copies to still serve.
That last step only passes if the references are real.
2026-08-30 13:41:04 +02:00
Edouard Vanbelle a8223cab65 test(storage-check): drain the GC cascade instead of one pass
One dedup_gc pass cannot fully drain now that thumbnails are derived
blobs. Reaping a source releases the references its derived artifacts
hold (each content_derived_blobs row pins a manifest), and those
releases happen mid-sweep — the derived chunks are stamped orphaned as
the pass is already walking past them, because
remove_manifest_reference deliberately does not unlink, to avoid racing
a concurrent upload re-referencing the same chunk. They are collectible
only on the NEXT sweep, which is why the check saw 15 leftover blobs.

Loops until a pass reclaims nothing rather than hardcoding two. Two is
correct only while the derivation graph is one level deep — a thumbnail
is derived from a file, nothing is derived from a thumbnail. That is a
property of the data, not an invariant the code enforces, so a fixed
count would silently under-drain the day transcodes-of-thumbnails or
E2E-wrapped derivatives exist, and the failure would surface as a
confusing leftover-file assertion rather than the design change it is.
Bounded at 3 with a warning if it does not settle.

Sleeps between passes. The JobRegistry serialises runs of the same job,
so a back-to-back trigger risks rejection as already-running — which
returns 0 reaped and would exit the loop early, declaring success with
blobs still on disk. A false pass is worse than a slow one. It also
gives the previous pass's detached unlink tasks (spawned by
on_blob_deleted, awaited by nothing) time to land.

Deliberately NOT fixed in production code: derived chunks land inside
the 1-hour orphan grace, so a second immediate sweep would collect
nothing there and the next scheduled run picks them up. A fixpoint loop
in garbage_collect would be dead code outside force=true, which is only
this test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:41:04 +02:00
Edouard Vanbelle 390aa31443 feat(cli): merge oxicloud binary and cli
this feature to simplify the creation of only 1 binary for multiple architecture
2026-08-29 11:57:48 +02:00
Edouard Vanbelle 0f29614a3b fix(ref_count): use SQL to correct ref_count on cascading deletion
then dedup_gc will trigger blob life cycle and ensure chunk deletions
2026-08-23 23:19:11 +02:00
Edouard Vanbelle 2760fe9efc refactor: rename tests on ref_count 2026-08-23 23:19:11 +02:00
Edouard Vanbelle a847e0fd2f test(manifest-consistency): ensure hurl tests are ok 2026-08-23 23:19:11 +02:00
Edouard Vanbelle 8d696ccc60 test(ref_count): check ref_count accross copy_folder and folder deletion 2026-08-23 23:19:11 +02:00
Edouard Vanbelle 537e7f15ef fix(users): /api/admin/users always returns a FullUserDto[] 2026-08-22 00:14:37 +02:00
Edouard Vanbelle a8fa281a02 refactor(user): apply chanoges to hurl tests 2026-08-21 23:56:25 +02:00
Dionisio Pozo 06aff99fa3 Merge pull request #635 from swissiety/rfc-6868-param-encoding
fix(vcard): add RFC 6868 parameter value encoding
2026-08-12 10:32:26 +02:00
Edouard Vanbelle bbc1e77e33 test(jobs): remove hardcoded number of jobs in tests 2026-08-09 17:03:27 +02:00
Edouard Vanbelle a7df46f8f8 feat(sessions): show session origin in admin panel + test 2026-08-09 16:36:42 +02:00
Edouard Vanbelle 811c7b0f12 feat(DPoP): add API test 2026-08-09 01:56:07 +02:00
Edouard Vanbelle 2de476d281 feat(pass reset): request a pass change on 1st login 2026-08-04 23:32:30 +02:00
Edouard Vanbelle 6965855388 reactor(opaque): normalize to OXICLOUD_AUTH_OPAQUE_* variables 2026-08-04 07:31:32 +02:00
Edouard Vanbelle 5418a5bd34 feat(opaque): add end to end test hurl + binary 2026-08-04 07:03:08 +02:00
Edouard Vanbelle fac65a7c4d feat(opaque): add lookup identifier (with anti-enum) 2026-08-04 07:03:08 +02:00
Edouard Vanbelle 94c6121f3b feat(opaque): add /api/auth/opaque/params 2026-08-04 07:03:08 +02:00
Edouard Vanbelle 7d7621e387 feat(opaque): wire API
- POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}.
- POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler.
- Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation.
- OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at.
- opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene).
- Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc.
- Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints.

Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so:
- Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak)
- Consumed handle can't be re-used to spam parse attempts
2026-08-04 07:03:08 +02:00
Edouard Vanbelle ae65c8475f test(opaque): add test that coverable by hurl 2026-08-04 07:03:08 +02:00
Edouard Vanbelle 0e395ae15f feat(auth): bring opaque (RFC 9807) auth
OPAQUE (RFC 9807) implementation (using `opaque-ke` crate)

    with opaque authentfication, server will never receive the password (in the auth=password mode)
    this is a must have to create trust with users to permit end to end encryption in the future
    (we cannot know if user use the same password/passphrase for his asymetric key or his oxicloud auth,
    this is why server must never have the password)

    pass1: prepare server
2026-08-04 07:03:08 +02:00
Edouard Vanbelle 8b0fb03b5c feat(recoverable jobs): clarify life cycle pause vs cancel
a job can be paused/resumed
    a job as an exclusibity by it's name
    if you want to run another job with same name:
    either cancel the first one, or wait of it's terminaison

    pause does not permit to run the other job, this can create
    race conditions
2026-08-02 17:38:44 +02:00
Edouard Vanbelle 015f2da0f7 refactor(backend): normalize naming convention to backend rather storage
no ambiguity with the backend rather storage
2026-08-02 14:56:29 +02:00
Edouard Vanbelle 9902a6f8fe refactor(usage_reconcile): explicit naming to prevent confusion with storage (backend) 2026-08-02 14:56:29 +02:00
Edouard Vanbelle a9d5aae781 feat(storage key rot): add rotate services 2026-08-02 02:40:33 +02:00
Edouard Vanbelle 88921c975a fix(hurl test): add new job 2026-08-01 20:08:17 +02:00
Edouard Vanbelle 8329b4aa56 feat(storage): improve admin panel 2026-08-01 17:10:06 +02:00
Edouard Vanbelle d3c2fc3e94 fix(job): correct amount of jobs + remove cound due to grace window 2026-07-30 02:00:39 +02:00
Edouard Vanbelle e1556e3d36 feat(recoverable-job): add findings 2026-07-29 07:57:06 +02:00
Edouard Vanbelle 41d83b3053 feat(recoverable-job): add consistency_batch (runs all consistency check) 2026-07-29 01:44:52 +02:00
Edouard Vanbelle 782a5c99bd feat(recoverable-job): add folder_consistency 2026-07-29 01:39:58 +02:00
Edouard Vanbelle 394708c9a1 feat(recov. job): add drive consistency service 2026-07-29 01:25:15 +02:00
Edouard Vanbelle 4ab57d0d8e test(job-registry): additional hurl coverage 2026-07-28 00:35:34 +02:00
Edouard Vanbelle f66f7fa31f feat(job-registry): remplace /api/admin/internal/trigger-*
remplace /api/admin/internal/trigger-* to /api/admin/jobs/{...}/trigger
remove OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS
2026-07-27 23:54:43 +02:00
Edouard Vanbelle 3b31b8911b feat(breadcrumb): build breadcrumb in 1 API call
add /api/folders/{id}/ancestors

    this API to iterate parent up to the drive root or the shared folder
    this will help UI to build the breadcrumb in 1 API call
    and to identify the root element (is it a drive users has access to or
    a shared folder ?)

    ui: now only 1 API call is now required to build the breadcrumb
2026-07-27 01:33:09 +02:00
Edouard Vanbelle c22741bc7f refactor(search): normalize answer to /resources format 2026-07-26 18:18:15 +02:00
Dionisio Pozo ef3e3f45b3 Merge pull request #521 from BCNelson/feat/external-file-mounts
feat(mounts): external file mounts — pluggable provider, read-write, WebDAV/NextCloud, admin UI
2026-07-22 06:52:43 +02:00
Dionisio Pozo 86268049ff Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list
feat(fileDto, folderDto): add is_favorite + is_shared
2026-07-22 06:51:28 +02:00
Bradley Nelson 3ad38ddde7 test(mounts): admin CRUD hurl coverage + frontend mounts-tab tests
Close the two coverage gaps in the external-mounts feature — the admin
REST surface and the admin UI tab both previously had no automated tests.

Backend (hurl):
- tests/api/external_mounts.hurl — black-box coverage of the admin mount
  endpoints (list / create / delete): input validation (empty name,
  non-existent host path → 400), create → 201 with the full view, the
  mount appears in the list, delete → 204 then 404, and the /api/admin
  middleware denies a non-admin on every verb → 403.
- server.env: enable OXICLOUD_ENABLE_EXTERNAL_MOUNTS for the test server;
  run.sh: register the new scenario.

Frontend (vitest):
- admin/page.test.ts — opening the mounts tab loads + lists mounts,
  the create form calls createExternalMount, and delete goes through the
  confirm modal to deleteExternalMount.

Tooling:
- devenv.nix: add b3sum (used by tests/api/run.sh's storage-integrity check).
2026-07-21 18:19:03 -06:00
Edouard Vanbelle d9987782c4 feat(fileDto, folderDto): add is_favorite + is_shared
- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
      regarding performances I try to be minimalis on SQL to prevent any perf regression

    - remove old set of sharedids and favoriteids (was not functionnal anymore)
    - fix date picker (no past selection) in grant
    - fix contextmenu close on /files section
2026-07-22 01:55:10 +02:00
Dionisio Pozo 67fe944c2a Merge pull request #596 from swissiety/rfc-5789-http-patch 2026-07-22 00:32:27 +02:00
M.Schmidt 027ba8a528 feat(vcard): add RFC 6868 parameter value encoding
Adds application/adapters/param_encoding.rs: encode/decode for the three
RFC 6868 caret-escape sequences (^n newline, ^^ literal caret, ^' literal
DQUOTE) plus render_param_value, which quotes and encodes a parameter
value only when the vCard/iCalendar param-value grammar requires it
(comma/semicolon/colon triggers quoting; an embedded DQUOTE/caret/newline
needs escaping even once quoted).

Wires render_param_value into the two vCard generators' TYPE parameter
emission (EMAIL/TEL/ADR) in carddav_adapter.rs::contact_to_vcard and
contact_service.rs::generate_vcard — contact `type` fields are free text,
not a fixed enum, so a value containing a comma or quote previously
produced outright broken vCard syntax (no quoting/escaping was applied
at all).

Scope note: this branch only fixes the generation side. The existing
vCard parser is a naive line-by-line substring scanner (`line.contains
("TYPE=WORK")`) with no real per-parameter tokenizing, so there's no
integration point yet for the decode half without a larger parser
rewrite — that's part of the vCard 4.0 work (RFC 6350), which depends on
this branch for the shared encoding utility.
2026-07-21 09:05:18 +02:00
Edouard Vanbelle 931e27d09c fix(resources): wire missing created_by and updated_by 2026-07-20 21:04:32 +02:00
M.Schmidt ec2b533a53 security(webdav): adapt to anti-enum pattern 2026-07-19 19:55:26 +02:00
Edouard Vanbelle 05dfda9aa3 feat(drive): add quota update handler
per today: admin only can update quota
    shared drive can have quota updated (personal drive's quota belong to user's quota)
2026-07-19 16:25:41 +02:00