diff --git a/migrations/20260801000004_faces.sql b/migrations/20260801000004_faces.sql new file mode 100644 index 00000000..2d2dfd43 --- /dev/null +++ b/migrations/20260801000004_faces.sql @@ -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); diff --git a/src/common/config.rs b/src/common/config.rs index 57414c63..3c28d46f 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -881,6 +881,9 @@ pub struct FeaturesConfig { 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, @@ -896,6 +899,7 @@ impl Default for FeaturesConfig { 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 } } @@ -1387,6 +1391,12 @@ impl AppConfig { config.features.enable_places = val; } + if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::()) + && let Ok(val) = enable_faces + { + config.features.enable_faces = val; + } + // Content search (embedded Tantivy index) if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::()) && let Ok(val) = v