feat(magic-link): user can resend email if magic link is expired
This commit is contained in:
@@ -105,6 +105,38 @@ Salient properties:
|
||||
- **Token in path, not query** — `GET /magic/v1/{token}` so the secret stays out of `Referer` headers.
|
||||
- **302 immediately on success** — the URL is replaced in the address bar before the user can navigate away or screenshot it.
|
||||
- **Optional resource target** — `resource_type` + `resource_id` columns, with a `CHECK ((resource_type IS NULL) = (resource_id IS NULL))` constraint to make the two-or-neither rule explicit.
|
||||
- **Rows persist past `used` / `expired`** — the sweeper transitions status, it does not immediately DELETE. This is what makes the self-service resend (next section) possible: the token in the URL keeps working as a recipient-discovery key well after the credential it carried has stopped working.
|
||||
|
||||
## Self-service resend
|
||||
|
||||
The 410-Gone landing page for a stale link is **not a dead end**. The page server-side branches on whether the token row is recoverable:
|
||||
|
||||
- Row exists, status is `expired` **or** `used`, owning user is still active → the page renders a one-click form: *"Send a fresh link to a…@example.com"* (POST to `/magic/v1/{token}/resend`).
|
||||
- Anything else (unknown token, pending, deactivated account, plumbing missing) → the page falls back to the existing generic "no longer valid" message. The two responses are deliberately indistinguishable to the caller — the rich page only differs when the row already proves the caller has legitimate context.
|
||||
|
||||
### Why no PII in the URL
|
||||
|
||||
An earlier sketch carried the recipient's email as `?r={base64(email)}` so the page could greet the user by address. We dropped it: a token is short-lived but a URL persists in browser history forever (and syncs to Chrome / Firefox cloud profiles), the address would leak via any future external Referer, and reverse-proxy access logs would gain a PII field they don't have today. Since the row already carries `user_id → users.email`, the server can recover the address on demand and the URL stays clean.
|
||||
|
||||
### Why both `expired` and `used` qualify
|
||||
|
||||
`used` covers the "I clicked the link on my phone, now I want to sign in on my laptop" case — the original link is dead by single-use design, but the recipient is real and the row still holds the recipient pointer. Offering resend on `used` is harmless (the new mail goes to the registered email, not the caller; rate limits are the same) and avoids a confusing dead-end for the most common second-device path.
|
||||
|
||||
### Endpoint shape
|
||||
|
||||
`POST /magic/v1/{token}/resend` mirrors `POST /api/auth/magic-link/send` in every operational respect:
|
||||
|
||||
1. **Per-source-IP rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR`, default 200/h) — runs first, unconditionally. Burns budget even when the token doesn't resolve so the endpoint can't be used to spread probes thin across many tokens.
|
||||
2. **Token resolution** — `MagicLinkInviteService::lookup_resend_recipient(token)`. Returns `Some(ResendRecipientHint)` only for `expired` / `used` rows whose owning user is active. `None` in every other case (pending, unknown, deactivated, repo absent).
|
||||
3. **Per-target-email rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR`, default 5/h) — keyed on the recipient we just resolved. Caps actual mail volume to the recipient regardless of how many IPs hammer the endpoint; effectively a per-token-recipient ceiling without a schema-level counter.
|
||||
4. **Fresh challenge + send** — generate a per-request challenge (cookie + token-row mirror, see PR 22), dispatch through `send_login_link`. The new token has the standard short login TTL, not the longer invite TTL — the recipient just clicked, so a slow second click is almost certainly someone else with mailbox access.
|
||||
5. **Uniform response** — every outcome (rate-limited, no-account, account deactivated, SMTP-failed, succeeded) renders the same "Check your inbox" HTML page. The real outcome is in the audit channel via `auth.magic_link_send` events.
|
||||
|
||||
The handler is a sibling under the same `/magic/v1/*` router, **no CSRF middleware** (the page that triggers it is the 410 response itself — same-origin, plain HTML form, no JS, no third-party referrer realistically able to forge the POST against a per-token URL).
|
||||
|
||||
### What the per-token ceiling looks like in practice
|
||||
|
||||
A single recovered URL × per-target-email cap (5/h) × 24h = **120 magic-link mails per day maximum** to that recipient from that specific URL. Annoying but bounded; the recipient's inbox cannot be flooded into uselessness from one stable handle. A per-token counter column (`resend_count` with a hard maximum, e.g. 3) would tighten the bound further; it's deferred until real abuse is observed.
|
||||
|
||||
## User-profile visibility rule (`GET /api/users/{id}`)
|
||||
|
||||
@@ -127,7 +159,7 @@ Every denial or rejection in the magic-link path emits a structured event on the
|
||||
|------------------------------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `authz.denied` | permission missing | `AuthorizationEngine::require` |
|
||||
| `auth.login` | `user_not_found`, `bad_password`, `account_deactivated` | `AuthApplicationService::login` |
|
||||
| `auth.magic_link_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email` | `MagicLinkInviteService::send_login_link` and the handler |
|
||||
| `auth.magic_link_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email`, `internal_error` | `MagicLinkInviteService::send_login_link`, `auth_handler::send_magic_link`, `magic_link_handler::resend_magic_link` |
|
||||
| `auth.magic_link_redeem` | `redeemed`, `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `MagicLinkInviteService::redeem` |
|
||||
| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `AuthApplicationService::get_user_profile` |
|
||||
| `grants.email_invite` | `rate_limited` | `grant_handler::create_grant` |
|
||||
@@ -148,6 +180,8 @@ Three caps protect the magic-link surface. Each is a moka sliding-window counter
|
||||
| Per-target-email send | normalised email | 5 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` | no (uniform 200) |
|
||||
| Per-source-IP send (backstop) | trusted client IP | 200 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` | no (uniform 200) |
|
||||
|
||||
The two send caps are shared between `POST /api/auth/magic-link/send` and `POST /magic/v1/{token}/resend` — they're the same moka counters, so an attacker can't double their budget by alternating endpoints.
|
||||
|
||||
Two distinct visibility regimes:
|
||||
|
||||
- **Authenticated callers** see 429 when they hit a cap, because their own rate-limit state leaks nothing about other accounts. The invite cap is in this regime.
|
||||
|
||||
@@ -47,8 +47,11 @@ When you enter someone's email address, OxiCloud sends them a message
|
||||
with a sign-in link. Clicking that link signs them in and opens the
|
||||
file or folder you shared — no password to create, no form to fill in.
|
||||
|
||||
The link expires after a day. If they miss it, you can resend it from
|
||||
the **My shares** section.
|
||||
The link expires after a day, and works only once. If the recipient
|
||||
clicks an old or already-used link, the page they land on offers a
|
||||
**"Send a fresh link"** button — one click and a new sign-in link is
|
||||
on its way to their inbox. They can also ask you to resend from your
|
||||
**My shares** section.
|
||||
|
||||
## Keeping track — *My shares*
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::MagicLinkConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken};
|
||||
use crate::domain::entities::magic_link_token::{
|
||||
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||
};
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
||||
@@ -495,6 +497,80 @@ impl MagicLinkInviteService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the resend-recipient hint for a token whose redemption
|
||||
/// just failed. Returns `Some` exactly when:
|
||||
///
|
||||
/// 1. the token row exists,
|
||||
/// 2. its status is `Expired` (TTL elapsed) or `Used` (already
|
||||
/// redeemed once — recipient may be re-clicking on a different
|
||||
/// device), and
|
||||
/// 3. the owning user account is still active.
|
||||
///
|
||||
/// Returns `None` (no resend offered) for `Pending` tokens, unknown
|
||||
/// tokens, and deactivated accounts. The `None` branches deliberately
|
||||
/// look identical to the caller — anyone who can present a valid
|
||||
/// token already has its access semantics, so the only "oracle"
|
||||
/// surface is "did this token exist in some non-pending state",
|
||||
/// which is moot.
|
||||
pub async fn lookup_resend_recipient(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<Option<ResendRecipientHint>, DomainError> {
|
||||
let Some(mlt) = self.magic_link_repo.find_by_token(token).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Pending tokens are still redeemable — no reason to offer a
|
||||
// resend. The user should just click the original link again.
|
||||
if !matches!(
|
||||
mlt.status(),
|
||||
MagicLinkStatus::Expired | MagicLinkStatus::Used
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let user = match UserRepository::get_user_by_id(&*self.user_storage, mlt.user_id()).await {
|
||||
Ok(u) => u,
|
||||
Err(UserRepositoryError::NotFound(_)) => return Ok(None),
|
||||
Err(e) => return Err(DomainError::from(e)),
|
||||
};
|
||||
if !user.is_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
let email = user.email().to_string();
|
||||
let masked_email = mask_email(&email);
|
||||
Ok(Some(ResendRecipientHint {
|
||||
email,
|
||||
masked_email,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hint surfaced by the 410-Gone page to offer a one-click "send me a
|
||||
/// fresh link" affordance to a recipient whose magic-link is no longer
|
||||
/// usable. Carries the recipient's email twice: the raw form (used by
|
||||
/// the resend handler to dispatch the new mail) and a masked form
|
||||
/// (rendered into the HTML page so the user can confirm the destination
|
||||
/// without the full address being plastered in the URL or address bar).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResendRecipientHint {
|
||||
pub email: String,
|
||||
pub masked_email: String,
|
||||
}
|
||||
|
||||
/// Mask an email for display: keep the first character of the local
|
||||
/// part, then `…`, then the full domain. `alice@example.com` →
|
||||
/// `a…@example.com`. Short local parts (1 char) collapse to just
|
||||
/// `…@domain`. Malformed input (no `@`) is masked entirely as `…`.
|
||||
pub fn mask_email(email: &str) -> String {
|
||||
match email.rsplit_once('@') {
|
||||
Some((local, domain)) if !local.is_empty() => {
|
||||
let mut chars = local.chars();
|
||||
let first = chars.next().unwrap_or('?');
|
||||
format!("{first}…@{domain}")
|
||||
}
|
||||
Some((_, domain)) => format!("…@{domain}"),
|
||||
None => "…".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight conversion so the grant handler can derive a
|
||||
@@ -545,6 +621,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_email_keeps_one_char_of_local() {
|
||||
assert_eq!(mask_email("alice@example.com"), "a…@example.com");
|
||||
assert_eq!(mask_email("very-long-name@example.com"), "v…@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_email_handles_edge_cases() {
|
||||
// Single-char local part still leaks just the first char, by
|
||||
// design — same masking rule applies uniformly so the output
|
||||
// shape itself doesn't disclose local-part length.
|
||||
assert_eq!(mask_email("a@b.co"), "a…@b.co");
|
||||
// Malformed (no `@`) is masked entirely.
|
||||
assert_eq!(mask_email("not-an-email"), "…");
|
||||
// Pathological (starts with `@`) collapses the empty local.
|
||||
assert_eq!(mask_email("@example.com"), "…@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_user_strict_then_lenient() {
|
||||
let u = user(Some("$argon2id$..."), None);
|
||||
|
||||
@@ -28,23 +28,33 @@ use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::application::services::auth_application_service::{
|
||||
MagicLinkRedeemResult, MagicLinkRedemption,
|
||||
};
|
||||
use crate::application::services::magic_link_invite_service::ResendRecipientHint;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::errors::ErrorKind;
|
||||
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::middleware::rate_limit::extract_client_ip;
|
||||
|
||||
/// Build the `/magic/v1/{token}` router. Mounted at the top of the
|
||||
/// application tree in `main.rs` — no auth middleware, no CSRF (the
|
||||
/// token is the credential, the route is GET-only).
|
||||
///
|
||||
/// `POST /magic/v1/{token}/resend` is a sibling endpoint that lets the
|
||||
/// 410-Gone page offer a one-click "send me a fresh link" button. The
|
||||
/// recipient email is looked up server-side from the (expired/used)
|
||||
/// token row — no PII in the URL — and the rate limits attached to
|
||||
/// `POST /api/auth/magic-link/send` apply identically.
|
||||
pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/magic/v1/{token}", get(redeem_magic_link))
|
||||
Router::new()
|
||||
.route("/magic/v1/{token}", get(redeem_magic_link))
|
||||
.route("/magic/v1/{token}/resend", post(resend_magic_link))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -124,11 +134,9 @@ async fn redeem_magic_link(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link sign-in is not enabled on this server.",
|
||||
),
|
||||
ErrorKind::NotFound | ErrorKind::AccessDenied => error_page(
|
||||
StatusCode::GONE,
|
||||
"This sign-in link is no longer valid. It may have already been \
|
||||
used or expired. Request a fresh link from the login page.",
|
||||
),
|
||||
ErrorKind::NotFound | ErrorKind::AccessDenied => {
|
||||
expired_or_used_page(&state, &token).await
|
||||
}
|
||||
_ => error_page(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong while signing you in. Please try again.",
|
||||
@@ -138,6 +146,201 @@ async fn redeem_magic_link(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /magic/v1/{token}/resend` — one-click handler behind the
|
||||
/// "Send a fresh link" button on the 410-Gone page.
|
||||
///
|
||||
/// The token in the URL serves as a *recipient-discovery key*, never as
|
||||
/// a credential: the server looks up the (expired or used) row, walks
|
||||
/// to the owning user, and dispatches a fresh login-via-email magic-
|
||||
/// link to that user's email. The endpoint never trusts client-supplied
|
||||
/// email and never echoes the resolved address back, so the resend
|
||||
/// URL is safe to leave in browser history.
|
||||
///
|
||||
/// Anti-abuse:
|
||||
/// - **Per-source-IP** (200/h, shared with `/api/auth/magic-link/send`)
|
||||
/// bounds burst from a single attacker.
|
||||
/// - **Per-target-email** (5/h, also shared) caps actual mail volume
|
||||
/// to the recipient regardless of how many IPs hammer the endpoint.
|
||||
/// - **Uniform response** on every outcome — rate-limited, no-account,
|
||||
/// SMTP-failed, succeeded — so the page shape is not an oracle.
|
||||
/// - **Audit log** carries the truth via the `auth.magic_link_send`
|
||||
/// events emitted by `MagicLinkInviteService::send_login_link`.
|
||||
async fn resend_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Response {
|
||||
let confirmation = || {
|
||||
resend_confirmation_page(
|
||||
"If the sign-in link belonged to an active account, a fresh \
|
||||
link has just been sent. Please check your inbox.",
|
||||
)
|
||||
};
|
||||
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return error_page(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link sign-in is not enabled on this server.",
|
||||
);
|
||||
};
|
||||
|
||||
let client_ip = extract_client_ip(&req);
|
||||
|
||||
// Per-IP backstop runs unconditionally — burns through the budget
|
||||
// even when the token doesn't resolve, so the endpoint can't be
|
||||
// used to spread probes thin across many tokens.
|
||||
if state
|
||||
.magic_link_send_per_ip_rate_limiter
|
||||
.check_and_increment(&client_ip)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_ip",
|
||||
ip = %client_ip,
|
||||
"Per-IP rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||
);
|
||||
return confirmation();
|
||||
}
|
||||
|
||||
let hint = match invite_svc.lookup_resend_recipient(&token).await {
|
||||
Ok(Some(h)) => h,
|
||||
_ => {
|
||||
// Unknown / pending / deactivated — uniform response so the
|
||||
// outcome is not an oracle for "is this a known token".
|
||||
return confirmation();
|
||||
}
|
||||
};
|
||||
|
||||
// Per-target-email cap — keyed on the recipient we just resolved.
|
||||
// Locks the mail volume to a single recipient regardless of how
|
||||
// many distinct IPs the attacker spreads across.
|
||||
if state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&hint.email)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_email",
|
||||
ip = %client_ip,
|
||||
"Per-target-email rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||
);
|
||||
return confirmation();
|
||||
}
|
||||
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
|
||||
// Service swallows operational outcomes and audits the truth; we
|
||||
// surface only DB / unexpected errors as 500.
|
||||
if let Err(e) = invite_svc.send_login_link(&hint.email, &challenge).await {
|
||||
tracing::error!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "internal_error",
|
||||
error = %e.message,
|
||||
"Resend dispatch failed for an unexpected reason"
|
||||
);
|
||||
return error_page(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong while sending the link. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
let mut response = confirmation();
|
||||
cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs);
|
||||
response
|
||||
}
|
||||
|
||||
/// Plain HTML confirmation rendered after the resend button is clicked.
|
||||
/// Same shape on every outcome — see [`resend_magic_link`] for why.
|
||||
fn resend_confirmation_page(message: &str) -> Response {
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:6em auto;\
|
||||
padding:0 1em;color:#333;line-height:1.5}}h1{{font-size:1.4em}}\
|
||||
.muted{{color:#666;font-size:.9em;margin-top:2em}}</style>\
|
||||
</head><body>\
|
||||
<h1>Check your inbox</h1>\
|
||||
<p>{}</p>\
|
||||
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
|
||||
</body></html>",
|
||||
html_escape(message)
|
||||
);
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// Render the 410-Gone landing for a token that's either expired,
|
||||
/// already used, or unknown. When the token belongs to an active user
|
||||
/// (i.e. the redemption failed because the row exists but is past its
|
||||
/// useful state), the page carries a one-click "send a fresh link"
|
||||
/// form pre-targeted at the recipient's masked email. When the token
|
||||
/// is unknown, the user is deactivated, or any plumbing is missing,
|
||||
/// the page falls back to the existing generic message — by design
|
||||
/// the two responses are indistinguishable to the caller.
|
||||
async fn expired_or_used_page(state: &Arc<AppState>, token: &str) -> Response {
|
||||
let generic = || {
|
||||
error_page(
|
||||
StatusCode::GONE,
|
||||
"This sign-in link is no longer valid. It may have already been \
|
||||
used or expired. Request a fresh link from the login page.",
|
||||
)
|
||||
};
|
||||
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return generic();
|
||||
};
|
||||
let hint = match invite_svc.lookup_resend_recipient(token).await {
|
||||
Ok(Some(h)) => h,
|
||||
_ => return generic(),
|
||||
};
|
||||
resend_offer_page(token, &hint)
|
||||
}
|
||||
|
||||
/// HTML body for the enriched 410 page: explains the outcome and
|
||||
/// offers a single-button form posting to `POST /magic/v1/{token}/resend`.
|
||||
/// Plain HTML — no JavaScript, no external assets — so it works the
|
||||
/// same in any mail-client embedded browser. The form action is the
|
||||
/// only place the token round-trips; the masked email is the only
|
||||
/// thing rendered, so screenshots / shoulder-surfing don't expose the
|
||||
/// full address.
|
||||
fn resend_offer_page(token: &str, hint: &ResendRecipientHint) -> Response {
|
||||
let action = format!("/magic/v1/{}/resend", html_escape(token));
|
||||
let masked = html_escape(&hint.masked_email);
|
||||
let body = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:6em auto;\
|
||||
padding:0 1em;color:#333;line-height:1.5}}h1{{font-size:1.4em}}\
|
||||
.btn{{display:inline-block;padding:.7em 1.4em;background:#2563eb;color:#fff;\
|
||||
border-radius:6px;text-decoration:none;font-weight:600;margin-top:.4em;\
|
||||
border:0;cursor:pointer;font-size:1em}}.btn:hover{{background:#1d4ed8}}\
|
||||
.muted{{color:#666;font-size:.9em;margin-top:2em}}</style>\
|
||||
</head><body>\
|
||||
<h1>This sign-in link is no longer valid</h1>\
|
||||
<p>The link may have expired or already been used. \
|
||||
We can send you a fresh one — it'll arrive in your inbox in a few seconds.</p>\
|
||||
<form method=\"post\" action=\"{action}\">\
|
||||
<button type=\"submit\" class=\"btn\">Send a fresh link to {masked}</button>\
|
||||
</form>\
|
||||
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
|
||||
</body></html>"
|
||||
);
|
||||
let mut response = (StatusCode::GONE, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
|
||||
let target = redirect_target(&redemption);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user