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