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

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

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

Backend (people/places/faces handlers, routes, DI, migrations) merged
cleanly. `cargo check --bins` passes. The new Places/People UI is not yet
in the Svelte app; that is ported in follow-up commits.
This commit is contained in:
Claude
2026-06-19 12:45:22 +00:00
126 changed files with 11677 additions and 947 deletions
+30 -9
View File
@@ -17,6 +17,19 @@ use crate::application::adapters::webdav_adapter::{
};
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
/// Returns whether `caller_id` owns `calendar`.
///
/// CalDAV clients (DAVx5, Apple Calendar, Thunderbird) only mount a collection
/// read-write when its `current-user-privilege-set` advertises `<D:write/>`, so
/// this gate decides read-only vs read-write for the caller. `caller_id` and
/// [`CalendarDto::owner_id`] are both the user's UUID rendered via
/// `Uuid::to_string()`, so a direct comparison is exact. Calendars merely shared
/// with the caller (non-owner access) stay read-only for now — this never
/// over-grants write.
fn caller_owns_calendar(calendar: &CalendarDto, caller_id: &str) -> bool {
!caller_id.is_empty() && calendar.owner_id == caller_id
}
/// CalDAV report type
#[derive(Debug, PartialEq)]
pub enum CalDavReportType {
@@ -205,6 +218,7 @@ impl CalDavAdapter {
request: &PropFindRequest,
base_href: &str,
username: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -227,6 +241,7 @@ impl CalDavAdapter {
calendar,
request,
&format!("{}{}/", base_href, calendar.id),
caller_id,
)?;
}
@@ -242,6 +257,7 @@ impl CalDavAdapter {
calendars: &[CalendarDto],
request: &PropFindRequest,
base_href: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -261,6 +277,7 @@ impl CalDavAdapter {
calendar,
request,
&format!("{}{}/", base_href, calendar.id),
caller_id,
)?;
}
@@ -557,6 +574,7 @@ impl CalDavAdapter {
calendar: &CalendarDto,
request: &PropFindRequest,
href: &str,
caller_id: &str,
) -> Result<()> {
// Start response element
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
@@ -576,7 +594,7 @@ impl CalDavAdapter {
match &request.prop_find_type {
PropFindType::AllProp => {
// Write all standard properties for a calendar
Self::write_calendar_standard_props(xml_writer, calendar)?;
Self::write_calendar_standard_props(xml_writer, calendar, caller_id)?;
}
PropFindType::PropName => {
// Write only property names (empty elements)
@@ -584,7 +602,7 @@ impl CalDavAdapter {
}
PropFindType::Prop(props) => {
// Write requested properties
Self::write_calendar_requested_props(xml_writer, calendar, props)?;
Self::write_calendar_requested_props(xml_writer, calendar, props, caller_id)?;
}
}
@@ -609,6 +627,7 @@ impl CalDavAdapter {
fn write_calendar_standard_props<W: Write>(
xml_writer: &mut Writer<W>,
calendar: &CalendarDto,
caller_id: &str,
) -> Result<()> {
// Common WebDAV properties
@@ -676,9 +695,10 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
// Only add write privilege if user owns the calendar or has write access
if calendar.owner_id == "current_user_id" {
// This should be replaced with actual user check
// Advertise write only when the caller owns the calendar. Clients
// (DAVx5, Apple Calendar, Thunderbird) mount the collection read-only
// unless this privilege is present.
if caller_owns_calendar(calendar, caller_id) {
xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
@@ -735,6 +755,7 @@ impl CalDavAdapter {
xml_writer: &mut Writer<W>,
calendar: &CalendarDto,
props: &[QualifiedName],
caller_id: &str,
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
@@ -780,9 +801,8 @@ impl CalDavAdapter {
xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
// Only add write privilege if user owns the calendar or has write access
if calendar.owner_id == "current_user_id" {
// This should be replaced with actual user check
// Advertise write only when the caller owns the calendar.
if caller_owns_calendar(calendar, caller_id) {
xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?;
@@ -882,6 +902,7 @@ impl CalDavAdapter {
request: &PropFindRequest,
base_href: &str,
depth: &str,
caller_id: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
@@ -894,7 +915,7 @@ impl CalDavAdapter {
))?;
// Write the calendar collection itself
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href)?;
Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?;
// If depth > 0, include event resources
if depth != "0" {
@@ -171,6 +171,7 @@ mod tests {
&calendars,
&request,
"/caldav/",
"user-001",
);
assert!(
@@ -210,6 +211,7 @@ mod tests {
&request,
"/caldav/cal-001",
"0",
"user-001",
);
assert!(
@@ -240,6 +242,7 @@ mod tests {
&request,
"/caldav/cal-001",
"1",
"user-001",
);
assert!(
@@ -258,6 +261,54 @@ mod tests {
);
}
#[test]
fn test_owner_gets_write_privilege_but_non_owner_is_read_only() {
// Regression for #480: the privilege gate previously compared owner_id
// against the literal "current_user_id", so <D:write/> was never emitted
// and every CalDAV client mounted calendars read-only.
let calendar = sample_calendar(); // owner_id = "user-001"
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
// Owner → read + write.
let mut owner_out = Vec::new();
CalDavAdapter::generate_calendar_collection_propfind(
&mut owner_out,
&calendar,
&[],
&request,
"/caldav/cal-001/",
"0",
"user-001",
)
.expect("owner propfind");
let owner_xml = String::from_utf8(owner_out).expect("utf8");
assert!(
owner_xml.contains("D:write"),
"Owner must be granted <D:write/>, got: {owner_xml}"
);
// A different caller (e.g. a read-only share) → read only, never write.
let mut other_out = Vec::new();
CalDavAdapter::generate_calendar_collection_propfind(
&mut other_out,
&calendar,
&[],
&request,
"/caldav/cal-001/",
"0",
"a-different-user",
)
.expect("non-owner propfind");
let other_xml = String::from_utf8(other_out).expect("utf8");
assert!(other_xml.contains("D:read"), "Non-owner keeps <D:read/>");
assert!(
!other_xml.contains("D:write"),
"Non-owner must NOT get <D:write/>, got: {other_xml}"
);
}
// ========================
// Calendar events response tests
// ========================
@@ -434,6 +485,7 @@ mod tests {
&request,
"/caldav/",
"testuser",
"user-001",
);
assert!(
result.is_ok(),
@@ -483,6 +535,7 @@ mod tests {
&request,
"/caldav/",
"testuser",
"user-001",
);
assert!(result.is_ok());
@@ -565,6 +618,7 @@ mod tests {
&request,
"/caldav/cal-001/",
"0",
"user-001",
);
assert!(result.is_ok(), "Failed: {:?}", result.err());
+257 -8
View File
@@ -17,6 +17,20 @@ use crate::application::adapters::webdav_adapter::{
use crate::application::dtos::address_book_dto::AddressBookDto;
use crate::application::dtos::contact_dto::ContactDto;
/// Render a requested property as a namespaced response element name, mapping
/// the known namespaces to their response prefixes (`D:` for DAV, `CR:` for
/// CardDAV). Used for the catch-all arms of the requested-property writers so
/// the prefix mapping lives in exactly one place.
fn carddav_prop_name(prop: &QualifiedName) -> String {
if prop.namespace == "urn:ietf:params:xml:ns:carddav" {
format!("CR:{}", prop.name)
} else if prop.namespace == "DAV:" {
format!("D:{}", prop.name)
} else {
prop.name.clone()
}
}
/// CardDAV report type
#[derive(Debug, PartialEq)]
pub enum CardDavReportType {
@@ -163,6 +177,96 @@ impl CardDavAdapter {
Ok(())
}
/// Generate a PROPFIND response for the CardDAV root `/carddav/`.
///
/// Mirrors the CalDAV root: emits a discovery entry for `/carddav/` itself
/// advertising `current-user-principal` and `addressbook-home-set` (the
/// properties DAVx5 / Apple Contacts read to locate address books), then —
/// at Depth > 0 — one entry per address book. Without these discovery
/// properties clients never find the address books at all.
pub fn generate_root_propfind_response<W: Write>(
writer: W,
address_books: &[AddressBookDto],
request: &PropFindRequest,
base_href: &str,
username: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
Self::write_carddav_root_response(&mut xml_writer, request, base_href, username)?;
for book in address_books {
Self::write_addressbook_response(
&mut xml_writer,
book,
request,
&format!("{}{}/", base_href, book.id),
)?;
}
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// Generate a PROPFIND response for a CardDAV user principal resource at
/// `/carddav/principals/{username}/`.
///
/// Returns `addressbook-home-set` so clients can resolve the collection
/// holding the user's address books, plus a self-referential
/// `current-user-principal`.
pub fn generate_principal_propfind_response<W: Write>(
writer: W,
request: &PropFindRequest,
username: &str,
) -> Result<()> {
let mut xml_writer = Writer::new(writer);
xml_writer.write_event(Event::Start(
BytesStart::new("D:multistatus").with_attributes([
("xmlns:D", "DAV:"),
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
("xmlns:CS", "http://calendarserver.org/ns/"),
]),
))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
let href = format!("/carddav/principals/{}/", username);
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
match &request.prop_find_type {
PropFindType::AllProp | PropFindType::PropName => {
Self::write_carddav_principal_props(&mut xml_writer, username)?;
}
PropFindType::Prop(props) => {
Self::write_carddav_principal_requested_props(&mut xml_writer, username, props)?;
}
}
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(())
}
/// Generate PROPFIND for a single address book collection + contacts
pub fn generate_addressbook_collection_propfind<W: Write>(
writer: W,
@@ -384,14 +488,159 @@ impl CardDavAdapter {
.write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?;
}
_ => {
let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" {
format!("CR:{}", prop.name)
} else if prop.namespace == "DAV:" {
format!("D:{}", prop.name)
} else {
prop.name.clone()
};
xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?;
xml_writer
.write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?;
}
}
}
Ok(())
}
/// Write a populated `current-user-principal` element pointing at the user's
/// CardDAV principal. Shared by the root and principal discovery responses.
fn write_current_user_principal<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"/carddav/principals/{}/",
username
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
Ok(())
}
/// Write a populated `addressbook-home-set` element pointing at the user's
/// address-book home collection. Shared by the root and principal responses.
fn write_addressbook_home_set<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("CR:addressbook-home-set")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"/carddav/{}/",
username
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::End(BytesEnd::new("CR:addressbook-home-set")))?;
Ok(())
}
/// Write the root `/carddav/` discovery entry.
fn write_carddav_root_response<W: Write>(
xml_writer: &mut Writer<W>,
request: &PropFindRequest,
href: &str,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
xml_writer.write_event(Event::Text(BytesText::new(href)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
match &request.prop_find_type {
PropFindType::AllProp => {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
Self::write_current_user_principal(xml_writer, username)?;
Self::write_addressbook_home_set(xml_writer, username)?;
}
PropFindType::PropName => {
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
xml_writer
.write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("CR:addressbook-home-set")))?;
}
PropFindType::Prop(props) => {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
("DAV:", "resourcetype") => {
xml_writer
.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer
.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
}
("DAV:", "current-user-principal") => {
Self::write_current_user_principal(xml_writer, username)?;
}
("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => {
Self::write_addressbook_home_set(xml_writer, username)?;
}
_ => {
xml_writer.write_event(Event::Empty(BytesStart::new(
carddav_prop_name(prop),
)))?;
}
}
}
}
}
xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?;
xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?;
Ok(())
}
/// Write the standard properties for a CardDAV principal resource.
fn write_carddav_principal_props<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
) -> Result<()> {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
xml_writer.write_event(Event::Text(BytesText::new(username)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
Self::write_addressbook_home_set(xml_writer, username)?;
Self::write_current_user_principal(xml_writer, username)?;
Ok(())
}
/// Write the requested properties for a CardDAV principal resource.
fn write_carddav_principal_requested_props<W: Write>(
xml_writer: &mut Writer<W>,
username: &str,
props: &[QualifiedName],
) -> Result<()> {
for prop in props {
match (prop.namespace.as_str(), prop.name.as_str()) {
("DAV:", "resourcetype") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
}
("DAV:", "displayname") => {
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
xml_writer.write_event(Event::Text(BytesText::new(username)))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
}
("DAV:", "current-user-principal") => {
Self::write_current_user_principal(xml_writer, username)?;
}
("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => {
Self::write_addressbook_home_set(xml_writer, username)?;
}
_ => {
xml_writer
.write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?;
}
}
}
@@ -307,6 +307,97 @@ mod tests {
);
}
#[test]
fn test_root_propfind_advertises_principal_and_home_set() {
// Regression for #480: without these discovery properties DAVx5 / Apple
// Contacts never locate the user's address books.
let books = vec![sample_address_book()];
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
let mut output = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut output,
&books,
&request,
"/carddav/",
"testuser",
)
.expect("root propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(
xml.contains("/carddav/principals/testuser/"),
"Root must expose current-user-principal href, got: {xml}"
);
assert!(
xml.contains("/carddav/testuser/"),
"Root must expose addressbook-home-set href, got: {xml}"
);
// Depth 1 also enumerates the books.
assert!(xml.contains("ab-001"), "Should list address book");
}
#[test]
fn test_root_propfind_prop_request_returns_populated_discovery() {
// A DAVx5-style targeted request for the two discovery properties.
let request = PropFindRequest {
prop_find_type: PropFindType::Prop(vec![
QualifiedName {
namespace: "DAV:".to_string(),
name: "current-user-principal".to_string(),
},
QualifiedName {
namespace: "urn:ietf:params:xml:ns:carddav".to_string(),
name: "addressbook-home-set".to_string(),
},
]),
};
let mut output = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut output,
&[],
&request,
"/carddav/",
"testuser",
)
.expect("root propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(xml.contains("/carddav/principals/testuser/"));
assert!(xml.contains("/carddav/testuser/"));
// Properties must be populated, not empty self-closing placeholders.
assert!(!xml.contains("<D:current-user-principal/>"));
assert!(!xml.contains("<CR:addressbook-home-set/>"));
}
#[test]
fn test_principal_propfind_returns_home_set() {
let request = PropFindRequest {
prop_find_type: PropFindType::AllProp,
};
let mut output = Vec::new();
CardDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser")
.expect("principal propfind");
let xml = String::from_utf8(output).expect("utf8");
assert!(
xml.contains("/carddav/principals/testuser/"),
"Principal href should be present"
);
assert!(
xml.contains("/carddav/testuser/"),
"addressbook-home-set should be present"
);
assert!(
xml.contains("D:principal"),
"resourcetype should include principal"
);
}
#[test]
fn test_generate_addressbook_collection_propfind_depth_0() {
let addressbook = sample_address_book();
+26
View File
@@ -0,0 +1,26 @@
//! DTOs for the "Places" (photo map) feature.
use serde::Serialize;
use utoipa::ToSchema;
/// A geographic bounding box in decimal degrees.
#[derive(Debug, Clone, Copy)]
pub struct GeoBounds {
pub west: f64,
pub south: f64,
pub east: f64,
pub north: f64,
}
/// A clustered group of geotagged photos within one aggregation cell.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GeoCluster {
/// Cluster centroid longitude.
pub lng: f64,
/// Cluster centroid latitude.
pub lat: f64,
/// Number of photos in the cluster.
pub count: i64,
/// A representative photo id, for the cluster thumbnail.
pub sample_file_id: String,
}
+89 -55
View File
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
@@ -95,6 +95,7 @@ pub enum PermissionDto {
Comment,
Delete,
Update,
Manage,
}
impl From<PermissionDto> for Permission {
@@ -106,6 +107,7 @@ impl From<PermissionDto> for Permission {
PermissionDto::Comment => Permission::Comment,
PermissionDto::Delete => Permission::Delete,
PermissionDto::Update => Permission::Update,
PermissionDto::Manage => Permission::Manage,
}
}
}
@@ -119,57 +121,58 @@ impl From<Permission> for PermissionDto {
Permission::Comment => PermissionDto::Comment,
Permission::Delete => PermissionDto::Delete,
Permission::Update => PermissionDto::Update,
Permission::Manage => PermissionDto::Manage,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Roles (DTO-layer sugar)
// Roles — the load-bearing model for ReBAC grants
// ════════════════════════════════════════════════════════════════════════════
//
// One row per role assignment in `storage.role_grants.role` (a
// `storage.grant_role` ENUM). The engine expands the bundle at query time
// via `Role::expand()` on the domain enum. Adding a role is two edits:
// the variant + match arm on `Role`, and an `ALTER TYPE
// storage.grant_role ADD VALUE 'name'` migration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
/// Wire-format wrapper around the domain `Role` enum. Carries the
/// serde/utoipa derives. Maps 1:1 to/from `Role` via `From`.
///
/// The historical `"admin"` alias for `Owner` (used during the D-Prep
/// dual-write window for cached clients) has been retired in the cleanup
/// PR — the OxiCloud UI emits `"owner"` exclusively. Stragglers receive
/// a 422 on POST/PUT, which surfaces the upgrade cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
pub enum RoleDto {
Viewer,
//Commenter,
Commenter,
Contributor,
Editor,
//Manager,
Admin,
Owner,
}
impl Role {
/// Expands a role into its constituent raw permissions. Storage and
/// engine know nothing about roles — the server normalizes here before
/// writing rows.
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
/* reserved for future
Role::Commenter => &[Permission::Read, Permission::Comment],
*/
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
/* reserved for future
Role::Manager => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
],
*/
Role::Admin => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
],
impl From<RoleDto> for Role {
fn from(r: RoleDto) -> Self {
match r {
RoleDto::Viewer => Role::Viewer,
RoleDto::Commenter => Role::Commenter,
RoleDto::Contributor => Role::Contributor,
RoleDto::Editor => Role::Editor,
RoleDto::Owner => Role::Owner,
}
}
}
impl From<Role> for RoleDto {
fn from(r: Role) -> Self {
match r {
Role::Viewer => RoleDto::Viewer,
Role::Commenter => RoleDto::Commenter,
Role::Contributor => RoleDto::Contributor,
Role::Editor => RoleDto::Editor,
Role::Owner => RoleDto::Owner,
}
}
}
@@ -206,17 +209,18 @@ pub enum SubjectInputDto {
},
}
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
/// `POST /api/grants` — create or refresh a role assignment.
///
/// Strictly role-keyed since the cleanup PR: callers send exactly one
/// role; the engine writes a single row in `storage.role_grants`. The
/// historical per-permission shape (`permissions: [...]`) was dropped —
/// the OxiCloud UI is the only known caller and it already sends `role`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectInputDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
pub role: RoleDto,
/// Optional expiry for the grant. RFC 3339 / ISO 8601.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -226,7 +230,7 @@ pub struct CreateGrantDto {
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
pub role: RoleDto,
/// Optional expiry applied to every grant written or updated by this call.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -241,7 +245,10 @@ pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
/// Role-keyed since D-Prep cleanup — one row in `storage.role_grants`
/// is one `GrantDto`. The bundle of underlying permissions is implied
/// by the role and recomputed client-side from the same lookup table.
pub role: RoleDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -254,7 +261,7 @@ impl From<Grant> for GrantDto {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
role: g.role.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
expires_at: g.expires_at,
@@ -430,12 +437,34 @@ pub struct SharedWithMeItemDto {
}
/// Derive the closest-matching role label from a set of permissions.
/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`.
///
/// **Legacy helper for the dual-write window.** Once D-Prep ships and the
/// engine reads `role_grants.role` directly, this function becomes unused
/// and is dropped in the cleanup PR. Kept here so callers that still hit
/// `access_grants` and reconstruct a role for display can stay working
/// during the transition.
///
/// Emits the new five-role roster on output (`"viewer"` / `"commenter"` /
/// `"contributor"` / `"editor"` / `"owner"`). Note this is **lossy** for
/// permission sets that don't match a bundle exactly — but D-Prep's
/// pre-flight refuses to migrate any such cluster, so post-migration data
/// only contains bundle-shaped sets.
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
"admin"
} else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) {
let has_read = perms.contains(&Permission::Read);
let has_comment = perms.contains(&Permission::Comment);
let has_create = perms.contains(&Permission::Create);
let has_update = perms.contains(&Permission::Update);
let has_delete = perms.contains(&Permission::Delete);
let has_share = perms.contains(&Permission::Share);
if has_delete && has_share {
"owner"
} else if has_create && has_update {
"editor"
} else if has_read && has_create && !has_update {
"contributor"
} else if has_read && has_comment && !has_create && !has_update {
"commenter"
} else {
"viewer"
}
@@ -457,7 +486,12 @@ pub struct OutgoingResourceGrantDto {
pub subject_id: Uuid,
/// Human-readable label (username for users, share name for tokens).
pub subject_display: String,
/// Derived role label: `"viewer"` | `"editor"` | `"admin"`.
/// Role label: `"viewer"` | `"commenter"` | `"contributor"` | `"editor"`
/// | `"owner"`. Emitted by `role_from_permissions()` during the dual-write
/// window; once D-Prep cleanup lands this is read directly from
/// `storage.role_grants.role`. The legacy `"admin"` spelling is no longer
/// emitted — clients that cached it must accept `"owner"` too (the API
/// `Role::parse` still accepts `"admin"` on input for one release).
pub role: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
+2
View File
@@ -10,9 +10,11 @@ pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
pub mod folder_listing_dto;
pub mod geo_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod people_dto;
pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
+30
View File
@@ -0,0 +1,30 @@
//! DTOs for the People (faces) API.
use serde::Serialize;
use utoipa::ToSchema;
/// A named (or unnamed) identity cluster, with a cover photo for its tile.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PersonDto {
pub id: String,
/// `None` until the user names the person.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// File id of the cover face's photo, for the tile thumbnail.
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_file_id: Option<String>,
pub face_count: i64,
pub is_hidden: bool,
}
/// One face box within a photo (for tagging overlays in the lightbox).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FaceBoxDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub person_id: Option<String>,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
+36 -32
View File
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
ResourceKind, Subject,
ResourceKind, Role, Subject,
};
pub trait AuthorizationEngine: Send + Sync + 'static {
@@ -90,11 +90,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// Resources explicitly granted to `subject`. Direct grants only — no
/// cascade expansion. Used by `GET /api/grants/incoming`.
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError>;
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError>;
/// Cursor-paginated list of resources explicitly granted to `subject`,
/// optionally filtered by resource kind. Multiple permission rows for the
@@ -139,38 +135,20 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint; if the row already exists its `expires_at` is updated.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Update `expires_at` on every grant row for the given subject.
/// Used when a share's expiry is changed — one call updates all
/// permission rows for that token in a single UPDATE.
/// Update `expires_at` for every role grant belonging to `subject`.
/// Used by `share_service` when a token-share's expiry is refreshed —
/// the subject (token) maps to a small fixed set of role grants, so a
/// single UPDATE covers them. Resource-scoped expiry changes go through
/// `set_role` (which carries `expires_at` as part of its UPSERT).
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Update `expires_at` on every grant row for the given `(subject, resource)`
/// pair. Used by `set_role` to sync the expiry of retained grants when the
/// caller changes expiry without changing permissions.
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
/// whether or not the row existed. The id comes from a prior listing
/// or `find_grant_full_by_id` lookup.
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
/// Removes every grant whose `resource` matches. Called by lifecycle
@@ -181,4 +159,30 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// Removes every grant whose `subject` matches. Called when a user/token
/// /group is deleted. Returns the count of rows removed.
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
// ── Role-keyed grant operations ────────────────────────────────────────
// These are the only grant write path. Lifecycle hook bulk-deletes
// (`revoke_all_for_*` above) wipe matching rows directly, so callers
// using those paths don't need to invoke `clear_role` separately.
/// Set the role for a `(subject, resource)` pair. Idempotent via the
/// UNIQUE `(subject_type, subject_id, resource_type, resource_id)`
/// constraint — `ON CONFLICT` updates the role + expires_at if they
/// changed, which is exactly the right semantics for an atomic role
/// change (e.g. promoting Viewer → Editor in one UPDATE with no race
/// window, no DELETE+INSERT).
async fn set_role(
&self,
granted_by: Uuid,
subject: Subject,
role: Role,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Remove the role for a `(subject, resource)` pair. Idempotent —
/// succeeds whether or not the row existed. Called after `revoke`
/// succeeds to keep the two tables in sync during dual-write; after
/// cleanup this is the canonical role-revocation entry point.
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>;
}
+4 -1
View File
@@ -112,7 +112,10 @@ pub trait DedupPort: Send + Sync + 'static {
/// Remove a reference from a blob.
///
/// Returns `true` if the blob was deleted (ref_count reached 0).
/// Returns `true` if the last reference was removed (the content is now
/// unreferenced). For CDC content the now-orphaned chunks are reclaimed
/// later by garbage collection rather than unlinked inline; legacy
/// whole-file blobs are still freed eagerly.
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
/// Calculate BLAKE3 hash of a file (streaming).
+78
View File
@@ -0,0 +1,78 @@
//! Ports for the People (faces) feature.
use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{DetectedFace, Face, Person};
/// Detects faces in an image and produces an aligned, L2-normalized embedding
/// for each. Takes raw encoded bytes (it decodes internally) so the
/// application layer stays decoupled from any image/ML crate.
///
/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer))
/// is a no-op that reports `is_ready() == false`; a real ONNX-backed
/// implementation is wired in when the operator provides models at runtime.
#[async_trait]
pub trait FaceAnalyzerPort: Send + Sync + 'static {
/// Whether a usable model is loaded. When false, indexing is skipped.
fn is_ready(&self) -> bool;
/// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…).
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError>;
}
/// Persistence for faces and persons. Every method is user-scoped; the
/// repository enforces `WHERE user_id = …` so callers only ever touch their
/// own biometric data.
#[async_trait]
pub trait FaceRepository: Send + Sync + 'static {
// ── faces ──────────────────────────────────────────────────────
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
/// Faces previously computed for any file sharing this content hash —
/// lets indexing reuse results for deduplicated (identical) uploads.
async fn faces_for_blob(
&self,
user_id: Uuid,
blob_hash: &str,
) -> Result<Vec<Face>, DomainError>;
async fn assign_person(
&self,
face_id: Uuid,
person_id: Option<Uuid>,
) -> Result<(), DomainError>;
// ── persons ────────────────────────────────────────────────────
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
async fn rename_person(
&self,
user_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError>;
async fn set_person_cover(
&self,
person_id: Uuid,
cover_face_id: Uuid,
) -> Result<(), DomainError>;
async fn set_person_hidden(
&self,
user_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError>;
/// File ids that contain a face assigned to this person (most recent first).
async fn files_for_person(
&self,
user_id: Uuid,
person_id: Uuid,
) -> Result<Vec<Uuid>, DomainError>;
/// Hard-delete every face and person for a user (right to erasure /
/// disabling the feature).
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>;
}
+1
View File
@@ -10,6 +10,7 @@ pub mod compression_ports;
pub mod content_index_ports;
pub mod dedup_ports;
pub mod email_sender;
pub mod face_ports;
pub mod favorites_ports;
pub mod file_lifecycle;
pub mod file_ports;
@@ -1388,7 +1388,7 @@ impl AuthApplicationService {
/// Visibility rule, evaluated top-to-bottom:
/// 1. **Self lookup** — `caller_id == target_id` always succeeds.
/// 2. **Shared-grant relationship** — caller and target appear
/// together on at least one row of `storage.access_grants`,
/// together on at least one row of `storage.role_grants`,
/// either direction (caller-as-granter / target-as-subject,
/// or target-as-granter / caller-as-subject). Applies to both
/// internal and external callers. This is what lets an
@@ -1454,7 +1454,7 @@ impl AuthApplicationService {
let related: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants
FROM storage.role_grants
WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2)
OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1)
LIMIT 1
@@ -250,6 +250,17 @@ impl FileRetrievalService {
let stream = self.file_read.get_file_stream(id).await?;
Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream))))
}
/// Batch counterpart of [`FileRetrievalUseCase::get_file`]: resolve many
/// file ids in ONE query instead of one per id. Like `get_file` it
/// performs no per-file authorization — both current callers (ACL grant
/// listing, NextCloud favorites REPORT) resolve ids already vetted by the
/// authorization engine or the favorites table. Missing or trashed ids are
/// absent from the result; callers re-associate by `id`.
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<FileDto>, DomainError> {
let files = self.file_read.get_files_by_ids(ids).await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
}
impl FileRetrievalUseCase for FileRetrievalService {
@@ -29,6 +29,17 @@ impl FolderService {
}
}
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
/// query instead of one per id. Like `get_folder` it performs no
/// per-folder authorization — both current callers (ACL grant listing,
/// NextCloud favorites REPORT) resolve ids already vetted by the
/// authorization engine or the favorites table. Missing or trashed ids
/// are absent from the result; callers re-associate by `id`.
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<FolderDto>, DomainError> {
let folders = self.folder_storage.get_folders_by_ids(ids).await?;
Ok(folders.into_iter().map(FolderDto::from).collect())
}
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
/// `DomainError::not_found` on parse error (anti-enumeration — the same
/// error as "folder does not exist").
+2
View File
@@ -20,6 +20,8 @@ pub mod magic_link_invite_service;
pub mod music_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
pub mod people_service;
pub mod places_service;
pub mod recent_service;
pub mod recipient_notification_service;
pub mod search_service;
+271
View File
@@ -0,0 +1,271 @@
//! People (faces) use cases: identity clustering + the read/mutation methods
//! the HTTP layer calls.
//!
//! Clustering is a full re-cluster over the user's faces: a union-find groups
//! faces whose embeddings are within a cosine threshold (connected
//! components), and groups of at least `min_faces` become a "person". This is
//! O(n²) in the user's face count — fine for moderate libraries; an ANN index
//! (pgvector/VectorChord) is the documented scale-up.
//!
//! Strictly user-scoped (the repository filters by user), so — like
//! `RecentService` / `PlacesService` — no `AuthorizationEngine` check is
//! needed: the `caller_id` parameter is the access scope.
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto};
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::Person;
use crate::infrastructure::repositories::pg::FacePgRepository;
/// Cosine similarity of two equal-length vectors. Embeddings are produced
/// L2-normalized, so this is ~a dot product; we normalize anyway for safety.
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
for (&x, &y) in a.iter().zip(b.iter()) {
dot += x * y;
na += x * x;
nb += y * y;
}
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na.sqrt() * nb.sqrt())
}
/// Disjoint-set with path-halving + union by rank.
struct UnionFind {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl UnionFind {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, mut x: usize) -> usize {
while self.parent[x] != x {
self.parent[x] = self.parent[self.parent[x]];
x = self.parent[x];
}
x
}
fn union(&mut self, a: usize, b: usize) {
let (ra, rb) = (self.find(a), self.find(b));
if ra == rb {
return;
}
match self.rank[ra].cmp(&self.rank[rb]) {
std::cmp::Ordering::Less => self.parent[ra] = rb,
std::cmp::Ordering::Greater => self.parent[rb] = ra,
std::cmp::Ordering::Equal => {
self.parent[rb] = ra;
self.rank[ra] += 1;
}
}
}
}
pub struct PeopleService {
repo: Arc<FacePgRepository>,
/// Min cosine similarity to link two faces into the same identity.
cluster_threshold: f32,
/// Min faces in a cluster before it becomes a named-able "person".
min_faces: usize,
}
impl PeopleService {
pub fn new(repo: Arc<FacePgRepository>) -> Self {
Self {
repo,
cluster_threshold: 0.5,
min_faces: 3,
}
}
/// Re-cluster a user's faces. Returns the number of new persons created.
pub async fn recluster(&self, user_id: Uuid) -> Result<usize, DomainError> {
let faces = self.repo.faces_for_user(user_id).await?;
let n = faces.len();
if n == 0 {
return Ok(0);
}
let mut uf = UnionFind::new(n);
for i in 0..n {
for j in (i + 1)..n {
if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold {
uf.union(i, j);
}
}
}
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
let root = uf.find(i);
groups.entry(root).or_default().push(i);
}
let mut created = 0usize;
for idxs in groups.into_values() {
if idxs.len() < self.min_faces {
// Too small to be a person — leave/reset these faces unassigned.
for &i in &idxs {
if faces[i].person_id.is_some() {
self.repo.assign_person(faces[i].id, None).await?;
}
}
continue;
}
// Reuse an existing person on this cluster (preserves a user's name)
// or mint a new one.
let existing = idxs.iter().find_map(|&i| faces[i].person_id);
let person_id = match existing {
Some(pid) => pid,
None => {
let pid = Uuid::new_v4();
let person = Person {
id: pid,
user_id,
display_name: None,
cover_face_id: Some(faces[idxs[0]].id),
is_hidden: false,
created_at: Utc::now(),
};
self.repo.create_person(&person).await?;
created += 1;
pid
}
};
for &i in &idxs {
if faces[i].person_id != Some(person_id) {
self.repo
.assign_person(faces[i].id, Some(person_id))
.await?;
}
}
let _ = self
.repo
.set_person_cover(person_id, faces[idxs[0]].id)
.await;
}
Ok(created)
}
/// People (non-empty clusters), most-photographed first.
pub async fn list_people(&self, caller_id: Uuid) -> Result<Vec<PersonDto>, DomainError> {
let persons = self.repo.persons_for_user(caller_id).await?;
let faces = self.repo.faces_for_user(caller_id).await?;
let mut count: HashMap<Uuid, i64> = HashMap::new();
let mut face_file: HashMap<Uuid, Uuid> = HashMap::new();
for f in &faces {
if let Some(pid) = f.person_id {
*count.entry(pid).or_default() += 1;
}
face_file.insert(f.id, f.file_id);
}
let mut out: Vec<PersonDto> = persons
.into_iter()
.filter_map(|p| {
let c = count.get(&p.id).copied().unwrap_or(0);
if c == 0 {
return None; // hide empty clusters (e.g. after a merge)
}
let cover_file_id = p
.cover_face_id
.and_then(|fid| face_file.get(&fid).copied())
.map(|u| u.to_string());
Some(PersonDto {
id: p.id.to_string(),
name: p.display_name,
cover_file_id,
face_count: c,
is_hidden: p.is_hidden,
})
})
.collect();
out.sort_by(|a, b| b.face_count.cmp(&a.face_count));
Ok(out)
}
/// File ids of a person's photos (most recent first).
pub async fn person_photos(
&self,
caller_id: Uuid,
person_id: Uuid,
) -> Result<Vec<String>, DomainError> {
let files = self.repo.files_for_person(caller_id, person_id).await?;
Ok(files.into_iter().map(|u| u.to_string()).collect())
}
/// Face boxes within a photo (for lightbox tagging), caller-scoped.
pub async fn faces_for_file(
&self,
caller_id: Uuid,
file_id: Uuid,
) -> Result<Vec<FaceBoxDto>, DomainError> {
let faces = self.repo.faces_for_file(file_id).await?;
Ok(faces
.into_iter()
.filter(|f| f.user_id == caller_id)
.map(|f| FaceBoxDto {
id: f.id.to_string(),
person_id: f.person_id.map(|u| u.to_string()),
x: f.bbox.x,
y: f.bbox.y,
w: f.bbox.w,
h: f.bbox.h,
})
.collect())
}
pub async fn rename_person(
&self,
caller_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError> {
self.repo.rename_person(caller_id, person_id, name).await
}
pub async fn set_hidden(
&self,
caller_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError> {
self.repo
.set_person_hidden(caller_id, person_id, hidden)
.await
}
/// Merge `from` into `into` by reassigning all of `from`'s faces. The
/// now-empty `from` person is hidden by `list_people`.
pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> {
let faces = self.repo.faces_for_user(caller_id).await?;
for f in faces.into_iter().filter(|f| f.person_id == Some(from)) {
self.repo.assign_person(f.id, Some(into)).await?;
}
Ok(())
}
/// Erase all of the caller's face data (right to erasure / opt-out).
pub async fn delete_all(&self, caller_id: Uuid) -> Result<(), DomainError> {
self.repo.delete_all_for_user(caller_id).await
}
}
@@ -0,0 +1,45 @@
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
use crate::common::errors::DomainError;
use crate::infrastructure::repositories::pg::FileBlobReadRepository;
/// "Places" use case: the caller's geotagged photos aggregated into map
/// clusters.
///
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
/// like [`RecentService`](super::recent_service::RecentService) and the photos
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
/// parameter *is* the access scope.
pub struct PlacesService {
file_read: Arc<FileBlobReadRepository>,
}
impl PlacesService {
pub fn new(file_read: Arc<FileBlobReadRepository>) -> Self {
Self { file_read }
}
/// Aggregation cell side, in degrees, for a slippy-map zoom level. The
/// world (360°) is split into `2^zoom` tiles; we use ~4 cells per tile so
/// clusters refine as the user zooms in. Clamped to a sane range.
fn cell_for_zoom(zoom: u8) -> f64 {
let z = i32::from(zoom.min(20));
360.0 / (2_f64.powi(z) * 4.0)
}
/// Clustered geotagged photos for `caller_id` within `bounds`.
pub async fn clusters(
&self,
caller_id: Uuid,
bounds: GeoBounds,
zoom: u8,
) -> Result<Vec<GeoCluster>, DomainError> {
let cell = Self::cell_for_zoom(zoom);
self.file_read
.list_geo_clusters(caller_id, bounds, cell)
.await
}
}
+6 -6
View File
@@ -5,7 +5,7 @@ use tokio::sync::Semaphore;
use uuid::Uuid;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::authorization::{Resource, Role, Subject};
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -254,9 +254,9 @@ impl ShareUseCase for ShareService {
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Create one Read-only grant for the token subject, carrying expires_at.
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
// cleans up this grant when the share is later deleted.
// Anonymous link tokens always get the Viewer role (read-only).
// The `trg_cleanup_grants_token` trigger cleans up this grant when
// the share row is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
@@ -267,10 +267,10 @@ impl ShareUseCase for ShareService {
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
.grant(
.set_role(
user_id,
Subject::Token(saved_share.id()),
Permission::Read,
Role::Viewer,
resource,
expires_dt,
)
@@ -5,7 +5,7 @@
//! - Name validation runs (defence-in-depth alongside the DB CHECK).
//! - Virtual groups (e.g. `Internal`) are protected from mutation.
//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`.
//! - Cascading delete of `storage.access_grants` rows referencing this
//! - Cascading delete of `storage.role_grants` rows referencing this
//! group runs in the same transaction as the group delete.
//!
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
@@ -172,9 +172,9 @@ impl SubjectGroupService {
/// Delete the group; cascades to:
/// - `auth.subject_group_members` rows (FK CASCADE).
/// - `storage.access_grants` rows where `subject_type='group'` and
/// - `storage.role_grants` rows where `subject_type='group'` and
/// `subject_id = id` (handled here, no FK exists between
/// `access_grants` and `subject_groups`).
/// `role_grants` and `subject_groups`).
pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> {
let existing = self.get_by_id(id).await?;
if existing.is_virtual {
@@ -196,7 +196,7 @@ impl SubjectGroupService {
})?;
let grants_deleted = sqlx::query(
"DELETE FROM storage.access_grants
"DELETE FROM storage.role_grants
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(id)
@@ -519,9 +519,9 @@ mod integration_tests {
// ── 13. Grants are revoked atomically when a group is deleted ──────────
//
// The plan said "FK CASCADE", but there's no FK between `access_grants`
// and `subject_groups` (different schemas; the cascade is handled by the
// service's transactional DELETE). This test pins that behaviour.
// There is no FK between `storage.role_grants` and `auth.subject_groups`
// (different schemas); the cascade is handled by the service's
// transactional DELETE. This test pins that behaviour.
#[tokio::test]
async fn test_grants_revoked_when_group_deleted() {
let svc = make_service().await;
@@ -534,21 +534,21 @@ mod integration_tests {
.unwrap();
let resource_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO storage.access_grants \
"INSERT INTO storage.role_grants \
(subject_type, subject_id, resource_type, resource_id, \
permission, granted_by) \
VALUES ('group', $1, 'folder', $2, 'read', $3)",
role, granted_by) \
VALUES ('group', $1, 'folder', $2, 'viewer', $3)",
)
.bind(group.id)
.bind(resource_id)
.bind(admin)
.execute(svc.pool.as_ref())
.await
.expect("insert grant row");
.expect("insert role_grants row");
// Sanity: the grant exists.
let pre: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
@@ -561,7 +561,7 @@ mod integration_tests {
svc.delete(group.id, admin).await.unwrap();
let post: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
+8 -5
View File
@@ -568,9 +568,11 @@ impl TrashUseCase for TrashService {
}
}
Err(e) => {
// Check if the file is not found - in that case, we can continue
// because we still want to remove the item from the trash index
if format!("{}", e).contains("not found") {
// File already gone — still remove the trash index
// entry. Match on the typed error kind, not the
// message text, so a reworded message can't
// silently turn this into a hard failure.
if e.kind == ErrorKind::NotFound {
info!(
"File not found, may already have been deleted: {}",
file_id
@@ -608,8 +610,9 @@ impl TrashUseCase for TrashService {
info!("Successfully deleted folder permanently: {}", folder_id);
}
Err(e) => {
// Check if the folder is not found - in that case, we can continue
if format!("{}", e).contains("not found") {
// Folder already gone — still remove the trash
// index entry. Typed-kind match (see file branch).
if e.kind == ErrorKind::NotFound {
info!(
"Folder not found, may already have been deleted: {}",
folder_id
+756
View File
@@ -0,0 +1,756 @@
//! `load-seed` — bulk-seeds OxiCloud's test database for K6 load scenarios.
//!
//! Inserts users, a deep folder tree, files (all sharing one dedup'd blob),
//! nested subject groups, and ReBAC grants directly via sqlx — bypassing the
//! REST API so the seed phase is fast and doesn't pollute measured metrics.
//!
//! Only the resources each k6 scenario actively touches (the grant being
//! created, the move target, etc.) go through the HTTP API at run time.
//!
//! Run (from repo root, against the test DB on port 5433):
//!
//! ```bash
//! DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
//! cargo run --bin load-seed -- \
//! --depth 8 --fanout 3 --files-per-leaf 3 \
//! --extra-users 20 --group-depth 3 --group-fanout 5 \
//! --manifest tests/load/results/seed-manifest.json
//! ```
//!
//! Output: writes a JSON manifest at `--manifest` listing the IDs and
//! credentials each k6 scenario needs (admin login, grantee user, deep folder
//! IDs, group chain, etc.). K6 scenarios load this manifest at startup.
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
use rand_core::OsRng;
use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
// ── CLI ──────────────────────────────────────────────────────────────────
struct Args {
depth: u32,
fanout: u32,
files_per_leaf: u32,
extra_users: u32,
group_depth: u32,
group_fanout: u32,
manifest: PathBuf,
password: String,
}
impl Args {
fn parse() -> Self {
// Defaults match tests/load/test.env; both bound the exponential cost
// of fanout^depth. Override via flags or LOAD_* env vars.
let mut depth = 5;
let mut fanout = 4;
let mut files_per_leaf = 3;
let mut extra_users = 20;
let mut group_depth = 3;
let mut group_fanout = 5;
let mut manifest = PathBuf::from("tests/load/results/seed-manifest.json");
let mut password = "TestPassword1!".to_string();
let argv: Vec<String> = env::args().collect();
let mut i = 1;
while i < argv.len() {
let key = argv[i].as_str();
let val = || {
argv.get(i + 1)
.unwrap_or_else(|| panic!("missing value for {}", key))
};
match key {
"--depth" => depth = val().parse().expect("--depth must be u32"),
"--fanout" => fanout = val().parse().expect("--fanout must be u32"),
"--files-per-leaf" => {
files_per_leaf = val().parse().expect("--files-per-leaf must be u32");
}
"--extra-users" => {
extra_users = val().parse().expect("--extra-users must be u32");
}
"--group-depth" => {
group_depth = val().parse().expect("--group-depth must be u32");
}
"--group-fanout" => {
group_fanout = val().parse().expect("--group-fanout must be u32");
}
"--manifest" => manifest = PathBuf::from(val()),
"--password" => password = val().clone(),
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => panic!("unknown argument: {}", other),
}
i += 2;
}
Self {
depth,
fanout,
files_per_leaf,
extra_users,
group_depth,
group_fanout,
manifest,
password,
}
}
}
fn print_help() {
println!("load-seed — bulk-seed OxiCloud for k6 load tests");
println!();
println!("Reads DATABASE_URL from env. Inserts users, folders, files, groups, grants.");
println!("Writes a JSON manifest the k6 scenarios consume.");
println!();
println!("Flags (with defaults):");
println!(" --depth 5 folder tree depth (exponential: fanout^depth folders)");
println!(" --fanout 4 children per non-leaf folder");
println!(" --files-per-leaf 3 files inserted in each leaf folder");
println!(" --extra-users 20 non-admin users created");
println!(" --group-depth 3 nested subject-group chain length");
println!(" --group-fanout 5 users added directly to each leaf group");
println!(" --manifest <path> output manifest JSON path");
println!(" --password <pw> shared password for all seeded users");
}
// ── Manifest (consumed by k6 scenarios) ──────────────────────────────────
#[derive(Serialize)]
struct Manifest {
admin: UserCreds,
grantee: UserCreds,
group_member: UserCreds,
shared_subtree: SubtreeIds,
group_subtree: SubtreeIds,
nested_groups: NestedGroupIds,
/// Total folders seeded (informational).
total_folders: u64,
/// Total files seeded (informational).
total_files: u64,
}
#[derive(Serialize)]
struct UserCreds {
id: Uuid,
username: String,
password: String,
}
#[derive(Serialize)]
struct SubtreeIds {
/// Root of the subtree the grant is attached to.
root: Uuid,
/// A folder at depth 4 inside the subtree (for mid-depth fetches).
depth4: Uuid,
/// A folder at depth 8 inside the subtree (for mid-depth fetches).
depth8: Uuid,
/// A folder at depth N (== `--depth`) inside the subtree (for deep fetches).
deepest: Uuid,
}
#[derive(Serialize)]
struct NestedGroupIds {
/// Outermost group — the one referenced by the grant. Contains `mid` as a member.
root: Uuid,
/// Intermediate group — member of `root`, contains `leaf` as a member.
mid: Uuid,
/// Innermost group — member of `mid`, contains `group_member` user as a direct member.
leaf: Uuid,
}
// ── Main ─────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Best-effort: load .env if present (mirrors the rest of the codebase).
let _ = dotenvy::dotenv();
let args = Args::parse();
let database_url = env::var("DATABASE_URL").map_err(|_| {
"DATABASE_URL must be set (e.g. postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test)"
})?;
println!(
"[load-seed] connecting to {}",
sanitize_url_for_log(&database_url)
);
let pool = PgPool::connect(&database_url).await?;
println!("[load-seed] wiping previous load-test data…");
wipe(&pool).await?;
println!("[load-seed] hashing shared password…");
let password_hash = hash_password(&args.password);
println!(
"[load-seed] inserting users (1 admin + {})…",
args.extra_users
);
let admin = insert_user(&pool, "load_admin", "admin", &password_hash).await?;
let extra_users = insert_extra_users(&pool, args.extra_users, &password_hash).await?;
// Pick a grantee (user[0]) and a group member (user[1]).
let grantee = extra_users
.first()
.ok_or("need at least one extra user for grantee")?
.clone();
let group_member = extra_users
.get(1)
.ok_or("need at least two extra users")?
.clone();
println!("[load-seed] inserting shared blob…");
let blob_hash = insert_shared_blob(&pool).await?;
println!(
"[load-seed] building folder tree (depth={}, fanout={})…",
args.depth, args.fanout
);
// Two parallel subtrees off root: one for the user-grant scenario, one for
// the group-grant scenario. Each has its own depth/fanout shape so a single
// grant cascades over a known fixed number of descendants.
let shared_subtree =
build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?;
let group_subtree =
build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?;
let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64;
let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len();
println!(
"[load-seed] inserting files (files_per_leaf={}, leaves={})…",
args.files_per_leaf, total_leaves
);
let total_files = insert_files(
&pool,
admin.id,
&blob_hash,
&shared_subtree.leaves,
&group_subtree.leaves,
args.files_per_leaf,
)
.await?;
println!(
"[load-seed] building nested group chain (depth={}, fanout={})…",
args.group_depth, args.group_fanout
);
let nested_groups = build_group_chain(
&pool,
admin.id,
&extra_users,
&group_member.id,
args.group_depth,
args.group_fanout,
)
.await?;
println!("[load-seed] inserting grants…");
// Grant the grantee the viewer role on shared_subtree.root — the read
// bundle the share_cascade_rebac scenario exercises.
insert_grant(
&pool,
"user",
grantee.id,
"folder",
shared_subtree.root,
"viewer",
admin.id,
)
.await?;
// Grant the outermost group the viewer role on group_subtree.root.
insert_grant(
&pool,
"group",
nested_groups.root,
"folder",
group_subtree.root,
"viewer",
admin.id,
)
.await?;
let manifest = Manifest {
admin: UserCreds {
id: admin.id,
username: admin.username,
password: args.password.clone(),
},
grantee: UserCreds {
id: grantee.id,
username: grantee.username,
password: args.password.clone(),
},
group_member: UserCreds {
id: group_member.id,
username: group_member.username,
password: args.password.clone(),
},
shared_subtree: SubtreeIds {
root: shared_subtree.root,
depth4: shared_subtree.depth4,
depth8: shared_subtree.depth8,
deepest: shared_subtree.deepest,
},
group_subtree: SubtreeIds {
root: group_subtree.root,
depth4: group_subtree.depth4,
depth8: group_subtree.depth8,
deepest: group_subtree.deepest,
},
nested_groups,
total_folders,
total_files,
};
if let Some(parent) = args.manifest.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&manifest)?;
fs::write(&args.manifest, json)?;
println!(
"[load-seed] manifest written → {} ({} folders, {} files)",
args.manifest.display(),
total_folders,
total_files
);
Ok(())
}
// ── Wipe ─────────────────────────────────────────────────────────────────
async fn wipe(pool: &PgPool) -> Result<(), sqlx::Error> {
// Users starting with `load_` are the only ones this seeder ever creates.
// CASCADE on auth.users → storage.folders / storage.files / grants / group
// memberships, so a single DELETE wipes everything load-test-related.
sqlx::query("DELETE FROM auth.users WHERE username LIKE 'load_%'")
.execute(pool)
.await?;
// Groups starting with `load_` aren't owned by users; clear them explicitly.
sqlx::query("DELETE FROM auth.subject_groups WHERE name LIKE 'load_%'")
.execute(pool)
.await?;
// The shared dedup blob is identified by its fixed hash sentinel below.
sqlx::query("DELETE FROM storage.blobs WHERE hash = $1")
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(())
}
// ── Password ─────────────────────────────────────────────────────────────
/// Light Argon2id parameters — same shape as the password_hasher test path
/// (`src/infrastructure/services/password_hasher.rs::test_hasher`).
/// Production hashes verify regardless of m/t/p because Argon2 reads the
/// parameters back from the hash string itself.
fn hash_password(password: &str) -> String {
let params = Params::new(16384, 1, 1, None).expect("valid argon2 params");
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(password.as_bytes(), &salt)
.expect("argon2 hash")
.to_string()
}
// ── Users ────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct SeededUser {
id: Uuid,
username: String,
}
async fn insert_user(
pool: &PgPool,
username: &str,
role: &str,
password_hash: &str,
) -> Result<SeededUser, sqlx::Error> {
let email = format!("{}@load.test.invalid", username);
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4::auth.userrole)
RETURNING id",
)
.bind(username)
.bind(email)
.bind(password_hash)
.bind(role)
.fetch_one(pool)
.await?;
Ok(SeededUser {
id: row.0,
username: username.to_string(),
})
}
async fn insert_extra_users(
pool: &PgPool,
count: u32,
password_hash: &str,
) -> Result<Vec<SeededUser>, sqlx::Error> {
if count == 0 {
return Ok(Vec::new());
}
let usernames: Vec<String> = (0..count).map(|i| format!("load_user_{:04}", i)).collect();
let emails: Vec<String> = usernames
.iter()
.map(|u| format!("{}@load.test.invalid", u))
.collect();
// Bulk insert via UNNEST — one round-trip for all N users.
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"INSERT INTO auth.users (username, email, password_hash, role)
SELECT u.username, u.email, $1::text, 'user'::auth.userrole
FROM UNNEST($2::text[], $3::text[]) AS u(username, email)
RETURNING id, username",
)
.bind(password_hash)
.bind(&usernames)
.bind(&emails)
.fetch_all(pool)
.await?;
// RETURNING order is implementation-defined — sort by username so the
// caller can rely on `extra_users[0]` being `load_user_0000`.
let mut users: Vec<SeededUser> = rows
.into_iter()
.map(|(id, username)| SeededUser { id, username })
.collect();
users.sort_by(|a, b| a.username.cmp(&b.username));
Ok(users)
}
// ── Blob ─────────────────────────────────────────────────────────────────
/// All seeded files share this single zero-byte blob. Its ref_count is bumped
/// in lockstep with file inserts; the file_metadata schema doesn't require a
/// real on-disk blob for the dedup index to be consistent.
const SHARED_BLOB_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
async fn insert_shared_blob(pool: &PgPool) -> Result<String, sqlx::Error> {
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, 0, 0, 'application/octet-stream')
ON CONFLICT (hash) DO NOTHING",
)
.bind(SHARED_BLOB_HASH)
.execute(pool)
.await?;
Ok(SHARED_BLOB_HASH.to_string())
}
// ── Folder tree ──────────────────────────────────────────────────────────
struct Subtree {
root: Uuid,
depth4: Uuid,
depth8: Uuid,
deepest: Uuid,
all_ids: Vec<Uuid>,
leaves: Vec<Uuid>,
}
/// Build a folder tree under `parent=NULL` rooted at `root_name`.
///
/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from
/// the already-committed parent rows. Returns the root, a depth-4 sample, the
/// deepest leaf, the full id set, and the leaf ids (used for file seeding).
async fn build_subtree(
pool: &PgPool,
user_id: Uuid,
root_name: &str,
depth: u32,
fanout: u32,
) -> Result<Subtree, sqlx::Error> {
// Per-row BEFORE INSERT trigger (compute_folder_path) makes each level cost
// O(parents × fanout); past depth 6 with fanout 5 the seed time grows fast.
// Print the count BEFORE each level so it's clear which level is in flight
// and how many rows are about to be inserted.
let predicted: u64 = (0..=depth).map(|l| (fanout as u64).pow(l)).sum();
println!(
" subtree '{}': ~{} folders predicted",
root_name, predicted
);
// Level 0 — the root folder.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
RETURNING id",
)
.bind(root_name)
.bind(user_id)
.fetch_one(pool)
.await?;
let root_id = root.0;
let mut all_ids = vec![root_id];
let mut current_level: Vec<Uuid> = vec![root_id];
let mut depth4: Option<Uuid> = None;
let mut depth8: Option<Uuid> = None;
let mut deepest: Uuid = root_id;
for level in 1..=depth {
// Build the (parent_id, name) pairs for this level.
let parents: Vec<Uuid> = current_level
.iter()
.flat_map(|p| std::iter::repeat_n(*p, fanout as usize))
.collect();
let names: Vec<String> = current_level
.iter()
.enumerate()
.flat_map(|(pi, _)| (0..fanout).map(move |c| format!("l{}_{}_{}", level, pi, c)))
.collect();
let started = std::time::Instant::now();
println!(
" level {}/{}: inserting {} folders…",
level,
depth,
parents.len()
);
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
SELECT f.name, f.parent_id, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
RETURNING id",
)
.bind(user_id)
.bind(&parents)
.bind(&names)
.fetch_all(pool)
.await?;
let level_ids: Vec<Uuid> = rows.into_iter().map(|(id,)| id).collect();
println!(
" level {}/{}: done in {:.1}s",
level,
depth,
started.elapsed().as_secs_f64()
);
if level == 4 {
depth4 = level_ids.first().copied();
}
if level == 8 {
depth8 = level_ids.first().copied();
}
if level == depth {
deepest = *level_ids.first().expect("non-empty deepest level");
}
all_ids.extend(&level_ids);
current_level = level_ids;
}
let depth4 = depth4.unwrap_or(deepest);
let depth8 = depth8.unwrap_or(deepest);
let leaves = current_level;
Ok(Subtree {
root: root_id,
depth4,
depth8,
deepest,
all_ids,
leaves,
})
}
// ── Files ────────────────────────────────────────────────────────────────
async fn insert_files(
pool: &PgPool,
user_id: Uuid,
blob_hash: &str,
shared_leaves: &[Uuid],
group_leaves: &[Uuid],
files_per_leaf: u32,
) -> Result<u64, sqlx::Error> {
if files_per_leaf == 0 {
return Ok(0);
}
let all_leaves: Vec<Uuid> = shared_leaves
.iter()
.chain(group_leaves.iter())
.copied()
.collect();
if all_leaves.is_empty() {
return Ok(0);
}
// Build (folder_id, name) pairs.
let folder_ids: Vec<Uuid> = all_leaves
.iter()
.flat_map(|f| std::iter::repeat_n(*f, files_per_leaf as usize))
.collect();
let names: Vec<String> = (0..all_leaves.len())
.flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi)))
.collect();
sqlx::query(
"INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)",
)
.bind(user_id)
.bind(blob_hash)
.bind(&folder_ids)
.bind(&names)
.execute(pool)
.await?;
let total = folder_ids.len() as i64;
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + $1 WHERE hash = $2")
.bind(total)
.bind(blob_hash)
.execute(pool)
.await?;
Ok(total as u64)
}
// ── Subject groups ───────────────────────────────────────────────────────
async fn build_group_chain(
pool: &PgPool,
admin_id: Uuid,
extra_users: &[SeededUser],
group_member_user_id: &Uuid,
depth: u32,
fanout: u32,
) -> Result<NestedGroupIds, Box<dyn std::error::Error>> {
if depth < 2 {
return Err(format!("--group-depth must be ≥ 2 (got {})", depth).into());
}
// Create `depth` groups: g_0 (outermost) → g_1 → … → g_{depth-1} (innermost).
// Insert one row per group; CITEXT name must match the RFC-5321 regex,
// and the `load_` prefix lets the wipe step find them again.
let salt: String = Uuid::new_v4().simple().to_string();
let names: Vec<String> = (0..depth)
.map(|i| format!("load_g_{}_{}", salt, i))
.collect();
let mut group_ids: HashMap<u32, Uuid> = HashMap::with_capacity(depth as usize);
for (i, name) in names.iter().enumerate() {
let row: (Uuid,) =
sqlx::query_as("INSERT INTO auth.subject_groups (name) VALUES ($1) RETURNING id")
.bind(name)
.fetch_one(pool)
.await?;
group_ids.insert(i as u32, row.0);
}
// Wire the chain: g_i contains g_{i+1} as a member (XOR: member_group_id set).
for i in 0..(depth - 1) {
let parent = group_ids[&i];
let child = group_ids[&(i + 1)];
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
VALUES ($1, NULL, $2, $3)",
)
.bind(parent)
.bind(child)
.bind(admin_id)
.execute(pool)
.await?;
}
// Innermost group gets `fanout` direct user members, ensuring the
// group_member sentinel user is always among them.
let leaf = group_ids[&(depth - 1)];
let user_ids: Vec<Uuid> = {
let mut v = vec![*group_member_user_id];
for u in extra_users.iter().filter(|u| u.id != *group_member_user_id) {
if v.len() as u32 >= fanout {
break;
}
v.push(u.id);
}
v
};
let leafs: Vec<Uuid> = std::iter::repeat_n(leaf, user_ids.len()).collect();
let added_by_col: Vec<Uuid> = std::iter::repeat_n(admin_id, user_ids.len()).collect();
sqlx::query(
"INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by)
SELECT g.group_id, g.member_user_id, NULL, g.added_by
FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[]) AS g(group_id, member_user_id, added_by)",
)
.bind(&leafs)
.bind(&user_ids)
.bind(&added_by_col)
.execute(pool)
.await?;
Ok(NestedGroupIds {
root: group_ids[&0],
mid: group_ids[&(depth / 2)],
leaf,
})
}
// ── Grants ───────────────────────────────────────────────────────────────
async fn insert_grant(
pool: &PgPool,
subject_type: &str,
subject_id: Uuid,
resource_type: &str,
resource_id: Uuid,
role: &str,
granted_by: Uuid,
) -> Result<(), sqlx::Error> {
// D-Prep replaced `storage.access_grants` (one row per Permission) with
// `storage.role_grants` (one row per role assignment; the role expands to
// a permission bundle in-code at engine read time). The seeder now writes
// role names ('viewer'/'editor'/etc.) instead of individual permissions.
//
// The `role` column was promoted from TEXT to the `storage.grant_role`
// enum by migration 20260801000000_role_grants_enum, so the cast on $5
// is required — sqlx binds Rust &str as TEXT, which postgres won't
// implicitly coerce into the enum.
sqlx::query(
"INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING",
)
.bind(subject_type)
.bind(subject_id)
.bind(resource_type)
.bind(resource_id)
.bind(role)
.bind(granted_by)
.execute(pool)
.await?;
Ok(())
}
// ── Utilities ────────────────────────────────────────────────────────────
fn sanitize_url_for_log(url: &str) -> String {
// Strip "user:password@" from the URL so logs don't leak credentials.
if let Some(at) = url.find('@')
&& let Some(scheme_end) = url.find("://")
{
let scheme = &url[..scheme_end + 3];
let rest = &url[at..];
return format!("{}***{}", scheme, rest);
}
url.to_string()
}
+106
View File
@@ -879,6 +879,11 @@ pub struct FeaturesConfig {
pub enable_trash: bool,
pub enable_search: bool,
pub enable_music: bool,
/// Lists the user's geotagged photos on a map (GET /api/photos/geo).
pub enable_places: bool,
/// Face detection + identity clustering for the photo library ("People").
/// Biometric data — OFF by default; opt-in per deployment/user.
pub enable_faces: bool,
/// Expose other OxiCloud users as a read-only "system" address book
/// at GET /api/address-books. Set to false to hide the user directory.
pub expose_system_users: bool,
@@ -893,11 +898,60 @@ impl Default for FeaturesConfig {
enable_trash: true, // Enable trash feature
enable_search: true, // Enable search feature
enable_music: true, // Enable music feature
enable_places: true, // Photo map (GET /api/photos/geo + Places tab)
enable_faces: false, // People/faces (biometric) — opt-in, off by default
expose_system_users: true, // Expose OxiCloud users as address book by default
}
}
}
/// Face-recognition (People) model configuration.
///
/// Only consulted when the `faces-onnx` cargo feature is compiled in *and*
/// [`FeaturesConfig::enable_faces`] is true; otherwise the inert
/// `NoopFaceAnalyzer` is used regardless of these values. The ONNX Runtime
/// dylib and both model files are operator-provided at runtime (never
/// committed) — when any is unset or fails to load, the People pipeline
/// silently falls back to the no-op analyzer and the server still boots.
#[derive(Debug, Clone)]
pub struct FacesConfig {
/// `libonnxruntime.{so,dylib,dll}`. Falls back to the `ORT_DYLIB_PATH`
/// environment variable when unset. Env: `OXICLOUD_FACES_ORT_DYLIB`.
pub ort_dylib: Option<PathBuf>,
/// SCRFD/RetinaFace detector model with 5-point landmarks.
/// Env: `OXICLOUD_FACES_DETECTOR_MODEL`.
pub detector_model: Option<PathBuf>,
/// ArcFace embedder model (112×112 → 512-d).
/// Env: `OXICLOUD_FACES_EMBEDDER_MODEL`.
pub embedder_model: Option<PathBuf>,
/// Detector square input size in pixels (default 640).
/// Env: `OXICLOUD_FACES_DET_SIZE`.
pub det_size: u32,
/// Minimum detector confidence to keep a face (default 0.5).
/// Env: `OXICLOUD_FACES_DET_THRESHOLD`.
pub det_threshold: f32,
/// IoU threshold for non-max suppression (default 0.4).
/// Env: `OXICLOUD_FACES_NMS_THRESHOLD`.
pub nms_threshold: f32,
/// ONNX Runtime intra-op threads (0 = let ORT decide).
/// Env: `OXICLOUD_FACES_INTRA_THREADS`.
pub intra_threads: usize,
}
impl Default for FacesConfig {
fn default() -> Self {
Self {
ort_dylib: None,
detector_model: None,
embedder_model: None,
det_size: 640,
det_threshold: 0.5,
nms_threshold: 0.4,
intra_threads: 0,
}
}
}
/// Content-search configuration (embedded Tantivy index over file names and
/// extracted file content).
///
@@ -1063,6 +1117,8 @@ pub struct AppConfig {
pub content_search: ContentSearchConfig,
/// WASM plugin runtime configuration
pub plugins: PluginConfig,
/// Face-recognition (People) model configuration
pub faces: FacesConfig,
}
/// Server-side i18n knobs.
@@ -1116,6 +1172,7 @@ impl Default for AppConfig {
i18n: I18nConfig::default(),
content_search: ContentSearchConfig::default(),
plugins: PluginConfig::default(),
faces: FacesConfig::default(),
}
}
}
@@ -1378,6 +1435,55 @@ impl AppConfig {
config.features.enable_music = val;
}
if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_places
{
config.features.enable_places = val;
}
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_faces
{
config.features.enable_faces = val;
}
// Faces (People) ONNX runtime + models — operator-provided at runtime.
if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH"))
&& !v.is_empty()
{
config.faces.ort_dylib = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_DETECTOR_MODEL")
&& !v.is_empty()
{
config.faces.detector_model = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_EMBEDDER_MODEL")
&& !v.is_empty()
{
config.faces.embedder_model = Some(PathBuf::from(v));
}
if let Ok(v) = env::var("OXICLOUD_FACES_DET_SIZE").map(|v| v.parse::<u32>())
&& let Ok(val) = v
{
config.faces.det_size = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_DET_THRESHOLD").map(|v| v.parse::<f32>())
&& let Ok(val) = v
{
config.faces.det_threshold = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_NMS_THRESHOLD").map(|v| v.parse::<f32>())
&& let Ok(val) = v
{
config.faces.nms_threshold = val;
}
if let Ok(v) = env::var("OXICLOUD_FACES_INTRA_THREADS").map(|v| v.parse::<usize>())
&& let Ok(val) = v
{
config.faces.intra_threads = val;
}
// Content search (embedded Tantivy index)
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
&& let Ok(val) = v
+112
View File
@@ -17,6 +17,8 @@ use crate::application::services::folder_service::FolderService;
use crate::application::services::i18n_application_service::I18nApplicationService;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
use crate::application::services::people_service::PeopleService;
use crate::application::services::places_service::PlacesService;
use crate::application::services::recent_service::RecentService;
use crate::application::services::search_service::SearchService;
use crate::application::services::share_browse_service::ShareBrowseService;
@@ -359,6 +361,9 @@ impl AppServiceFactory {
fls = fls.with_hook(audio.clone());
}
fls = fls.with_hook(media_metadata_service.clone());
if self.config.features.enable_faces {
fls = fls.with_hook(self.create_face_indexing_service(db_pool));
}
let file_lifecycle = Arc::new(fls);
Ok(CoreServices {
@@ -798,6 +803,95 @@ impl AppServiceFactory {
service
}
/// Creates the Places (photo map) service. Reuses the existing file-read
/// repository — the data is the caller's own geotagged photos.
pub fn create_places_service(
&self,
file_read: &Arc<FileBlobReadRepository>,
) -> Arc<PlacesService> {
let service = Arc::new(PlacesService::new(file_read.clone()));
tracing::info!("Places service initialized");
service
}
/// Creates the face-indexing lifecycle hook (People feature). Picks the
/// real ONNX analyzer when the `faces-onnx` feature is compiled in and the
/// operator has configured the runtime + models; otherwise the inert no-op
/// analyzer (see [`Self::build_face_analyzer`]).
pub fn create_face_indexing_service(
&self,
db_pool: &Arc<PgPool>,
) -> Arc<crate::infrastructure::services::face_indexing_service::FaceIndexingService> {
let blob_root = self.storage_path.join(".blobs");
let analyzer = self.build_face_analyzer();
Arc::new(
crate::infrastructure::services::face_indexing_service::FaceIndexingService::new(
db_pool.clone(),
blob_root,
analyzer,
),
)
}
/// Selects the face analyzer. With the `faces-onnx` feature and a fully
/// configured runtime + models, loads the real ONNX analyzer; any missing
/// piece or load failure degrades gracefully to the no-op analyzer (logged)
/// so startup never fails on biometric configuration.
fn build_face_analyzer(
&self,
) -> Arc<dyn crate::application::ports::face_ports::FaceAnalyzerPort> {
#[cfg(feature = "faces-onnx")]
{
let f = &self.config.faces;
if let (Some(dylib), Some(detector), Some(embedder)) = (
f.ort_dylib.as_ref(),
f.detector_model.as_ref(),
f.embedder_model.as_ref(),
) {
use crate::infrastructure::services::onnx_face_analyzer::{
OnnxFaceAnalyzer, OnnxLoadConfig,
};
let cfg = OnnxLoadConfig {
dylib,
detector,
embedder,
det_size: f.det_size,
det_threshold: f.det_threshold,
nms_threshold: f.nms_threshold,
intra_threads: f.intra_threads,
};
match OnnxFaceAnalyzer::load(&cfg) {
Ok(analyzer) => {
tracing::info!("Face analyzer: ONNX models loaded");
return Arc::new(analyzer);
}
Err(e) => {
tracing::warn!(
"Face analyzer: failed to load ONNX models ({e}); \
falling back to no-op analyzer"
);
}
}
} else {
tracing::info!(
"Face analyzer: faces-onnx compiled but runtime/models not fully \
configured; using no-op analyzer"
);
}
}
Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer)
}
/// Creates the People (faces) read/clustering service.
pub fn create_people_service(&self, db_pool: &Arc<PgPool>) -> Arc<PeopleService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::FacePgRepository::new(db_pool.clone()),
);
let service = Arc::new(PeopleService::new(repo));
tracing::info!("People service initialized");
service
}
/// Preloads translations for every locale in the registry. Build
/// the registry at startup via `LocaleRegistry::discover` and pass
/// the resulting list here.
@@ -1005,6 +1099,8 @@ impl AppServiceFactory {
// 6. Database-dependent services (PgPool always available in blob model)
let favorites_service: Option<Arc<FavoritesService>>;
let recent_service: Option<Arc<RecentService>>;
let places_service: Option<Arc<PlacesService>>;
let people_service: Option<Arc<PeopleService>>;
let storage_usage_service: Option<Arc<StorageUsageService>>;
let mut auth_services: Option<crate::common::di::AuthServices> = None;
let mut nextcloud_services: Option<NextcloudServices> = None;
@@ -1027,6 +1123,18 @@ impl AppServiceFactory {
recent_service = Some(recent.clone());
apps.recent_service = Some(recent);
places_service = if core.config.features.enable_places {
Some(self.create_places_service(&repos.file_read_repository))
} else {
None
};
people_service = if core.config.features.enable_faces {
Some(self.create_people_service(&pool))
} else {
None
};
storage_usage_service = Some(storage_usage.clone());
self.start_tree_etag_flush_job(&maintenance_pool);
@@ -1253,6 +1361,8 @@ impl AppServiceFactory {
share_browse_service,
favorites_service,
recent_service,
places_service,
people_service,
storage_usage_service,
calendar_service: None,
contact_service: None,
@@ -1699,6 +1809,8 @@ pub struct AppState {
pub share_browse_service: Option<Arc<ShareBrowseService>>,
pub favorites_service: Option<Arc<FavoritesService>>,
pub recent_service: Option<Arc<RecentService>>,
pub places_service: Option<Arc<PlacesService>>,
pub people_service: Option<Arc<PeopleService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
pub calendar_service: Option<Arc<CalendarService>>,
pub contact_service: Option<Arc<ContactStorageAdapter>>,
+72
View File
@@ -0,0 +1,72 @@
//! Domain entities for the People (faces) feature.
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// Length of a face embedding vector (ArcFace-style).
pub const EMBEDDING_DIM: usize = 512;
/// A face bounding box in normalized image coordinates (each component 0..1).
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl BoundingBox {
/// `[x, y, w, h]` — the storage representation (Postgres `REAL[]`).
pub fn to_array(self) -> Vec<f32> {
vec![self.x, self.y, self.w, self.h]
}
/// Build from a stored `[x, y, w, h]` array; missing components default to 0.
pub fn from_slice(a: &[f32]) -> Self {
Self {
x: a.first().copied().unwrap_or(0.0),
y: a.get(1).copied().unwrap_or(0.0),
w: a.get(2).copied().unwrap_or(0.0),
h: a.get(3).copied().unwrap_or(0.0),
}
}
}
/// A face produced by the analyzer but not yet persisted: where it is, how
/// confident the detector was, an optional quality score, and a 512-d,
/// L2-normalized embedding.
#[derive(Debug, Clone)]
pub struct DetectedFace {
pub bbox: BoundingBox,
pub det_score: f32,
pub quality: Option<f32>,
pub embedding: Vec<f32>,
}
/// A persisted face detection.
#[derive(Debug, Clone)]
pub struct Face {
pub id: Uuid,
pub file_id: Uuid,
pub user_id: Uuid,
/// Identity cluster this face belongs to, if any.
pub person_id: Option<Uuid>,
pub bbox: BoundingBox,
pub det_score: f32,
pub quality: Option<f32>,
pub embedding: Vec<f32>,
pub blob_hash: Option<String>,
pub created_at: DateTime<Utc>,
}
/// An identity cluster ("person"). `display_name` is `None` until the user
/// names it.
#[derive(Debug, Clone)]
pub struct Person {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub cover_face_id: Option<Uuid>,
pub is_hidden: bool,
pub created_at: DateTime<Utc>,
}
+1
View File
@@ -4,6 +4,7 @@ pub mod calendar_event;
pub mod contact;
pub mod device_code;
pub mod entity_errors;
pub mod face;
pub mod file;
pub mod folder;
pub mod magic_link_token;
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct Share {
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
/// Derived from `storage.role_grants.expires_at` — not stored on the share row.
expires_at: Option<u64>,
created_at: u64,
created_by: Uuid,
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Subject groups are root-owned (no `owner_id`), globally named with an
//! RFC 5321 local-part shape, and able to contain users *or* other groups.
//! Grants in `storage.access_grants` with `subject_type = 'group'` reference
//! Grants in `storage.role_grants` with `subject_type = 'group'` reference
//! a row in `auth.subject_groups`.
//!
//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the
@@ -93,8 +93,8 @@ pub trait SubjectGroupRepository: Send + Sync + 'static {
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
/// Delete the group. Cascades to `subject_group_members` and to
/// `storage.access_grants` rows referencing this group as subject (via
/// the application service — there is no FK between `access_grants` and
/// `storage.role_grants` rows referencing this group as subject (via
/// the application service — there is no FK between `role_grants` and
/// `subject_groups`, so the service performs the cascade explicitly in
/// the same transaction).
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>;
+139 -6
View File
@@ -3,7 +3,7 @@
//! These types are storage-agnostic — they describe the relationship between
//! a subject (who), a resource (what), and a permission (action). The
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
//! maps them to / from `storage.access_grants` rows.
//! maps them to / from `storage.role_grants` rows.
use crate::application::dtos::cursor::PageCursor;
use std::fmt;
@@ -49,7 +49,7 @@ impl Subject {
/// `"external"` is no longer accepted: PR-2 of the external-users
/// work folded the federated-identity case into `Subject::User(uuid)`
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
/// on `storage.access_grants.subject_type` was narrowed to match.
/// on `storage.role_grants.subject_type` was narrowed to match.
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
match subject_type {
"user" => Some(Subject::User(id)),
@@ -140,18 +140,29 @@ pub enum Permission {
Delete,
/// Modify the resource (rename, move, edit content).
Update,
/// Configure the resource's settings, add/remove members, change role
/// assignments. Used by:
/// - Drive owners managing drive membership and policies.
/// - Group owners managing the group itself (Group-as-Resource, future).
///
/// Folder and file resources do not currently surface a `Manage` check;
/// the permission lives in the enum because the role bundle (`Owner`)
/// includes it, and the resource types that DO check it (`Drive`,
/// `Group`) are added in subsequent PRs (see `docs/plan/drive.md`).
Manage,
}
impl Permission {
/// Every permission, in a stable order. Used by `Role::expand()` and SQL
/// `permission = ANY(...)` lookups.
pub const ALL: [Permission; 6] = [
pub const ALL: [Permission; 7] = [
Permission::Read,
Permission::Create,
Permission::Share,
Permission::Comment,
Permission::Delete,
Permission::Update,
Permission::Manage,
];
pub fn as_str(&self) -> &'static str {
@@ -162,6 +173,7 @@ impl Permission {
Permission::Comment => "comment",
Permission::Delete => "delete",
Permission::Update => "update",
Permission::Manage => "manage",
}
}
@@ -175,6 +187,7 @@ impl Permission {
"comment" => Some(Permission::Comment),
"delete" => Some(Permission::Delete),
"update" => Some(Permission::Update),
"manage" => Some(Permission::Manage),
_ => None,
}
}
@@ -187,7 +200,7 @@ impl fmt::Display for Permission {
}
// ════════════════════════════════════════════════════════════════════════════
// Grant — a row in storage.access_grants
// Grant — a row in storage.role_grants
// ════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Debug)]
@@ -195,12 +208,132 @@ pub struct Grant {
pub id: Uuid,
pub subject: Subject,
pub resource: Resource,
pub permission: Permission,
/// Role-keyed since D-Prep cleanup: one `Grant` represents the role
/// row in `storage.role_grants` rather than a single permission. The
/// engine and HTTP surface no longer carry per-permission rows;
/// callers that need permissions use `role.expand()`.
pub role: Role,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ════════════════════════════════════════════════════════════════════════════
// Role — a named bundle of permissions
// ════════════════════════════════════════════════════════════════════════════
//
// Roles are the load-bearing model for ReBAC grants since D-Prep. Each
// `storage.role_grants` row stores one role; the engine expands the bundle
// at read time via `Role::expand()`. Adding a role is two edits:
// 1. a variant here + match arm in `expand()` / `as_str()` / `parse()`
// 2. an `ALTER TYPE storage.grant_role ADD VALUE 'name'` migration
//
// `RoleDto` (DTO layer) carries the wire-format derives + the legacy
// `"admin"` alias for backwards compat.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Role {
Viewer,
Commenter,
Contributor,
Editor,
Owner,
}
impl Role {
/// Expand the role into its permission bundle. Single source of truth —
/// any code that needs "does this role include Permission X?" routes
/// through here (or its inverse, `roles_implying`).
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Contributor => &[Permission::Read, Permission::Create],
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
Role::Owner => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
Permission::Manage,
],
}
}
/// Lowercase discriminator — matches the SQL `role` ENUM values in
/// `storage.role_grants` (after the `::text` cast).
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Commenter => "commenter",
Role::Contributor => "contributor",
Role::Editor => "editor",
Role::Owner => "owner",
}
}
/// Parse a role from its SQL discriminator. Returns `None` for unknown
/// values. The `"admin"` legacy alias is handled by `RoleDto` at the
/// wire boundary — the database only ever stores the canonical names.
pub fn parse(s: &str) -> Option<Self> {
match s {
"viewer" => Some(Role::Viewer),
"commenter" => Some(Role::Commenter),
"contributor" => Some(Role::Contributor),
"editor" => Some(Role::Editor),
"owner" => Some(Role::Owner),
_ => None,
}
}
/// Every role, in declaration order. Mirrors the `storage.grant_role`
/// ENUM order in PG, which is weakest-to-strongest as written here for
/// historical reasons (`storage.grant_role` declares strongest first).
pub const ALL: [Role; 5] = [
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
];
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Inverse of [`Role::expand`]: returns every role whose bundle contains
/// the given permission. Used by the engine to build the SQL
/// `WHERE role IN (...)` filter on hot-path queries like "what drives can
/// this caller read?".
pub fn roles_implying(permission: Permission) -> &'static [Role] {
use Permission::*;
match permission {
Read => &[
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
],
Comment => &[Role::Commenter, Role::Editor, Role::Owner],
Create => &[Role::Contributor, Role::Editor, Role::Owner],
Update => &[Role::Editor, Role::Owner],
Delete => &[Role::Owner],
Share => &[Role::Owner],
Manage => &[Role::Owner],
}
}
impl Grant {
pub fn is_expired(&self) -> bool {
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
@@ -212,7 +345,7 @@ impl Grant {
// ════════════════════════════════════════════════════════════════════════════
/// Resource type without an id — used to filter paginated grant queries by
/// type. Mirrors the `resource_type` column values in `storage.access_grants`.
/// type. Mirrors the `resource_type` column values in `storage.role_grants`.
/// Add new variants here when new resource types are supported.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ResourceKind {
@@ -0,0 +1,322 @@
//! PostgreSQL repository for the People (faces) feature.
//!
//! Embeddings are stored as `BYTEA` (512 × little-endian `f32`); there is no
//! pgvector dependency. Similarity search / clustering is done in-app over the
//! decoded vectors (see `PeopleService`).
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::face_ports::FaceRepository;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, Face, Person};
/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`).
type FaceRow = (
Uuid, // id
Uuid, // file_id
Uuid, // user_id
Option<Uuid>, // person_id
Vec<f32>, // bbox (REAL[])
f32, // det_score
Option<f32>, // quality
Vec<u8>, // embedding (BYTEA)
Option<String>, // blob_hash
DateTime<Utc>, // created_at
);
type PersonRow = (
Uuid, // id
Uuid, // user_id
Option<String>, // display_name
Option<Uuid>, // cover_face_id
bool, // is_hidden
DateTime<Utc>, // created_at
);
fn embedding_to_bytes(e: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(e.len() * 4);
for v in e {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
b.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
fn row_to_face(r: FaceRow) -> Face {
let (
id,
file_id,
user_id,
person_id,
bbox,
det_score,
quality,
embedding,
blob_hash,
created_at,
) = r;
Face {
id,
file_id,
user_id,
person_id,
bbox: BoundingBox::from_slice(&bbox),
det_score,
quality,
embedding: bytes_to_embedding(&embedding),
blob_hash,
created_at,
}
}
fn row_to_person(r: PersonRow) -> Person {
let (id, user_id, display_name, cover_face_id, is_hidden, created_at) = r;
Person {
id,
user_id,
display_name,
cover_face_id,
is_hidden,
created_at,
}
}
fn db_err(ctx: &'static str, e: sqlx::Error) -> DomainError {
DomainError::internal_error("FacePg", format!("{ctx}: {e}"))
}
const FACE_COLS: &str =
"id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at";
const PERSON_COLS: &str = "id, user_id, display_name, cover_face_id, is_hidden, created_at";
pub struct FacePgRepository {
pool: Arc<PgPool>,
}
impl FacePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl FaceRepository for FacePgRepository {
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError> {
if faces.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
for f in faces {
sqlx::query(
r#"
INSERT INTO faces.faces
(id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(f.id)
.bind(f.file_id)
.bind(f.user_id)
.bind(f.person_id)
.bind(f.bbox.to_array())
.bind(f.det_score)
.bind(f.quality)
.bind(embedding_to_bytes(&f.embedding))
.bind(f.blob_hash.as_deref())
.execute(&mut *tx)
.await
.map_err(|e| db_err("save_faces", e))?;
}
tx.commit().await.map_err(|e| db_err("commit", e))?;
Ok(())
}
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(file_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_file", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM faces.faces WHERE file_id = $1")
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("delete_faces_for_file", e))?;
Ok(())
}
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError> {
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_user", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn faces_for_blob(
&self,
user_id: Uuid,
blob_hash: &str,
) -> Result<Vec<Face>, DomainError> {
let sql =
format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1 AND blob_hash = $2");
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
.bind(user_id)
.bind(blob_hash)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("faces_for_blob", e))?;
Ok(rows.into_iter().map(row_to_face).collect())
}
async fn assign_person(
&self,
face_id: Uuid,
person_id: Option<Uuid>,
) -> Result<(), DomainError> {
sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1")
.bind(face_id)
.bind(person_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("assign_person", e))?;
Ok(())
}
async fn create_person(&self, person: &Person) -> Result<(), DomainError> {
sqlx::query(
r#"
INSERT INTO faces.persons (id, user_id, display_name, cover_face_id, is_hidden)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(person.id)
.bind(person.user_id)
.bind(person.display_name.as_deref())
.bind(person.cover_face_id)
.bind(person.is_hidden)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("create_person", e))?;
Ok(())
}
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError> {
let sql = format!(
"SELECT {PERSON_COLS} FROM faces.persons WHERE user_id = $1 ORDER BY created_at"
);
let rows: Vec<PersonRow> = sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("persons_for_user", e))?;
Ok(rows.into_iter().map(row_to_person).collect())
}
async fn rename_person(
&self,
user_id: Uuid,
person_id: Uuid,
name: Option<String>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET display_name = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
)
.bind(user_id)
.bind(person_id)
.bind(name)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("rename_person", e))?;
Ok(())
}
async fn set_person_cover(
&self,
person_id: Uuid,
cover_face_id: Uuid,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET cover_face_id = $2, updated_at = now() WHERE id = $1",
)
.bind(person_id)
.bind(cover_face_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("set_person_cover", e))?;
Ok(())
}
async fn set_person_hidden(
&self,
user_id: Uuid,
person_id: Uuid,
hidden: bool,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE faces.persons SET is_hidden = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
)
.bind(user_id)
.bind(person_id)
.bind(hidden)
.execute(self.pool.as_ref())
.await
.map_err(|e| db_err("set_person_hidden", e))?;
Ok(())
}
async fn files_for_person(
&self,
user_id: Uuid,
person_id: Uuid,
) -> Result<Vec<Uuid>, DomainError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
r#"
SELECT file_id
FROM faces.faces
WHERE user_id = $1 AND person_id = $2
GROUP BY file_id
ORDER BY max(created_at) DESC
"#,
)
.bind(user_id)
.bind(person_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| db_err("files_for_person", e))?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError> {
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
sqlx::query("DELETE FROM faces.faces WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| db_err("delete_all_faces", e))?;
sqlx::query("DELETE FROM faces.persons WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|e| db_err("delete_all_persons", e))?;
tx.commit().await.map_err(|e| db_err("commit", e))?;
Ok(())
}
}
@@ -20,6 +20,8 @@ type MediaFileRow = (
String, // blob_hash
Option<Uuid>, // user_id
i64, // sort_date
Option<i32>, // width
Option<i32>, // height
);
use bytes::Bytes;
@@ -30,6 +32,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError;
@@ -255,6 +258,52 @@ impl FileBlobReadRepository {
})
}
/// Batch-fetch files by id — the by-ids counterpart of [`get_file`],
/// used to resolve a page of ACL grants or favorites in ONE round-trip
/// instead of one query per id (the previous `join_all(ids.map(get_file))`
/// could fan out to ~200 concurrent pooled connections per page). Applies
/// the same `NOT is_trashed` filter and identical column mapping as
/// `get_file`. Ids that are missing or trashed simply drop out, so callers
/// must re-associate results by id; ordering is not guaranteed.
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<File>, DomainError> {
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
if uuid_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, FileRow>(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&uuid_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}"))
})?;
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
DomainError::internal_error(
"FileBlobRead",
format!("get_files_by_ids mapping: {e}"),
)
})
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
@@ -370,7 +419,7 @@ impl FileBlobReadRepository {
owner_id: Uuid,
before: Option<i64>,
limit: i64,
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
let rows: Vec<MediaFileRow> = sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -379,9 +428,11 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
WHERE fi.user_id = $1
AND NOT fi.is_trashed
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
@@ -400,15 +451,67 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?);
sort_dates.push(sd);
dims.push((w, h));
}
Ok((files, sort_dates))
Ok((files, sort_dates, dims))
}
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
/// Returns one cluster per non-empty cell with its centroid, photo count
/// and a representative photo id (for the cluster thumbnail).
pub async fn list_geo_clusters(
&self,
user_id: Uuid,
bounds: GeoBounds,
cell: f64,
) -> Result<Vec<GeoCluster>, DomainError> {
let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as(
r#"
SELECT count(*) AS n,
avg(fm.longitude) AS clng,
avg(fm.latitude) AS clat,
min(fm.file_id::text) AS sample_id
FROM storage.file_metadata fm
JOIN storage.files fi ON fi.id = fm.file_id
WHERE fi.user_id = $1
AND NOT fi.is_trashed
AND fm.latitude IS NOT NULL
AND fm.longitude IS NOT NULL
AND fm.longitude BETWEEN $2 AND $3
AND fm.latitude BETWEEN $4 AND $5
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
"#,
)
.bind(user_id)
.bind(bounds.west)
.bind(bounds.east)
.bind(bounds.south)
.bind(bounds.north)
.bind(cell)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}"))
})?;
Ok(rows
.into_iter()
.map(|(n, clng, clat, sample_id)| GeoCluster {
lng: clng,
lat: clat,
count: n,
sample_file_id: sample_id,
})
.collect())
}
}
@@ -109,6 +109,37 @@ impl FolderDbRepository {
)
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
}
/// Batch-fetch folders by id — the by-ids counterpart of `get_folder`,
/// resolving a page of ACL grants or favorites in ONE query instead of
/// one per id. Same `NOT is_trashed` filter and column mapping as
/// `get_folder`; missing or trashed ids drop out and callers re-associate
/// by id; ordering is not guaranteed.
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<Folder>, DomainError> {
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
if uuid_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE id = ANY($1) AND NOT is_trashed
"#,
)
.bind(&uuid_ids)
.fetch_all(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
rows.into_iter()
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
.collect()
}
}
impl FolderRepository for FolderDbRepository {
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
mod contact_persistence_dto;
mod contact_pg_repository;
mod device_code_pg_repository;
mod face_pg_repository;
mod favorites_pg_repository;
pub mod file_metadata_repository;
mod magic_link_token_pg_repository;
@@ -33,6 +34,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
pub use contact_persistence_dto::*;
pub use contact_pg_repository::ContactPgRepository;
pub use device_code_pg_repository::DeviceCodePgRepository;
pub use face_pg_repository::FacePgRepository;
pub use favorites_pg_repository::FavoritesPgRepository;
pub use file_blob_read_repository::FileBlobReadRepository;
pub use file_blob_write_repository::FileBlobWriteRepository;
@@ -38,7 +38,7 @@ impl SharePgRepository {
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
/// expires_at (derived from role_grants subquery), created_at, created_by, access_count.
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
let id: Uuid = row
.try_get("id")
@@ -54,7 +54,7 @@ impl SharePgRepository {
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
})?;
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
// expires_at derived from access_grants subquery (unix seconds as i64)
// expires_at derived from role_grants subquery (unix seconds as i64)
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
let created_at: i64 = row.try_get("created_at").map_err(|e| {
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
@@ -98,7 +98,7 @@ impl ShareStoragePort for SharePgRepository {
RETURNING
id, item_id, item_name, item_type, token, password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
created_at, created_by, access_count
"#,
@@ -127,7 +127,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -160,7 +160,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -218,7 +218,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count
FROM storage.shares s
@@ -250,7 +250,7 @@ impl ShareStoragePort for SharePgRepository {
RETURNING
id, item_id, item_name, item_type, token, password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
created_at, created_by, access_count
"#,
@@ -286,7 +286,7 @@ impl ShareStoragePort for SharePgRepository {
r#"
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
FROM storage.access_grants ag
FROM storage.role_grants ag
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
s.created_at, s.created_by, s.access_count,
COUNT(*) OVER() AS total_count
@@ -272,8 +272,8 @@ impl SubjectGroupRepository for SubjectGroupPgRepository {
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> {
// The application service is responsible for clearing related
// `storage.access_grants` rows in the same transaction (there's no
// FK between access_grants and subject_groups). The subject_group_members
// `storage.role_grants` rows in the same transaction (there's no
// FK between role_grants and subject_groups). The subject_group_members
// rows cascade automatically via FK.
let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
.bind(id)
+286 -51
View File
@@ -161,7 +161,9 @@ impl IngestGuard {
) {
if !pinned.is_empty()
&& let Err(e) = sqlx::query(
"UPDATE storage.blobs SET ref_count = GREATEST(ref_count - 1, 0)
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(&pinned)
@@ -190,8 +192,8 @@ impl IngestGuard {
);
}
if let Err(e) = sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 0 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO NOTHING",
)
.bind(&hashes)
@@ -380,6 +382,16 @@ impl DedupService {
/// ~9 MiB regardless of file size.
const FLUSH_MAX_BYTES: usize = 8 * 1024 * 1024;
/// Grace period (seconds) a blob must stay orphaned (`ref_count = 0`)
/// before [`garbage_collect`](Self::garbage_collect) may physically delete
/// it. Mirrors git's `gc.pruneExpire`: content that became unreferenced
/// only moments ago is never reaped, so a concurrent uploader about to pin
/// a just-orphaned chunk — or a delta-upload client that registered loose
/// chunks at `ref_count = 0` and is about to commit their manifest — cannot
/// race the sweep. Must comfortably exceed the longest plausible gap
/// between registering a chunk and referencing it (any in-flight upload).
const GC_ORPHAN_GRACE_SECS: i64 = 60 * 60; // 1 hour
/// Store content with CDC deduplication, straight from a byte stream —
/// the single write path for every upload surface (REST multipart,
/// WebDAV PUT, NextCloud PUT, chunked-upload assembly, WOPI PutFile).
@@ -738,8 +750,8 @@ impl DedupService {
let sizes: Vec<i64> = new_rows.iter().map(|(_, s)| *s).collect();
self.backend.sync_blobs(&hashes).await?;
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 0 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at)
SELECT h, s, 0, now() FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO NOTHING",
)
.bind(&hashes)
@@ -923,7 +935,7 @@ impl DedupService {
"INSERT INTO storage.blobs (hash, size, ref_count)
SELECT h, s, 1 FROM UNNEST($1::text[], $2::bigint[]) AS t(h, s)
ON CONFLICT (hash) DO UPDATE
SET ref_count = storage.blobs.ref_count + 1",
SET ref_count = storage.blobs.ref_count + 1, orphaned_at = NULL",
)
.bind(&new_hashes)
.bind(&new_sizes)
@@ -971,7 +983,7 @@ impl DedupService {
// session's reference NOW; hashes not returned don't exist and are
// ours to write.
let pinned: HashSet<String> = sqlx::query_scalar::<_, String>(
"UPDATE storage.blobs SET ref_count = ref_count + 1
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL
WHERE hash = ANY($1)
RETURNING hash",
)
@@ -1123,7 +1135,9 @@ impl DedupService {
// Legacy blob
let rows_affected =
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + 1 WHERE hash = $1")
sqlx::query(
"UPDATE storage.blobs SET ref_count = ref_count + 1, orphaned_at = NULL WHERE hash = $1",
)
.bind(hash)
.execute(self.pool.as_ref())
.await
@@ -1150,9 +1164,13 @@ impl DedupService {
///
/// For CDC manifests: decrements manifest ref_count. When it reaches 0
/// the manifest is deleted and all chunk ref_counts are decremented;
/// chunks that reach 0 are deleted from both PG and the blob backend.
/// chunks that reach 0 are left for [`garbage_collect`](Self::garbage_collect)
/// to reclaim once they have been orphaned past the grace window — unlinking
/// them here would race a concurrent upload re-referencing the same chunk.
///
/// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`.
/// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`. A legacy
/// whole-file hash can never be re-created by an ingest (uploads are always
/// CDC now), so its file is unlinked eagerly — there is no writer to race.
pub async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError> {
// ── CDC manifest path ────────────────────────────────────
let manifest = sqlx::query_as::<_, (i32, Vec<String>)>(
@@ -1173,7 +1191,12 @@ impl DedupService {
self.remove_legacy_reference(hash).await
}
/// Remove a manifest reference. Handles chunk cleanup when last ref is removed.
/// Remove a manifest reference. When the last reference is removed the
/// manifest is deleted and its chunks are dereferenced, but the chunk files
/// are NOT unlinked here: a chunk hash can be re-uploaded concurrently, so
/// unlinking right after the commit would race that re-reference (the same
/// TOCTOU the GC grace window guards). Newly-orphaned chunks are stamped and
/// reclaimed by [`garbage_collect`](Self::garbage_collect).
async fn remove_manifest_reference(
&self,
file_hash: &str,
@@ -1199,7 +1222,7 @@ impl DedupService {
};
if current_rc <= 1 {
// Last reference — delete manifest and decrement chunks
// Last reference — delete the manifest and dereference its chunks.
sqlx::query("DELETE FROM storage.chunk_manifests WHERE file_hash = $1")
.bind(file_hash)
.execute(&mut *tx)
@@ -1208,45 +1231,36 @@ impl DedupService {
DomainError::internal_error("Dedup", format!("Delete manifest: {}", e))
})?;
// Batch decrement chunk ref_counts
sqlx::query("UPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY($1)")
.bind(chunk_hashes)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e))
})?;
// Find chunks that reached 0
let zero_chunks: Vec<String> = sqlx::query_scalar(
"DELETE FROM storage.blobs WHERE hash = ANY($1) AND ref_count <= 0 RETURNING hash",
// Decrement chunk ref_counts and stamp orphaned_at on the ones that
// reach 0. We deliberately do NOT delete the chunk rows or unlink
// their files here: a chunk hash can be re-uploaded concurrently, so
// unlinking right after this commit would race that re-reference
// (the TOCTOU the grace window guards). garbage_collect() reclaims
// them safely once orphaned past the grace window. GREATEST clamps
// the single-chunk case where the PG file-delete trigger already
// decremented the row (file_hash == chunk_hash).
sqlx::query(
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
.fetch_all(&mut *tx)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("Dedup", format!("Delete zero chunks: {}", e))
})?;
.map_err(|e| DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e)))?;
tx.commit()
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("Commit: {}", e)))?;
// Delete blob files AFTER commit
for chunk_hash in &zero_chunks {
if let Err(e) = self.backend.delete_blob(chunk_hash).await {
tracing::warn!("Failed to delete chunk blob {}: {}", chunk_hash, e);
}
}
// Bug 4 fix: notify hooks — e.g. thumbnail cleanup keyed by file_hash
// File content is gone — drop its blob-keyed thumbnails now.
self.fire_blob_hooks(file_hash);
tracing::info!(
"MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)",
"MANIFEST DELETED: {} ({} chunks dereferenced; orphans reclaimed by GC)",
&file_hash[..12],
chunk_hashes.len(),
zero_chunks.len()
chunk_hashes.len()
);
Ok(true)
} else {
@@ -1810,9 +1824,13 @@ impl DedupService {
/// Garbage collect orphaned manifests and blobs.
///
/// Phase 1: Delete manifests with ref_count = 0, then decrement
/// chunk ref_counts for their chunks.
/// Phase 2: Delete blobs (chunks + legacy) with ref_count = 0.
/// Phase 1: Delete manifests with ref_count = 0 (or no referencing file),
/// then decrement chunk ref_counts for their chunks.
/// Phase 2: Delete blobs (chunks + legacy) that are unreferenced
/// (ref_count = 0), no longer listed by any manifest or file, and have
/// been orphaned for at least [`GC_ORPHAN_GRACE_SECS`](Self::GC_ORPHAN_GRACE_SECS).
/// The grace window and reference cross-checks together make the sweep safe
/// against a concurrent uploader re-referencing a just-orphaned chunk.
pub async fn garbage_collect(&self) -> Result<(u64, u64), DomainError> {
const BATCH_SIZE: i64 = 500;
@@ -1855,9 +1873,12 @@ impl DedupService {
// single-chunk file case where the PG file-delete trigger already
// decremented blobs.ref_count (because file_hash == chunk_hash);
// without the clamp this would underflow the CHECK constraint.
// Stamp orphaned_at so chunks freed here get the same GC grace
// window as any other newly-orphaned blob.
sqlx::query(
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0)
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
@@ -1880,17 +1901,44 @@ impl DedupService {
}
// ── Phase 2: GC orphaned blobs/chunks ────────────────────
// A blob row is collectible only when ALL of these hold:
// • ref_count <= 0, AND
// • it has been orphaned for at least GC_ORPHAN_GRACE_SECS (or has a
// NULL orphaned_at — a pre-migration row or a path that never
// stamped it; those are safe to take immediately), AND
// • no manifest still lists it as a chunk, AND
// • no file still points at it directly (legacy whole-file blob).
//
// The two NOT EXISTS guards mirror Phase 1's file cross-check: a stale
// ref_count = 0 on still-referenced content can then only delay
// collection, never delete live bytes. The grace window keeps a
// concurrent uploader that is about to pin a just-orphaned chunk from
// racing the row-delete → file-unlink gap (see GC_ORPHAN_GRACE_SECS).
// The ctid snapshot already protects against a pin that commits DURING
// the DELETE (the pin rewrites the row's ctid, so it drops out of the
// set); grace covers the remaining post-commit unlink window.
loop {
let batch: Vec<(String, i64)> = sqlx::query_as(
"DELETE FROM storage.blobs
WHERE ctid = ANY(
SELECT ctid FROM storage.blobs
WHERE ref_count <= 0
SELECT b.ctid FROM storage.blobs b
WHERE b.ref_count <= 0
AND (b.orphaned_at IS NULL
OR b.orphaned_at < now() - ($2::int * interval '1 second'))
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.chunk_hashes @> ARRAY[b.hash]
)
AND NOT EXISTS (
SELECT 1 FROM storage.files f
WHERE f.blob_hash = b.hash
)
LIMIT $1
)
RETURNING hash, size",
)
.bind(BATCH_SIZE)
.bind(Self::GC_ORPHAN_GRACE_SECS as i32)
.fetch_all(self.maintenance_pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("Dedup", format!("GC blobs: {e}")))?;
@@ -1898,15 +1946,33 @@ impl DedupService {
if batch.is_empty() {
break;
}
let n = batch.len();
for (hash, size) in &batch {
if let Err(e) = self.backend.delete_blob(hash).await {
tracing::warn!("Failed to delete orphan blob {hash}: {e}");
}
// The rows are already gone, so a concurrent re-upload of identical
// content recreates both row and file (durability before
// visibility); the grace window above keeps that race vanishingly
// narrow. Unlink the backing files with bounded fan-out so a large
// sweep doesn't serialise on a slow (e.g. S3) backend.
let backend = self.backend.clone();
let deleted: Vec<(String, i64)> = stream::iter(batch)
.map(|(hash, size)| {
let backend = backend.clone();
async move {
if let Err(e) = backend.delete_blob(&hash).await {
tracing::warn!("Failed to delete orphan blob {hash}: {e}");
}
(hash, size)
}
})
.buffer_unordered(Self::CHUNK_UPLOAD_CONCURRENCY)
.collect()
.await;
for (hash, size) in &deleted {
self.fire_blob_hooks(hash);
total_bytes += *size as u64;
}
total_deleted += batch.len() as u64;
total_deleted += n as u64;
tokio::task::yield_now().await;
}
@@ -2256,7 +2322,9 @@ impl DedupService {
return;
}
if let Err(e) = sqlx::query(
"UPDATE storage.blobs SET ref_count = GREATEST(ref_count - 1, 0)
"UPDATE storage.blobs
SET ref_count = GREATEST(ref_count - 1, 0),
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
WHERE hash = ANY($1)",
)
.bind(chunk_hashes)
@@ -3299,6 +3367,173 @@ mod delta_upload_integration_tests {
cleanup(&pool, &file_hash, file_id, &[fresh_hash]).await;
}
// ── Garbage collection: grace window + reference cross-checks ─
#[tokio::test]
async fn garbage_collect_honours_grace_window_and_references() {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
// (A) An aged orphan (orphaned well past the grace window) with no
// references → must be collected (row + backing file).
// (B) A freshly orphaned blob (orphaned_at = now()) → must survive: a
// concurrent uploader could still be about to pin it.
let aged = blake3::hash(format!("aged-{}", Uuid::new_v4()).as_bytes())
.to_hex()
.to_string();
let fresh = blake3::hash(format!("fresh-{}", Uuid::new_v4()).as_bytes())
.to_hex()
.to_string();
for h in [&aged, &fresh] {
svc.backend()
.put_blob_from_bytes_unsynced(h, Bytes::from_static(b"xyz"))
.await
.expect("write blob");
}
svc.backend()
.sync_blobs(&[aged.clone(), fresh.clone()])
.await
.expect("sync");
sqlx::query(
"INSERT INTO storage.blobs (hash, size, ref_count, orphaned_at) VALUES
($1, 3, 0, now() - interval '2 hours'),
($2, 3, 0, now())",
)
.bind(&aged)
.bind(&fresh)
.execute(pool.as_ref())
.await
.expect("seed orphans");
// (C) A chunk still listed by a live file's manifest, but whose
// blobs.ref_count has drifted to 0 and aged past the grace window.
// The manifest cross-check must keep it (and its bytes) alive — a
// stale ref_count must never delete referenced content.
let data = content(3 * 1024 * 1024, 71);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "gc").await;
let referenced = owned_chunks[0].clone();
sqlx::query(
"UPDATE storage.blobs
SET ref_count = 0, orphaned_at = now() - interval '2 hours'
WHERE hash = $1",
)
.bind(&referenced)
.execute(pool.as_ref())
.await
.expect("drift referenced chunk");
let (deleted, _bytes) = svc.garbage_collect().await.expect("gc");
assert!(deleted >= 1, "the aged orphan must be collected");
// Aged orphan fully gone.
assert!(
blob_ref(&pool, &aged).await.is_none(),
"aged orphan row removed"
);
assert!(
!svc.backend().blob_exists(&aged).await.unwrap(),
"aged orphan file unlinked"
);
// Fresh orphan preserved by the grace window.
assert_eq!(
blob_ref(&pool, &fresh).await,
Some(0),
"fresh orphan survives the grace window"
);
assert!(
svc.backend().blob_exists(&fresh).await.unwrap(),
"fresh orphan bytes kept"
);
// Referenced chunk preserved by the manifest cross-check despite ref 0.
assert_eq!(
blob_ref(&pool, &referenced).await,
Some(0),
"referenced chunk row kept"
);
assert!(
svc.backend().blob_exists(&referenced).await.unwrap(),
"referenced chunk bytes kept"
);
let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = ANY($1)")
.bind(vec![aged, fresh])
.execute(pool.as_ref())
.await;
cleanup(&pool, &file_hash, file_id, &[]).await;
}
// ── Manifest dereference defers chunk reclamation to GC ──────
#[tokio::test]
async fn manifest_dereference_defers_chunk_reclamation_to_gc() {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
// Single-owner multi-chunk CDC file → its chunks are uniquely owned.
let data = content(3 * 1024 * 1024, 91);
let (file_hash, chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "deref").await;
assert!(chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
// The delete_file_permanently sequence: drop the file row (PG trigger)
// then dereference the manifest.
sqlx::query("DELETE FROM storage.files WHERE id = $1")
.bind(file_id)
.execute(pool.as_ref())
.await
.expect("delete file row");
assert!(
svc.remove_reference(&file_hash).await.expect("deref"),
"last reference removed"
);
// Manifest is gone immediately…
let manifest_rc: Option<i32> = sqlx::query_scalar(
"SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1",
)
.bind(&file_hash)
.fetch_optional(pool.as_ref())
.await
.expect("manifest query");
assert!(manifest_rc.is_none(), "manifest deleted");
// …but the chunk rows + bytes survive at ref_count 0: no inline unlink
// that could race a concurrent re-upload of the same chunk.
for c in &chunks {
assert_eq!(
blob_ref(&pool, c).await,
Some(0),
"chunk dereferenced, not yet deleted"
);
assert!(
svc.backend().blob_exists(c).await.unwrap(),
"chunk bytes kept until GC reclaims them"
);
}
// Age the orphans past the grace window; GC then reclaims rows + files.
sqlx::query(
"UPDATE storage.blobs SET orphaned_at = now() - interval '2 hours' WHERE hash = ANY($1)",
)
.bind(&chunks)
.execute(pool.as_ref())
.await
.expect("age orphans");
svc.garbage_collect().await.expect("gc");
for c in &chunks {
assert!(blob_ref(&pool, c).await.is_none(), "chunk row reclaimed");
assert!(
!svc.backend().blob_exists(c).await.unwrap(),
"chunk file reclaimed"
);
}
cleanup(&pool, &file_hash, file_id, &[]).await;
}
// ── Verification read ────────────────────────────────────────
#[tokio::test]
async fn hash_chunk_sequence_recomputes_and_validates_sizes() {
@@ -0,0 +1,473 @@
//! Pure geometry + post-processing for the ONNX face pipeline.
//!
//! Everything here is plain Rust (no `ort`, no `ndarray`) so it compiles in the
//! default build and is exercised by `cargo test` — the error-prone numerical
//! parts (SCRFD anchor decode, NMS, 5-point similarity alignment, the affine
//! warp, normalization) are unit-tested in isolation, while the untestable ONNX
//! session calls live behind the `faces-onnx` feature in `onnx_face_analyzer`.
//!
//! The pipeline mirrors InsightFace's reference implementation:
//! SCRFD detector (distance-to-box anchors over strides 8/16/32) → 5-point
//! similarity transform onto the canonical 112×112 ArcFace template → ArcFace
//! embedder → L2-normalized 512-d vector.
use image::RgbImage;
/// One detected face in **detector-input pixel** coordinates (before scaling
/// back to the original image): an axis-aligned box `[x1, y1, x2, y2]`, the
/// five facial landmarks, and the detector confidence.
#[derive(Debug, Clone, Copy)]
pub struct Detection {
pub bbox: [f32; 4],
pub kps: [[f32; 2]; 5],
pub score: f32,
}
/// A 2×3 affine transform mapping an output/template coordinate to a source
/// coordinate: `src = (a·ox + b·oy + tx, c·ox + d·oy + ty)`. Used to sample the
/// source image when warping an aligned face crop.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine {
pub a: f32,
pub b: f32,
pub c: f32,
pub d: f32,
pub tx: f32,
pub ty: f32,
}
/// Canonical ArcFace 5-point template for a 112×112 crop
/// (left eye, right eye, nose, left mouth, right mouth).
pub const ARCFACE_TEMPLATE: [[f32; 2]; 5] = [
[38.2946, 51.6963],
[73.5318, 51.5014],
[56.0252, 71.7366],
[41.5493, 92.3655],
[70.7299, 92.2041],
];
/// Aligned-crop side length expected by the ArcFace embedder.
pub const ALIGN_SIZE: u32 = 112;
/// Letterbox geometry for the detector: the largest scale that fits a
/// `w0 × h0` image into a `det × det` square without distortion, plus the
/// resulting (possibly smaller) dimensions placed at the top-left.
///
/// Returns `(new_w, new_h, scale)` where `scale = min(det/w0, det/h0)` and
/// detector-space coordinates map back to the original by dividing by `scale`.
pub fn letterbox(w0: u32, h0: u32, det: u32) -> (u32, u32, f32) {
if w0 == 0 || h0 == 0 {
return (0, 0, 1.0);
}
let scale = (det as f32 / w0 as f32).min(det as f32 / h0 as f32);
let new_w = ((w0 as f32 * scale).round() as u32).clamp(1, det);
let new_h = ((h0 as f32 * scale).round() as u32).clamp(1, det);
(new_w, new_h, scale)
}
/// `NCHW`, RGB, float input tensor for an ONNX model: `(px − mean) · scale`,
/// channel-major (all R, then all G, then all B). Length is `3 · w · h`.
pub fn chw_normalized(img: &RgbImage, mean: f32, scale: f32) -> Vec<f32> {
let (w, h) = (img.width() as usize, img.height() as usize);
let mut out = vec![0.0f32; 3 * w * h];
let plane = w * h;
for (i, px) in img.pixels().enumerate() {
out[i] = (px[0] as f32 - mean) * scale;
out[plane + i] = (px[1] as f32 - mean) * scale;
out[2 * plane + i] = (px[2] as f32 - mean) * scale;
}
out
}
/// Decode one SCRFD feature-map stride into detections, appending those above
/// `threshold` to `out`. All coordinates are in detector-input pixels.
///
/// `scores` is `[n]`, `bbox` is `[n·4]` (left, top, right, bottom *distances*,
/// already multiplied by `stride`), `kps` (when present) is `[n·10]`
/// (5 × (dx, dy) distances, already multiplied by `stride`), where
/// `n = feat_h · feat_w · num_anchors`. Anchor centers follow InsightFace's
/// row-major `mgrid` order with `num_anchors` consecutive duplicates.
#[allow(clippy::too_many_arguments)]
pub fn decode_stride(
scores: &[f32],
bbox: &[f32],
kps: Option<&[f32]>,
stride: u32,
feat_h: u32,
feat_w: u32,
num_anchors: u32,
threshold: f32,
out: &mut Vec<Detection>,
) {
let stride_f = stride as f32;
let mut idx = 0usize;
for y in 0..feat_h {
for x in 0..feat_w {
let cx = x as f32 * stride_f;
let cy = y as f32 * stride_f;
for _ in 0..num_anchors {
if idx >= scores.len() {
return;
}
let score = scores[idx];
if score >= threshold {
let b = idx * 4;
if b + 3 < bbox.len() {
let det_bbox = [
cx - bbox[b],
cy - bbox[b + 1],
cx + bbox[b + 2],
cy + bbox[b + 3],
];
let mut det_kps = [[0.0f32; 2]; 5];
if let Some(kps) = kps {
let k = idx * 10;
if k + 9 < kps.len() {
for (p, slot) in det_kps.iter_mut().enumerate() {
*slot = [cx + kps[k + p * 2], cy + kps[k + p * 2 + 1]];
}
}
}
out.push(Detection {
bbox: det_bbox,
kps: det_kps,
score,
});
}
}
idx += 1;
}
}
}
}
/// Intersection-over-union of two `[x1, y1, x2, y2]` boxes.
pub fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
let x1 = a[0].max(b[0]);
let y1 = a[1].max(b[1]);
let x2 = a[2].min(b[2]);
let y2 = a[3].min(b[3]);
let iw = (x2 - x1).max(0.0);
let ih = (y2 - y1).max(0.0);
let inter = iw * ih;
let area_a = (a[2] - a[0]).max(0.0) * (a[3] - a[1]).max(0.0);
let area_b = (b[2] - b[0]).max(0.0) * (b[3] - b[1]).max(0.0);
let union = area_a + area_b - inter;
if union <= 0.0 { 0.0 } else { inter / union }
}
/// Greedy non-maximum suppression: keep highest-scoring boxes, drop any whose
/// IoU with an already-kept box exceeds `iou_thresh`. Returns the kept
/// detections, highest score first.
pub fn nms(mut dets: Vec<Detection>, iou_thresh: f32) -> Vec<Detection> {
dets.sort_by(|a, b| b.score.total_cmp(&a.score));
let mut keep: Vec<Detection> = Vec::with_capacity(dets.len());
for d in dets {
if keep.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_thresh) {
keep.push(d);
}
}
keep
}
/// Least-squares similarity transform (scale + rotation + translation, no
/// shear, no reflection) mapping `src` landmarks onto `dst`, returned as its
/// **inverse** affine (output/template coordinate → source coordinate) ready
/// for backward-warp sampling.
///
/// Solved in closed form via the complex-number formulation: with points as
/// complex numbers, `w = Σ (b'ᵢ · conj(a'ᵢ)) / Σ |a'ᵢ|²` and `t = mean_b −
/// w·mean_a`, which is equivalent to the Umeyama solution InsightFace obtains
/// from `skimage.SimilarityTransform`.
pub fn similarity_transform_inverse(src: &[[f32; 2]; 5], dst: &[[f32; 2]; 5]) -> Affine {
let n = 5.0f32;
let (mut max, mut may, mut mbx, mut mby) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
for i in 0..5 {
max += src[i][0];
may += src[i][1];
mbx += dst[i][0];
mby += dst[i][1];
}
max /= n;
may /= n;
mbx /= n;
mby /= n;
// num = Σ b'·conj(a') (complex), den = Σ |a'|² (real)
let (mut num_re, mut num_im, mut den) = (0.0f32, 0.0f32, 0.0f32);
for i in 0..5 {
let ax = src[i][0] - max;
let ay = src[i][1] - may;
let bx = dst[i][0] - mbx;
let by = dst[i][1] - mby;
// b' · conj(a') = (bx + i·by)(ax − i·ay)
num_re += bx * ax + by * ay;
num_im += by * ax - bx * ay;
den += ax * ax + ay * ay;
}
let den = if den.abs() < 1e-12 { 1e-12 } else { den };
// w = num/den (forward scale·rotation)
let wr = num_re / den;
let wi = num_im / den;
// t = mean_b − w·mean_a
let tr = mbx - (wr * max - wi * may);
let ti = mby - (wi * max + wr * may);
// Inverse of the similarity: src = Ainv·(out − t), Ainv = [[wr,wi],[−wi,wr]]/|w|²
let det = wr * wr + wi * wi;
let g = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
Affine {
a: g * wr,
b: g * wi,
c: -g * wi,
d: g * wr,
tx: -g * (wr * tr + wi * ti),
ty: g * (wi * tr - wr * ti),
}
}
/// Warp `img` into an `ALIGN_SIZE × ALIGN_SIZE` aligned face crop using the
/// inverse affine from [`similarity_transform_inverse`], sampling bilinearly
/// and clamping to the image edge.
pub fn warp_to_aligned(img: &RgbImage, inv: &Affine) -> RgbImage {
let (w, h) = (img.width(), img.height());
let mut out = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
for oy in 0..ALIGN_SIZE {
for ox in 0..ALIGN_SIZE {
let sx = inv.a * ox as f32 + inv.b * oy as f32 + inv.tx;
let sy = inv.c * ox as f32 + inv.d * oy as f32 + inv.ty;
let px = bilinear_sample(img, sx, sy, w, h);
out.put_pixel(ox, oy, px);
}
}
out
}
/// Bilinear RGB sample at floating `(x, y)`, clamping out-of-bounds reads to
/// the nearest edge.
fn bilinear_sample(img: &RgbImage, x: f32, y: f32, w: u32, h: u32) -> image::Rgb<u8> {
let x = x.clamp(0.0, (w - 1) as f32);
let y = y.clamp(0.0, (h - 1) as f32);
let x0 = x.floor() as u32;
let y0 = y.floor() as u32;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let dx = x - x0 as f32;
let dy = y - y0 as f32;
let p00 = img.get_pixel(x0, y0);
let p10 = img.get_pixel(x1, y0);
let p01 = img.get_pixel(x0, y1);
let p11 = img.get_pixel(x1, y1);
let mut out = [0u8; 3];
for (ch, slot) in out.iter_mut().enumerate() {
let top = p00[ch] as f32 * (1.0 - dx) + p10[ch] as f32 * dx;
let bot = p01[ch] as f32 * (1.0 - dx) + p11[ch] as f32 * dx;
*slot = (top * (1.0 - dy) + bot * dy).round().clamp(0.0, 255.0) as u8;
}
image::Rgb(out)
}
/// In-place L2 normalization. A zero vector is left unchanged.
pub fn l2_normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-12 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
/// Variance of the discrete Laplacian over the luminance of an RGB crop — a
/// cheap focus/sharpness proxy (higher = sharper). Used as a face quality
/// score for cover selection and gating.
pub fn laplacian_variance(img: &RgbImage) -> f32 {
let (w, h) = (img.width() as i64, img.height() as i64);
if w < 3 || h < 3 {
return 0.0;
}
let lum = |x: i64, y: i64| -> f32 {
let p = img.get_pixel(x as u32, y as u32);
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
};
let mut vals = Vec::with_capacity(((w - 2) * (h - 2)) as usize);
for y in 1..h - 1 {
for x in 1..w - 1 {
let l = 4.0 * lum(x, y) - lum(x - 1, y) - lum(x + 1, y) - lum(x, y - 1) - lum(x, y + 1);
vals.push(l);
}
}
let n = vals.len() as f32;
if n == 0.0 {
return 0.0;
}
let mean = vals.iter().sum::<f32>() / n;
vals.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letterbox_fits_and_preserves_aspect() {
// Landscape 1000×500 into 640 → width-bound, scale 0.64.
let (nw, nh, s) = letterbox(1000, 500, 640);
assert_eq!(nw, 640);
assert_eq!(nh, 320);
assert!((s - 0.64).abs() < 1e-6);
// Square fills exactly.
let (nw, nh, s) = letterbox(800, 800, 640);
assert_eq!((nw, nh), (640, 640));
assert!((s - 0.8).abs() < 1e-6);
}
#[test]
fn letterbox_degenerate_is_safe() {
assert_eq!(letterbox(0, 10, 640), (0, 0, 1.0));
}
#[test]
fn chw_layout_and_normalization() {
let mut img = RgbImage::new(2, 1);
img.put_pixel(0, 0, image::Rgb([127, 0, 255]));
img.put_pixel(1, 0, image::Rgb([128, 255, 0]));
let t = chw_normalized(&img, 127.5, 1.0 / 128.0);
// Length = 3 channels × 2 px.
assert_eq!(t.len(), 6);
// R plane first, then G, then B (NCHW).
assert!((t[0] - (127.0 - 127.5) / 128.0).abs() < 1e-6);
assert!((t[1] - (128.0 - 127.5) / 128.0).abs() < 1e-6);
assert!((t[2] - (0.0 - 127.5) / 128.0).abs() < 1e-6); // G of px0
assert!((t[4] - (255.0 - 127.5) / 128.0).abs() < 1e-6); // B of px0
}
#[test]
fn distance_decode_recovers_box_and_kps() {
// 1×2 grid, stride 8, 1 anchor → cell centers (0,0) then (8,0).
let scores = [0.9f32, 0.9];
// distances left/top/right/bottom (already × stride), identical per cell.
let bbox = [2.0, 1.0, 3.0, 4.0, 2.0, 1.0, 3.0, 4.0];
let kps: Vec<f32> = vec![
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 0
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 1
];
let mut out = Vec::new();
decode_stride(&scores, &bbox, Some(&kps), 8, 1, 2, 1, 0.5, &mut out);
assert_eq!(out.len(), 2);
// Cell 0, center (0,0): box = center ± distances, kps = center + offset.
assert_eq!(out[0].bbox, [-2.0, -1.0, 3.0, 4.0]);
assert_eq!(out[0].kps[0], [1.0, 1.0]);
assert_eq!(out[0].kps[1], [2.0, 2.0]);
// Cell 1, center (8,0): anchor center advanced by one stride in x.
assert_eq!(out[1].bbox, [8.0 - 2.0, -1.0, 8.0 + 3.0, 4.0]);
assert_eq!(out[1].kps[0], [9.0, 1.0]);
}
#[test]
fn decode_thresholds_out_low_scores() {
let scores = [0.2f32, 0.8];
let bbox = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let mut out = Vec::new();
// 1×2 grid, 1 anchor → two cells.
decode_stride(&scores, &bbox, None, 8, 1, 2, 1, 0.5, &mut out);
assert_eq!(out.len(), 1);
assert!((out[0].score - 0.8).abs() < 1e-6);
}
#[test]
fn iou_and_nms() {
let a = [0.0, 0.0, 10.0, 10.0];
let b = [0.0, 0.0, 10.0, 10.0];
assert!((iou(&a, &b) - 1.0).abs() < 1e-6);
let c = [100.0, 100.0, 110.0, 110.0];
assert_eq!(iou(&a, &c), 0.0);
let dets = vec![
Detection {
bbox: a,
kps: [[0.0; 2]; 5],
score: 0.9,
},
Detection {
bbox: b,
kps: [[0.0; 2]; 5],
score: 0.8,
}, // dup of a
Detection {
bbox: c,
kps: [[0.0; 2]; 5],
score: 0.7,
}, // separate
];
let kept = nms(dets, 0.4);
assert_eq!(kept.len(), 2);
assert!((kept[0].score - 0.9).abs() < 1e-6);
}
#[test]
fn similarity_identity() {
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
assert!((inv.a - 1.0).abs() < 1e-4);
assert!(inv.b.abs() < 1e-4);
assert!(inv.c.abs() < 1e-4);
assert!((inv.d - 1.0).abs() < 1e-4);
assert!(inv.tx.abs() < 1e-3);
assert!(inv.ty.abs() < 1e-3);
}
#[test]
fn similarity_pure_translation() {
// src = dst shifted by (+10, +5); inverse must map out→src by the same shift.
let mut src = ARCFACE_TEMPLATE;
for p in &mut src {
p[0] += 10.0;
p[1] += 5.0;
}
let inv = similarity_transform_inverse(&src, &ARCFACE_TEMPLATE);
assert!((inv.a - 1.0).abs() < 1e-4);
assert!(inv.b.abs() < 1e-4);
assert!((inv.tx - 10.0).abs() < 1e-3);
assert!((inv.ty - 5.0).abs() < 1e-3);
}
#[test]
fn warp_identity_preserves_template_region() {
// A 112×112 gradient warped by identity returns (close to) itself.
let mut img = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
for y in 0..ALIGN_SIZE {
for x in 0..ALIGN_SIZE {
img.put_pixel(x, y, image::Rgb([x as u8, y as u8, 128]));
}
}
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
let out = warp_to_aligned(&img, &inv);
let a = out.get_pixel(40, 60);
assert!((a[0] as i32 - 40).abs() <= 1);
assert!((a[1] as i32 - 60).abs() <= 1);
}
#[test]
fn l2_normalize_unit_length() {
let mut v = vec![3.0f32, 4.0];
l2_normalize(&mut v);
assert!((v[0] - 0.6).abs() < 1e-6);
assert!((v[1] - 0.8).abs() < 1e-6);
let mut z = vec![0.0f32, 0.0];
l2_normalize(&mut z); // unchanged, no NaN
assert_eq!(z, vec![0.0, 0.0]);
}
#[test]
fn laplacian_variance_sharp_vs_flat() {
let flat = RgbImage::from_pixel(8, 8, image::Rgb([100, 100, 100]));
assert!(laplacian_variance(&flat) < 1e-3);
let mut checker = RgbImage::new(8, 8);
for y in 0..8 {
for x in 0..8 {
let v = if (x + y) % 2 == 0 { 0 } else { 255 };
checker.put_pixel(x, y, image::Rgb([v, v, v]));
}
}
assert!(laplacian_variance(&checker) > 1000.0);
}
}
@@ -0,0 +1,191 @@
//! Face indexing as a `FileLifecycleHook`.
//!
//! On image upload it detects + embeds faces (off the request path, in a
//! background task) and stores them. It mirrors `MediaMetadataService`: reads
//! the blob from the local `.blobs` tree, is dedup-aware (identical uploads
//! clone an existing file's faces instead of re-running inference), and is
//! completely inert when no model is configured (`FaceAnalyzerPort::is_ready()
//! == false`) — so the feature compiles and runs with the default no-op
//! analyzer until the operator wires a real ONNX model.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::face_ports::{FaceAnalyzerPort, FaceRepository};
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::common::errors::DomainError;
use crate::domain::entities::face::Face;
use crate::infrastructure::repositories::pg::FacePgRepository;
/// Minimum detector confidence for a face to be stored.
const MIN_DET_SCORE: f32 = 0.6;
fn is_image(content_type: &str) -> bool {
content_type.starts_with("image/")
}
pub struct FaceIndexingService {
pool: Arc<PgPool>,
repo: Arc<FacePgRepository>,
analyzer: Arc<dyn FaceAnalyzerPort>,
blob_root: PathBuf,
}
impl FaceIndexingService {
pub fn new(pool: Arc<PgPool>, blob_root: PathBuf, analyzer: Arc<dyn FaceAnalyzerPort>) -> Self {
let repo = Arc::new(FacePgRepository::new(pool.clone()));
Self {
pool,
repo,
analyzer,
blob_root,
}
}
/// Local path of a blob: `.blobs/{prefix}/{hash}.blob`.
fn blob_path(&self, hash: &str) -> PathBuf {
let prefix = if hash.len() >= 2 { &hash[0..2] } else { hash };
self.blob_root.join(prefix).join(format!("{hash}.blob"))
}
/// Spawn a background indexing task. `reuse_dedup` clones faces from an
/// existing file with the same blob hash instead of re-running inference;
/// `delete_first` clears prior faces (used on overwrite).
fn spawn_index(&self, file_id: Uuid, blob_hash: String, reuse_dedup: bool, delete_first: bool) {
let pool = self.pool.clone();
let repo = self.repo.clone();
let analyzer = self.analyzer.clone();
let blob_path = self.blob_path(&blob_hash);
tokio::spawn(async move {
if delete_first {
let _ = repo.delete_faces_for_file(file_id).await;
}
if let Err(e) = index_file(
&pool,
&repo,
analyzer.as_ref(),
file_id,
&blob_path,
&blob_hash,
reuse_dedup,
)
.await
{
tracing::warn!(target: "oxicloud::faces", "face indexing failed for {file_id}: {e}");
}
});
}
}
impl FileLifecycleHook for FaceIndexingService {
fn on_file_created(
&self,
file_id: &str,
blob_hash: &str,
content_type: &str,
is_new_blob: bool,
) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
// Dedup hit (blob already existed) → clone an existing file's faces.
self.spawn_index(fid, blob_hash.to_string(), !is_new_blob, false);
}
}
fn on_file_copied(
&self,
file_id: &str,
blob_hash: &str,
content_type: &str,
_source_file_id: &str,
) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
self.spawn_index(fid, blob_hash.to_string(), true, false);
}
}
fn on_file_updated(&self, file_id: &str, blob_hash: &str, content_type: &str) {
if !is_image(content_type) || !self.analyzer.is_ready() {
return;
}
if let Ok(fid) = file_id.parse::<Uuid>() {
self.spawn_index(fid, blob_hash.to_string(), false, true);
}
}
fn on_file_deleted(&self, _file_id: &str) {
// faces.faces.file_id has ON DELETE CASCADE — the DB cleans up.
}
}
async fn lookup_user(pool: &PgPool, file_id: Uuid) -> Result<Uuid, DomainError> {
let row: (Uuid,) = sqlx::query_as("SELECT user_id FROM storage.files WHERE id = $1")
.bind(file_id)
.fetch_one(pool)
.await
.map_err(|e| DomainError::internal_error("Faces", format!("lookup user: {e}")))?;
Ok(row.0)
}
async fn index_file(
pool: &PgPool,
repo: &FacePgRepository,
analyzer: &dyn FaceAnalyzerPort,
file_id: Uuid,
blob_path: &Path,
blob_hash: &str,
reuse_dedup: bool,
) -> Result<(), DomainError> {
let user_id = lookup_user(pool, file_id).await?;
// Dedup-aware fast path: reuse faces already computed for an identical blob.
if reuse_dedup {
let peers = repo.faces_for_blob(user_id, blob_hash).await?;
let cloned: Vec<Face> = peers
.into_iter()
.filter(|f| f.file_id != file_id)
.map(|f| Face {
id: Uuid::new_v4(),
file_id,
..f
})
.collect();
if !cloned.is_empty() {
repo.save_faces(&cloned).await?;
return Ok(());
}
// No peer found — fall through and analyze.
}
let bytes = tokio::fs::read(blob_path)
.await
.map_err(|e| DomainError::internal_error("Faces", format!("read blob: {e}")))?;
let detected = analyzer.analyze(&bytes).await?;
let faces: Vec<Face> = detected
.into_iter()
.filter(|d| d.det_score >= MIN_DET_SCORE)
.map(|d| Face {
id: Uuid::new_v4(),
file_id,
user_id,
person_id: None,
bbox: d.bbox,
det_score: d.det_score,
quality: d.quality,
embedding: d.embedding,
blob_hash: Some(blob_hash.to_string()),
created_at: Utc::now(),
})
.collect();
repo.save_faces(&faces).await
}
+5
View File
@@ -6,6 +6,8 @@ pub mod compression_service;
pub mod dedup_service;
pub mod encrypted_blob_backend;
pub mod exif_service;
pub mod face_geometry;
pub mod face_indexing_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod image_transcode_service;
@@ -17,7 +19,10 @@ pub mod migration_blob_backend;
pub mod migration_job;
pub mod mock_email_sender;
pub mod nextcloud_chunked_upload_service;
pub mod noop_face_analyzer;
pub mod oidc_service;
#[cfg(feature = "faces-onnx")]
pub mod onnx_face_analyzer;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
@@ -0,0 +1,26 @@
//! Default no-op face analyzer.
//!
//! Used when no ML model is configured: it reports `is_ready() == false` and
//! returns no faces, so the whole People pipeline compiles and runs inert
//! until a real ONNX-backed analyzer (provided by the operator) replaces it.
use async_trait::async_trait;
use crate::application::ports::face_ports::FaceAnalyzerPort;
use crate::common::errors::DomainError;
use crate::domain::entities::face::DetectedFace;
/// Analyzer that never detects anything.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopFaceAnalyzer;
#[async_trait]
impl FaceAnalyzerPort for NoopFaceAnalyzer {
fn is_ready(&self) -> bool {
false
}
async fn analyze(&self, _image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
Ok(Vec::new())
}
}
@@ -0,0 +1,340 @@
//! ONNX-backed face analyzer (SCRFD detector + ArcFace embedder).
//!
//! Compiled only with the `faces-onnx` cargo feature. Mirrors the
//! immich/InsightFace pipeline: detect faces + 5-point landmarks (SCRFD),
//! similarity-align each face to the canonical 112×112 template, then embed
//! (ArcFace) into an L2-normalized 512-d vector. All inference runs on a
//! blocking thread (`spawn_blocking`) so it never stalls a Tokio worker, and
//! each ONNX session is serialized behind a `Mutex` (ORT's `run` needs `&mut`).
//!
//! The heavy numerical post-processing lives in [`super::face_geometry`] (plain
//! Rust, unit-tested); this module only wires it to ONNX Runtime.
//!
//! **Models are operator-provided at runtime, never committed.** `load` returns
//! an error (→ caller falls back to the no-op analyzer) if the ONNX Runtime
//! dylib or either model file is missing or incompatible — the server still
//! boots. The dylib is loaded via [`ort::init_from`] (a fallible path) rather
//! than ORT's lazy loader, which would `panic` on a missing library (fatal
//! under `panic = "abort"`).
use std::path::Path;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use image::RgbImage;
use ort::session::Session;
use ort::value::Tensor;
use super::face_geometry as geom;
use crate::application::ports::face_ports::FaceAnalyzerPort;
use crate::common::errors::DomainError;
use crate::domain::entities::face::{BoundingBox, DetectedFace, EMBEDDING_DIM};
/// SCRFD pyramid strides for the 3- and 5-level model variants.
const STRIDES_3: [u32; 3] = [8, 16, 32];
const STRIDES_5: [u32; 5] = [8, 16, 32, 64, 128];
/// Discard faces smaller than this (original-image pixels) — embeddings of tiny
/// faces are unreliable.
const MIN_FACE_PX: f32 = 24.0;
/// Hard cap on faces processed per image (bounds work on crowd shots).
const MAX_FACES: usize = 64;
/// Output layout of an InsightFace SCRFD model, inferred from its output count.
#[derive(Clone, Copy)]
struct ScrfdLayout {
/// Feature-map count per output kind (3 for strides 8/16/32, 5 with 64/128).
fmc: usize,
num_anchors: u32,
use_kps: bool,
}
impl ScrfdLayout {
fn from_num_outputs(n: usize) -> Option<Self> {
match n {
6 => Some(Self {
fmc: 3,
num_anchors: 2,
use_kps: false,
}),
9 => Some(Self {
fmc: 3,
num_anchors: 2,
use_kps: true,
}),
10 => Some(Self {
fmc: 5,
num_anchors: 1,
use_kps: false,
}),
15 => Some(Self {
fmc: 5,
num_anchors: 1,
use_kps: true,
}),
_ => None,
}
}
fn strides(&self) -> &'static [u32] {
if self.fmc == 3 {
&STRIDES_3
} else {
&STRIDES_5
}
}
}
/// Where to find the runtime + models, plus detector knobs. Borrowed paths;
/// nothing is retained after [`OnnxFaceAnalyzer::load`].
pub struct OnnxLoadConfig<'a> {
/// Path to `libonnxruntime.{so,dylib,dll}`.
pub dylib: &'a Path,
/// SCRFD detector `.onnx`.
pub detector: &'a Path,
/// ArcFace embedder `.onnx`.
pub embedder: &'a Path,
pub det_size: u32,
pub det_threshold: f32,
pub nms_threshold: f32,
/// ORT intra-op threads (0 = let ONNX Runtime decide).
pub intra_threads: usize,
}
struct Inner {
detector: Mutex<Session>,
embedder: Mutex<Session>,
layout: ScrfdLayout,
det_size: u32,
det_threshold: f32,
nms_threshold: f32,
}
/// Real face analyzer. Cheap to clone (`Arc` inside).
#[derive(Clone)]
pub struct OnnxFaceAnalyzer {
inner: Arc<Inner>,
}
fn dom(e: impl std::fmt::Display) -> DomainError {
DomainError::internal_error("Faces", e.to_string())
}
fn build_session(path: &Path, intra_threads: usize) -> Result<Session, DomainError> {
let mut builder = Session::builder().map_err(dom)?;
if intra_threads > 0 {
builder = builder.with_intra_threads(intra_threads).map_err(dom)?;
}
builder.commit_from_file(path).map_err(dom)
}
impl OnnxFaceAnalyzer {
/// Load the ONNX Runtime dylib and both models. Returns an error (caller
/// falls back to the no-op analyzer) on any missing/incompatible artifact.
pub fn load(cfg: &OnnxLoadConfig<'_>) -> Result<Self, DomainError> {
// Fallible dylib load — populates ORT's global handle so later calls
// never hit the panicking lazy loader.
ort::init_from(cfg.dylib)
.map_err(|e| dom(format!("ONNX Runtime dylib: {e}")))?
.commit();
let detector = build_session(cfg.detector, cfg.intra_threads)?;
let embedder = build_session(cfg.embedder, cfg.intra_threads)?;
let n_out = detector.outputs().len();
let layout = ScrfdLayout::from_num_outputs(n_out).ok_or_else(|| {
dom(format!(
"detector has {n_out} outputs; expected an SCRFD model (6/9/10/15)"
))
})?;
if !layout.use_kps {
tracing::warn!(
target: "oxicloud::faces",
"SCRFD model has no landmark outputs; face alignment will be approximate"
);
}
tracing::info!(
target: "oxicloud::faces",
"ONNX face analyzer ready (detector {} outputs, embedder loaded, det_size={})",
n_out, cfg.det_size
);
Ok(Self {
inner: Arc::new(Inner {
detector: Mutex::new(detector),
embedder: Mutex::new(embedder),
layout,
det_size: cfg.det_size,
det_threshold: cfg.det_threshold,
nms_threshold: cfg.nms_threshold,
}),
})
}
}
impl Inner {
/// Full synchronous pipeline for one encoded image.
fn analyze_blocking(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
let orig = image::load_from_memory(image_bytes)
.map_err(|e| dom(format!("decode image: {e}")))?
.to_rgb8();
let (w0, h0) = (orig.width(), orig.height());
if w0 == 0 || h0 == 0 {
return Ok(Vec::new());
}
let dets = self.detect(&orig)?;
let mut faces = Vec::new();
for det in dets.into_iter().take(MAX_FACES) {
let fw = det.bbox[2] - det.bbox[0];
let fh = det.bbox[3] - det.bbox[1];
if fw < MIN_FACE_PX || fh < MIN_FACE_PX {
continue;
}
let Some(embedding) = self.embed(&orig, &det)? else {
continue;
};
let aligned_quality = {
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
let aligned = geom::warp_to_aligned(&orig, &inv);
geom::laplacian_variance(&aligned)
};
let x = (det.bbox[0] / w0 as f32).clamp(0.0, 1.0);
let y = (det.bbox[1] / h0 as f32).clamp(0.0, 1.0);
let bw = (fw / w0 as f32).clamp(0.0, 1.0);
let bh = (fh / h0 as f32).clamp(0.0, 1.0);
faces.push(DetectedFace {
bbox: BoundingBox { x, y, w: bw, h: bh },
det_score: det.score,
quality: Some(aligned_quality),
embedding,
});
}
Ok(faces)
}
/// Run SCRFD and return detections in **original-image pixels**.
fn detect(&self, orig: &RgbImage) -> Result<Vec<geom::Detection>, DomainError> {
let det = self.det_size;
let (nw, nh, scale) = geom::letterbox(orig.width(), orig.height(), det);
let resized = image::imageops::resize(orig, nw, nh, image::imageops::FilterType::Triangle);
let mut canvas = RgbImage::new(det, det);
image::imageops::overlay(&mut canvas, &resized, 0, 0);
let input = geom::chw_normalized(&canvas, 127.5, 1.0 / 128.0);
let tensor =
Tensor::from_array(([1_i64, 3, det as i64, det as i64], input)).map_err(dom)?;
let layout = self.layout;
let total = layout.fmc * if layout.use_kps { 3 } else { 2 };
let raw: Vec<Vec<f32>> = {
let mut sess = self
.detector
.lock()
.map_err(|_| dom("detector mutex poisoned"))?;
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
(0..total)
.map(|i| {
outputs[i]
.try_extract_tensor::<f32>()
.map(|(_, data)| data.to_vec())
.map_err(dom)
})
.collect::<Result<_, _>>()?
};
let mut dets = Vec::new();
for (si, &stride) in layout.strides().iter().enumerate() {
let scores = &raw[si];
let bbox: Vec<f32> = raw[layout.fmc + si]
.iter()
.map(|v| v * stride as f32)
.collect();
let kps: Option<Vec<f32>> = if layout.use_kps {
Some(
raw[2 * layout.fmc + si]
.iter()
.map(|v| v * stride as f32)
.collect(),
)
} else {
None
};
let feat = det / stride;
geom::decode_stride(
scores,
&bbox,
kps.as_deref(),
stride,
feat,
feat,
layout.num_anchors,
self.det_threshold,
&mut dets,
);
}
// Scale detector-space coordinates back to the original image.
let inv_scale = if scale.abs() < 1e-9 { 1.0 } else { 1.0 / scale };
for d in &mut dets {
for v in &mut d.bbox {
*v *= inv_scale;
}
for k in &mut d.kps {
k[0] *= inv_scale;
k[1] *= inv_scale;
}
}
Ok(geom::nms(dets, self.nms_threshold))
}
/// Align one detection and run the ArcFace embedder. Returns `None` if the
/// embedder produces an unexpected output length.
fn embed(
&self,
orig: &RgbImage,
det: &geom::Detection,
) -> Result<Option<Vec<f32>>, DomainError> {
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
let aligned = geom::warp_to_aligned(orig, &inv);
let input = geom::chw_normalized(&aligned, 127.5, 1.0 / 127.5);
let size = geom::ALIGN_SIZE as i64;
let tensor = Tensor::from_array(([1_i64, 3, size, size], input)).map_err(dom)?;
let mut embedding: Vec<f32> = {
let mut sess = self
.embedder
.lock()
.map_err(|_| dom("embedder mutex poisoned"))?;
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
let (_, data) = outputs[0].try_extract_tensor::<f32>().map_err(dom)?;
data.to_vec()
};
if embedding.len() != EMBEDDING_DIM {
tracing::warn!(
target: "oxicloud::faces",
"embedder returned {} dims, expected {EMBEDDING_DIM}; skipping face",
embedding.len()
);
return Ok(None);
}
geom::l2_normalize(&mut embedding);
Ok(Some(embedding))
}
}
#[async_trait]
impl FaceAnalyzerPort for OnnxFaceAnalyzer {
fn is_ready(&self) -> bool {
true
}
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
let inner = self.inner.clone();
let bytes = image_bytes.to_vec();
tokio::task::spawn_blocking(move || inner.analyze_blocking(&bytes))
.await
.map_err(|e| dom(format!("inference task join: {e}")))?
}
}
+233 -180
View File
@@ -1,13 +1,14 @@
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
//!
//! Stores grants in `storage.access_grants` (see migration
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
//! using the existing GiST index for O(log N) traversal.
//! Stores grants in `storage.role_grants` (one role per (subject, resource)
//! pair; the role's permission bundle is expanded in code via
//! `Role::expand()`). Cascading is resolved at check time via PostgreSQL
//! `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, using the
//! existing GiST index for O(log N) traversal.
//!
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
//! are checked first via dedicated helpers; if the caller is the owner, no
//! SQL against `access_grants` happens.
//! SQL against `role_grants` happens.
//!
//! ## Lifecycle cleanup
//!
@@ -43,7 +44,7 @@ use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
Permission, Resource, ResourceKind, Subject,
Permission, Resource, ResourceKind, Role, Subject, roles_implying,
};
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
@@ -205,7 +206,7 @@ impl PgAclEngine {
}
/// Expand a caller's `Subject` into the `(subject_types, subject_ids)`
/// pair that should be matched in `storage.access_grants`. For User
/// pair that should be matched in `storage.role_grants`. For User
/// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`;
/// for any non-user subject (Token / External / Group as direct caller)
/// it's a single-element pair with no cascade.
@@ -237,6 +238,22 @@ impl PgAclEngine {
}
}
/// Convert a `Permission` into the array of role strings whose bundle
/// includes it — bound as `ANY($N::storage.grant_role[])` so the
/// ENUM-typed `role` column compares without an implicit text cast.
///
/// This is the inverse of `Role::expand()`, precomputed via
/// `grant_dto::roles_implying()`. The mapping is small and static (≤5
/// roles per permission today); resolving it in code keeps the SQL
/// path simple and lets us add new roles without touching every
/// query site.
fn roles_implying_strings(permission: Permission) -> Vec<&'static str> {
roles_implying(permission)
.iter()
.map(|r| r.as_str())
.collect()
}
/// Cascading check for folders: is there a grant on any ancestor folder
/// (including the target itself) for any of the given subject IDs and
/// any of the given subject types?
@@ -247,6 +264,11 @@ impl PgAclEngine {
/// `subject_ids` is the expanded set returned by `expand_user` (or a
/// single-element vec for non-user callers).
///
/// Reads `storage.role_grants` (1 row per role assignment); a permission
/// filter `g.permission = $3` becomes `g.role = ANY($3::storage.grant_role[])` where
/// the array is the set of roles whose bundle includes the requested
/// permission — see `roles_implying()`.
///
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
async fn folder_cascade_grant_exists(
&self,
@@ -257,14 +279,15 @@ impl PgAclEngine {
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants g
FROM storage.role_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.permission = $3
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
@@ -273,7 +296,7 @@ impl PgAclEngine {
)
.bind(subject_types)
.bind(subject_ids)
.bind(permission.as_str())
.bind(&roles)
.bind(folder_id)
.fetch_optional(self.pool.as_ref())
.await
@@ -285,7 +308,7 @@ impl PgAclEngine {
/// Cascading check for files: either a direct file grant OR a grant on
/// any ancestor folder of the file's containing folder. See
/// `folder_cascade_grant_exists` for the meaning of `subject_types` /
/// `subject_ids`.
/// `subject_ids` and the D-Prep role-array migration.
async fn file_cascade_grant_exists(
&self,
subject_types: &[&str],
@@ -295,27 +318,28 @@ impl PgAclEngine {
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM (
-- direct file grant
SELECT 1
FROM storage.access_grants
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND permission = $3
AND role = ANY($3::storage.grant_role[])
AND resource_type = 'file' AND resource_id = $4
AND (expires_at IS NULL OR expires_at > NOW())
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
FROM storage.access_grants g
FROM storage.role_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.permission = $3
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'folder'
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND target_f.folder_id IS NOT NULL
@@ -327,7 +351,7 @@ impl PgAclEngine {
)
.bind(subject_types)
.bind(subject_ids)
.bind(permission.as_str())
.bind(&roles)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
@@ -336,39 +360,16 @@ impl PgAclEngine {
Ok(exists.is_some())
}
/// Look up a single grant by id. Returns `(resource, granted_by)` so
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
/// without a second round-trip. Returns `Ok(None)` if no such grant.
pub async fn find_grant_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
let Some((rt, rid, granter)) = row else {
return Ok(None);
};
let res = Resource::from_parts(&rt, rid)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
Ok(Some((res, granter)))
}
/// Variant of `find_grant_by_id` that also returns the subject —
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
/// Returns `(subject, resource, granted_by)` or `None`.
/// Look up a single role grant by id, returning the actors a revoke /
/// notify handler needs to make a decision without a second round-trip.
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
pub async fn find_grant_full_by_id(
&self,
grant_id: Uuid,
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
FROM storage.access_grants WHERE id = $1",
FROM storage.role_grants WHERE id = $1",
)
.bind(grant_id)
.fetch_optional(self.pool.as_ref())
@@ -385,8 +386,14 @@ impl PgAclEngine {
Ok(Some((subject, resource, granter)))
}
/// Row type for all full-grant SELECT queries:
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
/// Row type for `storage.role_grants` SELECTs:
/// (id, subject_type, subject_id, resource_type, resource_id, role, granted_by, granted_at, expires_at).
///
/// Builds a single role-keyed `Grant` per row. `Grant` is role-keyed
/// since the D-Prep cleanup PR — every listing method returns role
/// rows directly; bundle expansion to per-permission Grants no longer
/// happens here. Callers that need the permission set use
/// `grant.role.expand()` at the call site.
#[allow(clippy::type_complexity)]
fn row_to_grant(
row: (
@@ -405,13 +412,13 @@ impl PgAclEngine {
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
let resource = Resource::from_parts(&row.3, row.4)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
let permission = Permission::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
let role = Role::parse(&row.5)
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown role"))?;
Ok(Grant {
id: row.0,
subject,
resource,
permission,
role,
granted_by: row.6,
granted_at: row.7,
expires_at: row.8,
@@ -529,15 +536,14 @@ impl AuthorizationEngine for PgAclEngine {
result
}
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError> {
let perm_str = permission_filter.map(|p| p.as_str().to_string());
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError> {
let counters = QueryCounters::default();
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM
// declared as `(owner, editor, contributor, commenter, viewer)`,
// so the sort order matches the UX requirement ("Owner > Editor
// > Contributor > Commenter > Viewer") without a per-row CASE.
let rows = sqlx::query_as::<
_,
(
@@ -554,18 +560,16 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND ($3::text IS NULL OR permission = $3)
ORDER BY granted_at DESC
LIMIT $4
ORDER BY role ASC, granted_at DESC
LIMIT $3
"#,
)
.bind(&subject_types)
.bind(&subject_ids)
.bind(perm_str)
.bind(MAX_GRANT_ROWS + 1)
.fetch_all(self.pool.as_ref())
.await
@@ -596,7 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
// NULL otherwise. This lets every sort mode share a single query_as call.
// 0 resource_type String
// 1 resource_id Uuid
// 2 permissions Vec<String>
// 2 roles Vec<String> — every distinct role granting access to this
// resource (post-D-Prep). Expanded to permissions
// in `IncomingGrantSummary` via `Role::expand()`.
// Multiple entries possible when a user has both
// a direct grant and a group-mediated grant on
// the same resource.
// 3 granted_at DateTime<Utc>
// 4 granted_by Uuid
// 5 sort_str Option<String> — resource_name (name/type) or owner_name (granted_by)
@@ -628,14 +637,20 @@ impl AuthorizationEngine for PgAclEngine {
// is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the
// listing includes every resource the user can reach via a group
// grant (matching what `check()` allows). See `subject_match_set`.
//
// Post-D-Prep this reads `storage.role_grants` and aggregates the
// ENUM-typed `role` column into a text array. Multiple roles can
// appear per resource when the caller reaches it via both a direct
// grant and a group-mediated grant — the union of role bundles
// produces the displayed permission set in Rust below.
const AGG: &str = r#"agg AS (
SELECT
resource_type,
resource_id,
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
array_agg(DISTINCT role::text ORDER BY role::text) AS roles,
MIN(granted_at) AS granted_at,
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
FROM storage.access_grants
FROM storage.role_grants
WHERE subject_type = ANY($1)
AND subject_id = ANY($2)
AND ($3::text[] IS NULL OR resource_type = ANY($3))
@@ -700,7 +715,7 @@ impl AuthorizationEngine for PgAclEngine {
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
)
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
FROM named
WHERE {where_clause}
ORDER BY {order_clause}
@@ -740,7 +755,7 @@ impl AuthorizationEngine for PgAclEngine {
FROM agg
LEFT JOIN auth.users u ON u.id = agg.granted_by
)
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
FROM owner_named
WHERE {where_clause}
ORDER BY {order_clause}
@@ -768,7 +783,7 @@ impl AuthorizationEngine for PgAclEngine {
};
format!(
r#"WITH {AGG}
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
SELECT resource_type, resource_id, roles, granted_at, granted_by,
NULL::text AS sort_str,
NULL::bigint AS sort_int
FROM agg
@@ -850,14 +865,21 @@ impl AuthorizationEngine for PgAclEngine {
};
// ── Convert rows to domain summaries ──────────────────────────────────
// Post-D-Prep: the SQL aggregate produces a `roles` text array. We
// expand each role's bundle and union them — direct grants and
// group-mediated grants on the same resource collapse to a single
// deduplicated permission set, matching the pre-pivot behaviour.
let summaries = rows
.into_iter()
.filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| {
.filter_map(|(rt, rid, roles_str, granted_at, granted_by, _, _)| {
let resource_type = ResourceKind::parse(&rt)?;
let permissions = perms_str
let mut permissions: Vec<Permission> = roles_str
.into_iter()
.filter_map(|s| Permission::parse(&s))
.filter_map(|s| Role::parse(&s))
.flat_map(|r| r.expand().iter().copied())
.collect();
permissions.sort_by_key(|p| p.as_str());
permissions.dedup();
Some(IncomingGrantSummary {
resource_type,
resource_id: rid,
@@ -872,6 +894,14 @@ impl AuthorizationEngine for PgAclEngine {
}
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
// Each role row expands to N permission-keyed `Grant` rows via
// `role_row_to_grants` until the public `Grant` shape becomes
// role-keyed.
//
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM's
// declaration order (owner first → viewer last) so the share
// dialog's "who has access" list shows strongest grants on top.
let rows = sqlx::query_as::<
_,
(
@@ -888,11 +918,11 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE resource_type = $1
AND resource_id = $2
ORDER BY granted_at DESC
ORDER BY role ASC, granted_at DESC
LIMIT $3
"#,
)
@@ -917,7 +947,10 @@ impl AuthorizationEngine for PgAclEngine {
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError> {
let fetch_limit = (limit as i64) + 1;
// Row shape — one row per (resource, subject, permission).
// Row shape — post-D-Prep, one row per (resource, subject) since
// `storage.role_grants` carries exactly one role per pair (UNIQUE
// constraint). Permission bundles are expanded in the row consumer
// via `Role::expand()`.
// Columns:
// 0 resource_type String
// 1 resource_id Uuid
@@ -926,9 +959,9 @@ impl AuthorizationEngine for PgAclEngine {
// 4 subject_id Uuid
// 5 subject_display String — username or share item_name
// 6 grant_id Uuid
// 7 granted_at DateTime<Utc> — this (subject, perm) row
// 7 granted_at DateTime<Utc> — this (subject, role) row
// 8 expires_at Option<DateTime<Utc>>
// 9 permission String
// 9 role String — `grant_role` ENUM as text
// 10 sort_str Option<String>
// 11 sort_int Option<i64>
// 12 has_password bool — token: shares.password_hash IS NOT NULL
@@ -1015,7 +1048,7 @@ impl AuthorizationEngine for PgAclEngine {
CASE WHEN ag.resource_type = 'file' THEN fi.name END
) AS sort_str,
{sort_int_expr} AS sort_int
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder'
LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file'
WHERE ag.granted_by = $1
@@ -1030,12 +1063,12 @@ impl AuthorizationEngine for PgAclEngine {
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
rp.sort_str, rp.sort_int,
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
@@ -1104,7 +1137,7 @@ impl AuthorizationEngine for PgAclEngine {
ELSE 3
END)::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN auth.subject_groups sg
@@ -1135,13 +1168,13 @@ impl AuthorizationEngine for PgAclEngine {
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
ag.role::text AS role,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
@@ -1155,7 +1188,12 @@ impl AuthorizationEngine for PgAclEngine {
// Page on (role_order, subject_display, resource_id) triples so that all
// of one person's grants within a role are contiguous — enabling aggregation
// ("Bob on Folder A, Folder B") to work correctly across cursor pages.
// role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer
//
// role_order matches the `storage.grant_role` ENUM declaration
// order (strongest first) via `array_position`, so
// `sort_int ASC` matches the UX requirement: 1 = owner,
// 2 = editor, 3 = contributor, 4 = commenter, 5 = viewer.
// 1-based because `array_position` is.
// Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id
let (page_where, page_order) = if reverse {
(
@@ -1184,15 +1222,20 @@ impl AuthorizationEngine for PgAclEngine {
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
CASE
WHEN BOOL_OR(ag.permission = 'delete')
AND BOOL_OR(ag.permission = 'share') THEN 0
WHEN BOOL_OR(ag.permission = 'create')
OR BOOL_OR(ag.permission = 'update') THEN 1
ELSE 2
END::bigint AS sort_int,
-- One role per (resource, subject) post-D-Prep
-- (UNIQUE constraint on role_grants), so MAX
-- returns that single row's role. `array_position`
-- against the ENUM's declaration order produces a
-- 1-based rank: owner=1 → viewer=5. Strength
-- ordering tracks the ENUM declaration — adding
-- a new role between owner and viewer doesn't
-- need a parallel CASE update here.
array_position(
enum_range(NULL::storage.grant_role),
MAX(ag.role)
)::bigint AS sort_int,
MIN(ag.granted_at) AS first_granted_at
FROM storage.access_grants ag
FROM storage.role_grants ag
LEFT JOIN auth.users u
ON ag.subject_type = 'user' AND u.id = ag.subject_id
LEFT JOIN storage.shares sh
@@ -1221,13 +1264,13 @@ impl AuthorizationEngine for PgAclEngine {
ag.id AS grant_id,
ag.granted_at,
ag.expires_at,
ag.permission,
ag.role::text AS role,
LOWER(rp.subject_display) AS sort_str,
rp.sort_int,
rp.has_password,
rp.is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type
AND ag.resource_id = rp.resource_id
AND ag.subject_type = rp.subject_type
@@ -1259,7 +1302,7 @@ impl AuthorizationEngine for PgAclEngine {
SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at,
NULL::text AS sort_str,
NULL::bigint AS sort_int
FROM storage.access_grants
FROM storage.role_grants
WHERE granted_by = $1
GROUP BY resource_type, resource_id
),
@@ -1272,12 +1315,12 @@ impl AuthorizationEngine for PgAclEngine {
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
ag.subject_type, ag.subject_id,
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
NULL::text AS sort_str, NULL::bigint AS sort_int,
(sh.password_hash IS NOT NULL) AS has_password,
COALESCE(u.is_external, FALSE) AS is_external
FROM rp
JOIN storage.access_grants ag
JOIN storage.role_grants ag
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
AND ag.granted_by = $1
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
@@ -1355,7 +1398,7 @@ impl AuthorizationEngine for PgAclEngine {
grant_id,
granted_at,
expires_at,
perm_str,
role_str,
_,
_,
has_password,
@@ -1364,7 +1407,7 @@ impl AuthorizationEngine for PgAclEngine {
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
let Some(role) = Role::parse(&role_str) else {
continue;
};
let key = (resource_id, subj_id);
@@ -1384,8 +1427,10 @@ impl AuthorizationEngine for PgAclEngine {
},
)
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
for &perm in role.expand() {
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
}
@@ -1473,7 +1518,7 @@ impl AuthorizationEngine for PgAclEngine {
grant_id,
granted_at,
expires_at,
perm_str,
role_str,
_,
_,
has_password,
@@ -1482,7 +1527,7 @@ impl AuthorizationEngine for PgAclEngine {
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
continue;
};
let Some(perm) = Permission::parse(&perm_str) else {
let Some(role) = Role::parse(&role_str) else {
continue;
};
@@ -1506,8 +1551,10 @@ impl AuthorizationEngine for PgAclEngine {
has_password,
is_external,
});
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
for &perm in role.expand() {
if !entry.permissions.contains(&perm) {
entry.permissions.push(perm);
}
}
}
@@ -1553,6 +1600,11 @@ impl AuthorizationEngine for PgAclEngine {
}
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
// Group membership doesn't apply on the outgoing side — we
// filter by `granted_by` directly. Bundle expansion still
// happens at read time via `role_row_to_grants` until the
// public `Grant` shape becomes role-keyed.
let rows = sqlx::query_as::<
_,
(
@@ -1569,10 +1621,10 @@ impl AuthorizationEngine for PgAclEngine {
>(
r#"
SELECT id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
FROM storage.access_grants
role::text, granted_by, granted_at, expires_at
FROM storage.role_grants
WHERE granted_by = $1
ORDER BY granted_at DESC
ORDER BY role ASC, granted_at DESC
"#,
)
.bind(granted_by)
@@ -1583,11 +1635,68 @@ impl AuthorizationEngine for PgAclEngine {
rows.into_iter().map(Self::row_to_grant).collect()
}
async fn grant(
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.role_grants SET expires_at = $3 \
WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}"))
})?;
Ok(())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
// ── D-Prep role_grants writes ──────────────────────────────────────────
async fn set_role(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
role: Role,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError> {
@@ -1606,104 +1715,48 @@ impl AuthorizationEngine for PgAclEngine {
),
>(
r#"
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
DO UPDATE SET expires_at = EXCLUDED.expires_at
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by, expires_at)
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6, $7)
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
DO UPDATE SET role = EXCLUDED.role,
expires_at = EXCLUDED.expires_at,
granted_by = EXCLUDED.granted_by
RETURNING id, subject_type, subject_id, resource_type, resource_id,
permission, granted_by, granted_at, expires_at
role::text, granted_by, granted_at, expires_at
"#,
)
.bind(subject.type_str())
.bind(subject.id())
.bind(resource.type_str())
.bind(resource.id())
.bind(permission.as_str())
.bind(role.as_str())
.bind(granted_by)
.bind(expires_at)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?;
Self::row_to_grant(row)
}
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
Ok(())
}
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE storage.access_grants SET expires_at = $3 \
"DELETE FROM storage.role_grants \
WHERE subject_type = $1 AND subject_id = $2 \
AND resource_type = $4 AND resource_id = $5",
AND resource_type = $3 AND resource_id = $4",
)
.bind(subject.type_str())
.bind(subject.id())
.bind(expires_at)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
})?;
.map_err(|e| DomainError::internal_error("PgAcl", format!("clear_role: {e}")))?;
Ok(())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
.bind(grant_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
Ok(())
}
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
)
.bind(resource.type_str())
.bind(resource.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
Ok(result.rows_affected() as usize)
}
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
let result = sqlx::query(
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
)
.bind(subject.type_str())
.bind(subject.id())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
Ok(result.rows_affected() as usize)
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -56,6 +56,11 @@ const PREVIEW_BYTES: usize = 16 * 1024;
/// 1.5 s interval).
const ORPHAN_SWEEP_TICKS: u64 = 2400;
/// Backoff before the supervisor restarts the drain loop after an abnormal
/// exit (a panic). Long enough that a tight crash-loop can't busy-spin, short
/// enough that indexing resumes promptly.
const WORKER_RESTART_BACKOFF_SECS: u64 = 5;
pub struct ContentIndexWorker {
maintenance_pool: Arc<PgPool>,
dedup: Arc<DedupService>,
@@ -86,49 +91,77 @@ impl ContentIndexWorker {
}
}
/// Spawn the indexing loop. Fire-and-forget: the loop logs and survives
/// every error (an exited loop would silently freeze the index while the
/// queue grows), and the first drain runs immediately to absorb rows left
/// over from a previous run or the migration backfill.
/// Spawn the indexing loop, supervised. The drain loop logs and survives
/// every *operational* error (a failed drain just retries next tick), but a
/// panic in the loop body would otherwise kill the task and silently freeze
/// the index while the dirty queue grows unbounded. The supervisor restarts
/// the loop after a panic (with backoff) so indexing self-heals. The first
/// drain runs immediately to absorb rows left over from a previous run or
/// the migration backfill.
#[instrument(skip(self))]
pub fn start(self, needs_reseed: bool) {
info!(
"Starting content-index worker (every {}ms, batch {}, reseed: {})",
self.interval_ms, DRAIN_BATCH, needs_reseed
);
let worker = Arc::new(self);
tokio::spawn(async move {
if let Err(e) = self.prepare(needs_reseed).await {
// Reseed/version cleanup runs once, not on every restart.
if let Err(e) = worker.prepare(needs_reseed).await {
error!("Content-index prepare failed (continuing with queue as-is): {e}");
}
let mut ticker =
tokio::time::interval(std::time::Duration::from_millis(self.interval_ms));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut ticks: u64 = 0;
// run_loop() never returns under normal operation, so any exit is
// abnormal: a panic surfaces as a JoinError; a plain return would
// be a logic bug. Either way, log loudly and restart.
loop {
ticker.tick().await;
for _ in 0..MAX_BATCHES_PER_TICK {
match self.drain_once().await {
Ok(0) => break,
Ok(drained) => {
debug!("Content-index drain: processed {drained} queue row(s)");
if drained < DRAIN_BATCH as usize {
break;
}
}
Err(e) => {
error!("Content-index drain failed (queue preserved, will retry): {e}");
let w = worker.clone();
match tokio::spawn(async move { w.run_loop().await }).await {
Ok(()) => error!(
"Content-index drain loop returned unexpectedly; \
restarting in {WORKER_RESTART_BACKOFF_SECS}s"
),
Err(e) if e.is_panic() => error!(
"Content-index drain loop panicked ({e}); \
restarting in {WORKER_RESTART_BACKOFF_SECS}s"
),
Err(_) => return, // task cancelled — runtime shutting down
}
tokio::time::sleep(std::time::Duration::from_secs(WORKER_RESTART_BACKOFF_SECS))
.await;
}
});
}
/// The perpetual drain loop. Extracted from [`start`](Self::start) so the
/// supervisor can run it in a child task and restart it after a panic.
async fn run_loop(&self) {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(self.interval_ms));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut ticks: u64 = 0;
loop {
ticker.tick().await;
for _ in 0..MAX_BATCHES_PER_TICK {
match self.drain_once().await {
Ok(0) => break,
Ok(drained) => {
debug!("Content-index drain: processed {drained} queue row(s)");
if drained < DRAIN_BATCH as usize {
break;
}
}
}
ticks += 1;
if ticks.is_multiple_of(ORPHAN_SWEEP_TICKS) {
self.sweep_orphaned_text().await;
Err(e) => {
error!("Content-index drain failed (queue preserved, will retry): {e}");
break;
}
}
}
});
ticks += 1;
if ticks.is_multiple_of(ORPHAN_SWEEP_TICKS) {
self.sweep_orphaned_text().await;
}
}
}
/// Spawn the discard-only janitor used when content search is DISABLED:
+2 -2
View File
@@ -42,7 +42,7 @@ pub fn test_db_url() -> String {
/// OnceCell so concurrent test threads block until the first caller
/// finishes; subsequent calls are zero-cost.
///
/// Order matters: `storage.access_grants` rows go first because there's
/// Order matters: `storage.role_grants` rows go first because there's
/// no FK from there to `auth.subject_groups` (the service's `delete`
/// path does this transactionally; here we bypass the service).
static CLEANUP_ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
@@ -51,7 +51,7 @@ pub async fn ensure_clean_test_db(pool: &PgPool) {
CLEANUP_ONCE
.get_or_init(|| async {
let _ = sqlx::query(
"DELETE FROM storage.access_grants
"DELETE FROM storage.role_grants
WHERE subject_type = 'group'
AND subject_id IN (
SELECT id FROM auth.subject_groups WHERE name LIKE 'rust-test-%'
@@ -218,6 +218,9 @@ async fn handle_propfind(
.to_string();
let user = extract_user(&req)?;
// Caller UUID (string form) — gates the `<D:write/>` privilege on calendars
// the caller owns, so clients mount their own calendars read-write.
let caller_id = user.id.to_string();
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
@@ -256,6 +259,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&user.username,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -333,6 +337,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -359,6 +364,7 @@ async fn handle_propfind(
&calendars,
&propfind_request,
base_href,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -407,6 +413,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| {
AppError::internal_error(format!("Failed to generate XML: {}", e))
+77 -7
View File
@@ -58,6 +58,24 @@ pub fn carddav_routes() -> Router<Arc<AppState>> {
.route("/carddav", axum::routing::any(handle_carddav_methods_root))
}
/// Creates the RFC 6764 well-known discovery route for CardDAV.
/// Public (no auth) — simply redirects to the CardDAV root so clients that
/// bootstrap from `/.well-known/carddav` can locate the service.
pub fn well_known_routes() -> Router<Arc<AppState>> {
Router::new().route(
"/.well-known/carddav",
axum::routing::any(handle_well_known_carddav),
)
}
async fn handle_well_known_carddav() -> Response<Body> {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, "/carddav/")
.body(Body::empty())
.unwrap()
}
async fn handle_carddav_methods_root(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
@@ -226,10 +244,66 @@ async fn handle_propfind(
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
// Discovery: the true root `/carddav/` advertises current-user-principal and
// addressbook-home-set so clients (DAVx5, Apple Contacts) can locate the
// address books. Depth 0 → only the root entry; Depth 1+ → also the books.
if path.is_empty() {
let address_books = if depth == "0" {
vec![]
} else {
addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?
};
let mut response_body = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut response_body,
&address_books,
&propfind_request,
"/carddav/",
&user.username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
// Discovery: principal resource `/carddav/principals/{username}/` returns the
// addressbook-home-set the client should enumerate next.
if path == "principals" || path.starts_with("principals/") {
let username = path
.strip_prefix("principals/")
.map(|s| s.trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(&user.username);
let mut response_body = Vec::new();
CardDavAdapter::generate_principal_propfind_response(
&mut response_body,
&propfind_request,
username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
let effective_path = strip_username_prefix(path);
if effective_path.is_empty() {
// Root CardDAV path or user home — list user's address books
// User address-book home `/carddav/{username}/` — list the user's books.
let address_books = addressbook_service
.list_user_address_books(user.id)
.await
@@ -237,12 +311,8 @@ async fn handle_propfind(
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
let base_href = if path.is_empty() {
"/carddav/".to_string()
} else {
let user_part = path.split('/').next().unwrap_or(path);
format!("/carddav/{}/", user_part)
};
let user_part = path.split('/').next().unwrap_or(path);
let base_href = format!("/carddav/{}/", user_part);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbooks_propfind_response(
&mut response_body,
+183 -250
View File
@@ -11,31 +11,28 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use futures::future::join_all;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{error, info, warn};
use tracing::{error, warn};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
SubjectInputDto, UpdateRoleDto, role_from_permissions,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::recipient_notification_service::NotifyTrigger;
use crate::common::di::AppState;
#[allow(unused_imports)]
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
Subject,
Role, Subject,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -66,28 +63,7 @@ pub async fn create_grant(
let authz = &state.authorization;
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let role: Role = dto.role.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
@@ -152,25 +128,32 @@ pub async fn create_grant(
}
};
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz
.grant(caller_id, subject, perm, resource, expires_at)
.await
{
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
// the engine makes repeated POSTs with the same (subject, resource)
// a role refresh, matching the PATCH-style semantics callers expect.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
Ok(g) => g,
Err(err) => {
error!("set_role write failed: {err}");
return AppError::from(err).into_response();
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
};
let grants = vec![GrantDto::from(grant)];
tracing::info!(
target: "audit",
event = "role_grant.created",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🤝 grant created with role '{}'", role.as_str(),
);
// PR N1 — route the post-grant notification through the unified
@@ -240,7 +223,7 @@ pub async fn create_grant(
(
StatusCode::CREATED,
Json(CreateGrantResponseDto {
grants: results,
grants,
notification,
}),
)
@@ -274,17 +257,20 @@ pub async fn revoke_grant(
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match authz.find_grant_by_id(grant_id).await {
Ok(Some((res, granter))) => (res, granter),
// Look up the grant to find the subject, resource, and granter.
// `find_grant_full_by_id` returns the subject too — needed for the
// `clear_role` dual-write below (role_grants is keyed by (subject,
// resource), not by access_grants id).
let (subject, resource, granter) = match authz.find_grant_full_by_id(grant_id).await {
Ok(Some(triple)) => triple,
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
if granter != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
@@ -293,7 +279,36 @@ pub async fn revoke_grant(
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
// D-Prep dual-write: clear the role_grants row for this (subject,
// resource). Idempotent — succeeds whether or not the row existed.
//
// Today's API revokes one access_grants row by id; the role_grants
// row models the WHOLE (subject, resource) cluster. Calling clear_role
// here effectively revokes the WHOLE role assignment in role_grants,
// even if other per-permission access_grants rows remain. This is the
// correct semantics for the eventual cleanup-PR model (role_grants is
// role-keyed; once access_grants goes away, "revoke" means "drop the
// role"). During the dual-write window the two tables can drift
// briefly if a caller revokes only some permissions of a role, but
// the engine still reads access_grants so behaviour is unchanged.
if let Err(e) = authz.clear_role(subject, resource).await {
return AppError::from(e).into_response();
}
tracing::info!(
target: "audit",
event = "role_grant.revoked",
caller_id = %caller_id,
grant_id = %grant_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
granter_id = %granter,
self_revoke = (granter == caller_id),
"🗑️ grant revoked",
);
StatusCode::NO_CONTENT.into_response()
}
@@ -486,9 +501,8 @@ pub async fn set_role(
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let role: Role = dto.role.into();
let expires_at = dto.expires_at;
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// Caller must have Share on the resource.
if let Err(e) = authz
@@ -498,84 +512,41 @@ pub async fn set_role(
return AppError::from(e).into_response();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz
.grant(caller_id, subject, *perm, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// includes newly added ones and any that were already present (retained).
// Callers that omit expires_at will clear any existing expiry; this is
// intentional: it keeps all permission rows for the pair consistent.
if let Err(e) = authz
.set_expiry_on_resource(subject, resource, expires_at)
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
// no race window. Returns the resulting role row.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
tracing::info!(
target: "audit",
event = "role_grant.role_set",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🔁 role set to '{}'", role.as_str(),
);
(StatusCode::OK, Json(mine)).into_response()
(StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
),
security(("bearerAuth" = [])),
tag = "grants"
@@ -583,12 +554,11 @@ pub struct IncomingQuery {
pub async fn list_incoming(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state
.authorization
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.list_incoming_grants(Subject::User(caller_id))
.await
{
Ok(grants) => {
@@ -691,83 +661,64 @@ pub async fn list_shared_with_me(
.map(|s| s.resource_id.to_string())
.collect();
// Resolve resource details concurrently (files and folders in parallel).
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Resolve resource details in two batch queries (was one per id via
// join_all, which could fan out to ~limit concurrent pooled connections
// and starve the primary pool). Missing ids — stale grants whose resource
// was deleted before the cascade trigger fired — drop out of the maps.
let (file_list, folder_list) = tokio::join!(
file_service.get_files_by_ids(&file_ids),
folder_service.get_folders_by_ids(&folder_ids)
);
let file_map: HashMap<String, _> = match file_list {
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let folder_map: HashMap<String, _> = match folder_list {
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
// Build the unified item list in original grant order (newest first).
// We iterate summaries in order and pick the resolved result from the
// appropriate typed bucket.
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
// Build the unified item list in original grant order (newest first),
// looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
// Stale grant (file deleted, trigger not yet fired) — skip silently.
warn!(
"Skipping stale file grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch file {}: {e}",
summary.resource_id
))
.into_response();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale folder grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch folder {}: {e}",
summary.resource_id
))
.into_response();
}
None => warn!(
"Skipping stale file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
}
None => warn!(
"Skipping stale folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
@@ -936,13 +887,20 @@ pub async fn list_my_shares(
.map(|s| s.resource_id.to_string())
.collect();
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Two batch queries instead of one get_* per id (see list_shared_with_me).
let (file_list, folder_list) = tokio::join!(
file_service.get_files_by_ids(&file_ids),
folder_service.get_folders_by_ids(&folder_ids)
);
let file_map: HashMap<String, _> = match file_list {
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let folder_map: HashMap<String, _> = match folder_list {
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
Err(e) => return AppError::from(e).into_response(),
};
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
@@ -962,64 +920,39 @@ pub async fn list_my_shares(
})
.collect();
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing file grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch file {}: {e}",
summary.resource_id
))
.into_response();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
grants,
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
grants,
});
}
Err(e) if e.kind == ErrorKind::NotFound => {
warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
);
}
Err(e) => {
return AppError::internal_error(format!(
"Failed to fetch folder {}: {e}",
summary.resource_id
))
.into_response();
}
None => warn!(
"Skipping stale outgoing file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
grants,
});
}
}
None => warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
+1
View File
@@ -16,6 +16,7 @@ pub mod grant_handler;
pub mod i18n_handler;
pub mod magic_link_handler;
pub mod music_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod search_handler;
@@ -0,0 +1,177 @@
//! HTTP handlers for the People (faces) feature.
//!
//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service
//! is present in `AppState`); each handler is also defensive. All work is
//! strictly caller-scoped by `PeopleService` (the repository filters by user).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use uuid::Uuid;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
fn disabled() -> Response {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "People feature is disabled" })),
)
.into_response()
}
fn bad_id() -> Response {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid id" })),
)
.into_response()
}
/// GET /api/people — identity clusters for the caller.
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.list_people(auth_user.id).await {
Ok(people) => Json(people).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/{id}/photos — file ids of a person's photos.
pub async fn person_photos(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.person_photos(auth_user.id, person_id).await {
Ok(files) => Json(files).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct RenameBody {
pub name: Option<String>,
}
/// PATCH /api/people/{id} — name (or clear the name of) a person.
pub async fn rename_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<RenameBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.rename_person(auth_user.id, person_id, body.name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct HideBody {
pub hidden: bool,
}
/// POST /api/people/{id}/hide — hide/unhide a person from the grid.
pub async fn hide_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<HideBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.set_hidden(auth_user.id, person_id, body.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct MergeBody {
pub into: String,
pub from: String,
}
/// POST /api/people/merge — merge `from` into `into`.
pub async fn merge_people(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(body): Json<MergeBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else {
return bad_id();
};
match svc.merge(auth_user.id, into, from).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// POST /api/people/recluster — re-run identity clustering for the caller.
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.recluster(auth_user.id).await {
Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// DELETE /api/people/data — erase all of the caller's face data.
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.delete_all(auth_user.id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
pub async fn faces_for_file(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(fid) = Uuid::parse_str(&file_id) else {
return bad_id();
};
match svc.faces_for_file(auth_user.id, fid).await {
Ok(boxes) => Json(boxes).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
+99 -6
View File
@@ -4,11 +4,12 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::geo_dto::GeoBounds;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser;
@@ -21,6 +22,20 @@ pub struct PhotosQueryParams {
pub limit: Option<i64>,
}
/// Photos-timeline item: a `FileDto` plus the image's original pixel
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
/// the gallery can lay tiles out at their true aspect ratio without a
/// second per-file metadata round-trip.
#[derive(Serialize)]
struct PhotoDto {
#[serde(flatten)]
file: FileDto,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
}
/// Lists all image/video files for the authenticated user, sorted by
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
///
@@ -55,17 +70,22 @@ pub async fn list_photos(
.list_media_files(user_id, params.before, limit)
.await
{
Ok((files, sort_dates)) => {
Ok((files, sort_dates, dims)) => {
info!("Photos: returned {} media files for user", files.len());
// Convert to DTOs with sort_date populated
let dtos: Vec<FileDto> = files
// Convert to DTOs with sort_date + pixel dimensions populated.
let dtos: Vec<PhotoDto> = files
.into_iter()
.zip(sort_dates.iter())
.map(|(file, &sd)| {
.zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64);
dto
PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
height: h.map(|v| v.max(0) as u32),
}
})
.collect();
@@ -91,3 +111,76 @@ pub async fn list_photos(
}
}
}
/// Query parameters for the photos map (clustered) endpoint.
#[derive(Deserialize)]
pub struct GeoQueryParams {
/// Bounding box as `west,south,east,north` (decimal degrees).
pub bbox: String,
/// Slippy-map zoom level (0–20); controls cluster granularity.
pub zoom: Option<u8>,
}
/// Lists the caller's geotagged photos aggregated into map clusters within a
/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted
/// when the Places service is present).
#[utoipa::path(
get,
path = "/api/photos/geo",
params(
("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"),
("zoom" = Option<u8>, Query, description = "Map zoom level (0-20), controls cluster size")
),
responses(
(status = 200, description = "Geotagged photos aggregated into map clusters"),
(status = 400, description = "Invalid bounding box"),
(status = 401, description = "Unauthorized")
),
security(("bearerAuth" = [])),
tag = "photos"
)]
pub async fn list_photos_geo(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<GeoQueryParams>,
) -> impl IntoResponse {
let Some(places) = state.places_service.as_ref() else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Places feature is disabled" })),
)
.into_response();
};
let coords: Vec<f64> = params
.bbox
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if coords.len() != 4 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })),
)
.into_response();
}
let bounds = GeoBounds {
west: coords[0],
south: coords[1],
east: coords[2],
north: coords[3],
};
let zoom = params.zoom.unwrap_or(3);
match places.clusters(auth_user.id, bounds, zoom).await {
Ok(clusters) => Json(clusters).into_response(),
Err(err) => {
error!("Error listing photo geo clusters: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("{}", err) })),
)
.into_response()
}
}
}
+3 -2
View File
@@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
SubjectTypeDto, UpdateRoleDto,
};
use crate::application::dtos::i18n_dto::{
@@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::recent_handler::clear_recent_items,
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
// Batch handlers (free functions)
handlers::batch_handler::move_files_batch,
handlers::batch_handler::copy_files_batch,
@@ -351,7 +352,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
ResourceTypeDto,
ResourceDto,
PermissionDto,
Role,
RoleDto,
CreateGrantDto,
UpdateRoleDto,
GrantDto,
+24 -4
View File
@@ -6,7 +6,7 @@ use axum::{
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Json as AxumJson, Response},
routing::{any, delete, get, post, put},
routing::{any, delete, get, patch, post, put},
};
use serde_json::json;
use std::sync::Arc;
@@ -431,13 +431,33 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
{
use crate::interfaces::api::handlers::photos_handler;
let photos_router = Router::new()
.route("/", get(photos_handler::list_photos))
.with_state(app_state.clone());
let mut photos_router = Router::new().route("/", get(photos_handler::list_photos));
if app_state.places_service.is_some() {
photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo));
}
let photos_router = photos_router.with_state(app_state.clone());
router = router.nest("/photos", photos_router);
}
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
if app_state.people_service.is_some() {
use crate::interfaces::api::handlers::people_handler;
let people_router = Router::new()
.route("/", get(people_handler::list_people))
.route("/merge", post(people_handler::merge_people))
.route("/recluster", post(people_handler::recluster))
.route("/data", delete(people_handler::delete_all))
.route("/faces/{file_id}", get(people_handler::faces_for_file))
.route("/{id}", patch(people_handler::rename_person))
.route("/{id}/photos", get(people_handler::person_photos))
.route("/{id}/hide", post(people_handler::hide_person))
.with_state(app_state.clone());
router = router.nest("/people", people_router);
}
// Re-enable trash routes to make the trash view work
if let Some(_trash_service_ref) = trash_service.clone() {
tracing::info!("Setting up trash routes for trash view");
+46 -17
View File
@@ -14,6 +14,7 @@ use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Validate the request's JWT (from the `Authorization: Bearer …` header
/// or the access-token cookie) and require `claims.role == "admin"`.
@@ -43,19 +44,37 @@ pub async fn require_admin(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
if claims.role != "admin" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
));
}
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
// Gate on the *live* role, not the JWT claim: a demotion or deactivation
// must take effect within the flags-cache TTL rather than surviving until
// the token expires.
match resolve_live_role(
auth.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) if role == "admin" => Ok((user_id, role)),
LiveRole::Active(role) => {
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %user_id,
role = %role,
"👮🏻‍♂️ admin-only endpoint denied for non-admin caller"
);
Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
))
}
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
}
}
/// Validate the request's JWT (any role) and return `(user_id, role)`.
@@ -84,9 +103,19 @@ pub async fn require_authenticated(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
// Reject tokens whose account was deactivated/deleted, and return the
// caller's live role rather than the (possibly stale) JWT claim.
match resolve_live_role(
auth.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => Ok((user_id, role)),
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
}
}
+164 -41
View File
@@ -13,6 +13,7 @@ use crate::common::di::AppState;
// Re-export CurrentUser from application layer for use in handlers
pub use crate::application::dtos::user_dto::CurrentUser;
use crate::application::ports::auth_ports::TokenServicePort;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Marker inserted into request extensions when the user was authenticated
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
@@ -111,6 +112,9 @@ pub enum AuthError {
#[error("User not found")]
UserNotFound,
#[error("Account is no longer active")]
AccountInactive,
#[error("Access denied: {0}")]
AccessDenied(String),
@@ -127,6 +131,10 @@ impl IntoResponse for AuthError {
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
AuthError::AccountInactive => (
StatusCode::UNAUTHORIZED,
"Account is no longer active".to_string(),
),
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
AuthError::AuthServiceUnavailable => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -181,11 +189,26 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// A cryptographically valid token must not outlive the
// account: re-check the live record so deactivation,
// deletion and demotion take effect within the flags-cache
// TTL instead of waiting for token expiry. The returned
// role is authoritative — never the frozen JWT claim.
let role = match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => role,
LiveRole::Revoked => return Err(AuthError::AccountInactive),
};
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
@@ -240,17 +263,12 @@ pub async fn auth_middleware(
}
Err(e) => {
tracing::warn!("App password verification failed: {}", e);
// For WebDAV: include WWW-Authenticate so the client
// knows to re-prompt rather than silently failing.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(
"Invalid username or app password",
))
.unwrap());
// For DAV clients: include WWW-Authenticate so the client
// re-prompts for credentials rather than failing silently.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge(
"Invalid username or app password",
));
}
return Err(AuthError::InvalidToken(
"Invalid username or app password".to_string(),
@@ -285,16 +303,33 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
return Ok(next.run(request).await);
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
// /login, exactly like an invalid or expired cookie.
match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.await
{
LiveRole::Active(role) => {
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role,
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
// Fall through to the unauthenticated 401 / login redirect.
}
}
}
Err(e) => {
tracing::debug!("Cookie token validation failed: {}", e);
@@ -312,27 +347,48 @@ pub async fn auth_middleware(
return Err(AuthError::AuthServiceUnavailable);
}
// For WebDAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Nautilus, Cyberduck,
// Windows Explorer, macOS Finder) know to prompt for a username/password.
// Non-WebDAV routes return the standard AuthError which renders without
// this header — keeping browser sessions redirecting to /login as before.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from("Authentication required"))
.unwrap());
// For DAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Thunderbird, DAVx5,
// Apple Calendar/Contacts, Nautilus, Cyberduck, Windows Explorer, macOS
// Finder) know to prompt for credentials and retry. Unlike `curl -u`, these
// clients do NOT send Basic credentials preemptively — without the
// challenge they never authenticate and fail with "discovery failed" / 401.
// Non-DAV routes return the standard AuthError which renders without this
// header — keeping browser sessions redirecting to /login as before.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge("Authentication required"));
}
Err(AuthError::TokenNotProvided)
}
/// DAV protocol surfaces (WebDAV, CalDAV, CardDAV) authenticate over HTTP Basic.
/// Spec-compliant clients (Thunderbird, DAVx5, Apple Calendar/Contacts, file
/// managers) only send credentials after receiving a `401` carrying a
/// `WWW-Authenticate: Basic` challenge, so these paths must emit it. Browser and
/// JSON-API routes deliberately do not, so they keep redirecting to `/login`.
fn is_dav_path(path: &str) -> bool {
path.starts_with("/webdav") || path.starts_with("/caldav") || path.starts_with("/carddav")
}
/// Build the `401 Unauthorized` Basic-auth challenge shared by every DAV
/// surface, so clients re-prompt for credentials instead of failing silently.
fn dav_basic_auth_challenge(message: &'static str) -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(message))
.unwrap()
}
/// Middleware to verify that the authenticated user has an admin role.
///
/// Must be applied AFTER auth_middleware, as it depends on
/// `CurrentUser` being present in the request extensions.
/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser`
/// being present in the request extensions. The role carried by
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
/// here within the flags-cache TTL.
pub async fn require_admin(request: Request, next: Next) -> Response {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
@@ -340,16 +396,83 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
tracing::debug!("Admin access granted for user: {}", current_user.username);
return next.run(request).await;
}
tracing::warn!(
"Admin access denied for user: {} (role: {})",
current_user.username,
current_user.role
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %current_user.id,
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
} else {
tracing::warn!("Admin check failed: no authenticated user in request");
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
}
// Access denied
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dav_paths_receive_basic_auth_challenge() {
// Regression for #480: CalDAV/CardDAV clients (Thunderbird, DAVx5) only
// send credentials after a 401 carrying WWW-Authenticate. All three DAV
// surfaces must qualify so the challenge is emitted.
for path in [
"/webdav/",
"/webdav/admin/file.txt",
"/caldav/",
"/caldav/admin/cal/",
"/carddav/",
"/carddav/principals/admin/",
] {
assert!(is_dav_path(path), "{path} should be treated as a DAV path");
}
}
#[test]
fn non_dav_paths_do_not_receive_basic_auth_challenge() {
for path in [
"/",
"/api/files",
"/login",
"/index.html",
"/.well-known/caldav",
] {
assert!(
!is_dav_path(path),
"{path} must not get a Basic-auth challenge (browser/API surface)"
);
}
}
#[test]
fn challenge_sets_www_authenticate_header() {
let resp = dav_basic_auth_challenge("Authentication required");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok()),
Some(r#"Basic realm="OxiCloud""#),
);
}
#[test]
fn account_inactive_maps_to_401() {
// A token that is still cryptographically valid but whose account was
// deactivated/deleted must be rejected with 401 (credentials no longer
// valid), so browsers redirect to /login rather than seeing a 403.
let resp = AuthError::AccountInactive.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
+136 -1
View File
@@ -32,7 +32,8 @@ use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::common::di::AppState;
use crate::domain::entities::user::UserRole;
use crate::domain::entities::user::{UserFlags, UserRole};
use crate::domain::errors::{DomainError, ErrorKind};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
@@ -102,6 +103,91 @@ pub async fn require_admin_user(
Ok(())
}
/// Outcome of re-checking a token-authenticated caller against the live
/// user record (see [`resolve_live_role`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LiveRole {
/// The account exists and is active. Carries the caller's *current*
/// role string (`"admin"` / `"user"`), which is authoritative and
/// supersedes the — possibly stale — JWT `role` claim.
Active(String),
/// The account is deactivated or deleted: the request must be rejected
/// even though its token is still cryptographically valid.
Revoked,
}
/// Re-validate a caller carried by a still-valid token against the live
/// user record, so deactivation, deletion and role changes take effect
/// within [`USER_FLAGS_CACHE_TTL`](crate::application::services::auth_application_service)
/// instead of waiting for the token to expire (access 1 h / refresh 7 d by
/// default).
///
/// JWT claims — `role` included — are frozen at login. Without this check a
/// demoted admin keeps admin power, and a disabled or deleted account keeps
/// full access, until its token expires. Returning the *current* role lets
/// every caller stop trusting `claims.role`.
///
/// Cost: the short-TTL-cached `get_user_flags` (no `image` column), so ~one
/// tiny indexed query per user per cache-TTL window; admin role/active
/// changes invalidate the entry eagerly for immediate effect.
///
/// Availability stance mirrors [`require_internal_user`]: a *transient*
/// lookup failure fails OPEN with the claim role (a DB blip must not lock
/// every authenticated user out, and login/refresh already enforce `active`
/// at the canonical layer). A *missing* row (`NotFound`) is a definitive
/// revocation and fails CLOSED.
pub async fn resolve_live_role(
auth: &AuthApplicationService,
user_id: Uuid,
claim_role: &str,
) -> LiveRole {
decide_live_role(auth.get_user_flags(user_id).await, user_id, claim_role)
}
/// Pure decision core of [`resolve_live_role`], split out so the
/// allow/revoke/fail-open policy is unit-testable without a service or DB.
fn decide_live_role(
flags: Result<UserFlags, DomainError>,
user_id: Uuid,
claim_role: &str,
) -> LiveRole {
match flags {
Ok(flags) if flags.active => LiveRole::Active(flags.role.to_string()),
Ok(_) => {
audit_token_revoked(user_id, "deactivated");
LiveRole::Revoked
}
// The user row is gone — a definitive revocation; fail closed.
Err(e) if matches!(e.kind, ErrorKind::NotFound) => {
audit_token_revoked(user_id, "deleted");
LiveRole::Revoked
}
// Transient lookup failure (DB blip): fail open on the claim role so
// a momentary outage doesn't 401 every authenticated user at once.
Err(e) => {
tracing::warn!(
user_id = %user_id,
error = %e,
"live-user re-check failed transiently; allowing request on the JWT claim role (fail-open)"
);
LiveRole::Active(claim_role.to_string())
}
}
}
/// Audit a request rejected because the token outlived the account's access
/// (deactivation or deletion). Anti-enumeration is not a concern — the
/// subject is the caller's own account.
fn audit_token_revoked(user_id: Uuid, reason: &'static str) {
tracing::info!(
target: "audit",
event = "auth.token_revoked",
reason = reason,
caller_id = %user_id,
"👮🏻‍♂️ valid token presented for an account that is no longer active — rejected"
);
}
/// Axum middleware layer that blocks external users from a whole route
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
@@ -153,3 +239,52 @@ pub async fn require_internal_user_layer(
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
fn flags(role: UserRole, active: bool) -> UserFlags {
UserFlags {
role,
is_external: false,
active,
}
}
#[test]
fn active_admin_yields_current_admin_role() {
let live = decide_live_role(Ok(flags(UserRole::Admin, true)), Uuid::nil(), "user");
// The live record wins over the (stale) claim — a freshly promoted
// user is admin even though their token still says "user".
assert_eq!(live, LiveRole::Active("admin".to_string()));
}
#[test]
fn active_user_yields_current_user_role() {
// A demoted admin: token claim still "admin", live record "user".
let live = decide_live_role(Ok(flags(UserRole::User, true)), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("user".to_string()));
}
#[test]
fn deactivated_account_is_revoked() {
let live = decide_live_role(Ok(flags(UserRole::Admin, false)), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Revoked);
}
#[test]
fn deleted_account_not_found_is_revoked() {
let err = DomainError::new(ErrorKind::NotFound, "User", "no such user");
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Revoked);
}
#[test]
fn transient_error_fails_open_on_claim_role() {
// A DB blip must not lock everyone out: allow on the claim role.
let err = DomainError::new(ErrorKind::InternalError, "User", "connection reset");
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("admin".to_string()));
}
}
+34 -8
View File
@@ -7,7 +7,7 @@ use quick_xml::{
Reader, Writer,
events::{BytesEnd, BytesStart, Event},
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
@@ -17,7 +17,6 @@ use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
@@ -86,20 +85,47 @@ async fn handle_filter_files(
let home_prefix = format!("My Folder - {}/", user.username);
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
// concern from the oc:fileid resolution batched below).
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
// REPORT). Results are looked up by id so the response keeps favorites
// order; missing/trashed favorites simply drop out (as before).
let mut file_ids: Vec<String> = Vec::new();
let mut folder_ids: Vec<String> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => file_ids.push(fav.item_id.clone()),
"folder" => folder_ids.push(fav.item_id.clone()),
_ => {}
}
}
let file_map: HashMap<String, FileDto> = file_service
.get_files_by_ids(&file_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
.into_iter()
.map(|f| (f.id.clone(), f))
.collect();
let folder_map: HashMap<String, FolderDto> = folder_service
.get_folders_by_ids(&folder_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
.into_iter()
.map(|f| (f.id.clone(), f))
.collect();
let mut files: Vec<FileDto> = Vec::new();
let mut folders: Vec<FolderDto> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
if let Ok(f) = file_service.get_file(&fav.item_id).await {
files.push(f);
if let Some(f) = file_map.get(&fav.item_id) {
files.push(f.clone());
}
}
"folder" => {
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
folders.push(f);
if let Some(f) = folder_map.get(&fav.item_id) {
folders.push(f.clone());
}
}
_ => {}
+3 -1
View File
@@ -311,7 +311,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
use oxicloud::interfaces::api::handlers::carddav_handler;
use oxicloud::interfaces::api::handlers::webdav_handler;
let caldav_router = caldav_handler::caldav_routes();
let well_known_router = caldav_handler::well_known_routes();
// RFC 6764 discovery for both CalDAV and CardDAV (public redirects).
let well_known_router =
caldav_handler::well_known_routes().merge(carddav_handler::well_known_routes());
let carddav_router = carddav_handler::carddav_routes();
let webdav_router = webdav_handler::webdav_routes();