feat(faces): schema + feature flag for People (off by default)
First slice of Phase 2 (People / faces): - migration: `faces` schema with `faces.persons` and `faces.faces`. Embeddings are stored as BYTEA (512 x f32) — no pgvector extension dependency; similarity is computed in-app (pgvector/VectorChord is the documented scale-up). Cascade deletes (by user and by source file) satisfy the right to erasure. - OXICLOUD_ENABLE_FACES feature flag, OFF by default (biometric data, opt-in per deployment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -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);
|
||||||
@@ -881,6 +881,9 @@ pub struct FeaturesConfig {
|
|||||||
pub enable_music: bool,
|
pub enable_music: bool,
|
||||||
/// Lists the user's geotagged photos on a map (GET /api/photos/geo).
|
/// Lists the user's geotagged photos on a map (GET /api/photos/geo).
|
||||||
pub enable_places: bool,
|
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
|
/// Expose other OxiCloud users as a read-only "system" address book
|
||||||
/// at GET /api/address-books. Set to false to hide the user directory.
|
/// at GET /api/address-books. Set to false to hide the user directory.
|
||||||
pub expose_system_users: bool,
|
pub expose_system_users: bool,
|
||||||
@@ -896,6 +899,7 @@ impl Default for FeaturesConfig {
|
|||||||
enable_search: true, // Enable search feature
|
enable_search: true, // Enable search feature
|
||||||
enable_music: true, // Enable music feature
|
enable_music: true, // Enable music feature
|
||||||
enable_places: true, // Photo map (GET /api/photos/geo + Places tab)
|
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
|
expose_system_users: true, // Expose OxiCloud users as address book by default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1387,6 +1391,12 @@ impl AppConfig {
|
|||||||
config.features.enable_places = val;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Content search (embedded Tantivy index)
|
// Content search (embedded Tantivy index)
|
||||||
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
||||||
&& let Ok(val) = v
|
&& let Ok(val) = v
|
||||||
|
|||||||
Reference in New Issue
Block a user