//! 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");
}
}
neiam
/bismuth
MOTHER-FUCKING-BISMUTH
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.5 KiB · text
History
487bb12