test(login/register): via password or magic-link
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
This commit is contained in:
@@ -127,21 +127,37 @@ pub async fn register(
|
||||
}
|
||||
};
|
||||
|
||||
// Block password registration when OIDC-only mode is active.
|
||||
// Email-only signup still works in OIDC-only mode (no password
|
||||
// stored; the user authenticates via magic-link).
|
||||
// Block password registration when the policy forbids password
|
||||
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
|
||||
// without `password`). Email-only signup still works — the user
|
||||
// authenticates via magic-link or SSO on their first visit.
|
||||
if dto.password.is_some()
|
||||
&& auth_service
|
||||
&& !auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
.is_password_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password registration is disabled. Please use SSO/OIDC to sign in.",
|
||||
"Password registration is disabled by policy.",
|
||||
"PasswordRegistrationDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Symmetric guard: when magic-link is off, an email-only signup has
|
||||
// no path to a session (there's no token to click). Refuse rather
|
||||
// than silently succeed and leave the user with an unusable account.
|
||||
if dto.password.is_none()
|
||||
&& !auth_service
|
||||
.auth_application_service
|
||||
.is_magic_link_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Email-only registration requires magic-link login, which is disabled.",
|
||||
"MagicLinkLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Admin disabled public registration globally — surface 403.
|
||||
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
||||
&& !admin_svc.get_registration_enabled().await
|
||||
@@ -351,13 +367,19 @@ pub async fn login(
|
||||
));
|
||||
}
|
||||
|
||||
// Check if password login is disabled (OIDC-only mode)
|
||||
if auth_service
|
||||
// Check if password login is allowed (composes the legacy OIDC-only
|
||||
// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist). When
|
||||
// disabled, return `PasswordLoginDisabled` so the SPA can hide the
|
||||
// password field and surface the available fallback (magic-link or
|
||||
// SSO) instead of showing a generic "invalid credentials".
|
||||
if !auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
.is_password_login_allowed()
|
||||
{
|
||||
return Err(AppError::unauthorized(
|
||||
"Password login is disabled. Please use SSO/OIDC to sign in.",
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password login is disabled by policy.",
|
||||
"PasswordLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -425,6 +447,55 @@ pub async fn login(
|
||||
.login_lockout
|
||||
.record_failure(&dto.username, &client_ip);
|
||||
tracing::error!("Login failed for user {}: {}", dto.username, err);
|
||||
// Remap the `require_verified_email` refusal (message
|
||||
// string comes from AuthApplicationService::login) into a
|
||||
// distinguished error_type and, critically, PIGGYBACK a
|
||||
// verification link on the successful-password proof: the
|
||||
// caller just showed they know the password, so we can
|
||||
// safely mint a verification magic-link for their address
|
||||
// without going through the anti-enum-fronted
|
||||
// `magic-link/send` (which would refuse `has_password`).
|
||||
//
|
||||
// This branch is reached ONLY when the password validated
|
||||
// successfully — the service checks `require_verified_email`
|
||||
// AFTER the password check specifically so an attacker
|
||||
// without the password can't discover an account's
|
||||
// verification state from the response shape.
|
||||
if err.message == "Email not verified" {
|
||||
// Best-effort auto-send. We swallow any error and still
|
||||
// return the same EmailNotVerified response — the
|
||||
// frontend hint ("check your inbox") doubles as the
|
||||
// resend affordance if delivery didn't land.
|
||||
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
|
||||
// Re-look up the user by identifier (mirrors the
|
||||
// service's login dispatch) to get the User entity
|
||||
// that the verification helper needs. On any
|
||||
// lookup failure we skip the send — attacker never
|
||||
// sees the difference.
|
||||
let lookup = if dto.username.contains('@') {
|
||||
auth_service
|
||||
.auth_application_service
|
||||
.find_user_by_email(&dto.username)
|
||||
.await
|
||||
} else {
|
||||
auth_service
|
||||
.auth_application_service
|
||||
.find_user_by_username(&dto.username)
|
||||
.await
|
||||
};
|
||||
if let Ok(user) = lookup {
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let _ = invite_svc
|
||||
.send_verification_link_authenticated(&user, &challenge)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Your email is not verified. We sent a verification link to your inbox.",
|
||||
"EmailNotVerified",
|
||||
));
|
||||
}
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
@@ -870,12 +941,22 @@ pub async fn oidc_providers(
|
||||
|
||||
let auth_app = &auth_service.auth_application_service;
|
||||
|
||||
// Policy questions the SPA needs to decide which forms to render.
|
||||
// `is_magic_link_login_allowed()` composes SMTP wiring + allowlist +
|
||||
// the "OIDC master → no magic-link login" hard rule; the login page
|
||||
// shows the magic-link tab iff this is true.
|
||||
let password_login_enabled = auth_app.is_password_login_allowed();
|
||||
let magic_link_login_enabled = auth_app.is_magic_link_login_allowed();
|
||||
let require_verified_email = auth_app.require_verified_email();
|
||||
|
||||
if !auth_app.oidc_enabled() {
|
||||
return Ok(Json(OidcProviderInfoDto {
|
||||
enabled: false,
|
||||
provider_name: String::new(),
|
||||
authorize_endpoint: String::new(),
|
||||
password_login_enabled: true,
|
||||
password_login_enabled,
|
||||
magic_link_login_enabled,
|
||||
require_verified_email,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -885,7 +966,9 @@ pub async fn oidc_providers(
|
||||
enabled: true,
|
||||
provider_name: config.provider_name.clone(),
|
||||
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
|
||||
password_login_enabled: !config.disable_password_login,
|
||||
password_login_enabled,
|
||||
magic_link_login_enabled,
|
||||
require_verified_email,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1127,6 +1210,21 @@ pub async fn send_magic_link(
|
||||
));
|
||||
};
|
||||
|
||||
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
|
||||
// when SMTP is wired (an operator might want the invite path — used
|
||||
// by admins to seed accounts — without offering it as a login
|
||||
// fallback). Refuse with the same anti-enum shape as any other
|
||||
// policy-gated endpoint.
|
||||
if let Some(auth) = state.auth_service.as_ref()
|
||||
&& !auth.auth_application_service.is_magic_link_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Magic-link login is disabled by policy.",
|
||||
"MagicLinkLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Authentication signal — presence (not validity) of Bearer header
|
||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||
// stale-cookie holder gets a 401 from any other endpoint they
|
||||
@@ -1164,6 +1262,26 @@ pub async fn send_magic_link(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Login-identifier resolution. The DTO field is named `email` for
|
||||
// backwards-compat, but the value may be either an email address or
|
||||
// a username — dispatch matches the `POST /api/auth/login`
|
||||
// convention (`@` present → email, else → username). Username
|
||||
// lookups happen BEFORE rate-limiting so `alice` and
|
||||
// `alice@example.com` bucket on the same key; without this,
|
||||
// alternating shapes would double the effective per-email budget.
|
||||
//
|
||||
// Anti-enum: username misses fall through to `body.email` unchanged
|
||||
// and land in the malformed_email / no_account branches downstream,
|
||||
// both of which return the uniform 200 with an audit line.
|
||||
let resolved_email = if let Some(auth) = state.auth_service.as_ref() {
|
||||
auth.auth_application_service
|
||||
.resolve_login_identifier_to_email(&body.email)
|
||||
.await
|
||||
.unwrap_or_else(|| body.email.clone())
|
||||
} else {
|
||||
body.email.clone()
|
||||
};
|
||||
|
||||
// Per-request browser-binding challenge (PR 22). Generated for
|
||||
// every request and set as a cookie on every 200 response —
|
||||
// including the silent-rate-limit paths — so the cookie's
|
||||
@@ -1211,8 +1329,11 @@ pub async fn send_magic_link(
|
||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||
// addresses skip this check and fall through to the service,
|
||||
// which records its own audit entry under reason="malformed_email".
|
||||
// Buckets on the RESOLVED email (post-username lookup) so
|
||||
// username and email inputs for the same account share one
|
||||
// budget — see resolve_login_identifier_to_email() above.
|
||||
if let Ok(normalised) =
|
||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
||||
crate::domain::services::email_normalize::normalize_email(&resolved_email)
|
||||
&& state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&normalised)
|
||||
@@ -1232,8 +1353,12 @@ pub async fn send_magic_link(
|
||||
// The service swallows every operational outcome and logs the truth
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
// We pass the resolved email — if the caller sent a username, the
|
||||
// service sees the corresponding address; if the caller sent a
|
||||
// bare unknown identifier, the service still audits it as
|
||||
// malformed_email / no_account.
|
||||
invite_svc
|
||||
.send_login_link(&body.email, &challenge)
|
||||
.send_login_link(&resolved_email, &challenge)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -574,24 +574,32 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
|
||||
response
|
||||
}
|
||||
|
||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||
/// Build the SPA route the redemption should land on.
|
||||
///
|
||||
/// - **Resource token** (folder invitation): deep-link to the resource.
|
||||
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
|
||||
/// - **Resource token** (folder invitation): deep-link into the folder
|
||||
/// view. SvelteKit `files/[...path]` accepts folder IDs as path
|
||||
/// segments (see `frontend/src/routes/files/[...path]/+page.svelte`
|
||||
/// — `goto(resolve(`/files/${folder.id}`))`).
|
||||
/// - **NULL-resource token + external user**: land on `/shared-with-me`
|
||||
/// (their entry point — they own no folders themselves).
|
||||
/// - **NULL-resource token + internal user**: land on `/#/files` (the
|
||||
/// - **NULL-resource token + internal user**: land on `/files` (the
|
||||
/// user has a home folder; the "shared with me" view would be empty
|
||||
/// on first signup, so home is the better welcome). Internal users
|
||||
/// on NULL-resource tokens come from the email-only-signup welcome
|
||||
/// path (PR 18) or from a magic-link they requested themselves
|
||||
/// while password-eligible-and-lenient-mode (PR 19).
|
||||
///
|
||||
/// Historical: pre-SvelteKit these were hash routes
|
||||
/// (`/#/files`, `/#/sharedwithme`, `/#/files/folder/{id}`) served by the
|
||||
/// legacy vanilla frontend. Landing on those now serves the legacy
|
||||
/// shell (with old meta-CSP + inline scripts) instead of the SPA and
|
||||
/// triggers a CSP violation on modern deployments.
|
||||
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
|
||||
match (redemption.resource_kind, redemption.resource_id) {
|
||||
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
|
||||
format!("/#/files/folder/{}", folder_id)
|
||||
format!("/files/{}", folder_id)
|
||||
}
|
||||
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
|
||||
_ => "/#/files".to_string(),
|
||||
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
|
||||
_ => "/files".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user