From 9edafd94b00a73662d51824dbcba0a018e2140cf Mon Sep 17 00:00:00 2001 From: alyx Date: Thu, 10 Aug 2023 20:45:01 -0400 Subject: [PATCH] Create plain theme, do some escaping, fix some busted envvars, document themes. --- Cargo.toml | 2 + README.md | 161 ++++++++++++++++++++++++++++++++++-- screenshots/plain-light.png | Bin 0 -> 25504 bytes src/config.rs | 21 +++-- src/ctx.rs | 12 ++- src/main.rs | 26 +++--- src/themes/plain.hbs | 34 ++++++++ themes/test.css | 0 8 files changed, 227 insertions(+), 29 deletions(-) create mode 100644 screenshots/plain-light.png create mode 100644 src/themes/plain.hbs delete mode 100644 themes/test.css diff --git a/Cargo.toml b/Cargo.toml index da3ce4b..e72d50e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,11 @@ dotenv = "0.15.0" duration-str = "0.5.1" env_logger = "0.10.0" handlebars = { version = "4.3.7", features = ["dir_source"] } +htmlize = "1.0.3" log = "0.4.19" reqwest = { version = "0.11.18", features = ["gzip", "deflate", "brotli", "json"] } serde = { version = "1.0.183", features = ["derive", "rc", "alloc"] } serde_json = "1.0.104" tokio = { version = "1.29.1", features = ["full"] } +urlencoding = "2.1.3" warp = "0.3.5" diff --git a/README.md b/README.md index d1cbc57..af69629 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,45 @@ A simple webserver for rendering a last.fm embed. More specifically, this displays a simple webpage with info about your current or most recent last.fm scrobble. -Its intended use is to be put on a personal site or whatever. +Its intended use is to be put in an iframe on a personal site or whatever. +While it is self-hostable, there is an official public instance at `https://lfm.aleteoryx.me`. # Usage + +`lfm_embed` serves one thing: +Requests to `/user/` will generate a page displaying your current or last last.fm scrobble. +The look and feel of this is down to the configured theme for your request. + +As the user, you may select a theme supported by the server by passing `?theme=` query string. +Check with your host for a list of supported themes. + +Individual themes may also support parameters, via additional query parameters. + +## Builtin Themes + +### `plain` + +A minimal theme, displaying just the username, album art, and track info. +It is the default fallback for an unset or unknown theme. + +![A screenshot of the plain theme, displaying album art, and the text "@vvinrg is scrobbling Sweet Tooth from Sleepyhead by Cavetown".](screenshots/plain-light.png) + +#### Parameters + +- `dark`: toggle the theme's dark mode. + +# Self-Hosting `lfm_embed` is, as it stands, designed to use a reverse proxy. A future release may include HTTPS support, but currently, it will only listen on `localhost`, prohibiting it from public access. Once configured, a request of the form `http://localhost:9999/` will render out an embed with the default theme. As it stands, there are no plans to support displaying users with private listen history. -*** - # Configuration Configuration should be done via the environment. `lfm_embed` also supports reading from a standard `.env` file. The following are the environment variables which `lfm_embed` understands. -*** - ## Core Config ### `LMFE_API_KEY` (Required) @@ -29,7 +50,7 @@ Your last.fm API key. You'll need to create one [here](https://www.last.fm/api/a The port to serve on locally. ### `LFME_NO_REFRESH` (Default: `0`) -If set to `1`, disable outputting of the HTML `meta http-eqiv="refresh"` tag, used for live status updates. +If set to `1`, disable outputting of the HTTP `Refresh: ` header, used to provide live status updates. ## Logging @@ -74,6 +95,8 @@ If set, must be a valid path to a directory containing [Handlebars](https://hand They will be registered as themes on top of the builtin ones, with each theme's name being their filename minus the extension. Same-named themes will override builtin ones. +See [Theming](#theming) for details on writing custom themes. + Theme names are the same as their path, minus the extension. Given an extension of .hbs, a directory like: ``` themes/ @@ -94,9 +117,11 @@ alices-themes/mysuperawesometheme By default, these are loaded and compiled once, at startup. -### `LFME_THEME_EXT` (Default: `hbs`) +### `LFME_THEME_EXT` (Default: `.hbs`) The file extension for themes in `LFME_THEME_DIR`. +Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension. + ### `LFME_THEME_DEV` (Default: `0`) If set to `1`, existing themes will be reloaded on edit. @@ -116,7 +141,125 @@ LFME_WHITELIST_REFRESH=30s LFME_WHITELIST_MODE=exclusive LFME_WHITELIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345 ``` -*** -# Theming +# Theming {#theming} + +Custom themes are, as stated above, [Handlebars](https://handlebarsjs.com/guide/#language-features) files. +They are expected to produce a complete HTML doc, which should contain info about a user's scrobbles. + +See `src/themes` for examples of implementation. + +## Writing a Theme + +Themes should have, roughly, the structure below: + +```html + + + + + + + + {{#if error}}

{{error}}

{{else}} + + {{/if}} + + +``` + +You will be passed one of 2 context objects, depending on if there was an error processing the request. +If there was an error, the object will just be `{ error: String }`, otherwise it will be the following: + +```js +{ + // The user the request was made on the behalf of. + user: { + // Their username. + name: String, + // Their "Display Name". + realname: String, + // Link to user's profile picture. + image_url: String, + // Link to user's profile. + url: String, + + // Total scrobbles. + scrobble_count: Number, + // Number of artists in library. + artist_count: Number, + // Number of tracks in library. + track_count: Number, + // Number of albums in library. + album_count: Number, + + // True if user subscribes to last.fm pro. + pro_subscriber: Boolean + }, + + // The user's most current, or most scrobble. + scrobble: { + // The name of the track. + name: String, + // The name of its album. + album: String, + // The artist who made it. + artist: { + // Their name. + name: String, + // Link to their profile image. + image_url: String, + // Link to the artist's last.fm page. + url: String + }, + // A link to the track image. + image_url: String, + // A link to the track's last.fm page. + url: String, + + // True if the user has loved the track. + loved: Boolean + // True if the user is currently scrobbling it, false if it's just + // the most recently played track. + now_playing: Boolean, + }, + + // A set of extraneous query parameters. + // + // This should be considered UNTRUSTED, as the requester has full + // control over it. Use the provided `html_escape`, + // `html_attr_escape`, and `uri_encode` helpers when inlining + // any contained text. + query: Object +} +``` + +In addition, the following [helpers](https://handlebarsjs.com/guide/expressions.html#helpers) are provided, on top of the handlebars [builtins](https://handlebarsjs.com/guide/builtin-helpers.html): + +- `(eq Object Object) => Boolean`: Check equality between its args. + +- `(ne Object Object) => Boolean`: Check inequality between its args. + +- `(gt Number|String Number|String) => Boolean`: Check if the first arg is greater than the second. + +- `(gte Number|String Number|String) => Boolean`: Check if the first arg is greater than or equal to the second. + +- `(lt Number|String Number|String) => Boolean`: Check if the first arg is less than the second. + +- `(lte Number|String Number|String) => Boolean`: Check if the first arg is less than or equal to the second. + +- `(and Boolean Boolean) => Boolean`: Boolean AND gate. + +- `(or Boolean Boolean) => Boolean`: Boolean OR gate. + +- `(not Boolean) => Boolean`: Boolean NOT gate. + +- `(html_escape String) => String`: Escape HTML special characters from the input string, replacing them with HTML entities in the output. For use in standard markdown. + +- `(html_attr_escape String) => String`: Escape HTML special characters, as well as quotation marks, in the input, replacing them with HTML entities and escaped quotes in the output. For use in HTML tag attributes. + +- `(uri_encode String) => String`: URI-encode input text, making the output suitable to be included as part of a link or other URL. + +# Contributing +[E-Mail me](mailto:alyx@aleteoryx.me) if you'd like to help out, submit a custom theme, or request a feature. diff --git a/screenshots/plain-light.png b/screenshots/plain-light.png new file mode 100644 index 0000000000000000000000000000000000000000..46b93bdfc4c0f81d1f2a6fc2ea2060020ace036a GIT binary patch literal 25504 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 literal 0 HcmV?d00001 diff --git a/src/config.rs b/src/config.rs index aad298f..ab92108 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,17 +11,17 @@ use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User}; use reqwest::{Client, StatusCode}; use dotenv::var; use tokio::sync::RwLock; -use handlebars::Handlebars; +use handlebars::{Handlebars, handlebars_helper}; use duration_str as ds; -static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", "")]; +static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", include_str!("themes/plain.hbs"))]; pub static STATE: LazyLock> = LazyLock::new(|| { State::new() }); fn getter(username: &String) -> Pin, (StatusCode, &'static str)>> + Send + Sync)>> { - let username = username.clone(); + let username = urlencoding::encode(username.as_ref()).to_string(); Box::pin(async move { let userreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getInfo&format=json&user={username}&api_key={}", STATE.api_key)) .send().await @@ -84,6 +84,15 @@ impl State { handlebars: { let mut hb = Handlebars::new(); + + handlebars_helper!(html_escape: |s: String| htmlize::escape_text(s)); + handlebars_helper!(html_attr_escape: |s: String| htmlize::escape_attribute(s)); + handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s)); + + hb.register_helper("html-escape", Box::new(html_escape)); + hb.register_helper("html-attr-escape", Box::new(html_attr_escape)); + hb.register_helper("url-encode", Box::new(html_attr_escape)); + for (key, fulltext) in INTERNAL_THEMES { log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`"); hb.register_template_string(key, fulltext).unwrap(); @@ -92,7 +101,7 @@ impl State { if let Ok(themes_dir) = var("LFME_THEME_DIR") { log::info!(target: "lfm::config::theme", "Registering theme dir `{themes_dir}`"); - hb.register_templates_directory(&var("LFME_THEME_EXT").unwrap_or_else(|_| "hbs".into()), themes_dir).unwrap(); + hb.register_templates_directory(&var("LFME_THEME_EXT").unwrap_or_else(|_| ".hbs".into()), themes_dir).unwrap(); } hb @@ -118,8 +127,8 @@ impl State { } } }, - default_refresh: default_refresh + Duration::from_secs(5), - whitelist_refresh: whitelist_refresh + Duration::from_secs(5) + default_refresh: default_refresh + Duration::from_secs(1), + whitelist_refresh: whitelist_refresh + Duration::from_secs(1) }) } diff --git a/src/ctx.rs b/src/ctx.rs index 6beb634..63b54ff 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,9 +1,11 @@ use reqwest::StatusCode; use super::deserialize as de; use std::sync::Arc; +use std::collections::BTreeMap; pub mod model { use std::sync::Arc; + use std::collections::BTreeMap; /// The theme representation of a user. #[derive(serde::Serialize, Debug)] @@ -73,14 +75,15 @@ pub mod model { /// Contains text explaining a potential error. Error { error: &'static str }, /// Contains data about a user and what they're listening to. - Data { user: User, scrobble: Scrobble } + Data { user: User, scrobble: Scrobble, query: BTreeMap } } } #[derive(Debug)] pub struct ResponseCtx(pub model::Data, pub StatusCode); -impl From, (StatusCode, &'static str)>> for ResponseCtx { - fn from(v: Result, (StatusCode, &'static str)>) -> ResponseCtx { +impl From<(Result, (StatusCode, &'static str)>, BTreeMap)> for ResponseCtx { + fn from(v: (Result, (StatusCode, &'static str)>, BTreeMap)) -> ResponseCtx { + let (v, q) = v; match v { Ok(a) => { let (user, track) = a.as_ref(); @@ -111,7 +114,8 @@ impl From, (StatusCode, &'static str)>> for Re now_playing: track.attr.nowplaying, url: track.url.clone(), loved: track.loved.unwrap_or(false) - } + }, + query: q }, StatusCode::OK) }, Err((status, error)) => { diff --git a/src/main.rs b/src/main.rs index 729482f..b787049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use dotenv::var; use log::LevelFilter; +use std::collections::BTreeMap; use std::fs::File; use std::sync::Arc; use lfm_embed::{STATE, ResponseCtx}; @@ -10,7 +11,9 @@ use warp::Filter; #[derive(serde::Deserialize, Debug)] struct UserQuery { #[serde(default)] - theme: Option> + theme: Option>, + #[serde(flatten)] + rest: BTreeMap } #[tokio::main] @@ -35,17 +38,20 @@ async fn main() { let user = warp::path!("user" / String) .and(warp::query::()) .then(|s, q: UserQuery| async move { - log::info!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}"); + log::debug!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}"); let (ctx, dur) = STATE.get_userinfo(&s).await; - let ResponseCtx(data, status) = ctx.into(); - - let theme = q.theme.unwrap_or_else(|| STATE.default_theme()); + let ResponseCtx(mut data, status) = (ctx, q.rest).into(); + + let theme = q.theme.filter(|a| STATE.handlebars().has_template(&a)).unwrap_or_else(|| STATE.default_theme()); + log::debug!(target: "lfm::server::user", "Using theme {theme}"); warp::reply::with_header( - warp::reply::with_status( - warp::reply::html( - STATE.handlebars().render(&theme, &data).unwrap() - ), status - ), "Refresh", dur.as_secs() + warp::reply::with_header( + warp::reply::with_status( + warp::reply::html( + STATE.handlebars().render(&theme, &data).unwrap() + ), status + ), "Refresh", dur.as_secs() + ), "X-Selected-Theme", theme.as_ref() ) }); diff --git a/src/themes/plain.hbs b/src/themes/plain.hbs new file mode 100644 index 0000000..cbe8948 --- /dev/null +++ b/src/themes/plain.hbs @@ -0,0 +1,34 @@ + + + + + {{#if error}}Error!{{else}}@{{user.name}}'s Last.fm Stats{{/if}} + + + + {{#if error}} +

{{error}}

+ {{else}} + +

@{{user.name}}{{#if scrobble.now_playing}} + is scrobbling{{else}}'s last scrobble was + {{/if}} +

+ {{scrobble.name}} +

+ {{#if scrobble.album}}from {{scrobble.album}}{{/if}} +

+ by {{scrobble.artist.name}}. +

+ {{/if}} + + \ No newline at end of file diff --git a/themes/test.css b/themes/test.css deleted file mode 100644 index e69de29..0000000 -- 2.45.2