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
Adds an implementation-status section: Phase 0 done (with commit refs),
0.2 implemented via a flattened PhotoDto rather than widening FileDto, the
two Phase 0 loose ends (embedded map pin, drag-marquee, HEIC, sub-nav),
and Phase 1 (Places) marked backend-in-progress.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
- Photos zoom via wheel, double-click and two-finger pinch (1–5x) with
drag-to-pan; a touch swipe navigates prev/next when not zoomed.
- A new info button toggles a panel showing date, size, dimensions, camera
and GPS coordinates (resolving the old geoloc TODO), pulled from
/api/files/{id}/metadata.
- Zoom/pan state resets on every item change and on close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
Adds a Grid/Justified toggle to the photos toolbar. Justified mode packs
tiles into Flickr-style rows scaled to the container width using each
photo's real aspect ratio (from the new /api/photos width/height, falling
back to 1:1 when missing). It composes with the virtualized renderer:
per-group materialization and the off-screen spacer height estimates are
both layout-aware. The choice persists in localStorage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
list_media_files now LEFT JOINs storage.file_metadata and returns each
photo's pixel dimensions next to the sort date. The endpoint wraps FileDto
in a flattened PhotoDto carrying width/height, so the gallery can lay tiles
out at their true aspect ratio (justified layout) without a second per-file
metadata round-trip and without layout shift. FileItem gains optional
width/height. No change to FileDto or its other construction sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
- Tiles are focusable (tabindex / role=button / aria-label) with a
:focus-visible ring; Enter opens the lightbox (or toggles in selection
mode), Space toggles selection.
- Shift-click extends the selection from the last anchor across the
timeline; the range is tracked in the selection Set so it spans
dematerialized (off-screen) groups, with visible tiles updated at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
- Add Modal.confirmDialog() (Promise<boolean>, built on openPanel so it
inherits the overlay, animation, focus-trap and Escape handling) and use
it to replace native confirm() in the photos batch-delete and lightbox
single-delete flows.
- The lightbox now reflects the real favorite state when an item opens
(previously the star always started empty) and toggles favorites through
the favorites module so its cache stays in sync.
- Add photos.delete_* i18n keys (English; other locales fall back to en).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
Consolidates the research into a concrete, file-by-file plan following the
repo's hexagonal conventions (AuthZ in services with caller_id, audit logs,
feature flags, sqlx migrations, vanilla JS/CSS):
- Phase 0: gallery polish (virtualization done; dimensions in /api/photos,
justified layout, lightbox zoom/pan + map pin, a11y, sub-nav).
- Phase 1: Places — MapLibre + self-hosted Protomaps PMTiles served from
Axum (pmtiles crate) + SQL grid aggregation (no PostGIS).
- Phase 2: People — ort (ONNX) detect+embed, pgvector storage, incremental
threshold clustering; runtime-downloaded models; opt-in/GDPR.
Includes vendoring/dependency table and the open decisions to resolve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
The photos timeline rendered every tile into the DOM and grew it
unbounded on infinite scroll, degrading on large libraries. Each
date-group is now a <section> whose grid is materialized (tiles
inserted) only while near the viewport and dematerialized (emptied,
height frozen as a spacer) once it scrolls away, driven by an
IntersectionObserver rooted on the scroll container. DOM nodes stay
bounded by a few screens regardless of library size.
- Grouping (day/month/year), infinite scroll, multi-select, video
thumbnails and fade-in are all preserved.
- Selection state and the video-thumbnail cache survive the
materialize/dematerialize cycle.
- Falls back to full rendering when IntersectionObserver is unavailable.
- Spacer heights are estimated from grid geometry and re-estimated on
resize.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
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
D-Prep (migration 20260801000002_drop_access_grants) drops
storage.access_grants and replaces it with storage.role_grants — one
row per role assignment instead of N rows per permission bundle. The
load seeder still spoke the old per-permission shape and failed every
nightly with `relation "storage.access_grants" does not exist`.
Both seeder call sites already grant the read bundle (single
permission), which maps cleanly to the `viewer` role; switching them
to the new schema is a one-row INSERT with the role name. The
conflict key drops `permission` since uniqueness is now per
(subject, resource).
Adds a `ref` input to workflow_dispatch so the nightly can be triggered
from main and run the scenarios against a feature branch that does not
yet carry the workflow file (e.g. feat/drive). Empty input falls back
to `github.ref`, so cron and bare dispatch are unchanged.
The regression-issue title and body now show the tested ref instead of
`github.sha`, which always resolves to the workflow's ref (main) under
workflow_dispatch and would otherwise mislead.
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,
],
```
The KNOWN BUG pin for `GET /api/s/{folder-token}/file/{file_id}` was
stale — the route now returns 200 + body for files inside the share's
subtree and 404 for anything outside it. Replaces the sidestep
comment with two positive assertions:
- in-share: 200 + Content-Disposition references the file name
- out-of-share (caller-owned file in a different folder): 404,
matching "no such file" so the response can't be used to
enumerate foreign file ids
Coverage now exercises the actual recipient-side download path that
NC desktop and web clients use; the file-share variant (item_type=file)
moves down to test 8b.
Adds a teardown DELETE for the outsider hello.txt so the next test in
the runner (permissions.hurl) can re-upload its own hello.txt into
admin's home folder without hitting the live-name unique index.
handle_head was declaring `Content-Length: file.size` while writing
`Body::empty()` — on a keep-alive connection the client waits forever
for N bytes that never come. Hyper now derives Content-Length: 0 from
the actual body, which is honest about what's on the wire.
RFC 7231 §4.3.2 suggests HEAD return the same headers as GET, but
lying about Content-Length is worse than omitting it: NC and Sabre
clients use PROPFIND for size anyway, and curl -I (and any client
applying HEAD semantics) gets the same ETag/MIME/Last-Modified it
needs. Caught by the F6b test which uses `curl -X HEAD` to read the
current ETag before a conditional PUT.
Also adds `nc_status_propfind_depth0` to lib/dav_helpers.sh so the
F11/F11b assertions ("did the intermediate parent get auto-created?")
can compile.
When the client sends `MOVE /trashbin/{id}` with a `Destination` header,
handle_restore now resolves the destination path and returns 412
Precondition Failed if a live file or folder already sits there —
matching Sabre/DAV and the NC desktop client's expectation. There is
no `Overwrite: T` workflow for trash restore in either reference
implementation (silently replacing a live file with an undeleted one
is a footgun), so the refusal is unconditional.
The destination header is extracted at the dispatch site as an owned
String so the future stays Send-compatible (`&Request<Body>` is not
Sync because the body trait object is Send-only).
`extract_nc_subpath_from_dest` is promoted to `pub` so trashbin_handler
can share the same URL parser as handle_move.
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.
Extends the N2/PUT lock guard introduced earlier to the rest of the
native mutator surface. Same helper, same If: capture before body
consumption, same 423-on-reject shape:
- handle_delete : check source path
- handle_proppatch : check source path
- handle_move : check source AND destination paths
- handle_copy : check destination path only (source isn't
modified by a copy)
The class-2 DAV advertisement in OPTIONS is now honest across the
full surface, not just PUT.
New tests N2c-N2f run while n-locked.txt is still LOCKed (before the
existing N3 UNLOCK). Each asserts 423 without the token and verifies
the operation didn't half-apply: file present after DELETE-423,
source untouched + no destination after MOVE-423, locked destination's
content unchanged after COPY-423.
Positive (with-token) coverage is implicit via the M-series happy-
path tests that exercise each method on unlocked resources — a
regression that hard-rejected every call would fail there too.
Closes F11.
The NC MKCOL handler previously had `mkdir -p` semantics:
sending MKCOL on /a/b/c/ where neither a nor b exists silently
created both intermediates and returned 201. Sabre/DAV and the
actual NC server both 409 on that — our auto-create deviated.
NC desktop walks ancestors one MKCOL at a time during sync so
nothing real depended on the old behaviour.
Drop the segment-walking creation loop. New flow:
target exists → 405
parent path missing → 409
parent ok, target new → 201
The race-recovery branch for the loop's per-segment create is
also gone — single parent lookup, single create, no window.
Test F11 flipped from pinned-201 to strict 409 and asserts the
intermediate parent was not silently created. F11b and F11c added
as regression guards for the success path and the 'target already
exists' case.
Closes G9.
DELETE on a folder now flips is_trashed on every descendant folder
and file under it in a single CTE pipeline (lpath <@ root.lpath
covers the whole subtree via the GiST index). Previously only the
root row was flipped — descendants stayed live, directly addressable
via their full path, and confused desktop-sync tree walks that
expected the parent-collection 404 to imply the children were gone
too.
restore_from_trash mirrors the cascade: descendants where the
original_*_parent_id column is NULL are the ones we cascade-trashed,
so they get cascade-restored too. Descendants that were independently
trashed before the parent went to trash have original_*_parent_id
set, so they stay in trash and remain visible as top-level entries
in storage.trash_items.
No schema migration needed — both original_parent_id (folders) and
original_folder_id (files) were already nullable and already encoded
'where this came from when it was independently trashed'; using NULL
as the cascade-marker reuses that existing distinction cleanly.
Test G9 flipped from KNOWN BUG to assert every descendant 404s after
the parent DELETE; new G9b proves the inverse cascade by restoring
the trashed root and verifying every descendant comes back at its
original path.
Closes M5 / M7 / M8a / M8b.
The optimized PathResolver and the read-side find_*_by_path queries
disagree on what counts as 'a path that hits a row'. After the drive-
refactor migration rewrote the path column to drop the
"My Folder - <user>/" prefix, files PUT through the WebDAV surface
stayed reachable by GET (legacy lookup) but vanished from the
optimized resolver (strict path-match). MOVE/DELETE/COPY 404'd on
every root-level file as a result.
Introduces resolve_or_legacy: optimized resolver first, then the
GET-style legacy lookups as a strict superset. Ownership is enforced
in both branches. handle_delete / handle_move / handle_copy each
collapsed from two near-identical resolver-only + legacy-only branches
into a single match using the helper — fewer lines, identical
semantics, root-level paths now resolve.
handle_copy also fixes M8b: copy_file_with_perms takes no destination
name, so a copy to a different filename in the same folder collided
with the source. After the copy, rename the new file when dest_name
differs from source name. Mirrors what handle_move already does.
Closes G4 / G5 / K5.
handle_move now resolves the destination once before the file/folder
dispatch and applies RFC 4918 §9.9.4:
- Overwrite: F on a collision → 412 Precondition Failed, source
untouched, destination untouched.
- Overwrite: T (or absent) on a collision → delete the existing
destination, then proceed → 204 No Content.
- No collision → 201 Created (unchanged).
The same destination lookup powers the 201-vs-204 status decision, so
adding the precondition guard adds zero extra DB hits on the happy
path.
handle_restore now catches the unique-index collision out of
restore_item and returns 412 instead of letting it bubble as 500.
Mirrors the G4 semantics for the trashbin surface (restore has no
Overwrite header so the refusal is unconditional; client resolves by
renaming the live file first).
Sabre/DAV's CorePlugin and our test pins agreed independently — NC
clients expect this exact behavior, and the new G5b/G5c positive-case
tests guard against a regression that hard-rejected every MOVE.
Closes N2. The native WebDAV PUT handler now consults the lock
store before accepting a write: if the target path is exclusively
locked, the request must carry the lock token in its If: header
or the server returns 423 Locked. Without a matching token, the
body is never consumed — a rejected PUT no longer wastes the
upload bandwidth or hits the CDC ingester.
Two helpers are introduced so the same enforcement plugs into
the other mutator methods (delete/move/copy/proppatch) when their
fixes land:
extract_if_header_tokens — angle-bracket-scoop view of If:
(sufficient for one-target writes;
full §10.4 tagged-list grammar would
only matter for multi-resource Ifs)
enforce_native_lock — Some(423) when locked + no/wrong
token, None otherwise
Test N2 flipped from pinned 204 to assert 423. Added N2b: same
PUT with the captured Lock-Token in If:(<...>) returns 204, so a
regression that hard-rejected every PUT would still fail loudly.
Closes F5/F6. The NC PUT handler now evaluates conditional
preconditions before body ingestion and returns 412 Precondition
Failed when they fail:
- If-None-Match: * on an existing target → 412 (create-if-absent)
- If-None-Match with matching ETag → 412 (weak compare)
- If-Match: * with no current representation → 412
- If-Match with no listed ETag strong-matching the current → 412
The lookup that drives the precondition reuses the same query the
handler already needed for the 201-vs-204 distinction, so this adds
no extra DB round-trip. Rejected requests skip body ingestion
entirely so a 412 doesn't waste megabytes of bandwidth + disk I/O.
Test F5/F6 flipped from 'pinned current 204' to assert 412, plus
mirror cases F5b/F6b/F6c/F6d covering the legitimate-success paths
so logical-operator regressions can't slip past silently.
- permit reuse of blob where file is trashed (by-hash and chunked)
- add hurl test on /api/files/by-hash
- add anti enumeration of blob (404 is always blob_not_owned_by_caller)