use std::sync::Arc;
use axum::extract::{Form, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use serde::Deserialize;
use crate::application::service::unsubscribe_service::{TokenVerdict, UnsubscribeOutcome};
use crate::DigestModule;
pub type ApiState = Arc<DigestModule>;
#[derive(Debug, Deserialize, Default)]
pub struct UnsubscribeTokenParam {
pub t: Option<String>,
pub confirm: Option<String>,
}
pub fn public_composer() -> Router<ApiState> {
use axum::middleware as axum_mw;
Router::<ApiState>::new()
.route("/digest/unsubscribe", post(unsubscribe_post).get(unsubscribe_confirm_page))
.route_layer(axum_mw::from_fn_with_state(
backbone_rate_limit::middleware(120, 60),
backbone_rate_limit::rate_limit_middleware,
))
}
async fn unsubscribe_post(
State(module): State<ApiState>,
Query(params): Query<UnsubscribeTokenParam>,
Form(form): Form<UnsubscribeTokenParam>,
) -> Response {
let from_confirm_page = form.confirm.is_some();
let Some(unsub) = module.unsubscribe_service() else {
tracing::error!("unsubscribe POST with no token secret configured; no action taken");
return if from_confirm_page { unavailable_page().into_response() } else { StatusCode::OK.into_response() };
};
let token = form.t.or(params.t).unwrap_or_default();
let outcome = if token.is_empty() {
Ok(UnsubscribeOutcome::Refused)
} else {
unsub.unsubscribe_by_token(&token, chrono::Utc::now().timestamp()).await
};
if !from_confirm_page {
if let Err(e) = &outcome {
tracing::error!(error = %e, "unsubscribe apply failed; still the bare 200");
}
return StatusCode::OK.into_response();
}
match outcome {
Ok(UnsubscribeOutcome::Unsubscribed) | Ok(UnsubscribeOutcome::AlreadyUnsubscribed) => page(
"Unsubscribed",
"You will no longer receive this digest.",
)
.into_response(),
Ok(UnsubscribeOutcome::Expired) => expired_page().into_response(),
Ok(UnsubscribeOutcome::Refused) => refused_page().into_response(),
Err(e) => {
tracing::error!(error = %e, "unsubscribe apply failed on the confirm page");
page("Something went wrong", "Your unsubscribe was not recorded. Please try the link again later.")
.into_response()
}
}
}
async fn unsubscribe_confirm_page(
State(module): State<ApiState>,
Query(params): Query<UnsubscribeTokenParam>,
) -> Response {
let Some(unsub) = module.unsubscribe_service() else {
return unavailable_page().into_response();
};
let Some(token) = params.t.filter(|t| !t.is_empty()) else {
return refused_page().into_response();
};
match unsub.check_token(&token, chrono::Utc::now().timestamp()) {
TokenVerdict::Valid => Html(format!(
"<html><body><h2>Unsubscribe from this digest?</h2>\
<p>You will no longer receive this digest by mail.</p>\
<form method=\"post\" action=\"/digest/unsubscribe\">\
<input type=\"hidden\" name=\"t\" value=\"{token}\">\
<input type=\"hidden\" name=\"confirm\" value=\"1\">\
<button type=\"submit\">Unsubscribe</button></form></body></html>"
))
.into_response(),
TokenVerdict::Expired => expired_page().into_response(),
TokenVerdict::Refused => refused_page().into_response(),
}
}
fn page(title: &str, body: &str) -> Html<String> {
Html(format!("<html><body><h2>{title}</h2><p>{body}</p></body></html>"))
}
fn unavailable_page() -> Html<String> {
page("Unsubscribe unavailable", "This digest deployment has no unsubscribe signing key configured.")
}
fn expired_page() -> Html<String> {
page(
"This link has expired",
"Unsubscribe links live 180 days. Use the link in your most recent digest mail.",
)
}
fn refused_page() -> Html<String> {
page("This link is not valid", "This unsubscribe link was not issued by this digest, or it is incomplete.")
}