11.5 KiB · text History d76cd01
//! Saved cards. Owners create, edit and delete them; anyone holding the slug
//! can view, download, or embed one. The slug is random and unlisted, which
//! is the whole access model: share the link, share the card.
use axum::{
    extract::{Form, Path, Query, State},
    http::{header, HeaderValue, StatusCode},
    response::{Html, IntoResponse, Redirect, Response},
};
use chrono::Utc;
use minijinja::context;
use rand::distr::{Distribution, Uniform};
use sea_orm::*;
use serde::Deserialize;
use tracing::error;
use uuid::Uuid;

use super::cards::{editor_page, export_response, file_stem, CardForm, DEFAULT_PNG_SCALE};
use super::render;
use crate::entities::snippets;
use crate::middleware::UserContext;
use crate::render::{self as card, palette, CardSpec, Output};
use crate::AppState;

const SLUG_LEN: usize = 10;
const SLUG_ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";

fn new_slug() -> String {
    let dist = Uniform::new(0, SLUG_ALPHABET.len()).expect("non-empty alphabet");
    let mut rng = rand::rng();
    (0..SLUG_LEN).map(|_| SLUG_ALPHABET[dist.sample(&mut rng)] as char).collect()
}

fn db_err(what: &'static str) -> impl Fn(DbErr) -> StatusCode {
    move |e| {
        error!(error = %e, "{what}");
        StatusCode::INTERNAL_SERVER_ERROR
    }
}

async fn find(state: &AppState, slug: &str) -> Result<snippets::Model, StatusCode> {
    snippets::Entity::find()
        .filter(snippets::Column::Slug.eq(slug))
        .one(state.db.as_ref())
        .await
        .map_err(db_err("load snippet failed"))?
        .ok_or(StatusCode::NOT_FOUND)
}

/// Owner-only lookup. Someone else's snippet is a 404, not a 403, so slugs
/// can't be probed for existence through the edit routes.
async fn find_owned(state: &AppState, user: &UserContext, slug: &str) -> Result<snippets::Model, StatusCode> {
    let s = find(state, slug).await?;
    if s.user_id != user.user.id {
        return Err(StatusCode::NOT_FOUND);
    }
    Ok(s)
}

fn apply(mut model: snippets::ActiveModel, spec: CardSpec) -> snippets::ActiveModel {
    model.title = Set(spec.title);
    model.language = Set(spec.language);
    model.code = Set(spec.code);
    model.chrome = Set(spec.chrome);
    model.palette = Set(spec.palette);
    model.highlight = Set(spec.highlight);
    model.transparent = Set(spec.transparent);
    model.padding = Set(spec.padding as i32);
    model.line_numbers = Set(spec.line_numbers);
    model.updated_at = Set(Utc::now().into());
    model
}

pub async fn list_snippets(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let items = snippets::Entity::find()
        .filter(snippets::Column::UserId.eq(user.user.id))
        .order_by_desc(snippets::Column::UpdatedAt)
        .all(state.db.as_ref())
        .await
        .map_err(db_err("list snippets failed"))?;
    render(
        &state,
        "snippets/list.html",
        context! { title => "Snippets", user => user.user, snippets => items },
    )
}

pub async fn create_snippet(
    State(state): State<AppState>,
    user: UserContext,
    Form(form): Form<CardForm>,
) -> Result<Redirect, StatusCode> {
    let spec = form.into_spec();
    if spec.code.trim().is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    // Ten characters from a 32-letter alphabet is 50 bits; a collision is
    // astronomically unlikely, but retry rather than 500 if one happens.
    for _ in 0..3 {
        let now = Utc::now();
        let slug = new_slug();
        let model = apply(
            snippets::ActiveModel {
                id: Set(Uuid::now_v7()),
                user_id: Set(user.user.id),
                slug: Set(slug.clone()),
                created_at: Set(now.into()),
                ..Default::default()
            },
            spec.clone(),
        );
        match model.insert(state.db.as_ref()).await {
            Ok(_) => return Ok(Redirect::to(&format!("/s/{slug}"))),
            Err(e) if matches!(e.sql_err(), Some(SqlErr::UniqueConstraintViolation(_))) => continue,
            Err(e) => return Err(db_err("insert snippet failed")(e)),
        }
    }
    Err(StatusCode::INTERNAL_SERVER_ERROR)
}

pub async fn edit_snippet(
    State(state): State<AppState>,
    user: UserContext,
    Path(slug): Path<String>,
) -> Result<Html<String>, StatusCode> {
    let s = find_owned(&state, &user, &slug).await?;
    editor_page(&state, Some(user), &s.spec(), Some(&s.slug))
}

pub async fn update_snippet(
    State(state): State<AppState>,
    user: UserContext,
    Path(slug): Path<String>,
    Form(form): Form<CardForm>,
) -> Result<Redirect, StatusCode> {
    let s = find_owned(&state, &user, &slug).await?;
    let spec = form.into_spec();
    if spec.code.trim().is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    apply(s.into(), spec)
        .update(state.db.as_ref())
        .await
        .map_err(db_err("update snippet failed"))?;
    Ok(Redirect::to(&format!("/s/{slug}")))
}

pub async fn delete_snippet(
    State(state): State<AppState>,
    user: UserContext,
    Path(slug): Path<String>,
) -> Result<Redirect, StatusCode> {
    let s = find_owned(&state, &user, &slug).await?;
    snippets::Entity::delete_by_id(s.id)
        .exec(state.db.as_ref())
        .await
        .map_err(db_err("delete snippet failed"))?;
    Ok(Redirect::to("/snippets"))
}

/// Per-request look overrides for the public image and embed routes, so one
/// saved snippet can be dressed differently wherever it's placed. `theme` is
/// the palette, named to match the presentato / Diogramos vocabulary.
#[derive(Debug, Default, Deserialize)]
pub struct Overrides {
    pub theme: Option<String>,
    pub chrome: Option<String>,
    pub hl: Option<String>,
    /// `transparent` drops the backdrop; `backdrop` forces it back on.
    pub bg: Option<String>,
    pub pad: Option<u32>,
    pub ln: Option<bool>,
    pub scale: Option<f32>,
    /// `1` for an SVG fragment to inline into a page (no prolog, no fonts).
    pub inline: Option<u8>,
    /// `1` to serve the image as a download.
    pub download: Option<u8>,
}

impl Overrides {
    pub fn apply(&self, mut spec: CardSpec) -> CardSpec {
        if let Some(t) = self.theme.as_deref().filter(|t| palette::get(t).is_some()) {
            spec.palette = t.into();
        }
        if let Some(c) = self.chrome.as_deref().filter(|c| card::chrome::get(c).is_some()) {
            spec.chrome = c.into();
        }
        if let Some(h) = self.hl.as_deref().filter(|h| card::highlight::hl_theme(h).is_some()) {
            spec.highlight = h.into();
        }
        match self.bg.as_deref() {
            Some("transparent") => spec.transparent = true,
            Some("backdrop") => spec.transparent = false,
            _ => {}
        }
        if let Some(p) = self.pad {
            spec.padding = p;
        }
        if let Some(ln) = self.ln {
            spec.line_numbers = ln;
        }
        spec.sanitize()
    }

    /// The overrides as a query string (leading `?`, or empty), for carrying
    /// them from the embed page to the SVG it refetches on a theme change.
    fn query(&self) -> String {
        let mut q = Vec::new();
        for (k, v) in [("chrome", &self.chrome), ("hl", &self.hl), ("bg", &self.bg)] {
            if let Some(v) = v.as_deref().filter(|v| v.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
                q.push(format!("{k}={v}"));
            }
        }
        if let Some(p) = self.pad {
            q.push(format!("pad={p}"));
        }
        if let Some(ln) = self.ln {
            q.push(format!("ln={ln}"));
        }
        if q.is_empty() { String::new() } else { format!("?{}", q.join("&")) }
    }
}

pub async fn show_snippet(
    State(state): State<AppState>,
    user: Option<UserContext>,
    Path(slug): Path<String>,
) -> Result<Html<String>, StatusCode> {
    let s = find(&state, &slug).await?;
    let is_owner = user.as_ref().is_some_and(|u| u.user.id == s.user_id);
    let spec = s.spec();
    render(
        &state,
        "snippets/show.html",
        context! {
            title => if s.title.is_empty() { "Snippet".to_string() } else { s.title.clone() },
            user => user.map(|u| u.user),
            snippet => s,
            is_owner => is_owner,
            base => state.config.public_url,
            palette => spec.palette().id,
            card_svg => card::render_svg(&spec, Output::Inline),
        },
    )
}

/// Images change when the owner edits the snippet, so cache briefly.
const IMAGE_CACHE: &str = "public, max-age=60";

pub async fn snippet_svg(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    Query(o): Query<Overrides>,
) -> Result<Response, StatusCode> {
    let s = find(&state, &slug).await?;
    let spec = o.apply(s.spec());
    let mut resp = if o.inline == Some(1) {
        (
            [(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml; charset=utf-8"))],
            card::render_svg(&spec, Output::Inline),
        )
            .into_response()
    } else {
        export_response(spec, "svg", 1.0, &file_stem(&s.title), o.download == Some(1)).await
    };
    resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(IMAGE_CACHE));
    Ok(resp)
}

pub async fn snippet_png(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    Query(o): Query<Overrides>,
) -> Result<Response, StatusCode> {
    let s = find(&state, &slug).await?;
    let spec = o.apply(s.spec());
    let scale = o.scale.unwrap_or(DEFAULT_PNG_SCALE);
    let mut resp = export_response(spec, "png", scale, &file_stem(&s.title), o.download == Some(1)).await;
    if resp.status().is_success() {
        resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(IMAGE_CACHE));
    }
    Ok(resp)
}

/// The iframe target. Frameable from anywhere, and it follows the embedding
/// page's color scheme over postMessage (see templates/embed.html).
pub async fn embed(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    Query(o): Query<Overrides>,
) -> Result<Response, StatusCode> {
    let s = find(&state, &slug).await?;
    let spec = o.apply(s.spec());
    let html = render(
        &state,
        "embed.html",
        context! {
            snippet => s,
            palette => spec.palette().id,
            themes => palette::ids().join(" "),
            src => format!("/s/{}/card.svg{}", s.slug, o.query()),
            card_svg => card::render_svg(&spec, Output::Inline),
        },
    )?;
    let mut resp = html.into_response();
    resp.headers_mut().insert(
        header::CONTENT_SECURITY_POLICY,
        HeaderValue::from_static("frame-ancestors *"),
    );
    Ok(resp)
}

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

    #[test]
    fn slugs_use_the_alphabet() {
        let s = new_slug();
        assert_eq!(s.len(), SLUG_LEN);
        assert!(s.bytes().all(|b| SLUG_ALPHABET.contains(&b)));
    }

    #[test]
    fn overrides_ignore_unknown_ids() {
        let o = Overrides {
            theme: Some("blueprint".into()),
            chrome: Some("nope".into()),
            bg: Some("transparent".into()),
            ..Default::default()
        };
        let spec = o.apply(CardSpec::default());
        assert_eq!(spec.palette, "blueprint");
        assert_eq!(spec.chrome, card::chrome::DEFAULT_CHROME);
        assert!(spec.transparent);
    }

    #[test]
    fn override_query_drops_junk() {
        let o = Overrides {
            chrome: Some("keramik".into()),
            hl: Some("x\"><script>".into()),
            pad: Some(0),
            ..Default::default()
        };
        assert_eq!(o.query(), "?chrome=keramik&pad=0");
    }
}