rustango 0.61.13

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! `AdminError`: request-handling failures with a uniform JSON body.
//!
//! Every view handler returns `Result<_, AdminError>`. The
//! `IntoResponse` impl maps each variant to an HTTP status and a small
//! JSON payload.

use crate::sql::sqlx;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;

use crate::api_errors::ApiError;

use super::forms::FormError;

/// Error returned by admin handlers, including bulk actions registered
/// with [`super::Builder::register_action`]. Custom actions should
/// almost always return [`AdminError::Internal`].
#[derive(Debug)]
pub enum AdminError {
    /// No such table in the model registry: the URL slug is wrong.
    TableNotFound {
        table: String,
    },
    /// The model is registered but its SQL table is not in the
    /// connected database, so migrations have probably not run for this
    /// tenant yet. Renders a page pointing at `migrate` and
    /// `migrate-tenants` instead of a raw driver error.
    TableMissing {
        table: String,
    },
    RowNotFound {
        table: String,
        pk: String,
    },
    ReadOnly {
        table: String,
    },
    /// A per-object permission hook, registered with
    /// `register_admin_object_permission!`, said no for this row.
    /// Renders as a 403.
    Forbidden {
        table: String,
        action: &'static str,
    },
    Form(FormError),
    Internal(String),
}

/// Postgres SQLSTATE for "undefined table". Used to spot the "model
/// registered but not yet migrated" case and show a hint.
const PG_UNDEFINED_TABLE: &str = "42P01";

/// MySQL `ER_NO_SUCH_TABLE`. An error *number*, not a `SQLSTATE` —
/// MySQL reports `42S02` for it, so this must be read off
/// `MySqlDatabaseError::number()` and never off `code()` (#1646).
#[cfg(feature = "mysql")]
const MYSQL_NO_SUCH_TABLE: u16 = 1146;

fn pg_undefined_table_error(e: &sqlx::Error) -> Option<String> {
    let db = e.as_database_error()?;
    if db.code().as_deref() != Some(PG_UNDEFINED_TABLE) {
        return None;
    }
    // `.table()` is not reliable here, but the message reads
    // `relation "<name>" does not exist`. Take the quoted identifier.
    let msg = db.message();
    let name = msg
        .split_once('"')
        .and_then(|(_, rest)| rest.split_once('"'))
        .map(|(name, _)| name.to_owned())
        .unwrap_or_else(|| "<unknown>".to_owned());
    Some(name)
}

/// SQLite has no structured code for a missing table, only the message
/// `no such table: <name>`. Match that shape so SQLite hosts get the
/// same `TableMissing` page as Postgres hosts, not a 500.
fn sqlite_undefined_table_error(e: &sqlx::Error) -> Option<String> {
    let db = e.as_database_error()?;
    let msg = db.message();
    let rest = msg.strip_prefix("no such table: ")?;
    // Trailing context may follow the name: take the first identifier.
    let name: String = rest
        .chars()
        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
        .collect();
    if name.is_empty() {
        None
    } else {
        Some(name)
    }
}

/// MySQL reports a missing table as code 1146 with a message like
/// `Table 'db.table' doesn't exist`. Match the code first, then take
/// the unqualified name from the message.
#[cfg(feature = "mysql")]
fn mysql_undefined_table_error(e: &sqlx::Error) -> Option<String> {
    let db = e.as_database_error()?;
    let my = db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>()?;
    if my.number() != MYSQL_NO_SUCH_TABLE {
        return None;
    }
    // Message shape: `Table 'demo.rustango_admin_users' doesn't exist`.
    let msg = db.message();
    let (_, rest) = msg.split_once('\'')?;
    let (qualified, _) = rest.split_once('\'')?;
    let name = qualified
        .rsplit_once('.')
        .map(|(_, t)| t)
        .unwrap_or(qualified);
    Some(name.to_owned())
}

/// `cfg(not(mysql))` stub — there is no MySQL driver to downcast to.
#[cfg(not(feature = "mysql"))]
fn mysql_undefined_table_error(_e: &sqlx::Error) -> Option<String> {
    None
}

fn undefined_table_error(e: &sqlx::Error) -> Option<String> {
    pg_undefined_table_error(e)
        .or_else(|| sqlite_undefined_table_error(e))
        .or_else(|| mysql_undefined_table_error(e))
}

impl From<sqlx::Error> for AdminError {
    fn from(e: sqlx::Error) -> Self {
        if let Some(table) = undefined_table_error(&e) {
            return Self::TableMissing { table };
        }
        Self::Internal(e.to_string())
    }
}

/// The table `e` says is missing, on any dialect.
pub(crate) fn missing_table(e: &crate::sql::ExecError) -> Option<String> {
    match e {
        crate::sql::ExecError::Driver(sqlx_err) => undefined_table_error(sqlx_err),
        _ => None,
    }
}

/// Log `raw` under a fresh correlation id and return the id, so a page
/// can show the id and never the raw text. A client-caused refusal logs at
/// warn, anything else at error.
pub(crate) fn log_with_id(context: &str, raw: &dyn std::fmt::Display, client: bool) -> String {
    let id = short_correlation_id();
    if client {
        tracing::warn!(target: "rustango::admin", correlation_id = %id, error = %raw, "{context}");
    } else {
        tracing::error!(target: "rustango::admin", correlation_id = %id, error = %raw, "{context}");
    }
    id
}

impl From<crate::sql::ExecError> for AdminError {
    fn from(e: crate::sql::ExecError) -> Self {
        // Unwrap the sqlx error so the per-dialect undefined-table
        // check still runs and hosts get `TableMissing`, not a 500.
        if let Some(table) = missing_table(&e) {
            return Self::TableMissing { table };
        }
        Self::Internal(e.to_string())
    }
}

impl IntoResponse for AdminError {
    fn into_response(self) -> Response {
        match self {
            Self::TableNotFound { table } => page_message(
                "No such table in the admin.",
                ApiError::not_found("table not found").with_details(json!({ "table": table })),
            ),
            Self::TableMissing { table } => {
                let body = format!(
                    r##"<!doctype html>
<html><head><meta charset="utf-8"><title>Table not migrated — rustango admin</title>
<style{nonce}>body{{font-family:system-ui,sans-serif;max-width:680px;margin:4em auto;padding:0 1em;color:#222}}
h1{{color:#b00;font-size:1.25em}} code{{background:#f3f3f3;padding:.1em .35em;border-radius:.2em}}
.hint{{background:#fff8dc;border-left:4px solid #d4a000;padding:.8em 1em;margin:1em 0}}</style>
</head><body>
<h1>Table <code>{table}</code> isn&rsquo;t in this database yet</h1>
<p>The model is registered but the underlying SQL table doesn&rsquo;t exist
in the connected schema. This usually means migrations haven&rsquo;t been
applied yet for this tenant / database.</p>
<div class="hint">
  <p><strong>No migration for it yet?</strong> Run <code>cargo run -- makemigrations</code>
  first, then the command below.</p>
  <p><strong>Single-tenant?</strong> Run <code>cargo run -- migrate</code>.</p>
  <p><strong>Multi-tenant?</strong> Run <code>cargo run -- migrate-tenants</code>
  to apply tenant-scoped migrations to every active org.</p>
</div>
<p>Once migrations are applied this page will list the model&rsquo;s rows.</p>
</body></html>
"##,
                    table = html_escape(&table),
                    nonce = crate::csp_nonce::nonce_attr(),
                );
                // Its migrate hint beats a generic error page.
                (
                    StatusCode::SERVICE_UNAVAILABLE,
                    crate::errors::KeepBody,
                    Html(body),
                )
                    .into_response()
            }
            Self::RowNotFound { table, pk } => page_message(
                "This record doesn't exist, or it was deleted.",
                ApiError::not_found("row not found")
                    .with_details(json!({ "table": table, "pk": pk })),
            ),
            Self::ReadOnly { table } => page_message(
                "This table is read-only in the admin.",
                ApiError::forbidden("table is read-only").with_details(json!({ "table": table })),
            ),
            Self::Forbidden { table, action } => page_message(
                "You don't have permission for this. Ask an admin for a role that includes it.",
                ApiError::forbidden("permission denied")
                    .with_details(json!({ "table": table, "action": action })),
            ),
            // Mostly an unparseable pk in the URL: a bad request.
            Self::Form(e) => {
                let text = e.to_string();
                page_message(&text, ApiError::bad_request(text.clone()))
            }
            Self::Internal(msg) => {
                // Log the raw message for the operator and return a
                // generic body. The raw text can hold table names,
                // column names and SQL, so it must not reach the
                // client. The response carries only an id the operator
                // can grep for in the logs.
                let id = log_with_id("admin internal error", &msg, false);
                ApiError::internal("internal server error")
                    .with_details(json!({ "correlation_id": id }))
                    .into_response()
            }
        }
    }
}

/// The JSON body, plus words for an error page to show a browser.
fn page_message(message: &str, body: ApiError) -> Response {
    (crate::errors::Message::new(message), body).into_response()
}

/// Short id put in both the log line and the client response, so an
/// operator can find the log entry from what the user reports without
/// any internal detail reaching that user. 8 bytes of `OsRng` as 16 hex
/// chars: short enough to read aloud.
fn short_correlation_id() -> String {
    use rand::{rngs::OsRng, RngCore};
    let mut bytes = [0u8; 8];
    OsRng.fill_bytes(&mut bytes);
    let mut out = String::with_capacity(16);
    for b in bytes {
        out.push_str(&format!("{b:02x}"));
    }
    out
}

use crate::text::html_escape;

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::to_bytes;

    /// #1703 — the table-missing page's inline style carries the CSP nonce.
    #[tokio::test]
    async fn table_missing_page_style_carries_the_nonce() {
        let res = crate::csp_nonce::scoped("N0nce", async {
            AdminError::TableMissing { table: "t".into() }.into_response()
        })
        .await;
        let body = to_bytes(res.into_body(), 1 << 20).await.unwrap();
        crate::testkit::assert_strict_csp_html(
            &String::from_utf8_lossy(&body),
            "N0nce",
            "table missing",
        );
    }

    /// `short_correlation_id` returns 16 hex chars, and 32 calls give
    /// 32 distinct ids.
    #[test]
    fn short_correlation_id_shape_and_uniqueness() {
        for _ in 0..16 {
            let id = short_correlation_id();
            assert_eq!(id.len(), 16, "expected 16 hex chars, got `{id}`");
            assert!(
                id.chars().all(|c| c.is_ascii_hexdigit()),
                "expected hex only, got `{id}`"
            );
        }
        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
        for _ in 0..32 {
            seen.insert(short_correlation_id());
        }
        assert_eq!(seen.len(), 32, "expected all distinct correlation ids");
    }

    /// The table-missing page escapes `'` too; this copy used to skip it (#1663).
    #[tokio::test]
    async fn table_missing_page_escapes_the_apostrophe() {
        let resp = AdminError::TableMissing {
            table: "x'<b>".into(),
        }
        .into_response();
        let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap();
        let html = String::from_utf8(body.to_vec()).unwrap();
        assert!(html.contains("x&#x27;&lt;b&gt;"), "{html}");
    }

    /// A model with no migration file yet needs `makemigrations`; `migrate` alone finds nothing.
    #[tokio::test]
    async fn table_missing_page_names_makemigrations() {
        let resp = AdminError::TableMissing {
            table: "item".into(),
        }
        .into_response();
        let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap();
        let html = String::from_utf8(body.to_vec()).unwrap();
        assert!(html.contains("cargo run -- makemigrations"), "{html}");
    }

    /// `AdminError::Internal` must **not** echo the raw error text in
    /// the JSON body: SQL, table names and file paths stay in the
    /// operator's log only.
    #[tokio::test]
    async fn internal_error_response_is_redacted() {
        let raw = "table \"sensitive_internal\" does not exist at SELECT * FROM sensitive_internal";
        let err = AdminError::Internal(raw.to_owned());
        let resp = err.into_response();
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
        let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap();
        let body_str = String::from_utf8_lossy(&body);
        assert!(
            !body_str.contains("sensitive_internal"),
            "raw error text leaked to client: {body_str}"
        );
        assert!(
            !body_str.contains("SELECT"),
            "SQL leaked to client: {body_str}"
        );
        assert!(
            body_str.contains("internal server error"),
            "expected generic message, got: {body_str}"
        );
        assert!(
            body_str.contains("correlation_id"),
            "expected correlation_id in body for log lookup, got: {body_str}"
        );
    }

    // The SQLite and MySQL parsers read the table name out of each
    // driver's message, so `TableMissing` fires on every backend.
    #[test]
    fn sqlite_undefined_table_extracts_name_from_message() {
        let raw = "(code: 1) no such table: rustango_admin_users";
        // sqlx may or may not include the libsqlite3 prefix. What
        // matters is the `no such table: …` part of `.message()`.
        let msg = raw.strip_prefix("(code: 1) ").unwrap_or(raw);
        assert_eq!(
            msg.strip_prefix("no such table: ").map(|rest| {
                rest.chars()
                    .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
                    .collect::<String>()
            }),
            Some("rustango_admin_users".to_owned())
        );
    }

    #[test]
    fn mysql_undefined_table_extracts_name_from_message() {
        // MySQL's `.message()` reads `Table 'db.table' doesn't exist`.
        let msg = "Table 'demo.rustango_admin_users' doesn't exist";
        let (_, rest) = msg.split_once('\'').unwrap();
        let (qualified, _) = rest.split_once('\'').unwrap();
        let name = qualified
            .rsplit_once('.')
            .map(|(_, t)| t)
            .unwrap_or(qualified);
        assert_eq!(name, "rustango_admin_users");
    }

    /// The `TableMissing` page names only the table, which the user
    /// already typed in the URL, so it leaks nothing.
    #[tokio::test]
    async fn table_missing_response_keeps_friendly_html() {
        let err = AdminError::TableMissing {
            table: "posts".to_owned(),
        };
        let resp = err.into_response();
        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
        let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap();
        let body_str = String::from_utf8_lossy(&body);
        assert!(body_str.contains("posts"));
        assert!(body_str.contains("migrations"));
    }
}