diff --git a/crates/modelrelay-cloud/src/routes/auth.rs b/crates/modelrelay-cloud/src/routes/auth.rs index a63377b..9057f90 100644 --- a/crates/modelrelay-cloud/src/routes/auth.rs +++ b/crates/modelrelay-cloud/src/routes/auth.rs @@ -27,7 +27,10 @@ fn client_ip(headers: &HeaderMap) -> IpAddr { } /// Render a 429 Too Many Requests page. -fn rate_limit_response() -> Response { +/// +/// `path` is the canonical URL path of the request that triggered the rate limit +/// (e.g. `/signup` or `/login`), used for per-page og:url and canonical. +fn rate_limit_response(path: &str) -> Response { let body = "\
\

\ @@ -38,7 +41,7 @@ fn rate_limit_response() -> Response {

This limit protects your account from unauthorized access attempts.

\ Back to Home\

"; - let html = modelrelay_web::templates::page_shell("Too Many Requests", body, false); + let html = modelrelay_web::templates::page_shell("Too Many Requests", path, body, false); (StatusCode::TOO_MANY_REQUESTS, Html(html)).into_response() } @@ -67,6 +70,7 @@ pub async fn signup_page(session: Session) -> Response { let csrf_field = csrf::hidden_field(&session).await; Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html(None, &csrf_field), false, )) @@ -83,7 +87,7 @@ pub async fn signup_submit( ) -> Response { let ip = client_ip(&headers); if state.rate_limiter.is_limited(ip) { - return rate_limit_response(); + return rate_limit_response("/signup"); } let csrf_field = csrf::hidden_field(&session).await; @@ -91,6 +95,7 @@ pub async fn signup_submit( let Some(ref pool) = state.db else { return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", "

Error

Database not available.

", false, )) @@ -104,6 +109,7 @@ pub async fn signup_submit( if email.is_empty() || !email.contains('@') { return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html(Some("Please enter a valid email address."), &csrf_field), false, )) @@ -112,6 +118,7 @@ pub async fn signup_submit( if password.len() < 8 { return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html(Some("Password must be at least 8 characters."), &csrf_field), false, )) @@ -130,6 +137,7 @@ pub async fn signup_submit( state.rate_limiter.record_attempt(ip); return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html( Some("An account with this email already exists. Log in instead."), &csrf_field, @@ -146,6 +154,7 @@ pub async fn signup_submit( tracing::error!("password hash error: {e}"); return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html(Some("Internal error. Please try again."), &csrf_field), false, )) @@ -171,6 +180,7 @@ pub async fn signup_submit( tracing::error!("user insert error: {e}"); return Html(modelrelay_web::templates::page_shell( "Sign Up", + "/signup", &signup_form_html( Some("Could not create account. Please try again."), &csrf_field, @@ -210,6 +220,7 @@ pub async fn login_page(session: Session) -> Response { let csrf_field = csrf::hidden_field(&session).await; Html(modelrelay_web::templates::page_shell( "Log In", + "/login", &login_form_html(None, &csrf_field), false, )) @@ -225,7 +236,7 @@ pub async fn login_submit( ) -> Response { let ip = client_ip(&headers); if state.rate_limiter.is_limited(ip) { - return rate_limit_response(); + return rate_limit_response("/login"); } let csrf_field = csrf::hidden_field(&session).await; @@ -233,6 +244,7 @@ pub async fn login_submit( let Some(ref pool) = state.db else { return Html(modelrelay_web::templates::page_shell( "Log In", + "/login", "

Error

Database not available.

", false, )) @@ -252,6 +264,7 @@ pub async fn login_submit( state.rate_limiter.record_attempt(ip); return Html(modelrelay_web::templates::page_shell( "Log In", + "/login", &login_form_html(Some("Invalid email or password."), &csrf_field), false, )) @@ -262,6 +275,7 @@ pub async fn login_submit( state.rate_limiter.record_attempt(ip); return Html(modelrelay_web::templates::page_shell( "Log In", + "/login", &login_form_html(Some("Invalid email or password."), &csrf_field), false, )) diff --git a/crates/modelrelay-cloud/src/routes/checkout.rs b/crates/modelrelay-cloud/src/routes/checkout.rs index b61e8ca..e9e7c10 100644 --- a/crates/modelrelay-cloud/src/routes/checkout.rs +++ b/crates/modelrelay-cloud/src/routes/checkout.rs @@ -11,11 +11,21 @@ static CANCEL_HTML: &str = include_str!("../../templates/checkout_cancel.html"); static SUCCESS_HTML: &str = include_str!("../../templates/checkout_success.html"); fn success_page() -> String { - modelrelay_web::templates::page_shell("Subscription Active", SUCCESS_HTML, true) + modelrelay_web::templates::page_shell( + "Subscription Active", + "/checkout/success", + SUCCESS_HTML, + true, + ) } fn cancel_page() -> String { - modelrelay_web::templates::page_shell("Checkout Cancelled", CANCEL_HTML, false) + modelrelay_web::templates::page_shell( + "Checkout Cancelled", + "/checkout/cancel", + CANCEL_HTML, + false, + ) } /// POST /checkout — create a Stripe Checkout Session and redirect to Stripe. @@ -26,6 +36,7 @@ pub async fn create(session: Session, State(state): State>) -> R let Some(ref key) = state.stripe_key else { return Html(modelrelay_web::templates::page_shell( "Billing Not Configured", + "/checkout", "
\

\ \ @@ -42,6 +53,7 @@ pub async fn create(session: Session, State(state): State>) -> R if price_id.is_empty() { return Html(modelrelay_web::templates::page_shell( "Billing Not Configured", + "/checkout", "
\

\ \ @@ -104,6 +116,7 @@ pub async fn create(session: Session, State(state): State>) -> R } else { Html(modelrelay_web::templates::page_shell( "Checkout Error", + "/checkout", "

Stripe did not return a checkout URL.

\

← Back to pricing

", false, @@ -115,6 +128,7 @@ pub async fn create(session: Session, State(state): State>) -> R tracing::error!("stripe response parse error: {e}"); Html(modelrelay_web::templates::page_shell( "Checkout Error", + "/checkout", "

Could not process Stripe response.

\

← Back to pricing

", false, @@ -128,6 +142,7 @@ pub async fn create(session: Session, State(state): State>) -> R tracing::error!("stripe API error: {status} — {body}"); Html(modelrelay_web::templates::page_shell( "Checkout Error", + "/checkout", "

Could not create checkout session. Please try again later.

\

← Back to pricing

", false, @@ -138,6 +153,7 @@ pub async fn create(session: Session, State(state): State>) -> R tracing::error!("stripe request error: {e}"); Html(modelrelay_web::templates::page_shell( "Checkout Error", + "/checkout", "

Could not reach payment provider. Please try again later.

\

← Back to pricing

", false, diff --git a/crates/modelrelay-cloud/src/routes/dashboard.rs b/crates/modelrelay-cloud/src/routes/dashboard.rs index cd57c1e..2962435 100644 --- a/crates/modelrelay-cloud/src/routes/dashboard.rs +++ b/crates/modelrelay-cloud/src/routes/dashboard.rs @@ -56,10 +56,12 @@ struct ApiKeyRow { // ─── GET /dashboard ───────────────────────────────────────────────────────── /// GET /dashboard — show subscription status and API key info. +#[allow(clippy::too_many_lines)] pub async fn page(session: Session, State(state): State>) -> Response { let Some(ref pool) = state.db else { return Html(modelrelay_web::templates::page_shell( "Dashboard", + "/dashboard", &no_db_html(), true, )) @@ -88,6 +90,7 @@ pub async fn page(session: Session, State(state): State>) -> Res tracing::error!("dashboard user query error: {e}"); return Html(modelrelay_web::templates::page_shell( "Dashboard", + "/dashboard", "

Error

Could not load your account. Please try again later.

", true, )) @@ -109,6 +112,7 @@ pub async fn page(session: Session, State(state): State>) -> Res let html = admin_dashboard_html(&user.email, &keys, &csrf_field); Html(modelrelay_web::templates::page_shell_custom( "Dashboard", + "/dashboard", &html, true, "", @@ -158,6 +162,7 @@ pub async fn page(session: Session, State(state): State>) -> Res ); Html(modelrelay_web::templates::page_shell_custom( "Dashboard", + "/dashboard", &html, true, "", @@ -172,11 +177,11 @@ pub async fn page(session: Session, State(state): State>) -> Res /// POST /dashboard/billing-portal — create a Stripe billing portal session and redirect. pub async fn billing_portal(session: Session, State(state): State>) -> Response { let Some(ref key) = state.stripe_key else { - return error_page("Billing not configured").into_response(); + return error_page("/dashboard/billing-portal", "Billing not configured").into_response(); }; let Some(ref pool) = state.db else { - return error_page("Database not available").into_response(); + return error_page("/dashboard/billing-portal", "Database not available").into_response(); }; let user_id = match require_user(&session).await { @@ -196,6 +201,7 @@ pub async fn billing_portal(session: Session, State(state): State

No billing account

\

No Stripe customer found for your account.

\

← Back to dashboard

", @@ -223,23 +229,39 @@ pub async fn billing_portal(session: Session, State(state): State { tracing::error!("stripe portal response parse error: {e}"); - error_page("Could not process billing portal response.").into_response() + error_page( + "/dashboard/billing-portal", + "Could not process billing portal response.", + ) + .into_response() } }, Ok(r) => { let status = r.status(); let body = r.text().await.unwrap_or_default(); tracing::error!("stripe billing portal API error: {status} — {body}"); - error_page("Could not open billing portal. Please try again later.").into_response() + error_page( + "/dashboard/billing-portal", + "Could not open billing portal. Please try again later.", + ) + .into_response() } Err(e) => { tracing::error!("stripe billing portal request error: {e}"); - error_page("Could not reach payment provider. Please try again later.").into_response() + error_page( + "/dashboard/billing-portal", + "Could not reach payment provider. Please try again later.", + ) + .into_response() } } } @@ -249,7 +271,7 @@ pub async fn billing_portal(session: Session, State(state): State>) -> Response { let Some(ref pool) = state.db else { - return error_page("Database not available").into_response(); + return error_page("/dashboard/keys/generate", "Database not available").into_response(); }; let user_id = match require_user(&session).await { @@ -270,12 +292,18 @@ pub async fn keys_generate(session: Session, State(state): State } let Some(ref admin_url) = state.admin_url else { - return error_page("Admin API not configured. Cannot generate API keys at this time.") - .into_response(); + return error_page( + "/dashboard/keys/generate", + "Admin API not configured. Cannot generate API keys at this time.", + ) + .into_response(); }; let Some(ref admin_token) = state.admin_token else { - return error_page("Admin API not configured. Cannot generate API keys at this time.") - .into_response(); + return error_page( + "/dashboard/keys/generate", + "Admin API not configured. Cannot generate API keys at this time.", + ) + .into_response(); }; // Get user email for key name @@ -287,7 +315,11 @@ pub async fn keys_generate(session: Session, State(state): State Ok(e) => e, Err(e) => { tracing::error!("keys_generate user lookup error: {e}"); - return error_page("Could not look up your account.").into_response(); + return error_page( + "/dashboard/keys/generate", + "Could not look up your account.", + ) + .into_response(); } }; @@ -308,6 +340,7 @@ pub async fn keys_generate(session: Session, State(state): State { tracing::error!("keys_generate db insert error: {e}"); return error_page( + "/dashboard/keys/generate", "Key was provisioned on the server but could not be saved. Contact support.", ) .into_response(); @@ -318,6 +351,7 @@ pub async fn keys_generate(session: Session, State(state): State Err(e) => { tracing::error!("keys_generate provision error: {e}"); error_page( + "/dashboard/keys/generate", "Could not generate API key. The relay server may be unreachable. Please try again later.", ) .into_response() @@ -333,8 +367,9 @@ pub async fn keys_revoke( State(state): State>, Path(key_uuid): Path, ) -> Response { + let path = format!("/dashboard/keys/{key_uuid}/revoke"); let Some(ref pool) = state.db else { - return error_page("Database not available").into_response(); + return error_page(&path, "Database not available").into_response(); }; let user_id = match require_user(&session).await { @@ -366,7 +401,7 @@ pub async fn keys_revoke( .flatten(); let Some((key_id,)) = key_row else { - return error_page("API key not found or already revoked.").into_response(); + return error_page(&path, "API key not found or already revoked.").into_response(); }; // Attempt to revoke on the server @@ -386,7 +421,7 @@ pub async fn keys_revoke( .await { tracing::error!("keys_revoke db update error: {e}"); - return error_page("Could not revoke key. Please try again.").into_response(); + return error_page(&path, "Could not revoke key. Please try again.").into_response(); } tracing::info!(key_id = %key_id, "admin revoked API key"); @@ -570,9 +605,10 @@ pub async fn integrate(session: Session, State(state): State>) - // ─── HTML rendering ───────────────────────────────────────────────────────── -fn error_page(message: &str) -> Html { +fn error_page(path: &str, message: &str) -> Html { Html(modelrelay_web::templates::page_shell( "Error", + path, &format!( "
\

\ diff --git a/crates/modelrelay-cloud/src/routes/mod.rs b/crates/modelrelay-cloud/src/routes/mod.rs index ae2d3e9..a092f97 100644 --- a/crates/modelrelay-cloud/src/routes/mod.rs +++ b/crates/modelrelay-cloud/src/routes/mod.rs @@ -76,7 +76,8 @@ async fn download_redirect() -> Redirect { Redirect::temporary("/#download") } -async fn not_found() -> impl IntoResponse { +async fn not_found(request: Request) -> impl IntoResponse { + let path = request.uri().path().to_owned(); let body = r#"

404

Page Not Found

@@ -87,6 +88,7 @@ async fn not_found() -> impl IntoResponse { StatusCode::NOT_FOUND, Html(modelrelay_web::templates::page_shell( "404 — Not Found", + &path, body, false, )), @@ -172,6 +174,7 @@ async fn session_guard(request: Request, next: Next) -> Response { StatusCode::SERVICE_UNAVAILABLE, Html(modelrelay_web::templates::page_shell( "Service Unavailable", + &path, body, false, )), diff --git a/crates/modelrelay-cloud/templates/index.html b/crates/modelrelay-cloud/templates/index.html index 231a0c5..3a05234 100644 --- a/crates/modelrelay-cloud/templates/index.html +++ b/crates/modelrelay-cloud/templates/index.html @@ -6,12 +6,12 @@ ModelRelay — Managed LLM Relay for Your AI Workers - + - + diff --git a/crates/modelrelay-cloud/templates/pricing.html b/crates/modelrelay-cloud/templates/pricing.html index d0dab84..311d42e 100644 --- a/crates/modelrelay-cloud/templates/pricing.html +++ b/crates/modelrelay-cloud/templates/pricing.html @@ -5,6 +5,8 @@ Pricing — ModelRelay + +