2026-05-20 22:56:00 +02:00
//! REST handlers for the ReBAC grant management endpoints.
//!
//! All endpoints under `/api/grants`. The authenticated caller is taken from
//! the `AuthUser` extractor. Authorization for sharing operations is enforced
//! via `authz.require(caller, Share, resource)` — handlers never embed their
//! own checks (see CLAUDE.md § Authorization).
use axum ::{
Json ,
extract ::{ Path , Query , State },
http ::StatusCode ,
response ::IntoResponse ,
};
use serde ::Deserialize ;
2026-06-19 11:27:11 +00:00
use std ::collections ::HashMap ;
2026-05-20 22:56:00 +02:00
use std ::sync ::Arc ;
2026-06-17 23:14:25 +02:00
use tracing ::{ error , warn };
2026-05-20 22:56:00 +02:00
use utoipa ::IntoParams ;
use uuid ::Uuid ;
2026-05-26 17:52:28 +02:00
use crate ::application ::dtos ::cursor ::PageCursor ;
2026-06-22 23:52:44 +02:00
use crate ::application ::dtos ::drive_dto ::DriveDto ;
2026-05-20 22:56:00 +02:00
use crate ::application ::dtos ::grant_dto ::{
2026-06-05 09:46:51 +02:00
CreateGrantDto , CreateGrantResponseDto , GrantDto , MySharesDto , NotifyOutcomeSetDto ,
2026-06-18 01:42:32 +02:00
OutgoingResourceGrantDto , OutgoingResourceItemDto , ResourceContentDto , ResourceDto ,
ResourceTypeDto , SharedWithMeDto , SharedWithMeItemDto , SharedWithMeQuery , SubjectDto ,
SubjectInputDto , UpdateRoleDto , role_from_permissions ,
2026-05-20 22:56:00 +02:00
};
use crate ::application ::ports ::authorization_ports ::AuthorizationEngine ;
2026-06-05 09:46:51 +02:00
use crate ::application ::services ::recipient_notification_service ::NotifyTrigger ;
2026-05-24 01:05:13 +02:00
use crate ::common ::di ::AppState ;
#[allow(unused_imports)]
2026-05-20 22:56:00 +02:00
use crate ::common ::errors ::DomainError ;
2026-06-22 23:52:44 +02:00
use crate ::domain ::repositories ::drive_repository ::DriveRepository ;
2026-05-24 01:05:13 +02:00
use crate ::domain ::services ::authorization ::{
2026-05-29 02:16:15 +02:00
GrantCursor , IncomingGrantSummary , OutgoingResourceSummary , Permission , Resource , ResourceKind ,
2026-06-18 01:42:32 +02:00
Role , Subject ,
2026-05-24 01:05:13 +02:00
};
2026-05-20 22:56:00 +02:00
use crate ::interfaces ::errors ::AppError ;
use crate ::interfaces ::middleware ::auth ::AuthUser ;
2026-05-24 01:05:13 +02:00
type AppStateRef = Arc < AppState > ;
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
2026-06-22 23:52:44 +02:00
/// Share a resource with someone — kicks off the **social flow** (invite +
/// notification + lazy external-user provisioning if subject is an email).
///
/// **Use this when:** the recipient may not exist yet, hasn't been told,
/// or this is the initial grant. The handler:
/// - resolves `subject` (User / Group / Token / Email — email lazily creates
/// an external user via `MagicLinkInviteService::resolve_or_create_recipient`),
/// - writes one role-grant row via `authz.set_role` (`ON CONFLICT UPDATE`),
/// - sends a share-notification email (magic-link arm for externals, plain
/// notification for internal users — both honour `notify_on_share` opt-out
/// and per-recipient rate limits).
///
/// **Compare with `PUT /api/grants/role`:** that endpoint is the *silent*
/// admin-style role change for an already-known subject; it skips the
/// invitation and notification side-effects entirely. Both write through
/// the same idempotent UPSERT, so the only operational difference is the
/// social flow attached here.
///
/// **Drive resources** (`resource.type == "drive"`) are routed through
/// `DriveManagementService.set_member_role` internally — the
/// personal-drive guard and shared-drive last-owner protection apply
/// no matter which endpoint creates the grant.
2026-05-20 22:56:00 +02:00
#[utoipa::path(
post,
path = "/api/grants" ,
request_body = CreateGrantDto,
responses(
2026-06-05 09:46:51 +02:00
(status = 201, description = "Grant(s) created" , body = CreateGrantResponseDto),
2026-05-20 22:56:00 +02:00
(status = 400, description = "Invalid input (both/neither of permissions+role provided)" ),
(status = 404, description = "Resource not found OR caller lacks Share permission" ),
2026-06-22 23:52:44 +02:00
(status = 405, description = "Drive resource: personal drives have immutable membership" ),
2026-05-20 22:56:00 +02:00
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn create_grant (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Json ( dto ) : Json < CreateGrantDto > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
2026-06-18 01:42:32 +02:00
let role : Role = dto . role . into ();
2026-05-20 22:56:00 +02:00
let resource : Resource = dto . resource . into ();
2026-05-28 19:56:31 +02:00
let expires_at = dto . expires_at ;
2026-05-20 22:56:00 +02:00
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
2026-06-26 01:48:39 +02:00
// D5: load the resource's owning drive policies in one round-trip
// and gate `forbid_external_sharing` (early refusal for email
// subjects below + late refusal for resolved external users further
// down). `forbid_sharing` (the next D5 policy) will read the same
// fetched bag — see `docs/plan/drive.md` §8.
let drive_policies = match resource {
Resource ::File ( id ) => state . drive_repo . get_policies_for_file ( id ). await ,
Resource ::Folder ( id ) => state . drive_repo . get_policies_for_folder ( id ). await ,
Resource ::Drive ( id ) => state
. drive_repo
. get_by_id ( id )
. await
. map ( | d | d . drive . typed_policies ()),
2026-07-08 00:23:25 +02:00
// Calendars, address books and playlists live outside the
// drive hierarchy (top-level per user), so no drive-level
// policy gates apply. If per-resource policies ever ship for
// these kinds, they'll live on the resource itself, not on a
2026-07-06 00:38:17 +02:00
// drive; the default-empty bag is the right no-op here.
2026-07-08 00:23:25 +02:00
Resource ::Calendar ( _ ) | Resource ::AddressBook ( _ ) | Resource ::Playlist ( _ ) => {
2026-07-06 00:38:17 +02:00
Ok ( crate ::domain ::entities ::drive ::DrivePolicies ::default ())
}
2026-06-26 01:48:39 +02:00
};
let drive_policies = match drive_policies {
Ok ( p ) => p ,
Err ( e ) => {
return AppError ::internal_error ( format! ( "drive policy lookup: {e:?} " )). into_response ();
}
};
// D5 — `forbid_sharing`: refuses per-resource grants on
// File / Folder when the drive's policy is on. Drive-resource
// grants intentionally bypass this gate — they're drive
// membership, not per-resource sharing (§8 semantic carve-out).
if ! matches! ( resource , Resource ::Drive ( _ ))
&& let Err ( e ) =
drive_policies . refuse_sharing ( crate ::domain ::entities ::drive ::SharingGateContext {
caller_id ,
resource_type : resource . type_str (),
resource_id : resource . id (),
})
{
return AppError ::from ( e ). into_response ();
}
// D5 — `forbid_public_links`: Token subjects on `POST /api/grants`
// create exactly the anonymous-link grant that this policy is meant
// to block — the canonical surface is `share_service::create_shared_link`
// but the same kind of grant can be minted here by passing
// `subject.type=token`. Use the same shared gate so the refusal
// shape stays in lockstep with the share-handler path.
if matches! ( & dto . subject , SubjectInputDto ::Token { .. })
&& let Err ( e ) = drive_policies . refuse_public_links (
crate ::domain ::entities ::drive ::PublicLinkGateContext {
caller_id ,
item_type : resource . type_str (),
item_id : resource . id (),
},
)
{
return AppError ::from ( e ). into_response ();
}
// D5 — `forbid_external_sharing` (early): when the caller is sharing
// by email, refuse BEFORE `resolve_or_create_recipient` runs so the
// policy never side-effects a fresh external-user row. Existing
// external users are caught by the late check below.
if drive_policies . forbid_external_sharing
&& matches! ( & dto . subject , SubjectInputDto ::Email { .. })
{
tracing ::info! (
target : "audit" ,
event = "grant.rejected" ,
reason = "forbid_external_sharing" ,
stage = "early_email" ,
caller_id = % caller_id ,
resource_type = resource . type_str (),
resource_id = % resource . id (),
"👮🏻♂️ email-grant refused: drive policy forbid_external_sharing" ,
);
return AppError ::from ( DomainError ::operation_not_supported (
"Grant" ,
"This drive does not allow external sharing." ,
))
. into_response ();
}
2026-06-02 10:47:37 +02:00
// Resolve the subject. For the email variant this lazily provisions
// an external user (or reuses an existing match) and remembers the
// resolved User so the invitation email can be sent after the grant
// rows land.
let ( subject , invite_recipient ) = match dto . subject {
SubjectInputDto ::User { id } => ( Subject ::User ( id ), None ),
SubjectInputDto ::Group { id } => ( Subject ::Group ( id ), None ),
SubjectInputDto ::Token { id } => ( Subject ::Token ( id ), None ),
SubjectInputDto ::Email { email } => {
let Some ( invite_svc ) = state . magic_link_invite_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Magic-link invitations are not configured on this server \
(set OXICLOUD_SMTP_HOST in .env to enable)" ,
"ServiceUnavailable" ,
)
. into_response ();
};
2026-06-02 14:23:31 +02:00
// PR 12 — per-sharer ceiling: 50 email-invitations / hour
// per caller. Hitting the cap returns 429 because the
// caller is authenticated and rate-limit visibility leaks
// nothing they don't already know about their own
// behaviour.
if state
. email_invite_rate_limiter
. check_and_increment ( & caller_id . to_string ())
. is_err ()
{
tracing ::warn! (
target : "audit" ,
event = "grants.email_invite" ,
reason = "rate_limited" ,
caller_id = % caller_id ,
"Per-sharer email-invite rate limit exceeded"
);
return crate ::interfaces ::middleware ::rate_limit ::too_many_requests (
state . email_invite_rate_limiter . retry_after (),
);
}
2026-06-03 14:26:45 +02:00
// PR C: pass the inviter id so resolve_or_create_recipient
// can inherit their preferred_locale onto a freshly-
// provisioned external user (best-effort; lookup failure
// just leaves the new row's locale NULL, no hard error).
match invite_svc
. resolve_or_create_recipient ( & email , Some ( caller_id ))
. await
{
2026-06-02 10:47:37 +02:00
Ok ( user ) => ( Subject ::User ( user . id ()), Some ( user )),
Err ( e ) => return AppError ::from ( e ). into_response (),
}
}
};
2026-06-26 01:48:39 +02:00
// D5 — `forbid_external_sharing` (late) for File/Folder ONLY:
// catches the case where the subject resolved to a pre-existing
// external user. The early check above only fires for email-input;
// this one closes the user-by-id loophole.
//
// Drive resources are deliberately skipped here — they route through
// `set_member_role` below, which runs the SAME gate
// (`DrivePolicies::refuse_external_sharing`) at the service layer.
// That one service-layer check also covers `POST /api/drives/{id}/members`
// and its PATCH sibling, where no grant_handler runs. Checking
// again here for Drive would duplicate the user-flags lookup.
//
// `invite_recipient` carries the User entity when we just came from
// the email path — read its `is_external` flag instead of a
// redundant lookup; otherwise probe via `get_user_flags`.
if drive_policies . forbid_external_sharing
&& ! matches! ( resource , Resource ::Drive ( _ ))
&& let Subject ::User ( uid ) = subject
{
let is_external = if let Some ( user ) = invite_recipient . as_ref () {
user . is_external ()
} else if let Some ( auth_svc ) = state . auth_service . as_ref () {
match auth_svc . auth_application_service . get_user_flags ( uid ). await {
Ok ( flags ) => flags . is_external ,
Err ( e ) => {
return AppError ::internal_error ( format! ( "user flags lookup: {e:?} " ))
. into_response ();
}
}
} else {
false
};
if let Err ( e ) = drive_policies . refuse_external_sharing (
subject ,
is_external ,
crate ::domain ::entities ::drive ::ExternalSharingGateContext {
caller_id ,
stage : "late_user" ,
drive_id : None ,
resource_type : Some ( resource . type_str ()),
resource_id : Some ( resource . id ()),
},
) {
return AppError ::from ( e ). into_response ();
}
}
2026-06-18 01:42:32 +02:00
// 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.
2026-06-22 23:52:44 +02:00
//
// Drive resources are routed through `DriveManagementService` so the
// personal-drive guard and shared-drive last-owner protection apply
// — the same guards that gate `/api/drives/{id}/members`. Defense in
// depth: a caller can't bypass them by hitting `/api/grants` directly.
let grant = if let Resource ::Drive ( drive_id ) = resource {
match state
. drive_management_service
2026-06-24 01:20:44 +02:00
. set_member_role ( caller_id , false , drive_id , subject , role , expires_at )
2026-06-22 23:52:44 +02:00
. await
{
Ok ( g ) => g ,
Err ( err ) => return AppError ::from ( err ). into_response (),
}
} else {
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 ();
}
2026-06-18 01:42:32 +02:00
}
};
let grants = vec! [ GrantDto ::from ( grant )];
2026-06-17 23:14:25 +02:00
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 (),
2026-05-20 22:56:00 +02:00
);
2026-06-02 10:47:37 +02:00
2026-06-05 09:46:51 +02:00
// PR N1 — route the post-grant notification through the unified
// RecipientNotificationService. Handles user/group/token subjects
// uniformly (Token subjects return an empty outcome set); applies
// per-(granter, recipient) coalesce + per-recipient hard rate
// limit; dispatches the magic-link arm (delegating to
// MagicLinkInviteService::issue_invitation) for eligible externals
// and the plain-notification arm for internal users; honours the
// per-user `notify_on_share` opt-out and the operator-level
// `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` flag. SMTP failures
// remain non-fatal — the grant rows are already in place and the
// service captures every per-recipient result as a NotifyOutcome
// rather than an Err.
//
// For the email-resolved subject variant we already loaded the
// recipient `User` above for the lazy-provision side effect; the
// notification service re-resolves the same id, which is cheap and
// keeps the entry-point signature uniform across subject types.
let _ = invite_recipient ; // value used only for its side effect above
// Load the granter as a full `User` entity — the notification
// service uses display fields (`username`, `given/family_name`) for
// the inviter label in the email body. Failure here means the JWT
// claims correspond to a user row that has since been deleted; we
// return the grants without a notification rather than rolling back.
let notification = match (
state . recipient_notification_service . as_ref (),
state . auth_service . as_ref (),
) {
( Some ( svc ), Some ( auth_svc )) => {
match auth_svc
. auth_application_service
. get_user_entity ( caller_id )
. await
{
Ok ( granter ) => match svc
. send_share_notification (
& granter ,
subject ,
resource ,
NotifyTrigger ::GrantCreated ,
)
. await
{
Ok ( set ) => set . to_dto (),
Err ( e ) => {
warn! (
"notification dispatch failed for grant action by {}: {}" ,
caller_id , e
);
NotifyOutcomeSetDto ::empty ()
}
},
Err ( e ) => {
warn! (
"granter {} user-row load failed; skipping notification: {}" ,
caller_id , e
);
NotifyOutcomeSetDto ::empty ()
}
}
2026-06-02 10:47:37 +02:00
}
2026-06-05 09:46:51 +02:00
_ => NotifyOutcomeSetDto ::empty (),
};
2026-06-02 10:47:37 +02:00
2026-06-05 09:46:51 +02:00
(
StatusCode ::CREATED ,
Json ( CreateGrantResponseDto {
2026-06-18 01:42:32 +02:00
grants ,
2026-06-05 09:46:51 +02:00
notification ,
}),
)
. into_response ()
2026-05-20 22:56:00 +02:00
}
// ════════════════════════════════════════════════════════════════════════════
// DELETE /api/grants/{id}
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
delete,
path = "/api/grants/{id}" ,
params(( "id" = String, Path, description = "Grant UUID" )),
responses(
(status = 204, description = "Grant revoked (or did not exist)" ),
(status = 404, description = "Caller lacks Share permission on the underlying resource" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn revoke_grant (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let grant_id = match Uuid ::parse_str ( & id ) {
Ok ( u ) => u ,
Err ( _ ) => return AppError ::not_found ( format! ( "Grant {id} not found" )). into_response (),
};
2026-06-17 23:14:25 +02:00
// 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 ,
2026-05-20 22:56:00 +02:00
Ok ( None ) => return StatusCode ::NO_CONTENT . into_response (), // idempotent
Err ( e ) => return AppError ::from ( e ). into_response (),
};
2026-06-22 23:52:44 +02:00
// Drive resources are routed through `DriveManagementService` so the
// personal-drive guard and shared-drive last-owner protection apply.
// Note: the "granter can always revoke" shortcut DOES NOT apply for
// drive grants — drive membership mutations always require current
// Manage on the drive, even if you granted the row originally and
// were later demoted.
if let Resource ::Drive ( drive_id ) = resource {
// Also revoke the legacy access_grants row for the dual-write
// window — `remove_member` handles the role_grants side.
if let Err ( e ) = authz . revoke ( grant_id ). await {
return AppError ::from ( e ). into_response ();
}
if let Err ( e ) = state
. drive_management_service
2026-06-24 01:20:44 +02:00
. remove_member ( caller_id , false , drive_id , subject )
2026-05-20 22:56:00 +02:00
. await
2026-06-22 23:52:44 +02:00
{
return AppError ::from ( e ). into_response ();
}
} else {
// Caller is authorized if they are the granter OR have Share on the resource.
if granter != caller_id
&& let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
2026-05-20 22:56:00 +02:00
2026-06-22 23:52:44 +02:00
if let Err ( e ) = authz . revoke ( grant_id ). await {
return AppError ::from ( e ). into_response ();
}
2026-06-17 23:14:25 +02:00
2026-06-22 23:52:44 +02:00
// 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 ();
}
2026-06-17 23:14:25 +02:00
}
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" ,
);
2026-05-20 22:56:00 +02:00
StatusCode ::NO_CONTENT . into_response ()
}
2026-06-05 09:46:51 +02:00
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants/{id}/notify — manual share-notification resend
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants/{id}/notify" ,
params(( "id" = String, Path, description = "Grant UUID" )),
responses(
(status = 204, description = "Notification(s) dispatched" ),
(status = 200, description = "Mixed outcome (some recipients coalesced / not-applicable); body carries the full NotifyOutcomeSet" , body = NotifyOutcomeSetDto),
(status = 404, description = "Grant not found OR caller is not the granter" ),
(status = 409, description = "Token subject (use the existing /magic/v1/{token}/resend channel)" ),
(status = 429, description = "Per-recipient hard rate limit exceeded" ),
),
security(( "bearerAuth" = [])),
tag = "grants"
)]
pub async fn notify_grant_recipient (
State ( state ) : State < AppStateRef > ,
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
let authz = & state . authorization ;
let caller_id = auth_user . id ;
let grant_id = match Uuid ::parse_str ( & id ) {
Ok ( u ) => u ,
Err ( _ ) => return AppError ::not_found ( format! ( "Grant {id} not found" )). into_response (),
};
// Load the grant. Anti-enumeration: missing AND not-owner both
// surface as 404 to the caller; only the audit row carries the
// real reason. Mirrors `revoke_grant`'s precedent.
let ( subject , resource , granter_id ) = match authz . find_grant_full_by_id ( grant_id ). await {
Ok ( Some ( t )) => t ,
Ok ( None ) => {
tracing ::info! (
target : "audit" ,
event = "grant.notify_skipped" ,
reason = "grant_not_found" ,
caller_id = % caller_id ,
grant_id = % grant_id ,
"🤫 manual notify rejected: grant {} not found" ,
grant_id ,
);
return AppError ::not_found ( format! ( "Grant {grant_id} not found" )). into_response ();
}
Err ( e ) => return AppError ::from ( e ). into_response (),
};
if granter_id != caller_id {
tracing ::info! (
target : "audit" ,
event = "grant.notify_skipped" ,
reason = "not_owner" ,
caller_id = % caller_id ,
grant_id = % grant_id ,
actual_granter = % granter_id ,
"🤫 manual notify rejected: caller {} is not the granter of {}" ,
caller_id ,
grant_id ,
);
return AppError ::not_found ( format! ( "Grant {grant_id} not found" )). into_response ();
}
// Token subjects can't be notified — the link share has no human
// recipient to email. Map to 409 so the frontend can hide the menu
// item for these as defense-in-depth (the v1 UI already does this
// client-side; this is the server-side enforcement).
if matches! ( subject , Subject ::Token ( _ )) {
return AppError ::new (
StatusCode ::CONFLICT ,
"Cannot notify a link-share recipient — token shares have no email channel" ,
"subject_is_token" ,
)
. into_response ();
}
// Load the granter entity (we are the granter; needed for the
// notification email body's "Alice shared X with you" salutation).
let Some ( auth_svc ) = state . auth_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Authentication subsystem not available" ,
"ServiceUnavailable" ,
)
. into_response ();
};
let granter = match auth_svc
. auth_application_service
. get_user_entity ( caller_id )
. await
{
Ok ( u ) => u ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
let Some ( svc ) = state . recipient_notification_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Notification service is not configured on this server \
(set OXICLOUD_SMTP_HOST in .env to enable)" ,
"ServiceUnavailable" ,
)
. into_response ();
};
let outcome_set = match svc
. send_share_notification ( & granter , subject , resource , NotifyTrigger ::ManualResend )
. await
{
Ok ( s ) => s ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
let dto = outcome_set . to_dto ();
// HTTP mapping per the plan:
// - empty outcomes (Token subject — already 409'd above; defense
// in depth) → 409
// - every outcome is Sent → 204 No Content
// - all RateLimited (no Sent) → 429 with the longest Retry-After
// - mixed → 200 with the full body
if dto . outcomes . is_empty () {
return AppError ::new (
StatusCode ::CONFLICT ,
"Grant has no notifiable recipients" ,
"subject_is_token" ,
)
. into_response ();
}
let any_sent = dto . outcomes . iter (). any ( | o | {
matches! (
o ,
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::Sent { .. }
)
});
let max_retry_after = dto
. outcomes
. iter ()
. filter_map ( | o | match o {
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::RateLimited {
retry_after_secs ,
} => Some ( * retry_after_secs ),
_ => None ,
})
. max ();
let all_sent = dto . outcomes . iter (). all ( | o | {
matches! (
o ,
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::Sent { .. }
)
});
if all_sent {
return StatusCode ::NO_CONTENT . into_response ();
}
if ! any_sent && let Some ( secs ) = max_retry_after {
return crate ::interfaces ::middleware ::rate_limit ::too_many_requests ( secs as u64 );
}
( StatusCode ::OK , Json ( dto )). into_response ()
}
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// PUT /api/grants/role
// ════════════════════════════════════════════════════════════════════════════
2026-06-22 23:52:44 +02:00
/// Change an existing member's role on a resource — **silent**, no
/// invitation, no notification.
///
/// **Use this when:** the subject is already known (user/group/token has
/// an existing grant or you've already informed them out-of-band) and you
/// just want to bump their role. Typical use is the management UI's
/// "Viewer → Editor" dropdown — you don't want a fresh share email
/// firing on every dropdown change.
///
/// **Compare with `POST /api/grants`:** that endpoint kicks off the
/// invitation + share-notification social flow and accepts an email-shaped
/// subject for lazy external-user provisioning. This endpoint is the
/// admin-style update — concrete subjects only, no side effects beyond
/// the role row itself.
///
/// Both endpoints write through the same idempotent UPSERT
/// (`authz.set_role`, unique on `(subject, resource)`), so they are
/// indistinguishable in their effect on the role-grants table — the
/// difference is purely the social side-effects attached to `POST`.
///
/// **Drive resources** (`resource.type == "drive"`) are routed through
/// `DriveManagementService.set_member_role` internally — the
/// personal-drive guard and shared-drive last-owner protection apply.
2026-05-20 22:56:00 +02:00
#[utoipa::path(
put,
path = "/api/grants/role" ,
request_body = UpdateRoleDto,
responses(
(status = 200, description = "Role applied; returns the new full grant set" , body = Vec<GrantDto>),
2026-06-22 23:52:44 +02:00
(status = 400, description = "Drive resource: shared-drive last-owner demotion refused" ),
2026-05-20 22:56:00 +02:00
(status = 404, description = "Resource not found or caller lacks Share" ),
2026-06-22 23:52:44 +02:00
(status = 405, description = "Drive resource: personal drives have immutable membership" ),
2026-05-20 22:56:00 +02:00
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn set_role (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Json ( dto ) : Json < UpdateRoleDto > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let subject : Subject = dto . subject . into ();
let resource : Resource = dto . resource . into ();
2026-06-18 01:42:32 +02:00
let role : Role = dto . role . into ();
2026-05-28 19:56:31 +02:00
let expires_at = dto . expires_at ;
2026-05-20 22:56:00 +02:00
// Caller must have Share on the resource.
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
2026-06-18 01:42:32 +02:00
// 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.
2026-06-22 23:52:44 +02:00
//
// Drive resources are routed through `DriveManagementService` so the
// personal-drive guard and shared-drive last-owner protection apply.
// See `create_grant` for the same delegation rationale.
let grant = if let Resource ::Drive ( drive_id ) = resource {
match state
. drive_management_service
2026-06-24 01:20:44 +02:00
. set_member_role ( caller_id , false , drive_id , subject , role , expires_at )
2026-06-22 23:52:44 +02:00
. await
{
Ok ( g ) => g ,
Err ( e ) => return AppError ::from ( e ). into_response (),
}
} else {
match authz
. set_role ( caller_id , subject , role , resource , expires_at )
. await
{
Ok ( g ) => g ,
Err ( e ) => return AppError ::from ( e ). into_response (),
}
2026-05-20 22:56:00 +02:00
};
2026-06-17 23:14:25 +02:00
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 (),
2026-06-18 01:42:32 +02:00
role = role . as_str (),
2026-06-17 23:14:25 +02:00
expires_at = ? expires_at ,
2026-06-18 01:42:32 +02:00
"🔁 role set to '{}'" , role . as_str (),
2026-05-20 22:56:00 +02:00
);
2026-06-18 01:42:32 +02:00
( StatusCode ::OK , Json ( vec! [ GrantDto ::from ( grant )])). into_response ()
2026-05-20 22:56:00 +02:00
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/incoming" ,
responses(
2026-06-18 01:42:32 +02:00
(status = 200, description = "Direct role grants targeting the caller" , body = Vec<GrantDto>),
2026-05-20 22:56:00 +02:00
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_incoming (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
2026-05-24 01:05:13 +02:00
match state
. authorization
2026-06-18 01:42:32 +02:00
. list_incoming_grants ( Subject ::User ( caller_id ))
2026-05-20 22:56:00 +02:00
. await
{
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-05-24 01:05:13 +02:00
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming/resources
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/incoming/resources" ,
params(SharedWithMeQuery),
responses(
(status = 200,
description = "Cursor-paginated resources shared with the caller. \
Each item carries the full file or folder details plus \
aggregated permissions. `next_cursor` is absent on the \
last page." ,
body = SharedWithMeDto),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-24 01:05:13 +02:00
tag = "grants"
)]
pub async fn list_shared_with_me (
State ( state ) : State < AppStateRef > ,
auth_user : AuthUser ,
Query ( q ) : Query < SharedWithMeQuery > ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
let subject = Subject ::User ( caller_id );
// Parse resource_types filter (unknown values silently ignored).
let kinds : Vec < ResourceKind > = q
. resource_types
. as_deref ()
. map ( | s | {
s . split ( ',' )
. filter_map ( | t | ResourceKind ::parse ( t . trim ()))
. collect ()
})
. unwrap_or_default ();
// Clamp limit to 1– 200.
2026-05-26 17:52:28 +02:00
let limit = q . limit_clamped () as u32 ;
2026-05-24 01:05:13 +02:00
2026-05-27 00:32:10 +02:00
// Validate sort_by (defaults to "granted_at").
let sort_by = q . sort_by . as_deref (). unwrap_or ( "granted_at" );
2026-05-29 13:10:13 +02:00
if ! matches! ( sort_by , "granted_at" | "granted_by" | "name" | "type" ) {
2026-05-27 00:32:10 +02:00
return (
StatusCode ::BAD_REQUEST ,
2026-05-29 13:10:13 +02:00
Json ( serde_json ::json! ({ "error" : "invalid sort_by; valid values: granted_at, granted_by, name, type" })),
2026-05-27 00:32:10 +02:00
)
. into_response ();
}
2026-05-24 01:05:13 +02:00
2026-05-28 00:44:20 +02:00
let reverse = q . reverse ;
// Decode cursor — discard it when the sort dimension or direction changed
// to avoid keyset confusion across sort modes.
2026-05-27 00:32:10 +02:00
let cursor = q
. decode_cursor ::< GrantCursor > ()
2026-05-28 00:44:20 +02:00
. filter ( | c | c . sort_by == sort_by && c . reverse == reverse );
2026-05-24 01:05:13 +02:00
// Fetch paged summaries from the ACL engine.
let ( summaries , next_cursor ) = match state
. authorization
2026-05-28 00:44:20 +02:00
. list_incoming_resources_paged ( subject , & kinds , limit , cursor , sort_by , reverse )
2026-05-24 01:05:13 +02:00
. await
{
Ok ( r ) => r ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
// Split summaries by resource kind for parallel resolution.
let file_summaries : Vec <& IncomingGrantSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::File ))
. collect ();
let folder_summaries : Vec <& IncomingGrantSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::Folder ))
. collect ();
2026-06-22 23:52:44 +02:00
let drive_summaries : Vec <& IncomingGrantSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::Drive ))
. collect ();
2026-05-24 01:05:13 +02:00
let file_service = & state . applications . file_retrieval_service ;
let folder_service = & state . applications . folder_service_concrete ;
// Pre-compute ID strings to avoid temporaries inside async closures.
let file_ids : Vec < String > = file_summaries
. iter ()
. map ( | s | s . resource_id . to_string ())
. collect ();
let folder_ids : Vec < String > = folder_summaries
. iter ()
. map ( | s | s . resource_id . to_string ())
. collect ();
2026-06-22 23:52:44 +02:00
let drive_ids : Vec < Uuid > = drive_summaries . iter (). map ( | s | s . resource_id ). collect ();
2026-05-24 01:05:13 +02:00
2026-06-22 23:52:44 +02:00
// Resolve resource details in three batch queries (was one per id via
2026-06-19 11:27:11 +00:00
// 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.
2026-06-22 23:52:44 +02:00
let ( file_list , folder_list , drive_list ) = tokio ::join! (
2026-06-19 11:27:11 +00:00
file_service . get_files_by_ids ( & file_ids ),
2026-06-22 23:52:44 +02:00
folder_service . get_folders_by_ids ( & folder_ids ),
state . drive_repo . get_by_ids ( & drive_ids )
2026-05-24 01:05:13 +02:00
);
2026-06-19 11:27:11 +00:00
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 (),
};
2026-06-22 23:52:44 +02:00
let drive_map : HashMap < Uuid , DriveDto > = match drive_list {
Ok ( drives ) => drives
. into_iter ()
. map ( | d | ( d . drive . id , DriveDto ::from ( d )))
. collect (),
Err ( e ) => {
return AppError ::internal_error ( format! ( "Failed to batch-resolve drives: {e:?} " ))
. into_response ();
}
};
2026-05-24 01:05:13 +02:00
2026-06-19 11:27:11 +00:00
// Build the unified item list in original grant order (newest first),
// looking each resolved resource up by id.
2026-05-24 01:05:13 +02:00
let mut items : Vec < SharedWithMeItemDto > = Vec ::with_capacity ( summaries . len ());
2026-07-22 00:36:14 +02:00
// Enrich caller flags on every returned resource DTO. Incoming
// grants pages are typically small (10-50 items), so N sequential
// helper calls is acceptable; the folded-into-SQL treatment
// photos got is overkill here. Follow-up path if this grows
// hot: same LATERAL EXISTS shape in `get_files_by_ids` /
// `get_folders_by_ids`.
2026-05-24 01:05:13 +02:00
for summary in & summaries {
2026-06-19 11:27:11 +00:00
let rid = summary . resource_id . to_string ();
2026-05-24 01:05:13 +02:00
match summary . resource_type {
2026-06-19 11:27:11 +00:00
ResourceKind ::File => match file_map . get ( & rid ) {
Some ( file_dto ) => {
2026-07-22 00:36:14 +02:00
let mut dto = file_dto . clone (). without_hierarchy_info ();
crate ::interfaces ::api ::handlers ::caller_flags ::enrich_file_flags (
& state , & mut dto , caller_id ,
)
. await ;
2026-06-19 11:27:11 +00:00
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 ,
2026-07-22 00:36:14 +02:00
resource : ResourceContentDto ::File ( dto ),
2026-06-19 11:27:11 +00:00
});
2026-05-24 01:05:13 +02:00
}
2026-06-19 11:27:11 +00:00
None => warn! (
"Skipping stale file grant for resource_id={}: not found" ,
summary . resource_id
),
},
ResourceKind ::Folder => match folder_map . get ( & rid ) {
Some ( folder_dto ) => {
2026-07-22 00:36:14 +02:00
let mut dto = folder_dto . clone (). without_hierarchy_info ();
crate ::interfaces ::api ::handlers ::caller_flags ::enrich_folder_flags (
& state , & mut dto , caller_id ,
)
. await ;
2026-06-19 11:27:11 +00:00
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 ,
2026-07-22 00:36:14 +02:00
resource : ResourceContentDto ::Folder ( dto ),
2026-06-19 11:27:11 +00:00
});
2026-05-24 01:05:13 +02:00
}
2026-06-19 11:27:11 +00:00
None => warn! (
"Skipping stale folder grant for resource_id={}: not found" ,
summary . resource_id
),
},
2026-06-22 23:52:44 +02:00
ResourceKind ::Drive => match drive_map . get ( & summary . resource_id ) {
Some ( drive_dto ) => {
items . push ( SharedWithMeItemDto {
resource_type : ResourceTypeDto ::Drive ,
permissions : summary . permissions . iter (). map ( | p | ( * p ). into ()). collect (),
granted_at : summary . granted_at ,
granted_by : summary . granted_by ,
resource : ResourceContentDto ::Drive ( drive_dto . clone ()),
});
}
None => warn! (
"Skipping stale drive grant for resource_id={}: not found" ,
summary . resource_id
),
},
2026-05-24 01:05:13 +02:00
}
}
(
StatusCode ::OK ,
2026-05-26 17:52:28 +02:00
Json ( SharedWithMeDto ::with_cursor (
2026-05-24 01:05:13 +02:00
items ,
2026-05-26 17:52:28 +02:00
next_cursor . map ( | c | c . encode ()),
)),
2026-05-24 01:05:13 +02:00
)
. into_response ()
}
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing" ,
responses(
(status = 200, description = "Grants the caller has created" , body = Vec<GrantDto>),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_outgoing (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
2026-05-24 01:05:13 +02:00
match state . authorization . list_outgoing_grants ( caller_id ). await {
2026-05-20 22:56:00 +02:00
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants?resource_type=...&resource_id=...
// (list grants on a specific resource — requires Share on it)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct OnResourceQuery {
pub resource_type : ResourceTypeDto ,
pub resource_id : Uuid ,
}
#[utoipa::path(
get,
path = "/api/grants" ,
params(OnResourceQuery),
responses(
(status = 200, description = "Grants on the specified resource" , body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_on_resource (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Query ( q ) : Query < OnResourceQuery > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let resource : Resource = ResourceDto {
kind : q . resource_type ,
id : q . resource_id ,
}
. into ();
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
match authz . list_grants_on_resource ( resource ). await {
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-05-29 02:16:15 +02:00
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing/resources
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing/resources" ,
params(SharedWithMeQuery),
responses(
(status = 200,
description = "Cursor-paginated resources the caller has shared with others. \
Each item carries the full resource details plus all subjects \
(users and tokens) the resource was shared with. \
`next_cursor` is absent on the last page." ,
body = MySharesDto),
),
security(( "bearerAuth" = [])),
tag = "grants"
)]
pub async fn list_my_shares (
State ( state ) : State < AppStateRef > ,
auth_user : AuthUser ,
Query ( q ) : Query < SharedWithMeQuery > ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
let limit = q . limit_clamped () as u32 ;
let sort_by = q . sort_by . as_deref (). unwrap_or ( "first_shared_at" );
if ! matches! (
sort_by ,
"first_shared_at" | "name" | "type" | "subject" | "role"
) {
return (
StatusCode ::BAD_REQUEST ,
Json ( serde_json ::json! ({ "error" : "invalid sort_by; valid values: first_shared_at, name, type, subject, role" })),
)
. into_response ();
}
let reverse = q . reverse ;
let cursor = q
. decode_cursor ::< GrantCursor > ()
. filter ( | c | c . sort_by == sort_by && c . reverse == reverse );
let ( summaries , next_cursor ) = match state
. authorization
. list_outgoing_resources_paged ( caller_id , limit , cursor , sort_by , reverse )
. await
{
Ok ( r ) => r ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
let file_service = & state . applications . file_retrieval_service ;
let folder_service = & state . applications . folder_service_concrete ;
// Split summaries by resource kind for parallel resolution.
let file_summaries : Vec <& OutgoingResourceSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::File ))
. collect ();
let folder_summaries : Vec <& OutgoingResourceSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::Folder ))
. collect ();
2026-06-22 23:52:44 +02:00
let drive_summaries : Vec <& OutgoingResourceSummary > = summaries
. iter ()
. filter ( | s | matches! ( s . resource_type , ResourceKind ::Drive ))
. collect ();
2026-05-29 02:16:15 +02:00
let file_ids : Vec < String > = file_summaries
. iter ()
. map ( | s | s . resource_id . to_string ())
. collect ();
let folder_ids : Vec < String > = folder_summaries
. iter ()
. map ( | s | s . resource_id . to_string ())
. collect ();
2026-06-22 23:52:44 +02:00
let drive_ids : Vec < Uuid > = drive_summaries . iter (). map ( | s | s . resource_id ). collect ();
2026-05-29 02:16:15 +02:00
2026-06-22 23:52:44 +02:00
// Three batch queries instead of one get_* per id (see list_shared_with_me).
let ( file_list , folder_list , drive_list ) = tokio ::join! (
2026-06-19 11:27:11 +00:00
file_service . get_files_by_ids ( & file_ids ),
2026-06-22 23:52:44 +02:00
folder_service . get_folders_by_ids ( & folder_ids ),
state . drive_repo . get_by_ids ( & drive_ids )
2026-05-29 02:16:15 +02:00
);
2026-06-19 11:27:11 +00:00
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 (),
};
2026-06-22 23:52:44 +02:00
let drive_map : HashMap < Uuid , DriveDto > = match drive_list {
Ok ( drives ) => drives
. into_iter ()
. map ( | d | ( d . drive . id , DriveDto ::from ( d )))
. collect (),
Err ( e ) => {
return AppError ::internal_error ( format! ( "Failed to batch-resolve drives: {e:?} " ))
. into_response ();
}
};
2026-05-29 02:16:15 +02:00
let mut items : Vec < OutgoingResourceItemDto > = Vec ::with_capacity ( summaries . len ());
for summary in & summaries {
let grants : Vec < OutgoingResourceGrantDto > = summary
. grants
. iter ()
. map ( | g | OutgoingResourceGrantDto {
grant_id : g . grant_id ,
subject_type : g . subject_type . clone (),
subject_id : g . subject_id ,
subject_display : g . subject_display . clone (),
role : role_from_permissions ( & g . permissions ). to_owned (),
granted_at : g . granted_at ,
expires_at : g . expires_at ,
has_password : g . has_password ,
2026-06-05 09:46:51 +02:00
is_external : g . is_external ,
2026-05-29 02:16:15 +02:00
})
. collect ();
2026-06-19 11:27:11 +00:00
let rid = summary . resource_id . to_string ();
2026-05-29 02:16:15 +02:00
match summary . resource_type {
2026-06-19 11:27:11 +00:00
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).
2026-07-22 00:36:14 +02:00
let mut dto = file_dto . clone ();
// is_shared: TRUE by construction on this feed.
// is_favorite: real EXISTS via the shared helper.
crate ::interfaces ::api ::handlers ::caller_flags ::enrich_file_flags (
& state , & mut dto , caller_id ,
)
. await ;
dto . is_shared = true ;
2026-06-19 11:27:11 +00:00
items . push ( OutgoingResourceItemDto {
resource_type : ResourceTypeDto ::File ,
first_shared_at : summary . first_shared_at ,
2026-07-22 00:36:14 +02:00
resource : ResourceContentDto ::File ( dto ),
2026-06-19 11:27:11 +00:00
grants ,
});
2026-05-29 02:16:15 +02:00
}
2026-06-19 11:27:11 +00:00
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 ) => {
2026-07-22 00:36:14 +02:00
let mut dto = folder_dto . clone ();
crate ::interfaces ::api ::handlers ::caller_flags ::enrich_folder_flags (
& state , & mut dto , caller_id ,
)
. await ;
dto . is_shared = true ;
2026-06-19 11:27:11 +00:00
items . push ( OutgoingResourceItemDto {
resource_type : ResourceTypeDto ::Folder ,
first_shared_at : summary . first_shared_at ,
2026-07-22 00:36:14 +02:00
resource : ResourceContentDto ::Folder ( dto ),
2026-06-19 11:27:11 +00:00
grants ,
});
2026-05-29 02:16:15 +02:00
}
2026-06-19 11:27:11 +00:00
None => warn! (
"Skipping stale outgoing folder grant for resource_id={}: not found" ,
summary . resource_id
),
},
2026-06-22 23:52:44 +02:00
ResourceKind ::Drive => match drive_map . get ( & summary . resource_id ) {
Some ( drive_dto ) => {
items . push ( OutgoingResourceItemDto {
resource_type : ResourceTypeDto ::Drive ,
first_shared_at : summary . first_shared_at ,
resource : ResourceContentDto ::Drive ( drive_dto . clone ()),
grants ,
});
}
None => warn! (
"Skipping stale outgoing drive grant for resource_id={}: not found" ,
summary . resource_id
),
},
2026-05-29 02:16:15 +02:00
}
}
(
StatusCode ::OK ,
Json ( MySharesDto ::with_cursor (
items ,
next_cursor . map ( | c | c . encode ()),
)),
)
. into_response ()
}
2026-05-20 22:56:00 +02:00
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles ( _ : SubjectDto ) {}