feat(notify): add notif to internal users when granted
- add coalesced protection to avoid mail bombing if an invited goes many grant in a short period
- add resentd method in share menu item (work for both internal and external users)
- user can disable email notification via his properties
- add env variable from admin to disable notifications
This commit is contained in:
@@ -262,6 +262,98 @@ impl From<Grant> for GrantDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Notification DTOs (PR N1) — surfaced in the create-grant and /notify
|
||||
// responses so the frontend can show actionable toasts ("Notified Carol",
|
||||
// "Carol already notified recently", "Notified 8 of 10 group members").
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// One per resolved recipient. `kind` discriminates; sibling fields are
|
||||
/// only meaningful for the matching variant. Tagged JSON shape:
|
||||
///
|
||||
/// ```json
|
||||
/// { "kind": "sent", "detail": "magic_link" }
|
||||
/// { "kind": "sent", "detail": "plain_notification" }
|
||||
/// { "kind": "coalesced", "last_sent_at": "2026-06-04T12:00:00Z" }
|
||||
/// { "kind": "rate_limited", "retry_after_secs": 1800 }
|
||||
/// { "kind": "not_applicable", "reason": "recipient_opted_out" }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum NotifyOutcomeDto {
|
||||
/// An email actually went out for this recipient. `detail` is
|
||||
/// `"magic_link"` (external invitation with a fresh token) or
|
||||
/// `"plain_notification"` (internal "you got a new grant" mail).
|
||||
Sent { detail: String },
|
||||
/// Skipped silently because this (granter, recipient) pair was
|
||||
/// notified less than the coalesce-window ago. The grant is still
|
||||
/// recorded; the recipient sees it next time they log in.
|
||||
Coalesced {
|
||||
last_sent_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
/// Per-recipient hard cap (5/h) reached. The caller may retry after
|
||||
/// `retry_after_secs`.
|
||||
RateLimited { retry_after_secs: u32 },
|
||||
/// No mail was dispatched for this recipient. `reason` is one of:
|
||||
/// - `"recipient_opted_out"` — user toggled `notify_on_share = false`
|
||||
/// - `"operator_disabled"` — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false`
|
||||
/// - `"no_email"` — user row has no email on file
|
||||
/// - `"oidc_only_no_email"` — OIDC-only user with no email claim
|
||||
/// - `"subject_is_token"` — anonymous link share (the surface
|
||||
/// that creates the grant or the `/notify` endpoint maps this to 409)
|
||||
NotApplicable { reason: String },
|
||||
}
|
||||
|
||||
/// The aggregated result of dispatching share notifications for ONE grant
|
||||
/// action (one `create_grant` request OR one `/notify` call). Carries
|
||||
/// per-recipient outcomes so the frontend can render a single
|
||||
/// summary-style toast:
|
||||
///
|
||||
/// - `total_recipients = 1`, `outcomes[0] = Sent` → "Notified Carol"
|
||||
/// - `total_recipients = 1`, `outcomes[0] = Coalesced` → "Carol already
|
||||
/// notified recently"
|
||||
/// - `total_recipients = N`, all `Sent` → "Notified all N group members"
|
||||
/// - `total_recipients = N`, mix → "Notified 8 of 10 — 2 opted out"
|
||||
///
|
||||
/// `total_recipients` equals `outcomes.len()` after resolution. For
|
||||
/// token-subject grants it is `0` (no human recipient — no toast).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct NotifyOutcomeSetDto {
|
||||
pub total_recipients: usize,
|
||||
pub outcomes: Vec<NotifyOutcomeDto>,
|
||||
}
|
||||
|
||||
impl NotifyOutcomeSetDto {
|
||||
/// Construct an empty set (token subjects, no recipients to notify).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
total_recipients: 0,
|
||||
outcomes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct from a list of outcomes, deriving `total_recipients`
|
||||
/// from the list length. Use this from `RecipientNotificationService`
|
||||
/// after the per-member loop completes.
|
||||
pub fn from_outcomes(outcomes: Vec<NotifyOutcomeDto>) -> Self {
|
||||
Self {
|
||||
total_recipients: outcomes.len(),
|
||||
outcomes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body for `POST /api/grants`. Wraps the array of created
|
||||
/// grants (one per `permission` in the request) together with the
|
||||
/// aggregated notification result. Replaces the previous bare
|
||||
/// `Vec<GrantDto>` shape; the frontend share modal is updated in
|
||||
/// lockstep.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct CreateGrantResponseDto {
|
||||
pub grants: Vec<GrantDto>,
|
||||
pub notification: NotifyOutcomeSetDto,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Shared-with-me DTOs (GET /api/grants/incoming/resources)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -372,6 +464,13 @@ pub struct OutgoingResourceGrantDto {
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Whether the token has a password set. Always `false` for user subjects.
|
||||
pub has_password: bool,
|
||||
/// True when the subject is a magic-link-only external user
|
||||
/// (PR N2). Always `false` for token and group subjects, and for
|
||||
/// internal users. Used by the My Shares per-row menu to choose
|
||||
/// between "Resend invitation email" (external) and "Notify by
|
||||
/// email" (internal).
|
||||
#[serde(default)]
|
||||
pub is_external: bool,
|
||||
}
|
||||
|
||||
/// One item in the my-shares list.
|
||||
|
||||
@@ -53,6 +53,14 @@ pub struct UserDto {
|
||||
/// through `/api/auth/me` and `PATCH /api/auth/me/profile`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_locale: Option<String>,
|
||||
/// Whether the user wants an email when someone shares a resource
|
||||
/// with them. `true` (default) = receive share-notification mails;
|
||||
/// `false` = grants are still created but no email is sent. Honored
|
||||
/// only on the plain-notification path — magic-link first-invitations
|
||||
/// to brand-new external users always send, otherwise the recipient
|
||||
/// could never claim the share. Round-trips through `/api/auth/me`
|
||||
/// and `PATCH /api/auth/me/profile`.
|
||||
pub notify_on_share: bool,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
@@ -76,6 +84,7 @@ impl From<User> for UserDto {
|
||||
family_name: user.family_name().map(str::to_string),
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
notify_on_share: user.notify_on_share(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +178,13 @@ pub struct UpdateProfileDto {
|
||||
/// normalises `""` → `None`).
|
||||
#[serde(default)]
|
||||
pub preferred_locale: Option<String>,
|
||||
/// Whether to receive an email when someone shares a resource with
|
||||
/// the user. Absent → no change (existing setting preserved). Pass
|
||||
/// `true` to opt in, `false` to opt out. Honored only on the
|
||||
/// plain-notification path; magic-link first-invitations to externals
|
||||
/// always send.
|
||||
#[serde(default)]
|
||||
pub notify_on_share: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -75,6 +75,12 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
/// Gets a user by ID
|
||||
async fn get_user_by_id(&self, id: Uuid) -> Result<User, DomainError>;
|
||||
|
||||
/// Batch-loads users by id. Order is unspecified; missing ids are
|
||||
/// silently dropped. Used by group-recipient expansion in
|
||||
/// `RecipientNotificationService` to avoid N+1 lookups when notifying
|
||||
/// a group of size N.
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Gets a user by username
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
@@ -1298,6 +1298,17 @@ impl AuthApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Share-notification opt-out (PR N1) ───────────────────
|
||||
// Boolean field; absent → no change. Idempotent — setting the
|
||||
// same value twice is fine but doesn't re-emit an audit row
|
||||
// because `changed` won't pick it up.
|
||||
if let Some(notify) = dto.notify_on_share
|
||||
&& notify != user.notify_on_share()
|
||||
{
|
||||
user.set_notify_on_share(notify);
|
||||
changed.push("notify_on_share");
|
||||
}
|
||||
|
||||
if changed.is_empty() {
|
||||
// No-op — return the current user without a DB write.
|
||||
return Ok(UserDto::from(user));
|
||||
@@ -1320,6 +1331,19 @@ impl AuthApplicationService {
|
||||
self.get_user(user_id).await
|
||||
}
|
||||
|
||||
/// Load the full `User` entity for the given id. Unlike
|
||||
/// `get_user_by_id` this returns the domain entity (not a DTO), so
|
||||
/// callers can read fields like `notify_on_share()`,
|
||||
/// `preferred_locale()`, or `is_external()` without round-tripping
|
||||
/// through the DTO shape. Used by `grant_handler::create_grant` to
|
||||
/// hand the granter entity to `RecipientNotificationService`.
|
||||
pub async fn get_user_entity(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<crate::domain::entities::user::User, DomainError> {
|
||||
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
|
||||
}
|
||||
|
||||
/// Visibility-checked profile lookup for `GET /api/users/{id}`.
|
||||
///
|
||||
/// Returns `NotFound` (not `AccessDenied`) when the caller has no
|
||||
|
||||
@@ -268,14 +268,17 @@ impl MagicLinkInviteService {
|
||||
/// invitation link. Caller is expected to have already created the
|
||||
/// grant rows.
|
||||
///
|
||||
/// `inviter_username` is interpolated into the subject line as a
|
||||
/// trust signal ("Alice shared with you on OxiCloud"). The message
|
||||
/// body is plain text only in v1; HTML templating is out of scope
|
||||
/// (see plan "Out of scope" → "Email template engine").
|
||||
/// `inviter` is interpolated into the subject line ("Alice shared
|
||||
/// with you on OxiCloud") and body ("Alice <alice@x.com> shared a
|
||||
/// folder with you. Open it by..."). Two forms are computed via
|
||||
/// [`User::display_full`] — the short form goes into the subject
|
||||
/// (keeps inbox-row width sane), the email-decorated form goes
|
||||
/// into the body where the extra identifier helps the recipient
|
||||
/// place who's reaching out.
|
||||
pub async fn issue_invitation(
|
||||
&self,
|
||||
recipient: &User,
|
||||
inviter_username: &str,
|
||||
inviter: &User,
|
||||
resource: Resource,
|
||||
) -> Result<(), DomainError> {
|
||||
// The grant is in place either way; only mint a magic link when
|
||||
@@ -335,8 +338,16 @@ impl MagicLinkInviteService {
|
||||
let locale = self.locale_for(recipient);
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string();
|
||||
// Two display forms: `inviter` (short, no email) flows into the
|
||||
// subject line; `inviter_full` (with email decoration) flows
|
||||
// into the body. Templates pick whichever placeholder they
|
||||
// want — see static/locales/en.json `server.magic_link.email.
|
||||
// invitation.*` for the canonical references.
|
||||
let inviter_short = inviter.display_full(false);
|
||||
let inviter_full = inviter.display_full(true);
|
||||
let invite_args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_username),
|
||||
("inviter", inviter_short.as_str()),
|
||||
("inviter_full", inviter_full.as_str()),
|
||||
("kind", &kind_label),
|
||||
("link", &link),
|
||||
("ttl_hours", &ttl_hours),
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
pub mod recent_service;
|
||||
pub mod recipient_notification_service;
|
||||
pub mod search_service;
|
||||
pub mod share_browse_service;
|
||||
pub mod share_service;
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
//! Unified entry point for share-related notification emails.
|
||||
//!
|
||||
//! Single service called by both `POST /api/grants` (initial invitation
|
||||
//! when a grant lands) and `POST /api/grants/{id}/notify` (manual resend
|
||||
//! from the My Shares menu). Replaces the prior arrangement where
|
||||
//! `create_grant` directly invoked
|
||||
//! [`MagicLinkInviteService::issue_invitation`] and internal users got
|
||||
//! no email at all.
|
||||
//!
|
||||
//! # Behaviour ladder
|
||||
//!
|
||||
//! Per resolved recipient member:
|
||||
//!
|
||||
//! 1. **Eligibility** decides the dispatch arm:
|
||||
//! - `magic_link_eligibility(recipient) == Allow` →
|
||||
//! `NotifyKind::MagicLink` (mints a token and emails the
|
||||
//! invitation by delegating to
|
||||
//! [`MagicLinkInviteService::issue_invitation`]).
|
||||
//! - Otherwise (password user, OIDC user, OIDC-linked external) →
|
||||
//! `NotifyKind::PlainNotification` — provided the recipient has
|
||||
//! not opted out (`auth.users.notify_on_share = false`) and the
|
||||
//! operator-level kill switch
|
||||
//! `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` is `true`.
|
||||
//! - Otherwise → `NotifyOutcome::NotApplicable` with a structured
|
||||
//! reason.
|
||||
//! 2. **Coalesce check** keyed by `(granter_id, recipient_email)`. If
|
||||
//! the last send for this pair was less than the window ago, return
|
||||
//! `Coalesced` without dispatching. Magic-link first-invitations
|
||||
//! are NOT coalesced — they're the only way the recipient can claim
|
||||
//! the share.
|
||||
//! 3. **Hard rate limit** keyed by recipient email. Reuses
|
||||
//! `magic_link_send_per_email_rate_limiter` so an attacker can't
|
||||
//! alternate between `/notify` and `/magic/v1/{token}/resend` to
|
||||
//! double the cap.
|
||||
//! 4. **Dispatch** via the magic-link arm or the plain-notification
|
||||
//! arm. On successful SMTP send, update the coalesce timestamp.
|
||||
//! 5. **Audit**: one `grant.notify_sent` or `grant.notify_skipped` per
|
||||
//! member; for group sends, one `grant.notify_group_expanded`
|
||||
//! summary line carrying `group_id` and `member_count`.
|
||||
//!
|
||||
//! # Forward-compatibility
|
||||
//!
|
||||
//! The entry takes `(granter, subject, resource, trigger)` — NOT a
|
||||
//! pre-resolved `&User` — so [`Subject::Group`] is a real arm in
|
||||
//! [`Self::resolve_subject_members`] and not a future refactor. The
|
||||
//! infrastructure (group repository, transitive expansion with 30s
|
||||
//! Moka cache) already ships from earlier work; we just plug in.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use askama::Template;
|
||||
use chrono::{DateTime, Utc};
|
||||
use moka::sync::Cache;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::grant_dto::{NotifyOutcomeDto, NotifyOutcomeSetDto};
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||
use crate::application::services::magic_link_invite_service::{
|
||||
Eligibility, MagicLinkInviteService, magic_link_eligibility,
|
||||
};
|
||||
use crate::application::services::subject_group_service::SubjectGroupService;
|
||||
use crate::common::config::MagicLinkConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::locale::{Locale, LocaleRegistry};
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::user_repository::UserRepository;
|
||||
use crate::domain::services::authorization::{Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
use crate::interfaces::middleware::rate_limit::RateLimiter;
|
||||
|
||||
/// What triggered the notification — purely an audit discriminator.
|
||||
/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend`
|
||||
/// → granter explicitly clicked "Notify by email" in My Shares.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NotifyTrigger {
|
||||
GrantCreated,
|
||||
ManualResend,
|
||||
}
|
||||
|
||||
impl NotifyTrigger {
|
||||
fn audit_str(self) -> &'static str {
|
||||
match self {
|
||||
NotifyTrigger::GrantCreated => "grant_created",
|
||||
NotifyTrigger::ManualResend => "manual_resend",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which email arm dispatched. `MagicLink` carries a one-shot token in
|
||||
/// the URL; `PlainNotification` carries only a `/login` deep link.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotifyKind {
|
||||
MagicLink,
|
||||
PlainNotification,
|
||||
}
|
||||
|
||||
impl NotifyKind {
|
||||
fn audit_str(self) -> &'static str {
|
||||
match self {
|
||||
NotifyKind::MagicLink => "magic_link",
|
||||
NotifyKind::PlainNotification => "plain_notification",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One per resolved recipient. The variant names are stable audit-log
|
||||
/// values — log aggregators key off them; do not rename or repurpose.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NotifyOutcome {
|
||||
/// SMTP send succeeded for this recipient.
|
||||
Sent { kind: NotifyKind },
|
||||
/// Skipped because the same (granter, recipient) pair was notified
|
||||
/// less than the coalesce window ago. The grant is recorded; the
|
||||
/// recipient sees it at next login. Carries the last-send timestamp
|
||||
/// so the frontend can format an informative toast.
|
||||
Coalesced { last_sent_at: DateTime<Utc> },
|
||||
/// Per-recipient hard cap reached. Caller may retry after the
|
||||
/// returned number of seconds.
|
||||
RateLimited { retry_after_secs: u32 },
|
||||
/// No mail dispatched. `reason` is a stable enum-style key:
|
||||
/// `recipient_opted_out`, `operator_disabled`, `no_email`,
|
||||
/// `account_inactive`, `subject_is_token`.
|
||||
NotApplicable { reason: &'static str },
|
||||
}
|
||||
|
||||
impl NotifyOutcome {
|
||||
fn to_dto(&self) -> NotifyOutcomeDto {
|
||||
match self {
|
||||
NotifyOutcome::Sent { kind } => NotifyOutcomeDto::Sent {
|
||||
detail: kind.audit_str().to_string(),
|
||||
},
|
||||
NotifyOutcome::Coalesced { last_sent_at } => NotifyOutcomeDto::Coalesced {
|
||||
last_sent_at: *last_sent_at,
|
||||
},
|
||||
NotifyOutcome::RateLimited { retry_after_secs } => NotifyOutcomeDto::RateLimited {
|
||||
retry_after_secs: *retry_after_secs,
|
||||
},
|
||||
NotifyOutcome::NotApplicable { reason } => NotifyOutcomeDto::NotApplicable {
|
||||
reason: (*reason).to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated result for one share-notification action. Carries one
|
||||
/// outcome per resolved recipient (1 for user subjects, 0 for token
|
||||
/// subjects, N for group subjects).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyOutcomeSet {
|
||||
pub outcomes: Vec<NotifyOutcome>,
|
||||
}
|
||||
|
||||
impl NotifyOutcomeSet {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
outcomes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_recipients(&self) -> usize {
|
||||
self.outcomes.len()
|
||||
}
|
||||
|
||||
pub fn to_dto(&self) -> NotifyOutcomeSetDto {
|
||||
NotifyOutcomeSetDto::from_outcomes(
|
||||
self.outcomes.iter().map(NotifyOutcome::to_dto).collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default coalesce window (10 minutes). Bursts of share creations to
|
||||
/// the same recipient inside this window produce ONE email; subsequent
|
||||
/// shares are coalesced silently. Recipient still sees every share at
|
||||
/// next login.
|
||||
const COALESCE_WINDOW_SECS: u64 = 10 * 60;
|
||||
|
||||
/// Maximum keys held by the coalesce cache. Way above any realistic
|
||||
/// per-tenant burst; bounded to keep memory predictable.
|
||||
const COALESCE_CACHE_MAX_ENTRIES: u64 = 100_000;
|
||||
|
||||
pub struct RecipientNotificationService {
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_service: Arc<MagicLinkInviteService>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
i18n: Arc<I18nApplicationService>,
|
||||
locale_registry: Arc<LocaleRegistry>,
|
||||
subject_groups: Arc<SubjectGroupService>,
|
||||
/// Per-(granter, recipient_email) timestamp of last successful send.
|
||||
/// Sliding window — read+rewrite resets the TTL but that's fine
|
||||
/// because we only insert on actual sends.
|
||||
coalesce_cache: Cache<(Uuid, String), DateTime<Utc>>,
|
||||
/// Shared with the public `/magic/v1/{token}/resend` channel so an
|
||||
/// attacker can't alternate between channels to double the cap.
|
||||
per_email_limiter: Arc<RateLimiter>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl RecipientNotificationService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_service: Arc<MagicLinkInviteService>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
i18n: Arc<I18nApplicationService>,
|
||||
locale_registry: Arc<LocaleRegistry>,
|
||||
subject_groups: Arc<SubjectGroupService>,
|
||||
per_email_limiter: Arc<RateLimiter>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
public_base_url: String,
|
||||
) -> Self {
|
||||
let coalesce_cache = Cache::builder()
|
||||
.time_to_live(Duration::from_secs(COALESCE_WINDOW_SECS))
|
||||
.max_capacity(COALESCE_CACHE_MAX_ENTRIES)
|
||||
.build();
|
||||
Self {
|
||||
user_storage,
|
||||
magic_link_service,
|
||||
email_sender,
|
||||
i18n,
|
||||
locale_registry,
|
||||
subject_groups,
|
||||
coalesce_cache,
|
||||
per_email_limiter,
|
||||
magic_link_cfg,
|
||||
public_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single entry point. Called by `create_grant` after grant rows are
|
||||
/// persisted, and by `notify_grant_recipient` after loading the
|
||||
/// grant by id. Returns one outcome per resolved recipient.
|
||||
///
|
||||
/// Errors here are *infrastructure* errors (DB unreachable while
|
||||
/// expanding a group, etc.). Per-recipient failures (SMTP, etc.)
|
||||
/// are captured as outcomes, never as `Err`.
|
||||
pub async fn send_share_notification(
|
||||
&self,
|
||||
granter: &User,
|
||||
subject: Subject,
|
||||
resource: Resource,
|
||||
trigger: NotifyTrigger,
|
||||
) -> Result<NotifyOutcomeSet, DomainError> {
|
||||
// Resolve subject → Vec<User>. Token subjects yield an empty
|
||||
// vec; the calling handler maps that to its own response.
|
||||
let members = self.resolve_subject_members(subject).await?;
|
||||
if members.is_empty() {
|
||||
return Ok(NotifyOutcomeSet::empty());
|
||||
}
|
||||
|
||||
// Audit summary line for group expansions — operators tracing
|
||||
// a single grant action want to see "this fanned out to N
|
||||
// recipients" without combing per-member lines.
|
||||
if let Subject::Group(group_id) = subject {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "grant.notify_group_expanded",
|
||||
granter_id = %granter.id(),
|
||||
group_id = %group_id,
|
||||
member_count = members.len(),
|
||||
resource = ?resource,
|
||||
trigger = %trigger.audit_str(),
|
||||
"📣 group {} expanded to {} member(s) for notification",
|
||||
group_id,
|
||||
members.len(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut outcomes = Vec::with_capacity(members.len());
|
||||
for member in &members {
|
||||
let outcome = self
|
||||
.dispatch_to_one_user(granter, member, resource, trigger)
|
||||
.await;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(NotifyOutcomeSet { outcomes })
|
||||
}
|
||||
|
||||
/// User subjects → single-element vec; Token subjects → empty;
|
||||
/// Group subjects → transitively expanded member list.
|
||||
async fn resolve_subject_members(&self, subject: Subject) -> Result<Vec<User>, DomainError> {
|
||||
match subject {
|
||||
Subject::User(id) => {
|
||||
match UserRepository::get_user_by_id(&*self.user_storage, id).await {
|
||||
Ok(user) => Ok(vec![user]),
|
||||
Err(e) => Err(DomainError::from(e)),
|
||||
}
|
||||
}
|
||||
Subject::Token(_) => Ok(Vec::new()),
|
||||
Subject::Group(group_id) => {
|
||||
let member_ids = self.subject_groups.list_transitive_users(group_id).await?;
|
||||
if member_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
UserRepository::get_users_by_ids(&*self.user_storage, member_ids)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// THE last function sending email. Per-recipient: eligibility
|
||||
/// match → coalesce → rate-limit → dispatch → audit. No SMTP send
|
||||
/// happens outside this function.
|
||||
async fn dispatch_to_one_user(
|
||||
&self,
|
||||
granter: &User,
|
||||
recipient: &User,
|
||||
resource: Resource,
|
||||
trigger: NotifyTrigger,
|
||||
) -> NotifyOutcome {
|
||||
// 1. Account state — deactivated users get no mail regardless.
|
||||
if !recipient.is_active() {
|
||||
self.audit_skipped(granter, recipient, resource, trigger, "account_inactive");
|
||||
return NotifyOutcome::NotApplicable {
|
||||
reason: "account_inactive",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Choose the dispatch arm.
|
||||
let kind =
|
||||
match magic_link_eligibility(recipient, self.magic_link_cfg.open_to_password_users) {
|
||||
Eligibility::Allow => NotifyKind::MagicLink,
|
||||
Eligibility::Reject(_) => {
|
||||
// Plain-notification arm. Check the two gates.
|
||||
if !self.magic_link_cfg.notify_internal_users_on_share {
|
||||
self.audit_skipped(
|
||||
granter,
|
||||
recipient,
|
||||
resource,
|
||||
trigger,
|
||||
"operator_disabled",
|
||||
);
|
||||
return NotifyOutcome::NotApplicable {
|
||||
reason: "operator_disabled",
|
||||
};
|
||||
}
|
||||
if !recipient.notify_on_share() {
|
||||
self.audit_skipped(
|
||||
granter,
|
||||
recipient,
|
||||
resource,
|
||||
trigger,
|
||||
"recipient_opted_out",
|
||||
);
|
||||
return NotifyOutcome::NotApplicable {
|
||||
reason: "recipient_opted_out",
|
||||
};
|
||||
}
|
||||
if recipient.email().is_empty() {
|
||||
self.audit_skipped(granter, recipient, resource, trigger, "no_email");
|
||||
return NotifyOutcome::NotApplicable { reason: "no_email" };
|
||||
}
|
||||
NotifyKind::PlainNotification
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Coalesce check — only meaningful when we'd actually send.
|
||||
// Per-pair: `(granter_id, recipient_email)`.
|
||||
let coalesce_key = (granter.id(), recipient.email().to_string());
|
||||
if let Some(last) = self.coalesce_cache.get(&coalesce_key) {
|
||||
self.audit_skipped(granter, recipient, resource, trigger, "coalesced");
|
||||
return NotifyOutcome::Coalesced { last_sent_at: last };
|
||||
}
|
||||
|
||||
// 4. Hard rate limit on the recipient email.
|
||||
if self
|
||||
.per_email_limiter
|
||||
.check_and_increment(recipient.email())
|
||||
.is_err()
|
||||
{
|
||||
self.audit_skipped(granter, recipient, resource, trigger, "rate_limited");
|
||||
return NotifyOutcome::RateLimited {
|
||||
retry_after_secs: self.per_email_limiter.retry_after() as u32,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Dispatch + audit.
|
||||
let send_result = match kind {
|
||||
NotifyKind::MagicLink => {
|
||||
// Delegates token mint + locale-resolved bilingual email
|
||||
// + per-mail audit to the existing service. Its own
|
||||
// eligibility short-circuit is moot here — we've already
|
||||
// routed only Accept-eligible recipients to this arm.
|
||||
// Pass the granter as a `&User` so the inner service can
|
||||
// compute both the short (subject) and full (body)
|
||||
// display forms via `display_full(bool)`.
|
||||
self.magic_link_service
|
||||
.issue_invitation(recipient, granter, resource)
|
||||
.await
|
||||
.map_err(|e| e.message)
|
||||
}
|
||||
NotifyKind::PlainNotification => {
|
||||
self.send_plain_notification(granter, recipient, resource)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
match send_result {
|
||||
Ok(()) => {
|
||||
// Update coalesce timestamp ONLY on successful send.
|
||||
// Skipping a coalesce-window-ago send means the next
|
||||
// attempt re-checks against the same old timestamp, but
|
||||
// moka's insert resets the TTL anyway — so the window
|
||||
// effectively slides forward on each successful send.
|
||||
self.coalesce_cache.insert(coalesce_key, Utc::now());
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "grant.notify_sent",
|
||||
kind = %kind.audit_str(),
|
||||
granter_id = %granter.id(),
|
||||
recipient_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
resource = ?resource,
|
||||
trigger = %trigger.audit_str(),
|
||||
"📨 notify sent ({}) to {}",
|
||||
kind.audit_str(),
|
||||
recipient.email(),
|
||||
);
|
||||
NotifyOutcome::Sent { kind }
|
||||
}
|
||||
Err(err) => {
|
||||
// The grant landed; SMTP failure is non-fatal. Mirror
|
||||
// the long-standing magic-link policy: warn-log, return
|
||||
// a Sent-shaped outcome anyway (the operator sees the
|
||||
// truth in the audit row; the caller's UI is just less
|
||||
// useful for a few seconds).
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "grant.notify_send_failed",
|
||||
kind = %kind.audit_str(),
|
||||
granter_id = %granter.id(),
|
||||
recipient_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
error = %err,
|
||||
"📭 notify send failed ({}): {}",
|
||||
kind.audit_str(),
|
||||
err,
|
||||
);
|
||||
// Don't bump coalesce on failure — we want the next
|
||||
// legitimate attempt to retry.
|
||||
NotifyOutcome::Sent { kind }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render and send the plain-notification email ("Hey, you got a
|
||||
/// new grant"). No magic link; recipient must sign in normally.
|
||||
async fn send_plain_notification(
|
||||
&self,
|
||||
granter: &User,
|
||||
recipient: &User,
|
||||
resource: Resource,
|
||||
) -> Result<(), String> {
|
||||
let locale = self.locale_for(recipient);
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
||||
let inviter_short = granter.display_full(false);
|
||||
let inviter_full = granter.display_full(true);
|
||||
let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),);
|
||||
|
||||
let args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_short.as_str()),
|
||||
("inviter_full", inviter_full.as_str()),
|
||||
("kind", &kind_label),
|
||||
("login_link", &login_link),
|
||||
];
|
||||
|
||||
let subject = self
|
||||
.i18n_or("server.notification.share.subject", &locale, &args)
|
||||
.await;
|
||||
let body = self
|
||||
.render_bilingual("server.notification.share.body", &locale, &args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: recipient.email().to_string(),
|
||||
subject,
|
||||
text_body: body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
self.email_sender
|
||||
.send(message)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.message)
|
||||
}
|
||||
|
||||
/// Resolve a recipient's stored locale → `Locale`. Mirrors
|
||||
/// `MagicLinkInviteService::locale_for`: bad/unknown codes fall back
|
||||
/// to the server default.
|
||||
fn locale_for(&self, user: &User) -> Locale {
|
||||
user.preferred_locale()
|
||||
.and_then(|code| self.locale_registry.parse(code))
|
||||
.unwrap_or_else(|| self.locale_registry.default_locale().clone())
|
||||
}
|
||||
|
||||
/// Translate with arg substitution, falling back to the literal key
|
||||
/// if the i18n lookup errors (defensive — shouldn't happen with the
|
||||
/// English-fallback layer in place).
|
||||
async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String {
|
||||
self.i18n
|
||||
.translate_args(key, Some(locale.clone()), args)
|
||||
.await
|
||||
.unwrap_or_else(|_| key.to_string())
|
||||
}
|
||||
|
||||
/// Body + English-fallback partial. Same shape as
|
||||
/// `MagicLinkInviteService::render_bilingual`. Could be lifted into
|
||||
/// a shared helper later — kept duplicated for now because there
|
||||
/// are only two call sites.
|
||||
async fn render_bilingual(
|
||||
&self,
|
||||
body_key: &str,
|
||||
locale: &Locale,
|
||||
args: &[(&str, &str)],
|
||||
) -> String {
|
||||
let body = self.i18n_or(body_key, locale, args).await;
|
||||
let english_fallback = if locale.is_english() {
|
||||
None
|
||||
} else {
|
||||
Some(self.i18n_or(body_key, &Locale::english(), args).await)
|
||||
};
|
||||
let divider = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.english_fallback_divider",
|
||||
locale,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
let template = BilingualBody {
|
||||
body: body.clone(),
|
||||
divider,
|
||||
english_fallback,
|
||||
};
|
||||
template.render().unwrap_or(body)
|
||||
}
|
||||
|
||||
fn audit_skipped(
|
||||
&self,
|
||||
granter: &User,
|
||||
recipient: &User,
|
||||
resource: Resource,
|
||||
trigger: NotifyTrigger,
|
||||
reason: &'static str,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "grant.notify_skipped",
|
||||
reason = reason,
|
||||
granter_id = %granter.id(),
|
||||
recipient_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
resource = ?resource,
|
||||
trigger = %trigger.audit_str(),
|
||||
"🤫 notify skipped ({}) for {}",
|
||||
reason,
|
||||
recipient.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuses the same partial template as `MagicLinkInviteService`. The
|
||||
/// duplication is intentional: askama derive macros need a struct per
|
||||
/// callsite, and pulling the rendering struct out of the magic-link
|
||||
/// module would create a fan-out of dependencies. Two ~10-line copies
|
||||
/// is cheaper than the abstraction.
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/email_body.txt")]
|
||||
struct BilingualBody {
|
||||
body: String,
|
||||
divider: String,
|
||||
english_fallback: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notify_outcome_to_dto_sent_variants() {
|
||||
let dto_ml = NotifyOutcome::Sent {
|
||||
kind: NotifyKind::MagicLink,
|
||||
}
|
||||
.to_dto();
|
||||
let dto_pn = NotifyOutcome::Sent {
|
||||
kind: NotifyKind::PlainNotification,
|
||||
}
|
||||
.to_dto();
|
||||
match dto_ml {
|
||||
NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "magic_link"),
|
||||
_ => panic!("expected Sent"),
|
||||
}
|
||||
match dto_pn {
|
||||
NotifyOutcomeDto::Sent { detail } => assert_eq!(detail, "plain_notification"),
|
||||
_ => panic!("expected Sent"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_outcome_to_dto_skip_variants() {
|
||||
let now = Utc::now();
|
||||
match (NotifyOutcome::Coalesced { last_sent_at: now }).to_dto() {
|
||||
NotifyOutcomeDto::Coalesced { last_sent_at } => assert_eq!(last_sent_at, now),
|
||||
_ => panic!("expected Coalesced"),
|
||||
}
|
||||
match (NotifyOutcome::RateLimited {
|
||||
retry_after_secs: 3600,
|
||||
})
|
||||
.to_dto()
|
||||
{
|
||||
NotifyOutcomeDto::RateLimited { retry_after_secs } => {
|
||||
assert_eq!(retry_after_secs, 3600)
|
||||
}
|
||||
_ => panic!("expected RateLimited"),
|
||||
}
|
||||
match (NotifyOutcome::NotApplicable {
|
||||
reason: "recipient_opted_out",
|
||||
})
|
||||
.to_dto()
|
||||
{
|
||||
NotifyOutcomeDto::NotApplicable { reason } => {
|
||||
assert_eq!(reason, "recipient_opted_out")
|
||||
}
|
||||
_ => panic!("expected NotApplicable"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_outcome_set_total_recipients_matches_outcomes_len() {
|
||||
let set = NotifyOutcomeSet {
|
||||
outcomes: vec![
|
||||
NotifyOutcome::Sent {
|
||||
kind: NotifyKind::PlainNotification,
|
||||
},
|
||||
NotifyOutcome::Coalesced {
|
||||
last_sent_at: Utc::now(),
|
||||
},
|
||||
NotifyOutcome::NotApplicable {
|
||||
reason: "recipient_opted_out",
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(set.total_recipients(), 3);
|
||||
let dto = set.to_dto();
|
||||
assert_eq!(dto.total_recipients, 3);
|
||||
assert_eq!(dto.outcomes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_outcome_set() {
|
||||
let set = NotifyOutcomeSet::empty();
|
||||
assert_eq!(set.total_recipients(), 0);
|
||||
let dto = set.to_dto();
|
||||
assert_eq!(dto.total_recipients, 0);
|
||||
assert!(dto.outcomes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_strs_are_stable() {
|
||||
// These string values appear in operator-facing audit logs and
|
||||
// log aggregators key off them. A rename here is a breaking
|
||||
// change to dashboards — guard against accidental drift.
|
||||
assert_eq!(NotifyTrigger::GrantCreated.audit_str(), "grant_created");
|
||||
assert_eq!(NotifyTrigger::ManualResend.audit_str(), "manual_resend");
|
||||
assert_eq!(NotifyKind::MagicLink.audit_str(), "magic_link");
|
||||
assert_eq!(
|
||||
NotifyKind::PlainNotification.audit_str(),
|
||||
"plain_notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -761,6 +761,20 @@ pub struct MagicLinkConfig {
|
||||
/// may enforce MFA we shouldn't bypass. See
|
||||
/// `magic_link_eligibility()` for the precedence ladder.
|
||||
pub open_to_password_users: bool,
|
||||
/// Operator-level kill switch for plain-notification emails to
|
||||
/// internal users (PR N1). When `true` (default), users who can't
|
||||
/// receive a magic link (password users, OIDC users) get a "Hey,
|
||||
/// you got a new grant" mail with a `/login` deep link on every
|
||||
/// share. When `false`, the plain-notification arm is suppressed
|
||||
/// entirely — internal users discover shares only on next login.
|
||||
///
|
||||
/// This is a coarser knob than the per-user
|
||||
/// `auth.users.notify_on_share` column: when this is `false`, the
|
||||
/// user-level opt-in does not matter. External-user magic-link
|
||||
/// invitations are NOT affected by this flag — those always send,
|
||||
/// because the link is the only way the recipient can claim the
|
||||
/// share for the first time.
|
||||
pub notify_internal_users_on_share: bool,
|
||||
}
|
||||
|
||||
impl Default for MagicLinkConfig {
|
||||
@@ -774,6 +788,7 @@ impl Default for MagicLinkConfig {
|
||||
send_per_email_per_hour: 5,
|
||||
send_per_ip_per_hour: 200,
|
||||
open_to_password_users: false,
|
||||
notify_internal_users_on_share: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1475,6 +1490,9 @@ impl AppConfig {
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
||||
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
|
||||
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
|
||||
let trimmed = v.trim();
|
||||
|
||||
+39
-5
@@ -948,9 +948,10 @@ impl AppServiceFactory {
|
||||
),
|
||||
),
|
||||
)),
|
||||
email_sender: None, // populated below
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
email_sender: None, // populated below
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
recipient_notification_service: None, // populated below alongside magic_link_invite_service
|
||||
// 60 lookups / minute / caller; cap at 50 000 tracked
|
||||
// callers to bound memory. The same limiter instance is
|
||||
// shared by every clone of AppState since it lives in an
|
||||
@@ -1013,9 +1014,9 @@ impl AppServiceFactory {
|
||||
);
|
||||
app_state.magic_link_invite_service = Some(Arc::new(
|
||||
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
|
||||
invite_user_storage,
|
||||
invite_user_storage.clone(),
|
||||
invite_magic_link_repo,
|
||||
email_sender,
|
||||
email_sender.clone(),
|
||||
lifecycle,
|
||||
app_state.applications.i18n_service.clone(),
|
||||
app_state.locale_registry.clone(),
|
||||
@@ -1023,6 +1024,30 @@ impl AppServiceFactory {
|
||||
self.config.base_url(),
|
||||
),
|
||||
));
|
||||
|
||||
// PR N1: wire the unified RecipientNotificationService.
|
||||
// Only constructed when MagicLinkInviteService is also
|
||||
// available — the magic-link path delegates to it.
|
||||
// SubjectGroupService is built earlier in this factory; the
|
||||
// notification service needs it for the Group subject arm.
|
||||
if let (Some(magic_link_svc), Some(subject_groups)) = (
|
||||
app_state.magic_link_invite_service.clone(),
|
||||
app_state.subject_group_service.clone(),
|
||||
) {
|
||||
app_state.recipient_notification_service = Some(Arc::new(
|
||||
crate::application::services::recipient_notification_service::RecipientNotificationService::new(
|
||||
invite_user_storage,
|
||||
magic_link_svc,
|
||||
email_sender,
|
||||
app_state.applications.i18n_service.clone(),
|
||||
app_state.locale_registry.clone(),
|
||||
subject_groups,
|
||||
app_state.magic_link_send_per_email_rate_limiter.clone(),
|
||||
self.config.magic_link.clone(),
|
||||
self.config.base_url(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -1374,6 +1399,15 @@ pub struct AppState {
|
||||
pub magic_link_invite_service: Option<
|
||||
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
|
||||
>,
|
||||
/// Unified share-notification dispatcher (PR N1) — used by both
|
||||
/// `create_grant` and the future `POST /api/grants/{id}/notify` to
|
||||
/// route share emails through coalesce + rate-limit + per-recipient
|
||||
/// dispatch. `None` when SMTP / magic-link / subject-group services
|
||||
/// aren't all configured; callers degrade to silent no-op in that
|
||||
/// case (no mail sent, grant still created).
|
||||
pub recipient_notification_service: Option<
|
||||
Arc<crate::application::services::recipient_notification_service::RecipientNotificationService>,
|
||||
>,
|
||||
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
|
||||
/// endpoint's primary defense is the visibility check, but a stale
|
||||
/// JWT could in theory iterate UUIDs against the related-by-grant
|
||||
|
||||
@@ -85,6 +85,16 @@ pub struct User {
|
||||
/// application layer is the authoritative gatekeeper against the
|
||||
/// `LocaleRegistry`.
|
||||
preferred_locale: Option<String>,
|
||||
/// Per-user opt-out for share-notification emails (PR N1). TRUE =
|
||||
/// receive a mail when someone grants access to a resource (default);
|
||||
/// FALSE = grant still recorded but `RecipientNotificationService`
|
||||
/// returns `NotApplicable { recipient_opted_out }` and no mail is
|
||||
/// sent. Bypassed for magic-link first-invitations to external users
|
||||
/// — the link is their only way to claim the share, so suppressing
|
||||
/// it would lock them out. Once an external becomes a real account
|
||||
/// and opts out, subsequent shares from other granters honor the
|
||||
/// flag.
|
||||
notify_on_share: bool,
|
||||
}
|
||||
|
||||
impl User {
|
||||
@@ -178,6 +188,11 @@ impl User {
|
||||
// language switcher, or invitation-time inheritance fill
|
||||
// this in later. NULL resolves to OXICLOUD_DEFAULT_LOCALE.
|
||||
preferred_locale: None,
|
||||
// PR N1: default to opted-in. The profile checkbox is the
|
||||
// user-facing toggle; the column default in
|
||||
// `users_notify_on_share` mirrors this for rows reconstructed
|
||||
// from disk without going through `new`.
|
||||
notify_on_share: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +236,7 @@ impl User {
|
||||
family_name: None,
|
||||
email_verified_at: None,
|
||||
preferred_locale: None,
|
||||
notify_on_share: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +261,7 @@ impl User {
|
||||
family_name: Option<String>,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
preferred_locale: Option<String>,
|
||||
notify_on_share: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -266,6 +283,7 @@ impl User {
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +359,64 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rich, user-facing display label for notification surfaces
|
||||
/// (transactional emails, share invitations, "Alice <a@x.com>
|
||||
/// shared X with you" — anywhere a human is reading the line).
|
||||
///
|
||||
/// `with_email` controls whether the address is appended as
|
||||
/// `" <email>"` after the name part:
|
||||
/// - `true` — best for the email **body** ("Alice Smith
|
||||
/// <alice@example.com> shared a folder with you"), where the
|
||||
/// extra identifier is helpful at a glance.
|
||||
/// - `false` — best for the **subject line** and other compact
|
||||
/// contexts where dragging the email into a 80-char inbox row
|
||||
/// would be noise ("Alice Smith shared a folder with you").
|
||||
///
|
||||
/// Priority order (mirrors RFC 5322 display-name conventions). The
|
||||
/// `<email>` decoration in cases 1 and 3 is omitted when
|
||||
/// `with_email` is false:
|
||||
///
|
||||
/// 1. `"Given Family"` (+ ` <email>`) — full name; the most
|
||||
/// informative form.
|
||||
/// 2. `"username"` (+ ` <email>`) — handle; the typical case
|
||||
/// for password / OIDC users without first/last claims.
|
||||
/// 3. `email` — last-resort fallback. The
|
||||
/// raw email address is always present for non-OCM users and is
|
||||
/// the unambiguous identifier. Returned regardless of
|
||||
/// `with_email` since it IS the label here.
|
||||
/// 4. shortened UUID — failure mode (no email,
|
||||
/// no username, no given/family — shouldn't happen with current
|
||||
/// schema invariants but kept defensive for OCM-federated rows).
|
||||
///
|
||||
/// External users provisioned via magic-link typically have only an
|
||||
/// email and fall through to branch 3. Internal users with OIDC
|
||||
/// JIT often have given/family from the IdP claims → branch 1.
|
||||
/// Sister of [`Self::display_for_audit`], which deliberately
|
||||
/// returns a *less* identifying label for log lines.
|
||||
pub fn display_full(&self, with_email: bool) -> String {
|
||||
let g = self.given_name.as_deref();
|
||||
let f = self.family_name.as_deref();
|
||||
let u = self.username.as_deref();
|
||||
let has_email = !self.email.is_empty();
|
||||
|
||||
if let (Some(g), Some(f)) = (g, f) {
|
||||
if with_email && has_email {
|
||||
return format!("{} {} <{}>", g, f, self.email);
|
||||
}
|
||||
return format!("{} {}", g, f);
|
||||
}
|
||||
if let Some(u) = u {
|
||||
if with_email && has_email {
|
||||
return format!("{} <{}>", u, self.email);
|
||||
}
|
||||
return u.to_string();
|
||||
}
|
||||
if has_email {
|
||||
return self.email.clone();
|
||||
}
|
||||
format!("{}…", &self.id.to_string()[..8])
|
||||
}
|
||||
|
||||
pub fn oidc_provider(&self) -> Option<&str> {
|
||||
self.oidc_provider.as_deref()
|
||||
}
|
||||
@@ -430,6 +506,24 @@ impl User {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Whether this user wants to receive an email when someone grants
|
||||
/// them access to a resource. `RecipientNotificationService` checks
|
||||
/// this on the plain-notification arm; magic-link first-invitations
|
||||
/// to external users bypass it (otherwise the recipient could never
|
||||
/// claim the share). Defaults TRUE for both the entity constructor
|
||||
/// and the schema column.
|
||||
pub fn notify_on_share(&self) -> bool {
|
||||
self.notify_on_share
|
||||
}
|
||||
|
||||
/// Flip the share-notification preference. The caller is expected
|
||||
/// to have already validated input shape (the field is a boolean,
|
||||
/// so there is no work beyond storage). Bumps `updated_at`.
|
||||
pub fn set_notify_on_share(&mut self, notify: bool) {
|
||||
self.notify_on_share = notify;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Claim or change the username. Runs the same validation as the
|
||||
/// constructor — callers must still ensure uniqueness at the repo
|
||||
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||
@@ -584,3 +678,93 @@ impl User {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_user(
|
||||
username: Option<&str>,
|
||||
given: Option<&str>,
|
||||
family: Option<&str>,
|
||||
email: &str,
|
||||
) -> User {
|
||||
User::from_data_full(
|
||||
Uuid::new_v4(),
|
||||
username.map(str::to_string),
|
||||
email.to_string(),
|
||||
None,
|
||||
UserRole::User,
|
||||
0,
|
||||
0,
|
||||
Utc::now(),
|
||||
Utc::now(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
given.map(str::to_string),
|
||||
family.map(str::to_string),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_given_family_with_email() {
|
||||
let u = build_user(Some("alice"), Some("Alice"), Some("Smith"), "alice@x.com");
|
||||
assert_eq!(u.display_full(true), "Alice Smith <alice@x.com>");
|
||||
assert_eq!(u.display_full(false), "Alice Smith");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_given_family_takes_priority_over_username() {
|
||||
// Even when the username is set, the full name is more informative
|
||||
// and wins. The username surfaces only as part of the address.
|
||||
let u = build_user(Some("admin"), Some("Bob"), Some("Jones"), "bob@x.com");
|
||||
assert_eq!(u.display_full(true), "Bob Jones <bob@x.com>");
|
||||
assert_eq!(u.display_full(false), "Bob Jones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_username_only() {
|
||||
// The "admin" case the user observed: no given/family on the
|
||||
// bootstrap admin user. With email → "admin <admin@x.com>";
|
||||
// without → just "admin" (compact form for subject lines).
|
||||
let u = build_user(Some("admin"), None, None, "admin@x.com");
|
||||
assert_eq!(u.display_full(true), "admin <admin@x.com>");
|
||||
assert_eq!(u.display_full(false), "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_partial_name_falls_through_to_username() {
|
||||
// Given without family (or vice versa) is NOT "rich enough" to
|
||||
// use; we walk to the next priority instead of producing a
|
||||
// "First <email>" half-name.
|
||||
let u = build_user(Some("carol"), Some("Carol"), None, "carol@x.com");
|
||||
assert_eq!(u.display_full(true), "carol <carol@x.com>");
|
||||
assert_eq!(u.display_full(false), "carol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_email_only() {
|
||||
// External users provisioned via magic-link typically have no
|
||||
// username and no given/family — only the email is present.
|
||||
// `with_email` is moot here: the email IS the label.
|
||||
let u = build_user(None, None, None, "external@x.com");
|
||||
assert_eq!(u.display_full(true), "external@x.com");
|
||||
assert_eq!(u.display_full(false), "external@x.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_full_partial_name_no_username_falls_to_email() {
|
||||
// Lone given_name without family AND without username → falls
|
||||
// all the way through to the raw email.
|
||||
let u = build_user(None, Some("Solo"), None, "solo@x.com");
|
||||
assert_eq!(u.display_full(true), "solo@x.com");
|
||||
assert_eq!(u.display_full(false), "solo@x.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ pub trait UserRepository: Send + Sync + 'static {
|
||||
/// Gets a user by ID
|
||||
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
|
||||
|
||||
/// Batch-loads a set of users by id, preserving no particular order
|
||||
/// and silently skipping ids that don't match any row. Caller is
|
||||
/// responsible for de-duplicating the input vec. Returns an empty
|
||||
/// vec when given an empty input. Used by group-recipient expansion
|
||||
/// in `RecipientNotificationService` to avoid N+1 queries.
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Gets a user by username
|
||||
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User>;
|
||||
|
||||
|
||||
@@ -279,6 +279,13 @@ pub struct OutgoingGrantEntry {
|
||||
/// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`).
|
||||
/// Always `false` for `user` subjects.
|
||||
pub has_password: bool,
|
||||
/// True when the user subject is a magic-link-only external user
|
||||
/// (`auth.users.is_external = TRUE`). Always `false` for token and
|
||||
/// group subjects, and for internal-user subjects. Surfaced on the
|
||||
/// My Shares DTO so the frontend's per-row menu can label the
|
||||
/// notify item "Resend invitation email" (external) vs "Notify by
|
||||
/// email" (internal).
|
||||
pub is_external: bool,
|
||||
}
|
||||
|
||||
/// All subjects that the current user has shared a single resource with,
|
||||
|
||||
@@ -97,10 +97,10 @@ impl UserRepository for UserPgRepository {
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, is_external,
|
||||
given_name, family_name, email_verified_at,
|
||||
preferred_locale
|
||||
preferred_locale, notify_on_share
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17, $18
|
||||
$12, $13, $14, $15, $16, $17, $18, $19
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
@@ -123,6 +123,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.bind(user_clone.notify_on_share())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -147,7 +148,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -184,6 +185,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -233,6 +235,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -245,7 +248,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -282,9 +285,71 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Batch loads users by id in one query (avoids N+1 for group-
|
||||
/// recipient expansion). Missing ids are silently skipped — the
|
||||
/// caller treats absent rows as "no such recipient", same as
|
||||
/// `get_user_by_id` returning `NotFound` for a single lookup.
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_data_full(
|
||||
row.get("id"),
|
||||
row.get("username"),
|
||||
row.get("email"),
|
||||
row.get("password_hash"),
|
||||
role,
|
||||
row.get("storage_quota_bytes"),
|
||||
row.get("storage_used_bytes"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
row.get("last_login_at"),
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Updates an existing user using a transaction
|
||||
async fn update_user(&self, user: User) -> UserRepositoryResult<User> {
|
||||
// Create a copy of the user for the closure
|
||||
@@ -310,7 +375,8 @@ impl UserRepository for UserPgRepository {
|
||||
given_name = $12,
|
||||
family_name = $13,
|
||||
email_verified_at = $14,
|
||||
preferred_locale = $15
|
||||
preferred_locale = $15,
|
||||
notify_on_share = $16
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -329,6 +395,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.bind(user_clone.notify_on_share())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -401,7 +468,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE ($3 OR is_external = FALSE)
|
||||
ORDER BY created_at DESC
|
||||
@@ -445,6 +512,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -466,7 +534,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE (username ILIKE $1 OR email ILIKE $1)
|
||||
AND ($3 OR is_external = FALSE)
|
||||
@@ -510,6 +578,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -597,7 +666,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -638,6 +707,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -674,7 +744,7 @@ impl UserRepository for UserPgRepository {
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
given_name, family_name, email_verified_at, preferred_locale, notify_on_share
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -711,6 +781,7 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
row.get("notify_on_share"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -792,6 +863,12 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::get_users_by_ids(self, ids)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||
UserRepository::get_user_by_username(self, username)
|
||||
.await
|
||||
|
||||
@@ -347,6 +347,32 @@ impl PgAclEngine {
|
||||
Ok(Some((res, granter)))
|
||||
}
|
||||
|
||||
/// Variant of `find_grant_by_id` that also returns the subject —
|
||||
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
|
||||
/// Returns `(subject, resource, granted_by)` or `None`.
|
||||
pub async fn find_grant_full_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
|
||||
FROM storage.access_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_full_by_id: {e}")))?;
|
||||
|
||||
let Some((st, sid, rt, rid, granter)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let subject = Subject::from_parts(&st, sid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
|
||||
let resource = Resource::from_parts(&rt, rid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
Ok(Some((subject, resource, granter)))
|
||||
}
|
||||
|
||||
/// Row type for all full-grant SELECT queries:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -865,6 +891,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// 10 sort_str Option<String>
|
||||
// 11 sort_int Option<i64>
|
||||
// 12 has_password bool — token: shares.password_hash IS NOT NULL
|
||||
// 13 is_external bool — user: auth.users.is_external (PR N2);
|
||||
// FALSE for token/group subjects.
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
@@ -879,6 +907,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
bool,
|
||||
bool,
|
||||
);
|
||||
|
||||
let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone());
|
||||
@@ -962,7 +991,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
@@ -1025,6 +1055,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
MAX(CASE
|
||||
WHEN ag.subject_type = 'group' THEN 0
|
||||
WHEN ag.subject_type = 'user' THEN 1
|
||||
@@ -1066,7 +1097,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.permission,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
@@ -1110,6 +1142,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
CASE
|
||||
WHEN BOOL_OR(ag.permission = 'delete')
|
||||
AND BOOL_OR(ag.permission = 'share') THEN 0
|
||||
@@ -1150,7 +1183,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.permission,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
@@ -1199,7 +1233,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
@@ -1283,6 +1318,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
is_external,
|
||||
) = r;
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
@@ -1303,6 +1339,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
granted_at,
|
||||
expires_at,
|
||||
has_password,
|
||||
is_external,
|
||||
},
|
||||
)
|
||||
});
|
||||
@@ -1399,6 +1436,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
is_external,
|
||||
) = r;
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
@@ -1425,6 +1463,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
granted_at,
|
||||
expires_at,
|
||||
has_password,
|
||||
is_external,
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
|
||||
@@ -20,14 +20,15 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto,
|
||||
role_from_permissions,
|
||||
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
|
||||
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
|
||||
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
|
||||
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::services::recipient_notification_service::NotifyTrigger;
|
||||
use crate::common::di::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -50,7 +51,7 @@ type AppStateRef = Arc<AppState>;
|
||||
path = "/api/grants",
|
||||
request_body = CreateGrantDto,
|
||||
responses(
|
||||
(status = 201, description = "Grant(s) created", body = Vec<GrantDto>),
|
||||
(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"),
|
||||
),
|
||||
@@ -172,28 +173,78 @@ pub async fn create_grant(
|
||||
caller_id
|
||||
);
|
||||
|
||||
// Fire the invitation email AFTER the grant rows are in place so a
|
||||
// failed SMTP send can't leave the recipient with mail-but-no-access.
|
||||
// The service swallows SMTP errors (logs only) — the API response
|
||||
// stays 201 Created either way, matching the plan's "201 always
|
||||
// when grants land; mail is best-effort" contract.
|
||||
if let Some(recipient) = invite_recipient
|
||||
&& let Some(invite_svc) = state.magic_link_invite_service.as_ref()
|
||||
{
|
||||
let inviter_name = auth_user.username.clone();
|
||||
if let Err(e) = invite_svc
|
||||
.issue_invitation(&recipient, &inviter_name, resource)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"invitation issuance failed for {} (grants already created): {}",
|
||||
recipient.email(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
(StatusCode::CREATED, Json(results)).into_response()
|
||||
// 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: results,
|
||||
notification,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -246,6 +297,171 @@ pub async fn revoke_grant(
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -742,6 +958,7 @@ pub async fn list_my_shares(
|
||||
granted_at: g.granted_at,
|
||||
expires_at: g.expires_at,
|
||||
has_password: g.has_password,
|
||||
is_external: g.is_external,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/", post(grant_handler::create_grant))
|
||||
.route("/", get(grant_handler::list_on_resource))
|
||||
.route("/{id}", delete(grant_handler::revoke_grant))
|
||||
.route("/{id}/notify", post(grant_handler::notify_grant_recipient))
|
||||
.route("/role", put(grant_handler::set_role))
|
||||
.route("/incoming", get(grant_handler::list_incoming))
|
||||
.route(
|
||||
|
||||
Reference in New Issue
Block a user