d9987782c4
- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
regarding performances I try to be minimalis on SQL to prevent any perf regression
- remove old set of sharedids and favoriteids (was not functionnal anymore)
- fix date picker (no past selection) in grant
- fix contextmenu close on /files section
1295 lines
53 KiB
Rust
1295 lines
53 KiB
Rust
//! 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;
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
use tracing::{error, warn};
|
||
use utoipa::IntoParams;
|
||
use uuid::Uuid;
|
||
|
||
use crate::application::dtos::cursor::PageCursor;
|
||
use crate::application::dtos::drive_dto::DriveDto;
|
||
use crate::application::dtos::grant_dto::{
|
||
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
|
||
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
|
||
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
|
||
SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||
};
|
||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||
use crate::application::services::recipient_notification_service::NotifyTrigger;
|
||
use crate::common::di::AppState;
|
||
#[allow(unused_imports)]
|
||
use crate::common::errors::DomainError;
|
||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||
use crate::domain::services::authorization::{
|
||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||
Role, Subject,
|
||
};
|
||
use crate::interfaces::errors::AppError;
|
||
use crate::interfaces::middleware::auth::AuthUser;
|
||
|
||
type AppStateRef = Arc<AppState>;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// POST /api/grants
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
/// 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.
|
||
#[utoipa::path(
|
||
post,
|
||
path = "/api/grants",
|
||
request_body = CreateGrantDto,
|
||
responses(
|
||
(status = 201, description = "Grant(s) created", body = CreateGrantResponseDto),
|
||
(status = 400, description = "Invalid input (both/neither of permissions+role provided)"),
|
||
(status = 404, description = "Resource not found OR caller lacks Share permission"),
|
||
(status = 405, description = "Drive resource: personal drives have immutable membership"),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn create_grant(
|
||
State(state): State<AppStateRef>,
|
||
auth_user: AuthUser,
|
||
Json(dto): Json<CreateGrantDto>,
|
||
) -> impl IntoResponse {
|
||
let authz = &state.authorization;
|
||
let caller_id = auth_user.id;
|
||
|
||
let role: Role = dto.role.into();
|
||
let resource: Resource = dto.resource.into();
|
||
let expires_at = dto.expires_at;
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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()),
|
||
// 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
|
||
// drive; the default-empty bag is the right no-op here.
|
||
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
|
||
Ok(crate::domain::entities::drive::DrivePolicies::default())
|
||
}
|
||
};
|
||
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();
|
||
}
|
||
|
||
// 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();
|
||
};
|
||
// 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(),
|
||
);
|
||
}
|
||
// 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
|
||
{
|
||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||
Err(e) => return AppError::from(e).into_response(),
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// 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
|
||
.set_member_role(caller_id, false, drive_id, subject, role, expires_at)
|
||
.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();
|
||
}
|
||
}
|
||
};
|
||
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
|
||
// 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()
|
||
}
|
||
}
|
||
}
|
||
_ => NotifyOutcomeSetDto::empty(),
|
||
};
|
||
|
||
(
|
||
StatusCode::CREATED,
|
||
Json(CreateGrantResponseDto {
|
||
grants,
|
||
notification,
|
||
}),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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"),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn revoke_grant(
|
||
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(),
|
||
};
|
||
|
||
// 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(),
|
||
};
|
||
|
||
// 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
|
||
.remove_member(caller_id, false, drive_id, subject)
|
||
.await
|
||
{
|
||
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();
|
||
}
|
||
|
||
if let Err(e) = authz.revoke(grant_id).await {
|
||
return AppError::from(e).into_response();
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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()
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// PUT /api/grants/role
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
/// 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.
|
||
#[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>),
|
||
(status = 400, description = "Drive resource: shared-drive last-owner demotion refused"),
|
||
(status = 404, description = "Resource not found or caller lacks Share"),
|
||
(status = 405, description = "Drive resource: personal drives have immutable membership"),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn set_role(
|
||
State(state): State<AppStateRef>,
|
||
auth_user: AuthUser,
|
||
Json(dto): Json<UpdateRoleDto>,
|
||
) -> impl IntoResponse {
|
||
let authz = &state.authorization;
|
||
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;
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// 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
|
||
.set_member_role(caller_id, false, drive_id, subject, role, expires_at)
|
||
.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(),
|
||
}
|
||
};
|
||
|
||
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(vec![GrantDto::from(grant)])).into_response()
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// GET /api/grants/incoming
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
#[utoipa::path(
|
||
get,
|
||
path = "/api/grants/incoming",
|
||
responses(
|
||
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn list_incoming(
|
||
State(state): State<AppStateRef>,
|
||
auth_user: AuthUser,
|
||
) -> impl IntoResponse {
|
||
let caller_id = auth_user.id;
|
||
match state
|
||
.authorization
|
||
.list_incoming_grants(Subject::User(caller_id))
|
||
.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(),
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
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.
|
||
let limit = q.limit_clamped() as u32;
|
||
|
||
// Validate sort_by (defaults to "granted_at").
|
||
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
|
||
if !matches!(sort_by, "granted_at" | "granted_by" | "name" | "type") {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type"})),
|
||
)
|
||
.into_response();
|
||
}
|
||
|
||
let reverse = q.reverse;
|
||
|
||
// Decode cursor — discard it when the sort dimension or direction changed
|
||
// to avoid keyset confusion across sort modes.
|
||
let cursor = q
|
||
.decode_cursor::<GrantCursor>()
|
||
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
|
||
|
||
// Fetch paged summaries from the ACL engine.
|
||
let (summaries, next_cursor) = match state
|
||
.authorization
|
||
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse)
|
||
.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();
|
||
let drive_summaries: Vec<&IncomingGrantSummary> = summaries
|
||
.iter()
|
||
.filter(|s| matches!(s.resource_type, ResourceKind::Drive))
|
||
.collect();
|
||
|
||
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();
|
||
let drive_ids: Vec<Uuid> = drive_summaries.iter().map(|s| s.resource_id).collect();
|
||
|
||
// Resolve resource details in three 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, drive_list) = tokio::join!(
|
||
file_service.get_files_by_ids(&file_ids),
|
||
folder_service.get_folders_by_ids(&folder_ids),
|
||
state.drive_repo.get_by_ids(&drive_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 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();
|
||
}
|
||
};
|
||
|
||
// 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());
|
||
|
||
// 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`.
|
||
for summary in &summaries {
|
||
let rid = summary.resource_id.to_string();
|
||
match summary.resource_type {
|
||
ResourceKind::File => match file_map.get(&rid) {
|
||
Some(file_dto) => {
|
||
let mut dto = file_dto.clone().without_hierarchy_info();
|
||
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
|
||
&state, &mut dto, caller_id,
|
||
)
|
||
.await;
|
||
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(dto),
|
||
});
|
||
}
|
||
None => warn!(
|
||
"Skipping stale file grant for resource_id={}: not found",
|
||
summary.resource_id
|
||
),
|
||
},
|
||
ResourceKind::Folder => match folder_map.get(&rid) {
|
||
Some(folder_dto) => {
|
||
let mut dto = folder_dto.clone().without_hierarchy_info();
|
||
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
|
||
&state, &mut dto, caller_id,
|
||
)
|
||
.await;
|
||
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(dto),
|
||
});
|
||
}
|
||
None => warn!(
|
||
"Skipping stale folder grant for resource_id={}: not found",
|
||
summary.resource_id
|
||
),
|
||
},
|
||
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
|
||
),
|
||
},
|
||
}
|
||
}
|
||
|
||
(
|
||
StatusCode::OK,
|
||
Json(SharedWithMeDto::with_cursor(
|
||
items,
|
||
next_cursor.map(|c| c.encode()),
|
||
)),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// GET /api/grants/outgoing
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
#[utoipa::path(
|
||
get,
|
||
path = "/api/grants/outgoing",
|
||
responses(
|
||
(status = 200, description = "Grants the caller has created", body = Vec<GrantDto>),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn list_outgoing(
|
||
State(state): State<AppStateRef>,
|
||
auth_user: AuthUser,
|
||
) -> impl IntoResponse {
|
||
let caller_id = auth_user.id;
|
||
match state.authorization.list_outgoing_grants(caller_id).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(),
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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"),
|
||
),
|
||
security(("bearerAuth" = [])),
|
||
tag = "grants"
|
||
)]
|
||
pub async fn list_on_resource(
|
||
State(state): State<AppStateRef>,
|
||
auth_user: AuthUser,
|
||
Query(q): Query<OnResourceQuery>,
|
||
) -> impl IntoResponse {
|
||
let authz = &state.authorization;
|
||
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(),
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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();
|
||
let drive_summaries: Vec<&OutgoingResourceSummary> = summaries
|
||
.iter()
|
||
.filter(|s| matches!(s.resource_type, ResourceKind::Drive))
|
||
.collect();
|
||
|
||
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();
|
||
let drive_ids: Vec<Uuid> = drive_summaries.iter().map(|s| s.resource_id).collect();
|
||
|
||
// Three batch queries instead of one get_* per id (see list_shared_with_me).
|
||
let (file_list, folder_list, drive_list) = tokio::join!(
|
||
file_service.get_files_by_ids(&file_ids),
|
||
folder_service.get_folders_by_ids(&folder_ids),
|
||
state.drive_repo.get_by_ids(&drive_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 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();
|
||
}
|
||
};
|
||
|
||
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,
|
||
is_external: g.is_external,
|
||
})
|
||
.collect();
|
||
|
||
let rid = summary.resource_id.to_string();
|
||
match summary.resource_type {
|
||
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).
|
||
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;
|
||
items.push(OutgoingResourceItemDto {
|
||
resource_type: ResourceTypeDto::File,
|
||
first_shared_at: summary.first_shared_at,
|
||
resource: ResourceContentDto::File(dto),
|
||
grants,
|
||
});
|
||
}
|
||
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) => {
|
||
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;
|
||
items.push(OutgoingResourceItemDto {
|
||
resource_type: ResourceTypeDto::Folder,
|
||
first_shared_at: summary.first_shared_at,
|
||
resource: ResourceContentDto::Folder(dto),
|
||
grants,
|
||
});
|
||
}
|
||
None => warn!(
|
||
"Skipping stale outgoing folder grant for resource_id={}: not found",
|
||
summary.resource_id
|
||
),
|
||
},
|
||
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
|
||
),
|
||
},
|
||
}
|
||
}
|
||
|
||
(
|
||
StatusCode::OK,
|
||
Json(MySharesDto::with_cursor(
|
||
items,
|
||
next_cursor.map(|c| c.encode()),
|
||
)),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// Silence unused-import warnings for SubjectDto when only certain endpoints
|
||
// touch it directly.
|
||
#[allow(dead_code)]
|
||
fn _ensure_subject_dto_compiles(_: SubjectDto) {}
|