Skip to content

Commit

Permalink
Fix #110: drop globals
Browse files Browse the repository at this point in the history
This is a relatively chunky change replacing all globals with data in
`AppState` along with some cleanups that occured naturally. Especially,
all CSS can now be served with their SHA256 content hashsum to avoid
browser cache issues.

On the other hand, we dropped the possibility to nest the routes under
the WASTEBIN_BASE_URL. This is better done by some dedicated proxy. The
base URL is now only used to construct the QR link.
  • Loading branch information
matze committed Feb 6, 2025
1 parent 713ef9d commit 0405ee7
Show file tree
Hide file tree
Showing 20 changed files with 796 additions and 710 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@

### Changed

- **Breaking**: From now on, `WASTEBIN_BASE_URL` is only used for the QR code
link but not for internal routing. Use a dedicated proxy server to do that if
necessary.
- Use the [two-face](https://docs.rs/two-face) crate for an extended syntax
list.
- Serve all CSS assets under hashed URL to avoid caching issues.

### Fixes

Expand Down
221 changes: 221 additions & 0 deletions src/assets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use askama::Template;
use axum::response::{IntoResponse, Response};
use axum_extra::{headers, TypedHeader};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::time::Duration;
use syntect::highlighting::{Color, ThemeSet};
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
use two_face::theme::EmbeddedThemeName;

use crate::highlight::Theme;

/// An asset associated with a MIME type.
#[derive(Clone)]
pub struct Asset {
/// Route that this will be served under.
pub route: String,
/// MIME type of this asset determined for the `ContentType` response header.
mime: mime::Mime,
/// Actual asset content.
content: Vec<u8>,
}

/// Asset kind.
#[derive(Copy, Clone)]
pub enum Kind {
Css,
Js,
}

impl IntoResponse for Asset {
fn into_response(self) -> Response {
let content_type_header = headers::ContentType::from(self.mime);

let headers = (
TypedHeader(content_type_header),
TypedHeader(headers::CacheControl::new().with_max_age(Duration::from_secs(100))),
);

(headers, self.content).into_response()
}
}

impl Asset {
/// Construct new asset under the given `name`, `mime` type and `content`.
pub fn new(name: &str, mime: mime::Mime, content: Vec<u8>) -> Self {
Self {
route: format!("/{name}"),
mime,
content,
}
}

/// Construct new hashed asset under the given `name`, `kind` and `content`.
pub fn new_hashed(name: &str, kind: Kind, content: Vec<u8>) -> Self {
let (mime, ext) = match kind {
Kind::Css => (mime::TEXT_CSS, "css"),
Kind::Js => (mime::TEXT_JAVASCRIPT, "js"),
};

let route = format!(
"/{name}.{}.{ext}",
hex::encode(Sha256::digest(&content))
.get(0..16)
.expect("at least 16 characters")
);

Self {
route,
mime,
content,
}
}

pub fn route(&self) -> &str {
&self.route
}
}

/// Collection of light and dark CSS and main UI style CSS derived from them.
pub struct CssAssets {
/// Main UI CSS stylesheet.
pub style: Asset,
/// Light theme colors.
pub light: Asset,
/// Dark theme colors.
pub dark: Asset,
}

trait ColorExt {
/// Construct some color from the given RGBA components.
fn new(r: u8, g: u8, b: u8, a: u8) -> Self;
}

impl ColorExt for Color {
fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
}

impl CssAssets {
/// Create CSS assets for `theme`.
pub fn new(theme: Theme) -> Self {
#[derive(Template)]
#[template(path = "style.css", escape = "none")]
struct StyleCss {
light_background: Color,
light_foreground: Color,
dark_background: Color,
dark_foreground: Color,
light_asset: Asset,
dark_asset: Asset,
}

let light_theme = light_theme(theme);
let dark_theme = dark_theme(theme);

let light_foreground = light_theme
.settings
.foreground
.unwrap_or(Color::new(3, 3, 3, 100));

let light_background = light_theme
.settings
.background
.unwrap_or(Color::new(250, 250, 250, 100));

let dark_foreground = dark_theme
.settings
.foreground
.unwrap_or(Color::new(230, 225, 207, 100));

let dark_background = dark_theme
.settings
.background
.unwrap_or(Color::new(15, 20, 25, 100));

let light = Asset::new_hashed(
"light",
Kind::Css,
css_for_theme_with_class_style(&light_theme, ClassStyle::Spaced)
.expect("generating CSS")
.into_bytes(),
);

let dark = Asset::new_hashed(
"dark",
Kind::Css,
css_for_theme_with_class_style(&dark_theme, ClassStyle::Spaced)
.expect("generating CSS")
.into_bytes(),
);

let style = StyleCss {
light_background,
light_foreground,
dark_background,
dark_foreground,
light_asset: light.clone(),
dark_asset: dark.clone(),
};

let style = Asset::new_hashed(
"style",
Kind::Css,
style.render().expect("rendering style css").into_bytes(),
);

Self { style, light, dark }
}
}

fn light_theme(theme: Theme) -> syntect::highlighting::Theme {
let theme_set = two_face::theme::extra();

match theme {
Theme::Ayu => {
let theme = include_str!("themes/ayu-light.tmTheme");
ThemeSet::load_from_reader(&mut Cursor::new(theme)).expect("loading theme")
}
Theme::Base16Ocean => theme_set.get(EmbeddedThemeName::Base16OceanLight).clone(),
Theme::Coldark => theme_set.get(EmbeddedThemeName::ColdarkCold).clone(),
Theme::Gruvbox => theme_set.get(EmbeddedThemeName::GruvboxLight).clone(),
Theme::Monokai => theme_set
.get(EmbeddedThemeName::MonokaiExtendedLight)
.clone(),
Theme::Onehalf => theme_set.get(EmbeddedThemeName::OneHalfLight).clone(),
Theme::Solarized => theme_set.get(EmbeddedThemeName::SolarizedLight).clone(),
}
}

fn dark_theme(theme: Theme) -> syntect::highlighting::Theme {
let theme_set = two_face::theme::extra();

match theme {
Theme::Ayu => {
let theme = include_str!("themes/ayu-dark.tmTheme");
ThemeSet::load_from_reader(&mut Cursor::new(theme)).expect("loading theme")
}
Theme::Base16Ocean => theme_set.get(EmbeddedThemeName::Base16OceanDark).clone(),
Theme::Coldark => theme_set.get(EmbeddedThemeName::ColdarkDark).clone(),
Theme::Gruvbox => theme_set.get(EmbeddedThemeName::GruvboxDark).clone(),
Theme::Monokai => theme_set.get(EmbeddedThemeName::MonokaiExtended).clone(),
Theme::Onehalf => theme_set.get(EmbeddedThemeName::OneHalfDark).clone(),
Theme::Solarized => theme_set.get(EmbeddedThemeName::SolarizedDark).clone(),
}
}

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

#[test]
fn hashed_asset() {
let asset = Asset::new_hashed("style", Kind::Css, String::from("body {}").into_bytes());
assert_eq!(asset.route, "/style.62368a1a29259b30.css");

let asset = Asset::new_hashed("main", Kind::Js, String::from("1 + 1").into_bytes());
assert_eq!(asset.route, "/main.72fce59447a01f48.js");
}
}
95 changes: 15 additions & 80 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,8 @@ use std::env::VarError;
use std::net::SocketAddr;
use std::num::{NonZero, NonZeroU32, NonZeroUsize, ParseIntError};
use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::Duration;

pub struct Metadata<'a> {
pub title: String,
pub version: &'a str,
pub highlight: &'a highlight::Data<'a>,
}

pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(5);

const VAR_ADDRESS_PORT: &str = "WASTEBIN_ADDRESS_PORT";
Expand Down Expand Up @@ -44,87 +37,29 @@ pub enum Error {
HttpTimeout(ParseIntError),
#[error("failed to parse {VAR_MAX_PASTE_EXPIRATION}: {0}")]
MaxPasteExpiration(ParseIntError),
#[error("unknown theme {0}")]
UnknownTheme(String),
}

pub struct BasePath(String);

impl BasePath {
pub fn path(&self) -> &str {
&self.0
}

pub fn join(&self, s: &str) -> String {
let b = &self.0;
format!("{b}{s}")
}
}

impl Default for BasePath {
fn default() -> Self {
BasePath("/".to_string())
}
pub fn title() -> String {
std::env::var("WASTEBIN_TITLE").unwrap_or_else(|_| "wastebin".to_string())
}

pub static METADATA: LazyLock<Metadata> = LazyLock::new(|| {
let title = std::env::var("WASTEBIN_TITLE").unwrap_or_else(|_| "wastebin".to_string());
let version = env!("CARGO_PKG_VERSION");
let highlight = &highlight::DATA;

Metadata {
title,
version,
highlight,
}
});

// NOTE: This relies on `VAR_BASE_URL` but repeats parsing to handle errors.
pub static BASE_PATH: LazyLock<BasePath> = LazyLock::new(|| {
std::env::var(VAR_BASE_URL).map_or_else(
|err| {
match err {
VarError::NotPresent => (),
VarError::NotUnicode(_) => {
tracing::warn!("`VAR_BASE_URL` not Unicode, defaulting to '/'");
}
};
BasePath::default()
},
|var| match url::Url::parse(&var) {
Ok(url) => {
let path = url.path();

if path.ends_with('/') {
BasePath(path.to_string())
} else {
BasePath(format!("{path}/"))
}
}
Err(err) => {
tracing::error!("error parsing `VAR_BASE_URL`, defaulting to '/': {err}");
BasePath::default()
}
},
)
});

pub static THEME: LazyLock<highlight::Theme> = LazyLock::new(|| {
pub fn theme() -> Result<highlight::Theme, Error> {
std::env::var(VAR_THEME).map_or_else(
|_| highlight::Theme::Ayu,
|_| Ok(highlight::Theme::Ayu),
|var| match var.as_str() {
"ayu" => highlight::Theme::Ayu,
"base16ocean" => highlight::Theme::Base16Ocean,
"coldark" => highlight::Theme::Coldark,
"gruvbox" => highlight::Theme::Gruvbox,
"monokai" => highlight::Theme::Monokai,
"onehalf" => highlight::Theme::Onehalf,
"solarized" => highlight::Theme::Solarized,
_ => {
tracing::error!("unrecognized theme {var}");
highlight::Theme::Ayu
}
"ayu" => Ok(highlight::Theme::Ayu),
"base16ocean" => Ok(highlight::Theme::Base16Ocean),
"coldark" => Ok(highlight::Theme::Coldark),
"gruvbox" => Ok(highlight::Theme::Gruvbox),
"monokai" => Ok(highlight::Theme::Monokai),
"onehalf" => Ok(highlight::Theme::Onehalf),
"solarized" => Ok(highlight::Theme::Solarized),
_ => Err(Error::UnknownTheme(var)),
},
)
});
}

pub fn cache_size() -> Result<NonZeroUsize, Error> {
std::env::var(VAR_CACHE_SIZE)
Expand Down
Loading

0 comments on commit 0405ee7

Please sign in to comment.