//! The stateless side: the editor page, its live preview, and one-off
//! exports. None of it touches the database, so it works signed out.
use axum::{
extract::{Form, Query, State},
http::{header, HeaderValue, StatusCode},
response::{Html, IntoResponse, Response},
};
use minijinja::context;
use serde::Deserialize;
use tracing::error;
use super::render;
use crate::middleware::UserContext;
use crate::render::{self as card, chrome, highlight, palette, CardSpec, Output};
use crate::AppState;
/// The editor's fields. Checkboxes arrive only when ticked, and padding is
/// taken as text so an emptied number box isn't a 422.
#[derive(Debug, Deserialize)]
pub struct CardForm {
#[serde(default)]
pub code: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub language: String,
#[serde(default)]
pub chrome: String,
#[serde(default)]
pub palette: String,
#[serde(default)]
pub highlight: String,
pub padding: Option<String>,
pub transparent: Option<String>,
pub line_numbers: Option<String>,
}
impl CardForm {
pub fn into_spec(self) -> CardSpec {
let d = CardSpec::default();
let or = |v: String, dflt: String| if v.trim().is_empty() { dflt } else { v };
CardSpec {
code: self.code,
title: self.title.trim().to_string(),
language: or(self.language, highlight::AUTO_LANG.into()),
chrome: or(self.chrome, d.chrome),
palette: or(self.palette, d.palette),
highlight: or(self.highlight, d.highlight),
padding: self
.padding
.and_then(|p| p.trim().parse().ok())
.unwrap_or(card::DEFAULT_PADDING),
transparent: self.transparent.is_some(),
line_numbers: self.line_numbers.is_some(),
}
.sanitize()
}
}
/// Everything the editor's pickers list.
pub fn options() -> minijinja::Value {
let (kde, neiam): (Vec<_>, Vec<_>) =
palette::PALETTES.iter().partition(|p| p.family == palette::Family::Kde);
context! {
chromes => chrome::CHROMES,
palettes_kde => kde,
palettes_neiam => neiam,
hl_themes => highlight::HL_THEMES,
languages => highlight::languages(),
}
}
/// Render the editor, either blank (the landing page) or for a saved snippet.
pub(crate) fn editor_page(
state: &AppState,
user: Option<UserContext>,
spec: &CardSpec,
slug: Option<&str>,
) -> Result<Html<String>, StatusCode> {
render(
state,
"editor.html",
context! {
title => if slug.is_some() { "Edit snippet" } else { "New card" },
user => user.map(|u| u.user),
spec => spec,
slug => slug,
options => options(),
max_padding => card::MAX_PADDING,
card_svg => card::render_svg(spec, Output::Inline),
},
)
}
pub async fn editor(
State(state): State<AppState>,
user: Option<UserContext>,
) -> Result<Html<String>, StatusCode> {
editor_page(&state, user, &CardSpec::default(), None)
}
/// htmx target for the live preview: just the inline SVG, wrapped in the id
/// the editor's `hx-select` picks out.
pub async fn preview(Form(form): Form<CardForm>) -> Html<String> {
let spec = form.into_spec();
Html(format!(
r#"<div id="preview-inner">{}</div>"#,
card::render_svg(&spec, Output::Inline)
))
}
#[derive(Debug, Deserialize)]
pub struct ExportQuery {
#[serde(default)]
pub format: String,
pub scale: Option<f32>,
}
pub const DEFAULT_PNG_SCALE: f32 = 2.0;
/// Turn a spec into a download (`png` by default, or `svg`).
pub(crate) async fn export_response(
spec: CardSpec,
format: &str,
scale: f32,
filename: &str,
attachment: bool,
) -> Response {
let disposition = |ext: &str| {
let kind = if attachment { "attachment" } else { "inline" };
HeaderValue::from_str(&format!(r#"{kind}; filename="{filename}.{ext}""#))
.unwrap_or_else(|_| HeaderValue::from_static("inline"))
};
if format == "svg" {
let body = card::render_svg(&spec, Output::Standalone);
return (
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml; charset=utf-8")),
(header::CONTENT_DISPOSITION, disposition("svg")),
],
body,
)
.into_response();
}
// Rasterising is CPU-bound; keep it off the async workers.
let png = tokio::task::spawn_blocking(move || {
let svg = card::render_svg(&spec, Output::Inline);
card::render_png(&svg, scale)
})
.await;
match png {
Ok(Ok(bytes)) => (
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
(header::CONTENT_DISPOSITION, disposition("png")),
],
bytes,
)
.into_response(),
Ok(Err(e)) => {
error!(error = %e, "png render failed");
(StatusCode::UNPROCESSABLE_ENTITY, format!("could not render: {e}")).into_response()
}
Err(e) => {
error!(error = %e, "png render task panicked");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// A filesystem-safe download name from the card title.
pub(crate) fn file_stem(title: &str) -> String {
let stem: String = title
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' })
.collect();
let stem = stem.trim_matches(['-', '.']).to_string();
if stem.is_empty() { "bismuth".into() } else { stem.chars().take(64).collect() }
}
pub async fn export(Query(q): Query<ExportQuery>, Form(form): Form<CardForm>) -> Response {
let spec = form.into_spec();
let name = file_stem(&spec.title);
export_response(spec, &q.format, q.scale.unwrap_or(DEFAULT_PNG_SCALE), &name, true).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_defaults_and_checkboxes() {
let form = CardForm {
code: "x".into(),
title: " t ".into(),
language: "".into(),
chrome: "".into(),
palette: "her".into(),
highlight: "".into(),
padding: Some("".into()),
transparent: Some("on".into()),
line_numbers: None,
};
let spec = form.into_spec();
assert_eq!(spec.title, "t");
assert_eq!(spec.language, highlight::AUTO_LANG);
assert_eq!(spec.chrome, chrome::DEFAULT_CHROME);
assert_eq!(spec.palette, "her");
assert_eq!(spec.padding, card::DEFAULT_PADDING);
assert!(spec.transparent);
assert!(!spec.line_numbers);
}
#[test]
fn file_stems_are_safe() {
assert_eq!(file_stem("main.rs — Konsole"), "main.rs---Konsole");
assert_eq!(file_stem("../../etc/passwd"), "etc-passwd");
assert_eq!(file_stem("\"';"), "bismuth");
}
}
neiam
/bismuth
MOTHER-FUCKING-BISMUTH
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.9 KiB · text
History
d76cd01