Commit Graph

415 Commits

Author SHA1 Message Date
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 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
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
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
Paul Meier f42756aa29 fix(oidc): make SSO callback idempotent + evict stale legacy service worker (#510)
OIDC SSO login intermittently ended on a 403 "Invalid or expired OIDC state
— possible CSRF attack" even though the login had already succeeded
server-side.

Root cause: the (now-removed) legacy vanilla-JS frontend registered a
`/sw.js` service worker that, with navigation preload enabled, double-fetched
the top-level navigation to `/api/auth/oidc/callback`. The OIDC `state` is
single-use, so the first callback consumed it and logged the user in while
the duplicate (~0.4s later) found the state gone and returned the 403 the
browser rendered.

Backend — idempotent callback: after a successful web login, remember
`state -> exchange_code` in a short-lived (120s) cache. A duplicate callback
whose state was already consumed now replays that same redirect instead of
403-ing, returning the cached result directly without re-running the IdP code
exchange (the authorization `code` is single-use too). Keyed by the
unguessable 32-byte state, so it adds no new attack surface and fixes the 403
for everyone — including browsers still running a stale legacy service worker.

Frontend — evict the stale worker: the current SvelteKit app registers no
service worker, so fresh clients can't double-fire. But a browser that
previously loaded the legacy frontend still has `/sw.js` registered and
controlling pages (and `/sw.js` now 404s, so vendor self-cleanup is
inconsistent). killLegacyServiceWorker() runs first in the root layout's
onMount: it surgically unregisters only `/sw.js` workers, drops only the
legacy `oxicloud-cache-*` caches, and reloads once (guarded).

Fixes #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:31:59 -05:00
DioCrafts 8981c1dfb9 refactor(api): remove 5 dead routes + stale deprecation markers
An audit (backend /api routes vs SvelteKit frontend usage, adversarially
verified across the whole repo) found these 5 routes have ZERO callers — no
frontend, no test, no protocol layer, no internal caller — and are superseded:

- GET  /api/folders/paginated            no-op duplicate of GET /api/folders
                                         (discards the page arg); superseded by
                                         the cursor-paginated /{id}/resources.
- POST /api/dedup/upload                 superseded by /api/files/upload, which
                                         does the identical CDC dedup ingest.
- POST /api/people/{id}/hide             the hide-person toggle was never built
                                         into the UI (is_hidden never read).
- GET  /api/admin/settings/general       never called anywhere.
- GET  /api/admin/settings/registration  only the PUT is used; the GET had no
                                         caller (PUT kept).

Removes each route, its handler + _impl, the now-orphaned DedupUploadResponse
DTO + its two serialization tests, the set_hidden service method (only caller
was hide_person), and the OpenAPI path/schema registrations.

Also cleans 4 stale markers: two #[allow(deprecated)] that no longer suppress
anything (zero #[deprecated] remain), the "Legacy folder endpoints (contents,
listing)" comment (both already removed), and a "Re-export AppError for backward
compatibility" comment describing a re-export that doesn't exist.

Net -323 lines. The 31 other unused-by-frontend routes (device-code auth,
CardDAV contact-groups, people/photos & music WIP, dedup/admin debug, i18n,
openapi.json) are intentional surface and were left untouched.

cargo clippy --all-features --all-targets -D warnings: clean. cargo test: 446 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:35:09 +02:00
DioCrafts b14c4dc911 perf: cache-stampede coalescing + DB safeguards; ui/i18n fixes
Backend — tail latency & throughput:
- FileContentCache, image transcode, and search now use moka single-flight
  (try_get_with / get_or_load) so N concurrent misses for the same key
  collapse to one disk read / transcode / query instead of a thundering herd.
  Microbenchmark (128 concurrent on one hot key): 128 loads / p99 ~1023ms
  before vs 1 load / p99 ~32ms after.
- DB: configurable per-statement timeout on the primary pool
  (OXICLOUD_DB_STATEMENT_TIMEOUT_SECS, default 30; maintenance pool exempt) so
  a runaway query can't pin a connection and starve the pool.
- DB: background pool-saturation monitor
  (OXICLOUD_DB_POOL_MONITOR_INTERVAL_SECS) that WARNs as the primary pool nears
  exhaustion — the early signal before tail latency cliffs.
- mimalloc: set MIMALLOC_PURGE_DELAY=0 (Dockerfile + compose) so freed pages
  return to the OS and RSS tracks the live working set; benchmarked on
  musl/aarch64 at ~400MB reclaimed vs 0MB with the default.

Frontend — UI / i18n fixes:
- i18n: fix literal "{{count}}" and "{{percentage}}/{{used}}/{{total}}" in the
  selection toolbar and storage line — the call sites passed param names that
  didn't match the locale placeholders; unify on `count` and pass the storage
  template its params. Add es files.selected_count.
- sidebar: hide the drive picker when there's only one drive (the redundant
  "Personal" row); remove the coloured left accent on the active nav item.
- logo: stop clipping the cloud's left bulge — viewBox recentred on the cloud's
  true bbox with proportional SVG size so it keeps the same rendered scale.
- user menu: drop the default <a> underline on the link rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:42:10 +02:00
Dionisio Pozo ddfc481616 Merge pull request #479 from EdouardVanbelle/feat/drive-impl
feat/drive impl
2026-06-19 18:48:32 +02:00
Claude 9ccaeef0ab perf(listing): return per-item is_favorite/is_shared, drop client badge fetches
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:21:01 +00:00
Edouard Vanbelle 30eabdb23e chore(clippy): use sort_by_key with Reverse for face_count ordering 2026-06-19 16:06:38 +02:00
Edouard Vanbelle e5b9a4a8db feat(drive): remove create_home_folder
remove create_home_folder() & ensure_home_folder()

    now: on_user_created() and on_user_login both() call provision_if_needed()
    which calls **create_personal_drive_atomic()**

    add a helper to find Personal drive for a user and also it's root directorry
2026-06-19 16:06:38 +02:00
Edouard Vanbelle e7f4826778 feat(drive): complete updated_by created_by 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 50fb34659e feat(drive): limit NC request by disk 2026-06-19 16:06:38 +02:00
Edouard Vanbelle aa155dffa0 fix_next_cloud 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 42510d94f3 POC(nextcloud): add chroot on nextcloud
Bring chroot to nextcloud capability: login on Nextcloud via username="{user}~{folder-uuid}
    Doing a such login will chroot the folder folder-uuid

    If user has several folder as root (parent=None), the login flow will request which
    folder user want to chroot
2026-06-19 16:06:38 +02:00
Edouard Vanbelle 16ea08b093 feat(drive): improve Drive model
now Drive is purely a metadata
    each drive has always a root folder
    this model minimize Oxicloud changes, and simplify
    the Drive name is simply the folder's root's name
    note: owner of Drive has more permission that an owner of the root folder
2026-06-19 16:06:37 +02:00
Edouard Vanbelle eab7a609b9 feat(drive): start implementation of drive
- add storage.drives
    - prepare migration phase
    - add created_by and updated_by on storage.folders
2026-06-19 16:06:37 +02:00
Dionisio Pozo 1c9bb30475 Merge pull request #485 from AtalayaLabs/claude/zealous-faraday-58s1at
feat: Photos evolution — Places (map) & People (faces) + gallery polish
2026-06-19 14:31:14 +02:00
Claude de95de013d feat(faces): PeopleService — identity clustering + use cases
Phase 2 increment 4:
- recluster(user): union-find connected-components over the user's face
  embeddings (cosine threshold); groups of >= min_faces become persons, and
  an existing person's name is preserved across reclusters. O(n^2) — fine
  for moderate libraries; an ANN index is the documented scale-up.
- caller_id-scoped use cases for the HTTP layer: list_people (non-empty
  clusters, cover thumbnail, most-photographed first), person_photos,
  faces_for_file, rename, hide, merge, and delete_all (right to erasure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:40:33 +00:00
Claude 524e573756 feat(faces): domain entities, ports & no-op analyzer
Phase 2 increment 2:
- domain: Face, Person, BoundingBox, DetectedFace (512-d embeddings).
- ports: FaceAnalyzerPort (detect + embed from raw bytes; decodes internally
  so the application layer stays image/ML-crate agnostic) and FaceRepository
  (user-scoped face/person persistence).
- DTOs: PersonDto, FaceBoxDto.
- NoopFaceAnalyzer — reports is_ready()==false and returns no faces, so the
  whole People pipeline compiles and runs inert until the operator wires a
  real ONNX-backed analyzer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:34:46 +00:00
Claude 05d51ededd perf(grants,favorites): batch resource resolution to kill N+1 and pool fan-out
Three list endpoints resolved each resource with one query per id:

- GET /api/grants/incoming and /api/grants/outgoing used
  join_all(ids.map(get_file)) + join_all(ids.map(get_folder)), so a single
  page (limit ≤ 200) could demand ~200 concurrent connections from the
  20-connection primary pool, causing acquire-timeouts and head-of-line
  blocking under load.
- The NextCloud favorites REPORT (oc:filter-files) fetched get_file/
  get_folder once per favorite — up to N serial round-trips per sync.

Add by-ids batch reads that mirror the existing get_file/get_folder column
mapping and NOT is_trashed filter:

- FileBlobReadRepository::get_files_by_ids / FolderDbRepository::get_folders_by_ids
  (one SELECT ... WHERE id = ANY($1)), exposed as FileRetrievalService::
  get_files_by_ids / FolderService::get_folders_by_ids returning DTOs.
- Both grant handlers and the favorites REPORT now issue two batch queries
  total and look results up by id, preserving original order. Missing ids
  (stale grants whose resource was deleted, or trashed/removed favorites)
  drop out exactly as before. No auth-semantics change: these paths already
  resolved ids vetted by the authorization engine / favorites table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAzLEQDaLak3dnrEN3YT35
2026-06-19 11:27:11 +00:00
Claude f4b431bb03 feat(places): backend for the photo map (geo clusters API)
Phase 1 server side, gated on OXICLOUD_ENABLE_PLACES (off by default):

- migration: partial index on storage.file_metadata(longitude, latitude).
- FileBlobReadRepository::list_geo_clusters — plain-SQL grid aggregation
  (no PostGIS) scoped to the caller's own non-trashed photos, returning a
  centroid, count and a representative file id per non-empty cell.
- PlacesService (caller_id-scoped; user-scoped data needs no authz check,
  mirroring RecentService) with a zoom→cell-size mapping.
- GET /api/photos/geo?bbox=w,s,e,n&zoom=N returning GeoCluster[]. The route
  is mounted only when the Places service is present, and is registered in
  the OpenAPI path list.

The map frontend (PMTiles serving + MapLibre module) is deferred pending
the basemap-sourcing and vendoring decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 10:57:01 +00:00
Claude 9a6b174a30 Defer CDC chunk reclamation in manifest dereference to GC
remove_manifest_reference unlinked a chunk's backing file right after the
row-delete committed — the same TOCTOU the GC grace window was added to
close: a concurrent upload of identical content can re-reference (pin) the
chunk in the gap between commit and unlink, after which the deferred
unlink strands a referenced chunk with no bytes.

Route physical chunk reclamation through the single grace-protected path:
on last reference, delete the manifest and decrement its chunks (stamping
orphaned_at on the ones that reach 0), but leave the chunk rows and files
for garbage_collect() to reclaim once orphaned past the grace window. The
manifest deletion and its blob-keyed thumbnail hook stay eager.

remove_legacy_reference and cleanup_if_orphaned's legacy path are left as
eager deletes on purpose: a legacy whole-file hash can never be re-created
by an ingest (uploads are always CDC now), so there is no writer to race —
the existing "row gone ⇒ no resurrection" reasoning holds for them.

Adds an integration test asserting a CDC manifest dereference leaves
chunks orphaned-but-present, then reclaimed by a post-grace GC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rsVwzTwD216R9HXT2aU4
2026-06-19 09:58:51 +00:00
Claude e0efaed549 Harden blob GC and supervise the content-index worker
Deduplication GC (garbage_collect, Phase 2):
- Add an orphan grace period before a ref_count=0 blob's backing file is
  physically deleted, mirroring git's gc.pruneExpire. New
  storage.blobs.orphaned_at records when a blob last reached ref_count 0;
  the delete trigger and every decrement / 0-ref insert path stamp it,
  every re-reference clears it.
- Cross-check that no manifest lists the chunk and no file points at the
  blob before deleting it (mirrors Phase 1's file check), so a stale
  ref_count can only delay collection, never delete live content.
- Unlink the backing files with bounded parallel fan-out.

Together these close a TOCTOU where a concurrent upload of identical
content could re-reference a chunk in the window between the GC row
delete committing and the backing file being unlinked. Individual file
deletes still reclaim eagerly; only bulk empty-trash and the periodic
sweep observe the grace window.

Trash: match ErrorKind::NotFound instead of substring-matching the error
message when treating an already-deleted item as success.

Content-index worker: supervise the drain loop and restart it with
backoff after a panic, instead of letting a panic silently freeze the
search index while the dirty queue grows unbounded.

Adds migration 20260802000000_blob_gc_grace.sql and an integration test
covering the grace window and reference cross-checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172rsVwzTwD216R9HXT2aU4
2026-06-19 09:37:51 +00:00
Claude fe852d3b79 fix(dav): repair CalDAV/CardDAV client connectivity (#480)
Standard CalDAV/CardDAV clients (Thunderbird, DAVx5, Apple
Calendar/Contacts) failed to connect, mounted collections read-only, or
could not discover address books, even though curl worked. Three
protocol-compliance gaps caused this:

1. Missing Basic-auth challenge on /caldav and /carddav.
   The 401 returned for these surfaces carried no `WWW-Authenticate`
   header (only /webdav did). Spec-compliant clients never send
   credentials preemptively the way `curl -u` does — they wait for the
   challenge — so Thunderbird never authenticated and failed with
   "discovery failed" / 401. Extend the challenge to all DAV surfaces via
   shared `is_dav_path` / `dav_basic_auth_challenge` helpers.

2. Calendars always advertised read-only.
   The `current-user-privilege-set` write gate compared `owner_id`
   against the literal string "current_user_id", which never matched a
   real UUID, so `<D:write/>` was never emitted and clients mounted every
   calendar read-only. Thread the caller's id through the CalDAV adapter
   and grant write when the caller owns the calendar.

3. CardDAV discovery was incomplete.
   There was no `/.well-known/carddav` route and the root PROPFIND
   exposed neither `current-user-principal` nor `addressbook-home-set`,
   so clients could not locate address books. Add the well-known redirect
   and root/principal discovery responses mirroring the CalDAV adapter.

Adds unit tests for the auth challenge predicate, the calendar
owner/non-owner privilege split, and the CardDAV root/principal discovery
responses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cVV9nRQjP6G6a8zbNUWMw
2026-06-19 08:38:27 +00:00
Edouard Vanbelle 72129af0bd refactor(role): use grant only
- remove permission centric mode
    - finalize migration drop all tables with permissions
    - ensure roles are ENUM (owner is always displayed first)
2026-06-18 02:47:06 +02:00
Edouard Vanbelle f168c4578f feat(roles): prepare migration ReBAC to roles
prepare migration of permission to roles
    this simplify drastically database (permission are now simply defined in code)
    and will permit reuse of the same ReBAC engine to define owners of drives

    mapping:

    ```
        Role::Viewer => &[Permission::Read],
        Role::Commenter => &[Permission::Read, Permission::Comment],
        Role::Contributor => &[Permission::Read, Permission::Create],
        Role::Editor => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
        ],
        Role::Owner => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
            Permission::Share,
            Permission::Delete,
            Permission::Manage,
        ],
    ```
2026-06-18 02:47:06 +02:00
Dionisio Pozo b8a0018785 Merge pull request #473 from EdouardVanbelle/fix/nextcloud+webdav
fix(nextcloud+webdav) fix bugs found via end to end tests
2026-06-17 12:42:31 +02:00
Bradley Nelson b3e1e42e93 Clean up 2026-06-17 00:28:10 -06:00
Bradley Nelson 4427b1613b plugin logging 2026-06-16 23:00:23 -06:00
Bradley Nelson 803150635c add frontend 2026-06-16 21:26:36 -06:00
Bradley Nelson 87d68c5b6f init plugins 2026-06-16 17:57:57 -06:00
Edouard Vanbelle f9de7ac596 fix(webdav): native COPY honours destination filename (M8)
Threads `new_name: Option<&str>` through FileWritePort::copy_file and
FileManagementUseCase::copy_file_with_perms so a same-folder
COPY /a.txt → /b.txt picks up the destination name via a single
COALESCE($3::text, name) in the CTE. Without it the new row inherits
the source's filename and collides on the (folder_id, name, user_id)
unique index — the "Already Exists" 500 M8 was hitting.

handle_copy in the native WebDAV surface now passes
`(file.name != dest_name).then(|| dest_name.into())`, keeping the
"same name in a different folder" case at None so existing semantics
are preserved.
2026-06-17 01:49:13 +02:00
Claude b5b80549ea perf(storage): incremental per-upload usage update (O(1)) instead of full SUM
After every upload, maybe_update_storage_usage spawned a full
`SUM(size) OVER all the user's non-trashed files` to refresh
auth.users.storage_used_bytes — O(N) in the user's file count per upload,
i.e. O(N²) for a bulk upload. (The covering index makes it index-only but
still scans N rows.)

Replace it with an O(1) incremental `storage_used_bytes += size`, keyed by the
file's owner_id (dropping the brittle "My Folder - <user>" path-parsing hack).
Deletes/trash never decremented this value — they already rely on the periodic
reconciliation sweep — so the model is unchanged: the sweep remains the
correctness backstop for every mutation, and the counter is clamped at 0.
Both stay fire-and-forget on a background task, off the upload's latency path.

Benchmarked (per-call, vs the user's existing file count N):
  N=1k:  full-SUM 202us  vs incremental 123us
  N=10k: full-SUM 1185us vs incremental 113us   (10x)
  N=50k: full-SUM 5397us vs incremental 114us   (47x — incremental is flat O(1))
Bulk upload of 10k files (insert + usage update each):
  full-SUM (O(N²)) 10.37s  ->  incremental (O(N)) 4.89s   (>2x, diverges with scale)

https://claude.ai/code/session_01DCszkkU11LYxMEUWr4setK
2026-06-15 12:34:57 +00:00
Claude a9ce071a51 perf(cache): key the file content cache by blob hash, not file id
FileContentCache (moka, 512 MiB) was keyed by the file UUID, so content that
the CDC store already deduplicates to ONE blob on disk was cached once PER
FILE in RAM: N files sharing a blob held N copies, all counting against the
512 MiB cap. With effective dedup the cache filled with duplicates and
thrashed.

Key it by the blob hash instead (already on FileDto::content_hash):
- The in-RAM cache now benefits from dedup like the disk does — each distinct
  blob is cached once and shared across every file/user that references it,
  so a download by user A warms the cache for user B's identical content.
- Content is immutable by hash, so entries never go stale; the existing
  invalidate(file_id) calls become harmless no-ops (a UUID never matches a
  hash key) and can be removed in a later cleanup.
- ETag is now the immutable content hash (strong validator).
- Guarded: a hash-less stub DTO disables caching for that request rather than
  colliding every such file on the empty key.

Response Content-Type still comes from the DTO, not the cache, so keying does
not affect the served MIME (verified).

Benchmark (real moka, exact 512 MiB/weight config, 400 files x 4 MiB = 1600 MiB
working set, 4000 uniform-random accesses):
  dedup 1x : file_id 35.8% hit / 2568 reads  vs hash 35.6% / 2577  (no dedup -> no change; control)
  dedup 5x : file_id 35.8% hit / 2570 reads  vs hash 98.0% / 80    (32x fewer disk reads, RAM 512->320 MiB)
  dedup 20x: file_id 35.1% hit / 2596 reads  vs hash 99.5% / 20    (130x fewer disk reads, RAM 512->80 MiB)
The win scales with the dedup ratio; with no dedup it is a no-op.

https://claude.ai/code/session_01DCszkkU11LYxMEUWr4setK
2026-06-15 12:34:57 +00:00
Claude f70dffeaf5 perf(dedup): backend-aware chunk read-ahead for CDC reassembly
read_blob_stream / read_blob_range_stream reassembled a CDC file by fetching
its chunks with `buffered(1)` — strictly sequential, so the next chunk's
backend fetch (a file `open` locally; a full request round-trip on S3/Azure)
only started after the current chunk was fully drained.

A benchmark of the exact pipeline (stream::iter(chunks).map(get).buffered(K)
.try_flatten()) showed a blind `buffered(4)` is the WRONG fix: on a local
disk it is neutral on a warm page cache and ~37% SLOWER cold, because
concurrent opens turn one sequential read into several competing random-I/O
streams over content-addressed (scattered) chunk files. The win is entirely
on remote backends, where per-chunk request latency dominates and overlapping
fetches hide it (≈ linear in K).

So the read-ahead depth is now a backend hint, not a constant:
- BlobStorageBackend::read_prefetch() default 1 (sequential; safe for local).
- S3 / Azure override to 8 (overlap GETs to hide TTFB).
- cached / encrypted / retry / migration delegate to the backend that serves
  the bytes.
- Both CDC read paths use `self.backend.read_prefetch().max(1)`.

Net: local backend unchanged (no regression); remote reassembly ~4-8x faster.
Ordered `buffered` (not buffer_unordered) keeps chunks in sequence.

Bench (per-chunk fetch-latency model): buffered(1)->(4)/(8) = x3.9 / x7.8
@1ms, x4.0 / x8.1 @5ms, x4.0 / x8.0 @20ms. Local warm: 230ms@1 vs 227ms@4
(noise); local cold: 425ms@1 vs 585ms@4 (why local stays at 1).

https://claude.ai/code/session_01DCszkkU11LYxMEUWr4setK
2026-06-15 12:34:56 +00:00
DioCrafts 8e43391df8 fix(clippy): allow large_enum_variant on DeltaCommitOutcome for Rust 1.93
Pre-existing in the delta-upload service: Rust 1.93's stricter large_enum_variant lint flags the FileDto-carrying Done variant (the sibling variant is tiny). The value is short-lived (one per commit), so silence the lint rather than box FileDto and complicate every call site. Keeps the CI clippy -D warnings gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:33:35 +02:00
Claude 5c09f916f7 Merge origin/main (Tantivy content search) into delta-sync branch
Both sides added a parameter to create_application_services and a
setup step before it: this branch's storage-usage/quota service (for
the instant-upload path) and main's Tantivy content index (for
SearchService). The resolution keeps both — the signature takes both
arguments and the build runs storage usage as step 3c and the content
index as 3d.

https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
2026-06-11 18:32:27 +00:00
Claude ed9a204e49 Delta download: file manifest + user-scoped chunk fetch for sync clients
Phase 3 of the delta-sync plan — the inverse direction, so a future
client app holding an older local version can fetch only what changed:

- GET /api/files/{id}/manifest returns the file's chunk recipe
  ({file_hash, total_size, chunks}). Owner-scoped like the rest of the
  delta surface (Read permission through the authz engine first, then
  the chunk layer's possession standard; shared files use the regular
  download endpoints). A manifest is immutable for a given file_hash,
  so it is served with ETag = file_hash and If-None-Match answers 304 —
  polling sync clients pay one header round-trip per unchanged file.
  Legacy pre-CDC blobs are presented as a single-chunk manifest of
  themselves, so clients need no special case.
- POST /api/files/delta/download streams the requested chunks as
  [u32 BE length][bytes] frames in request order — the same wire format
  the upload direction uses. Entitlement is the same possession rule as
  negotiate/commit (chunks reachable through the caller's own files);
  anything else returns 404 {not_available} — deliberately
  indistinguishable from "never existed" — with a
  delta_download.rejected audit event. Batches are bounded by the
  chunk_max_bytes budget; Content-Length is exact (sizes come from the
  dedup index) and peak RAM is one backend read frame.

Both endpoints share the delta rate limiter. New DedupService
primitives: manifest_chunk_list (with legacy fallback), chunk_sizes,
chunk_stream. OpenAPI regenerated; protocol doc gains the download
section; types.js maps the new wire shapes (plus the delta-upload
typedefs that a container reset had silently dropped from a previous
commit).

Verified end-to-end against PostgreSQL 16 with a simulated two-device
sync: device A uploaded 24 MB by bytes and delta-updated it (2 edits →
2 chunks); device B diffed the manifest against its WASM-chunked local
copy, needed 2/79 chunks, fetched 970 KB instead of 24 MB (96.1%
saved) and rebuilt the file byte-identical with the BLAKE3 verifying.
If-None-Match revalidation returned 304; a second user got 404 on both
the manifest and the chunk batch (with the not_available list and
audit lines); an unknown hash was indistinguishable from a denied one;
an empty hash list returned 400.

https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
2026-06-11 16:38:02 +00:00