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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -603,19 +603,28 @@ impl Role {
|
||||
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::Manager => &[Permission::Read, Permission::Comment,
|
||||
Role::Owner => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update,
|
||||
Permission::Share],
|
||||
Role::Admin => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update,
|
||||
Permission::Share, Permission::Delete],
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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").
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
// 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)
|
||||
.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",
|
||||
)
|
||||
.bind(chunk_hashes)
|
||||
.fetch_all(&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 {
|
||||
// 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
|
||||
}
|
||||
@@ -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}")))?
|
||||
}
|
||||
}
|
||||
@@ -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,10 +1427,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
},
|
||||
)
|
||||
});
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let summaries: Vec<OutgoingResourceSummary> = seen_pairs
|
||||
.into_iter()
|
||||
@@ -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,10 +1551,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
has_password,
|
||||
is_external,
|
||||
});
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let summaries: Vec<OutgoingResourceSummary> = seen_resources
|
||||
.into_iter()
|
||||
@@ -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,23 +91,52 @@ 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));
|
||||
// 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 {
|
||||
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 {
|
||||
@@ -128,7 +162,6 @@ impl ContentIndexWorker {
|
||||
self.sweep_orphaned_text().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn the discard-only janitor used when content search is DISABLED:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 base_href = format!("/carddav/{}/", user_part);
|
||||
let mut response_body = Vec::new();
|
||||
CardDavAdapter::generate_addressbooks_propfind_response(
|
||||
&mut response_body,
|
||||
|
||||
@@ -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)
|
||||
// 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(grant) => results.push(grant.into()),
|
||||
Ok(g) => g,
|
||||
Err(err) => {
|
||||
error!("grant insert failed for {perm:?}: {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 {
|
||||
// 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
|
||||
{
|
||||
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(¤t_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)
|
||||
.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,27 +661,32 @@ 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) => {
|
||||
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(),
|
||||
@@ -722,27 +697,13 @@ pub async fn list_shared_with_me(
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
// Stale grant (file deleted, trigger not yet fired) — skip silently.
|
||||
warn!(
|
||||
None => 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::Folder => {
|
||||
let result = &folder_results[folder_idx];
|
||||
folder_idx += 1;
|
||||
match result {
|
||||
Ok(folder_dto) => {
|
||||
),
|
||||
},
|
||||
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(),
|
||||
@@ -753,21 +714,11 @@ pub async fn list_shared_with_me(
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
None => 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +920,10 @@ 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) => {
|
||||
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).
|
||||
@@ -978,26 +934,13 @@ pub async fn list_my_shares(
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
None => 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::Folder => {
|
||||
let result = &folder_results[folder_idx];
|
||||
folder_idx += 1;
|
||||
match result {
|
||||
Ok(folder_dto) => {
|
||||
),
|
||||
},
|
||||
ResourceKind::Folder => match folder_map.get(&rid) {
|
||||
Some(folder_dto) => {
|
||||
items.push(OutgoingResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
first_shared_at: summary.first_shared_at,
|
||||
@@ -1005,21 +948,11 @@ pub async fn list_my_shares(
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
None => 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,20 +44,38 @@ 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(
|
||||
let user_id = Uuid::parse_str(&claims.sub)
|
||||
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
|
||||
|
||||
// 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",
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
Uuid::parse_str(&claims.sub)
|
||||
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
|
||||
claims.role.clone(),
|
||||
))
|
||||
}
|
||||
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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
// 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",
|
||||
))
|
||||
.unwrap());
|
||||
));
|
||||
}
|
||||
return Err(AuthError::InvalidToken(
|
||||
"Invalid username or app password".to_string(),
|
||||
@@ -285,17 +303,34 @@ pub async fn auth_middleware(
|
||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||
AuthError::InvalidToken("Invalid user ID in token".to_string())
|
||||
})?;
|
||||
// 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: claims.role.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);
|
||||
// Don't return error — fall through to "no token" so
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** 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._renderedCount = this.items.length;
|
||||
this._observeSentinel();
|
||||
this._setupVideoThumbnails(startIndex);
|
||||
this._fadeInTiles();
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 (btn) {
|
||||
btn.classList.toggle('active');
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
|
||||
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) {
|
||||
const nowFav = !isFav;
|
||||
btn.classList.toggle('active', nowFav);
|
||||
const icon = btn.querySelector('i');
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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`):
|
||||
*
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
maplibre-gl 5.24.0
|
||||
pmtiles 4.4.1
|
||||
Vendored
+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
Reference in New Issue
Block a user