Files
Oxicloud/src/application/dtos/settings_dto.rs
T
2026-08-09 10:41:34 +02:00

494 lines
21 KiB
Rust

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
// ============================================================================
// OIDC Settings DTOs (Admin Panel)
// ============================================================================
/// Current OIDC settings returned to admin UI (secrets masked)
#[derive(Debug, Serialize, Deserialize)]
pub struct OidcSettingsDto {
pub enabled: bool,
pub issuer_url: String,
pub client_id: String,
/// True if a client secret is configured (never reveals the actual value)
pub client_secret_set: bool,
pub scopes: String,
pub auto_provision: bool,
pub admin_groups: String,
pub disable_password_login: bool,
pub provider_name: String,
/// Auto-generated callback URL the admin must register in their IdP
pub callback_url: String,
/// Field names overridden by environment variables (read-only in UI)
pub env_overrides: Vec<String>,
}
/// Request body for saving OIDC settings from the admin panel
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SaveOidcSettingsDto {
pub enabled: bool,
pub issuer_url: String,
pub client_id: String,
/// Only update if provided and non-empty (None = keep existing)
pub client_secret: Option<String>,
pub scopes: Option<String>,
pub auto_provision: Option<bool>,
pub admin_groups: Option<String>,
pub disable_password_login: Option<bool>,
pub provider_name: Option<String>,
}
/// Request body for testing OIDC discovery
#[derive(Debug, Serialize, Deserialize)]
pub struct TestOidcConnectionDto {
pub issuer_url: String,
}
/// Result of OIDC connection test
#[derive(Debug, Serialize, Deserialize)]
pub struct OidcTestResultDto {
pub success: bool,
pub message: String,
pub issuer: Option<String>,
pub authorization_endpoint: Option<String>,
pub token_endpoint: Option<String>,
pub userinfo_endpoint: Option<String>,
/// Suggested provider name (derived from issuer hostname)
pub provider_name_suggestion: Option<String>,
}
// ============================================================================
// Admin User Management DTOs
// ============================================================================
/// Request body for updating a user's role
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserRoleDto {
pub role: String,
}
/// Request body for updating a user's active status
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserActiveDto {
pub active: bool,
}
/// Request body for updating a user's storage quota
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserQuotaDto {
/// Quota in bytes. Use 0 for unlimited.
pub quota_bytes: i64,
}
/// Request body for admin-created users
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AdminCreateUserDto {
pub username: String,
pub password: String,
/// Optional — if omitted, a placeholder email is generated
pub email: Option<String>,
/// "admin" or "user"; defaults to "user"
pub role: Option<String>,
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
/// Ignored when `is_external = true` (external users have no storage).
pub quota_bytes: Option<i64>,
/// Whether the account is active; defaults to true
pub active: Option<bool>,
/// `true` to create a grant-only external user (no home folder, no
/// storage quota). Defaults to `false` (internal user). External
/// users authenticate via magic-link / OIDC / OCM federation —
/// password is set but never used.
pub is_external: Option<bool>,
}
/// Request body for admin password reset
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AdminResetPasswordDto {
pub new_password: String,
}
/// Query parameters for listing users
#[derive(Debug, Serialize, Deserialize)]
pub struct ListUsersQueryDto {
pub limit: Option<i64>,
pub offset: Option<i64>,
/// Return only the fields rendered by the paginated management table.
/// Defaults to `false` so existing API clients keep the full user shape.
pub summary: Option<bool>,
}
/// Query parameters for the admin sessions listing.
///
/// `user_id` is a String (not `Uuid`) because bad UUIDs need a clean
/// 400 response — the handler parses and rejects malformed input.
/// `include_revoked` defaults to `false` at the handler layer.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListSessionsQueryDto {
pub user_id: Option<String>,
pub include_revoked: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// One row of the dashboard's quota panel — usage aggregate for a
/// single drive kind. Unlimited caps are excluded from `capped_quota_bytes`
/// and counted in `unlimited_count` so the panel can render the ratio
/// honestly ("X / Y over N capped drives · M unlimited").
#[derive(Debug, Serialize, Deserialize)]
pub struct DriveKindUsageDto {
/// `"personal"` or `"shared"`.
pub kind: String,
/// Total bytes stored across drives of this kind. Excludes trashed
/// files (see `bug_trash_excluded_from_quota` for the known gap).
pub used_bytes: i64,
/// Sum of caps over capped drives only. `None` when there are no
/// capped drives of this kind (would otherwise report `0 / 0`
/// meaninglessly).
pub capped_quota_bytes: Option<i64>,
/// Count of drives (personal: users) with no cap. Personal-kind
/// unlimited = `auth.users.storage_quota_bytes = 0`; shared-kind
/// unlimited = `storage.drives.quota_bytes IS NULL`.
pub unlimited_count: i64,
/// Count of drives with a numeric cap. Used to hide rows with
/// zero drives and denominate the ratio.
pub capped_count: i64,
}
/// Dashboard statistics
#[derive(Debug, Serialize, Deserialize)]
pub struct DashboardStatsDto {
// System info
pub server_version: String,
pub auth_enabled: bool,
pub oidc_configured: bool,
pub quotas_enabled: bool,
// User stats
pub total_users: i64,
pub active_users: i64,
pub admin_users: i64,
// ── Per-drive-kind quota accounting ──
// One row per drive kind (personal, shared). Pre-dedup, logical
// file sizes summed from `drives.used_bytes` (personal rolls up
// via the user envelope). Cap sums exclude unlimited entries;
// `unlimited_count` tracks them separately so the ratio stays
// honest.
pub drive_usage: Vec<DriveKindUsageDto>,
pub users_over_80_percent: i64,
pub users_over_quota: i64,
// ── Backend physical accounting ──
// Bytes actually stored on the active backend (`storage.blobs`
// aggregate) plus the dedup ratio (referenced / stored).
// `total_bytes_stored` is typically << `total_used_bytes` on a
// healthy deployment — dedup + shared blobs mean many user file
// rows resolve to one physical blob. `None` when the dedup
// stats service is unavailable or errored (dashboard renders as
// "—" in that case).
#[serde(skip_serializing_if = "Option::is_none")]
pub total_bytes_stored: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedup_ratio: Option<f64>,
pub registration_enabled: bool,
}
// ============================================================================
// Storage Settings DTOs (Admin Panel)
// ============================================================================
/// Current storage settings returned to admin UI.
///
/// Post-multi-entry (`docs/plan/storage-multi-entry.md`) this exposes:
/// - the entries declared in `.env` (safe: `location_hint` shows
/// provider+bucket, credential-related fields never appear),
/// - the name of the active entry (backend selection is admin-observable),
/// - the read-only flag (drives the UI banner during migration),
/// - the currently-live backend type + dedup stats (informational).
///
/// The pre-multi-entry `s3_*` / `backend` / `env_overrides` fields
/// used to also appear here — they duplicated `entries[]` and leaked
/// stale legacy admin_settings rows, so slice-6 dropped them. Consumers
/// wanting per-provider details read them off `entries[i].backend` and
/// `entries[i].location_hint` instead.
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageSettingsDto {
// ── Current stats — pertain to the running process ──
/// Backend type currently in use (`"local"` / `"s3"` / `"azure"`) —
/// what the LIVE `blob_backend` is bound to, from
/// `dedup_service.backend().backend_type()`. Redundant with
/// `entries[i where is_active].backend` in multi-entry mode; kept
/// because pre-boot / mid-migration inspection may still find it
/// useful.
pub current_backend: String,
pub total_blobs: u64,
pub total_bytes_stored: u64,
pub dedup_ratio: f64,
// ── Multi-entry view (slice 6) ──
/// All named storage entries declared in env. Empty when running
/// in legacy single-backend mode (`OXICLOUD_STORAGE_ENTRIES`
/// unset AND no legacy synthesis happened). Order matches
/// `_ENTRIES`.
pub entries: Vec<StorageEntrySummaryDto>,
/// Name of the entry the LIVE backend is currently bound to.
/// Populated as the boot-selected name (per
/// `CoreServices.active_backend_name`). Empty string for the
/// zero-entries legacy path (`"legacy"` sentinel).
pub active_entry_name: String,
/// Global read-only flag — when true, all write-adjacent
/// AuthZ checks refuse. Set by the migration handler at run
/// start; cleared by the boot-clear rule after operator
/// restart. Frontend renders a banner on the storage tab when
/// true.
pub migration_readonly: bool,
}
/// Per-entry summary emitted in `StorageSettingsDto.entries`. Never
/// carries credentials — those live in env vars only. `is_active`
/// marks which entry the LIVE backend uses right now (matches
/// `active_entry_name` on the parent DTO).
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageEntrySummaryDto {
pub name: String,
/// Backend type — "local" / "s3" / "azure".
pub backend: String,
/// True for exactly one entry (the entry the LIVE backend is on).
/// Frontend uses this to badge the active row and to exclude it
/// from the migration-target dropdown.
pub is_active: bool,
/// True when the entry has a per-entry encryption key. UI shows
/// a lock icon. Presence-only — the key bytes never leave the
/// server.
pub encryption_enabled: bool,
/// Human-readable physical location hint, if the backend surfaces
/// one (`root_dir` for Local, `bucket` for S3, `container` for
/// Azure). Cosmetic — helps the admin distinguish two Local
/// entries pointing at different disks.
pub location_hint: Option<String>,
/// Ordered pair-list summary — one entry per configured pair in
/// `OXICLOUD_STORAGE_<NAME>_ENCRYPTION_KEY`, oldest first, head
/// last. Empty vec means the entry has no `_ENCRYPTION_KEY`
/// declared at all (pure plaintext-v1 writes today, no crypto).
///
/// Frontend renders this on the entry card so operators can:
/// - See which pairs are configured + their SSH-style
/// fingerprints without inspecting `.env`.
/// - Cross-reference the head pair against the `head_key_fp`
/// from the last `backend_rotate` completion — if they
/// match AND `failed = 0`, every on-disk blob is under the
/// head, and non-head pairs are safe to remove.
#[serde(default)]
pub encryption_pairs: Vec<StorageEncryptionPairDto>,
}
/// One `<cipher>:<key>` pair rendered for the admin UI. Never
/// carries key material — only cipher name + a truncated fingerprint
/// safe to show operators.
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageEncryptionPairDto {
/// `"aes-256-gcm"` for a real-cipher pair, `"none"` for a
/// `none:` sentinel (writes as plaintext-v1).
pub cipher: String,
/// SSH-style colon-hex 8-byte truncation of `sha256(key)`.
/// Matches the v1 header's `<key_fp>` field and the CLI's
/// `oxicloud --fingerprint <key>` output. `None` for `none:`
/// pairs (no key material to fingerprint).
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// True for the LAST pair in the list — the write pair. UI
/// badges it distinctly ("← head" or an arrow). Exactly one
/// pair has `is_head = true` when the list is non-empty.
pub is_head: bool,
}
/// Request body for saving storage settings from the admin panel
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SaveStorageSettingsDto {
pub backend: String,
pub s3_endpoint_url: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
/// Only update if provided and non-empty (None = keep existing)
pub s3_access_key: Option<String>,
/// Only update if provided and non-empty (None = keep existing)
pub s3_secret_key: Option<String>,
pub s3_force_path_style: Option<bool>,
}
/// Request body for testing a storage connection.
///
/// Two shapes are accepted:
///
/// - Multi-entry test — set `entry_name` to the name of a declared entry
/// (from `OXICLOUD_STORAGE_ENTRIES`). Server looks it up, builds a fresh
/// backend via the shared factory, runs health-check + round-trip against
/// it. `backend` and the S3 fields are ignored in this mode.
/// - Legacy DTO test — leave `entry_name` unset and populate `backend` +
/// the S3 fields. Server builds a temporary backend from those values
/// (pre-multi-entry behaviour). Still supported for zero-entries
/// deployments; deprecated for new integrations.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TestStorageConnectionDto {
/// If set, all other fields are ignored — server resolves this
/// name against `OXICLOUD_STORAGE_ENTRIES` and tests that entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_name: Option<String>,
#[serde(default)]
pub backend: String,
pub s3_endpoint_url: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
pub s3_access_key: Option<String>,
pub s3_secret_key: Option<String>,
pub s3_force_path_style: Option<bool>,
}
// Default is derived — every field is either an Option (defaults to
// None) or `backend: String` (empty via `#[serde(default)]`). The
// hand-rolled impl was flagged by clippy::derivable_impls.
/// Result of a storage connection + round-trip test.
///
/// `connected` is TRUE when the backend was reachable (health-check
/// passed — HEAD bucket / statfs). `roundtrip_passed` is TRUE when
/// the subsequent PUT → GET → verify → DELETE cycle succeeded — it
/// validates the exact permissions the migration job needs
/// (`s3:PutObject` + `s3:GetObject` + `s3:DeleteObject` on S3, disk
/// write permission on Local). All round-trip fields are `None` when
/// reachability failed (we don't attempt the round-trip if we can't
/// even HEAD the bucket).
///
/// `phase_reached` names the last step that succeeded — on
/// `roundtrip_passed = false` it pinpoints where the failure hit
/// (`put_ok` → wrote but couldn't confirm; `exists_ok` → wrote +
/// confirmed but GET failed; etc.). `cleanup_ok = false` means the
/// backend was readable + writable but the test object may be
/// orphaned on it (~100 B, content-addressed — harmless, admin can
/// reap by hash).
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageTestResultDto {
pub connected: bool,
pub message: String,
pub backend_type: String,
pub available_bytes: Option<u64>,
/// Set only when reachability passed AND a round-trip was
/// attempted. `Some(true)` = full write + read + verify success;
/// `Some(false)` = reachability OK, round-trip failed at
/// `phase_reached`; `None` = round-trip not attempted (typically
/// because reachability failed).
#[serde(skip_serializing_if = "Option::is_none")]
pub roundtrip_passed: Option<bool>,
/// Last round-trip phase completed successfully — one of
/// `initialize`, `put_ok`, `exists_ok`, `get_ok`, `verify_ok`,
/// `cleanup_ok`. `None` when round-trip wasn't attempted.
#[serde(skip_serializing_if = "Option::is_none")]
pub phase_reached: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_written: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_read: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub roundtrip_elapsed_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cleanup_ok: Option<bool>,
}
// ============================================================================
// Migration DTOs (Admin Panel — Storage Migration)
// ============================================================================
/// Migration progress returned by `GET /api/admin/storage/migration`.
/// Re-exports the `MigrationState` shape for the admin UI.
#[derive(Debug, Serialize, Deserialize)]
pub struct MigrationStateDto {
pub status: String,
pub total_blobs: u64,
pub migrated_blobs: u64,
pub migrated_bytes: u64,
pub failed_blobs: Vec<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
/// Estimated throughput in bytes/sec (for UI ETA calculation).
pub throughput_bytes_per_sec: Option<f64>,
}
/// Request body for `POST /api/admin/storage/migration/start`.
///
/// **Multi-entry contract** (see `docs/plan/storage-multi-entry.md`):
/// `target_name` is REQUIRED — it names the storage entry the copy
/// job will move blobs INTO. The admin picks it from the entries
/// declared in `OXICLOUD_STORAGE_ENTRIES`. The trigger endpoint
/// rejects the request when the name doesn't exist or equals the
/// currently-active entry (no-op guard).
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct StartMigrationDto {
/// Name of the storage entry to migrate blobs INTO. Must be
/// present in `OXICLOUD_STORAGE_ENTRIES` and must differ from
/// the currently-active entry.
pub target_name: String,
/// How many blobs to copy in parallel (default: 4).
///
/// **Currently ignored** — the recoverable copy loop is
/// sequential (one blob at a time within the batch). Kept in
/// the DTO for wire-compat with the admin UI form; will be
/// honoured once per-batch fan-out lands (dual-write /
/// concurrent-copy future slice).
pub concurrency: Option<usize>,
}
// VerifyMigrationDto retired in slice 7 of
// docs/plan/storage-multi-entry.md — the corresponding endpoint's
// sample-based check is superseded by
// `blobs_consistency?storage=<name>`, a full walk that emits
// structured findings per mismatch.
// ============================================================================
// SMTP Settings DTOs (Admin Panel)
// ============================================================================
/// Read-only SMTP info shown on the admin SMTP page. SMTP configuration
/// is sourced exclusively from environment variables — these fields are
/// for display only and any change has to happen by updating the env
/// and restarting the server.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SmtpInfoDto {
/// Whether `OXICLOUD_SMTP_HOST` is set and SMTP construction succeeded.
pub enabled: bool,
/// `OXICLOUD_SMTP_HOST`. Empty string when unset.
pub host: String,
/// `OXICLOUD_SMTP_PORT`. Default 587.
pub port: u16,
/// Transport encryption mode: `"starttls"`, `"tls"`, or `"none"`.
pub tls: String,
/// `OXICLOUD_SMTP_FROM` mailbox. Empty when unset.
pub from: String,
/// `<set>` if a SASL user is configured, `<anon>` otherwise.
/// Never echoes the username — admins compare against the
/// runtime config without having to look in `.env`.
pub user_state: &'static str,
}
/// Request body for `POST /api/admin/smtp/test`: send a hardcoded
/// diagnostic email to the given recipient.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SendSmtpTestDto {
pub to: String,
}
/// Result of a `POST /api/admin/smtp/test` invocation. `success=true`
/// carries the SMTP server's response code + first reply line; on
/// failure the relevant error message goes in `error`. Always 200 OK
/// so the frontend can render both outcomes in one place — the SMTP
/// failure is a normal operational state, not an HTTP error.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SmtpTestResultDto {
pub success: bool,
/// SMTP status code (e.g. 250). Only set on success.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<u16>,
/// First line of the SMTP server's reply. Only set on success.
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// Human-readable error message. Only set on failure.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}