Merge main (Photos/People/Places + ReBAC) into the SvelteKit rewrite

Bring the feature-rich main branch into the frontend Svelte rewrite
(PR #478, base bcn/frontend-svelte-rewrite). main moved well ahead of the
PR's branch point (b8a0018): it added the Places photo-map and People
(faces) backends, photos enhancements, the ReBAC→role-grants migration,
load tests, and more.

Conflicts resolved (4 files):
- Dockerfile: combine the explicit --bin allowlist (defence-in-depth from
  main) with the SPA copy from the frontend build stage (PR).
- .github/workflows/ci.yml: keep the PR's Svelte frontend job
  (svelte-check + eslint + stylelint + prettier + vitest); the legacy
  static/-targeted tsc/locale/icon advisory steps don't fit the new
  working-directory: frontend job and svelte-check supersedes them.
- justfile: keep both the new fe-* / dev recipes (PR) and the load-* k6
  recipes (main).
- static/locales: keep the PR's symlink (-> ../frontend/static/locales);
  main's new photos/people locale keys are folded into the Svelte locale
  files alongside the ported views.

Backend (people/places/faces handlers, routes, DI, migrations) merged
cleanly. `cargo check --bins` passes. The new Places/People UI is not yet
in the Svelte app; that is ported in follow-up commits.
This commit is contained in:
Claude
2026-06-19 12:45:22 +00:00
126 changed files with 11677 additions and 947 deletions
+3 -1
View File
@@ -168,7 +168,9 @@ jobs:
- name: Fail if fixtures are stale (rebuild + commit them)
run: git diff --exit-code tests/fixtures/plugins/
- name: Run plugin runtime tests
run: cargo test --features plugins plugins::
# Quote: the trailing `::` confuses GitHub's YAML parser (mapping
# values not allowed) and aborts the whole workflow at load time.
run: 'cargo test --features plugins plugins::'
rust-test:
name: Server Unit and Functionnal Tests
+126
View File
@@ -0,0 +1,126 @@
name: Load Nightly
# Nightly regression gate. Runs every k6 scenario, diffs against
# baseline/load.json, opens an issue if any metric regresses.
#
# IMPORTANT: shared GitHub-hosted runners produce noisy timings —
# regression signal is unreliable until this workflow is moved to a
# pinned self-hosted runner with consistent hardware. The cron run is
# informational until then; treat opened issues as "investigate" not
# "broken main."
on:
schedule:
# 03:00 UTC daily — outside US/EU working hours, low contention.
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
ref:
description: 'Branch / tag / SHA to load-test (leave blank to use the workflow ref). Lets you dispatch from main and run the scenarios against a feature branch — useful when the target branch does not have the workflow file yet.'
required: false
default: ''
env:
CARGO_TERM_COLOR: always
jobs:
load:
name: k6 load suite + baseline diff
# Skip the scheduled run on forks — nightly is only meaningful against the
# canonical baseline.json on the upstream repo. Forks can still hit it
# manually via `workflow_dispatch` if they want, and the guard is bypassed
# for that path (event != schedule). Additional fork repositories are
# opted-in explicitly below.
if: github.event_name != 'schedule' || github.repository == 'AtalayaLabs/OxiCloud' || github.repository == 'EdouardVanbelle/OxiCloud'
# tunning on Ed's nuc to ensure stable environment
# treating regressions here as merge-blocking. ubuntu-latest is too noisy
# for trustworthy p95/p99 deltas.
runs-on: [self-hosted, nuc-loadtest]
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
# `inputs.ref` is set only for workflow_dispatch with a non-empty
# value; for cron and bare dispatch it's empty, in which case the
# action falls back to the workflow's own ref (`github.ref`). The
# `||` short-circuits on empty strings, so the cron path keeps the
# exact behaviour it had before.
ref: ${{ inputs.ref || github.ref }}
# The self-hosted runner bind-mounts target/ as a persistent volume
# for fast incremental rebuilds. actions/checkout's default cleanup
# tries to rmdir it and hits EBUSY on the mount point. Git still
# syncs the working tree to the target SHA — only non-git files
# (target/, tests/load/results/) persist, which is what we want.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
- name: Install Node 20
# The self-hosted runner image ships Node 12, which can't parse the
# ES-module `.mjs` helpers (compare.mjs / merge-summaries.mjs /
# bake-baseline.mjs). Pin to a current LTS for both runners.
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Build OxiCloud + load-seed (release)
# Single cargo invocation: load_seed_bin is an empty marker feature
# that gates the load-seed bin without changing oxicloud's dep
# graph, so cargo compiles oxicloud exactly once.
run: cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed
- name: Run full load suite
id: load
run: bash tests/load/run.sh
env:
BUILD_TARGET: release
continue-on-error: true
- name: Upload raw k6 summaries
if: always()
uses: actions/upload-artifact@v4
with:
name: load-results-${{ github.run_id }}
path: tests/load/results/*.json
retention-days: 30
- name: Prepare regression report
if: steps.load.outcome == 'failure'
run: |
{
echo "## Load nightly regression"
echo ""
echo "Run: \`${{ github.run_id }}\` · Commit: \`${{ github.sha }}\`"
echo "Ref tested: \`${{ inputs.ref || github.ref }}\`"
echo "Runner: \`${{ runner.name }}\` (\`${{ runner.os }}/${{ runner.arch }}\`)"
echo ""
echo "Compare exited non-zero — see uploaded artifact \`load-results-${{ github.run_id }}\` for raw summaries."
echo ""
echo "Baseline is anchored to the runner that produced it; cross-runner"
echo "comparisons (e.g. an artifact baked on different hardware) will"
echo "show large \"regressions\" that are really hardware deltas."
} > regression-issue.md
- name: Open issue on regression
# Skip on forks where Issues are disabled — the regression report is
# already in the artifact, and the workflow's exit code marks the run
# as failed regardless. Forks that want issue notifications can enable
# issues on their repo and remove this condition.
if: steps.load.outcome == 'failure' && github.repository == 'AtalayaLabs/OxiCloud'
uses: peter-evans/create-issue-from-file@v5
with:
# `github.sha` resolves to the workflow's ref, not the tested ref,
# so when dispatched against a non-default branch we surface the
# input explicitly — otherwise the title misleads with main's SHA.
title: "Load nightly: regression on ${{ inputs.ref || github.sha }}"
content-filepath: regression-issue.md
labels: |
load-test
regression
+45
View File
@@ -0,0 +1,45 @@
name: Load Smoke
# PR-tier liveness check. Verifies the k6 load harness still builds and a
# single happy-path iteration runs against a freshly built server.
# NO regression gate — that's the nightly workflow's job.
on:
pull_request:
branches: [ main, dev ]
paths:
- 'tests/load/**'
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'migrations/**'
- '.github/workflows/load-smoke.yml'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
smoke:
name: k6 smoke (load harness)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
- name: Install k6
uses: grafana/setup-k6-action@v1
- name: Build OxiCloud (debug)
run: cargo build --bin oxicloud
- name: Run smoke scenario
run: bash tests/load/smoke.sh
env:
BUILD_TARGET: debug
+4
View File
@@ -109,3 +109,7 @@ wasm/oxicloud-hash/target/
.devenv/
.direnv/
.devenv-state/
# K6 load-test raw run outputs and per-run storage (baseline is committed)
tests/load/results/*.json
tests/load/storage/
Generated
+80
View File
@@ -3654,6 +3654,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -3866,6 +3876,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "maybe-owned"
version = "0.3.4"
@@ -4071,6 +4091,21 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -4157,6 +4192,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -4238,6 +4282,25 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ort"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"libloading",
"ndarray",
"ort-sys",
"smallvec",
"tracing",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
[[package]]
name = "outref"
version = "0.1.0"
@@ -4676,7 +4739,9 @@ dependencies = [
"mockall",
"moka",
"mp3-duration",
"ndarray",
"nom-exif",
"ort",
"oxc_allocator",
"oxc_codegen",
"oxc_minifier",
@@ -5074,6 +5139,15 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "postcard"
version = "1.1.3"
@@ -5496,6 +5570,12 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
+22
View File
@@ -81,6 +81,8 @@ nom-exif = "3.6.1"
extism = { version = "1.30.0", optional = true }
toml = { version = "1.1.2", optional = true }
file-rotate = { version = "0.7.6", optional = true }
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "tracing", "api-24"], optional = true }
ndarray = { version = "0.17.2", optional = true }
[features]
default = []
@@ -89,6 +91,18 @@ integration_tests = []
# WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most
# deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true.
plugins = ["dep:extism", "dep:toml", "dep:file-rotate"]
# Empty marker feature that gates the `load-seed` binary so it isn't built
# by default (and skipped in prod Docker builds). Kept separate from
# test_utils so enabling it doesn't change the oxicloud dependency graph —
# this lets one `cargo build` produce both `oxicloud` and `load-seed`
# without recompiling oxicloud with mockall in scope.
load_seed_bin = []
# Real ONNX-backed face analyzer (detector + embedder) for the People feature.
# Opt-in: pulls `ort` (ONNX Runtime, load-dynamic — dlopen's libonnxruntime at
# runtime) + `ndarray`, a heavy stack most deployments won't use. Activation also
# requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without
# this feature the People pipeline falls back to the inert NoopFaceAnalyzer.
faces-onnx = ["dep:ort", "dep:ndarray"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
@@ -101,6 +115,14 @@ path = "src/bin/generate-openapi.rs"
name = "migrate-nfc-filenames"
path = "src/bin/migrate-nfc-filenames.rs"
[[bin]]
name = "load-seed"
path = "src/bin/load-seed.rs"
# Test fixture seeder. Gated behind the empty `load_seed_bin` feature so
# `cargo build --release` (and the prod Dockerfile) skip it. tests/load/run.sh
# and load-nightly.yml build it explicitly with --features load_seed_bin.
required-features = ["load_seed_bin"]
[build-dependencies]
oxc_allocator = "0.125.0"
oxc_parser = "0.125.0"
+5 -2
View File
@@ -28,7 +28,7 @@ RUN mkdir -p src/bin && \
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
echo 'fn main() {}' > src/bin/generate-openapi.rs && \
echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \
cargo build --release && \
cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
# ─── Stage 3: Build the application ──────────────────────────────────────────
@@ -48,7 +48,10 @@ COPY migrations migrations
COPY templates templates
# Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx)
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# Explicit --bin list: defence-in-depth so the prod image never ships
# test-only bins (e.g. load-seed) even if `required-features` gating
# changes upstream.
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames
# The SPA is built by the frontend stage; bring it in for the runtime copy below.
# (build.rs no longer generates static-dist unless OXICLOUD_RUST_ASSETS=1.)
COPY --from=frontend /static-dist ./static-dist
+11 -1
View File
@@ -107,9 +107,19 @@ storage.access_grants
expires_at TIMESTAMPTZ NULL
```
One row per `(subject, permission, resource)` triple. An "admin role on folder
One row per `(subject, permission, resource)` triple. An "owner role on folder
X for user Y" is 6 rows; a "viewer role" is 1 row.
> **Note (D-Prep, 2026-06-17):** the role assignment has since pivoted into
> a separate `storage.role_grants` table that stores **one row per role
> assignment** rather than one per permission. `access_grants` stays
> populated via dual-write during the transition; the engine reads the
> role-keyed table for authz decisions. The cleanup PR drops
> `access_grants` after the dual-write window. The historical role name
> `Admin` was renamed to `Owner` at the same time, to disambiguate from
> `UserRole::Admin` (user-account privilege) and match Drive plan
> terminology.
Cleanup is trigger-driven (`trg_cleanup_grants_folder`, …): when a resource or
subject is deleted, all referencing grants disappear in the same transaction.
+465
View File
@@ -0,0 +1,465 @@
# Plan: Photos Evolution — Gallery, Places (map) & People (faces)
## Context
The Photos view (`static/js/features/library/photos.js` + `photosLightbox.js`) is a
date-grouped timeline with infinite scroll, multi-select and a lightbox. The backend
already extracts and stores per-photo EXIF — including **GPS latitude/longitude** — in
`storage.file_metadata` (`src/infrastructure/services/exif_service.rs`,
`media_metadata_service.rs`), and serves the timeline via `GET /api/photos`
(`src/interfaces/api/handlers/photos_handler.rs` → `list_media_files` in
`src/infrastructure/repositories/pg/file_blob_read_repository.rs`).
This plan adds, in three phases:
0. **Gallery polish** — performance + modern UX (timeline virtualization already landed).
1. **Places** — a map of geotagged photos. *Most of the data already exists.*
2. **People** — face detection, embedding, identity clustering ("like Apple/Google Photos").
All work follows OxiCloud conventions: hexagonal layering, **AuthZ enforced only in the
application service layer** via `*_with_perms(caller_id)` methods calling
`AuthorizationEngine::require(...)`, **audit logging on every denial**
(`target: "audit"`), feature flags (`OXICLOUD_ENABLE_*`), native `UUID` columns, sqlx
migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from
`static/css/base/variables.css`, JSDoc-typed, BEM).
> ⚠️ New dependencies (JS libraries to vendor, Rust crates, the `pgvector`/`vectorchord`
> Postgres extension, and runtime-downloaded ML models) need explicit sign-off — see
> **§Vendoring & dependencies** and **§Open decisions**. Per repo rules: never hand-edit
> `Cargo.lock` (use `cargo add`), and don't introduce JS frameworks.
---
## Implementation status (updated)
**Phase 0 — Gallery polish: essentially complete** (branch `claude/zealous-faraday-58s1at`).
| Item | Status | Commit / note |
|------|--------|---------------|
| 0.1 Virtualization | ✅ done | `081b2b6` |
| 0.2 width/height on `/api/photos` | ✅ done | `8d09589` — implemented via a flattened `PhotoDto` (`#[serde(flatten)]`) instead of widening `FileDto` + its 6 construction sites; `list_media_files` LEFT JOINs `storage.file_metadata`. `FileItem` gained optional `width`/`height`. |
| 0.3 Justified layout | ✅ done | `75ee9b7` |
| 0.4 Lightbox (zoom/pan, swipe, info panel, favorite fix) | 🟡 mostly | `ca1a8cb`, `e8520e4` — the map pin shows **coordinates as text**; the embedded mini-map / deep-link into Places is deferred until Places exists. |
| 0.5 Shift-select, confirm→Modal, keyboard a11y | 🟡 mostly | `824df4d`, `e8520e4` — optional drag-marquee not done. |
| 0.6 HEIC | ⬜ pending | open decision (native `libheif` dep). |
| 0.7 Sub-nav tabs | ⬜ pending | deferred to Phase 1 (tabs need the Places/People views). |
**Phase 1 — Places: complete (Approach A).**
- Backend (`f4b431b`): migration `…_places_geo_index.sql`, `FileBlobReadRepository::list_geo_clusters` (plain-SQL grid aggregation, no PostGIS), `PlacesService` (caller_id-scoped), `OXICLOUD_ENABLE_PLACES` (now **default on**, `513b622`), `GET /api/photos/geo`.
- Frontend: vendored MapLibre 5.24.0 + pmtiles 4.4.1 (`bb3d739`); `places.js` (`513b622`) renders the server-aggregated clusters as **HTML thumbnail markers** (no glyphs/sprites, no client-side clustering), refetches on pan/zoom, and drills into the lightbox. Optional Protomaps `.pmtiles` basemap read over HTTP **Range via the existing `ServeDir`** (label-light style, light/dark) with graceful fallback to a themed background; ODbL attribution. "Moments | Places" sub-nav.
- **Deviations from the original plan:** 1.5 serves the basemap as a *static file* (ServeDir Range) instead of the `pmtiles` Rust crate; 1.8 uses MapLibre HTML markers instead of a deck.gl `IconLayer`. Both keep the footprint minimal and need zero new backend code.
- **Pending:** browser smoke-test, and an operator-supplied `static/basemaps/basemap.pmtiles` for the street backdrop (works without it).
**Phase 2 — People: complete (detector/embedder shipped, opt-in).**
- **Migration** (`…_faces.sql`): `faces` schema with `faces.persons` + `faces.faces`.
**Deviation from 2.2:** embeddings stored as **`BYTEA`** (512×`f32` little-endian), **no
`pgvector`** — cosine similarity runs in Rust. This keeps the extension footprint at
today's `pg_trgm`/`ltree`/`citext` and is fine at personal-library scale; the HNSW/ANN
path is the documented growth step if it's ever needed.
- **Config:** `OXICLOUD_ENABLE_FACES` (`FeaturesConfig::enable_faces`, **default off** —
biometric/opt-in). Everything below is inert when off.
- **Domain/ports:** `Face`, `Person`, `BoundingBox`, `DetectedFace` (`domain/entities/face.rs`);
`FaceAnalyzerPort` (single `analyze(&[u8]) -> Vec<DetectedFace>` + `is_ready()`) and
`FaceRepository` (`face_ports.rs`). **Deviation from 2.3:** detector+embedder collapsed
into one `FaceAnalyzerPort` (the analyzer owns detect→align→embed) instead of split
`FaceDetectorPort`/`FaceEmbedderPort` — simpler seam for a single ONNX session.
- **Repository:** `FacePgRepository` (`infrastructure/repositories/pg/`) — bytea
encode/decode, person CRUD, `faces_for_*`, `assign_person`, `delete_all_for_user`.
- **Service:** `PeopleService` (`application/services/people_service.rs`) — `recluster()`
via **union-find connected-components** (cosine ≥ 0.5, `min_faces` 3, immich-style),
plus list/photos/rename/hide/merge/delete. "List my own people" needs no `authz.require`
(user-scoped, like `RecentService`/`PlacesService`).
- **Indexing:** `FaceIndexingService` implements `FileLifecycleHook` — background
detect+embed on image create/copy/update, **dedup by blob hash**. Driven by the
analyzer port; with the no-op analyzer it does nothing.
- **Analyzer:** two implementations behind `FaceAnalyzerPort`. `NoopFaceAnalyzer`
(`is_ready()=false`) is the default so the stack compiles/runs **without any ML model**.
`OnnxFaceAnalyzer` (`12ede47`, behind the **`faces-onnx`** cargo feature) is the real
SCRFD+ArcFace pipeline; `di::build_face_analyzer` picks it when the feature is compiled in
and runtime+models are configured, else degrades to the no-op (logged) so startup never
fails. **Deviation from 2.4:** the error-prone math (SCRFD anchor decode, NMS, the
closed-form similarity alignment, affine warp, normalization) lives in `face_geometry.rs`,
compiled in **every** build and covered by 11 unit tests; only the ONNX session calls are
feature-gated (and untestable here, no models). `ort` uses **load-dynamic** so
`libonnxruntime` is dlopen'd at runtime and the crate builds without it; loading goes
through `ort::init_from` (fallible) not ORT's lazy loader, which would `panic` under
`panic = "abort"`.
- **HTTP:** `people_handler.rs` + routes (gated on `people_service.is_some()`):
`GET /api/people`, `/api/people/{id}/photos`, `PATCH /api/people/{id}`,
`POST /api/people/merge`, `/api/people/recluster`, `GET /api/people/data`,
`GET /api/people/faces/{file_id}`, `POST /api/people/{id}/hide`.
- **Frontend (`6314fa6`):** `people.js` + `people.css` — person grid (circular cover,
name, count), drill into a person's photos via the existing lightbox, rename via
`Modal.prompt` + `PATCH`. Wired into the Photos sub-nav as a third **People** tab that a
capability probe (`GET /api/people`) reveals only when faces are on; otherwise hidden.
i18n keys in `en.json` (others fall back to English).
- **Config (2.4):** `FacesConfig` + `OXICLOUD_FACES_{ORT_DYLIB,DETECTOR_MODEL,
EMBEDDER_MODEL,DET_SIZE,DET_THRESHOLD,NMS_THRESHOLD,INTRA_THREADS}` (documented in
`example.env`). To run faces: build `--features faces-onnx`, set `OXICLOUD_ENABLE_FACES=true`,
and point the three model/runtime paths at an operator-supplied ONNX Runtime +
SCRFD detector + ArcFace embedder (e.g. InsightFace `buffalo_l`). Nothing is committed.
- **Still open (optional):** per-user opt-in consent gate (2.1), lightbox face-box tagging
(2.8), and the periodic full re-cluster job (2.6 has on-demand `recluster`; no scheduler
yet). End-to-end smoke-test needs real models + a browser, which only you can run.
---
## Research summary (the decisions these phases encode)
**Map (no third-party APIs, self-host, extreme perf):**
- **Engine:** MapLibre GL JS v5 (BSD-3, WebGL2, vendorable UMD, no framework).
- **Basemap:** self-hosted **Protomaps `.pmtiles`** (single file) served by Axum via the
**`pmtiles`** Rust crate over HTTP Range — OxiCloud serves its own basemap. Global
z0–6 ≈ 60 MB; regional extracts on demand; planet ≈ 120 GB.
- **Clustering:** client-side **Supercluster** (MapLibre `cluster: true`, in a web worker)
up to ~100k points; beyond that, **plain-SQL grid/geohash aggregation** by zoom+bbox —
**no PostGIS needed** (only `pg_trgm`/`ltree`/`citext` are enabled today).
- **Gotchas:** self-host glyphs+sprites (not the Protomaps CDN); ODbL attribution
"Protomaps © OpenStreetMap" is mandatory; dark-mode via `@protomaps/basemaps` flavors.
**Faces (self-host, precision, CPU-first):**
- **Runtime:** **`ort`** (ONNX Runtime). `candle` can't run SCRFD/RetinaFace (missing
`Resize` op); `tract` is the pure-Rust fallback for a single static binary.
- **Licensing landmine:** no permissive high-accuracy face-recognition checkpoint exists.
InsightFace `buffalo_l` (IJB-C ~97.3) and EdgeFace weights are **non-commercial**.
- **Recommended (immich/PhotoPrism pattern):** **download** SCRFD + `buffalo_l` weights at
runtime (not committed); personal self-hosted use is non-commercial-compliant. Offer a
fully-permissive fallback (RetinaFace-MobileNet0.25 **MIT** + a self-retrained
EdgeFace/GhostFaceNet embedder, ~94 IJB-C, ~10× smaller).
- **Storage/clustering:** embeddings in Postgres via **pgvector** (HNSW, 512-d), growth
path to **VectorChord**; **threshold / connected-components incremental clustering**
(immich-style), not Approximate Rank-Order; ANN + exact re-rank; quality gating
(det-score ≥0.7, face ≥50–80px, blur); `minFaces ≥3` to promote a cluster to a Person.
- **Privacy:** biometric data (GDPR Art. 9) → **opt-in, OFF by default, per-user
isolation, cascade-delete**, all local.
---
## Phase 0 — Gallery polish
### Execution order
#### 0.1 Timeline virtualization — ✅ DONE (commit `081b2b6`)
Each date-group is a `<section>` whose grid is materialized only near the viewport.
**Remaining:** browser smoke-test, then it's closed.
#### 0.2 Expose image dimensions on the timeline (enables justified layout, kills CLS)
**`src/application/dtos/file_dto.rs`** — add to `FileDto`:
```rust
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
```
**`src/infrastructure/repositories/pg/file_blob_read_repository.rs`** — in the
`list_media_files` query, `LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id` and
select `fm.width, fm.height`; map into the new fields.
**`static/js/core/types.js`** — add `width?`/`height?` to `FileItem` (already on
`FileMetadata`).
#### 0.3 Justified rows layout (modern, aspect-preserving)
**`static/js/features/library/photos.js`** — add a `layoutMode: 'square' | 'justified'`
toggle in the toolbar. In justified mode, replace the CSS grid with a row-packing pass
(target row height ~180–220px, distribute by aspect ratio = `width/height`, fallback 1:1
when dimensions are absent). Keep the existing virtualization: row-pack **within each
materialized group**, so it composes with section materialize/dematerialize.
**`static/css/views/photos.css`** — `.photos-grid--justified` (flex rows) variant.
#### 0.4 Lightbox upgrades
**`static/js/features/library/photosLightbox.js`**
- **Zoom/pan** (wheel + pinch) and **mobile swipe** for prev/next.
- **Info panel** (toggle) showing EXIF from `/api/files/{id}/metadata`.
- **Map pin** (resolves the existing `//TODO: add geoloc pointer` at line ~301): when
`latitude/longitude` present, render a small static MapLibre mini-map / "Show on map"
link that deep-links into the Places view.
- **Favorite initial state** (bug fix): call `favorites.isFavorite(item.id, 'file')` in
`_show()` to set the star correctly (today it always starts empty).
#### 0.5 UX & a11y
**`static/js/features/library/photos.js`**
- **Shift-click range select** and optional drag-marquee.
- Replace native `confirm()`/`alert()` with the app modal
(`static/js/components/modal.js`; add an async `Modal.confirm()` helper).
- Tiles become focusable/role-correct; arrow-key navigation across the grid.
#### 0.6 (Optional) HEIC support
`image` crate ships only `jpeg/png/gif/webp` — iPhone HEIC photos currently get **no
server thumbnail**. Either add `libheif-rs` decoding in `thumbnail_service.rs` /
`media_metadata_service.rs`, or transcode HEIC→JPEG on upload. Flagged as its own task
(native dep).
#### 0.7 Sub-navigation inside Photos
**`static/index.html`** + **`static/js/app/navigation.js`** (`switchToPhotosSection`,
line ~434) + i18n: add a tab strip **Moments · Places · People** within the Photos view.
Places/People tabs are hidden unless their feature flags are on. This is the mount point
for Phases 1 & 2.
---
## Phase 1 — Places (map)
Data already exists (`storage.file_metadata.latitude/longitude`, `DOUBLE PRECISION`).
No PostGIS.
### Execution order
#### 1.1 Migration — index (+ optional geohash)
**New file:** `migrations/<ts>_places_geo_index.sql`
```sql
-- Fast bbox scans over geotagged photos
CREATE INDEX IF NOT EXISTS idx_file_metadata_geo
ON storage.file_metadata (latitude, longitude)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
-- Optional (scale): a geohash/quadkey integer + btree for prefix grouping by zoom.
-- ALTER TABLE storage.file_metadata ADD COLUMN geohash BIGINT;
```
#### 1.2 Application port + PG repository (grid aggregation)
**`src/application/ports/`** — new `GeoPhotoReadPort` (or extend an existing media port):
```rust
pub struct GeoCluster { pub lng: f64, pub lat: f64, pub count: i64, pub sample_file_id: Uuid }
pub struct GeoBounds { pub w: f64, pub s: f64, pub e: f64, pub n: f64 }
#[async_trait]
pub trait GeoPhotoReadPort: Send + Sync {
async fn clusters_in_bounds(&self, user_id: Uuid, b: GeoBounds, cell: f64)
-> Result<Vec<GeoCluster>, DomainError>;
async fn photos_in_bounds(&self, user_id: Uuid, b: GeoBounds, limit: i64)
-> Result<Vec<FileDto>, DomainError>;
}
```
**`src/infrastructure/repositories/pg/`** — PG impl. Grid aggregation (no PostGIS):
```sql
SELECT round(fm.longitude / $6) * $6 AS gx,
round(fm.latitude / $6) * $6 AS gy,
count(*) AS n,
avg(fm.longitude) AS clng,
avg(fm.latitude) AS clat,
min(fm.file_id) AS sample_id
FROM storage.file_metadata fm
JOIN storage.files fi ON fi.id = fm.file_id
WHERE fi.user_id = $1::uuid AND NOT fi.is_trashed
AND fm.longitude BETWEEN $2 AND $3 -- west .. east
AND fm.latitude BETWEEN $4 AND $5 -- south .. north
AND fm.latitude IS NOT NULL
GROUP BY gx, gy;
```
`$6` (`cell`) shrinks with zoom. Single indexed scan + hash aggregate; the browser only
receives `{count, center, sample_file_id}` per cell.
#### 1.3 Application service (AuthZ + audit)
**`src/application/services/places_service.rs`** (new):
```rust
pub async fn list_clusters_with_perms(
&self, caller_id: Uuid, bounds: GeoBounds, zoom: u8,
) -> Result<Vec<GeoCluster>, AppError> {
// Scoped to the caller's own library; no cross-user data.
self.authz.require(caller_id, /* own photos */).await?; // audit on deny inside require()
let cell = cell_for_zoom(zoom);
self.geo.clusters_in_bounds(caller_id, bounds, cell).await
}
```
Wire it in **`src/common/di.rs`** (`AppServiceFactory` → `AppState`), `Option<Arc<…>>`
gated on the feature flag.
#### 1.4 Config flag
**`src/common/config.rs`** — `FeaturesConfig::enable_places` from `OXICLOUD_ENABLE_PLACES`.
#### 1.5 Basemap serving (PMTiles via Axum)
- Add the **`pmtiles`** crate (`cargo add pmtiles`).
- Ship a `.pmtiles` basemap (config: path; default global z0–6 ≈ 60 MB) + self-hosted
**glyphs** and **sprites** under `static/` (from `basemaps-assets`).
- **`src/interfaces/api/handlers/basemap_handler.rs`** (new): open the reader once
(`AsyncPmTilesReader::new_with_path`, `Arc` into `AppState`), serve
`GET /api/basemap/{z}/{x}/{y}.mvt` (`reader.get_tile(...)`). *Alt:* serve the raw
`.pmtiles` over Range and let `pmtiles.js` do directory math (no tile handler).
#### 1.6 HTTP endpoints + routes
**`src/interfaces/api/handlers/places_handler.rs`** (new):
- `GET /api/photos/geo?bbox=w,s,e,n&zoom=Z` → `Vec<GeoCluster>` (auth middleware injects
`caller_id`; handler does **no** AuthZ — service does).
- `GET /api/photos/geo/cell?bbox=…` → photos in a cell (opens lightbox).
Register in **`src/interfaces/api/routes.rs`** (protected routes) + the basemap route
(public/cached).
#### 1.7 Frontend — vendored map + Places module
- **Vendor** (needs sign-off): `maplibre-gl` (UMD + CSS), `pmtiles.js`,
`@protomaps/basemaps` style JSON → `static/js/vendors/` + `static/css/`.
- **`static/js/features/library/places.js`** (+ `static/css/views/places.css`): init
MapLibre with the self-hosted style (light/dark flavor by theme), register the
`pmtiles://` protocol, add a clustered GeoJSON source fed from `/api/photos/geo`
(`cluster: true`) — or, above ~100k, the server-aggregated endpoint. Click cluster →
zoom; click point → open lightbox filtered to that cell. Mandatory ODbL attribution
control.
- **`static/js/core/types.js`** — `GeoCluster` typedef.
- Mount under the **Places** sub-nav tab (§0.7).
#### 1.8 (Optional) thumbnail markers
deck.gl `IconLayer` (MIT, no React) atlas for **visible cluster representatives only** —
never atlas all points. Start without it (count bubbles), add later.
---
## Phase 2 — People (faces)
Feature-flagged, opt-in, OFF by default. Biometric data → privacy-first.
### Execution order
#### 2.1 Config flag + privacy switch
**`src/common/config.rs`** — `OXICLOUD_ENABLE_FACES`. Plus a **per-user opt-in** setting
(stored in `auth.users` or a user-settings table) — clustering only runs for users who
opted in.
#### 2.2 Migration — pgvector + schema
**New file:** `migrations/<ts>_faces.sql`
```sql
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector (or vectorchord)
CREATE SCHEMA IF NOT EXISTS faces;
CREATE TABLE faces.persons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT, -- null = unnamed
cover_face_id UUID,
is_hidden BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE faces.faces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
bbox REAL[4] NOT NULL, -- x,y,w,h (normalized)
det_score REAL NOT NULL,
quality REAL, -- blur/size gate result
embedding vector(512) NOT NULL,
person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_faces_embedding ON faces.faces
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_faces_person ON faces.faces (person_id);
CREATE INDEX idx_faces_user ON faces.faces (user_id);
```
Cascade-delete guarantees the **right to erasure**: deleting a file/user removes its
faces; deleting a Person unlinks its faces.
#### 2.3 Domain + ports
**`src/domain/entities/`** — `Face`, `Person`.
**`src/application/ports/face_ports.rs`** (new):
```rust
pub struct DetectedFace { pub bbox: [f32;4], pub landmarks: [[f32;2];5], pub score: f32 }
#[async_trait] pub trait FaceDetectorPort: Send + Sync {
async fn detect(&self, image: &DynamicImage) -> Result<Vec<DetectedFace>, DomainError>;
}
#[async_trait] pub trait FaceEmbedderPort: Send + Sync {
async fn embed(&self, aligned_112: &DynamicImage) -> Result<[f32;512], DomainError>;
}
```
**`src/application/ports/`** — `FaceRepository` (CRUD + ANN search via pgvector `<=>`).
#### 2.4 Infrastructure — ONNX runtime adapter
- `cargo add ort ndarray`.
- **`src/infrastructure/services/onnx_face_service.rs`** (new): loads detector + embedder
ONNX models, runs on a **dedicated thread pool** (mirror `thumbnail_service.rs` /
`image_transcode_service.rs` to avoid starving Tokio). Pipeline: detect → 5-point
similarity align to 112×112 → embed → L2-normalize. Implements `FaceDetectorPort` +
`FaceEmbedderPort`.
- **Models** are **downloaded at runtime** to a models dir (NOT committed). Default:
SCRFD-2.5G + `buffalo_l/w600k_r50` (immich pattern). Config switch to the
permissive fallback (RetinaFace-MobileNet0.25 MIT + bundled-by-you embedder).
- GPU optional via `ort` execution providers (`ORT_DYLIB_PATH` / EP Cargo features); same
code path falls back to CPU.
#### 2.5 Indexing pipeline (lifecycle hook + backfill)
- **`src/infrastructure/services/face_indexing_service.rs`** (new) implements
`FileLifecycleHook` (same pattern as `media_metadata_service.rs`): on image create →
decode (reuse decode path) → detect → **quality-gate** (score ≥0.7, face ≥50–80px,
Laplacian blur) → embed → store. **Dedup by `blob_hash`**: identical photos reuse faces.
- **Backfill**: a throttled background job over the existing library on the **maintenance
pool**.
#### 2.6 Clustering (incremental + periodic) — application service
**`src/application/services/people_service.rs`** (new). All methods
`*_with_perms(caller_id)` → `authz.require(...)` → audit on deny.
- **Online (per import):** ANN candidate via pgvector `<=>` + **exact cosine re-rank**;
assign to existing Person if within the *match* threshold (tighter), else leave
unassigned. Thresholds: form ≈ cosine-sim 0.75–0.80 (Euclid ≈ 0.5); match tighter (≈0.4
Euclid) for precision.
- **Periodic full re-cluster:** threshold connected-components over the user's faces;
`minFaces ≥3` to promote a cluster to a Person; singletons → "Unknown".
#### 2.7 HTTP endpoints + routes (AuthZ in service)
**`src/interfaces/api/handlers/people_handler.rs`** (new):
- `GET /api/people` — persons (cover + count).
- `GET /api/people/{id}/photos`.
- `PATCH /api/people/{id}` — rename.
- `POST /api/people/merge` · `/split` · `POST /api/people/{id}/hide`.
- `GET /api/files/{id}/faces` — face boxes for lightbox tagging.
- Settings: enable/disable, re-index, **delete all my face data**.
Register in `routes.rs`. Wire service in `di.rs` (`Option<Arc<PeopleService>>`).
#### 2.8 Frontend — People module
- **`static/js/features/library/people.js`** (+ `people.css`): grid of person tiles
(circular cover face + name), click → that person's photos; rename/merge/hide UI;
lightbox face boxes + "tag person".
- **`static/js/core/types.js`** — `Person`, `Face` typedefs.
- Mount under the **People** sub-nav tab (§0.7); show an explicit **opt-in consent** gate
before first indexing.
#### 2.9 (Optional, later) Semantic search
CLIP/SigLIP via the same `ort` stack → natural-language photo search ("beach", "cake").
Reuses the embedding-in-Postgres + ANN infrastructure.
---
## Vendoring & dependencies (need sign-off)
| Kind | Item | License | Notes |
|------|------|---------|-------|
| JS (vendor) | `maplibre-gl` (UMD+CSS) | BSD-3 | Map engine; no framework |
| JS (vendor) | `pmtiles.js` | BSD-3 | Range-reads `.pmtiles` in browser |
| JS (vendor) | `@protomaps/basemaps` style + assets | code BSD-3 / design CC0 | self-host glyphs+sprites |
| JS (vendor, opt) | `deck.gl` core+layers | MIT | thumbnail `IconLayer` only |
| Rust crate | `pmtiles` | MIT/Apache-2.0 | serve basemap from Axum (`cargo add`) |
| Rust crate | `ort` (+`ndarray`) | MIT/Apache-2.0 | ONNX runtime; ships `libonnxruntime.so` |
| Rust crate (opt) | `libheif-rs` | LGPL | HEIC decode (native dep) |
| PG extension | `pgvector` (→ `vectorchord`) | PostgreSQL / Apache-2.0 | 512-d embeddings + HNSW |
| Asset (basemap) | Protomaps `.pmtiles` | data ODbL | self-hosted; attribution required |
| ML models (runtime DL, NOT committed) | SCRFD + `buffalo_l` | **non-commercial** | personal self-host OK; commercial = license InsightFace |
Repo rules respected: no hand-editing `Cargo.lock`; no JS framework; design tokens only
for CSS; `target:"audit"` denial logs; AuthZ exclusively in services.
---
## Open decisions (need your call)
1. **Faces embedder strategy:** (a) **immich pattern** — runtime-download `buffalo_l`
(~97 IJB-C, non-commercial, recommended default) · (b) fully-permissive bundle —
retrain EdgeFace/GhostFaceNet (~94, real ML project) · (c) defer People.
2. **Basemap extent / hosting:** global z0–6 (~60 MB, simplest) vs regional extract vs
full planet (~120 GB) — and store path / how shipped.
3. **Vector store start:** `pgvector` now (simplest) vs `VectorChord` from day one
(immich's scaled choice).
4. **Map thumbnails:** start with count bubbles (MapLibre only) vs deck.gl `IconLayer`
from the start.
5. **HEIC:** in scope for Phase 0, or deferred (native `libheif` dep)?
6. **MapLibre vendoring approval** (new JS library — per repo rules, needs explicit OK).
---
## Suggested sequencing
| Phase | Risk | Notes |
|-------|------|-------|
| 0.1 virtualization | done | smoke-test pending |
| 0.2–0.5 polish | low | self-contained, verifiable |
| 1 Places | low–med | data ready; new vendored map + basemap serving |
| 2 People | high | new ML stack, pgvector, privacy, licensing decision |
| 0.6 HEIC / 2.9 search | opt | independent, schedule freely |
Recommended order: finish **Phase 0**, ship **Places**, then tackle **People** once the
embedder-licensing decision (#1) is made.
@@ -0,0 +1,187 @@
# OxiCloud Load Test Scaffolding (K6) — Scenario A
## Context
OxiCloud needs a repeatable way to **detect performance regressions** as features land. The current `tests/load/` directory is empty except for a README asking K6-vs-drill. Scenario A focuses on a single user with many subfolders and cascading systems (folder ops, ReBAC permission groups, nested subject groups) — measuring p50/p95/p99 to identify inflections when new features are added. Scenario B (many concurrent users) is deferred.
**Decisions already locked in via conversation:**
- K6 (Go-based, JS scripting in goja) over Rust testers — client overhead is dwarfed by server latency for these scenarios; regression deltas only require client *consistency*, not raw speed.
- **Two scenario tiers**: a **fast smoke** for PRs (~1 min, no regression gate — just verifies the harness still runs) and **long scenarios** for nightly + manual (regression-gated).
- **Hybrid seeding**: bulk fixtures via direct SQL (`cargo run --bin load-seed`); the resources each scenario actively touches go through REST so the measured path is realistic.
- **Result storage**: `baseline/baseline.json` committed in repo (p50/p95/p99 + tolerance per metric); per-run raw JSON kept locally / CI artifact, gitignored. Baseline updates are deliberate PRs.
- **No PR gating on long suite** — nightly on main + manual `workflow_dispatch` only.
## Approach
### 1. Directory layout
```
tests/load/
README.md
test.env # base_url=http://localhost:8088, admin creds
run.sh # full suite: spawn-db → seed → server → k6 → compare → cleanup
smoke.sh # smoke only: same shape, runs scenarios/smoke.js, no gate
compare.mjs # diffs k6 summary.json vs baseline/baseline.json
lib/
auth.js # login() → bearer token (POST /api/auth/login)
http.js # base URL + auth header helpers
metrics.js # custom k6 Trends, naming convention <scenario>.<op>
scenarios/
smoke.js # 1 VU, 1 iter — login, create folder, upload, list, delete
folder_cascade.js # list/move/copy/trash on depth-8 fanout-5 tree
share_cascade_rebac.js # grant a folder, fetch N descendants as grantee
subject_group_nested.js # nested-group chain (depth 3), grant via group, fetch
baseline/
baseline.json # committed; { "<scenario>.<op>": {p50,p95,p99,tolerance_pct} }
results/
.gitkeep # raw runs, gitignored
```
Mirrors the `tests/api/` shell pattern (run.sh, test.env, separate server port). Uses port **8088** to avoid colliding with api-test on 8087.
### 2. Rust bulk seeder — `src/bin/load-seed.rs`
New binary registered in `Cargo.toml` alongside `generate-openapi` and `migrate-nfc-filenames`.
**CLI:**
```
cargo run --bin load-seed -- \
--depth 8 --fanout 5 --files-per-leaf 10 \
--extra-users 20 --group-depth 3 --group-fanout 5
```
**Inserts via sqlx (one transaction per phase):**
- 1 admin + N extra users into `auth.users` (Argon2id with light params m=16384,t=1,p=1; same params as `src/infrastructure/services/password_hasher.rs` test path).
- One shared 0-byte blob row in `storage.blobs` with `ref_count = total_files`.
- Folder tree in `storage.folders`, **inserted level-by-level** so the `trg_folders_path` BEFORE-INSERT trigger can compute `path`/`lpath` from the parent chain.
- Files in `storage.files`, all referencing the shared blob hash for dedup.
- Subject groups in `auth.subject_groups` + membership rows in `auth.subject_group_members` (XOR `member_user_id`/`member_group_id`), forming a nested chain `G_root → G_mid → G_leaf → users`.
- ReBAC grants in `storage.access_grants` for a known set of test resources (used by `share_cascade_rebac.js`).
Reuses existing connection config: reads `DATABASE_URL` from env, same shape as `tests/common/server.env`.
**Critical gotchas surfaced during exploration:**
- Tree-ETag triggers (`folders_bump_tree_etag_*`) are STATEMENT-level (per commit `4200209d`), so bulk inserts fire them once per statement — safe.
- ReBAC cascade is computed in the application layer (`pg_acl_engine`), not at DB level. Seeder just inserts grant rows; the cascade is the *server* behavior we want to measure.
- `auth.subject_groups.name` is CITEXT, max 64, RFC-5321 local-part shape — generated names use `g_NNN` pattern to stay valid.
### 3. K6 scenarios
All scenarios load `baseline.json` at startup and set `thresholds` dynamically from it (`http_req_duration{op:x}: p(95)<baseline.x.p95 * (1 + tolerance_pct/100)`). This makes K6 itself fail the run on regression, *and* `compare.mjs` produces the human-readable diff.
**Endpoint contracts (confirmed by exploration):**
- Login: `POST /api/auth/login` body `{username, password}` → `{access_token, ...}`.
- Folder ops: `POST /api/folders`, `GET /api/folders/{id}/contents` (or `/contents/paginated` at high depth), `PUT /api/folders/{id}/move`, `POST /api/batch/folders/copy`, `DELETE /api/folders/{id}`. Handlers in `src/interfaces/api/handlers/folder_handler.rs` and `batch_handler.rs`.
- Grants: `POST /api/grants` (subject `{type, id|email}`, resource `{type, id}`, `role` or `permissions[]`), `GET /api/grants?resource_type=folder&resource_id=…`. Handler `src/interfaces/api/handlers/grant_handler.rs`.
- Groups: `POST /api/groups` (admin), `POST /api/groups/{id}/members` body `{user_id}` or `{group_id}`. Handler `src/interfaces/api/handlers/subject_group_handler.rs`. Nesting limit is 8 — `--group-depth 3` is well inside.
- Files: multipart `POST /api/files/upload` with fields `folder_id` + `file`.
**Per-scenario metric naming** (`metrics.js` enforces): `<scenario>.<op>` → e.g. `folder_cascade.list_depth8`, `share_cascade_rebac.fetch_as_grantee`, `subject_group_nested.grant_via_chain3`.
**Cold/warm split**: first iteration tagged `cold` (its metrics flow to `<scenario>.<op>_cold`), rest are warm. Single first iter avoids polluting warm metrics with one-shot connection setup.
### 4. Runner scripts
**`run.sh`** (mirrors `tests/api/run.sh` shape):
1. `spawn-db.sh` (reused from `tests/common/`)
2. `init-test-schema.sh` (reused)
3. `cargo run --bin load-seed -- <args>` against the test DB
4. Start `target/<profile>/oxicloud` on port 8088 with `OXICLOUD_STORAGE_PATH=tests/load/storage`
5. `wait_for_http http://localhost:8088/ready`
6. `k6 run --summary-export=results/$(date +%s).json scenarios/folder_cascade.js scenarios/share_cascade_rebac.js scenarios/subject_group_nested.js`
7. `node compare.mjs results/<latest>.json baseline/baseline.json` → exits non-zero on regression
8. `trap cleanup EXIT` tears down server + DB
**`smoke.sh`**: same shape but only runs `scenarios/smoke.js`, **skips the seeder** (smoke creates its own minimal data), **skips `compare.mjs`**. Goal is harness liveness, not regression.
### 5. `compare.mjs`
Plain Node script (no deps, just `fs` + `process`). Reads two JSONs, walks every metric in baseline, computes:
```
delta_pct = (current - baseline) / baseline * 100
```
Prints a human table:
```
folder_cascade.list_depth8.p95: 142ms → 198ms (+39%) REGRESSION (tolerance 10%)
share_cascade_rebac.grant.p95: 60ms → 58ms (-3%) ok
```
Exit 1 if any metric exceeds tolerance. Exit 0 otherwise. Missing metrics in current = exit 1 (suite drift); new metrics in current = warn only.
### 6. Baseline format
```json
{
"folder_cascade.list_depth8": { "p50": 12.0, "p95": 45.0, "p99": 80.0, "tolerance_pct": 10 },
"share_cascade_rebac.fetch_as_grantee": { "p50": 18.0, "p95": 55.0, "p99": 100.0, "tolerance_pct": 10 },
...
}
```
Values are placeholders — first run on a stable machine seeds real numbers. Updated only via the `load-baseline` recipe (which rewrites `baseline.json` from the latest run) and committed deliberately as `chore(load): accept new baseline for <reason>`.
### 7. Justfile recipes (added to existing `justfile`)
```
load: # full suite — nightly + manual
bash tests/load/run.sh
load-smoke: # fast harness check
bash tests/load/smoke.sh
load-baseline: # rerun, overwrite baseline from latest result
bash tests/load/run.sh
cp tests/load/results/$(ls -t tests/load/results/*.json | head -1) tests/load/baseline/baseline.json
load-seed: # standalone seeder for local poking
cargo run --bin load-seed -- --depth 8 --fanout 5 --files-per-leaf 10
```
Matches existing recipe naming (`test-*`, `front-*`, `api-test`).
### 8. CI workflows (stubs)
- **`.github/workflows/load-nightly.yml`** — cron `0 3 * * *` UTC + `workflow_dispatch`; runs `just load` on `ubuntu-latest` initially with a `# TODO: replace with self-hosted runner — shared GH runners produce noisy results for regression detection` comment; uploads `tests/load/results/*.json` as artifact; on `compare.mjs` non-zero, opens an issue using `peter-evans/create-issue-from-file` with the diff report.
- **`.github/workflows/load-smoke.yml`** — on every PR to `main`; runs `just load-smoke`; ~1 min budget; no regression gate, just "harness builds & runs."
### 9. Critical files to create / modify
**Create:**
- `tests/load/README.md`
- `tests/load/test.env`
- `tests/load/run.sh`, `smoke.sh`
- `tests/load/compare.mjs`
- `tests/load/lib/auth.js`, `http.js`, `metrics.js`
- `tests/load/scenarios/smoke.js`, `folder_cascade.js`, `share_cascade_rebac.js`, `subject_group_nested.js`
- `tests/load/baseline/baseline.json` (placeholder values)
- `tests/load/results/.gitkeep`
- `src/bin/load-seed.rs`
- `.github/workflows/load-nightly.yml`, `load-smoke.yml`
**Modify:**
- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `migrate-nfc-filenames` entry
- `justfile` — append four `load*` recipes
- `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/`
### 10. Reused existing utilities
- `tests/common/spawn-db.sh`, `stop-db.sh`, `init-test-schema.sh`, `server.env` — reused as-is by `run.sh` / `smoke.sh`.
- Argon2id parameters from `src/infrastructure/services/password_hasher.rs` test path — mirrored in `load-seed.rs` so test users can log in via the real auth flow.
- DB schema migrations (`migrations/`) — applied via the existing `init-test-schema.sh`; no schema changes needed.
## Verification
1. **Build**: `cargo build --bin load-seed` succeeds; `cargo clippy --all-features --all-targets -- -D warnings` stays green.
2. **Seeder works standalone**: `just db && just load-seed` populates `auth.users`, `storage.folders` (depth-8 tree visible via `SELECT count(*) FROM storage.folders WHERE user_id = …`), `auth.subject_groups`, `storage.access_grants`.
3. **Smoke runs locally**: `just load-smoke` — exits 0 in under ~90 s, leaves no orphan postgres/oxicloud processes.
4. **Full suite runs locally**: `just load` — k6 prints per-scenario p50/p95/p99; `compare.mjs` prints the diff table; since `baseline.json` is empty placeholders, expect a "REGRESSION" stub-report that the user reviews before running `just load-baseline` to accept the first real baseline.
5. **Baseline workflow**: after first `just load`, `just load-baseline` rewrites `baseline.json`; subsequent `just load` exits 0 (within tolerance).
6. **CI smoke**: trigger `load-smoke.yml` on a draft PR; confirm it completes under the time budget and uploads no artifacts on success.
7. **CI nightly**: manually trigger `load-nightly.yml` via `workflow_dispatch`; confirm artifact upload and (since no real baseline yet) confirm issue creation works.
## Out of scope
- Real baseline values (first run on user's hardware seeds them).
- Self-hosted runner setup (workflow stub uses `ubuntu-latest` with a TODO comment).
- Scenario B (many concurrent users) — deferred per user request.
- Goose / Rust-tester alternative — deferred until scenario B demonstrates K6 saturation issues.
- Additional scenarios (CalDAV / CardDAV / WebDAV / search depth) — the scaffolding makes them additive: drop a new file in `scenarios/`, add its metric names to `baseline.json`.
+1401
View File
File diff suppressed because it is too large Load Diff
@@ -602,20 +602,29 @@ impl Role {
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Editor => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update],
Role::Manager => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update,
Permission::Share],
Role::Admin => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update,
Permission::Share, Permission::Delete],
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],
}
}
}
```
> **Note (D-Prep, 2026-06-17):** the `Manager` role was retired before shipping
> (its bundle was a strict subset of `Owner`); the historical `Admin` role was
> renamed to `Owner` to disambiguate from `UserRole::Admin` (the user-account
> privilege) and match Drive plan terminology. `Contributor` is the new
> drop-zone role. The actual on-the-wire enum lives in
> `src/application/dtos/grant_dto.rs`; that file is the canonical source of
> truth for bundle expansion. The pivot to role-keyed storage (`role_grants`
> table) also happened in D-Prep — see
> `docs/architecture/rebac-authorization.md` for the dual-write timeline.
### `POST /api/grants` accepts either shape
```json
+37
View File
@@ -224,6 +224,43 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
# Set to false to prevent users from browsing the user directory.
#OXICLOUD_EXPOSE_SYSTEM_USERS=true
# ── People (face recognition) ────────────────────────────────────────────
# Biometric data (GDPR Art. 9) — OFF by default, opt-in per deployment.
# Detects faces and clusters them into people in the photo library.
#
# Requires ALL of:
# 1. a binary built with the `faces-onnx` cargo feature
# (`cargo build --release --features faces-onnx`),
# 2. OXICLOUD_ENABLE_FACES=true,
# 3. the ONNX Runtime shared library + two operator-provided ONNX models
# (a SCRFD/RetinaFace detector with 5-point landmarks, and an ArcFace
# 512-d embedder — e.g. InsightFace `buffalo_l`). Models are NOT shipped.
# Without all three, the People pipeline stays inert (no-op analyzer) and the
# server still boots; the People tab stays hidden in the UI.
#OXICLOUD_ENABLE_FACES=false
# Path to libonnxruntime.{so,dylib,dll}. Falls back to ORT_DYLIB_PATH.
# Use the ONNX Runtime build matching this app's `ort` crate (>= 1.24).
#OXICLOUD_FACES_ORT_DYLIB=/opt/onnxruntime/lib/libonnxruntime.so
# Face detector model (SCRFD/RetinaFace, 5-point landmarks).
#OXICLOUD_FACES_DETECTOR_MODEL=/var/lib/oxicloud/models/scrfd_10g_bnkps.onnx
# Face embedder model (ArcFace, 112x112 input -> 512-d output).
#OXICLOUD_FACES_EMBEDDER_MODEL=/var/lib/oxicloud/models/w600k_r50.onnx
# Detector square input size in px (default: 640)
#OXICLOUD_FACES_DET_SIZE=640
# Minimum detector confidence to keep a face, 0..1 (default: 0.5)
#OXICLOUD_FACES_DET_THRESHOLD=0.5
# IoU threshold for non-maximum suppression, 0..1 (default: 0.4)
#OXICLOUD_FACES_NMS_THRESHOLD=0.4
# ONNX Runtime intra-op threads; 0 = let ONNX Runtime decide (default: 0)
#OXICLOUD_FACES_INTRA_THREADS=0
# WASM plugin runtime (Extism). Requires a binary built with the `plugins`
# cargo feature (`cargo run --features plugins`); without that feature these
# vars are inert. Untrusted plugins run sandboxed: no filesystem, no network,
+22
View File
@@ -189,3 +189,25 @@ dev:
backend=$!
trap 'kill $backend 2>/dev/null' EXIT INT TERM
cd frontend && npm run dev
# k6 load suite — full scenarios + regression diff vs baseline/load.json.
# Used by the nightly workflow and on demand. Release build for fair timings.
load:
bash tests/load/run.sh
# k6 smoke — single happy-path iteration. PR-tier liveness check, no gate.
load-smoke:
bash tests/load/smoke.sh
# Re-run the full suite, then bake the latest summary into baseline/load.json.
# The leading `-` lets `run.sh`'s regression-exit not abort the bake step;
# review the diff and commit deliberately:
# chore(load): accept new baseline for <reason>
load-baseline:
-bash tests/load/run.sh
node tests/load/bake-baseline.mjs
# Standalone seeder (poking around in psql). Needs the test DB up:
# just db
load-seed:
cargo run --bin load-seed -- --depth 5 --fanout 4 --files-per-leaf 3
+244
View File
@@ -0,0 +1,244 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D-Prep: storage.role_grants — role-bundle replacement for access_grants
-- ════════════════════════════════════════════════════════════════════════════
-- Refactor #1 of the Drive sequence (see `docs/plan/drive.md` § Prerequisite).
--
-- Today every role assignment is stored as N rows in `storage.access_grants`
-- (one row per Permission in the role's bundle — editor = 4 rows, owner = 6).
-- This migration introduces `storage.role_grants` where each role assignment
-- is ONE row carrying the role name; permission expansion happens at engine
-- read time via the in-code `role_bundle()` function.
--
-- The five roles shipped on day one:
-- viewer = {read}
-- commenter = {comment, read} ← new
-- contributor = {create, read} ← new
-- editor = {comment, create, read, update}
-- owner = {comment, create, delete, read, share, update}
-- (post-Drive: + manage, when Group-as-Resource lands)
--
-- This migration is **additive**: `storage.access_grants` stays populated as
-- a dual-write safety net until a follow-up cleanup PR drops it after the
-- new model has baked in production. The down migration just drops
-- role_grants — access_grants is untouched, so rollback is trivial.
--
-- Pre-flight: the migration REFUSES to run if `access_grants` contains any
-- non-bundle clusters (permission sets that don't match one of the five
-- roles above). Run `tools/audit-grants-bundle-shape.sql` first to confirm
-- the data is clean — Ed's audit on 2026-06-17 returned 100% bundle-shaped.
-- ── 1. Pre-flight assertion ─────────────────────────────────────────────────
-- Refuse to migrate if there are any non-bundle clusters. The five known
-- bundles are listed here verbatim; keep them in sync with the in-code
-- `role_bundle()` function.
DO $BODY$
DECLARE
bad_count BIGINT;
BEGIN
WITH cluster AS (
SELECT subject_type, subject_id, resource_type, resource_id,
array_agg(permission ORDER BY permission) AS perms
FROM storage.access_grants
GROUP BY 1, 2, 3, 4
)
SELECT count(*) INTO bad_count
FROM cluster
WHERE perms NOT IN (
ARRAY['read']::text[],
ARRAY['comment','read']::text[],
ARRAY['create','read']::text[],
ARRAY['comment','create','read','update']::text[],
ARRAY['comment','create','delete','read','share','update']::text[]
);
IF bad_count > 0 THEN
RAISE EXCEPTION
'D-Prep migration refused: % (subject,resource) clusters in '
'storage.access_grants have non-bundle permission sets. Run '
'tools/audit-grants-bundle-shape.sql section 3 to inspect them, '
'then either resolve manually or extend the bundle list above '
'with a new named role before retrying.', bad_count;
END IF;
END $BODY$;
-- ── 2. The role_grants table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS storage.role_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the role)
-- 'user' → auth.users.id
-- 'group' → storage.subject_groups.id
-- 'token' → storage.shares.id (anonymous link — always 'viewer')
subject_type TEXT NOT NULL
CHECK (subject_type IN ('user', 'group', 'token')),
subject_id UUID NOT NULL,
-- Resource (what the role is on)
-- 'drive' and 'group' join later as Drive + Group-as-Resource land.
resource_type TEXT NOT NULL
CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Role — expands to a permission bundle via the in-code `role_bundle()`
-- function. The CHECK lists the day-one role roster; adding a new
-- role is a single ALTER TABLE DROP CONSTRAINT / ADD CONSTRAINT pair
-- (or replace with a foreign key into a lookup table if instance-
-- defined roles ever land).
--
-- Universal roster: ANY role can be granted on ANY resource_type.
-- Permission bundles include capabilities the resource type may not
-- check for (e.g. `Manage` on a folder, `Create` on a file); those
-- produce harmless no-ops at engine read time — no per-resource-type
-- validation needed at the DB layer.
--
-- The UI exposes only Viewer/Editor/Owner in the share dialog today
-- (matches the existing 3-button UX). Commenter and Contributor stay
-- in the enum for server-side use + future UI exposure when a real
-- use case asks for them.
role TEXT NOT NULL
CHECK (role IN ('viewer', 'commenter', 'contributor', 'editor', 'owner')),
-- Audit + lifecycle
granted_by UUID NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
-- Exactly one role per (subject, resource). Atomic role changes become
-- a single UPDATE; no DELETE+INSERT race.
UNIQUE (subject_type, subject_id, resource_type, resource_id)
);
COMMENT ON TABLE storage.role_grants IS
'Role-based ReBAC grants. One row = one role assignment. Permission '
'bundle expansion is in-code; see role_bundle() in '
'src/application/dtos/grant_dto.rs. Replaces storage.access_grants; '
'both tables coexist during the D-Prep dual-write window.';
COMMENT ON COLUMN storage.role_grants.role IS
'One of viewer / commenter / contributor / editor / owner. Expanded to '
'a Permission bundle by the in-code role_bundle() function at engine '
'read time.';
-- ── 3. Indexes — match the hot-path queries ─────────────────────────────────
-- "What does this caller have access to?" — every WebDAV / NC request,
-- every UI default-drive resolution (post-Drive) hits this.
CREATE INDEX IF NOT EXISTS idx_role_grants_subject
ON storage.role_grants (subject_type, subject_id);
-- "Who has access to this resource?" — share dialogs, audit views.
CREATE INDEX IF NOT EXISTS idx_role_grants_resource
ON storage.role_grants (resource_type, resource_id);
-- Partial index on expiry — only rows that actually expire (mirrors the
-- access_grants index pattern, same rationale).
CREATE INDEX IF NOT EXISTS idx_role_grants_expires_at
ON storage.role_grants (expires_at) WHERE expires_at IS NOT NULL;
-- For GET /api/grants/outgoing/resources (who granted what).
CREATE INDEX IF NOT EXISTS idx_role_grants_granted_by
ON storage.role_grants (granted_by);
-- ── 4. Backfill from access_grants ─────────────────────────────────────────
-- For each (subject, resource) cluster in access_grants, write one
-- role_grants row with the matching role. The CASE expression mirrors
-- `Role::expand()` exactly — when that function changes (new role added),
-- update both this CASE and the CHECK constraint above.
--
-- expires_at: take MIN across the cluster (most conservative — the role
-- assignment expires at the earliest expiry of any of its constituent
-- grants). granted_at: MIN (when the role assignment started). granted_by:
-- the granter of the earliest row (preserves attribution to the admin who
-- initially set the role up).
WITH cluster AS (
SELECT subject_type,
subject_id,
resource_type,
resource_id,
array_agg(permission ORDER BY permission) AS perms,
MIN(granted_at) AS earliest_granted_at,
MIN(expires_at) AS earliest_expires_at
FROM storage.access_grants
GROUP BY 1, 2, 3, 4
),
earliest_grantor AS (
SELECT DISTINCT ON (subject_type, subject_id, resource_type, resource_id)
subject_type,
subject_id,
resource_type,
resource_id,
granted_by
FROM storage.access_grants
ORDER BY subject_type, subject_id, resource_type, resource_id, granted_at ASC
)
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by, granted_at, expires_at)
SELECT
c.subject_type,
c.subject_id,
c.resource_type,
c.resource_id,
CASE c.perms
WHEN ARRAY['read']::text[]
THEN 'viewer'
WHEN ARRAY['comment','read']::text[]
THEN 'commenter'
WHEN ARRAY['create','read']::text[]
THEN 'contributor'
WHEN ARRAY['comment','create','read','update']::text[]
THEN 'editor'
WHEN ARRAY['comment','create','delete','read','share','update']::text[]
THEN 'owner'
END AS role,
eg.granted_by,
c.earliest_granted_at,
c.earliest_expires_at
FROM cluster c
JOIN earliest_grantor eg USING (subject_type, subject_id, resource_type, resource_id)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING;
-- ── 5. Post-flight consistency check ───────────────────────────────────────
-- Assert that the backfill landed one role_grants row per (subject,
-- resource) cluster in access_grants. Any mismatch means a bundle pattern
-- silently failed to match — refuses to commit, surfacing the bug.
DO $BODY$
DECLARE
expected_clusters BIGINT;
actual_role_grants BIGINT;
null_roles BIGINT;
BEGIN
SELECT count(*) INTO expected_clusters
FROM (
SELECT 1 FROM storage.access_grants
GROUP BY subject_type, subject_id, resource_type, resource_id
) c;
SELECT count(*) INTO actual_role_grants FROM storage.role_grants;
IF expected_clusters != actual_role_grants THEN
RAISE EXCEPTION
'D-Prep backfill consistency check failed: expected % role_grants '
'rows (one per distinct (subject, resource) cluster in access_grants), '
'got %. Investigate before declaring the migration successful.',
expected_clusters, actual_role_grants;
END IF;
-- Defensive: NULL role would mean the CASE expression failed to match.
-- Pre-flight already refuses this, but double-check.
SELECT count(*) INTO null_roles FROM storage.role_grants WHERE role IS NULL;
IF null_roles > 0 THEN
RAISE EXCEPTION
'D-Prep backfill produced % role_grants rows with NULL role — '
'a bundle pattern slipped past the pre-flight check. Investigate.',
null_roles;
END IF;
END $BODY$;
@@ -0,0 +1,72 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Cleanup #1: storage.role_grants.role — TEXT → storage.grant_role ENUM
-- ════════════════════════════════════════════════════════════════════════════
-- D-Prep shipped `role_grants.role` as TEXT + CHECK constraint. Promoting it
-- to a native PostgreSQL ENUM gives us three things at once:
--
-- 1. Index-driven sort by role strength. The ENUM values are declared in
-- strength order — owner first, viewer last. `ORDER BY role ASC` then
-- yields the UX-mandated "strongest first" ordering (Owner → Editor →
-- Contributor → Commenter → Viewer) without a CASE expression. The
-- `idx_role_grants_subject` / `idx_role_grants_resource` indexes can be
-- extended (or composite-augmented) with the role column for index-only
-- ordered scans.
--
-- 2. Type-level safety. The CHECK constraint goes away; invalid roles fail
-- at the column type, not at row insertion. One contract instead of two
-- (column type AND check constraint).
--
-- 3. Cleaner query shape. Every listing query that used the strength CASE
-- becomes a plain `ORDER BY role` after this migration.
--
-- Trade-off accepted: PostgreSQL ENUMs allow ADD VALUE (with BEFORE / AFTER
-- positional anchors) and RENAME VALUE, but not DROP VALUE or arbitrary
-- reorder. The OxiCloud role roster is intentionally stable — new roles get
-- appended, none get reordered or removed. Confirmed with Ed.
--
-- This migration must run BEFORE the access_grants drop, since it's purely
-- about role_grants.role.
-- ── 1. Create the ENUM type ────────────────────────────────────────────────
-- Declaration order = sort order. Strongest first so `ORDER BY role ASC`
-- matches the UX requirement (max permission → least permission).
CREATE TYPE storage.grant_role AS ENUM (
'owner', -- ordinal 0, sorts first
'editor', -- ordinal 1
'contributor', -- ordinal 2
'commenter', -- ordinal 3
'viewer' -- ordinal 4, sorts last
);
COMMENT ON TYPE storage.grant_role IS
'Role-keyed grant strength. Declaration order is sort order: ORDER BY '
'role ASC yields owner → viewer (strongest → weakest), matching the '
'share-dialog and shared-with-me UX. Adding a new role is ALTER TYPE '
'ADD VALUE; renaming is ALTER TYPE RENAME VALUE. Dropping or reordering '
'is not supported — adjust the roster only by append.';
-- ── 2. Drop the redundant CHECK constraint ─────────────────────────────────
-- The inline CHECK on role_grants.role was auto-named
-- `role_grants_role_check` by PostgreSQL. Drop it before the type swap —
-- the ENUM now enforces the same invariant at the column level.
ALTER TABLE storage.role_grants
DROP CONSTRAINT IF EXISTS role_grants_role_check;
-- ── 3. Convert role TEXT → storage.grant_role ──────────────────────────────
-- USING cast: text values are guaranteed to be one of the five valid labels
-- (the dropped CHECK enforced this; the D-Prep backfill only produced these
-- five values). If a stray value slipped through, the cast errors out and
-- the migration aborts — preferable to silently coercing.
ALTER TABLE storage.role_grants
ALTER COLUMN role TYPE storage.grant_role
USING role::storage.grant_role;
COMMENT ON COLUMN storage.role_grants.role IS
'One of owner / editor / contributor / commenter / viewer. Expanded to '
'a Permission bundle by the in-code role_bundle() function at engine '
'read time. Sort order matches declaration order in storage.grant_role.';
@@ -0,0 +1,122 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Cleanup #2: cascade triggers for storage.role_grants
-- ════════════════════════════════════════════════════════════════════════════
-- The D-Prep migration created `storage.role_grants` but no cascade triggers.
-- Until now, role_grants stayed consistent because the application-layer
-- lifecycle hooks (`engine.revoke_all_for_resource` / `_subject`) wiped rows
-- on the canonical delete paths, AND the existing `trg_cleanup_grants_*`
-- triggers kept `storage.access_grants` clean as a defence-in-depth net.
--
-- The follow-up cleanup PR drops `access_grants` (and its triggers) entirely.
-- Without this migration that drop would leave `role_grants` without any
-- DB-level safety net — direct SQL, future codepaths that forget to call the
-- engine hooks, and any other bypass route could orphan rows whose subject
-- or resource has already been deleted.
--
-- This migration mirrors the four forward + one reverse triggers from
-- `20260520000000_rebac_access_grants.sql` and `20260612000001_share_grant_
-- reverse_cascade.sql`, retargeted at `storage.role_grants`. Same shape, same
-- AFTER-DELETE semantics, same idempotent CREATE OR REPLACE patterns.
--
-- During the transition window (this migration applied; `access_grants` not
-- yet dropped) both sets of triggers coexist — they target different tables
-- and don't conflict. Once `access_grants` is dropped, the old triggers and
-- their helper functions vanish in the same migration.
-- ── 1. Forward cascade: resource delete → cleanup role_grants ──────────────
-- Fires AFTER DELETE on storage.folders / storage.files; deletes every
-- role_grants row referencing that resource. TG_ARGV[0] discriminates which
-- resource_type the trigger is wired for.
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_resource_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.role_grants
WHERE resource_type = TG_ARGV[0]
AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_folder ON storage.folders;
CREATE TRIGGER trg_cleanup_role_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('folder');
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_file ON storage.files;
CREATE TRIGGER trg_cleanup_role_grants_file
AFTER DELETE ON storage.files
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('file');
-- ── 2. Forward cascade: subject delete → cleanup role_grants ───────────────
-- Fires AFTER DELETE on auth.users / storage.shares; deletes every
-- role_grants row referencing that subject. Groups are NOT wired here —
-- `subject_group_service::delete()` performs that cascade transactionally
-- in application code, mirroring the historical access_grants behaviour.
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_subject_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.role_grants
WHERE subject_type = TG_ARGV[0]
AND subject_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_user ON auth.users;
CREATE TRIGGER trg_cleanup_role_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('user');
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_token ON storage.shares;
CREATE TRIGGER trg_cleanup_role_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('token');
-- ── 3. Reverse cascade: last-token-grant delete → cleanup storage.shares ───
-- A caller hitting DELETE /api/grants/{id} on a token's role grant would
-- otherwise leave the storage.shares row stranded — the token still
-- resolves to "no access" (cascade query finds no rows), but the metadata
-- row accumulates forever.
--
-- With role_grants the UNIQUE (subject, resource) constraint guarantees a
-- token has at most ONE role grant per resource, so "the last grant for a
-- token" collapses to "the only grant for that token". The NOT EXISTS
-- guard still works correctly — it just always evaluates the same way for
-- token subjects.
--
-- The DELETE on storage.shares is a no-op when the share row is already
-- gone (the forward cascade `trg_cleanup_role_grants_token` is in flight
-- and already removed it). Idempotent in both directions.
CREATE OR REPLACE FUNCTION storage.cleanup_share_on_last_role_grant_delete()
RETURNS trigger AS $$
BEGIN
IF OLD.subject_type = 'token' THEN
DELETE FROM storage.shares s
WHERE s.id = OLD.subject_id
AND NOT EXISTS (
SELECT 1 FROM storage.role_grants rg
WHERE rg.subject_type = 'token'
AND rg.subject_id = OLD.subject_id
);
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_share_on_role_grant_delete ON storage.role_grants;
CREATE TRIGGER trg_cleanup_share_on_role_grant_delete
AFTER DELETE ON storage.role_grants
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_share_on_last_role_grant_delete();
COMMENT ON FUNCTION storage.cleanup_share_on_last_role_grant_delete() IS
'Reverse cascade: deletes storage.shares row when its last token role grant is removed. Pairs with trg_cleanup_role_grants_token (forward direction).';
@@ -0,0 +1,63 @@
-- ════════════════════════════════════════════════════════════════════════════
-- Cleanup #3: drop storage.access_grants (and everything attached to it)
-- ════════════════════════════════════════════════════════════════════════════
-- The final step of the role-keyed ReBAC cleanup. By the time this migration
-- runs:
--
-- * Every read path goes through `storage.role_grants` (cleanup #1 / #2).
-- * The engine no longer has a `grant()` method; `set_role()` /
-- `clear_role()` are the only writes.
-- * The HTTP surface (`POST /api/grants`, `PUT /api/grants/role`) only
-- accepts role-keyed shapes.
-- * `share_service`, `subject_group_service`, `auth_application_service`,
-- `share_pg_repository`, and `integration_test_support` all read
-- `role_grants` exclusively.
-- * `storage.role_grants` has its own cascade triggers
-- (`trg_cleanup_role_grants_*`) and reverse-cascade
-- (`trg_cleanup_share_on_role_grant_delete`), added in cleanup #2.
--
-- So `access_grants` is fully unreferenced — we can drop it together with
-- the helper triggers + functions defined in
-- `20260520000000_rebac_access_grants.sql` and
-- `20260612000001_share_grant_reverse_cascade.sql`.
--
-- Roll-back posture: this is destructive. There is no down migration. The
-- D-Prep backfill is one-way (role-keyed rows are derived from
-- permission-keyed clusters; the reverse reconstruction would need a fixed
-- bundle mapping that may have shifted between releases). Recovering
-- requires restoring from a backup taken before this migration runs.
-- ── 1. Drop the access_grants triggers FROM their source tables ────────────
-- These triggers live on storage.folders / storage.files / auth.users /
-- storage.shares. Dropping access_grants doesn't implicitly remove them
-- (the trigger row points at the source table; the body references the
-- target table, and that body is what breaks once access_grants is gone).
-- Drop them explicitly so subsequent DELETEs on those source tables don't
-- error out.
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
-- The reverse-cascade trigger is ON access_grants and goes away with the
-- table — but the IF EXISTS makes this safe regardless of drop order.
DROP TRIGGER IF EXISTS trg_cleanup_share_on_grant_delete ON storage.access_grants;
-- ── 2. Drop the trigger helper functions ────────────────────────────────────
-- No other code references these — the `cleanup_role_grants_*` equivalents
-- defined in cleanup #2 carry the same behaviour against role_grants.
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_resource_delete();
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_subject_delete();
DROP FUNCTION IF EXISTS storage.cleanup_share_on_last_token_grant_delete();
-- ── 3. Drop the table ──────────────────────────────────────────────────────
-- CASCADE removes any remaining dependent objects (indexes, comments, and
-- the reverse-cascade trigger if it survived step 1). With every Rust code
-- path already routed through role_grants, nothing in the application
-- layer will notice.
DROP TABLE IF EXISTS storage.access_grants CASCADE;
@@ -0,0 +1,10 @@
-- ════════════════════════════════════════════════════════════════════════
-- Places (photo map): partial index for fast bounding-box scans over the
-- caller's geotagged photos. Plain B-tree on (longitude, latitude); no
-- PostGIS required. The partial predicate keeps the index small — only rows
-- that actually carry GPS coordinates are indexed.
-- ════════════════════════════════════════════════════════════════════════
CREATE INDEX IF NOT EXISTS idx_file_metadata_geo
ON storage.file_metadata (longitude, latitude)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
+47
View File
@@ -0,0 +1,47 @@
-- ════════════════════════════════════════════════════════════════════════
-- People / Faces: per-user face detections and identity clusters.
--
-- Embeddings are stored as BYTEA (512 × float32, L2-normalized = 2048 bytes)
-- rather than a pgvector column, so the feature adds NO new PostgreSQL
-- extension dependency. Similarity is computed in-app (brute-force cosine
-- scales comfortably to ~100k faces); pgvector / VectorChord with an HNSW
-- index is the documented upgrade path for larger libraries.
--
-- Biometric data — the feature is OFF by default (OXICLOUD_ENABLE_FACES) and
-- opt-in per user. All rows cascade-delete with their owning user, and face
-- rows cascade-delete with their source file, satisfying the right to erasure.
-- ════════════════════════════════════════════════════════════════════════
CREATE SCHEMA IF NOT EXISTS faces;
-- An identity cluster ("person"). display_name is NULL until the user names it.
CREATE TABLE IF NOT EXISTS faces.persons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT,
cover_face_id UUID, -- representative face (set by the app)
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_persons_user ON faces.persons (user_id);
-- A single detected face with its embedding and (optional) person assignment.
CREATE TABLE IF NOT EXISTS faces.faces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL,
bbox REAL[] NOT NULL, -- [x, y, w, h], normalized 0..1
det_score REAL NOT NULL, -- detector confidence
quality REAL, -- blur/size gate score (nullable)
embedding BYTEA NOT NULL, -- 512 × float32, L2-normalized
blob_hash VARCHAR(64), -- dedup-aware reuse across identical files
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_faces_user ON faces.faces (user_id);
CREATE INDEX IF NOT EXISTS idx_faces_person ON faces.faces (person_id);
CREATE INDEX IF NOT EXISTS idx_faces_file ON faces.faces (file_id);
CREATE INDEX IF NOT EXISTS idx_faces_blob ON faces.faces (blob_hash);
@@ -0,0 +1,47 @@
-- Garbage-collection safety for orphaned blobs.
--
-- The dedup GC deletes a blob row (committed) and then unlinks the backing
-- file. A concurrent uploader of identical content can re-reference a chunk in
-- that window. Two mechanisms make the sweep safe:
-- (a) garbage_collect() never collects a blob still referenced by a manifest
-- (chunk) or a file (legacy whole-file blob) — cross-checks backed by
-- idx_chunk_manifests_chunk_hashes_gin and idx_files_blob_hash. A stale
-- ref_count = 0 on live content can then only delay collection, never
-- delete it.
-- (b) garbage_collect() never collects a blob that became unreferenced only
-- moments ago — the grace period below, mirroring git's gc.pruneExpire,
-- so a writer about to pin a just-orphaned chunk cannot race the sweep.
--
-- `orphaned_at` records when ref_count last reached 0. NULL means the row is
-- referenced (ref_count > 0) or predates this column.
ALTER TABLE storage.blobs ADD COLUMN IF NOT EXISTS orphaned_at TIMESTAMPTZ;
-- Existing orphans start their grace window now, so applying this migration
-- never triggers an immediate sweep of content a writer might still be racing.
UPDATE storage.blobs
SET orphaned_at = now()
WHERE ref_count <= 0 AND orphaned_at IS NULL;
-- GC scan index: orphan rows ordered by when they became collectible. Replaces
-- the old ref_count-only partial index (the GC now also filters on orphaned_at).
DROP INDEX IF EXISTS storage.idx_blobs_orphaned;
CREATE INDEX IF NOT EXISTS idx_blobs_gc_eligible
ON storage.blobs (orphaned_at) WHERE ref_count = 0;
-- Stamp orphaned_at when a file delete drops a blob's ref_count to 0, so the
-- grace window starts at the moment of orphaning. No-op for multi-chunk files
-- whose file_hash is not itself a storage.blobs row.
CREATE OR REPLACE FUNCTION storage.decrement_blob_ref()
RETURNS trigger AS $$
BEGIN
UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = OLD.blob_hash;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
COMMENT ON COLUMN storage.blobs.orphaned_at IS
'When ref_count last reached 0; GC waits a grace period past this before deleting (NULL = referenced or pre-migration)';
+30 -9
View File
@@ -17,6 +17,19 @@ use crate::application::adapters::webdav_adapter::{
};
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
/// Returns whether `caller_id` owns `calendar`.
///
/// CalDAV clients (DAVx5, Apple Calendar, Thunderbird) only mount a collection
/// read-write when its `current-user-privilege-set` advertises `<D:write/>`, so
/// this gate decides read-only vs read-write for the caller. `caller_id` and
/// [`CalendarDto::owner_id`] are both the user's UUID rendered via
/// `Uuid::to_string()`, so a direct comparison is exact. Calendars merely shared
/// with the caller (non-owner access) stay read-only for now — this never
/// over-grants write.
fn caller_owns_calendar(calendar: &CalendarDto, caller_id: &str) -> bool {
!caller_id.is_empty() && calendar.owner_id == caller_id
}
/// CalDAV report type
#[derive(Debug, PartialEq)]
pub enum CalDavReportType {
@@ -205,6 +218,7 @@ impl CalDavAdapter {
request: &PropFindRequest,
base_href: &str,
username: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -227,6 +241,7 @@ impl CalDavAdapter {
calendar,
request,
&format!("{}{}/", base_href, calendar.id),
caller_id,
)?;
}
@@ -242,6 +257,7 @@ impl CalDavAdapter {
calendars: &[CalendarDto],
request: &PropFindRequest,
base_href: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -261,6 +277,7 @@ impl CalDavAdapter {
calendar,
request,
&format!("{}{}/", base_href, calendar.id),
caller_id,
)?;
}
@@ -557,6 +574,7 @@ impl CalDavAdapter {
calendar: &CalendarDto,
request: &PropFindRequest,
href: &str,
caller_id: &str,
) -> Result<()> {
// Start response element
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
@@ -576,7 +594,7 @@ impl CalDavAdapter {
match &request.prop_find_type {
PropFindType::AllProp => {
// Write all standard properties for a calendar
Self::write_calendar_standard_props(xml_writer, calendar)?;
Self::write_calendar_standard_props(xml_writer, calendar, caller_id)?;
}
PropFindType::PropName => {
// Write only property names (empty elements)
@@ -584,7 +602,7 @@ impl CalDavAdapter {
}
PropFindType::Prop(props) => {
// Write requested properties
Self::write_calendar_requested_props(xml_writer, calendar, props)?;
Self::write_calendar_requested_props(xml_writer, calendar, props, caller_id)?;
}
}
@@ -609,6 +627,7 @@ impl CalDavAdapter {
fn write_calendar_standard_props<W: Write>(
xml_writer: &mut Writer<W>,
calendar: &CalendarDto,
caller_id: &str,
) -> Result<()> {
// Common WebDAV properties
@@ -676,9 +695,10 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
// Only add write privilege if user owns the calendar or has write access
if calendar.owner_id == "current_user_id" {
// This should be replaced with actual user check
// Advertise write only when the caller owns the calendar. Clients
// (DAVx5, Apple Calendar, Thunderbird) mount the collection read-only
// unless this privilege is present.
if caller_owns_calendar(calendar, caller_id) {
xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
@@ -735,6 +755,7 @@ impl CalDavAdapter {
xml_writer: &mut Writer<W>,
calendar: &CalendarDto,
props: &[QualifiedName],
caller_id: &str,
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
@@ -780,9 +801,8 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
// Only add write privilege if user owns the calendar or has write access
if calendar.owner_id == "current_user_id" {
// This should be replaced with actual user check
// Advertise write only when the caller owns the calendar.
if caller_owns_calendar(calendar, caller_id) {
xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
@@ -882,6 +902,7 @@ impl CalDavAdapter {
request: &PropFindRequest,
base_href: &str,
depth: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -894,7 +915,7 @@ impl CalDavAdapter {
))?;
// Write the calendar collection itself
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href)?;
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?;
// If depth > 0, include event resources
if depth != "0" {
@@ -171,6 +171,7 @@ mod tests {
&calendars,
&request,
"/caldav/",
"user-001",
);
assert!(
@@ -210,6 +211,7 @@ mod tests {
&request,
"/caldav/cal-001",
"0",
"user-001",
);
assert!(
@@ -240,6 +242,7 @@ mod tests {
&request,
"/caldav/cal-001",
"1",
"user-001",
);
assert!(
@@ -258,6 +261,54 @@ mod tests {
);
}
#[test]
fn test_owner_gets_write_privilege_but_non_owner_is_read_only() {
// Regression for #480: the privilege gate previously compared owner_id
// against the literal "current_user_id", so <D:write/> was never emitted
// and every CalDAV client mounted calendars read-only.
let calendar = sample_calendar(); // owner_id = "user-001"
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
// Owner → read + write.
let mut owner_out = Vec::new();
CalDavAdapter::generate_calendar_collection_propfind(
&mut owner_out,
&calendar,
&[],
&request,
"/caldav/cal-001/",
"0",
"user-001",
)
.expect("owner propfind");
let owner_xml = String::from_utf8(owner_out).expect("utf8");
assert!(
owner_xml.contains("D:write"),
"Owner must be granted <D:write/>, got: {owner_xml}"
);
// A different caller (e.g. a read-only share) → read only, never write.
let mut other_out = Vec::new();
CalDavAdapter::generate_calendar_collection_propfind(
&mut other_out,
&calendar,
&[],
&request,
"/caldav/cal-001/",
"0",
"a-different-user",
)
.expect("non-owner propfind");
let other_xml = String::from_utf8(other_out).expect("utf8");
assert!(other_xml.contains("D:read"), "Non-owner keeps <D:read/>");
assert!(
!other_xml.contains("D:write"),
"Non-owner must NOT get <D:write/>, got: {other_xml}"
);
}
// ========================
// Calendar events response tests
// ========================
@@ -434,6 +485,7 @@ mod tests {
&request,
"/caldav/",
"testuser",
"user-001",
);
assert!(
result.is_ok(),
@@ -483,6 +535,7 @@ mod tests {
&request,
"/caldav/",
"testuser",
"user-001",
);
assert!(result.is_ok());
@@ -565,6 +618,7 @@ mod tests {
&request,
"/caldav/cal-001/",
"0",
"user-001",
);
assert!(result.is_ok(), "Failed: {:?}", result.err());
+257 -8
View File
@@ -17,6 +17,20 @@ use crate::application::adapters::webdav_adapter::{
use crate::application::dtos::address_book_dto::AddressBookDto;
use crate::application::dtos::contact_dto::ContactDto;
/// Render a requested property as a namespaced response element name, mapping
/// the known namespaces to their response prefixes (`D:` for DAV, `CR:` for
/// CardDAV). Used for the catch-all arms of the requested-property writers so
/// the prefix mapping lives in exactly one place.
fn carddav_prop_name(prop: &QualifiedName) -> String {
if prop.namespace == "urn:ietf:params:xml:ns:carddav" {
format!("CR:{}", prop.name)
} else if prop.namespace == "DAV:" {
format!("D:{}", prop.name)
} else {
prop.name.clone()
}
}
/// CardDAV report type
#[derive(Debug, PartialEq)]
pub enum CardDavReportType {
@@ -163,6 +177,96 @@ impl CardDavAdapter {
Ok(())
}
/// Generate a PROPFIND response for the CardDAV root `/carddav/`.
///
/// Mirrors the CalDAV root: emits a discovery entry for `/carddav/` itself
/// advertising `current-user-principal` and `addressbook-home-set` (the
/// properties DAVx5 / Apple Contacts read to locate address books), then —
/// at Depth > 0 — one entry per address book. Without these discovery
/// properties clients never find the address books at all.
pub fn generate_root_propfind_response<W: Write>(
writer: W,
address_books: &[AddressBookDto],
request: &PropFindRequest,
base_href: &str,
username: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
Self::write_carddav_root_response(&mut xml_writer, request, base_href, username)?;
for book in address_books {
Self::write_addressbook_response(
&mut xml_writer,
book,
request,
&format!("{}{}/", base_href, book.id),
)?;
}
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// Generate a PROPFIND response for a CardDAV user principal resource at
/// `/carddav/principals/{username}/`.
///
/// Returns `addressbook-home-set` so clients can resolve the collection
/// holding the user's address books, plus a self-referential
/// `current-user-principal`.
pub fn generate_principal_propfind_response<W: Write>(
writer: W,
request: &PropFindRequest,
username: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
let href = format!("/carddav/principals/{}/", username);
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
match &request.prop_find_type {
PropFindType::AllProp | PropFindType::PropName => {
Self::write_carddav_principal_props(&mut xml_writer, username)?;
}
PropFindType::Prop(props) => {
Self::write_carddav_principal_requested_props(&mut xml_writer, username, props)?;
}
}
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// Generate PROPFIND for a single address book collection + contacts
pub fn generate_addressbook_collection_propfind<W: Write>(
writer: W,
@@ -384,14 +488,159 @@ impl CardDavAdapter {
.write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?;
}
_ => {
let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" {
format!("CR:{}", prop.name)
} else if prop.namespace == "DAV:" {
format!("D:{}", prop.name)
} else {
prop.name.clone()
};
xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?;
xml_writer
.write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?;
}
}
}
Ok(())
}
/// Write a populated `current-user-principal` element pointing at the user's
/// CardDAV principal. Shared by the root and principal discovery responses.
fn write_current_user_principal<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"/carddav/principals/{}/",
username
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
Ok(())
}
/// Write a populated `addressbook-home-set` element pointing at the user's
/// address-book home collection. Shared by the root and principal responses.
fn write_addressbook_home_set<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("CR:addressbook-home-set")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"/carddav/{}/",
username
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::End(BytesEnd::new("CR:addressbook-home-set")))?;
Ok(())
}
/// Write the root `/carddav/` discovery entry.
fn write_carddav_root_response<W: Write>(
xml_writer: &mut Writer<W>,
request: &PropFindRequest,
href: &str,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
match &request.prop_find_type {
PropFindType::AllProp => {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
Self::write_current_user_principal(xml_writer, username)?;
Self::write_addressbook_home_set(xml_writer, username)?;
}
PropFindType::PropName => {
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
xml_writer
.write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("CR:addressbook-home-set")))?;
}
PropFindType::Prop(props) => {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
("DAV:", "resourcetype") => {
xml_writer
.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer
.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
}
("DAV:", "current-user-principal") => {
Self::write_current_user_principal(xml_writer, username)?;
}
("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => {
Self::write_addressbook_home_set(xml_writer, username)?;
}
_ => {
xml_writer.write_event(Event::Empty(BytesStart::new(
carddav_prop_name(prop),
)))?;
}
}
}
}
}
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
Ok(())
}
/// Write the standard properties for a CardDAV principal resource.
fn write_carddav_principal_props<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
xml_writer.write_event(Event::Text(BytesText::new(username)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
Self::write_addressbook_home_set(xml_writer, username)?;
Self::write_current_user_principal(xml_writer, username)?;
Ok(())
}
/// Write the requested properties for a CardDAV principal resource.
fn write_carddav_principal_requested_props<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
props: &[QualifiedName],
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
("DAV:", "resourcetype") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
}
("DAV:", "displayname") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
xml_writer.write_event(Event::Text(BytesText::new(username)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
}
("DAV:", "current-user-principal") => {
Self::write_current_user_principal(xml_writer, username)?;
}
("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => {
Self::write_addressbook_home_set(xml_writer, username)?;
}
_ => {
xml_writer
.write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?;
}
}
}
@@ -307,6 +307,97 @@ mod tests {
);
}
#[test]
fn test_root_propfind_advertises_principal_and_home_set() {
// Regression for #480: without these discovery properties DAVx5 / Apple
// Contacts never locate the user's address books.
let books = vec![sample_address_book()];
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
let mut output = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut output,
&books,
&request,
"/carddav/",
"testuser",
)
.expect("root propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(
xml.contains("/carddav/principals/testuser/"),
"Root must expose current-user-principal href, got: {xml}"
);
assert!(
xml.contains("/carddav/testuser/"),
"Root must expose addressbook-home-set href, got: {xml}"
);
// Depth 1 also enumerates the books.
assert!(xml.contains("ab-001"), "Should list address book");
}
#[test]
fn test_root_propfind_prop_request_returns_populated_discovery() {
// A DAVx5-style targeted request for the two discovery properties.
let request = PropFindRequest {
prop_find_type: PropFindType::Prop(vec![
QualifiedName {
namespace: "DAV:".to_string(),
name: "current-user-principal".to_string(),
},
QualifiedName {
namespace: "urn:ietf:params:xml:ns:carddav".to_string(),
name: "addressbook-home-set".to_string(),
},
]),
};
let mut output = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut output,
&[],
&request,
"/carddav/",
"testuser",
)
.expect("root propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(xml.contains("/carddav/principals/testuser/"));
assert!(xml.contains("/carddav/testuser/"));
// Properties must be populated, not empty self-closing placeholders.
assert!(!xml.contains("<D:current-user-principal/>"));
assert!(!xml.contains("<CR:addressbook-home-set/>"));
}
#[test]
fn test_principal_propfind_returns_home_set() {
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
let mut output = Vec::new();
CardDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser")
.expect("principal propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(
xml.contains("/carddav/principals/testuser/"),
"Principal href should be present"
);
assert!(
xml.contains("/carddav/testuser/"),
"addressbook-home-set should be present"
);
assert!(
xml.contains("D:principal"),
"resourcetype should include principal"
);
}
#[test]
fn test_generate_addressbook_collection_propfind_depth_0() {
let addressbook = sample_address_book();
+26
View File
@@ -0,0 +1,26 @@
//! DTOs for the "Places" (photo map) feature.
use serde::Serialize;
use utoipa::ToSchema;
/// A geographic bounding box in decimal degrees.
#[derive(Debug, Clone, Copy)]
pub struct GeoBounds {
pub west: f64,
pub south: f64,
pub east: f64,
pub north: f64,
}
/// A clustered group of geotagged photos within one aggregation cell.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GeoCluster {
/// Cluster centroid longitude.
pub lng: f64,
/// Cluster centroid latitude.
pub lat: f64,
/// Number of photos in the cluster.
pub count: i64,
/// A representative photo id, for the cluster thumbnail.
pub sample_file_id: String,
}
+89 -55
View File
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
@@ -95,6 +95,7 @@ pub enum PermissionDto {
Comment,
Delete,
Update,
Manage,
}
impl From<PermissionDto> for Permission {
@@ -106,6 +107,7 @@ impl From<PermissionDto> for Permission {
PermissionDto::Comment => Permission::Comment,
PermissionDto::Delete => Permission::Delete,
PermissionDto::Update => Permission::Update,
PermissionDto::Manage => Permission::Manage,
}
}
}
@@ -119,57 +121,58 @@ impl From<Permission> for PermissionDto {
Permission::Comment => PermissionDto::Comment,
Permission::Delete => PermissionDto::Delete,
Permission::Update => PermissionDto::Update,
Permission::Manage => PermissionDto::Manage,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Roles (DTO-layer sugar)
// Roles — the load-bearing model for ReBAC grants
// ════════════════════════════════════════════════════════════════════════════
//
// One row per role assignment in `storage.role_grants.role` (a
// `storage.grant_role` ENUM). The engine expands the bundle at query time
// via `Role::expand()` on the domain enum. Adding a role is two edits:
// the variant + match arm on `Role`, and an `ALTER TYPE
// storage.grant_role ADD VALUE 'name'` migration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
/// Wire-format wrapper around the domain `Role` enum. Carries the
/// serde/utoipa derives. Maps 1:1 to/from `Role` via `From`.
///
/// The historical `"admin"` alias for `Owner` (used during the D-Prep
/// dual-write window for cached clients) has been retired in the cleanup
/// PR — the OxiCloud UI emits `"owner"` exclusively. Stragglers receive
/// a 422 on POST/PUT, which surfaces the upgrade cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
pub enum RoleDto {
Viewer,
//Commenter,
Commenter,
Contributor,
Editor,
//Manager,
Admin,
Owner,
}
impl Role {
/// Expands a role into its constituent raw permissions. Storage and
/// engine know nothing about roles — the server normalizes here before
/// writing rows.
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
/* reserved for future
Role::Commenter => &[Permission::Read, Permission::Comment],
*/
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
/* reserved for future
Role::Manager => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
],
*/
Role::Admin => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
],
impl From<RoleDto> for Role {
fn from(r: RoleDto) -> Self {
match r {
RoleDto::Viewer => Role::Viewer,
RoleDto::Commenter => Role::Commenter,
RoleDto::Contributor => Role::Contributor,
RoleDto::Editor => Role::Editor,
RoleDto::Owner => Role::Owner,
}
}
}
impl From<Role> for RoleDto {
fn from(r: Role) -> Self {
match r {
Role::Viewer => RoleDto::Viewer,
Role::Commenter => RoleDto::Commenter,
Role::Contributor => RoleDto::Contributor,
Role::Editor => RoleDto::Editor,
Role::Owner => RoleDto::Owner,
}
}
}
@@ -206,17 +209,18 @@ pub enum SubjectInputDto {
},
}
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
/// `POST /api/grants` — create or refresh a role assignment.
///
/// Strictly role-keyed since the cleanup PR: callers send exactly one
/// role; the engine writes a single row in `storage.role_grants`. The
/// historical per-permission shape (`permissions: [...]`) was dropped —
/// the OxiCloud UI is the only known caller and it already sends `role`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectInputDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
pub role: RoleDto,
/// Optional expiry for the grant. RFC 3339 / ISO 8601.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -226,7 +230,7 @@ pub struct CreateGrantDto {
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
pub role: RoleDto,
/// Optional expiry applied to every grant written or updated by this call.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -241,7 +245,10 @@ pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
/// Role-keyed since D-Prep cleanup — one row in `storage.role_grants`
/// is one `GrantDto`. The bundle of underlying permissions is implied
/// by the role and recomputed client-side from the same lookup table.
pub role: RoleDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -254,7 +261,7 @@ impl From<Grant> for GrantDto {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
role: g.role.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
expires_at: g.expires_at,
@@ -430,12 +437,34 @@ pub struct SharedWithMeItemDto {
}
/// Derive the closest-matching role label from a set of permissions.
/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`.
///
/// **Legacy helper for the dual-write window.** Once D-Prep ships and the
/// engine reads `role_grants.role` directly, this function becomes unused
/// and is dropped in the cleanup PR. Kept here so callers that still hit
/// `access_grants` and reconstruct a role for display can stay working
/// during the transition.
///
/// Emits the new five-role roster on output (`"viewer"` / `"commenter"` /
/// `"contributor"` / `"editor"` / `"owner"`). Note this is **lossy** for
/// permission sets that don't match a bundle exactly — but D-Prep's
/// pre-flight refuses to migrate any such cluster, so post-migration data
/// only contains bundle-shaped sets.
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
"admin"
} else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) {
let has_read = perms.contains(&Permission::Read);
let has_comment = perms.contains(&Permission::Comment);
let has_create = perms.contains(&Permission::Create);
let has_update = perms.contains(&Permission::Update);
let has_delete = perms.contains(&Permission::Delete);
let has_share = perms.contains(&Permission::Share);
if has_delete && has_share {
"owner"
} else if has_create && has_update {
"editor"
} else if has_read && has_create && !has_update {
"contributor"
} else if has_read && has_comment && !has_create && !has_update {
"commenter"
} else {
"viewer"
}
@@ -457,7 +486,12 @@ pub struct OutgoingResourceGrantDto {
pub subject_id: Uuid,
/// Human-readable label (username for users, share name for tokens).
pub subject_display: String,
/// Derived role label: `"viewer"` | `"editor"` | `"admin"`.
/// Role label: `"viewer"` | `"commenter"` | `"contributor"` | `"editor"`
/// | `"owner"`. Emitted by `role_from_permissions()` during the dual-write
/// window; once D-Prep cleanup lands this is read directly from
/// `storage.role_grants.role`. The legacy `"admin"` spelling is no longer
/// emitted — clients that cached it must accept `"owner"` too (the API
/// `Role::parse` still accepts `"admin"` on input for one release).
pub role: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
+2
View File
@@ -10,9 +10,11 @@ pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
pub mod folder_listing_dto;
pub mod geo_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod people_dto;
pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
+30
View File
@@ -0,0 +1,30 @@
//! DTOs for the People (faces) API.
use serde::Serialize;
use utoipa::ToSchema;
/// A named (or unnamed) identity cluster, with a cover photo for its tile.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PersonDto {
pub id: String,
/// `None` until the user names the person.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// File id of the cover face's photo, for the tile thumbnail.
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_file_id: Option<String>,
pub face_count: i64,
pub is_hidden: bool,
}
/// One face box within a photo (for tagging overlays in the lightbox).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FaceBoxDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub person_id: Option<String>,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
+36 -32
View File
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
ResourceKind, Subject,
ResourceKind, Role, Subject,
};
pub trait AuthorizationEngine: Send + Sync + 'static {
@@ -90,11 +90,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// Resources explicitly granted to `subject`. Direct grants only — no
/// cascade expansion. Used by `GET /api/grants/incoming`.
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError>;
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError>;
/// Cursor-paginated list of resources explicitly granted to `subject`,
/// optionally filtered by resource kind. Multiple permission rows for the
@@ -139,38 +135,20 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint; if the row already exists its `expires_at` is updated.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Update `expires_at` on every grant row for the given subject.
/// Used when a share's expiry is changed — one call updates all
/// permission rows for that token in a single UPDATE.
/// Update `expires_at` for every role grant belonging to `subject`.
/// Used by `share_service` when a token-share's expiry is refreshed —
/// the subject (token) maps to a small fixed set of role grants, so a
/// single UPDATE covers them. Resource-scoped expiry changes go through
/// `set_role` (which carries `expires_at` as part of its UPSERT).
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Update `expires_at` on every grant row for the given `(subject, resource)`
/// pair. Used by `set_role` to sync the expiry of retained grants when the
/// caller changes expiry without changing permissions.
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
/// whether or not the row existed. The id comes from a prior listing
/// or `find_grant_full_by_id` lookup.
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
/// Removes every grant whose `resource` matches. Called by lifecycle
@@ -181,4 +159,30 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// Removes every grant whose `subject` matches. Called when a user/token
/// /group is deleted. Returns the count of rows removed.
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
// ── Role-keyed grant operations ────────────────────────────────────────
// These are the only grant write path. Lifecycle hook bulk-deletes
// (`revoke_all_for_*` above) wipe matching rows directly, so callers
// using those paths don't need to invoke `clear_role` separately.
/// Set the role for a `(subject, resource)` pair. Idempotent via the
/// UNIQUE `(subject_type, subject_id, resource_type, resource_id)`
/// constraint — `ON CONFLICT` updates the role + expires_at if they
/// changed, which is exactly the right semantics for an atomic role
/// change (e.g. promoting Viewer → Editor in one UPDATE with no race
/// window, no DELETE+INSERT).
async fn set_role(
&self,
granted_by: Uuid,
subject: Subject,
role: Role,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Remove the role for a `(subject, resource)` pair. Idempotent —
/// succeeds whether or not the row existed. Called after `revoke`
/// succeeds to keep the two tables in sync during dual-write; after
/// cleanup this is the canonical role-revocation entry point.
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>;
}
+4 -1
View File
@@ -112,7 +112,10 @@ pub trait DedupPort: Send + Sync + 'static {
/// Remove a reference from a blob.
///
/// Returns `true` if the blob was deleted (ref_count reached 0).
/// Returns `true` if the last reference was removed (the content is now
/// unreferenced). For CDC content the now-orphaned chunks are reclaimed
/// later by garbage collection rather than unlinked inline; legacy
/// whole-file blobs are still freed eagerly.
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
/// Calculate BLAKE3 hash of a file (streaming).
+78
View File
@@ -0,0 +1,78 @@
//! Ports for the People (faces) feature.
use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{DetectedFace, Face, Person};
/// Detects faces in an image and produces an aligned, L2-normalized embedding
/// for each. Takes raw encoded bytes (it decodes internally) so the
/// application layer stays decoupled from any image/ML crate.
///
/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer))
/// is a no-op that reports `is_ready() == false`; a real ONNX-backed
/// implementation is wired in when the operator provides models at runtime.
#[async_trait]
pub trait FaceAnalyzerPort: Send + Sync + 'static {
/// Whether a usable model is loaded. When false, indexing is skipped.
fn is_ready(&self) -> bool;
/// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…).
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError>;
}
/// Persistence for faces and persons. Every method is user-scoped; the
/// repository enforces `WHERE user_id = …` so callers only ever touch their
/// own biometric data.
#[async_trait]
pub trait FaceRepository: Send + Sync + 'static {
// ── faces ──────────────────────────────────────────────────────
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Faces previously computed for any file sharing this content hash —
/// lets indexing reuse results for deduplicated (identical) uploads.
async fn faces_for_blob(
&self,
user_id: Uuid,
blob_hash: &str,
) -> Result<Vec<Face>, DomainError>;
async fn assign_person(
&self,
face_id: Uuid,
person_id: Option<Uuid>,
) -> Result<(), DomainError>;
// ── persons ────────────────────────────────────────────────────
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
async fn rename_person(
&self,
user_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError>;
async fn set_person_cover(
&self,
person_id: Uuid,
cover_face_id: Uuid,
) -> Result<(), DomainError>;
async fn set_person_hidden(
&self,
user_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError>;
/// File ids that contain a face assigned to this person (most recent first).
async fn files_for_person(
&self,
user_id: Uuid,
person_id: Uuid,
) -> Result<Vec<Uuid>, DomainError>;
/// Hard-delete every face and person for a user (right to erasure /
/// disabling the feature).
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>;
}
+1
View File
@@ -10,6 +10,7 @@ pub mod compression_ports;
pub mod content_index_ports;
pub mod dedup_ports;
pub mod email_sender;
pub mod face_ports;
pub mod favorites_ports;
pub mod file_lifecycle;
pub mod file_ports;
@@ -1388,7 +1388,7 @@ impl AuthApplicationService {
/// Visibility rule, evaluated top-to-bottom:
/// 1. **Self lookup** — `caller_id == target_id` always succeeds.
/// 2. **Shared-grant relationship** — caller and target appear
/// together on at least one row of `storage.access_grants`,
/// together on at least one row of `storage.role_grants`,
/// either direction (caller-as-granter / target-as-subject,
/// or target-as-granter / caller-as-subject). Applies to both
/// internal and external callers. This is what lets an
@@ -1454,7 +1454,7 @@ impl AuthApplicationService {
let related: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants
FROM storage.role_grants
WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2)
OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1)
LIMIT 1
@@ -250,6 +250,17 @@ impl FileRetrievalService {
let stream = self.file_read.get_file_stream(id).await?;
Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream))))
}
/// Batch counterpart of [`FileRetrievalUseCase::get_file`]: resolve many
/// file ids in ONE query instead of one per id. Like `get_file` it
/// performs no per-file authorization — both current callers (ACL grant
/// listing, NextCloud favorites REPORT) resolve ids already vetted by the
/// authorization engine or the favorites table. Missing or trashed ids are
/// absent from the result; callers re-associate by `id`.
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<FileDto>, DomainError> {
let files = self.file_read.get_files_by_ids(ids).await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
}
impl FileRetrievalUseCase for FileRetrievalService {
@@ -29,6 +29,17 @@ impl FolderService {
}
}
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
/// query instead of one per id. Like `get_folder` it performs no
/// per-folder authorization — both current callers (ACL grant listing,
/// NextCloud favorites REPORT) resolve ids already vetted by the
/// authorization engine or the favorites table. Missing or trashed ids
/// are absent from the result; callers re-associate by `id`.
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<FolderDto>, DomainError> {
let folders = self.folder_storage.get_folders_by_ids(ids).await?;
Ok(folders.into_iter().map(FolderDto::from).collect())
}
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
/// `DomainError::not_found` on parse error (anti-enumeration — the same
/// error as "folder does not exist").
+2
View File
@@ -20,6 +20,8 @@ pub mod magic_link_invite_service;
pub mod music_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
pub mod people_service;
pub mod places_service;
pub mod recent_service;
pub mod recipient_notification_service;
pub mod search_service;
+271
View File
@@ -0,0 +1,271 @@
//! People (faces) use cases: identity clustering + the read/mutation methods
//! the HTTP layer calls.
//!
//! Clustering is a full re-cluster over the user's faces: a union-find groups
//! faces whose embeddings are within a cosine threshold (connected
//! components), and groups of at least `min_faces` become a "person". This is
//! O(n²) in the user's face count — fine for moderate libraries; an ANN index
//! (pgvector/VectorChord) is the documented scale-up.
//!
//! Strictly user-scoped (the repository filters by user), so — like
//! `RecentService` / `PlacesService` — no `AuthorizationEngine` check is
//! needed: the `caller_id` parameter is the access scope.
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto};
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::Person;
use crate::infrastructure::repositories::pg::FacePgRepository;
/// Cosine similarity of two equal-length vectors. Embeddings are produced
/// L2-normalized, so this is ~a dot product; we normalize anyway for safety.
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
for (&x, &y) in a.iter().zip(b.iter()) {
dot += x * y;
na += x * x;
nb += y * y;
}
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na.sqrt() * nb.sqrt())
}
/// Disjoint-set with path-halving + union by rank.
struct UnionFind {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl UnionFind {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, mut x: usize) -> usize {
while self.parent[x] != x {
self.parent[x] = self.parent[self.parent[x]];
x = self.parent[x];
}
x
}
fn union(&mut self, a: usize, b: usize) {
let (ra, rb) = (self.find(a), self.find(b));
if ra == rb {
return;
}
match self.rank[ra].cmp(&self.rank[rb]) {
std::cmp::Ordering::Less => self.parent[ra] = rb,
std::cmp::Ordering::Greater => self.parent[rb] = ra,
std::cmp::Ordering::Equal => {
self.parent[rb] = ra;
self.rank[ra] += 1;
}
}
}
}
pub struct PeopleService {
repo: Arc<FacePgRepository>,
/// Min cosine similarity to link two faces into the same identity.
cluster_threshold: f32,
/// Min faces in a cluster before it becomes a named-able "person".
min_faces: usize,
}
impl PeopleService {
pub fn new(repo: Arc<FacePgRepository>) -> Self {
Self {
repo,
cluster_threshold: 0.5,
min_faces: 3,
}
}
/// Re-cluster a user's faces. Returns the number of new persons created.
pub async fn recluster(&self, user_id: Uuid) -> Result<usize, DomainError> {
let faces = self.repo.faces_for_user(user_id).await?;
let n = faces.len();
if n == 0 {
return Ok(0);
}
let mut uf = UnionFind::new(n);
for i in 0..n {
for j in (i + 1)..n {
if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold {
uf.union(i, j);
}
}
}
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
let root = uf.find(i);
groups.entry(root).or_default().push(i);
}
let mut created = 0usize;
for idxs in groups.into_values() {
if idxs.len() < self.min_faces {
// Too small to be a person — leave/reset these faces unassigned.
for &i in &idxs {
if faces[i].person_id.is_some() {
self.repo.assign_person(faces[i].id, None).await?;
}
}
continue;
}
// Reuse an existing person on this cluster (preserves a user's name)
// or mint a new one.
let existing = idxs.iter().find_map(|&i| faces[i].person_id);
let person_id = match existing {
Some(pid) => pid,
None => {
let pid = Uuid::new_v4();
let person = Person {
id: pid,
user_id,
display_name: None,
cover_face_id: Some(faces[idxs[0]].id),
is_hidden: false,
created_at: Utc::now(),
};
self.repo.create_person(&person).await?;
created += 1;
pid
}
};
for &i in &idxs {
if faces[i].person_id != Some(person_id) {
self.repo
.assign_person(faces[i].id, Some(person_id))
.await?;
}
}
let _ = self
.repo
.set_person_cover(person_id, faces[idxs[0]].id)
.await;
}
Ok(created)
}
/// People (non-empty clusters), most-photographed first.
pub async fn list_people(&self, caller_id: Uuid) -> Result<Vec<PersonDto>, DomainError> {
let persons = self.repo.persons_for_user(caller_id).await?;
let faces = self.repo.faces_for_user(caller_id).await?;
let mut count: HashMap<Uuid, i64> = HashMap::new();
let mut face_file: HashMap<Uuid, Uuid> = HashMap::new();
for f in &faces {
if let Some(pid) = f.person_id {
*count.entry(pid).or_default() += 1;
}
face_file.insert(f.id, f.file_id);
}
let mut out: Vec<PersonDto> = persons
.into_iter()
.filter_map(|p| {
let c = count.get(&p.id).copied().unwrap_or(0);
if c == 0 {
return None; // hide empty clusters (e.g. after a merge)
}
let cover_file_id = p
.cover_face_id
.and_then(|fid| face_file.get(&fid).copied())
.map(|u| u.to_string());
Some(PersonDto {
id: p.id.to_string(),
name: p.display_name,
cover_file_id,
face_count: c,
is_hidden: p.is_hidden,
})
})
.collect();
out.sort_by(|a, b| b.face_count.cmp(&a.face_count));
Ok(out)
}
/// File ids of a person's photos (most recent first).
pub async fn person_photos(
&self,
caller_id: Uuid,
person_id: Uuid,
) -> Result<Vec<String>, DomainError> {
let files = self.repo.files_for_person(caller_id, person_id).await?;
Ok(files.into_iter().map(|u| u.to_string()).collect())
}
/// Face boxes within a photo (for lightbox tagging), caller-scoped.
pub async fn faces_for_file(
&self,
caller_id: Uuid,
file_id: Uuid,
) -> Result<Vec<FaceBoxDto>, DomainError> {
let faces = self.repo.faces_for_file(file_id).await?;
Ok(faces
.into_iter()
.filter(|f| f.user_id == caller_id)
.map(|f| FaceBoxDto {
id: f.id.to_string(),
person_id: f.person_id.map(|u| u.to_string()),
x: f.bbox.x,
y: f.bbox.y,
w: f.bbox.w,
h: f.bbox.h,
})
.collect())
}
pub async fn rename_person(
&self,
caller_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError> {
self.repo.rename_person(caller_id, person_id, name).await
}
pub async fn set_hidden(
&self,
caller_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError> {
self.repo
.set_person_hidden(caller_id, person_id, hidden)
.await
}
/// Merge `from` into `into` by reassigning all of `from`'s faces. The
/// now-empty `from` person is hidden by `list_people`.
pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> {
let faces = self.repo.faces_for_user(caller_id).await?;
for f in faces.into_iter().filter(|f| f.person_id == Some(from)) {
self.repo.assign_person(f.id, Some(into)).await?;
}
Ok(())
}
/// Erase all of the caller's face data (right to erasure / opt-out).
pub async fn delete_all(&self, caller_id: Uuid) -> Result<(), DomainError> {
self.repo.delete_all_for_user(caller_id).await
}
}
@@ -0,0 +1,45 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
use crate::common::errors::DomainError;
use crate::infrastructure::repositories::pg::FileBlobReadRepository;
/// "Places" use case: the caller's geotagged photos aggregated into map
/// clusters.
///
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
/// like [`RecentService`](super::recent_service::RecentService) and the photos
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
/// parameter *is* the access scope.
pub struct PlacesService {
file_read: Arc<FileBlobReadRepository>,
}
impl PlacesService {
pub fn new(file_read: Arc<FileBlobReadRepository>) -> Self {
Self { file_read }
}
/// Aggregation cell side, in degrees, for a slippy-map zoom level. The
/// world (360°) is split into `2^zoom` tiles; we use ~4 cells per tile so
/// clusters refine as the user zooms in. Clamped to a sane range.
fn cell_for_zoom(zoom: u8) -> f64 {
let z = i32::from(zoom.min(20));
360.0 / (2_f64.powi(z) * 4.0)
}
/// Clustered geotagged photos for `caller_id` within `bounds`.
pub async fn clusters(
&self,
caller_id: Uuid,
bounds: GeoBounds,
zoom: u8,
) -> Result<Vec<GeoCluster>, DomainError> {
let cell = Self::cell_for_zoom(zoom);
self.file_read
.list_geo_clusters(caller_id, bounds, cell)
.await
}
}
+6 -6
View File
@@ -5,7 +5,7 @@ use tokio::sync::Semaphore;
use uuid::Uuid;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::authorization::{Resource, Role, Subject};
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -254,9 +254,9 @@ impl ShareUseCase for ShareService {
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Create one Read-only grant for the token subject, carrying expires_at.
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
// cleans up this grant when the share is later deleted.
// Anonymous link tokens always get the Viewer role (read-only).
// The `trg_cleanup_grants_token` trigger cleans up this grant when
// the share row is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
@@ -267,10 +267,10 @@ impl ShareUseCase for ShareService {
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
.grant(
.set_role(
user_id,
Subject::Token(saved_share.id()),
Permission::Read,
Role::Viewer,
resource,
expires_dt,
)
@@ -5,7 +5,7 @@
//! - Name validation runs (defence-in-depth alongside the DB CHECK).
//! - Virtual groups (e.g. `Internal`) are protected from mutation.
//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`.
//! - Cascading delete of `storage.access_grants` rows referencing this
//! - Cascading delete of `storage.role_grants` rows referencing this
//! group runs in the same transaction as the group delete.
//!
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
@@ -172,9 +172,9 @@ impl SubjectGroupService {
/// Delete the group; cascades to:
/// - `auth.subject_group_members` rows (FK CASCADE).
/// - `storage.access_grants` rows where `subject_type='group'` and
/// - `storage.role_grants` rows where `subject_type='group'` and
/// `subject_id = id` (handled here, no FK exists between
/// `access_grants` and `subject_groups`).
/// `role_grants` and `subject_groups`).
pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> {
let existing = self.get_by_id(id).await?;
if existing.is_virtual {
@@ -196,7 +196,7 @@ impl SubjectGroupService {
})?;
let grants_deleted = sqlx::query(
"DELETE FROM storage.access_grants
"DELETE FROM storage.role_grants
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(id)
@@ -519,9 +519,9 @@ mod integration_tests {
// ── 13. Grants are revoked atomically when a group is deleted ──────────
//
// The plan said "FK CASCADE", but there's no FK between `access_grants`
// and `subject_groups` (different schemas; the cascade is handled by the
// service's transactional DELETE). This test pins that behaviour.
// There is no FK between `storage.role_grants` and `auth.subject_groups`
// (different schemas); the cascade is handled by the service's
// transactional DELETE. This test pins that behaviour.
#[tokio::test]
async fn test_grants_revoked_when_group_deleted() {
let svc = make_service().await;
@@ -534,21 +534,21 @@ mod integration_tests {
.unwrap();
let resource_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO storage.access_grants \
"INSERT INTO storage.role_grants \
(subject_type, subject_id, resource_type, resource_id, \
permission, granted_by) \
VALUES ('group', $1, 'folder', $2, 'read', $3)",
role, granted_by) \
VALUES ('group', $1, 'folder', $2, 'viewer', $3)",
)
.bind(group.id)
.bind(resource_id)
.bind(admin)
.execute(svc.pool.as_ref())
.await
.expect("insert grant row");
.expect("insert role_grants row");
// Sanity: the grant exists.
let pre: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
@@ -561,7 +561,7 @@ mod integration_tests {
svc.delete(group.id, admin).await.unwrap();
let post: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
+8 -5
View File
@@ -568,9 +568,11 @@ impl TrashUseCase for TrashService {
}
}
Err(e) => {
// Check if the file is not found - in that case, we can continue
// because we still want to remove the item from the trash index
if format!("{}", e).contains("not found") {
// File already gone — still remove the trash index
// entry. Match on the typed error kind, not the
// message text, so a reworded message can't
// silently turn this into a hard failure.
if e.kind == ErrorKind::NotFound {
info!(
"File not found, may already have been deleted: {}",
file_id
@@ -608,8 +610,9 @@ impl TrashUseCase for TrashService {
info!("Successfully deleted folder permanently: {}", folder_id);
}
Err(e) => {
// Check if the folder is not found - in that case, we can continue
if format!("{}", e).contains("not found") {
// Folder already gone — still remove the trash
// index entry. Typed-kind match (see file branch).
if e.kind == ErrorKind::NotFound {
info!(
"Folder not found, may already have been deleted: {}",
folder_id
+756
View File
@@ -0,0 +1,756 @@
//! `load-seed` — bulk-seeds OxiCloud's test database for K6 load scenarios.
//!
//! Inserts users, a deep folder tree, files (all sharing one dedup'd blob),
//! nested subject groups, and ReBAC grants directly via sqlx — bypassing the
//! REST API so the seed phase is fast and doesn't pollute measured metrics.
//!
//! Only the resources each k6 scenario actively touches (the grant being
//! created, the move target, etc.) go through the HTTP API at run time.
//!
//! Run (from repo root, against the test DB on port 5433):
//!
//! ```bash
//! DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
//! cargo run --bin load-seed -- \
//! --depth 8 --fanout 3 --files-per-leaf 3 \
//! --extra-users 20 --group-depth 3 --group-fanout 5 \
//! --manifest tests/load/results/seed-manifest.json
//! ```
//!
//! Output: writes a JSON manifest at `--manifest` listing the IDs and
//! credentials each k6 scenario needs (admin login, grantee user, deep folder
//! IDs, group chain, etc.). K6 scenarios load this manifest at startup.
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
use rand_core::OsRng;
use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
// ── CLI ──────────────────────────────────────────────────────────────────
struct Args {
depth: u32,
fanout: u32,
files_per_leaf: u32,
extra_users: u32,
group_depth: u32,
group_fanout: u32,
manifest: PathBuf,
password: String,
}
impl Args {
fn parse() -> Self {
// Defaults match tests/load/test.env; both bound the exponential cost
// of fanout^depth. Override via flags or LOAD_* env vars.
let mut depth = 5;
let mut fanout = 4;
let mut files_per_leaf = 3;
let mut extra_users = 20;
let mut group_depth = 3;
let mut group_fanout = 5;
let mut manifest = PathBuf::from("tests/load/results/seed-manifest.json");
let mut password = "TestPassword1!".to_string();
let argv: Vec<String> = env::args().collect();
let mut i = 1;
while i < argv.len() {
let key = argv[i].as_str();
let val = || {
argv.get(i + 1)
.unwrap_or_else(|| panic!("missing value for {}", key))
};
match key {
"--depth" => depth = val().parse().expect("--depth must be u32"),
"--fanout" => fanout = val().parse().expect("--fanout must be u32"),
"--files-per-leaf" => {
files_per_leaf = val().parse().expect("--files-per-leaf must be u32");
}
"--extra-users" => {
extra_users = val().parse().expect("--extra-users must be u32");
}
"--group-depth" => {
group_depth = val().parse().expect("--group-depth must be u32");
}
"--group-fanout" => {
group_fanout = val().parse().expect("--group-fanout must be u32");
}
"--manifest" => manifest = PathBuf::from(val()),
"--password" => password = val().clone(),
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => panic!("unknown argument: {}", other),
}
i += 2;
}
Self {
depth,
fanout,
files_per_leaf,
extra_users,
group_depth,
group_fanout,
manifest,
password,
}
}
}
fn print_help() {
println!("load-seed — bulk-seed OxiCloud for k6 load tests");
println!();
println!("Reads DATABASE_URL from env. Inserts users, folders, files, groups, grants.");
println!("Writes a JSON manifest the k6 scenarios consume.");
println!();
println!("Flags (with defaults):");
println!(" --depth 5 folder tree depth (exponential: fanout^depth folders)");
println!(" --fanout 4 children per non-leaf folder");
println!(" --files-per-leaf 3 files inserted in each leaf folder");
println!(" --extra-users 20 non-admin users created");
println!(" --group-depth 3 nested subject-group chain length");
println!(" --group-fanout 5 users added directly to each leaf group");
println!(" --manifest <path> output manifest JSON path");
println!(" --password <pw> shared password for all seeded users");
}
// ── Manifest (consumed by k6 scenarios) ──────────────────────────────────
#[derive(Serialize)]
struct Manifest {
admin: UserCreds,
grantee: UserCreds,
group_member: UserCreds,
shared_subtree: SubtreeIds,
group_subtree: SubtreeIds,
nested_groups: NestedGroupIds,
/// Total folders seeded (informational).
total_folders: u64,
/// Total files seeded (informational).
total_files: u64,
}
#[derive(Serialize)]
struct UserCreds {
id: Uuid,
username: String,
password: String,
}
#[derive(Serialize)]
struct SubtreeIds {
/// Root of the subtree the grant is attached to.
root: Uuid,
/// A folder at depth 4 inside the subtree (for mid-depth fetches).
depth4: Uuid,
/// A folder at depth 8 inside the subtree (for mid-depth fetches).
depth8: Uuid,
/// A folder at depth N (== `--depth`) inside the subtree (for deep fetches).
deepest: Uuid,
}
#[derive(Serialize)]
struct NestedGroupIds {
/// Outermost group — the one referenced by the grant. Contains `mid` as a member.
root: Uuid,
/// Intermediate group — member of `root`, contains `leaf` as a member.
mid: Uuid,
/// Innermost group — member of `mid`, contains `group_member` user as a direct member.
leaf: Uuid,
}
// ── Main ─────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Best-effort: load .env if present (mirrors the rest of the codebase).
let _ = dotenvy::dotenv();
let args = Args::parse();
let database_url = env::var("DATABASE_URL").map_err(|_| {
"DATABASE_URL must be set (e.g. postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test)"
})?;
println!(
"[load-seed] connecting to {}",
sanitize_url_for_log(&database_url)
);
let pool = PgPool::connect(&database_url).await?;
println!("[load-seed] wiping previous load-test data…");
wipe(&pool).await?;
println!("[load-seed] hashing shared password…");
let password_hash = hash_password(&args.password);
println!(
"[load-seed] inserting users (1 admin + {})…",
args.extra_users
);
let admin = insert_user(&pool, "load_admin", "admin", &password_hash).await?;
let extra_users = insert_extra_users(&pool, args.extra_users, &password_hash).await?;
// Pick a grantee (user[0]) and a group member (user[1]).
let grantee = extra_users
.first()
.ok_or("need at least one extra user for grantee")?
.clone();
let group_member = extra_users
.get(1)
.ok_or("need at least two extra users")?
.clone();
println!("[load-seed] inserting shared blob…");
let blob_hash = insert_shared_blob(&pool).await?;
println!(
"[load-seed] building folder tree (depth={}, fanout={})…",
args.depth, args.fanout
);
// Two parallel subtrees off root: one for the user-grant scenario, one for
// the group-grant scenario. Each has its own depth/fanout shape so a single
// grant cascades over a known fixed number of descendants.
let shared_subtree =
build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?;
let group_subtree =
build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?;
let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64;
let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len();
println!(
"[load-seed] inserting files (files_per_leaf={}, leaves={})…",
args.files_per_leaf, total_leaves
);
let total_files = insert_files(
&pool,
admin.id,
&blob_hash,
&shared_subtree.leaves,
&group_subtree.leaves,
args.files_per_leaf,
)
.await?;
println!(
"[load-seed] building nested group chain (depth={}, fanout={})…",
args.group_depth, args.group_fanout
);
let nested_groups = build_group_chain(
&pool,
admin.id,
&extra_users,
&group_member.id,
args.group_depth,
args.group_fanout,
)
.await?;
println!("[load-seed] inserting grants…");
// Grant the grantee the viewer role on shared_subtree.root — the read
// bundle the share_cascade_rebac scenario exercises.
insert_grant(
&pool,
"user",
grantee.id,
"folder",
shared_subtree.root,
"viewer",
admin.id,
)
.await?;
// Grant the outermost group the viewer role on group_subtree.root.
insert_grant(
&pool,
"group",
nested_groups.root,
"folder",
group_subtree.root,
"viewer",
admin.id,
)
.await?;
let manifest = Manifest {
admin: UserCreds {
id: admin.id,
username: admin.username,
password: args.password.clone(),
},
grantee: UserCreds {
id: grantee.id,
username: grantee.username,
password: args.password.clone(),
},
group_member: UserCreds {
id: group_member.id,
username: group_member.username,
password: args.password.clone(),
},
shared_subtree: SubtreeIds {
root: shared_subtree.root,
depth4: shared_subtree.depth4,
depth8: shared_subtree.depth8,
deepest: shared_subtree.deepest,
},
group_subtree: SubtreeIds {
root: group_subtree.root,
depth4: group_subtree.depth4,
depth8: group_subtree.depth8,
deepest: group_subtree.deepest,
},
nested_groups,
total_folders,
total_files,
};
if let Some(parent) = args.manifest.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&manifest)?;
fs::write(&args.manifest, json)?;
println!(
"[load-seed] manifest written → {} ({} folders, {} files)",
args.manifest.display(),
total_folders,
total_files
);
Ok(())
}
// ── Wipe ─────────────────────────────────────────────────────────────────
async fn wipe(pool: &PgPool) -> Result<(), sqlx::Error> {
// Users starting with `load_` are the only ones this seeder ever creates.
// CASCADE on auth.users → storage.folders / storage.files / grants / group
// memberships, so a single DELETE wipes everything load-test-related.
sqlx::query("DELETE FROM auth.users WHERE username LIKE 'load_%'")
.execute(pool)
.await?;
// Groups starting with `load_` aren't owned by users; clear them explicitly.
sqlx::query("DELETE FROM auth.subject_groups WHERE name LIKE 'load_%'")
.execute(pool)
.await?;
// The shared dedup blob is identified by its fixed hash sentinel below.
sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(())
}
// ── Password ─────────────────────────────────────────────────────────────
/// Light Argon2id parameters — same shape as the password_hasher test path
/// (`src/infrastructure/services/password_hasher.rs::test_hasher`).
/// Production hashes verify regardless of m/t/p because Argon2 reads the
/// parameters back from the hash string itself.
fn hash_password(password: &str) -> String {
let params = Params::new(16384, 1, 1, None).expect("valid argon2 params");
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash")
.to_string()
}
// ── Users ────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct SeededUser {
id: Uuid,
username: String,
}
async fn insert_user(
pool: &PgPool,
username: &str,
role: &str,
password_hash: &str,
) -> Result<SeededUser, sqlx::Error> {
let email = format!("{}@load.test.invalid", username);
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4::auth.userrole)
RETURNING id",
)
.bind(username)
.bind(email)
.bind(password_hash)
.bind(role)
.fetch_one(pool)
.await?;
Ok(SeededUser {
id: row.0,
username: username.to_string(),
})
}
async fn insert_extra_users(
pool: &PgPool,
count: u32,
password_hash: &str,
) -> Result<Vec<SeededUser>, sqlx::Error> {
if count == 0 {
return Ok(Vec::new());
}
let usernames: Vec<String> = (0..count).map(|i| format!("load_user_{:04}", i)).collect();
let emails: Vec<String> = usernames
.iter()
.map(|u| format!("{}@load.test.invalid", u))
.collect();
// Bulk insert via UNNEST — one round-trip for all N users.
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
SELECT u.username, u.email, $1::text, 'user'::auth.userrole
FROM UNNEST($2::text[], $3::text[]) AS u(username, email)
RETURNING id, username",
)
.bind(password_hash)
.bind(&usernames)
.bind(&emails)
.fetch_all(pool)
.await?;
// RETURNING order is implementation-defined — sort by username so the
// caller can rely on `extra_users[0]` being `load_user_0000`.
let mut users: Vec<SeededUser> = rows
.into_iter()
.map(|(id, username)| SeededUser { id, username })
.collect();
users.sort_by(|a, b| a.username.cmp(&b.username));
Ok(users)
}
// ── Blob ─────────────────────────────────────────────────────────────────
/// All seeded files share this single zero-byte blob. Its ref_count is bumped
/// in lockstep with file inserts; the file_metadata schema doesn't require a
/// real on-disk blob for the dedup index to be consistent.
const SHARED_BLOB_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
async fn insert_shared_blob(pool: &PgPool) -> Result<String, sqlx::Error> {
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, 0, 0, 'application/octet-stream')
ON CONFLICT (hash) DO NOTHING",
)
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(SHARED_BLOB_HASH.to_string())
}
// ── Folder tree ──────────────────────────────────────────────────────────
struct Subtree {
root: Uuid,
depth4: Uuid,
depth8: Uuid,
deepest: Uuid,
all_ids: Vec<Uuid>,
leaves: Vec<Uuid>,
}
/// Build a folder tree under `parent=NULL` rooted at `root_name`.
///
/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from
/// the already-committed parent rows. Returns the root, a depth-4 sample, the
/// deepest leaf, the full id set, and the leaf ids (used for file seeding).
async fn build_subtree(
pool: &PgPool,
user_id: Uuid,
root_name: &str,
depth: u32,
fanout: u32,
) -> Result<Subtree, sqlx::Error> {
// Per-row BEFORE INSERT trigger (compute_folder_path) makes each level cost
// O(parents × fanout); past depth 6 with fanout 5 the seed time grows fast.
// Print the count BEFORE each level so it's clear which level is in flight
// and how many rows are about to be inserted.
let predicted: u64 = (0..=depth).map(|l| (fanout as u64).pow(l)).sum();
println!(
" subtree '{}': ~{} folders predicted",
root_name, predicted
);
// Level 0 — the root folder.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
RETURNING id",
)
.bind(root_name)
.bind(user_id)
.fetch_one(pool)
.await?;
let root_id = root.0;
let mut all_ids = vec![root_id];
let mut current_level: Vec<Uuid> = vec![root_id];
let mut depth4: Option<Uuid> = None;
let mut depth8: Option<Uuid> = None;
let mut deepest: Uuid = root_id;
for level in 1..=depth {
// Build the (parent_id, name) pairs for this level.
let parents: Vec<Uuid> = current_level
.iter()
.flat_map(|p| std::iter::repeat_n(*p, fanout as usize))
.collect();
let names: Vec<String> = current_level
.iter()
.enumerate()
.flat_map(|(pi, _)| (0..fanout).map(move |c| format!("l{}_{}_{}", level, pi, c)))
.collect();
let started = std::time::Instant::now();
println!(
" level {}/{}: inserting {} folders…",
level,
depth,
parents.len()
);
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
SELECT f.name, f.parent_id, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
RETURNING id",
)
.bind(user_id)
.bind(&parents)
.bind(&names)
.fetch_all(pool)
.await?;
let level_ids: Vec<Uuid> = rows.into_iter().map(|(id,)| id).collect();
println!(
" level {}/{}: done in {:.1}s",
level,
depth,
started.elapsed().as_secs_f64()
);
if level == 4 {
depth4 = level_ids.first().copied();
}
if level == 8 {
depth8 = level_ids.first().copied();
}
if level == depth {
deepest = *level_ids.first().expect("non-empty deepest level");
}
all_ids.extend(&level_ids);
current_level = level_ids;
}
let depth4 = depth4.unwrap_or(deepest);
let depth8 = depth8.unwrap_or(deepest);
let leaves = current_level;
Ok(Subtree {
root: root_id,
depth4,
depth8,
deepest,
all_ids,
leaves,
})
}
// ── Files ────────────────────────────────────────────────────────────────
async fn insert_files(
pool: &PgPool,
user_id: Uuid,
blob_hash: &str,
shared_leaves: &[Uuid],
group_leaves: &[Uuid],
files_per_leaf: u32,
) -> Result<u64, sqlx::Error> {
if files_per_leaf == 0 {
return Ok(0);
}
let all_leaves: Vec<Uuid> = shared_leaves
.iter()
.chain(group_leaves.iter())
.copied()
.collect();
if all_leaves.is_empty() {
return Ok(0);
}
// Build (folder_id, name) pairs.
let folder_ids: Vec<Uuid> = all_leaves
.iter()
.flat_map(|f| std::iter::repeat_n(*f, files_per_leaf as usize))
.collect();
let names: Vec<String> = (0..all_leaves.len())
.flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi)))
.collect();
sqlx::query(
"INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)",
)
.bind(user_id)
.bind(blob_hash)
.bind(&folder_ids)
.bind(&names)
.execute(pool)
.await?;
let total = folder_ids.len() as i64;
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + $1 WHERE hash = $2")
.bind(total)
.bind(blob_hash)
.execute(pool)
.await?;
Ok(total as u64)
}
// ── Subject groups ───────────────────────────────────────────────────────
async fn build_group_chain(
pool: &PgPool,
admin_id: Uuid,
extra_users: &[SeededUser],
group_member_user_id: &Uuid,
depth: u32,
fanout: u32,
) -> Result<NestedGroupIds, Box<dyn std::error::Error>> {
if depth < 2 {
return Err(format!("--group-depth must be ≥ 2 (got {})", depth).into());
}
// Create `depth` groups: g_0 (outermost) → g_1 → … → g_{depth-1} (innermost).
// Insert one row per group; CITEXT name must match the RFC-5321 regex,
// and the `load_` prefix lets the wipe step find them again.
let salt: String = Uuid::new_v4().simple().to_string();
let names: Vec<String> = (0..depth)
.map(|i| format!("load_g_{}_{}", salt, i))
.collect();
let mut group_ids: HashMap<u32, Uuid> = HashMap::with_capacity(depth as usize);
for (i, name) in names.iter().enumerate() {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO auth.subject_groups (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await?;
group_ids.insert(i as u32, row.0);
}
// Wire the chain: g_i contains g_{i+1} as a member (XOR: member_group_id set).
for i in 0..(depth - 1) {
let parent = group_ids[&i];
let child = group_ids[&(i + 1)];
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
VALUES ($1, NULL, $2, $3)",
)
.bind(parent)
.bind(child)
.bind(admin_id)
.execute(pool)
.await?;
}
// Innermost group gets `fanout` direct user members, ensuring the
// group_member sentinel user is always among them.
let leaf = group_ids[&(depth - 1)];
let user_ids: Vec<Uuid> = {
let mut v = vec![*group_member_user_id];
for u in extra_users.iter().filter(|u| u.id != *group_member_user_id) {
if v.len() as u32 >= fanout {
break;
}
v.push(u.id);
}
v
};
let leafs: Vec<Uuid> = std::iter::repeat_n(leaf, user_ids.len()).collect();
let added_by_col: Vec<Uuid> = std::iter::repeat_n(admin_id, user_ids.len()).collect();
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
SELECT g.group_id, g.member_user_id, NULL, g.added_by
FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[]) AS g(group_id, member_user_id, added_by)",
)
.bind(&leafs)
.bind(&user_ids)
.bind(&added_by_col)
.execute(pool)
.await?;
Ok(NestedGroupIds {
root: group_ids[&0],
mid: group_ids[&(depth / 2)],
leaf,
})
}
// ── Grants ───────────────────────────────────────────────────────────────
async fn insert_grant(
pool: &PgPool,
subject_type: &str,
subject_id: Uuid,
resource_type: &str,
resource_id: Uuid,
role: &str,
granted_by: Uuid,
) -> Result<(), sqlx::Error> {
// D-Prep replaced `storage.access_grants` (one row per Permission) with
// `storage.role_grants` (one row per role assignment; the role expands to
// a permission bundle in-code at engine read time). The seeder now writes
// role names ('viewer'/'editor'/etc.) instead of individual permissions.
//
// The `role` column was promoted from TEXT to the `storage.grant_role`
// enum by migration 20260801000000_role_grants_enum, so the cast on $5
// is required — sqlx binds Rust &str as TEXT, which postgres won't
// implicitly coerce into the enum.
sqlx::query(
"INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING",
)
.bind(subject_type)
.bind(subject_id)
.bind(resource_type)
.bind(resource_id)
.bind(role)
.bind(granted_by)
.execute(pool)
.await?;
Ok(())
}
// ── Utilities ────────────────────────────────────────────────────────────
fn sanitize_url_for_log(url: &str) -> String {
// Strip "user:password@" from the URL so logs don't leak credentials.
if let Some(at) = url.find('@')
&& let Some(scheme_end) = url.find("://")
{
let scheme = &url[..scheme_end + 3];
let rest = &url[at..];
return format!("{}***{}", scheme, rest);
}
url.to_string()
}
+106
View File
@@ -879,6 +879,11 @@ pub struct FeaturesConfig {
pub enable_trash: bool,
pub enable_search: bool,
pub enable_music: bool,
/// Lists the user's geotagged photos on a map (GET /api/photos/geo).
pub enable_places: bool,
/// Face detection + identity clustering for the photo library ("People").
/// Biometric data — OFF by default; opt-in per deployment/user.
pub enable_faces: bool,
/// Expose other OxiCloud users as a read-only "system" address book
/// at GET /api/address-books. Set to false to hide the user directory.
pub expose_system_users: bool,
@@ -893,11 +898,60 @@ impl Default for FeaturesConfig {
enable_trash: true, // Enable trash feature
enable_search: true, // Enable search feature
enable_music: true, // Enable music feature
enable_places: true, // Photo map (GET /api/photos/geo + Places tab)
enable_faces: false, // People/faces (biometric) — opt-in, off by default
expose_system_users: true, // Expose OxiCloud users as address book by default
}
}
}
/// Face-recognition (People) model configuration.
///
/// Only consulted when the `faces-onnx` cargo feature is compiled in *and*
/// [`FeaturesConfig::enable_faces`] is true; otherwise the inert
/// `NoopFaceAnalyzer` is used regardless of these values. The ONNX Runtime
/// dylib and both model files are operator-provided at runtime (never
/// committed) — when any is unset or fails to load, the People pipeline
/// silently falls back to the no-op analyzer and the server still boots.
#[derive(Debug, Clone)]
pub struct FacesConfig {
/// `libonnxruntime.{so,dylib,dll}`. Falls back to the `ORT_DYLIB_PATH`
/// environment variable when unset. Env: `OXICLOUD_FACES_ORT_DYLIB`.
pub ort_dylib: Option<PathBuf>,
/// SCRFD/RetinaFace detector model with 5-point landmarks.
/// Env: `OXICLOUD_FACES_DETECTOR_MODEL`.
pub detector_model: Option<PathBuf>,
/// ArcFace embedder model (112×112 → 512-d).
/// Env: `OXICLOUD_FACES_EMBEDDER_MODEL`.
pub embedder_model: Option<PathBuf>,
/// Detector square input size in pixels (default 640).
/// Env: `OXICLOUD_FACES_DET_SIZE`.
pub det_size: u32,
/// Minimum detector confidence to keep a face (default 0.5).
/// Env: `OXICLOUD_FACES_DET_THRESHOLD`.
pub det_threshold: f32,
/// IoU threshold for non-max suppression (default 0.4).
/// Env: `OXICLOUD_FACES_NMS_THRESHOLD`.
pub nms_threshold: f32,
/// ONNX Runtime intra-op threads (0 = let ORT decide).
/// Env: `OXICLOUD_FACES_INTRA_THREADS`.
pub intra_threads: usize,
}
impl Default for FacesConfig {
fn default() -> Self {
Self {
ort_dylib: None,
detector_model: None,
embedder_model: None,
det_size: 640,
det_threshold: 0.5,
nms_threshold: 0.4,
intra_threads: 0,
}
}
}
/// Content-search configuration (embedded Tantivy index over file names and
/// extracted file content).
///
@@ -1063,6 +1117,8 @@ pub struct AppConfig {
pub content_search: ContentSearchConfig,
/// WASM plugin runtime configuration
pub plugins: PluginConfig,
/// Face-recognition (People) model configuration
pub faces: FacesConfig,
}
/// Server-side i18n knobs.
@@ -1116,6 +1172,7 @@ impl Default for AppConfig {
i18n: I18nConfig::default(),
content_search: ContentSearchConfig::default(),
plugins: PluginConfig::default(),
faces: FacesConfig::default(),
}
}
}
@@ -1378,6 +1435,55 @@ impl AppConfig {
config.features.enable_music = val;
}
if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_places
{
config.features.enable_places = val;
}
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_faces
{
config.features.enable_faces = val;
}
// Faces (People) ONNX runtime + models — operator-provided at runtime.
if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH"))
&& !v.is_empty()
{
config.faces.ort_dylib = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_DETECTOR_MODEL")
&& !v.is_empty()
{
config.faces.detector_model = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_EMBEDDER_MODEL")
&& !v.is_empty()
{
config.faces.embedder_model = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_DET_SIZE").map(|v| v.parse::<u32>())
&& let Ok(val) = v
{
config.faces.det_size = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_DET_THRESHOLD").map(|v| v.parse::<f32>())
&& let Ok(val) = v
{
config.faces.det_threshold = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_NMS_THRESHOLD").map(|v| v.parse::<f32>())
&& let Ok(val) = v
{
config.faces.nms_threshold = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_INTRA_THREADS").map(|v| v.parse::<usize>())
&& let Ok(val) = v
{
config.faces.intra_threads = val;
}
// Content search (embedded Tantivy index)
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
&& let Ok(val) = v
+112
View File
@@ -17,6 +17,8 @@ use crate::application::services::folder_service::FolderService;
use crate::application::services::i18n_application_service::I18nApplicationService;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
use crate::application::services::people_service::PeopleService;
use crate::application::services::places_service::PlacesService;
use crate::application::services::recent_service::RecentService;
use crate::application::services::search_service::SearchService;
use crate::application::services::share_browse_service::ShareBrowseService;
@@ -359,6 +361,9 @@ impl AppServiceFactory {
fls = fls.with_hook(audio.clone());
}
fls = fls.with_hook(media_metadata_service.clone());
if self.config.features.enable_faces {
fls = fls.with_hook(self.create_face_indexing_service(db_pool));
}
let file_lifecycle = Arc::new(fls);
Ok(CoreServices {
@@ -798,6 +803,95 @@ impl AppServiceFactory {
service
}
/// Creates the Places (photo map) service. Reuses the existing file-read
/// repository — the data is the caller's own geotagged photos.
pub fn create_places_service(
&self,
file_read: &Arc<FileBlobReadRepository>,
) -> Arc<PlacesService> {
let service = Arc::new(PlacesService::new(file_read.clone()));
tracing::info!("Places service initialized");
service
}
/// Creates the face-indexing lifecycle hook (People feature). Picks the
/// real ONNX analyzer when the `faces-onnx` feature is compiled in and the
/// operator has configured the runtime + models; otherwise the inert no-op
/// analyzer (see [`Self::build_face_analyzer`]).
pub fn create_face_indexing_service(
&self,
db_pool: &Arc<PgPool>,
) -> Arc<crate::infrastructure::services::face_indexing_service::FaceIndexingService> {
let blob_root = self.storage_path.join(".blobs");
let analyzer = self.build_face_analyzer();
Arc::new(
crate::infrastructure::services::face_indexing_service::FaceIndexingService::new(
db_pool.clone(),
blob_root,
analyzer,
),
)
}
/// Selects the face analyzer. With the `faces-onnx` feature and a fully
/// configured runtime + models, loads the real ONNX analyzer; any missing
/// piece or load failure degrades gracefully to the no-op analyzer (logged)
/// so startup never fails on biometric configuration.
fn build_face_analyzer(
&self,
) -> Arc<dyn crate::application::ports::face_ports::FaceAnalyzerPort> {
#[cfg(feature = "faces-onnx")]
{
let f = &self.config.faces;
if let (Some(dylib), Some(detector), Some(embedder)) = (
f.ort_dylib.as_ref(),
f.detector_model.as_ref(),
f.embedder_model.as_ref(),
) {
use crate::infrastructure::services::onnx_face_analyzer::{
OnnxFaceAnalyzer, OnnxLoadConfig,
};
let cfg = OnnxLoadConfig {
dylib,
detector,
embedder,
det_size: f.det_size,
det_threshold: f.det_threshold,
nms_threshold: f.nms_threshold,
intra_threads: f.intra_threads,
};
match OnnxFaceAnalyzer::load(&cfg) {
Ok(analyzer) => {
tracing::info!("Face analyzer: ONNX models loaded");
return Arc::new(analyzer);
}
Err(e) => {
tracing::warn!(
"Face analyzer: failed to load ONNX models ({e}); \
falling back to no-op analyzer"
);
}
}
} else {
tracing::info!(
"Face analyzer: faces-onnx compiled but runtime/models not fully \
configured; using no-op analyzer"
);
}
}
Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer)
}
/// Creates the People (faces) read/clustering service.
pub fn create_people_service(&self, db_pool: &Arc<PgPool>) -> Arc<PeopleService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::FacePgRepository::new(db_pool.clone()),
);
let service = Arc::new(PeopleService::new(repo));
tracing::info!("People service initialized");
service
}
/// Preloads translations for every locale in the registry. Build
/// the registry at startup via `LocaleRegistry::discover` and pass
/// the resulting list here.
@@ -1005,6 +1099,8 @@ impl AppServiceFactory {
// 6. Database-dependent services (PgPool always available in blob model)
let favorites_service: Option<Arc<FavoritesService>>;
let recent_service: Option<Arc<RecentService>>;
let places_service: Option<Arc<PlacesService>>;
let people_service: Option<Arc<PeopleService>>;
let storage_usage_service: Option<Arc<StorageUsageService>>;
let mut auth_services: Option<crate::common::di::AuthServices> = None;
let mut nextcloud_services: Option<NextcloudServices> = None;
@@ -1027,6 +1123,18 @@ impl AppServiceFactory {
recent_service = Some(recent.clone());
apps.recent_service = Some(recent);
places_service = if core.config.features.enable_places {
Some(self.create_places_service(&repos.file_read_repository))
} else {
None
};
people_service = if core.config.features.enable_faces {
Some(self.create_people_service(&pool))
} else {
None
};
storage_usage_service = Some(storage_usage.clone());
self.start_tree_etag_flush_job(&maintenance_pool);
@@ -1253,6 +1361,8 @@ impl AppServiceFactory {
share_browse_service,
favorites_service,
recent_service,
places_service,
people_service,
storage_usage_service,
calendar_service: None,
contact_service: None,
@@ -1699,6 +1809,8 @@ pub struct AppState {
pub share_browse_service: Option<Arc<ShareBrowseService>>,
pub favorites_service: Option<Arc<FavoritesService>>,
pub recent_service: Option<Arc<RecentService>>,
pub places_service: Option<Arc<PlacesService>>,
pub people_service: Option<Arc<PeopleService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
pub calendar_service: Option<Arc<CalendarService>>,
pub contact_service: Option<Arc<ContactStorageAdapter>>,
+72
View File
@@ -0,0 +1,72 @@
//! Domain entities for the People (faces) feature.
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// Length of a face embedding vector (ArcFace-style).
pub const EMBEDDING_DIM: usize = 512;
/// A face bounding box in normalized image coordinates (each component 0..1).
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl BoundingBox {
/// `[x, y, w, h]` — the storage representation (Postgres `REAL[]`).
pub fn to_array(self) -> Vec<f32> {
vec![self.x, self.y, self.w, self.h]
}
/// Build from a stored `[x, y, w, h]` array; missing components default to 0.
pub fn from_slice(a: &[f32]) -> Self {
Self {
x: a.first().copied().unwrap_or(0.0),
y: a.get(1).copied().unwrap_or(0.0),
w: a.get(2).copied().unwrap_or(0.0),
h: a.get(3).copied().unwrap_or(0.0),
}
}
}
/// A face produced by the analyzer but not yet persisted: where it is, how
/// confident the detector was, an optional quality score, and a 512-d,
/// L2-normalized embedding.
#[derive(Debug, Clone)]
pub struct DetectedFace {
pub bbox: BoundingBox,
pub det_score: f32,
pub quality: Option<f32>,
pub embedding: Vec<f32>,
}
/// A persisted face detection.
#[derive(Debug, Clone)]
pub struct Face {
pub id: Uuid,
pub file_id: Uuid,
pub user_id: Uuid,
/// Identity cluster this face belongs to, if any.
pub person_id: Option<Uuid>,
pub bbox: BoundingBox,
pub det_score: f32,
pub quality: Option<f32>,
pub embedding: Vec<f32>,
pub blob_hash: Option<String>,
pub created_at: DateTime<Utc>,
}
/// An identity cluster ("person"). `display_name` is `None` until the user
/// names it.
#[derive(Debug, Clone)]
pub struct Person {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub cover_face_id: Option<Uuid>,
pub is_hidden: bool,
pub created_at: DateTime<Utc>,
}
+1
View File
@@ -4,6 +4,7 @@ pub mod calendar_event;
pub mod contact;
pub mod device_code;
pub mod entity_errors;
pub mod face;
pub mod file;
pub mod folder;
pub mod magic_link_token;
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct Share {
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
/// Derived from `storage.role_grants.expires_at` — not stored on the share row.
expires_at: Option<u64>,
created_at: u64,
created_by: Uuid,
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Subject groups are root-owned (no `owner_id`), globally named with an
//! RFC 5321 local-part shape, and able to contain users *or* other groups.
//! Grants in `storage.access_grants` with `subject_type = 'group'` reference
//! Grants in `storage.role_grants` with `subject_type = 'group'` reference
//! a row in `auth.subject_groups`.
//!
//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the
@@ -93,8 +93,8 @@ pub trait SubjectGroupRepository: Send + Sync + 'static {
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
/// Delete the group. Cascades to `subject_group_members` and to
/// `storage.access_grants` rows referencing this group as subject (via
/// the application service — there is no FK between `access_grants` and
/// `storage.role_grants` rows referencing this group as subject (via
/// the application service — there is no FK between `role_grants` and
/// `subject_groups`, so the service performs the cascade explicitly in
/// the same transaction).
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>;
+139 -6
View File
@@ -3,7 +3,7 @@
//! These types are storage-agnostic — they describe the relationship between
//! a subject (who), a resource (what), and a permission (action). The
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
//! maps them to / from `storage.access_grants` rows.
//! maps them to / from `storage.role_grants` rows.
use crate::application::dtos::cursor::PageCursor;
use std::fmt;
@@ -49,7 +49,7 @@ impl Subject {
/// `"external"` is no longer accepted: PR-2 of the external-users
/// work folded the federated-identity case into `Subject::User(uuid)`
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
/// on `storage.access_grants.subject_type` was narrowed to match.
/// on `storage.role_grants.subject_type` was narrowed to match.
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
match subject_type {
"user" => Some(Subject::User(id)),
@@ -140,18 +140,29 @@ pub enum Permission {
Delete,
/// Modify the resource (rename, move, edit content).
Update,
/// Configure the resource's settings, add/remove members, change role
/// assignments. Used by:
/// - Drive owners managing drive membership and policies.
/// - Group owners managing the group itself (Group-as-Resource, future).
///
/// Folder and file resources do not currently surface a `Manage` check;
/// the permission lives in the enum because the role bundle (`Owner`)
/// includes it, and the resource types that DO check it (`Drive`,
/// `Group`) are added in subsequent PRs (see `docs/plan/drive.md`).
Manage,
}
impl Permission {
/// Every permission, in a stable order. Used by `Role::expand()` and SQL
/// `permission = ANY(...)` lookups.
pub const ALL: [Permission; 6] = [
pub const ALL: [Permission; 7] = [
Permission::Read,
Permission::Create,
Permission::Share,
Permission::Comment,
Permission::Delete,
Permission::Update,
Permission::Manage,
];
pub fn as_str(&self) -> &'static str {
@@ -162,6 +173,7 @@ impl Permission {
Permission::Comment => "comment",
Permission::Delete => "delete",
Permission::Update => "update",
Permission::Manage => "manage",
}
}
@@ -175,6 +187,7 @@ impl Permission {
"comment" => Some(Permission::Comment),
"delete" => Some(Permission::Delete),
"update" => Some(Permission::Update),
"manage" => Some(Permission::Manage),
_ => None,
}
}
@@ -187,7 +200,7 @@ impl fmt::Display for Permission {
}
// ════════════════════════════════════════════════════════════════════════════
// Grant — a row in storage.access_grants
// Grant — a row in storage.role_grants
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Debug)]
@@ -195,12 +208,132 @@ pub struct Grant {
pub id: Uuid,
pub subject: Subject,
pub resource: Resource,
pub permission: Permission,
/// Role-keyed since D-Prep cleanup: one `Grant` represents the role
/// row in `storage.role_grants` rather than a single permission. The
/// engine and HTTP surface no longer carry per-permission rows;
/// callers that need permissions use `role.expand()`.
pub role: Role,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ════════════════════════════════════════════════════════════════════════════
// Role — a named bundle of permissions
// ════════════════════════════════════════════════════════════════════════════
//
// Roles are the load-bearing model for ReBAC grants since D-Prep. Each
// `storage.role_grants` row stores one role; the engine expands the bundle
// at read time via `Role::expand()`. Adding a role is two edits:
// 1. a variant here + match arm in `expand()` / `as_str()` / `parse()`
// 2. an `ALTER TYPE storage.grant_role ADD VALUE 'name'` migration
//
// `RoleDto` (DTO layer) carries the wire-format derives + the legacy
// `"admin"` alias for backwards compat.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Role {
Viewer,
Commenter,
Contributor,
Editor,
Owner,
}
impl Role {
/// Expand the role into its permission bundle. Single source of truth —
/// any code that needs "does this role include Permission X?" routes
/// through here (or its inverse, `roles_implying`).
pub fn expand(self) -> &'static [Permission] {
match self {
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,
],
}
}
/// Lowercase discriminator — matches the SQL `role` ENUM values in
/// `storage.role_grants` (after the `::text` cast).
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Commenter => "commenter",
Role::Contributor => "contributor",
Role::Editor => "editor",
Role::Owner => "owner",
}
}
/// Parse a role from its SQL discriminator. Returns `None` for unknown
/// values. The `"admin"` legacy alias is handled by `RoleDto` at the
/// wire boundary — the database only ever stores the canonical names.
pub fn parse(s: &str) -> Option<Self> {
match s {
"viewer" => Some(Role::Viewer),
"commenter" => Some(Role::Commenter),
"contributor" => Some(Role::Contributor),
"editor" => Some(Role::Editor),
"owner" => Some(Role::Owner),
_ => None,
}
}
/// Every role, in declaration order. Mirrors the `storage.grant_role`
/// ENUM order in PG, which is weakest-to-strongest as written here for
/// historical reasons (`storage.grant_role` declares strongest first).
pub const ALL: [Role; 5] = [
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
];
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Inverse of [`Role::expand`]: returns every role whose bundle contains
/// the given permission. Used by the engine to build the SQL
/// `WHERE role IN (...)` filter on hot-path queries like "what drives can
/// this caller read?".
pub fn roles_implying(permission: Permission) -> &'static [Role] {
use Permission::*;
match permission {
Read => &[
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
],
Comment => &[Role::Commenter, Role::Editor, Role::Owner],
Create => &[Role::Contributor, Role::Editor, Role::Owner],
Update => &[Role::Editor, Role::Owner],
Delete => &[Role::Owner],
Share => &[Role::Owner],
Manage => &[Role::Owner],
}
}
impl Grant {
pub fn is_expired(&self) -> bool {
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
@@ -212,7 +345,7 @@ impl Grant {
// ════════════════════════════════════════════════════════════════════════════
/// Resource type without an id — used to filter paginated grant queries by
/// type. Mirrors the `resource_type` column values in `storage.access_grants`.
/// type. Mirrors the `resource_type` column values in `storage.role_grants`.
/// Add new variants here when new resource types are supported.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ResourceKind {
@@ -0,0 +1,322 @@
//! PostgreSQL repository for the People (faces) feature.
//!
//! Embeddings are stored as `BYTEA` (512 × little-endian `f32`); there is no
//! pgvector dependency. Similarity search / clustering is done in-app over the
//! decoded vectors (see `PeopleService`).
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, Face, Person};
/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`).
type FaceRow = (
Uuid, // id
Uuid, // file_id
Uuid, // user_id
Option<Uuid>, // person_id
Vec<f32>, // bbox (REAL[])
f32, // det_score
Option<f32>, // quality
Vec<u8>, // embedding (BYTEA)
Option<String>, // blob_hash
DateTime<Utc>, // created_at
);
type PersonRow = (
Uuid, // id
Uuid, // user_id
Option<String>, // display_name
Option<Uuid>, // cover_face_id
bool, // is_hidden
DateTime<Utc>, // created_at
);
fn embedding_to_bytes(e: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(e.len() * 4);
for v in e {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
b.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
fn row_to_face(r: FaceRow) -> Face {
let (
id,
file_id,
user_id,
person_id,
bbox,
det_score,
quality,
embedding,
blob_hash,
created_at,
) = r;
Face {
id,
file_id,
user_id,
person_id,
bbox: BoundingBox::from_slice(&bbox),
det_score,
quality,
embedding: bytes_to_embedding(&embedding),
blob_hash,
created_at,
}
}
fn row_to_person(r: PersonRow) -> Person {
let (id, user_id, display_name, cover_face_id, is_hidden, created_at) = r;
Person {
id,
user_id,
display_name,
cover_face_id,
is_hidden,
created_at,
}
}
fn db_err(ctx: &'static str, e: sqlx::Error) -> DomainError {
DomainError::internal_error("FacePg", format!("{ctx}: {e}"))
}
const FACE_COLS: &str =
"id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at";
const PERSON_COLS: &str = "id, user_id, display_name, cover_face_id, is_hidden, created_at";
pub struct FacePgRepository {
pool: Arc<PgPool>,
}
impl FacePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl FaceRepository for FacePgRepository {
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError> {
if faces.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
for f in faces {
sqlx::query(
r#"
INSERT INTO faces.faces
(id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(f.id)
.bind(f.file_id)
.bind(f.user_id)
.bind(f.person_id)
.bind(f.bbox.to_array())
.bind(f.det_score)
.bind(f.quality)
.bind(embedding_to_bytes(&f.embedding))
.bind(f.blob_hash.as_deref())
.execute(&mut *tx)
.await
.map_err(|e| db_err("save_faces", e))?;
}
tx.commit().await.map_err(|e| db_err("commit", e))?;
Ok(())
}
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(file_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_file", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM faces.faces WHERE file_id = $1")
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("delete_faces_for_file", e))?;
Ok(())
}
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_user", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn faces_for_blob(
&self,
user_id: Uuid,
blob_hash: &str,
) -> Result<Vec<Face>, DomainError> {
let sql =
format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1 AND blob_hash = $2");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(user_id)
.bind(blob_hash)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_blob", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn assign_person(
&self,
face_id: Uuid,
person_id: Option<Uuid>,
) -> Result<(), DomainError> {
sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1")
.bind(face_id)
.bind(person_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("assign_person", e))?;
Ok(())
}
async fn create_person(&self, person: &Person) -> Result<(), DomainError> {
sqlx::query(
r#"
INSERT INTO faces.persons (id, user_id, display_name, cover_face_id, is_hidden)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(person.id)
.bind(person.user_id)
.bind(person.display_name.as_deref())
.bind(person.cover_face_id)
.bind(person.is_hidden)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("create_person", e))?;
Ok(())
}
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError> {
let sql = format!(
"SELECT {PERSON_COLS} FROM faces.persons WHERE user_id = $1 ORDER BY created_at"
);
let rows: Vec<PersonRow> = sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("persons_for_user", e))?;
Ok(rows.into_iter().map(row_to_person).collect())
}
async fn rename_person(
&self,
user_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET display_name = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
)
.bind(user_id)
.bind(person_id)
.bind(name)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("rename_person", e))?;
Ok(())
}
async fn set_person_cover(
&self,
person_id: Uuid,
cover_face_id: Uuid,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET cover_face_id = $2, updated_at = now() WHERE id = $1",
)
.bind(person_id)
.bind(cover_face_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("set_person_cover", e))?;
Ok(())
}
async fn set_person_hidden(
&self,
user_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET is_hidden = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
)
.bind(user_id)
.bind(person_id)
.bind(hidden)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("set_person_hidden", e))?;
Ok(())
}
async fn files_for_person(
&self,
user_id: Uuid,
person_id: Uuid,
) -> Result<Vec<Uuid>, DomainError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
r#"
SELECT file_id
FROM faces.faces
WHERE user_id = $1 AND person_id = $2
GROUP BY file_id
ORDER BY max(created_at) DESC
"#,
)
.bind(user_id)
.bind(person_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("files_for_person", e))?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
sqlx::query("DELETE FROM faces.faces WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| db_err("delete_all_faces", e))?;
sqlx::query("DELETE FROM faces.persons WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| db_err("delete_all_persons", e))?;
tx.commit().await.map_err(|e| db_err("commit", e))?;
Ok(())
}
}
@@ -20,6 +20,8 @@ type MediaFileRow = (
String, // blob_hash
Option<Uuid>, // user_id
i64, // sort_date
Option<i32>, // width
Option<i32>, // height
);
use bytes::Bytes;
@@ -30,6 +32,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError;
@@ -255,6 +258,52 @@ impl FileBlobReadRepository {
})
}
/// Batch-fetch files by id — the by-ids counterpart of [`get_file`],
/// used to resolve a page of ACL grants or favorites in ONE round-trip
/// instead of one query per id (the previous `join_all(ids.map(get_file))`
/// could fan out to ~200 concurrent pooled connections per page). Applies
/// the same `NOT is_trashed` filter and identical column mapping as
/// `get_file`. Ids that are missing or trashed simply drop out, so callers
/// must re-associate results by id; ordering is not guaranteed.
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<File>, DomainError> {
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
if uuid_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, FileRow>(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&uuid_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}"))
})?;
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
DomainError::internal_error(
"FileBlobRead",
format!("get_files_by_ids mapping: {e}"),
)
})
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
@@ -370,7 +419,7 @@ impl FileBlobReadRepository {
owner_id: Uuid,
before: Option<i64>,
limit: i64,
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
let rows: Vec<MediaFileRow> = sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -379,9 +428,11 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
WHERE fi.user_id = $1
AND NOT fi.is_trashed
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
@@ -400,15 +451,67 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?);
sort_dates.push(sd);
dims.push((w, h));
}
Ok((files, sort_dates))
Ok((files, sort_dates, dims))
}
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
/// Returns one cluster per non-empty cell with its centroid, photo count
/// and a representative photo id (for the cluster thumbnail).
pub async fn list_geo_clusters(
&self,
user_id: Uuid,
bounds: GeoBounds,
cell: f64,
) -> Result<Vec<GeoCluster>, DomainError> {
let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as(
r#"
SELECT count(*) AS n,
avg(fm.longitude) AS clng,
avg(fm.latitude) AS clat,
min(fm.file_id::text) AS sample_id
FROM storage.file_metadata fm
JOIN storage.files fi ON fi.id = fm.file_id
WHERE fi.user_id = $1
AND NOT fi.is_trashed
AND fm.latitude IS NOT NULL
AND fm.longitude IS NOT NULL
AND fm.longitude BETWEEN $2 AND $3
AND fm.latitude BETWEEN $4 AND $5
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
"#,
)
.bind(user_id)
.bind(bounds.west)
.bind(bounds.east)
.bind(bounds.south)
.bind(bounds.north)
.bind(cell)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}"))
})?;
Ok(rows
.into_iter()
.map(|(n, clng, clat, sample_id)| GeoCluster {
lng: clng,
lat: clat,
count: n,
sample_file_id: sample_id,
})
.collect())
}
}
@@ -109,6 +109,37 @@ impl FolderDbRepository {
)
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
}
/// Batch-fetch folders by id — the by-ids counterpart of `get_folder`,
/// resolving a page of ACL grants or favorites in ONE query instead of
/// one per id. Same `NOT is_trashed` filter and column mapping as
/// `get_folder`; missing or trashed ids drop out and callers re-associate
/// by id; ordering is not guaranteed.
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<Folder>, DomainError> {
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
if uuid_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE id = ANY($1) AND NOT is_trashed
"#,
)
.bind(&uuid_ids)
.fetch_all(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
rows.into_iter()
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
.collect()
}
}
impl FolderRepository for FolderDbRepository {
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
mod contact_persistence_dto;
mod contact_pg_repository;
mod device_code_pg_repository;
mod face_pg_repository;
mod favorites_pg_repository;
pub mod file_metadata_repository;
mod magic_link_token_pg_repository;
@@ -33,6 +34,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
pub use contact_persistence_dto::*;
pub use contact_pg_repository::ContactPgRepository;
pub use device_code_pg_repository::DeviceCodePgRepository;
pub use face_pg_repository::FacePgRepository;
pub use favorites_pg_repository::FavoritesPgRepository;
pub use file_blob_read_repository::FileBlobReadRepository;
pub use file_blob_write_repository::FileBlobWriteRepository;
@@ -38,7 +38,7 @@ impl SharePgRepository {
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
/// expires_at (derived from role_grants subquery), created_at, created_by, access_count.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: Uuid = row
.try_get("id")
@@ -54,7 +54,7 @@ impl SharePgRepository {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
// expires_at derived from access_grants subquery (unix seconds as i64)
// expires_at derived from role_grants subquery (unix seconds as i64)
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
@@ -98,7 +98,7 @@ impl ShareStoragePort for SharePgRepository {
RETURNING
id, item_id, item_name, item_type, token, password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
created_at, created_by, access_count
"#,
@@ -127,7 +127,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -160,7 +160,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -218,7 +218,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -250,7 +250,7 @@ impl ShareStoragePort for SharePgRepository {
RETURNING
id, item_id, item_name, item_type, token, password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
created_at, created_by, access_count
"#,
@@ -286,7 +286,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count,
COUNT(*) OVER() AS total_count
@@ -272,8 +272,8 @@ impl SubjectGroupRepository for SubjectGroupPgRepository {
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> {
// The application service is responsible for clearing related
// `storage.access_grants` rows in the same transaction (there's no
// FK between access_grants and subject_groups). The subject_group_members
// `storage.role_grants` rows in the same transaction (there's no
// FK between role_grants and subject_groups). The subject_group_members
// rows cascade automatically via FK.
let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
.bind(id)
+286 -51
View File
@@ -161,7 +161,9 @@ impl IngestGuard {
) {
if !pinned.is_empty()
&& let Err(e) = sqlx::query(
"UPDATE storage.blobs SET ref_count = GREATEST(ref_count - 1, 0)
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(&pinned)
@@ -190,8 +192,8 @@ impl IngestGuard {
);
}
if let Err(e) = sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 0 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO NOTHING",
)
.bind(&hashes)
@@ -380,6 +382,16 @@ impl DedupService {
/// ~9 MiB regardless of file size.
const FLUSH_MAX_BYTES: usize = 8 * 1024 * 1024;
/// Grace period (seconds) a blob must stay orphaned (`ref_count = 0`)
/// before [`garbage_collect`](Self::garbage_collect) may physically delete
/// it. Mirrors git's `gc.pruneExpire`: content that became unreferenced
/// only moments ago is never reaped, so a concurrent uploader about to pin
/// a just-orphaned chunk — or a delta-upload client that registered loose
/// chunks at `ref_count = 0` and is about to commit their manifest — cannot
/// race the sweep. Must comfortably exceed the longest plausible gap
/// between registering a chunk and referencing it (any in-flight upload).
const GC_ORPHAN_GRACE_SECS: i64 = 60 * 60; // 1 hour
/// Store content with CDC deduplication, straight from a byte stream —
/// the single write path for every upload surface (REST multipart,
/// WebDAV PUT, NextCloud PUT, chunked-upload assembly, WOPI PutFile).
@@ -738,8 +750,8 @@ impl DedupService {
let sizes: Vec<i64> = new_rows.iter().map(|(_, s)| *s).collect();
self.backend.sync_blobs(&hashes).await?;
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 0 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO NOTHING",
)
.bind(&hashes)
@@ -923,7 +935,7 @@ impl DedupService {
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 1 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO UPDATE
SET ref_count = storage.blobs.ref_count + 1",
SET ref_count = storage.blobs.ref_count + 1, orphaned_at = NULL",
)
.bind(&new_hashes)
.bind(&new_sizes)
@@ -971,7 +983,7 @@ impl DedupService {
// session's reference NOW; hashes not returned don't exist and are
// ours to write.
let pinned: HashSet<String> = sqlx::query_scalar::<_, String>(
"UPDATE storage.blobs SET ref_count = ref_count + 1
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL
WHERE hash = ANY($1)
RETURNING hash",
)
@@ -1123,7 +1135,9 @@ impl DedupService {
// Legacy blob
let rows_affected =
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + 1 WHERE hash = $1")
sqlx::query(
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL WHERE hash = $1",
)
.bind(hash)
.execute(self.pool.as_ref())
.await
@@ -1150,9 +1164,13 @@ impl DedupService {
///
/// For CDC manifests: decrements manifest ref_count. When it reaches 0
/// the manifest is deleted and all chunk ref_counts are decremented;
/// chunks that reach 0 are deleted from both PG and the blob backend.
/// chunks that reach 0 are left for [`garbage_collect`](Self::garbage_collect)
/// to reclaim once they have been orphaned past the grace window — unlinking
/// them here would race a concurrent upload re-referencing the same chunk.
///
/// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`.
/// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`. A legacy
/// whole-file hash can never be re-created by an ingest (uploads are always
/// CDC now), so its file is unlinked eagerly — there is no writer to race.
pub async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError> {
// ── CDC manifest path ────────────────────────────────────
let manifest = sqlx::query_as::<_, (i32, Vec<String>)>(
@@ -1173,7 +1191,12 @@ impl DedupService {
self.remove_legacy_reference(hash).await
}
/// Remove a manifest reference. Handles chunk cleanup when last ref is removed.
/// Remove a manifest reference. When the last reference is removed the
/// manifest is deleted and its chunks are dereferenced, but the chunk files
/// are NOT unlinked here: a chunk hash can be re-uploaded concurrently, so
/// unlinking right after the commit would race that re-reference (the same
/// TOCTOU the GC grace window guards). Newly-orphaned chunks are stamped and
/// reclaimed by [`garbage_collect`](Self::garbage_collect).
async fn remove_manifest_reference(
&self,
file_hash: &str,
@@ -1199,7 +1222,7 @@ impl DedupService {
};
if current_rc <= 1 {
// Last reference — delete manifest and decrement chunks
// Last reference — delete the manifest and dereference its chunks.
sqlx::query("DELETE FROM storage.chunk_manifests WHERE file_hash = $1")
.bind(file_hash)
.execute(&mut *tx)
@@ -1208,45 +1231,36 @@ impl DedupService {
DomainError::internal_error("Dedup", format!("Delete manifest: {}", e))
})?;
// Batch decrement chunk ref_counts
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY($1)")
.bind(chunk_hashes)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e))
})?;
// Find chunks that reached 0
let zero_chunks: Vec<String> = sqlx::query_scalar(
"DELETE FROM storage.blobs WHERE hash = ANY($1) AND ref_count <= 0 RETURNING hash",
// Decrement chunk ref_counts and stamp orphaned_at on the ones that
// reach 0. We deliberately do NOT delete the chunk rows or unlink
// their files here: a chunk hash can be re-uploaded concurrently, so
// unlinking right after this commit would race that re-reference
// (the TOCTOU the grace window guards). garbage_collect() reclaims
// them safely once orphaned past the grace window. GREATEST clamps
// the single-chunk case where the PG file-delete trigger already
// decremented the row (file_hash == chunk_hash).
sqlx::query(
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
.fetch_all(&mut *tx)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("Delete zero chunks: {}", e))
})?;
.map_err(|e| DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e)))?;
tx.commit()
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Commit: {}", e)))?;
// Delete blob files AFTER commit
for chunk_hash in &zero_chunks {
if let Err(e) = self.backend.delete_blob(chunk_hash).await {
tracing::warn!("Failed to delete chunk blob {}: {}", chunk_hash, e);
}
}
// Bug 4 fix: notify hooks — e.g. thumbnail cleanup keyed by file_hash
// File content is gone — drop its blob-keyed thumbnails now.
self.fire_blob_hooks(file_hash);
tracing::info!(
"MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)",
"MANIFEST DELETED: {} ({} chunks dereferenced; orphans reclaimed by GC)",
&file_hash[..12],
chunk_hashes.len(),
zero_chunks.len()
chunk_hashes.len()
);
Ok(true)
} else {
@@ -1810,9 +1824,13 @@ impl DedupService {
/// Garbage collect orphaned manifests and blobs.
///
/// Phase 1: Delete manifests with ref_count = 0, then decrement
/// chunk ref_counts for their chunks.
/// Phase 2: Delete blobs (chunks + legacy) with ref_count = 0.
/// Phase 1: Delete manifests with ref_count = 0 (or no referencing file),
/// then decrement chunk ref_counts for their chunks.
/// Phase 2: Delete blobs (chunks + legacy) that are unreferenced
/// (ref_count = 0), no longer listed by any manifest or file, and have
/// been orphaned for at least [`GC_ORPHAN_GRACE_SECS`](Self::GC_ORPHAN_GRACE_SECS).
/// The grace window and reference cross-checks together make the sweep safe
/// against a concurrent uploader re-referencing a just-orphaned chunk.
pub async fn garbage_collect(&self) -> Result<(u64, u64), DomainError> {
const BATCH_SIZE: i64 = 500;
@@ -1855,9 +1873,12 @@ impl DedupService {
// single-chunk file case where the PG file-delete trigger already
// decremented blobs.ref_count (because file_hash == chunk_hash);
// without the clamp this would underflow the CHECK constraint.
// Stamp orphaned_at so chunks freed here get the same GC grace
// window as any other newly-orphaned blob.
sqlx::query(
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0)
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
@@ -1880,17 +1901,44 @@ impl DedupService {
}
// ── Phase 2: GC orphaned blobs/chunks ────────────────────
// A blob row is collectible only when ALL of these hold:
// • ref_count <= 0, AND
// • it has been orphaned for at least GC_ORPHAN_GRACE_SECS (or has a
// NULL orphaned_at — a pre-migration row or a path that never
// stamped it; those are safe to take immediately), AND
// • no manifest still lists it as a chunk, AND
// • no file still points at it directly (legacy whole-file blob).
//
// The two NOT EXISTS guards mirror Phase 1's file cross-check: a stale
// ref_count = 0 on still-referenced content can then only delay
// collection, never delete live bytes. The grace window keeps a
// concurrent uploader that is about to pin a just-orphaned chunk from
// racing the row-delete → file-unlink gap (see GC_ORPHAN_GRACE_SECS).
// The ctid snapshot already protects against a pin that commits DURING
// the DELETE (the pin rewrites the row's ctid, so it drops out of the
// set); grace covers the remaining post-commit unlink window.
loop {
let batch: Vec<(String, i64)> = sqlx::query_as(
"DELETE FROM storage.blobs
WHERE ctid = ANY(
SELECT ctid FROM storage.blobs
WHERE ref_count <= 0
SELECT b.ctid FROM storage.blobs b
WHERE b.ref_count <= 0
AND (b.orphaned_at IS NULL
OR b.orphaned_at < now() - ($2::int * interval '1 second'))
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.chunk_hashes @> ARRAY[b.hash]
)
AND NOT EXISTS (
SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash
)
LIMIT $1
)
RETURNING hash, size",
)
.bind(BATCH_SIZE)
.bind(Self::GC_ORPHAN_GRACE_SECS as i32)
.fetch_all(self.maintenance_pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("GC blobs: {e}")))?;
@@ -1898,15 +1946,33 @@ impl DedupService {
if batch.is_empty() {
break;
}
let n = batch.len();
for (hash, size) in &batch {
if let Err(e) = self.backend.delete_blob(hash).await {
tracing::warn!("Failed to delete orphan blob {hash}: {e}");
}
// The rows are already gone, so a concurrent re-upload of identical
// content recreates both row and file (durability before
// visibility); the grace window above keeps that race vanishingly
// narrow. Unlink the backing files with bounded fan-out so a large
// sweep doesn't serialise on a slow (e.g. S3) backend.
let backend = self.backend.clone();
let deleted: Vec<(String, i64)> = stream::iter(batch)
.map(|(hash, size)| {
let backend = backend.clone();
async move {
if let Err(e) = backend.delete_blob(&hash).await {
tracing::warn!("Failed to delete orphan blob {hash}: {e}");
}
(hash, size)
}
})
.buffer_unordered(Self::CHUNK_UPLOAD_CONCURRENCY)
.collect()
.await;
for (hash, size) in &deleted {
self.fire_blob_hooks(hash);
total_bytes += *size as u64;
}
total_deleted += batch.len() as u64;
total_deleted += n as u64;
tokio::task::yield_now().await;
}
@@ -2256,7 +2322,9 @@ impl DedupService {
return;
}
if let Err(e) = sqlx::query(
"UPDATE storage.blobs SET ref_count = GREATEST(ref_count - 1, 0)
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
@@ -3299,6 +3367,173 @@ mod delta_upload_integration_tests {
cleanup(&pool, &file_hash, file_id, &[fresh_hash]).await;
}
// ── Garbage collection: grace window + reference cross-checks ─
#[tokio::test]
async fn garbage_collect_honours_grace_window_and_references() {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
// (A) An aged orphan (orphaned well past the grace window) with no
// references → must be collected (row + backing file).
// (B) A freshly orphaned blob (orphaned_at = now()) → must survive: a
// concurrent uploader could still be about to pin it.
let aged = blake3::hash(format!("aged-{}", Uuid::new_v4()).as_bytes())
.to_hex()
.to_string();
let fresh = blake3::hash(format!("fresh-{}", Uuid::new_v4()).as_bytes())
.to_hex()
.to_string();
for h in [&aged, &fresh] {
svc.backend()
.put_blob_from_bytes_unsynced(h, Bytes::from_static(b"xyz"))
.await
.expect("write blob");
}
svc.backend()
.sync_blobs(&[aged.clone(), fresh.clone()])
.await
.expect("sync");
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at) VALUES
($1, 3, 0, now() - interval '2 hours'),
($2, 3, 0, now())",
)
.bind(&aged)
.bind(&fresh)
.execute(pool.as_ref())
.await
.expect("seed orphans");
// (C) A chunk still listed by a live file's manifest, but whose
// blobs.ref_count has drifted to 0 and aged past the grace window.
// The manifest cross-check must keep it (and its bytes) alive — a
// stale ref_count must never delete referenced content.
let data = content(3 * 1024 * 1024, 71);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "gc").await;
let referenced = owned_chunks[0].clone();
sqlx::query(
"UPDATE storage.blobs
SET ref_count = 0, orphaned_at = now() - interval '2 hours'
WHERE hash = $1",
)
.bind(&referenced)
.execute(pool.as_ref())
.await
.expect("drift referenced chunk");
let (deleted, _bytes) = svc.garbage_collect().await.expect("gc");
assert!(deleted >= 1, "the aged orphan must be collected");
// Aged orphan fully gone.
assert!(
blob_ref(&pool, &aged).await.is_none(),
"aged orphan row removed"
);
assert!(
!svc.backend().blob_exists(&aged).await.unwrap(),
"aged orphan file unlinked"
);
// Fresh orphan preserved by the grace window.
assert_eq!(
blob_ref(&pool, &fresh).await,
Some(0),
"fresh orphan survives the grace window"
);
assert!(
svc.backend().blob_exists(&fresh).await.unwrap(),
"fresh orphan bytes kept"
);
// Referenced chunk preserved by the manifest cross-check despite ref 0.
assert_eq!(
blob_ref(&pool, &referenced).await,
Some(0),
"referenced chunk row kept"
);
assert!(
svc.backend().blob_exists(&referenced).await.unwrap(),
"referenced chunk bytes kept"
);
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = ANY($1)")
.bind(vec![aged, fresh])
.execute(pool.as_ref())
.await;
cleanup(&pool, &file_hash, file_id, &[]).await;
}
// ── Manifest dereference defers chunk reclamation to GC ──────
#[tokio::test]
async fn manifest_dereference_defers_chunk_reclamation_to_gc() {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
// Single-owner multi-chunk CDC file → its chunks are uniquely owned.
let data = content(3 * 1024 * 1024, 91);
let (file_hash, chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "deref").await;
assert!(chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
// The delete_file_permanently sequence: drop the file row (PG trigger)
// then dereference the manifest.
sqlx::query("DELETE FROM storage.files WHERE id = $1")
.bind(file_id)
.execute(pool.as_ref())
.await
.expect("delete file row");
assert!(
svc.remove_reference(&file_hash).await.expect("deref"),
"last reference removed"
);
// Manifest is gone immediately…
let manifest_rc: Option<i32> = sqlx::query_scalar(
"SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1",
)
.bind(&file_hash)
.fetch_optional(pool.as_ref())
.await
.expect("manifest query");
assert!(manifest_rc.is_none(), "manifest deleted");
// …but the chunk rows + bytes survive at ref_count 0: no inline unlink
// that could race a concurrent re-upload of the same chunk.
for c in &chunks {
assert_eq!(
blob_ref(&pool, c).await,
Some(0),
"chunk dereferenced, not yet deleted"
);
assert!(
svc.backend().blob_exists(c).await.unwrap(),
"chunk bytes kept until GC reclaims them"
);
}
// Age the orphans past the grace window; GC then reclaims rows + files.
sqlx::query(
"UPDATE storage.blobs SET orphaned_at = now() - interval '2 hours' WHERE hash = ANY($1)",
)
.bind(&chunks)
.execute(pool.as_ref())
.await
.expect("age orphans");
svc.garbage_collect().await.expect("gc");
for c in &chunks {
assert!(blob_ref(&pool, c).await.is_none(), "chunk row reclaimed");
assert!(
!svc.backend().blob_exists(c).await.unwrap(),
"chunk file reclaimed"
);
}
cleanup(&pool, &file_hash, file_id, &[]).await;
}
// ── Verification read ────────────────────────────────────────
#[tokio::test]
async fn hash_chunk_sequence_recomputes_and_validates_sizes() {
@@ -0,0 +1,473 @@
//! Pure geometry + post-processing for the ONNX face pipeline.
//!
//! Everything here is plain Rust (no `ort`, no `ndarray`) so it compiles in the
//! default build and is exercised by `cargo test` — the error-prone numerical
//! parts (SCRFD anchor decode, NMS, 5-point similarity alignment, the affine
//! warp, normalization) are unit-tested in isolation, while the untestable ONNX
//! session calls live behind the `faces-onnx` feature in `onnx_face_analyzer`.
//!
//! The pipeline mirrors InsightFace's reference implementation:
//! SCRFD detector (distance-to-box anchors over strides 8/16/32) → 5-point
//! similarity transform onto the canonical 112×112 ArcFace template → ArcFace
//! embedder → L2-normalized 512-d vector.
use image::RgbImage;
/// One detected face in **detector-input pixel** coordinates (before scaling
/// back to the original image): an axis-aligned box `[x1, y1, x2, y2]`, the
/// five facial landmarks, and the detector confidence.
#[derive(Debug, Clone, Copy)]
pub struct Detection {
pub bbox: [f32; 4],
pub kps: [[f32; 2]; 5],
pub score: f32,
}
/// A 2×3 affine transform mapping an output/template coordinate to a source
/// coordinate: `src = (a·ox + b·oy + tx, c·ox + d·oy + ty)`. Used to sample the
/// source image when warping an aligned face crop.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine {
pub a: f32,
pub b: f32,
pub c: f32,
pub d: f32,
pub tx: f32,
pub ty: f32,
}
/// Canonical ArcFace 5-point template for a 112×112 crop
/// (left eye, right eye, nose, left mouth, right mouth).
pub const ARCFACE_TEMPLATE: [[f32; 2]; 5] = [
[38.2946, 51.6963],
[73.5318, 51.5014],
[56.0252, 71.7366],
[41.5493, 92.3655],
[70.7299, 92.2041],
];
/// Aligned-crop side length expected by the ArcFace embedder.
pub const ALIGN_SIZE: u32 = 112;
/// Letterbox geometry for the detector: the largest scale that fits a
/// `w0 × h0` image into a `det × det` square without distortion, plus the
/// resulting (possibly smaller) dimensions placed at the top-left.
///
/// Returns `(new_w, new_h, scale)` where `scale = min(det/w0, det/h0)` and
/// detector-space coordinates map back to the original by dividing by `scale`.
pub fn letterbox(w0: u32, h0: u32, det: u32) -> (u32, u32, f32) {
if w0 == 0 || h0 == 0 {
return (0, 0, 1.0);
}
let scale = (det as f32 / w0 as f32).min(det as f32 / h0 as f32);
let new_w = ((w0 as f32 * scale).round() as u32).clamp(1, det);
let new_h = ((h0 as f32 * scale).round() as u32).clamp(1, det);
(new_w, new_h, scale)
}
/// `NCHW`, RGB, float input tensor for an ONNX model: `(px − mean) · scale`,
/// channel-major (all R, then all G, then all B). Length is `3 · w · h`.
pub fn chw_normalized(img: &RgbImage, mean: f32, scale: f32) -> Vec<f32> {
let (w, h) = (img.width() as usize, img.height() as usize);
let mut out = vec![0.0f32; 3 * w * h];
let plane = w * h;
for (i, px) in img.pixels().enumerate() {
out[i] = (px[0] as f32 - mean) * scale;
out[plane + i] = (px[1] as f32 - mean) * scale;
out[2 * plane + i] = (px[2] as f32 - mean) * scale;
}
out
}
/// Decode one SCRFD feature-map stride into detections, appending those above
/// `threshold` to `out`. All coordinates are in detector-input pixels.
///
/// `scores` is `[n]`, `bbox` is `[n·4]` (left, top, right, bottom *distances*,
/// already multiplied by `stride`), `kps` (when present) is `[n·10]`
/// (5 × (dx, dy) distances, already multiplied by `stride`), where
/// `n = feat_h · feat_w · num_anchors`. Anchor centers follow InsightFace's
/// row-major `mgrid` order with `num_anchors` consecutive duplicates.
#[allow(clippy::too_many_arguments)]
pub fn decode_stride(
scores: &[f32],
bbox: &[f32],
kps: Option<&[f32]>,
stride: u32,
feat_h: u32,
feat_w: u32,
num_anchors: u32,
threshold: f32,
out: &mut Vec<Detection>,
) {
let stride_f = stride as f32;
let mut idx = 0usize;
for y in 0..feat_h {
for x in 0..feat_w {
let cx = x as f32 * stride_f;
let cy = y as f32 * stride_f;
for _ in 0..num_anchors {
if idx >= scores.len() {
return;
}
let score = scores[idx];
if score >= threshold {
let b = idx * 4;
if b + 3 < bbox.len() {
let det_bbox = [
cx - bbox[b],
cy - bbox[b + 1],
cx + bbox[b + 2],
cy + bbox[b + 3],
];
let mut det_kps = [[0.0f32; 2]; 5];
if let Some(kps) = kps {
let k = idx * 10;
if k + 9 < kps.len() {
for (p, slot) in det_kps.iter_mut().enumerate() {
*slot = [cx + kps[k + p * 2], cy + kps[k + p * 2 + 1]];
}
}
}
out.push(Detection {
bbox: det_bbox,
kps: det_kps,
score,
});
}
}
idx += 1;
}
}
}
}
/// Intersection-over-union of two `[x1, y1, x2, y2]` boxes.
pub fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
let x1 = a[0].max(b[0]);
let y1 = a[1].max(b[1]);
let x2 = a[2].min(b[2]);
let y2 = a[3].min(b[3]);
let iw = (x2 - x1).max(0.0);
let ih = (y2 - y1).max(0.0);
let inter = iw * ih;
let area_a = (a[2] - a[0]).max(0.0) * (a[3] - a[1]).max(0.0);
let area_b = (b[2] - b[0]).max(0.0) * (b[3] - b[1]).max(0.0);
let union = area_a + area_b - inter;
if union <= 0.0 { 0.0 } else { inter / union }
}
/// Greedy non-maximum suppression: keep highest-scoring boxes, drop any whose
/// IoU with an already-kept box exceeds `iou_thresh`. Returns the kept
/// detections, highest score first.
pub fn nms(mut dets: Vec<Detection>, iou_thresh: f32) -> Vec<Detection> {
dets.sort_by(|a, b| b.score.total_cmp(&a.score));
let mut keep: Vec<Detection> = Vec::with_capacity(dets.len());
for d in dets {
if keep.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_thresh) {
keep.push(d);
}
}
keep
}
/// Least-squares similarity transform (scale + rotation + translation, no
/// shear, no reflection) mapping `src` landmarks onto `dst`, returned as its
/// **inverse** affine (output/template coordinate → source coordinate) ready
/// for backward-warp sampling.
///
/// Solved in closed form via the complex-number formulation: with points as
/// complex numbers, `w = Σ (b'ᵢ · conj(a'ᵢ)) / Σ |a'ᵢ|²` and `t = mean_b −
/// w·mean_a`, which is equivalent to the Umeyama solution InsightFace obtains
/// from `skimage.SimilarityTransform`.
pub fn similarity_transform_inverse(src: &[[f32; 2]; 5], dst: &[[f32; 2]; 5]) -> Affine {
let n = 5.0f32;
let (mut max, mut may, mut mbx, mut mby) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
for i in 0..5 {
max += src[i][0];
may += src[i][1];
mbx += dst[i][0];
mby += dst[i][1];
}
max /= n;
may /= n;
mbx /= n;
mby /= n;
// num = Σ b'·conj(a') (complex), den = Σ |a'|² (real)
let (mut num_re, mut num_im, mut den) = (0.0f32, 0.0f32, 0.0f32);
for i in 0..5 {
let ax = src[i][0] - max;
let ay = src[i][1] - may;
let bx = dst[i][0] - mbx;
let by = dst[i][1] - mby;
// b' · conj(a') = (bx + i·by)(ax − i·ay)
num_re += bx * ax + by * ay;
num_im += by * ax - bx * ay;
den += ax * ax + ay * ay;
}
let den = if den.abs() < 1e-12 { 1e-12 } else { den };
// w = num/den (forward scale·rotation)
let wr = num_re / den;
let wi = num_im / den;
// t = mean_b − w·mean_a
let tr = mbx - (wr * max - wi * may);
let ti = mby - (wi * max + wr * may);
// Inverse of the similarity: src = Ainv·(out − t), Ainv = [[wr,wi],[−wi,wr]]/|w|²
let det = wr * wr + wi * wi;
let g = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
Affine {
a: g * wr,
b: g * wi,
c: -g * wi,
d: g * wr,
tx: -g * (wr * tr + wi * ti),
ty: g * (wi * tr - wr * ti),
}
}
/// Warp `img` into an `ALIGN_SIZE × ALIGN_SIZE` aligned face crop using the
/// inverse affine from [`similarity_transform_inverse`], sampling bilinearly
/// and clamping to the image edge.
pub fn warp_to_aligned(img: &RgbImage, inv: &Affine) -> RgbImage {
let (w, h) = (img.width(), img.height());
let mut out = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
for oy in 0..ALIGN_SIZE {
for ox in 0..ALIGN_SIZE {
let sx = inv.a * ox as f32 + inv.b * oy as f32 + inv.tx;
let sy = inv.c * ox as f32 + inv.d * oy as f32 + inv.ty;
let px = bilinear_sample(img, sx, sy, w, h);
out.put_pixel(ox, oy, px);
}
}
out
}
/// Bilinear RGB sample at floating `(x, y)`, clamping out-of-bounds reads to
/// the nearest edge.
fn bilinear_sample(img: &RgbImage, x: f32, y: f32, w: u32, h: u32) -> image::Rgb<u8> {
let x = x.clamp(0.0, (w - 1) as f32);
let y = y.clamp(0.0, (h - 1) as f32);
let x0 = x.floor() as u32;
let y0 = y.floor() as u32;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let dx = x - x0 as f32;
let dy = y - y0 as f32;
let p00 = img.get_pixel(x0, y0);
let p10 = img.get_pixel(x1, y0);
let p01 = img.get_pixel(x0, y1);
let p11 = img.get_pixel(x1, y1);
let mut out = [0u8; 3];
for (ch, slot) in out.iter_mut().enumerate() {
let top = p00[ch] as f32 * (1.0 - dx) + p10[ch] as f32 * dx;
let bot = p01[ch] as f32 * (1.0 - dx) + p11[ch] as f32 * dx;
*slot = (top * (1.0 - dy) + bot * dy).round().clamp(0.0, 255.0) as u8;
}
image::Rgb(out)
}
/// In-place L2 normalization. A zero vector is left unchanged.
pub fn l2_normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-12 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
/// Variance of the discrete Laplacian over the luminance of an RGB crop — a
/// cheap focus/sharpness proxy (higher = sharper). Used as a face quality
/// score for cover selection and gating.
pub fn laplacian_variance(img: &RgbImage) -> f32 {
let (w, h) = (img.width() as i64, img.height() as i64);
if w < 3 || h < 3 {
return 0.0;
}
let lum = |x: i64, y: i64| -> f32 {
let p = img.get_pixel(x as u32, y as u32);
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
};
let mut vals = Vec::with_capacity(((w - 2) * (h - 2)) as usize);
for y in 1..h - 1 {
for x in 1..w - 1 {
let l = 4.0 * lum(x, y) - lum(x - 1, y) - lum(x + 1, y) - lum(x, y - 1) - lum(x, y + 1);
vals.push(l);
}
}
let n = vals.len() as f32;
if n == 0.0 {
return 0.0;
}
let mean = vals.iter().sum::<f32>() / n;
vals.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letterbox_fits_and_preserves_aspect() {
// Landscape 1000×500 into 640 → width-bound, scale 0.64.
let (nw, nh, s) = letterbox(1000, 500, 640);
assert_eq!(nw, 640);
assert_eq!(nh, 320);
assert!((s - 0.64).abs() < 1e-6);
// Square fills exactly.
let (nw, nh, s) = letterbox(800, 800, 640);
assert_eq!((nw, nh), (640, 640));
assert!((s - 0.8).abs() < 1e-6);
}
#[test]
fn letterbox_degenerate_is_safe() {
assert_eq!(letterbox(0, 10, 640), (0, 0, 1.0));
}
#[test]
fn chw_layout_and_normalization() {
let mut img = RgbImage::new(2, 1);
img.put_pixel(0, 0, image::Rgb([127, 0, 255]));
img.put_pixel(1, 0, image::Rgb([128, 255, 0]));
let t = chw_normalized(&img, 127.5, 1.0 / 128.0);
// Length = 3 channels × 2 px.
assert_eq!(t.len(), 6);
// R plane first, then G, then B (NCHW).
assert!((t[0] - (127.0 - 127.5) / 128.0).abs() < 1e-6);
assert!((t[1] - (128.0 - 127.5) / 128.0).abs() < 1e-6);
assert!((t[2] - (0.0 - 127.5) / 128.0).abs() < 1e-6); // G of px0
assert!((t[4] - (255.0 - 127.5) / 128.0).abs() < 1e-6); // B of px0
}
#[test]
fn distance_decode_recovers_box_and_kps() {
// 1×2 grid, stride 8, 1 anchor → cell centers (0,0) then (8,0).
let scores = [0.9f32, 0.9];
// distances left/top/right/bottom (already × stride), identical per cell.
let bbox = [2.0, 1.0, 3.0, 4.0, 2.0, 1.0, 3.0, 4.0];
let kps: Vec<f32> = vec![
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 0
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 1
];
let mut out = Vec::new();
decode_stride(&scores, &bbox, Some(&kps), 8, 1, 2, 1, 0.5, &mut out);
assert_eq!(out.len(), 2);
// Cell 0, center (0,0): box = center ± distances, kps = center + offset.
assert_eq!(out[0].bbox, [-2.0, -1.0, 3.0, 4.0]);
assert_eq!(out[0].kps[0], [1.0, 1.0]);
assert_eq!(out[0].kps[1], [2.0, 2.0]);
// Cell 1, center (8,0): anchor center advanced by one stride in x.
assert_eq!(out[1].bbox, [8.0 - 2.0, -1.0, 8.0 + 3.0, 4.0]);
assert_eq!(out[1].kps[0], [9.0, 1.0]);
}
#[test]
fn decode_thresholds_out_low_scores() {
let scores = [0.2f32, 0.8];
let bbox = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let mut out = Vec::new();
// 1×2 grid, 1 anchor → two cells.
decode_stride(&scores, &bbox, None, 8, 1, 2, 1, 0.5, &mut out);
assert_eq!(out.len(), 1);
assert!((out[0].score - 0.8).abs() < 1e-6);
}
#[test]
fn iou_and_nms() {
let a = [0.0, 0.0, 10.0, 10.0];
let b = [0.0, 0.0, 10.0, 10.0];
assert!((iou(&a, &b) - 1.0).abs() < 1e-6);
let c = [100.0, 100.0, 110.0, 110.0];
assert_eq!(iou(&a, &c), 0.0);
let dets = vec![
Detection {
bbox: a,
kps: [[0.0; 2]; 5],
score: 0.9,
},
Detection {
bbox: b,
kps: [[0.0; 2]; 5],
score: 0.8,
}, // dup of a
Detection {
bbox: c,
kps: [[0.0; 2]; 5],
score: 0.7,
}, // separate
];
let kept = nms(dets, 0.4);
assert_eq!(kept.len(), 2);
assert!((kept[0].score - 0.9).abs() < 1e-6);
}
#[test]
fn similarity_identity() {
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
assert!((inv.a - 1.0).abs() < 1e-4);
assert!(inv.b.abs() < 1e-4);
assert!(inv.c.abs() < 1e-4);
assert!((inv.d - 1.0).abs() < 1e-4);
assert!(inv.tx.abs() < 1e-3);
assert!(inv.ty.abs() < 1e-3);
}
#[test]
fn similarity_pure_translation() {
// src = dst shifted by (+10, +5); inverse must map out→src by the same shift.
let mut src = ARCFACE_TEMPLATE;
for p in &mut src {
p[0] += 10.0;
p[1] += 5.0;
}
let inv = similarity_transform_inverse(&src, &ARCFACE_TEMPLATE);
assert!((inv.a - 1.0).abs() < 1e-4);
assert!(inv.b.abs() < 1e-4);
assert!((inv.tx - 10.0).abs() < 1e-3);
assert!((inv.ty - 5.0).abs() < 1e-3);
}
#[test]
fn warp_identity_preserves_template_region() {
// A 112×112 gradient warped by identity returns (close to) itself.
let mut img = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
for y in 0..ALIGN_SIZE {
for x in 0..ALIGN_SIZE {
img.put_pixel(x, y, image::Rgb([x as u8, y as u8, 128]));
}
}
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
let out = warp_to_aligned(&img, &inv);
let a = out.get_pixel(40, 60);
assert!((a[0] as i32 - 40).abs() <= 1);
assert!((a[1] as i32 - 60).abs() <= 1);
}
#[test]
fn l2_normalize_unit_length() {
let mut v = vec![3.0f32, 4.0];
l2_normalize(&mut v);
assert!((v[0] - 0.6).abs() < 1e-6);
assert!((v[1] - 0.8).abs() < 1e-6);
let mut z = vec![0.0f32, 0.0];
l2_normalize(&mut z); // unchanged, no NaN
assert_eq!(z, vec![0.0, 0.0]);
}
#[test]
fn laplacian_variance_sharp_vs_flat() {
let flat = RgbImage::from_pixel(8, 8, image::Rgb([100, 100, 100]));
assert!(laplacian_variance(&flat) < 1e-3);
let mut checker = RgbImage::new(8, 8);
for y in 0..8 {
for x in 0..8 {
let v = if (x + y) % 2 == 0 { 0 } else { 255 };
checker.put_pixel(x, y, image::Rgb([v, v, v]));
}
}
assert!(laplacian_variance(&checker) > 1000.0);
}
}
@@ -0,0 +1,191 @@
//! Face indexing as a `FileLifecycleHook`.
//!
//! On image upload it detects + embeds faces (off the request path, in a
//! background task) and stores them. It mirrors `MediaMetadataService`: reads
//! the blob from the local `.blobs` tree, is dedup-aware (identical uploads
//! clone an existing file's faces instead of re-running inference), and is
//! completely inert when no model is configured (`FaceAnalyzerPort::is_ready()
//! == false`) — so the feature compiles and runs with the default no-op
//! analyzer until the operator wires a real ONNX model.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::face_ports::{FaceAnalyzerPort, FaceRepository};
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::common::errors::DomainError;
use crate::domain::entities::face::Face;
use crate::infrastructure::repositories::pg::FacePgRepository;
/// Minimum detector confidence for a face to be stored.
const MIN_DET_SCORE: f32 = 0.6;
fn is_image(content_type: &str) -> bool {
content_type.starts_with("image/")
}
pub struct FaceIndexingService {
pool: Arc<PgPool>,
repo: Arc<FacePgRepository>,
analyzer: Arc<dyn FaceAnalyzerPort>,
blob_root: PathBuf,
}
impl FaceIndexingService {
pub fn new(pool: Arc<PgPool>, blob_root: PathBuf, analyzer: Arc<dyn FaceAnalyzerPort>) -> Self {
let repo = Arc::new(FacePgRepository::new(pool.clone()));
Self {
pool,
repo,
analyzer,
blob_root,
}
}
/// Local path of a blob: `.blobs/{prefix}/{hash}.blob`.
fn blob_path(&self, hash: &str) -> PathBuf {
let prefix = if hash.len() >= 2 { &hash[0..2] } else { hash };
self.blob_root.join(prefix).join(format!("{hash}.blob"))
}
/// Spawn a background indexing task. `reuse_dedup` clones faces from an
/// existing file with the same blob hash instead of re-running inference;
/// `delete_first` clears prior faces (used on overwrite).
fn spawn_index(&self, file_id: Uuid, blob_hash: String, reuse_dedup: bool, delete_first: bool) {
let pool = self.pool.clone();
let repo = self.repo.clone();
let analyzer = self.analyzer.clone();
let blob_path = self.blob_path(&blob_hash);
tokio::spawn(async move {
if delete_first {
let _ = repo.delete_faces_for_file(file_id).await;
}
if let Err(e) = index_file(
&pool,
&repo,
analyzer.as_ref(),
file_id,
&blob_path,
&blob_hash,
reuse_dedup,
)
.await
{
tracing::warn!(target: "oxicloud::faces", "face indexing failed for {file_id}: {e}");
}
});
}
}
impl FileLifecycleHook for FaceIndexingService {
fn on_file_created(
&self,
file_id: &str,
blob_hash: &str,
content_type: &str,
is_new_blob: bool,
) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
// Dedup hit (blob already existed) → clone an existing file's faces.
self.spawn_index(fid, blob_hash.to_string(), !is_new_blob, false);
}
}
fn on_file_copied(
&self,
file_id: &str,
blob_hash: &str,
content_type: &str,
_source_file_id: &str,
) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
self.spawn_index(fid, blob_hash.to_string(), true, false);
}
}
fn on_file_updated(&self, file_id: &str, blob_hash: &str, content_type: &str) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
self.spawn_index(fid, blob_hash.to_string(), false, true);
}
}
fn on_file_deleted(&self, _file_id: &str) {
// faces.faces.file_id has ON DELETE CASCADE — the DB cleans up.
}
}
async fn lookup_user(pool: &PgPool, file_id: Uuid) -> Result<Uuid, DomainError> {
let row: (Uuid,) = sqlx::query_as("SELECT user_id FROM storage.files WHERE id = $1")
.bind(file_id)
.fetch_one(pool)
.await
.map_err(|e| DomainError::internal_error("Faces", format!("lookup user: {e}")))?;
Ok(row.0)
}
async fn index_file(
pool: &PgPool,
repo: &FacePgRepository,
analyzer: &dyn FaceAnalyzerPort,
file_id: Uuid,
blob_path: &Path,
blob_hash: &str,
reuse_dedup: bool,
) -> Result<(), DomainError> {
let user_id = lookup_user(pool, file_id).await?;
// Dedup-aware fast path: reuse faces already computed for an identical blob.
if reuse_dedup {
let peers = repo.faces_for_blob(user_id, blob_hash).await?;
let cloned: Vec<Face> = peers
.into_iter()
.filter(|f| f.file_id != file_id)
.map(|f| Face {
id: Uuid::new_v4(),
file_id,
..f
})
.collect();
if !cloned.is_empty() {
repo.save_faces(&cloned).await?;
return Ok(());
}
// No peer found — fall through and analyze.
}
let bytes = tokio::fs::read(blob_path)
.await
.map_err(|e| DomainError::internal_error("Faces", format!("read blob: {e}")))?;
let detected = analyzer.analyze(&bytes).await?;
let faces: Vec<Face> = detected
.into_iter()
.filter(|d| d.det_score >= MIN_DET_SCORE)
.map(|d| Face {
id: Uuid::new_v4(),
file_id,
user_id,
person_id: None,
bbox: d.bbox,
det_score: d.det_score,
quality: d.quality,
embedding: d.embedding,
blob_hash: Some(blob_hash.to_string()),
created_at: Utc::now(),
})
.collect();
repo.save_faces(&faces).await
}
+5
View File
@@ -6,6 +6,8 @@ pub mod compression_service;
pub mod dedup_service;
pub mod encrypted_blob_backend;
pub mod exif_service;
pub mod face_geometry;
pub mod face_indexing_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod image_transcode_service;
@@ -17,7 +19,10 @@ pub mod migration_blob_backend;
pub mod migration_job;
pub mod mock_email_sender;
pub mod nextcloud_chunked_upload_service;
pub mod noop_face_analyzer;
pub mod oidc_service;
#[cfg(feature = "faces-onnx")]
pub mod onnx_face_analyzer;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
@@ -0,0 +1,26 @@
//! Default no-op face analyzer.
//!
//! Used when no ML model is configured: it reports `is_ready() == false` and
//! returns no faces, so the whole People pipeline compiles and runs inert
//! until a real ONNX-backed analyzer (provided by the operator) replaces it.
use async_trait::async_trait;
use crate::application::ports::face_ports::FaceAnalyzerPort;
use crate::common::errors::DomainError;
use crate::domain::entities::face::DetectedFace;
/// Analyzer that never detects anything.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopFaceAnalyzer;
#[async_trait]
impl FaceAnalyzerPort for NoopFaceAnalyzer {
fn is_ready(&self) -> bool {
false
}
async fn analyze(&self, _image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
Ok(Vec::new())
}
}
@@ -0,0 +1,340 @@
//! ONNX-backed face analyzer (SCRFD detector + ArcFace embedder).
//!
//! Compiled only with the `faces-onnx` cargo feature. Mirrors the
//! immich/InsightFace pipeline: detect faces + 5-point landmarks (SCRFD),
//! similarity-align each face to the canonical 112×112 template, then embed
//! (ArcFace) into an L2-normalized 512-d vector. All inference runs on a
//! blocking thread (`spawn_blocking`) so it never stalls a Tokio worker, and
//! each ONNX session is serialized behind a `Mutex` (ORT's `run` needs `&mut`).
//!
//! The heavy numerical post-processing lives in [`super::face_geometry`] (plain
//! Rust, unit-tested); this module only wires it to ONNX Runtime.
//!
//! **Models are operator-provided at runtime, never committed.** `load` returns
//! an error (→ caller falls back to the no-op analyzer) if the ONNX Runtime
//! dylib or either model file is missing or incompatible — the server still
//! boots. The dylib is loaded via [`ort::init_from`] (a fallible path) rather
//! than ORT's lazy loader, which would `panic` on a missing library (fatal
//! under `panic = "abort"`).
use std::path::Path;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use image::RgbImage;
use ort::session::Session;
use ort::value::Tensor;
use super::face_geometry as geom;
use crate::application::ports::face_ports::FaceAnalyzerPort;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, DetectedFace, EMBEDDING_DIM};
/// SCRFD pyramid strides for the 3- and 5-level model variants.
const STRIDES_3: [u32; 3] = [8, 16, 32];
const STRIDES_5: [u32; 5] = [8, 16, 32, 64, 128];
/// Discard faces smaller than this (original-image pixels) — embeddings of tiny
/// faces are unreliable.
const MIN_FACE_PX: f32 = 24.0;
/// Hard cap on faces processed per image (bounds work on crowd shots).
const MAX_FACES: usize = 64;
/// Output layout of an InsightFace SCRFD model, inferred from its output count.
#[derive(Clone, Copy)]
struct ScrfdLayout {
/// Feature-map count per output kind (3 for strides 8/16/32, 5 with 64/128).
fmc: usize,
num_anchors: u32,
use_kps: bool,
}
impl ScrfdLayout {
fn from_num_outputs(n: usize) -> Option<Self> {
match n {
6 => Some(Self {
fmc: 3,
num_anchors: 2,
use_kps: false,
}),
9 => Some(Self {
fmc: 3,
num_anchors: 2,
use_kps: true,
}),
10 => Some(Self {
fmc: 5,
num_anchors: 1,
use_kps: false,
}),
15 => Some(Self {
fmc: 5,
num_anchors: 1,
use_kps: true,
}),
_ => None,
}
}
fn strides(&self) -> &'static [u32] {
if self.fmc == 3 {
&STRIDES_3
} else {
&STRIDES_5
}
}
}
/// Where to find the runtime + models, plus detector knobs. Borrowed paths;
/// nothing is retained after [`OnnxFaceAnalyzer::load`].
pub struct OnnxLoadConfig<'a> {
/// Path to `libonnxruntime.{so,dylib,dll}`.
pub dylib: &'a Path,
/// SCRFD detector `.onnx`.
pub detector: &'a Path,
/// ArcFace embedder `.onnx`.
pub embedder: &'a Path,
pub det_size: u32,
pub det_threshold: f32,
pub nms_threshold: f32,
/// ORT intra-op threads (0 = let ONNX Runtime decide).
pub intra_threads: usize,
}
struct Inner {
detector: Mutex<Session>,
embedder: Mutex<Session>,
layout: ScrfdLayout,
det_size: u32,
det_threshold: f32,
nms_threshold: f32,
}
/// Real face analyzer. Cheap to clone (`Arc` inside).
#[derive(Clone)]
pub struct OnnxFaceAnalyzer {
inner: Arc<Inner>,
}
fn dom(e: impl std::fmt::Display) -> DomainError {
DomainError::internal_error("Faces", e.to_string())
}
fn build_session(path: &Path, intra_threads: usize) -> Result<Session, DomainError> {
let mut builder = Session::builder().map_err(dom)?;
if intra_threads > 0 {
builder = builder.with_intra_threads(intra_threads).map_err(dom)?;
}
builder.commit_from_file(path).map_err(dom)
}
impl OnnxFaceAnalyzer {
/// Load the ONNX Runtime dylib and both models. Returns an error (caller
/// falls back to the no-op analyzer) on any missing/incompatible artifact.
pub fn load(cfg: &OnnxLoadConfig<'_>) -> Result<Self, DomainError> {
// Fallible dylib load — populates ORT's global handle so later calls
// never hit the panicking lazy loader.
ort::init_from(cfg.dylib)
.map_err(|e| dom(format!("ONNX Runtime dylib: {e}")))?
.commit();
let detector = build_session(cfg.detector, cfg.intra_threads)?;
let embedder = build_session(cfg.embedder, cfg.intra_threads)?;
let n_out = detector.outputs().len();
let layout = ScrfdLayout::from_num_outputs(n_out).ok_or_else(|| {
dom(format!(
"detector has {n_out} outputs; expected an SCRFD model (6/9/10/15)"
))
})?;
if !layout.use_kps {
tracing::warn!(
target: "oxicloud::faces",
"SCRFD model has no landmark outputs; face alignment will be approximate"
);
}
tracing::info!(
target: "oxicloud::faces",
"ONNX face analyzer ready (detector {} outputs, embedder loaded, det_size={})",
n_out, cfg.det_size
);
Ok(Self {
inner: Arc::new(Inner {
detector: Mutex::new(detector),
embedder: Mutex::new(embedder),
layout,
det_size: cfg.det_size,
det_threshold: cfg.det_threshold,
nms_threshold: cfg.nms_threshold,
}),
})
}
}
impl Inner {
/// Full synchronous pipeline for one encoded image.
fn analyze_blocking(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
let orig = image::load_from_memory(image_bytes)
.map_err(|e| dom(format!("decode image: {e}")))?
.to_rgb8();
let (w0, h0) = (orig.width(), orig.height());
if w0 == 0 || h0 == 0 {
return Ok(Vec::new());
}
let dets = self.detect(&orig)?;
let mut faces = Vec::new();
for det in dets.into_iter().take(MAX_FACES) {
let fw = det.bbox[2] - det.bbox[0];
let fh = det.bbox[3] - det.bbox[1];
if fw < MIN_FACE_PX || fh < MIN_FACE_PX {
continue;
}
let Some(embedding) = self.embed(&orig, &det)? else {
continue;
};
let aligned_quality = {
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
let aligned = geom::warp_to_aligned(&orig, &inv);
geom::laplacian_variance(&aligned)
};
let x = (det.bbox[0] / w0 as f32).clamp(0.0, 1.0);
let y = (det.bbox[1] / h0 as f32).clamp(0.0, 1.0);
let bw = (fw / w0 as f32).clamp(0.0, 1.0);
let bh = (fh / h0 as f32).clamp(0.0, 1.0);
faces.push(DetectedFace {
bbox: BoundingBox { x, y, w: bw, h: bh },
det_score: det.score,
quality: Some(aligned_quality),
embedding,
});
}
Ok(faces)
}
/// Run SCRFD and return detections in **original-image pixels**.
fn detect(&self, orig: &RgbImage) -> Result<Vec<geom::Detection>, DomainError> {
let det = self.det_size;
let (nw, nh, scale) = geom::letterbox(orig.width(), orig.height(), det);
let resized = image::imageops::resize(orig, nw, nh, image::imageops::FilterType::Triangle);
let mut canvas = RgbImage::new(det, det);
image::imageops::overlay(&mut canvas, &resized, 0, 0);
let input = geom::chw_normalized(&canvas, 127.5, 1.0 / 128.0);
let tensor =
Tensor::from_array(([1_i64, 3, det as i64, det as i64], input)).map_err(dom)?;
let layout = self.layout;
let total = layout.fmc * if layout.use_kps { 3 } else { 2 };
let raw: Vec<Vec<f32>> = {
let mut sess = self
.detector
.lock()
.map_err(|_| dom("detector mutex poisoned"))?;
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
(0..total)
.map(|i| {
outputs[i]
.try_extract_tensor::<f32>()
.map(|(_, data)| data.to_vec())
.map_err(dom)
})
.collect::<Result<_, _>>()?
};
let mut dets = Vec::new();
for (si, &stride) in layout.strides().iter().enumerate() {
let scores = &raw[si];
let bbox: Vec<f32> = raw[layout.fmc + si]
.iter()
.map(|v| v * stride as f32)
.collect();
let kps: Option<Vec<f32>> = if layout.use_kps {
Some(
raw[2 * layout.fmc + si]
.iter()
.map(|v| v * stride as f32)
.collect(),
)
} else {
None
};
let feat = det / stride;
geom::decode_stride(
scores,
&bbox,
kps.as_deref(),
stride,
feat,
feat,
layout.num_anchors,
self.det_threshold,
&mut dets,
);
}
// Scale detector-space coordinates back to the original image.
let inv_scale = if scale.abs() < 1e-9 { 1.0 } else { 1.0 / scale };
for d in &mut dets {
for v in &mut d.bbox {
*v *= inv_scale;
}
for k in &mut d.kps {
k[0] *= inv_scale;
k[1] *= inv_scale;
}
}
Ok(geom::nms(dets, self.nms_threshold))
}
/// Align one detection and run the ArcFace embedder. Returns `None` if the
/// embedder produces an unexpected output length.
fn embed(
&self,
orig: &RgbImage,
det: &geom::Detection,
) -> Result<Option<Vec<f32>>, DomainError> {
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
let aligned = geom::warp_to_aligned(orig, &inv);
let input = geom::chw_normalized(&aligned, 127.5, 1.0 / 127.5);
let size = geom::ALIGN_SIZE as i64;
let tensor = Tensor::from_array(([1_i64, 3, size, size], input)).map_err(dom)?;
let mut embedding: Vec<f32> = {
let mut sess = self
.embedder
.lock()
.map_err(|_| dom("embedder mutex poisoned"))?;
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
let (_, data) = outputs[0].try_extract_tensor::<f32>().map_err(dom)?;
data.to_vec()
};
if embedding.len() != EMBEDDING_DIM {
tracing::warn!(
target: "oxicloud::faces",
"embedder returned {} dims, expected {EMBEDDING_DIM}; skipping face",
embedding.len()
);
return Ok(None);
}
geom::l2_normalize(&mut embedding);
Ok(Some(embedding))
}
}
#[async_trait]
impl FaceAnalyzerPort for OnnxFaceAnalyzer {
fn is_ready(&self) -> bool {
true
}
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
let inner = self.inner.clone();
let bytes = image_bytes.to_vec();
tokio::task::spawn_blocking(move || inner.analyze_blocking(&bytes))
.await
.map_err(|e| dom(format!("inference task join: {e}")))?
}
}
+233 -180
View File
@@ -1,13 +1,14 @@
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
//!
//! Stores grants in `storage.access_grants` (see migration
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
//! using the existing GiST index for O(log N) traversal.
//! Stores grants in `storage.role_grants` (one role per (subject, resource)
//! pair; the role's permission bundle is expanded in code via
//! `Role::expand()`). Cascading is resolved at check time via PostgreSQL
//! `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, using the
//! existing GiST index for O(log N) traversal.
//!
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
//! are checked first via dedicated helpers; if the caller is the owner, no
//! SQL against `access_grants` happens.
//! SQL against `role_grants` happens.
//!
//! ## Lifecycle cleanup
//!
@@ -43,7 +44,7 @@ use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
Permission, Resource, ResourceKind, Subject,
Permission, Resource, ResourceKind, Role, Subject, roles_implying,
};
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
@@ -205,7 +206,7 @@ impl PgAclEngine {
}
/// Expand a caller's `Subject` into the `(subject_types, subject_ids)`
/// pair that should be matched in `storage.access_grants`. For User
/// pair that should be matched in `storage.role_grants`. For User
/// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`;
/// for any non-user subject (Token / External / Group as direct caller)
/// it's a single-element pair with no cascade.
@@ -237,6 +238,22 @@ impl PgAclEngine {
}
}
/// Convert a `Permission` into the array of role strings whose bundle
/// includes it — bound as `ANY($N::storage.grant_role[])` so the
/// ENUM-typed `role` column compares without an implicit text cast.
///
/// This is the inverse of `Role::expand()`, precomputed via
/// `grant_dto::roles_implying()`. The mapping is small and static (≤5
/// roles per permission today); resolving it in code keeps the SQL
/// path simple and lets us add new roles without touching every
/// query site.
fn roles_implying_strings(permission: Permission) -> Vec<&'static str> {
roles_implying(permission)
.iter()
.map(|r| r.as_str())
.collect()
}
/// Cascading check for folders: is there a grant on any ancestor folder
/// (including the target itself) for any of the given subject IDs and
/// any of the given subject types?
@@ -247,6 +264,11 @@ impl PgAclEngine {
/// `subject_ids` is the expanded set returned by `expand_user` (or a
/// single-element vec for non-user callers).
///
/// Reads `storage.role_grants` (1 row per role assignment); a permission
/// filter `g.permission = $3` becomes `g.role = ANY($3::storage.grant_role[])` where
/// the array is the set of roles whose bundle includes the requested
/// permission — see `roles_implying()`.
///
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
async fn folder_cascade_grant_exists(
&self,
@@ -257,14 +279,15 @@ impl PgAclEngine {
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants g
FROM storage.role_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.permission = $3
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
@@ -273,7 +296,7 @@ impl PgAclEngine {
)
.bind(subject_types)
.bind(subject_ids)
.bind(permission.as_str())
.bind(&roles)
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
@@ -285,7 +308,7 @@ impl PgAclEngine {
/// Cascading check for files: either a direct file grant OR a grant on
/// any ancestor folder of the file's containing folder. See
/// `folder_cascade_grant_exists` for the meaning of `subject_types` /
/// `subject_ids`.
/// `subject_ids` and the D-Prep role-array migration.
async fn file_cascade_grant_exists(
&self,
subject_types: &[&str],
@@ -295,27 +318,28 @@ impl PgAclEngine {
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM (
-- direct file grant
SELECT 1
FROM storage.access_grants
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND permission = $3
AND role = ANY($3::storage.grant_role[])
AND resource_type = 'file' AND resource_id = $4
AND (expires_at IS NULL OR expires_at > NOW())
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
FROM storage.access_grants g
FROM storage.role_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.permission = $3
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND target_f.folder_id IS NOT NULL
@@ -327,7 +351,7 @@ impl PgAclEngine {
)
.bind(subject_types)
.bind(subject_ids)
.bind(permission.as_str())
.bind(&roles)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
@@ -336,39 +360,16 @@ impl PgAclEngine {
Ok(exists.is_some())
}
/// Look up a single grant by id. Returns `(resource, granted_by)` so
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
/// without a second round-trip. Returns `Ok(None)` if no such grant.
pub async fn find_grant_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
let Some((rt, rid, granter)) = row else {
return Ok(None);
};
let res = Resource::from_parts(&rt, rid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
Ok(Some((res, granter)))
}
/// Variant of `find_grant_by_id` that also returns the subject —
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
/// Returns `(subject, resource, granted_by)` or `None`.
/// Look up a single role grant by id, returning the actors a revoke /
/// notify handler needs to make a decision without a second round-trip.
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
pub async fn find_grant_full_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
FROM storage.access_grants WHERE id = $1",
FROM storage.role_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
@@ -385,8 +386,14 @@ impl PgAclEngine {
Ok(Some((subject, resource, granter)))
}
/// Row type for all full-grant SELECT queries:
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
/// Row type for `storage.role_grants` SELECTs:
/// (id, subject_type, subject_id, resource_type, resource_id, role, granted_by, granted_at, expires_at).
///
/// Builds a single role-keyed `Grant` per row. `Grant` is role-keyed
/// since the D-Prep cleanup PR — every listing method returns role
/// rows directly; bundle expansion to per-permission Grants no longer
/// happens here. Callers that need the permission set use
/// `grant.role.expand()` at the call site.
#[allow(clippy::type_complexity)]
fn row_to_grant(
row: (
@@ -405,13 +412,13 @@ impl PgAclEngine {
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
let resource = Resource::from_parts(&row.3, row.4)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
let permission = Permission::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
let role = Role::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown role"))?;
Ok(Grant {
id: row.0,
subject,
resource,
permission,
role,
granted_by: row.6,
granted_at: row.7,
expires_at: row.8,
@@ -529,15 +536,14 @@ impl AuthorizationEngine for PgAclEngine {
result
}
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError> {
let perm_str = permission_filter.map(|p| p.as_str().to_string());
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError> {
let counters = QueryCounters::default();
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM
// declared as `(owner, editor, contributor, commenter, viewer)`,
// so the sort order matches the UX requirement ("Owner > Editor
// > Contributor > Commenter > Viewer") without a per-row CASE.
let rows = sqlx::query_as::<
_,
(
@@ -554,18 +560,16 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND ($3::text IS NULL OR permission = $3)
ORDER BY granted_at DESC
LIMIT $4
ORDER BY role ASC, granted_at DESC
LIMIT $3
"#,
)
.bind(&subject_types)
.bind(&subject_ids)
.bind(perm_str)
.bind(MAX_GRANT_ROWS + 1)
.fetch_all(self.pool.as_ref())
.await
@@ -596,7 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
// NULL otherwise. This lets every sort mode share a single query_as call.
// 0 resource_type String
// 1 resource_id Uuid
// 2 permissions Vec<String>
// 2 roles Vec<String> — every distinct role granting access to this
// resource (post-D-Prep). Expanded to permissions
// in `IncomingGrantSummary` via `Role::expand()`.
// Multiple entries possible when a user has both
// a direct grant and a group-mediated grant on
// the same resource.
// 3 granted_at DateTime<Utc>
// 4 granted_by Uuid
// 5 sort_str Option<String> — resource_name (name/type) or owner_name (granted_by)
@@ -628,14 +637,20 @@ impl AuthorizationEngine for PgAclEngine {
// is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the
// listing includes every resource the user can reach via a group
// grant (matching what `check()` allows). See `subject_match_set`.
//
// Post-D-Prep this reads `storage.role_grants` and aggregates the
// ENUM-typed `role` column into a text array. Multiple roles can
// appear per resource when the caller reaches it via both a direct
// grant and a group-mediated grant — the union of role bundles
// produces the displayed permission set in Rust below.
const AGG: &str = r#"agg AS (
SELECT
resource_type,
resource_id,
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
array_agg(DISTINCT role::text ORDER BY role::text) AS roles,
MIN(granted_at) AS granted_at,
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
FROM storage.access_grants
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND ($3::text[] IS NULL OR resource_type = ANY($3))
@@ -700,7 +715,7 @@ impl AuthorizationEngine for PgAclEngine {
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
)
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
FROM named
WHERE {where_clause}
ORDER BY {order_clause}
@@ -740,7 +755,7 @@ impl AuthorizationEngine for PgAclEngine {
FROM agg
LEFT JOIN auth.users u ON u.id = agg.granted_by
)
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
FROM owner_named
WHERE {where_clause}
ORDER BY {order_clause}
@@ -768,7 +783,7 @@ impl AuthorizationEngine for PgAclEngine {
};
format!(
r#"WITH {AGG}
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
SELECT resource_type, resource_id, roles, granted_at, granted_by,
NULL::text AS sort_str,
NULL::bigint AS sort_int
FROM agg
@@ -850,14 +865,21 @@ impl AuthorizationEngine for PgAclEngine {
};
// ── Convert rows to domain summaries ──────────────────────────────────
// Post-D-Prep: the SQL aggregate produces a `roles` text array. We
// expand each role's bundle and union them — direct grants and
// group-mediated grants on the same resource collapse to a single
// deduplicated permission set, matching the pre-pivot behaviour.
let summaries = rows
.into_iter()
.filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| {
.filter_map(|(rt, rid, roles_str, granted_at, granted_by, _, _)| {
let resource_type = ResourceKind::parse(&rt)?;
let permissions = perms_str
let mut permissions: Vec<Permission> = roles_str
.into_iter()
.filter_map(|s| Permission::parse(&s))
.filter_map(|s| Role::parse(&s))
.flat_map(|r| r.expand().iter().copied())
.collect();
permissions.sort_by_key(|p| p.as_str());
permissions.dedup();
Some(IncomingGrantSummary {
resource_type,
resource_id: rid,
@@ -872,6 +894,14 @@ impl AuthorizationEngine for PgAclEngine {
}
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
// Each role row expands to N permission-keyed `Grant` rows via
// `role_row_to_grants` until the public `Grant` shape becomes
// role-keyed.
//
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM's
// declaration order (owner first → viewer last) so the share
// dialog's "who has access" list shows strongest grants on top.
let rows = sqlx::query_as::<
_,
(
@@ -888,11 +918,11 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE resource_type = $1
AND resource_id = $2
ORDER BY granted_at DESC
ORDER BY role ASC, granted_at DESC
LIMIT $3
"#,
)
@@ -917,7 +947,10 @@ impl AuthorizationEngine for PgAclEngine {
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError> {
let fetch_limit = (limit as i64) + 1;
// Row shape — one row per (resource, subject, permission).
// Row shape — post-D-Prep, one row per (resource, subject) since
// `storage.role_grants` carries exactly one role per pair (UNIQUE
// constraint). Permission bundles are expanded in the row consumer
// via `Role::expand()`.
// Columns:
// 0 resource_type String
// 1 resource_id Uuid
@@ -926,9 +959,9 @@ impl AuthorizationEngine for PgAclEngine {
// 4 subject_id Uuid
// 5 subject_display String — username or share item_name
// 6 grant_id Uuid
// 7 granted_at DateTime<Utc> — this (subject, perm) row
// 7 granted_at DateTime<Utc> — this (subject, role) row
// 8 expires_at Option<DateTime<Utc>>
// 9 permission String
// 9 role String — `grant_role` ENUM as text
// 10 sort_str Option<String>
// 11 sort_int Option<i64>
// 12 has_password bool — token: shares.password_hash IS NOT NULL
@@ -1015,7 +1048,7 @@ impl AuthorizationEngine for PgAclEngine {
CASE WHEN ag.resource_type = 'file' THEN fi.name END
) AS sort_str,
{sort_int_expr} AS sort_int
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder'
LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file'
WHERE ag.granted_by = $1
@@ -1030,12 +1063,12 @@ impl AuthorizationEngine for PgAclEngine {
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
rp.sort_str, rp.sort_int,
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
@@ -1104,7 +1137,7 @@ impl AuthorizationEngine for PgAclEngine {
ELSE 3
END)::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN auth.subject_groups sg
@@ -1135,13 +1168,13 @@ impl AuthorizationEngine for PgAclEngine {
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
ag.role::text AS role,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
@@ -1155,7 +1188,12 @@ impl AuthorizationEngine for PgAclEngine {
// Page on (role_order, subject_display, resource_id) triples so that all
// of one person's grants within a role are contiguous — enabling aggregation
// ("Bob on Folder A, Folder B") to work correctly across cursor pages.
// role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer
//
// role_order matches the `storage.grant_role` ENUM declaration
// order (strongest first) via `array_position`, so
// `sort_int ASC` matches the UX requirement: 1 = owner,
// 2 = editor, 3 = contributor, 4 = commenter, 5 = viewer.
// 1-based because `array_position` is.
// Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id
let (page_where, page_order) = if reverse {
(
@@ -1184,15 +1222,20 @@ impl AuthorizationEngine for PgAclEngine {
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
CASE
WHEN BOOL_OR(ag.permission = 'delete')
AND BOOL_OR(ag.permission = 'share') THEN 0
WHEN BOOL_OR(ag.permission = 'create')
OR BOOL_OR(ag.permission = 'update') THEN 1
ELSE 2
END::bigint AS sort_int,
-- One role per (resource, subject) post-D-Prep
-- (UNIQUE constraint on role_grants), so MAX
-- returns that single row's role. `array_position`
-- against the ENUM's declaration order produces a
-- 1-based rank: owner=1 → viewer=5. Strength
-- ordering tracks the ENUM declaration — adding
-- a new role between owner and viewer doesn't
-- need a parallel CASE update here.
array_position(
enum_range(NULL::storage.grant_role),
MAX(ag.role)
)::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh
@@ -1221,13 +1264,13 @@ impl AuthorizationEngine for PgAclEngine {
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
ag.role::text AS role,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
@@ -1259,7 +1302,7 @@ impl AuthorizationEngine for PgAclEngine {
SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at,
NULL::text AS sort_str,
NULL::bigint AS sort_int
FROM storage.access_grants
FROM storage.role_grants
WHERE granted_by = $1
GROUP BY resource_type, resource_id
),
@@ -1272,12 +1315,12 @@ impl AuthorizationEngine for PgAclEngine {
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
NULL::text AS sort_str, NULL::bigint AS sort_int,
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
@@ -1355,7 +1398,7 @@ impl AuthorizationEngine for PgAclEngine {
grant_id,
granted_at,
expires_at,
perm_str,
role_str,
_,
_,
has_password,
@@ -1364,7 +1407,7 @@ impl AuthorizationEngine for PgAclEngine {
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
let Some(role) = Role::parse(&role_str) else {
continue;
};
let key = (resource_id, subj_id);
@@ -1384,8 +1427,10 @@ impl AuthorizationEngine for PgAclEngine {
},
)
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
for &perm in role.expand() {
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
}
@@ -1473,7 +1518,7 @@ impl AuthorizationEngine for PgAclEngine {
grant_id,
granted_at,
expires_at,
perm_str,
role_str,
_,
_,
has_password,
@@ -1482,7 +1527,7 @@ impl AuthorizationEngine for PgAclEngine {
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
let Some(role) = Role::parse(&role_str) else {
continue;
};
@@ -1506,8 +1551,10 @@ impl AuthorizationEngine for PgAclEngine {
has_password,
is_external,
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
for &perm in role.expand() {
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
}
@@ -1553,6 +1600,11 @@ impl AuthorizationEngine for PgAclEngine {
}
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
// Group membership doesn't apply on the outgoing side — we
// filter by `granted_by` directly. Bundle expansion still
// happens at read time via `role_row_to_grants` until the
// public `Grant` shape becomes role-keyed.
let rows = sqlx::query_as::<
_,
(
@@ -1569,10 +1621,10 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
ORDER BY role ASC, granted_at DESC
"#,
)
.bind(granted_by)
@@ -1583,11 +1635,68 @@ impl AuthorizationEngine for PgAclEngine {
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn grant(
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.role_grants SET expires_at = $3 \
WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}"))
})?;
Ok(())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
// ── D-Prep role_grants writes ──────────────────────────────────────────
async fn set_role(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
role: Role,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError> {
@@ -1606,104 +1715,48 @@ impl AuthorizationEngine for PgAclEngine {
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET expires_at = EXCLUDED.expires_at
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO UPDATE SET role = EXCLUDED.role,
expires_at = EXCLUDED.expires_at,
granted_by = EXCLUDED.granted_by
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
role::text, granted_by, granted_at, expires_at
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(resource.type_str())
.bind(resource.id())
.bind(permission.as_str())
.bind(role.as_str())
.bind(granted_by)
.bind(expires_at)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?;
Self::row_to_grant(row)
}
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
Ok(())
}
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 \
"DELETE FROM storage.role_grants \
WHERE subject_type = $1 AND subject_id = $2 \
AND resource_type = $4 AND resource_id = $5",
AND resource_type = $3 AND resource_id = $4",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
})?;
.map_err(|e| DomainError::internal_error("PgAcl", format!("clear_role: {e}")))?;
Ok(())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -56,6 +56,11 @@ const PREVIEW_BYTES: usize = 16 * 1024;
/// 1.5 s interval).
const ORPHAN_SWEEP_TICKS: u64 = 2400;
/// Backoff before the supervisor restarts the drain loop after an abnormal
/// exit (a panic). Long enough that a tight crash-loop can't busy-spin, short
/// enough that indexing resumes promptly.
const WORKER_RESTART_BACKOFF_SECS: u64 = 5;
pub struct ContentIndexWorker {
maintenance_pool: Arc<PgPool>,
dedup: Arc<DedupService>,
@@ -86,49 +91,77 @@ impl ContentIndexWorker {
}
}
/// Spawn the indexing loop. Fire-and-forget: the loop logs and survives
/// every error (an exited loop would silently freeze the index while the
/// queue grows), and the first drain runs immediately to absorb rows left
/// over from a previous run or the migration backfill.
/// Spawn the indexing loop, supervised. The drain loop logs and survives
/// every *operational* error (a failed drain just retries next tick), but a
/// panic in the loop body would otherwise kill the task and silently freeze
/// the index while the dirty queue grows unbounded. The supervisor restarts
/// the loop after a panic (with backoff) so indexing self-heals. The first
/// drain runs immediately to absorb rows left over from a previous run or
/// the migration backfill.
#[instrument(skip(self))]
pub fn start(self, needs_reseed: bool) {
info!(
"Starting content-index worker (every {}ms, batch {}, reseed: {})",
self.interval_ms, DRAIN_BATCH, needs_reseed
);
let worker = Arc::new(self);
tokio::spawn(async move {
if let Err(e) = self.prepare(needs_reseed).await {
// Reseed/version cleanup runs once, not on every restart.
if let Err(e) = worker.prepare(needs_reseed).await {
error!("Content-index prepare failed (continuing with queue as-is): {e}");
}
let mut ticker =
tokio::time::interval(std::time::Duration::from_millis(self.interval_ms));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut ticks: u64 = 0;
// run_loop() never returns under normal operation, so any exit is
// abnormal: a panic surfaces as a JoinError; a plain return would
// be a logic bug. Either way, log loudly and restart.
loop {
ticker.tick().await;
for _ in 0..MAX_BATCHES_PER_TICK {
match self.drain_once().await {
Ok(0) => break,
Ok(drained) => {
debug!("Content-index drain: processed {drained} queue row(s)");
if drained < DRAIN_BATCH as usize {
break;
}
}
Err(e) => {
error!("Content-index drain failed (queue preserved, will retry): {e}");
let w = worker.clone();
match tokio::spawn(async move { w.run_loop().await }).await {
Ok(()) => error!(
"Content-index drain loop returned unexpectedly; \
restarting in {WORKER_RESTART_BACKOFF_SECS}s"
),
Err(e) if e.is_panic() => error!(
"Content-index drain loop panicked ({e}); \
restarting in {WORKER_RESTART_BACKOFF_SECS}s"
),
Err(_) => return, // task cancelled — runtime shutting down
}
tokio::time::sleep(std::time::Duration::from_secs(WORKER_RESTART_BACKOFF_SECS))
.await;
}
});
}
/// The perpetual drain loop. Extracted from [`start`](Self::start) so the
/// supervisor can run it in a child task and restart it after a panic.
async fn run_loop(&self) {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(self.interval_ms));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut ticks: u64 = 0;
loop {
ticker.tick().await;
for _ in 0..MAX_BATCHES_PER_TICK {
match self.drain_once().await {
Ok(0) => break,
Ok(drained) => {
debug!("Content-index drain: processed {drained} queue row(s)");
if drained < DRAIN_BATCH as usize {
break;
}
}
}
ticks += 1;
if ticks.is_multiple_of(ORPHAN_SWEEP_TICKS) {
self.sweep_orphaned_text().await;
Err(e) => {
error!("Content-index drain failed (queue preserved, will retry): {e}");
break;
}
}
}
});
ticks += 1;
if ticks.is_multiple_of(ORPHAN_SWEEP_TICKS) {
self.sweep_orphaned_text().await;
}
}
}
/// Spawn the discard-only janitor used when content search is DISABLED:
+2 -2
View File
@@ -42,7 +42,7 @@ pub fn test_db_url() -> String {
/// OnceCell so concurrent test threads block until the first caller
/// finishes; subsequent calls are zero-cost.
///
/// Order matters: `storage.access_grants` rows go first because there's
/// Order matters: `storage.role_grants` rows go first because there's
/// no FK from there to `auth.subject_groups` (the service's `delete`
/// path does this transactionally; here we bypass the service).
static CLEANUP_ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
@@ -51,7 +51,7 @@ pub async fn ensure_clean_test_db(pool: &PgPool) {
CLEANUP_ONCE
.get_or_init(|| async {
let _ = sqlx::query(
"DELETE FROM storage.access_grants
"DELETE FROM storage.role_grants
WHERE subject_type = 'group'
AND subject_id IN (
SELECT id FROM auth.subject_groups WHERE name LIKE 'rust-test-%'
@@ -218,6 +218,9 @@ async fn handle_propfind(
.to_string();
let user = extract_user(&req)?;
// Caller UUID (string form) — gates the `<D:write/>` privilege on calendars
// the caller owns, so clients mount their own calendars read-write.
let caller_id = user.id.to_string();
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
@@ -256,6 +259,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&user.username,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -333,6 +337,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -359,6 +364,7 @@ async fn handle_propfind(
&calendars,
&propfind_request,
base_href,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -407,6 +413,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| {
AppError::internal_error(format!("Failed to generate XML: {}", e))
+77 -7
View File
@@ -58,6 +58,24 @@ pub fn carddav_routes() -> Router<Arc<AppState>> {
.route("/carddav", axum::routing::any(handle_carddav_methods_root))
}
/// Creates the RFC 6764 well-known discovery route for CardDAV.
/// Public (no auth) — simply redirects to the CardDAV root so clients that
/// bootstrap from `/.well-known/carddav` can locate the service.
pub fn well_known_routes() -> Router<Arc<AppState>> {
Router::new().route(
"/.well-known/carddav",
axum::routing::any(handle_well_known_carddav),
)
}
async fn handle_well_known_carddav() -> Response<Body> {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, "/carddav/")
.body(Body::empty())
.unwrap()
}
async fn handle_carddav_methods_root(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
@@ -226,10 +244,66 @@ async fn handle_propfind(
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
// Discovery: the true root `/carddav/` advertises current-user-principal and
// addressbook-home-set so clients (DAVx5, Apple Contacts) can locate the
// address books. Depth 0 → only the root entry; Depth 1+ → also the books.
if path.is_empty() {
let address_books = if depth == "0" {
vec![]
} else {
addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?
};
let mut response_body = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut response_body,
&address_books,
&propfind_request,
"/carddav/",
&user.username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
// Discovery: principal resource `/carddav/principals/{username}/` returns the
// addressbook-home-set the client should enumerate next.
if path == "principals" || path.starts_with("principals/") {
let username = path
.strip_prefix("principals/")
.map(|s| s.trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(&user.username);
let mut response_body = Vec::new();
CardDavAdapter::generate_principal_propfind_response(
&mut response_body,
&propfind_request,
username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
let effective_path = strip_username_prefix(path);
if effective_path.is_empty() {
// Root CardDAV path or user home — list user's address books
// User address-book home `/carddav/{username}/` — list the user's books.
let address_books = addressbook_service
.list_user_address_books(user.id)
.await
@@ -237,12 +311,8 @@ async fn handle_propfind(
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
let base_href = if path.is_empty() {
"/carddav/".to_string()
} else {
let user_part = path.split('/').next().unwrap_or(path);
format!("/carddav/{}/", user_part)
};
let user_part = path.split('/').next().unwrap_or(path);
let base_href = format!("/carddav/{}/", user_part);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbooks_propfind_response(
&mut response_body,
+183 -250
View File
@@ -11,31 +11,28 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use futures::future::join_all;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{error, info, warn};
use tracing::{error, warn};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
SubjectInputDto, UpdateRoleDto, role_from_permissions,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::recipient_notification_service::NotifyTrigger;
use crate::common::di::AppState;
#[allow(unused_imports)]
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
Subject,
Role, Subject,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -66,28 +63,7 @@ pub async fn create_grant(
let authz = &state.authorization;
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let role: Role = dto.role.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
@@ -152,25 +128,32 @@ pub async fn create_grant(
}
};
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz
.grant(caller_id, subject, perm, resource, expires_at)
.await
{
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
// the engine makes repeated POSTs with the same (subject, resource)
// a role refresh, matching the PATCH-style semantics callers expect.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
Ok(g) => g,
Err(err) => {
error!("set_role write failed: {err}");
return AppError::from(err).into_response();
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
};
let grants = vec![GrantDto::from(grant)];
tracing::info!(
target: "audit",
event = "role_grant.created",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🤝 grant created with role '{}'", role.as_str(),
);
// PR N1 — route the post-grant notification through the unified
@@ -240,7 +223,7 @@ pub async fn create_grant(
(
StatusCode::CREATED,
Json(CreateGrantResponseDto {
grants: results,
grants,
notification,
}),
)
@@ -274,17 +257,20 @@ pub async fn revoke_grant(
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match authz.find_grant_by_id(grant_id).await {
Ok(Some((res, granter))) => (res, granter),
// Look up the grant to find the subject, resource, and granter.
// `find_grant_full_by_id` returns the subject too — needed for the
// `clear_role` dual-write below (role_grants is keyed by (subject,
// resource), not by access_grants id).
let (subject, resource, granter) = match authz.find_grant_full_by_id(grant_id).await {
Ok(Some(triple)) => triple,
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
if granter != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
@@ -293,7 +279,36 @@ pub async fn revoke_grant(
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
// D-Prep dual-write: clear the role_grants row for this (subject,
// resource). Idempotent — succeeds whether or not the row existed.
//
// Today's API revokes one access_grants row by id; the role_grants
// row models the WHOLE (subject, resource) cluster. Calling clear_role
// here effectively revokes the WHOLE role assignment in role_grants,
// even if other per-permission access_grants rows remain. This is the
// correct semantics for the eventual cleanup-PR model (role_grants is
// role-keyed; once access_grants goes away, "revoke" means "drop the
// role"). During the dual-write window the two tables can drift
// briefly if a caller revokes only some permissions of a role, but
// the engine still reads access_grants so behaviour is unchanged.
if let Err(e) = authz.clear_role(subject, resource).await {
return AppError::from(e).into_response();
}
tracing::info!(
target: "audit",
event = "role_grant.revoked",
caller_id = %caller_id,
grant_id = %grant_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
granter_id = %granter,
self_revoke = (granter == caller_id),
"🗑️ grant revoked",
);
StatusCode::NO_CONTENT.into_response()
}
@@ -486,9 +501,8 @@ pub async fn set_role(
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let role: Role = dto.role.into();
let expires_at = dto.expires_at;
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// Caller must have Share on the resource.
if let Err(e) = authz
@@ -498,84 +512,41 @@ pub async fn set_role(
return AppError::from(e).into_response();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz
.grant(caller_id, subject, *perm, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// includes newly added ones and any that were already present (retained).
// Callers that omit expires_at will clear any existing expiry; this is
// intentional: it keeps all permission rows for the pair consistent.
if let Err(e) = authz
.set_expiry_on_resource(subject, resource, expires_at)
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
// no race window. Returns the resulting role row.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
tracing::info!(
target: "audit",
event = "role_grant.role_set",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🔁 role set to '{}'", role.as_str(),
);
(StatusCode::OK, Json(mine)).into_response()
(StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
),
security(("bearerAuth" = [])),
tag = "grants"
@@ -583,12 +554,11 @@ pub struct IncomingQuery {
pub async fn list_incoming(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state
.authorization
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.list_incoming_grants(Subject::User(caller_id))
.await
{
Ok(grants) => {
@@ -691,83 +661,64 @@ pub async fn list_shared_with_me(
.map(|s| s.resource_id.to_string())
.collect();
// Resolve resource details concurrently (files and folders in parallel).
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Resolve resource details in two batch queries (was one per id via
// join_all, which could fan out to ~limit concurrent pooled connections
// and starve the primary pool). Missing ids — stale grants whose resource
// was deleted before the cascade trigger fired — drop out of the maps.
let (file_list, folder_list) = tokio::join!(
file_service.get_files_by_ids(&file_ids),
folder_service.get_folders_by_ids(&folder_ids)
);
let file_map: HashMap<String, _> = match file_list {
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let folder_map: HashMap<String, _> = match folder_list {
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
// Build the unified item list in original grant order (newest first).
// We iterate summaries in order and pick the resolved result from the
// appropriate typed bucket.
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
// Build the unified item list in original grant order (newest first),
// looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
// Stale grant (file deleted, trigger not yet fired) — skip silently.
warn!(
"Skipping stale file grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch file {}: {e}",
summary.resource_id
))
.into_response();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale folder grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch folder {}: {e}",
summary.resource_id
))
.into_response();
}
None => warn!(
"Skipping stale file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
}
None => warn!(
"Skipping stale folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
@@ -936,13 +887,20 @@ pub async fn list_my_shares(
.map(|s| s.resource_id.to_string())
.collect();
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Two batch queries instead of one get_* per id (see list_shared_with_me).
let (file_list, folder_list) = tokio::join!(
file_service.get_files_by_ids(&file_ids),
folder_service.get_folders_by_ids(&folder_ids)
);
let file_map: HashMap<String, _> = match file_list {
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let folder_map: HashMap<String, _> = match folder_list {
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
@@ -962,64 +920,39 @@ pub async fn list_my_shares(
})
.collect();
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing file grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch file {}: {e}",
summary.resource_id
))
.into_response();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
grants,
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch folder {}: {e}",
summary.resource_id
))
.into_response();
}
None => warn!(
"Skipping stale outgoing file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
grants,
});
}
}
None => warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
+1
View File
@@ -16,6 +16,7 @@ pub mod grant_handler;
pub mod i18n_handler;
pub mod magic_link_handler;
pub mod music_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod search_handler;
@@ -0,0 +1,177 @@
//! HTTP handlers for the People (faces) feature.
//!
//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service
//! is present in `AppState`); each handler is also defensive. All work is
//! strictly caller-scoped by `PeopleService` (the repository filters by user).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use uuid::Uuid;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
fn disabled() -> Response {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "People feature is disabled" })),
)
.into_response()
}
fn bad_id() -> Response {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid id" })),
)
.into_response()
}
/// GET /api/people — identity clusters for the caller.
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.list_people(auth_user.id).await {
Ok(people) => Json(people).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/{id}/photos — file ids of a person's photos.
pub async fn person_photos(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.person_photos(auth_user.id, person_id).await {
Ok(files) => Json(files).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct RenameBody {
pub name: Option<String>,
}
/// PATCH /api/people/{id} — name (or clear the name of) a person.
pub async fn rename_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<RenameBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.rename_person(auth_user.id, person_id, body.name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct HideBody {
pub hidden: bool,
}
/// POST /api/people/{id}/hide — hide/unhide a person from the grid.
pub async fn hide_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<HideBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.set_hidden(auth_user.id, person_id, body.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct MergeBody {
pub into: String,
pub from: String,
}
/// POST /api/people/merge — merge `from` into `into`.
pub async fn merge_people(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(body): Json<MergeBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else {
return bad_id();
};
match svc.merge(auth_user.id, into, from).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// POST /api/people/recluster — re-run identity clustering for the caller.
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.recluster(auth_user.id).await {
Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// DELETE /api/people/data — erase all of the caller's face data.
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.delete_all(auth_user.id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
pub async fn faces_for_file(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(fid) = Uuid::parse_str(&file_id) else {
return bad_id();
};
match svc.faces_for_file(auth_user.id, fid).await {
Ok(boxes) => Json(boxes).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
+99 -6
View File
@@ -4,11 +4,12 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::geo_dto::GeoBounds;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser;
@@ -21,6 +22,20 @@ pub struct PhotosQueryParams {
pub limit: Option<i64>,
}
/// Photos-timeline item: a `FileDto` plus the image's original pixel
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
/// the gallery can lay tiles out at their true aspect ratio without a
/// second per-file metadata round-trip.
#[derive(Serialize)]
struct PhotoDto {
#[serde(flatten)]
file: FileDto,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
}
/// Lists all image/video files for the authenticated user, sorted by
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
///
@@ -55,17 +70,22 @@ pub async fn list_photos(
.list_media_files(user_id, params.before, limit)
.await
{
Ok((files, sort_dates)) => {
Ok((files, sort_dates, dims)) => {
info!("Photos: returned {} media files for user", files.len());
// Convert to DTOs with sort_date populated
let dtos: Vec<FileDto> = files
// Convert to DTOs with sort_date + pixel dimensions populated.
let dtos: Vec<PhotoDto> = files
.into_iter()
.zip(sort_dates.iter())
.map(|(file, &sd)| {
.zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64);
dto
PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
height: h.map(|v| v.max(0) as u32),
}
})
.collect();
@@ -91,3 +111,76 @@ pub async fn list_photos(
}
}
}
/// Query parameters for the photos map (clustered) endpoint.
#[derive(Deserialize)]
pub struct GeoQueryParams {
/// Bounding box as `west,south,east,north` (decimal degrees).
pub bbox: String,
/// Slippy-map zoom level (0–20); controls cluster granularity.
pub zoom: Option<u8>,
}
/// Lists the caller's geotagged photos aggregated into map clusters within a
/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted
/// when the Places service is present).
#[utoipa::path(
get,
path = "/api/photos/geo",
params(
("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"),
("zoom" = Option<u8>, Query, description = "Map zoom level (0-20), controls cluster size")
),
responses(
(status = 200, description = "Geotagged photos aggregated into map clusters"),
(status = 400, description = "Invalid bounding box"),
(status = 401, description = "Unauthorized")
),
security(("bearerAuth" = [])),
tag = "photos"
)]
pub async fn list_photos_geo(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<GeoQueryParams>,
) -> impl IntoResponse {
let Some(places) = state.places_service.as_ref() else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Places feature is disabled" })),
)
.into_response();
};
let coords: Vec<f64> = params
.bbox
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if coords.len() != 4 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })),
)
.into_response();
}
let bounds = GeoBounds {
west: coords[0],
south: coords[1],
east: coords[2],
north: coords[3],
};
let zoom = params.zoom.unwrap_or(3);
match places.clusters(auth_user.id, bounds, zoom).await {
Ok(clusters) => Json(clusters).into_response(),
Err(err) => {
error!("Error listing photo geo clusters: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("{}", err) })),
)
.into_response()
}
}
}
+3 -2
View File
@@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
SubjectTypeDto, UpdateRoleDto,
};
use crate::application::dtos::i18n_dto::{
@@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::recent_handler::clear_recent_items,
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
// Batch handlers (free functions)
handlers::batch_handler::move_files_batch,
handlers::batch_handler::copy_files_batch,
@@ -351,7 +352,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
ResourceTypeDto,
ResourceDto,
PermissionDto,
Role,
RoleDto,
CreateGrantDto,
UpdateRoleDto,
GrantDto,
+24 -4
View File
@@ -6,7 +6,7 @@ use axum::{
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Json as AxumJson, Response},
routing::{any, delete, get, post, put},
routing::{any, delete, get, patch, post, put},
};
use serde_json::json;
use std::sync::Arc;
@@ -431,13 +431,33 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
{
use crate::interfaces::api::handlers::photos_handler;
let photos_router = Router::new()
.route("/", get(photos_handler::list_photos))
.with_state(app_state.clone());
let mut photos_router = Router::new().route("/", get(photos_handler::list_photos));
if app_state.places_service.is_some() {
photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo));
}
let photos_router = photos_router.with_state(app_state.clone());
router = router.nest("/photos", photos_router);
}
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
if app_state.people_service.is_some() {
use crate::interfaces::api::handlers::people_handler;
let people_router = Router::new()
.route("/", get(people_handler::list_people))
.route("/merge", post(people_handler::merge_people))
.route("/recluster", post(people_handler::recluster))
.route("/data", delete(people_handler::delete_all))
.route("/faces/{file_id}", get(people_handler::faces_for_file))
.route("/{id}", patch(people_handler::rename_person))
.route("/{id}/photos", get(people_handler::person_photos))
.route("/{id}/hide", post(people_handler::hide_person))
.with_state(app_state.clone());
router = router.nest("/people", people_router);
}
// Re-enable trash routes to make the trash view work
if let Some(_trash_service_ref) = trash_service.clone() {
tracing::info!("Setting up trash routes for trash view");
+46 -17
View File
@@ -14,6 +14,7 @@ use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Validate the request's JWT (from the `Authorization: Bearer …` header
/// or the access-token cookie) and require `claims.role == "admin"`.
@@ -43,19 +44,37 @@ pub async fn require_admin(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
if claims.role != "admin" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
));
}
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
// Gate on the *live* role, not the JWT claim: a demotion or deactivation
// must take effect within the flags-cache TTL rather than surviving until
// the token expires.
match resolve_live_role(
auth.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) if role == "admin" => Ok((user_id, role)),
LiveRole::Active(role) => {
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %user_id,
role = %role,
"👮🏻‍♂️ admin-only endpoint denied for non-admin caller"
);
Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
))
}
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
}
}
/// Validate the request's JWT (any role) and return `(user_id, role)`.
@@ -84,9 +103,19 @@ pub async fn require_authenticated(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
// Reject tokens whose account was deactivated/deleted, and return the
// caller's live role rather than the (possibly stale) JWT claim.
match resolve_live_role(
auth.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => Ok((user_id, role)),
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
}
}
+164 -41
View File
@@ -13,6 +13,7 @@ use crate::common::di::AppState;
// Re-export CurrentUser from application layer for use in handlers
pub use crate::application::dtos::user_dto::CurrentUser;
use crate::application::ports::auth_ports::TokenServicePort;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Marker inserted into request extensions when the user was authenticated
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
@@ -111,6 +112,9 @@ pub enum AuthError {
#[error("User not found")]
UserNotFound,
#[error("Account is no longer active")]
AccountInactive,
#[error("Access denied: {0}")]
AccessDenied(String),
@@ -127,6 +131,10 @@ impl IntoResponse for AuthError {
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
AuthError::AccountInactive => (
StatusCode::UNAUTHORIZED,
"Account is no longer active".to_string(),
),
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
AuthError::AuthServiceUnavailable => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -181,11 +189,26 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// A cryptographically valid token must not outlive the
// account: re-check the live record so deactivation,
// deletion and demotion take effect within the flags-cache
// TTL instead of waiting for token expiry. The returned
// role is authoritative — never the frozen JWT claim.
let role = match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => role,
LiveRole::Revoked => return Err(AuthError::AccountInactive),
};
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
@@ -240,17 +263,12 @@ pub async fn auth_middleware(
}
Err(e) => {
tracing::warn!("App password verification failed: {}", e);
// For WebDAV: include WWW-Authenticate so the client
// knows to re-prompt rather than silently failing.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(
"Invalid username or app password",
))
.unwrap());
// For DAV clients: include WWW-Authenticate so the client
// re-prompts for credentials rather than failing silently.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge(
"Invalid username or app password",
));
}
return Err(AuthError::InvalidToken(
"Invalid username or app password".to_string(),
@@ -285,16 +303,33 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
return Ok(next.run(request).await);
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
// /login, exactly like an invalid or expired cookie.
match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => {
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role,
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
// Fall through to the unauthenticated 401 / login redirect.
}
}
}
Err(e) => {
tracing::debug!("Cookie token validation failed: {}", e);
@@ -312,27 +347,48 @@ pub async fn auth_middleware(
return Err(AuthError::AuthServiceUnavailable);
}
// For WebDAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Nautilus, Cyberduck,
// Windows Explorer, macOS Finder) know to prompt for a username/password.
// Non-WebDAV routes return the standard AuthError which renders without
// this header — keeping browser sessions redirecting to /login as before.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from("Authentication required"))
.unwrap());
// For DAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Thunderbird, DAVx5,
// Apple Calendar/Contacts, Nautilus, Cyberduck, Windows Explorer, macOS
// Finder) know to prompt for credentials and retry. Unlike `curl -u`, these
// clients do NOT send Basic credentials preemptively — without the
// challenge they never authenticate and fail with "discovery failed" / 401.
// Non-DAV routes return the standard AuthError which renders without this
// header — keeping browser sessions redirecting to /login as before.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge("Authentication required"));
}
Err(AuthError::TokenNotProvided)
}
/// DAV protocol surfaces (WebDAV, CalDAV, CardDAV) authenticate over HTTP Basic.
/// Spec-compliant clients (Thunderbird, DAVx5, Apple Calendar/Contacts, file
/// managers) only send credentials after receiving a `401` carrying a
/// `WWW-Authenticate: Basic` challenge, so these paths must emit it. Browser and
/// JSON-API routes deliberately do not, so they keep redirecting to `/login`.
fn is_dav_path(path: &str) -> bool {
path.starts_with("/webdav") || path.starts_with("/caldav") || path.starts_with("/carddav")
}
/// Build the `401 Unauthorized` Basic-auth challenge shared by every DAV
/// surface, so clients re-prompt for credentials instead of failing silently.
fn dav_basic_auth_challenge(message: &'static str) -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(message))
.unwrap()
}
/// Middleware to verify that the authenticated user has an admin role.
///
/// Must be applied AFTER auth_middleware, as it depends on
/// `CurrentUser` being present in the request extensions.
/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser`
/// being present in the request extensions. The role carried by
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
/// here within the flags-cache TTL.
pub async fn require_admin(request: Request, next: Next) -> Response {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
@@ -340,16 +396,83 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
tracing::debug!("Admin access granted for user: {}", current_user.username);
return next.run(request).await;
}
tracing::warn!(
"Admin access denied for user: {} (role: {})",
current_user.username,
current_user.role
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %current_user.id,
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
} else {
tracing::warn!("Admin check failed: no authenticated user in request");
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
}
// Access denied
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dav_paths_receive_basic_auth_challenge() {
// Regression for #480: CalDAV/CardDAV clients (Thunderbird, DAVx5) only
// send credentials after a 401 carrying WWW-Authenticate. All three DAV
// surfaces must qualify so the challenge is emitted.
for path in [
"/webdav/",
"/webdav/admin/file.txt",
"/caldav/",
"/caldav/admin/cal/",
"/carddav/",
"/carddav/principals/admin/",
] {
assert!(is_dav_path(path), "{path} should be treated as a DAV path");
}
}
#[test]
fn non_dav_paths_do_not_receive_basic_auth_challenge() {
for path in [
"/",
"/api/files",
"/login",
"/index.html",
"/.well-known/caldav",
] {
assert!(
!is_dav_path(path),
"{path} must not get a Basic-auth challenge (browser/API surface)"
);
}
}
#[test]
fn challenge_sets_www_authenticate_header() {
let resp = dav_basic_auth_challenge("Authentication required");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok()),
Some(r#"Basic realm="OxiCloud""#),
);
}
#[test]
fn account_inactive_maps_to_401() {
// A token that is still cryptographically valid but whose account was
// deactivated/deleted must be rejected with 401 (credentials no longer
// valid), so browsers redirect to /login rather than seeing a 403.
let resp = AuthError::AccountInactive.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
+136 -1
View File
@@ -32,7 +32,8 @@ use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::common::di::AppState;
use crate::domain::entities::user::UserRole;
use crate::domain::entities::user::{UserFlags, UserRole};
use crate::domain::errors::{DomainError, ErrorKind};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
@@ -102,6 +103,91 @@ pub async fn require_admin_user(
Ok(())
}
/// Outcome of re-checking a token-authenticated caller against the live
/// user record (see [`resolve_live_role`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LiveRole {
/// The account exists and is active. Carries the caller's *current*
/// role string (`"admin"` / `"user"`), which is authoritative and
/// supersedes the — possibly stale — JWT `role` claim.
Active(String),
/// The account is deactivated or deleted: the request must be rejected
/// even though its token is still cryptographically valid.
Revoked,
}
/// Re-validate a caller carried by a still-valid token against the live
/// user record, so deactivation, deletion and role changes take effect
/// within [`USER_FLAGS_CACHE_TTL`](crate::application::services::auth_application_service)
/// instead of waiting for the token to expire (access 1 h / refresh 7 d by
/// default).
///
/// JWT claims — `role` included — are frozen at login. Without this check a
/// demoted admin keeps admin power, and a disabled or deleted account keeps
/// full access, until its token expires. Returning the *current* role lets
/// every caller stop trusting `claims.role`.
///
/// Cost: the short-TTL-cached `get_user_flags` (no `image` column), so ~one
/// tiny indexed query per user per cache-TTL window; admin role/active
/// changes invalidate the entry eagerly for immediate effect.
///
/// Availability stance mirrors [`require_internal_user`]: a *transient*
/// lookup failure fails OPEN with the claim role (a DB blip must not lock
/// every authenticated user out, and login/refresh already enforce `active`
/// at the canonical layer). A *missing* row (`NotFound`) is a definitive
/// revocation and fails CLOSED.
pub async fn resolve_live_role(
auth: &AuthApplicationService,
user_id: Uuid,
claim_role: &str,
) -> LiveRole {
decide_live_role(auth.get_user_flags(user_id).await, user_id, claim_role)
}
/// Pure decision core of [`resolve_live_role`], split out so the
/// allow/revoke/fail-open policy is unit-testable without a service or DB.
fn decide_live_role(
flags: Result<UserFlags, DomainError>,
user_id: Uuid,
claim_role: &str,
) -> LiveRole {
match flags {
Ok(flags) if flags.active => LiveRole::Active(flags.role.to_string()),
Ok(_) => {
audit_token_revoked(user_id, "deactivated");
LiveRole::Revoked
}
// The user row is gone — a definitive revocation; fail closed.
Err(e) if matches!(e.kind, ErrorKind::NotFound) => {
audit_token_revoked(user_id, "deleted");
LiveRole::Revoked
}
// Transient lookup failure (DB blip): fail open on the claim role so
// a momentary outage doesn't 401 every authenticated user at once.
Err(e) => {
tracing::warn!(
user_id = %user_id,
error = %e,
"live-user re-check failed transiently; allowing request on the JWT claim role (fail-open)"
);
LiveRole::Active(claim_role.to_string())
}
}
}
/// Audit a request rejected because the token outlived the account's access
/// (deactivation or deletion). Anti-enumeration is not a concern — the
/// subject is the caller's own account.
fn audit_token_revoked(user_id: Uuid, reason: &'static str) {
tracing::info!(
target: "audit",
event = "auth.token_revoked",
reason = reason,
caller_id = %user_id,
"👮🏻‍♂️ valid token presented for an account that is no longer active — rejected"
);
}
/// Axum middleware layer that blocks external users from a whole route
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
@@ -153,3 +239,52 @@ pub async fn require_internal_user_layer(
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
fn flags(role: UserRole, active: bool) -> UserFlags {
UserFlags {
role,
is_external: false,
active,
}
}
#[test]
fn active_admin_yields_current_admin_role() {
let live = decide_live_role(Ok(flags(UserRole::Admin, true)), Uuid::nil(), "user");
// The live record wins over the (stale) claim — a freshly promoted
// user is admin even though their token still says "user".
assert_eq!(live, LiveRole::Active("admin".to_string()));
}
#[test]
fn active_user_yields_current_user_role() {
// A demoted admin: token claim still "admin", live record "user".
let live = decide_live_role(Ok(flags(UserRole::User, true)), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("user".to_string()));
}
#[test]
fn deactivated_account_is_revoked() {
let live = decide_live_role(Ok(flags(UserRole::Admin, false)), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Revoked);
}
#[test]
fn deleted_account_not_found_is_revoked() {
let err = DomainError::new(ErrorKind::NotFound, "User", "no such user");
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Revoked);
}
#[test]
fn transient_error_fails_open_on_claim_role() {
// A DB blip must not lock everyone out: allow on the claim role.
let err = DomainError::new(ErrorKind::InternalError, "User", "connection reset");
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("admin".to_string()));
}
}
+34 -8
View File
@@ -7,7 +7,7 @@ use quick_xml::{
Reader, Writer,
events::{BytesEnd, BytesStart, Event},
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
@@ -17,7 +17,6 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
@@ -86,20 +85,47 @@ async fn handle_filter_files(
let home_prefix = format!("My Folder - {}/", user.username);
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
// concern from the oc:fileid resolution batched below).
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
// REPORT). Results are looked up by id so the response keeps favorites
// order; missing/trashed favorites simply drop out (as before).
let mut file_ids: Vec<String> = Vec::new();
let mut folder_ids: Vec<String> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => file_ids.push(fav.item_id.clone()),
"folder" => folder_ids.push(fav.item_id.clone()),
_ => {}
}
}
let file_map: HashMap<String, FileDto> = file_service
.get_files_by_ids(&file_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
.into_iter()
.map(|f| (f.id.clone(), f))
.collect();
let folder_map: HashMap<String, FolderDto> = folder_service
.get_folders_by_ids(&folder_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
.into_iter()
.map(|f| (f.id.clone(), f))
.collect();
let mut files: Vec<FileDto> = Vec::new();
let mut folders: Vec<FolderDto> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
if let Ok(f) = file_service.get_file(&fav.item_id).await {
files.push(f);
if let Some(f) = file_map.get(&fav.item_id) {
files.push(f.clone());
}
}
"folder" => {
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
folders.push(f);
if let Some(f) = folder_map.get(&fav.item_id) {
folders.push(f.clone());
}
}
_ => {}
+3 -1
View File
@@ -311,7 +311,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
use oxicloud::interfaces::api::handlers::carddav_handler;
use oxicloud::interfaces::api::handlers::webdav_handler;
let caldav_router = caldav_handler::caldav_routes();
let well_known_router = caldav_handler::well_known_routes();
// RFC 6764 discovery for both CalDAV and CardDAV (public redirects).
let well_known_router =
caldav_handler::well_known_routes().merge(carddav_handler::well_known_routes());
let carddav_router = carddav_handler::carddav_routes();
let webdav_router = webdav_handler::webdav_routes();
+5
View File
@@ -0,0 +1,5 @@
# The vector basemap is large (tens of MB) and operator-provided — never
# commit it to the repo. Drop a Protomaps `.pmtiles` here as `basemap.pmtiles`
# and the existing static file server (tower-http ServeDir, Range-capable)
# will serve it to the Places map. See README.md.
*.pmtiles
+33
View File
@@ -0,0 +1,33 @@
# Places basemap (optional)
The **Places** photo map renders your geotagged photos as clusters. It works
out of the box **without** a basemap (clusters on a plain background). To get a
real street/terrain backdrop, drop a self-hosted vector basemap here — no
third-party tile API, fully offline.
## How it works (Approach "A")
OxiCloud already serves `static/` through `tower-http`'s `ServeDir`, which
honours **HTTP Range** requests. A [PMTiles](https://docs.protomaps.com/pmtiles/)
basemap is a *single file* read directly by the browser via Range — so the
basemap is just a static file the app already knows how to serve. No extra
backend, no tile server, no API keys.
## Enabling it
1. Get a Protomaps `.pmtiles` basemap (vector, ODbL OpenStreetMap data):
- Whole planet z0–15 (~120 GB) or a smaller global `z0-6` (~60 MB), or
- A **regional extract** (recommended — only the area you need, a few MB):
```sh
# one-time, downloads only your bounding box from the remote planet
pmtiles extract https://build.protomaps.com/<DATE>.pmtiles basemap.pmtiles \
--bbox=<west>,<south>,<east>,<north>
```
See https://docs.protomaps.com/basemaps/downloads
2. Place it here as **`static/basemaps/basemap.pmtiles`** (this path is
git-ignored on purpose — see `.gitignore`).
3. Reload the Places view. The map will pick it up automatically.
The bundled style is **label-light** (water / land / roads / buildings, no
text) so it needs no glyph/sprite assets. Attribution “© OpenStreetMap”
(ODbL) is shown automatically when a basemap is present.
+112
View File
@@ -0,0 +1,112 @@
/* People (faces) view */
.people-container {
display: none;
}
.people-container.active {
display: block;
padding: var(--space-2);
}
/* Grid of person tiles */
.people-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--space-4);
padding: var(--space-2);
}
.person-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: none;
border: none;
cursor: pointer;
border-radius: var(--radius-lg);
}
.person-tile:hover {
background: var(--color-bg-muted);
}
.person-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
background-size: cover;
background-position: center;
background-color: var(--color-bg-muted);
border: 2px solid var(--color-border);
}
.person-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.person-count {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* Single-person header */
.people-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2);
}
.people-toolbar .people-title {
flex: 1;
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--color-text);
}
.people-back,
.people-rename {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: none;
color: var(--color-text-subtle);
font-size: var(--text-base);
cursor: pointer;
}
.people-back:hover,
.people-rename:hover {
background: var(--color-bg-muted);
}
/* Loading / empty states */
.people-loading,
.people-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-20) var(--space-5);
color: var(--color-text-faint);
}
.people-empty i {
font-size: 48px;
color: var(--color-border-medium);
}
.people-loading i {
animation: spin 1s linear infinite;
}
+31
View File
@@ -8,11 +8,18 @@
display: block;
}
/* Virtualized timeline: each date-group is a <section>; its grid is
materialized (tiles inserted) only while near the viewport — see photos.js. */
.photos-group {
display: block;
}
/* Toolbar with group mode toggle */
.photos-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-2) var(--space-2) var(--space-1);
}
@@ -48,6 +55,25 @@
margin-bottom: var(--space-4);
}
/* Justified (aspect-preserving) layout — the grid becomes a column of rows;
tile sizes are set inline by photos.js (see _justifiedRows). */
.photos-layout-justified .photos-grid {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.photos-jrow {
display: flex;
flex-direction: row;
gap: var(--space-2);
}
.photos-layout-justified .photo-tile {
aspect-ratio: auto;
flex: 0 0 auto;
}
/* Monthly mode — larger tiles, more breathing room */
.photos-group-monthly .photos-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
@@ -107,6 +133,11 @@
border-color: var(--color-border-medium);
}
.photo-tile:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.photo-tile:hover img {
transform: scale(1.03);
}
+39
View File
@@ -170,6 +170,45 @@
z-index: 10001;
}
/* EXIF info panel */
.lightbox-infopanel {
position: absolute;
top: 64px;
right: var(--space-4);
max-width: 320px;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-lightbox-btn-bg);
color: var(--color-lightbox-btn-text);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
z-index: 10001;
}
.lightbox-infopanel.hidden {
display: none;
}
.lb-info-row {
display: flex;
align-items: center;
gap: var(--space-2);
word-break: break-word;
}
.lb-info-row i {
width: 18px;
text-align: center;
opacity: 0.8;
}
/* Zoomed photo shows a grab cursor for panning */
.lightbox-content img.is-zoomed {
cursor: grab;
}
/* Responsive */
@media (max-width: 768px) {
.lightbox-nav {
+90
View File
@@ -0,0 +1,90 @@
/* Photos sub-navigation (Moments | Places) */
.photos-subnav {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-2) 0;
}
.photos-subnav.hidden {
display: none;
}
.photos-subnav-tab {
background: none;
border: none;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text-faint);
cursor: pointer;
border-radius: var(--radius-md);
border-bottom: 2px solid transparent;
}
.photos-subnav-tab:hover {
color: var(--color-text);
}
.photos-subnav-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Map view */
.places-container {
display: none;
}
.places-container.active {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
min-height: 360px;
padding: var(--space-2);
}
.places-map {
flex: 1 1 auto;
width: 100%;
border-radius: var(--radius-2xl);
overflow: hidden;
}
.places-loading,
.places-error {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--color-text-faint);
}
.places-loading i {
animation: spin 1s linear infinite;
}
/* Cluster markers — a circular photo thumbnail with a count badge */
.places-cluster {
background-size: cover;
background-position: center;
background-color: var(--color-bg-muted);
border-radius: 50%;
border: 2px solid var(--color-bg-surface);
box-shadow: 0 2px 8px var(--color-shadow-sm);
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
}
.places-cluster-count {
background: var(--color-accent);
color: var(--color-danger-text);
font-size: var(--text-2xs);
font-weight: var(--weight-bold);
line-height: 1;
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-full);
transform: translateY(35%);
}
+4
View File
@@ -36,6 +36,8 @@
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
<link rel="stylesheet" href="/css/views/places.css">
<link rel="stylesheet" href="/css/views/people.css">
<link rel="stylesheet" href="/css/views/music.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
@@ -57,6 +59,8 @@
<script defer type="module" src="/js/features/library/favorites.js"></script>
<script defer type="module" src="/js/features/library/recent.js"></script>
<script defer type="module" src="/js/features/library/photos.js"></script>
<script defer type="module" src="/js/features/library/places.js"></script>
<script defer type="module" src="/js/features/library/people.js"></script>
<script defer type="module" src="/js/features/library/music.js"></script>
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
<script defer type="module" src="/js/model/recentModel.js"></script>
+4 -1
View File
@@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
import { placesView } from '../features/library/places.js';
import { grants } from '../model/grants.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
import { mySharesView } from '../views/myShares/mySharesView.js';
@@ -225,9 +226,10 @@ function setCurrentSection(section) {
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
// Hide photosView when switching to any other section
// Hide photosView (+ the Places sub-view) when switching to any other section
if (section !== 'photos' && photosView) {
photosView.hide();
placesView.unmountTabs();
}
// Hide musicView when switching to any other section
@@ -451,6 +453,7 @@ function switchToPhotosSection() {
if (photosView) {
photosView.show();
}
placesView.mountTabs();
if (batchToolbar) batchToolbar.clear();
}
+41
View File
@@ -436,6 +436,47 @@ const Modal = {
requestAnimationFrame(() => {
this.overlay.classList.add('active');
});
},
/**
* Confirmation dialog (replacement for window.confirm()).
* Built on openPanel, so it inherits the overlay, animation, focus-trap,
* Escape and click-outside handling.
* @param {Object} options
* @param {string} options.title
* @param {string} options.message
* @param {string} [options.confirmText]
* @param {string} [options.cancelText]
* @param {string} [options.icon] - Font Awesome class, default 'fa-circle-question'
* @returns {Promise<boolean>} true if confirmed, false otherwise
*/
confirmDialog({ title, message, confirmText = null, cancelText = null, icon = 'fa-circle-question' }) {
return new Promise((resolve) => {
if (!this.overlay) {
resolve(false);
return;
}
const content = document.createElement('p');
content.className = 'modal-confirm-message';
content.textContent = message;
let settled = false;
const done = (/** @type {boolean} */ value) => {
if (settled) return;
settled = true;
resolve(value);
};
this.openPanel({
title,
icon,
content,
confirmText: confirmText ?? i18n.t('actions.confirm'),
cancelText: cancelText ?? i18n.t('actions.cancel'),
onConfirm: () => done(true),
onCancel: () => done(false)
});
});
}
};
+1 -1
View File
@@ -454,7 +454,7 @@ class MySharesList {
);
menu.appendChild(this._menuSeparator());
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
for (const role of /** @type {('owner'|'editor'|'viewer')[]} */ (['owner', 'editor', 'viewer'])) {
const isCurrent = grant.role === role;
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
menu.remove();
+7 -4
View File
@@ -23,7 +23,7 @@ import { i18n } from '../core/i18n.js';
* @returns {'manage'|'edit'|'view'}
*/
function roleMod(role) {
if (role === 'admin') return 'manage';
if (role === 'owner') return 'manage';
if (role === 'editor') return 'edit';
return 'view';
}
@@ -31,14 +31,17 @@ function roleMod(role) {
/**
* Translate a role identifier into a localized human-readable label.
* Exported so callers that just want the label (e.g. context-menu rows)
* can reuse the same wording the chip uses.
* can reuse the same wording the chip uses. Unknown roles fall back to
* the raw role string — `commenter` and `contributor` exist server-side
* but aren't surfaced in the UI today, so they'll display as-is until a
* future UI exposure adds proper labels.
* @param {string} role
* @returns {string}
*/
export function roleLabel(role) {
/** @type {Record<string,string>} */
const m = {
admin: i18n.t('share.role.canManage', 'Can manage'),
owner: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
@@ -51,7 +54,7 @@ export function roleLabel(role) {
* @returns {string}
*/
function roleIcon(role) {
if (role === 'admin') return 'fa-crown';
if (role === 'owner') return 'fa-crown';
if (role === 'editor') return 'fa-pencil-alt';
return 'fa-eye';
}
+15 -16
View File
@@ -73,13 +73,6 @@ function _looksLikeEmail(q) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
}
/** Permissions that belong to each role (must mirror the Rust DTO). */
const ROLE_PERMISSIONS = {
viewer: ['read'],
editor: ['read', 'comment', 'create', 'update'],
admin: ['read', 'comment', 'create', 'update', 'share', 'delete']
};
/**
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
* endpoint; returns `[]` on any failure so the autocomplete degrades to
@@ -107,14 +100,20 @@ async function _searchGroups(q) {
}
/**
* Derive the highest role a set of grants represents for one subject.
* Pick the displayed role for a member row. Server-side every Grant
* carries an explicit role since the cleanup PR, so this just reads it.
* The server may emit `commenter` or `contributor` (full enum), but the
* picker only exposes Viewer/Editor/Owner — collapse the two unexposed
* roles to the closest neighbour so the UI never renders an unknown
* option.
* @param {Grant[]} subjectGrants
* @returns {ShareRoleEnum}
*/
function _roleFromGrants(subjectGrants) {
const perms = new Set(subjectGrants.map((g) => g.permission));
if (perms.has('delete') || perms.has('share')) return 'admin';
if (perms.has('create') || perms.has('update')) return 'editor';
const role = subjectGrants[0]?.role;
if (role === 'owner' || role === 'editor' || role === 'viewer') return role;
if (role === 'commenter') return 'viewer';
if (role === 'contributor') return 'editor';
return 'viewer';
}
@@ -363,7 +362,7 @@ const shareModal = {
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
@@ -606,7 +605,7 @@ const shareModal = {
granted_at: '',
granted_by: '',
subject: { type: subjectType, id: contact.id },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
role: this._stagedRole,
resource: { type: this._itemType, id: this._item?.id ?? '' }
};
this._localMembers.push({
@@ -649,7 +648,7 @@ const shareModal = {
// matching the UX contract and the kebab-menu / role-select dropdown
// order. Renaming the labels from "Manager"/"Editor"/"Viewer" to
// "Can manage"/"Can edit"/"Can view" left this iteration order stale.
const groups = /** @type {ShareRoleEnum[]} */ (['admin', 'editor', 'viewer']);
const groups = /** @type {ShareRoleEnum[]} */ (['owner', 'editor', 'viewer']);
let memberIndex = 0;
for (const role of groups) {
@@ -663,7 +662,7 @@ const shareModal = {
header.className = 'smd-group-header';
const labelMap = {
admin: i18n.t('share.role.canManage', 'Can manage'),
owner: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
@@ -711,7 +710,7 @@ const shareModal = {
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['admin', i18n.t('share.role.canManage', 'Can manage')]
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
+18 -1
View File
@@ -524,7 +524,24 @@ const OxiIcons = {
'M384 96c0-35.3 28.7-64 64-64s64 28.7 64 64l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32c0-70.7-57.3-128-128-128S320 25.3 320 96l0 64-160 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64l-32 0 0-64z'
],
adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z']
adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z'],
'clock-rotate-left': [
576,
'M288 64c106 0 192 86 192 192S394 448 288 448c-65.2 0-122.9-32.5-157.6-82.3-10.1-14.5-30.1-18-44.6-7.9s-18 30.1-7.9 44.6C124.1 468.6 201 512 288 512 429.4 512 544 397.4 544 256S429.4 0 288 0C202.3 0 126.5 42.1 80 106.7L80 80c0-17.7-14.3-32-32-32S16 62.3 16 80l0 112c0 17.7 14.3 32 32 32l24.6 0c.5 0 1 0 1.5 0l86 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-38.3 0C154.9 102.6 217 64 288 64zm24 88c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65 0-94.1z'
],
'puzzle-piece': [
512,
'M224 0c35.3 0 64 21.5 64 48 0 10.4-4.4 20-12 27.9-6.6 6.9-12 15.3-12 24.9 0 15 12.2 27.2 27.2 27.2l44.8 0c26.5 0 48 21.5 48 48l0 44.8c0 15 12.2 27.2 27.2 27.2 9.5 0 18-5.4 24.9-12 7.9-7.5 17.5-12 27.9-12 26.5 0 48 28.7 48 64s-21.5 64-48 64c-10.4 0-20.1-4.4-27.9-12-6.9-6.6-15.3-12-24.9-12-15 0-27.2 12.2-27.2 27.2L384 464c0 26.5-21.5 48-48 48l-56.8 0c-12.8 0-23.2-10.4-23.2-23.2 0-9.2 5.8-17.3 13.2-22.8 11.6-8.7 18.8-20.7 18.8-34 0-26.5-28.7-48-64-48s-64 21.5-64 48c0 13.3 7.2 25.3 18.8 34 7.4 5.5 13.2 13.5 13.2 22.8 0 12.8-10.4 23.2-23.2 23.2L48 512c-26.5 0-48-21.5-48-48L0 343.2c0-12.8 10.4-23.2 23.2-23.2 9.2 0 17.3 5.8 22.8 13.2 8.7 11.6 20.7 18.8 34 18.8 26.5 0 48-28.7 48-64s-21.5-64-48-64c-13.3 0-25.3 7.2-34 18.8-5.5 7.4-13.5 13.2-22.8 13.2-12.8 0-23.2-10.4-23.2-23.2L0 176c0-26.5 21.5-48 48-48l108.8 0c15 0 27.2-12.2 27.2-27.2 0-9.5-5.4-18-12-24.9-7.5-7.9-12-17.5-12-27.9 0-26.5 28.7-48 64-48z'
],
sync: [
512,
'M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z'
],
upload: [
448,
'M256 109.3L256 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-210.7-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l96-96c12.5-12.5 32.8-12.5 45.3 0l96 96c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L256 109.3zM224 400c44.2 0 80-35.8 80-80l80 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64l80 0c0 44.2 35.8 80 80 80zm144 24a24 24 0 1 0 0-48 24 24 0 1 0 0 48z'
]
};
// Icon CSS is now in /css/components/icons.css (loaded via main.css).
+24 -9
View File
@@ -45,6 +45,8 @@
* @property {number} size
* @property {string} size_formatted
* @property {number} sort_date
* @property {number} [width] original pixel width (photos timeline only)
* @property {number} [height] original pixel height (photos timeline only)
* @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match
* @property {string} content_hash raw BLAKE3 content hash, for dedup checks
* @property {string} [snippet] plain-text fragment around a content match (search results only)
@@ -302,21 +304,27 @@
* @property {String} id
*/
/**
* Server-side role enum — every grantable role the backend recognises.
* The share modal's UI picker only exposes a subset (see `ShareRoleEnum`);
* the wire format may carry any of these values on a Grant.
* @typedef {'viewer'|'commenter'|'contributor'|'editor'|'owner'} GrantRoleEnum
*/
/**
* @typedef {Object} Grant
* @property {string} id
* @property {string} granted_at - ISO-8601 datetime string.
* @property {string} granted_by
* @property {Subject} subject
* @property {PermissionTypeEnum} permission
* @property {GrantRoleEnum} role - Role-keyed grant. One Grant = one role
* assignment in `storage.role_grants`. The implied permission bundle
* is derived client-side from the same lookup table used by
* `Role::expand()` on the server (see `ROLE_PERMISSIONS` in shareModal).
* @property {Resource} resource
* @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
*/
/**
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
*/
/**
* Configuration for `ResourceListComponent`.
* @typedef {Object} ResourceListConfig
@@ -367,7 +375,7 @@
* @property {'user'|'group'|'token'|'external'} subject_type
* @property {string} subject_id
* @property {string} subject_display - Username (users) or share name (tokens).
* @property {'viewer'|'editor'|'admin'} role
* @property {GrantRoleEnum} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`).
* @property {string} granted_at - ISO-8601
* @property {string|null} [expires_at] - ISO-8601 or absent.
* @property {boolean} has_password - True when a token subject has a password set.
@@ -453,15 +461,22 @@
// ------------------- share modal
/**
* Share roles (DTO-layer sugar for the ReBAC permission sets).
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
* Share-modal-exposed roles. The server's `Role` enum also includes
* `commenter` and `contributor` (see `OutgoingResourceGrant.role`); those
* are reserved for future UI exposure and are not offered as picker options
* today. The "Can manage" UI label maps to `owner`.
* @typedef {'viewer'|'editor'|'owner'} ShareRoleEnum
*/
/**
* One collaborator row in the share modal's People section.
* @typedef {Object} MemberEntry
* @property {Grant} grant - Representative grant (used for subject/resource info).
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
* @property {Grant[]} _grants - All grants for this subject on the resource. Post-pivot
* this is at most one entry (`storage.role_grants` UNIQUE on
* `(subject, resource)`); the array shape is preserved so the existing
* "revoke every grant on remove" loop in `_applyAll` still works
* without a special-case for empty / new entries.
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
+177
View File
@@ -0,0 +1,177 @@
/**
* OxiCloud - People (faces)
*
* A grid of identity clusters from GET /api/people; clicking a person shows
* their photos (reusing the photos lightbox). Faces are detected + clustered
* server-side; this view is read-mostly (list, drill-in, rename).
*
* The feature is gated on OXICLOUD_ENABLE_FACES — when it is off the API 404s
* and the view shows a short "disabled" hint (and the Places/People sub-nav
* hides the People tab via a capability probe).
*/
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { photosLightbox } from './photosLightbox.js';
/** @import {FileItem} from '../../core/types.js' */
/** @typedef {{id: string, name?: string, cover_file_id?: string, face_count: number, is_hidden: boolean}} PersonItem */
export const peopleView = {
/** @type {HTMLElement|null} */
_container: null,
_headers() {
return getCsrfHeaders();
},
/** Ensure the container exists (sibling in .content-area). */
_mount() {
const ca = document.querySelector('.content-area');
if (!ca) return;
if (!this._container) {
const el = document.createElement('div');
el.id = 'people-container';
el.className = 'people-container';
ca.appendChild(el);
this._container = el;
}
},
async show() {
this._mount();
if (!this._container) return;
this._container.classList.add('active');
await this._renderList();
},
hide() {
this._container?.classList.remove('active');
},
async _renderList() {
if (!this._container) return;
this._container.innerHTML = '<div class="people-loading"><i class="fas fa-spinner"></i></div>';
try {
const res = await fetch('/api/people', { credentials: 'include', headers: this._headers() });
if (!res.ok) {
this._renderHint(i18n.t('people.disabled'));
return;
}
/** @type {PersonItem[]} */
const people = await res.json();
if (!people.length) {
this._renderHint(i18n.t('people.empty'));
return;
}
let html = '<div class="people-grid">';
for (const p of people) {
const cover = p.cover_file_id ? `/api/files/${p.cover_file_id}/thumbnail/icon` : '';
const name = p.name || i18n.t('people.unnamed');
html += `<button class="person-tile" type="button" data-id="${this._escAttr(p.id)}" data-name="${this._escAttr(name)}">`;
html += `<span class="person-avatar" style="background-image:url(${cover})"></span>`;
html += `<span class="person-name">${this._escHtml(name)}</span>`;
html += `<span class="person-count">${p.face_count}</span>`;
html += '</button>';
}
html += '</div>';
this._container.innerHTML = html;
this._container.querySelectorAll('.person-tile').forEach((t) => {
const el = /** @type {HTMLElement} */ (t);
el.addEventListener('click', () => this._openPerson(el.dataset.id || '', el.dataset.name || ''));
});
} catch (err) {
console.error('People load failed:', err);
this._renderHint(i18n.t('people.disabled'));
}
},
/**
* @param {string} personId
* @param {string} name
*/
async _openPerson(personId, name) {
if (!this._container) return;
this._container.innerHTML =
'<div class="people-toolbar">' +
`<button class="people-back" type="button" title="${this._escAttr(i18n.t('people.back'))}"><i class="fas fa-arrow-left"></i></button>` +
`<h2 class="people-title">${this._escHtml(name)}</h2>` +
`<button class="people-rename" type="button" title="${this._escAttr(i18n.t('people.rename_title'))}"><i class="fas fa-pen"></i></button>` +
'</div>' +
'<div class="photos-grid" id="person-photos"></div>';
/** @type {HTMLButtonElement} */ (this._container.querySelector('.people-back')).onclick = () => this._renderList();
/** @type {HTMLButtonElement} */ (this._container.querySelector('.people-rename')).onclick = () => this._rename(personId, name);
try {
const res = await fetch(`/api/people/${personId}/photos`, { credentials: 'include', headers: this._headers() });
if (!res.ok) return;
/** @type {string[]} */
const fileIds = await res.json();
// Minimal FileItems so the lightbox can open them by id.
const items = fileIds.map(
(id) =>
/** @type {FileItem} */ (/** @type {any} */ ({ id, name: '', mime_type: 'image/jpeg', created_at: 0, sort_date: 0, size_formatted: '' }))
);
const grid = this._container.querySelector('#person-photos');
if (!grid) return;
let html = '';
fileIds.forEach((id, i) => {
html += `<div class="photo-tile" data-idx="${i}"><img src="/api/files/${this._escAttr(id)}/thumbnail/preview" loading="lazy" decoding="async" alt=""></div>`;
});
grid.innerHTML = html;
grid.querySelectorAll('.photo-tile').forEach((t) => {
const el = /** @type {HTMLElement} */ (t);
el.addEventListener('click', () => photosLightbox.open(items, Number(el.dataset.idx)));
});
} catch (err) {
console.error('Person photos failed:', err);
}
},
/**
* @param {string} personId
* @param {string} current
*/
async _rename(personId, current) {
const placeholder = i18n.t('people.unnamed');
const value = current === placeholder ? '' : current;
const name = await Modal.prompt({
title: i18n.t('people.rename_title'),
label: i18n.t('people.name_label'),
value
});
if (name === null) return;
try {
await fetch(`/api/people/${personId}`, {
method: 'PATCH',
credentials: 'include',
headers: { ...this._headers(), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || null })
});
} catch (err) {
console.error('Rename failed:', err);
}
this._openPerson(personId, name || placeholder);
},
/** @param {string} text */
_renderHint(text) {
if (!this._container) return;
this._container.innerHTML = `<div class="people-empty"><i class="fas fa-user-group"></i><p>${this._escHtml(text)}</p></div>`;
},
/** @param {any} s */
_escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
};
+444 -96
View File
@@ -3,6 +3,7 @@
* Photo grid grouped by day/month/year, with infinite scroll and multi-select.
*/
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { thumbnail } from '../thumbnail.js';
@@ -14,6 +15,14 @@ import { photosLightbox } from './photosLightbox.js';
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
/**
* @typedef {Object} PhotoGroup
* @property {string} label
* @property {FileItem[]} files
* @property {HTMLElement} section
* @property {boolean} materialized
*/
const photosView = {
/** @type {Array<FileItem>} All loaded photo items */
items: [],
@@ -25,18 +34,32 @@ const photosView = {
exhausted: false,
/** @type {Set<string>} Selected item IDs */
selected: new Set(),
/** @type {IntersectionObserver|null} */
_observer: null,
/** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */
_materializeObserver: null,
/** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */
_sentinelObserver: null,
/** @type {HTMLElement|null} */
_container: null,
/** @type {HTMLElement|null} The infinite-scroll sentinel element */
_sentinelEl: null,
/** @type {boolean} */
_initialized: false,
/** @type {PhotoModeEnum} */
groupMode: 'monthly',
/** @type {'square'|'justified'} */
layoutMode: 'square',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
/** @type {number} Items already rendered in the DOM */
_renderedCount: 0,
/** @type {Map<string, PhotoGroup>} group label → group record (DOM + data) */
_groupData: new Map(),
/** @type {string[]} Ordered group labels (timeline order) */
_groupOrder: [],
/** @type {(() => void)|null} Debounced window resize handler */
_resizeHandler: null,
/** @type {number} */
_resizeTimer: 0,
/** @type {string|null} Anchor id for shift-range selection */
_selectAnchorId: null,
PAGE_SIZE: 200,
@@ -60,6 +83,7 @@ const photosView = {
}
if (!this._initialized) {
this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly';
this.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square';
this._initialized = true;
}
},
@@ -73,7 +97,9 @@ const photosView = {
this.nextCursor = null;
this.exhausted = false;
this.selected.clear();
this._renderedCount = 0;
this._groupData = new Map();
this._groupOrder = [];
this._destroyObserver();
this._container.innerHTML = '';
this._loadPage();
},
@@ -84,6 +110,7 @@ const photosView = {
this._container.classList.remove('active');
}
this._destroyObserver();
this._unbindResize();
this._hideSelectionBar();
},
@@ -97,7 +124,17 @@ const photosView = {
if (this.groupMode === mode) return;
this.groupMode = mode;
localStorage.setItem('oxicloud-photos-group', mode);
this._renderedCount = 0;
this._renderFull();
},
/**
* Switch tile layout (square crop vs justified aspect-preserving rows).
* @param {'square'|'justified'} mode
*/
setLayoutMode(mode) {
if (this.layoutMode === mode) return;
this.layoutMode = mode;
localStorage.setItem('oxicloud-photos-layout', mode);
this._renderFull();
},
@@ -149,18 +186,29 @@ const photosView = {
}
},
// ── Rendering ───────────────────────────────────────────────────
// Two render paths:
// _renderFull() — full DOM rebuild (first load, group-mode change, delete)
// _appendBatch(n) — append-only for infinite-scroll pages (O(batch))
// ── Virtualized rendering ───────────────────────────────────────
// The timeline can hold tens of thousands of items, so we never keep
// every tile in the DOM. Each date-group is a <section> with a header
// (always present, cheap) and a grid that is *materialized* (tiles in
// the DOM) only while near the viewport, and *dematerialized* (emptied,
// its height frozen as a spacer) once it scrolls far away. An
// IntersectionObserver rooted on the scroll container drives the swap,
// so the DOM node count stays bounded by a few screens regardless of
// library size.
// _renderFull() — rebuild the group skeleton (first load, mode switch, delete)
// _appendBatch(n) — append new groups for infinite-scroll pages
/** Full DOM rebuild — first load, group-mode switch, or after deletions. */
/** Rebuild the group skeleton — first load, group-mode switch, or deletions. */
_renderFull() {
if (!this._container) return;
this._destroyObserver();
this._groupData = new Map();
this._groupOrder = [];
this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly');
this._container.classList.add(`photos-group-${this.groupMode}`);
this._container.classList.remove('photos-layout-square', 'photos-layout-justified');
this._container.classList.add(`photos-layout-${this.layoutMode}`);
if (this.items.length === 0 && this.exhausted) {
this._renderEmpty();
@@ -168,89 +216,257 @@ const photosView = {
}
if (this.items.length === 0) return;
const groups = this._groupItems(this.items);
let html = this._renderToolbar();
// Toolbar via innerHTML, then append group <section>s + sentinel as
// real elements so we keep references for the observer.
this._container.innerHTML = this._renderToolbar();
this._container.onclick = (e) => this._handleClick(e);
this._container.onkeydown = (e) => this._handleKeydown(e);
const groups = this._groupItems(this.items);
for (const [label, files] of groups) {
html += `<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
html += '<div class="photos-grid">';
for (const file of files) html += this._renderTile(file);
html += '</div>';
/** @type {PhotoGroup} */
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
this._groupData.set(label, rec);
this._groupOrder.push(label);
this._container.appendChild(rec.section);
}
html += '<div class="photos-sentinel"></div>';
this._container.innerHTML = html;
this._container.onclick = (e) => this._handleClick(e);
this._fadeInTiles();
this._renderedCount = this.items.length;
this._observeSentinel();
this._setupVideoThumbnails();
const sentinel = document.createElement('div');
sentinel.className = 'photos-sentinel';
this._container.appendChild(sentinel);
this._sentinelEl = sentinel;
this._setupObservers();
this._eagerMaterialize();
this._bindResize();
},
/** Append-only render for infinite scroll — inserts only the items
* from this.items[startIndex..] without destroying existing DOM.
* Complexity: O(batch) instead of O(total_items).
/** Append new groups for an infinite-scroll page without rebuilding the
* existing skeleton. The first new group may continue the previous tail
* label, in which case we merge into it. Complexity: O(new groups).
* @param {number} startIndex
*/
_appendBatch(startIndex) {
if (!this._container) return;
this._destroyObserver();
const newItems = this.items.slice(startIndex);
if (newItems.length === 0) {
this._observeSentinel();
return;
}
const newGroups = this._groupItems(newItems);
const sentinel = this._container.querySelector('.photos-sentinel');
if (!sentinel) {
// Fallback: sentinel missing — full rebuild
this._renderedCount = 0;
if (!this._container || !this._sentinelEl) {
this._renderFull();
return;
}
const newItems = this.items.slice(startIndex);
if (newItems.length === 0) return;
const newGroups = this._groupItems(newItems);
for (const [label, files] of newGroups) {
let tilesHtml = '';
for (const file of files) tilesHtml += this._renderTile(file);
// Does this date-group already exist in the DOM?
const existingHeader = this._container.querySelector(`.photos-day-header[data-group="${CSS.escape(label)}"]`);
if (existingHeader) {
// Append tiles to existing grid and update count badge
const grid = existingHeader.nextElementSibling;
if (grid?.classList.contains('photos-grid')) {
grid.insertAdjacentHTML('beforeend', tilesHtml);
const countSpan = existingHeader.querySelector('.photos-day-count');
if (countSpan) countSpan.textContent = String(grid.children.length);
const existing = this._groupData.get(label);
if (existing) {
// Continuation of a group already in the timeline.
existing.files = existing.files.concat(files);
const countEl = existing.section.querySelector('.photos-day-count');
if (countEl) countEl.textContent = String(existing.files.length);
const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid'));
if (grid) {
if (existing.materialized) {
if (this.layoutMode === 'justified') {
// Justified rows must repack against the whole group.
grid.innerHTML = this._renderGroupTiles(existing.files);
} else {
let tilesHtml = '';
for (const file of files) tilesHtml += this._renderTile(file);
grid.insertAdjacentHTML('beforeend', tilesHtml);
}
this._setupVideoThumbnails(grid);
this._fadeInTiles(grid);
} else {
grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`;
}
}
} else {
// New group — insert header + grid before sentinel
const sectionHtml =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid">${tilesHtml}</div>`;
sentinel.insertAdjacentHTML('beforebegin', sectionHtml);
/** @type {PhotoGroup} */
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
this._groupData.set(label, rec);
this._groupOrder.push(label);
this._container.insertBefore(rec.section, this._sentinelEl);
this._materializeObserver?.observe(rec.section);
}
}
},
this._renderedCount = this.items.length;
this._observeSentinel();
this._setupVideoThumbnails(startIndex);
this._fadeInTiles();
/** Build a dematerialized group section (header + empty grid spacer).
* @param {string} label
* @param {FileItem[]} files
* @returns {HTMLElement}
*/
_buildGroupEl(label, files) {
const section = document.createElement('section');
section.className = 'photos-group';
section.dataset.group = label;
section.innerHTML =
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
`<div class="photos-grid" style="min-height:${this._estimateHeight(files.length)}px"></div>`;
return section;
},
/** Wire the two IntersectionObservers (materialization + infinite scroll). */
_setupObservers() {
const root = this._container?.parentElement || null;
if (!('IntersectionObserver' in window)) {
// Degrade gracefully: render every group (legacy behaviour).
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (rec) this._materializeGroup(rec.section);
}
return;
}
this._materializeObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const section = /** @type {HTMLElement} */ (entry.target);
if (entry.isIntersecting) this._materializeGroup(section);
else this._dematerializeGroup(section);
}
},
{ root, rootMargin: '1200px 0px' }
);
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (rec) this._materializeObserver.observe(rec.section);
}
if (this._sentinelEl) {
this._sentinelObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) this._loadPage();
},
{ root, rootMargin: '600px 0px' }
);
this._sentinelObserver.observe(this._sentinelEl);
}
},
/** Synchronously materialize the first groups within ~1.5 viewports so
* the initial paint has tiles before the observer's first callback. */
_eagerMaterialize() {
const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5;
let acc = 0;
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (!rec) continue;
this._materializeGroup(rec.section);
acc += rec.section.offsetHeight;
if (acc > budget) break;
}
},
/** Fill a group's grid with tiles (idempotent).
* @param {HTMLElement} section
*/
_materializeGroup(section) {
const rec = this._groupData.get(section.dataset.group || '');
if (!rec || rec.materialized) return;
rec.materialized = true;
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
if (!grid) return;
grid.innerHTML = this._renderGroupTiles(rec.files);
grid.style.minHeight = '';
this._setupVideoThumbnails(grid);
this._fadeInTiles(grid);
},
/** Empty a group's grid, freezing its current height as a spacer.
* @param {HTMLElement} section
*/
_dematerializeGroup(section) {
const rec = this._groupData.get(section.dataset.group || '');
if (!rec?.materialized) return;
rec.materialized = false;
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
if (!grid) return;
grid.style.minHeight = `${grid.offsetHeight}px`;
grid.innerHTML = '';
},
/** Current grid geometry (columns / gap / square tile px) for the active
* mode, used to estimate off-screen group heights.
* @returns {{cols: number, gap: number, tile: number}}
*/
_gridMetrics() {
const width = this._gridWidth();
const mobile = window.matchMedia('(max-width: 768px)').matches;
let min;
let gap;
if (this.groupMode === 'yearly') {
min = mobile ? 80 : 120;
gap = mobile ? 4 : 10;
} else if (this.groupMode === 'monthly') {
min = mobile ? 110 : 180;
gap = mobile ? 2 : 14;
} else {
min = mobile ? 100 : 150;
gap = mobile ? 2 : 12;
}
const cols = Math.max(1, Math.floor((width + gap) / (min + gap)));
const tile = (width - (cols - 1) * gap) / cols;
return { cols, gap, tile };
},
/** Estimated pixel height of a grid holding `count` square tiles.
* @param {number} count
* @returns {number}
*/
_estimateHeight(count) {
if (this.layoutMode === 'justified') {
const width = this._gridWidth();
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
const perRow = Math.max(1, Math.round(width / (target * 1.4)));
const rows = Math.max(1, Math.ceil(count / perRow));
return Math.round(rows * target + (rows - 1) * 8);
}
const { cols, gap, tile } = this._gridMetrics();
const rows = Math.max(1, Math.ceil(count / cols));
return Math.round(rows * tile + (rows - 1) * gap);
},
/** Re-estimate spacer heights for dematerialized groups after a resize. */
_bindResize() {
if (this._resizeHandler) return;
this._resizeHandler = () => {
clearTimeout(this._resizeTimer);
this._resizeTimer = window.setTimeout(() => this._onResize(), 150);
};
window.addEventListener('resize', this._resizeHandler);
},
_onResize() {
if (!this._container?.classList.contains('active')) return;
for (const label of this._groupOrder) {
const rec = this._groupData.get(label);
if (!rec || rec.materialized) continue;
const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid'));
if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`;
}
},
_unbindResize() {
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
clearTimeout(this._resizeTimer);
},
/**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
* @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows.
*/
_renderTile(file) {
_renderTile(file, sizeStyle) {
const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : '';
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}"${styleAttr}>`;
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
const srcset = cachedThumb
? ''
@@ -261,12 +477,85 @@ const photosView = {
return h;
},
/**
* Inner HTML for a group's grid in the current layout mode.
* @param {FileItem[]} files
* @returns {string}
*/
_renderGroupTiles(files) {
if (this.layoutMode !== 'justified') {
let html = '';
for (const file of files) html += this._renderTile(file);
return html;
}
const rows = this._justifiedRows(files, this._gridWidth());
let html = '';
for (const row of rows) {
html += `<div class="photos-jrow" style="height:${row.height}px">`;
for (const t of row.tiles) {
html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`);
}
html += '</div>';
}
return html;
},
/**
* Pack files into justified rows (Flickr-style): each full row is scaled so
* it fills the container width while preserving every tile's aspect ratio.
* Missing dimensions fall back to a 1:1 aspect.
* @param {FileItem[]} files
* @param {number} width Available content width in px.
* @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>}
*/
_justifiedRows(files, width) {
const gap = 8;
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
/** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */
const rows = [];
/** @type {Array<{file: FileItem, aspect: number}>} */
let cur = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) }))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target }))
});
}
return rows;
},
/** Current grid content width in px (for layout / height estimates). */
_gridWidth() {
const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid'));
return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16;
},
/**
* Fade tiles in as their thumbnails finish loading (kills the pop-in).
* Idempotent — only wires images not already marked loaded.
* @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container.
*/
_fadeInTiles() {
this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
_fadeInTiles(scope) {
const root = scope || this._container;
root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
const img = /** @type {HTMLImageElement} */ (el);
if (img.complete) {
img.classList.add('is-loaded');
@@ -278,40 +567,25 @@ const photosView = {
});
},
/** (Re-)observe the sentinel element for infinite scroll */
_observeSentinel() {
this._destroyObserver();
const sentinel = this._container?.querySelector('.photos-sentinel');
if (sentinel && !this.exhausted) {
this._observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) this._loadPage();
},
{ rootMargin: '400px' }
);
this._observer.observe(sentinel);
}
},
// ── Client-side video thumbnail generation ──────────────────────
// Uses the browser's native video decoder (<video> + <canvas>) to
// extract a frame, show it immediately, and upload to the server
// for permanent caching. Zero server-side dependencies (no ffmpeg).
/** Attach error handlers to video tile images; on failure, extract a
* frame from the video using the browser's built-in codec. */
/** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
/** Attach error handlers to video tile images within a freshly
* materialized grid; on failure, extract a frame from the video using
* the browser's built-in codec.
* @param {ParentNode} [scope] Subtree to scan; defaults to the whole container.
*/
_setupVideoThumbnails(scope) {
const root = scope || this._container;
const tiles = /** @type {NodeListOf<HTMLDivElement>|undefined} */ (root?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
if (!tiles) return;
for (const tile of tiles) {
const fileId = tile.dataset.id;
if (!fileId) continue;
if (newIds && !newIds.has(fileId)) continue;
if (this._videoThumbCache.has(fileId)) continue;
const img = tile.querySelector('img');
@@ -356,7 +630,16 @@ const photosView = {
['monthly', i18n.t('photos.view_monthly')],
['yearly', i18n.t('photos.view_yearly')]
];
let html = '<div class="photos-toolbar"><div class="view-toggle">';
let html = '<div class="photos-toolbar">';
// Layout toggle (square crop ↔ justified rows)
html += '<div class="view-toggle photos-layout-toggle">';
html += `<button class="toggle-btn${this.layoutMode === 'square' ? ' active' : ''}" data-layout-mode="square" title="${this._escAttr(i18n.t('photos.layout_square'))}" aria-label="${this._escAttr(i18n.t('photos.layout_square'))}"><i class="fas fa-table-cells"></i></button>`;
html += `<button class="toggle-btn${this.layoutMode === 'justified' ? ' active' : ''}" data-layout-mode="justified" title="${this._escAttr(i18n.t('photos.layout_justified'))}" aria-label="${this._escAttr(i18n.t('photos.layout_justified'))}"><i class="fas fa-grip"></i></button>`;
html += '</div>';
// Grouping toggle (day / month / year)
html += '<div class="view-toggle">';
for (const [mode, label] of modes) {
const active = this.groupMode === mode ? ' active' : '';
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
@@ -420,15 +703,28 @@ const photosView = {
return;
}
const layoutBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-layout-mode]'));
if (layoutBtn) {
this.setLayoutMode(/** @type {'square'|'justified'} */ (layoutBtn.dataset.layoutMode));
return;
}
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
const id = tile.dataset.id;
const check = target.closest('.photo-check');
// Shift-click extends the selection from the last anchor.
if (id && e.shiftKey && this._selectAnchorId) {
this._selectRange(this._selectAnchorId, id);
return;
}
// If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) {
this._toggleSelect(id, tile);
this._selectAnchorId = id || null;
return;
}
@@ -439,6 +735,49 @@ const photosView = {
}
},
/**
* Select every item between the anchor and the target (inclusive), in
* timeline order. Tracked in the Set so it survives dematerialized
* groups; currently-visible tiles get the class applied immediately.
* @param {string} anchorId
* @param {string} toId
*/
_selectRange(anchorId, toId) {
const a = this.items.findIndex((f) => f.id === anchorId);
const b = this.items.findIndex((f) => f.id === toId);
if (a < 0 || b < 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) this.selected.add(this.items[i].id);
this._container?.querySelectorAll('.photo-tile').forEach((el) => {
const t = /** @type {HTMLElement} */ (el);
if (t.dataset.id && this.selected.has(t.dataset.id)) t.classList.add('selected');
});
this._selectAnchorId = toId;
this._updateSelectionBar();
},
/**
* Keyboard activation for focused tiles: Enter opens the lightbox (or
* toggles selection when in selection mode); Space toggles selection.
* @param {KeyboardEvent} e
*/
_handleKeydown(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = /** @type {Element} */ (e.target);
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
e.preventDefault();
const id = tile.dataset.id;
if (e.key === ' ' || this.selected.size > 0) {
this._toggleSelect(id, tile);
this._selectAnchorId = id || null;
return;
}
const idx = this.items.findIndex((f) => f.id === id);
if (idx >= 0) photosLightbox.open(this.items, idx);
},
/**
* Toggle selection of an item
* @param {string} id
@@ -493,7 +832,13 @@ const photosView = {
const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete'));
if (bar_delete) {
bar_delete.onclick = async () => {
if (!confirm('Delete selected items?')) return;
const ok = await Modal.confirmDialog({
title: i18n.t('photos.delete_title'),
message: i18n.t('photos.delete_selected_confirm'),
confirmText: i18n.t('actions.delete'),
icon: 'fa-trash'
});
if (!ok) return;
// One batch request per chunk instead of one DELETE per photo.
// The photos view is files-only, so every id is a file id.
@@ -526,7 +871,6 @@ const photosView = {
if (trashed.size > 0) {
this.items = this.items.filter((f) => !trashed.has(f.id));
for (const id of trashed) this.selected.delete(id);
this._renderedCount = 0;
this._renderFull();
}
// Refresh (or hide) the bar to reflect any items left selected.
@@ -571,9 +915,13 @@ const photosView = {
},
_destroyObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
if (this._materializeObserver) {
this._materializeObserver.disconnect();
this._materializeObserver = null;
}
if (this._sentinelObserver) {
this._sentinelObserver.disconnect();
this._sentinelObserver = null;
}
},
+206 -15
View File
@@ -10,7 +10,9 @@
* original streams in only on demand via the toolbar expand button.
*/
import { Modal } from '../../components/modal.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
@@ -35,6 +37,23 @@ export const photosLightbox = {
*/
_showGeneration: 0,
/** @type {number} Current zoom factor (1 = fit) */
_zoom: 1,
/** @type {number} */
_panX: 0,
/** @type {number} */
_panY: 0,
/** @type {Map<number, {x: number, y: number}>} Active pointers (for pinch) */
_pointers: new Map(),
/** @type {number} */
_pinchStartDist: 0,
/** @type {number} */
_pinchStartZoom: 1,
/** @type {{x: number, y: number, panX: number, panY: number}|null} */
_dragStart: null,
/** @type {{x: number, y: number, t: number}|null} */
_swipeStart: null,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
* @param {any} pv
@@ -91,6 +110,7 @@ export const photosLightbox = {
}
}, 200);
}
this._resetZoom();
this._unbindKeys();
},
@@ -127,11 +147,13 @@ export const photosLightbox = {
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
<div class="lightbox-toolbar">
<button class="lb-fullres hidden" title="Full resolution"><i class="fas fa-expand"></i></button>
<button class="lb-info" title="Info"><i class="fas fa-circle-info"></i></button>
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
</div>
<div class="lightbox-counter"></div>
<div class="lightbox-infopanel hidden"></div>
`;
document.body.appendChild(el);
this._overlay = el;
@@ -149,6 +171,7 @@ export const photosLightbox = {
});
// Toolbar actions (`.lb-fullres` is wired per-item in `_show`)
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-info')).onclick = () => this._toggleInfoPanel();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
@@ -172,6 +195,7 @@ export const photosLightbox = {
_show() {
if (!this._overlay || this.index < 0) return;
const generation = ++this._showGeneration;
this._resetZoom();
const item = this.items[this.index];
const content = this._overlay.querySelector('.lightbox-content');
@@ -182,6 +206,15 @@ export const photosLightbox = {
filename.textContent = item.name;
counter.textContent = `${this.index + 1} / ${this.items.length}`;
// Reflect the current favorite state on the toolbar star.
const favBtn = this._overlay.querySelector('.lb-favorite');
if (favBtn) {
const isFav = favorites.isFavorite(item.id, 'file');
favBtn.classList.toggle('active', isFav);
const favIcon = favBtn.querySelector('i');
if (favIcon) favIcon.className = isFav ? 'fas fa-star' : 'far fa-star';
}
// Format date
const ts = (item.sort_date || item.created_at) * 1000;
const dateStr = new Date(ts).toLocaleDateString(undefined, {
@@ -235,6 +268,7 @@ export const photosLightbox = {
let showingOriginal = isGif;
const img = document.createElement('img');
img.alt = item.name;
this._wireZoomPan(img);
img.addEventListener('load', () => {
if (generation !== this._showGeneration) return;
@@ -297,8 +331,7 @@ export const photosLightbox = {
parts.push(`${metadata.width}×${metadata.height}`);
}
metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
this._fillInfoPanel(metadata, dateStr, sizeStr);
}
} catch (_err) {
// Non-critical, keep existing meta
@@ -317,23 +350,25 @@ export const photosLightbox = {
a.remove();
},
/** Toggle favorite on current item */
/** Toggle favorite on current item (via the favorites module so its
* cache stays in sync — the lightbox can then show the right initial
* star next time the item is opened). */
async _toggleFavorite() {
const item = this.items[this.index];
if (!item || !favorites) return;
if (!item) return;
const isFav = favorites.isFavorite(item.id, 'file');
try {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers()
});
const btn = this._overlay.querySelector('.lb-favorite');
if (isFav) {
await favorites.removeFromFavorites(item.id, 'file', item.name);
} else {
await favorites.addToFavorites(item.id, item.name, 'file', null);
}
const btn = this._overlay?.querySelector('.lb-favorite');
if (btn) {
btn.classList.toggle('active');
const nowFav = !isFav;
btn.classList.toggle('active', nowFav);
const icon = btn.querySelector('i');
if (icon) {
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
}
if (icon) icon.className = nowFav ? 'fas fa-star' : 'far fa-star';
}
} catch (err) {
console.error('Favorite toggle failed:', err);
@@ -344,7 +379,13 @@ export const photosLightbox = {
async _delete() {
const item = this.items[this.index];
if (!item) return;
if (!confirm(`Delete ${item.name}?`)) return;
const ok = await Modal.confirmDialog({
title: i18n.t('photos.delete_title'),
message: i18n.t('photos.delete_one_confirm', { name: item.name }),
confirmText: i18n.t('actions.delete'),
icon: 'fa-trash'
});
if (!ok) return;
try {
await fetch(`/api/files/${item.id}`, {
@@ -370,6 +411,156 @@ export const photosLightbox = {
}
},
// ── Zoom / pan / swipe ──────────────────────────────────────────
/** @returns {HTMLImageElement|null} The image element currently shown. */
_currentImg() {
return /** @type {HTMLImageElement|null} */ (this._overlay?.querySelector('.lightbox-content img') || null);
},
/** Reset zoom/pan state (per item and on close). */
_resetZoom() {
this._zoom = 1;
this._panX = 0;
this._panY = 0;
this._pointers.clear();
this._pinchStartDist = 0;
this._dragStart = null;
this._swipeStart = null;
},
_applyTransform() {
const img = this._currentImg();
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
},
/**
* Set the zoom factor (clamped 1–5), centered. Resets pan at 1.
* @param {number} z
*/
_setZoom(z) {
z = Math.min(Math.max(z, 1), 5);
if (z === 1) {
this._panX = 0;
this._panY = 0;
}
this._zoom = z;
this._applyTransform();
const img = this._currentImg();
if (img) img.classList.toggle('is-zoomed', z > 1);
},
/**
* Wire wheel-zoom, double-click zoom, drag-pan, pinch-zoom and (when not
* zoomed) touch swipe-to-navigate onto a photo element.
* @param {HTMLImageElement} img
*/
_wireZoomPan(img) {
img.style.transformOrigin = 'center center';
img.style.touchAction = 'none';
img.addEventListener(
'wheel',
(e) => {
e.preventDefault();
this._setZoom(this._zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2));
},
{ passive: false }
);
img.addEventListener('dblclick', (e) => {
e.preventDefault();
this._setZoom(this._zoom > 1 ? 1 : 2.5);
});
img.addEventListener('pointerdown', (e) => {
img.setPointerCapture?.(e.pointerId);
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this._pointers.size === 2) {
const pts = [...this._pointers.values()];
this._pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
this._pinchStartZoom = this._zoom;
} else {
this._dragStart = { x: e.clientX, y: e.clientY, panX: this._panX, panY: this._panY };
this._swipeStart = { x: e.clientX, y: e.clientY, t: Date.now() };
}
});
img.addEventListener('pointermove', (e) => {
if (!this._pointers.has(e.pointerId)) return;
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (this._pointers.size === 2 && this._pinchStartDist > 0) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
this._setZoom(this._pinchStartZoom * (dist / this._pinchStartDist));
} else if (this._zoom > 1 && this._dragStart) {
this._panX = this._dragStart.panX + (e.clientX - this._dragStart.x);
this._panY = this._dragStart.panY + (e.clientY - this._dragStart.y);
this._applyTransform();
}
});
const endPointer = (/** @type {PointerEvent} */ e) => {
const wasPinch = this._pointers.size === 2;
this._pointers.delete(e.pointerId);
if (!wasPinch && this._zoom === 1 && this._swipeStart && e.pointerType === 'touch') {
const dx = e.clientX - this._swipeStart.x;
const dy = e.clientY - this._swipeStart.y;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx > 0) this.prev();
else this.next();
}
}
if (wasPinch) this._pinchStartDist = 0;
this._dragStart = null;
this._swipeStart = null;
};
img.addEventListener('pointerup', endPointer);
img.addEventListener('pointercancel', endPointer);
},
// ── Info panel ──────────────────────────────────────────────────
/** Toggle the EXIF info panel. */
_toggleInfoPanel() {
this._overlay?.querySelector('.lightbox-infopanel')?.classList.toggle('hidden');
},
/**
* Populate the info panel from fetched EXIF metadata.
* @param {FileMetadata} metadata
* @param {string} dateStr
* @param {string} sizeStr
*/
_fillInfoPanel(metadata, dateStr, sizeStr) {
const panel = this._overlay?.querySelector('.lightbox-infopanel');
if (!panel) return;
const item = this.items[this.index];
const rows = [this._infoRow('fa-image', item?.name || ''), this._infoRow('fa-calendar', dateStr)];
if (sizeStr) rows.push(this._infoRow('fa-hard-drive', sizeStr));
if (metadata.width && metadata.height) {
rows.push(this._infoRow('fa-ruler-combined', `${metadata.width} × ${metadata.height}`));
}
if (metadata.camera_make || metadata.camera_model) {
rows.push(this._infoRow('fa-camera', [metadata.camera_make, metadata.camera_model].filter(Boolean).join(' ')));
}
if (metadata.latitude != null && metadata.longitude != null) {
rows.push(this._infoRow('fa-location-dot', `${metadata.latitude.toFixed(5)}, ${metadata.longitude.toFixed(5)}`));
}
panel.innerHTML = rows.join('');
},
/**
* @param {string} icon FontAwesome class
* @param {string} text
* @returns {string}
*/
_infoRow(icon, text) {
const d = document.createElement('div');
d.textContent = text;
return `<div class="lb-info-row"><i class="fas ${icon}"></i><span>${d.innerHTML}</span></div>`;
},
/** Keyboard navigation */
_bindKeys() {
this._keyHandler = (e) => {
+394
View File
@@ -0,0 +1,394 @@
/**
* OxiCloud - Places (photo map)
*
* Renders the user's geotagged photos on a self-hosted MapLibre GL map.
* Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation),
* so we draw one lightweight HTML marker per cluster — no glyph/sprite assets
* and no client-side clustering needed. The vector basemap is optional: if a
* `static/basemaps/basemap.pmtiles` is present it is read directly by the
* browser over HTTP Range (pmtiles.js); otherwise the map falls back to a
* plain themed background and still shows the photo clusters.
*
* MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only
* when the Places tab is first opened.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { peopleView } from './people.js';
import { photosView } from './photos.js';
import { photosLightbox } from './photosLightbox.js';
/** @import {FileItem} from '../../core/types.js' */
/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
export const placesView = {
/** @type {HTMLElement|null} */
_container: null,
/** @type {HTMLElement|null} */
_subnav: null,
/** @type {any} MapLibre Map instance */
_map: null,
/** @type {any[]} current cluster markers */
_markers: [],
/** @type {{maplibregl: any, pmtiles: any}|null} */
_libs: null,
/** @type {number} debounce timer for moveend refresh */
_moveTimer: 0,
/** @type {'moments'|'places'|'people'} */
_activeTab: 'moments',
/** @type {boolean|null} cached basemap availability */
_hasBasemap: null,
/** Auth headers (HttpOnly cookies + CSRF) */
_headers() {
return getCsrfHeaders();
},
// ── Sub-navigation (Moments | Places) ───────────────────────────
// Lives at the top of `.content-area`; mounted while the Photos section
// is active and torn down (hidden) when the user leaves it.
/** Create/show the Moments|Places tab bar and the map container. */
mountTabs() {
const contentArea = document.querySelector('.content-area');
if (!contentArea) return;
if (!this._subnav) {
const bar = document.createElement('div');
bar.className = 'photos-subnav';
bar.innerHTML =
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>` +
`<button class="photos-subnav-tab hidden" type="button" data-ptab="people">${this._esc(i18n.t('photos.tab_people'))}</button>`;
bar.addEventListener('click', (e) => {
const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]');
if (btn) this._switchTab(/** @type {'moments'|'places'|'people'} */ (btn.getAttribute('data-ptab')));
});
contentArea.insertBefore(bar, contentArea.firstChild);
this._subnav = bar;
this._probePeople();
}
this._subnav.classList.remove('hidden');
if (!this._container) {
const el = document.createElement('div');
el.id = 'places-container';
el.className = 'places-container';
contentArea.appendChild(el);
this._container = el;
}
// Always (re)enter the Photos section on the Moments tab.
this._activeTab = 'moments';
this._setActiveTab('moments');
this.hide();
peopleView.hide();
},
/** Reveal the People tab only if GET /api/people is available (faces on). */
async _probePeople() {
try {
const res = await fetch('/api/people', { credentials: 'include', headers: getCsrfHeaders() });
if (res.ok) {
this._subnav?.querySelector('[data-ptab="people"]')?.classList.remove('hidden');
}
} catch {
/* leave the People tab hidden */
}
},
/** Hide the tab bar and the map (called when leaving the Photos section). */
unmountTabs() {
this._subnav?.classList.add('hidden');
this.hide();
peopleView.hide();
},
/** Hide the map container (without destroying the map). */
hide() {
this._container?.classList.remove('active');
},
/**
* @param {'moments'|'places'|'people'} tab
*/
_switchTab(tab) {
if (tab === this._activeTab) return;
this._activeTab = tab;
this._setActiveTab(tab);
// Hide all three views, then show the selected one.
photosView.hide();
this.hide();
peopleView.hide();
if (tab === 'places') this._showMap();
else if (tab === 'people') peopleView.show();
else photosView.show();
},
/** @param {string} tab */
_setActiveTab(tab) {
this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-ptab') === tab);
});
},
// ── Map ─────────────────────────────────────────────────────────
/** Reveal the map container and (lazily) build the map. */
async _showMap() {
if (!this._container) return;
this._container.classList.add('active');
if (this._map) {
this._map.resize();
this._refreshClusters(false);
return;
}
this._container.innerHTML = '<div class="places-map" id="places-map"></div>' + '<div class="places-loading"><i class="fas fa-spinner"></i></div>';
try {
const libs = await this._loadLibs();
await this._initMap(libs);
} catch (err) {
console.error('Places map failed to load:', err);
if (this._container) {
this._container.innerHTML = `<div class="places-error">${this._esc(i18n.t('photos.map_error'))}</div>`;
}
}
},
/** Inject a vendored script once, resolving when it has loaded.
* @param {string} src
* @returns {Promise<void>}
*/
_loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-vendor="${src}"]`)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.vendor = src;
s.addEventListener('load', () => resolve());
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
document.head.appendChild(s);
});
},
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
async _loadLibs() {
if (this._libs) return this._libs;
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = '/js/vendors/maplibre-gl.css';
l.dataset.vendor = 'maplibre-css';
document.head.appendChild(l);
}
await this._loadScript('/js/vendors/maplibre-gl.js');
await this._loadScript('/js/vendors/pmtiles.js');
const w = /** @type {any} */ (window);
this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
return this._libs;
},
/** Whether a basemap .pmtiles is available (cached after first probe). */
async _checkBasemap() {
if (this._hasBasemap !== null) return this._hasBasemap;
try {
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
this._hasBasemap = res.ok; // 200/206 = present, 404 = absent
} catch {
this._hasBasemap = false;
}
return this._hasBasemap;
},
/**
* @param {{maplibregl: any, pmtiles: any}} libs
*/
async _initMap({ maplibregl, pmtiles }) {
const hasBasemap = await this._checkBasemap();
if (hasBasemap) {
try {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
} catch (e) {
console.error('pmtiles protocol registration failed:', e);
}
}
this._map = new maplibregl.Map({
container: 'places-map',
style: hasBasemap ? this._basemapStyle() : this._blankStyle(),
center: [0, 25],
zoom: 1.3,
attributionControl: false
});
this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
if (hasBasemap) {
this._map.addControl(
new maplibregl.AttributionControl({
customAttribution: 'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
})
);
}
this._map.on('load', () => {
this._removeLoading();
this._refreshClusters(true);
});
this._map.on('moveend', () => {
clearTimeout(this._moveTimer);
this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250);
});
},
_removeLoading() {
this._container?.querySelector('.places-loading')?.remove();
},
/** Fetch clusters for the current viewport and render them.
* @param {boolean} fit Fit the map to the returned clusters (first load).
*/
async _refreshClusters(fit) {
if (!this._map) return;
const b = this._map.getBounds();
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
const zoom = Math.round(this._map.getZoom());
try {
const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, {
credentials: 'include',
headers: this._headers()
});
if (!res.ok) return;
/** @type {GeoClusterItem[]} */
const clusters = await res.json();
this._renderMarkers(clusters);
if (fit && clusters.length) this._fitTo(clusters);
} catch (err) {
console.error('Places geo fetch failed:', err);
}
},
/** @param {GeoClusterItem[]} clusters */
_renderMarkers(clusters) {
for (const m of this._markers) m.remove();
this._markers = [];
if (!this._libs) return;
const { maplibregl } = this._libs;
for (const c of clusters) {
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
const el = document.createElement('div');
el.className = 'places-cluster';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`;
if (c.count > 1) {
el.innerHTML = `<span class="places-cluster-count">${c.count}</span>`;
}
el.addEventListener('click', () => this._onClusterClick(c));
const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map);
this._markers.push(marker);
}
},
/** @param {GeoClusterItem} c */
_onClusterClick(c) {
const zoom = this._map.getZoom();
if (c.count === 1 || zoom >= 16) {
// Drill down to the representative photo. We only know its id, so
// build a minimal item and let the lightbox load the rest.
const item = /** @type {FileItem} */ (
/** @type {any} */ ({
id: c.sample_file_id,
name: '',
mime_type: 'image/jpeg',
created_at: 0,
sort_date: 0,
size_formatted: ''
})
);
photosLightbox.open([item], 0);
} else {
this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
}
},
/** @param {GeoClusterItem[]} clusters */
_fitTo(clusters) {
if (!this._libs) return;
const { maplibregl } = this._libs;
const bounds = new maplibregl.LngLatBounds();
for (const c of clusters) bounds.extend([c.lng, c.lat]);
if (!bounds.isEmpty()) {
this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
}
},
/** @returns {boolean} */
_isDark() {
return document.documentElement.getAttribute('data-color-scheme') === 'dark';
},
/** Minimal MapLibre style: themed background only (no basemap). */
_blankStyle() {
return {
version: 8,
sources: {},
layers: [
{
id: 'bg',
type: 'background',
paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' }
}
]
};
},
/** Label-light Protomaps vector style (no glyphs/sprites required). */
_basemapStyle() {
const dark = this._isDark();
const c = dark
? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' }
: { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' };
return {
version: 8,
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${BASEMAP_URL}`,
attribution: 'Protomaps © OpenStreetMap'
}
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
{ id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } },
{ id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } },
{ id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } },
{ id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } },
{ id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } },
{
id: 'boundaries',
type: 'line',
source: 'protomaps',
'source-layer': 'boundaries',
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
}
]
};
},
/** @param {any} s */
_esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
};
+3 -1
View File
@@ -164,7 +164,9 @@ const grants = {
/**
* Create a new grant.
* Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`.
* Body mirrors `CreateGrantDto`: `{ subject, resource, role, expires_at? }`.
* Strictly role-keyed since the cleanup PR — the per-permission shape
* is no longer accepted.
*
* Response shape (PR N1 — `CreateGrantResponseDto`):
*
+2
View File
@@ -0,0 +1,2 @@
maplibre-gl 5.24.0
pmtiles 4.4.1
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More