~aleteoryx/lfm_embed

7e4da5f0de16c35ce304620bd37b08d57ff46858 — alyx 8 months ago b53cb29
Custom font support complete
6 files changed, 131 insertions(+), 61 deletions(-)

M TODO.md
M src/config.rs
M src/ctx.rs
M src/font.rs
M src/main.rs
M src/themes/plain.hbs
M TODO.md => TODO.md +38 -24
@@ 1,32 1,10 @@
# Future plans for this project:


## Custom Fonts

For ease-of-inclusion this should probably be handled by internal code, and provided as a set of properties on `lfm_embed::ctx::model::Data`.

There are 3 main kinds of fonts we should support.
- User-hosted fonts. These would be loaded directly from a link to a TTF,WOFF2,etc font file.
- Google fonts fonts. In theory, we'd proxy these, but that's not 100% necessary.
- Named fonts. e.x. 'serif', 'sans-serif', 'monospace'. Browser will handle these.

There is no good way to expose a typed enum with the current wizard UI, and the resulting UI from this could allow added user flexibility.

### Query Parameters

- `?font=foo` corresponds directly with `* { font-family: 'foo' }` in the CSS.
- `?font=foo&include-font=https://example.com/font.otf` will additionally append `@font-face { font-family: 'foo' src: url('https://example.com/font.otf') }`.
- `?include-stylesheet` allows for general stylesheet inclusion. It may be specified N times.

### Context Members

- `font: object` null if no font params specified.
- `font.name: string` contains `?font`
- `font.additional_css` will contain the `?include-font` css, as well as the proxied contents of `?include-stylesheet`.


## Move more things internal

Untouched.

Of the crates currently relied on, the following appear too feature-packed should be replaced with simpler internal code, to reduce binary size.

- duration-str


@@ 35,6 13,8 @@ Of the crates currently relied on, the following appear too feature-packed shoul

## Watermarking

Untouched.

Support for a limited form of (optional) watermaking. It would be up to the themes to include it.

### Query Parameters


@@ 64,12 44,16 @@ A warning should be output on startup.

## Additional Helpers

Untouched.

- `(range Number start: Number = 0): Number` Generates values from start to the main argument, exclusive.
- `(random): Number` Generates a random float.


## Additional Services to Support

Untouched.

This should be as transparent as possible to the theme and end-user.

### Services


@@ 94,3 78,33 @@ This should be as transparent as possible to the theme and end-user.
- `user.service` never not null
- `user.service.type` `(audioscrobbler|listenbrainz|gnukebox)`
- `user.service.domain` the string for the service's domain.


## Error Cache

Untouched.

Add support to `lfm_embed::cache::AsyncCache` for caching `Err` values with a different lifetime from `Ok` values.


# Completed Features


## Custom Fonts

Implemented!

### Query Parameters

Each of these are mutually exclusive, and take priority over eachother in the order listed below.
The highest one detected will be handled and others, if present, will be silently ignored.

- `?google_font=` the name of a google font to try loading. If it exists, the `fonts.googleapis.com` stylesheet will end up in `font.css`. The font name will end up in `font.name`.
- `?include_font=` the URL to a font to include. `font.css` will be a generated `@font-family` declaration. A provided name will be in `font.name`.
- `?font=` the name of a browser-native or system font. This will only set `font.name`.

### Context Members

- `font: object` null if no font overrides are present.
- `font.name: string` All text elements should have their font set to this.
- `font.css` If present, it should be placed inside a `<style>` tag somewhere. For legibility of generated HTML, preferably its own tag.

M src/config.rs => src/config.rs +61 -22
@@ 20,18 20,18 @@ pub static STATE: LazyLock<Arc<State>> = LazyLock::new(|| {
    State::new()
});

fn getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>> {
fn user_getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>> {
    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))
        let userreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getInfo&format=json&user={username}&api_key={}", STATE.lastfm_api_key))
            .send().await
            .map_err(|e| {log::error!("Failed to get info for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
        if userreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }

        let userinfo = userreq.json::<GetUserInfo>().await
            .map_err(|e| {log::error!("Couldn't parse user.getInfo for `{username}`: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse user.getInfo!")})?.user;
        
        let tracksreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&format=json&extended=1&limit=1&user={username}&api_key={}", STATE.api_key))

        let tracksreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&format=json&extended=1&limit=1&user={username}&api_key={}", STATE.lastfm_api_key))
            .send().await
            .map_err(|e| {log::error!("Failed to get tracks for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
        if tracksreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }


@@ 44,23 44,55 @@ fn getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, T
        Ok(Arc::new((userinfo, tracksinfo)))
    })
}
type Getter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>>;
type Cache = Arc<RwLock<AsyncCache<String, Arc<(User, Track)>, Getter>>>;

fn font_getter(fontname: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<str>, (StatusCode, &'static str)>> + Send + Sync)>> {
  let fontname = urlencoding::encode(fontname.as_ref()).to_string();
  Box::pin(async move {
    let Some(google_api_key) = STATE.google_api_key.clone()
    else {
      unreachable!();
    };

    let fontreq = STATE.http.get(format!("https://www.googleapis.com/webfonts/v1/webfonts?key={}&family={fontname}", google_api_key))
      .send().await
      .map_err(|e| {log::error!("Failed to get info for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to Google Fonts!")})?;
    if fontreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "Font does not exist!")); }
    if fontreq.status() == StatusCode::FORBIDDEN {
      log::error!("Invalid Google API key in config!");
      return Err((StatusCode::SERVICE_UNAVAILABLE, "This instance is not configured to support Google Fonts properly, please use a different font."));
    }

    let cssreq = STATE.http.get(format!("https://fonts.googleapis.com/css2?family={fontname}"))
      .send().await
      .map_err(|e| {log::error!("Failed to get CSS for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't download font CSS!")})?;

    Ok(cssreq.text().await.unwrap().into())
  })
}

type UserGetter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>>;
type UserCache = Arc<RwLock<AsyncCache<String, Arc<(User, Track)>, UserGetter>>>;
type FontGetter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<Arc<str>, (StatusCode, &'static str)>> + Send + Sync)>>;
type FontCache = Arc<RwLock<AsyncCache<String, Arc<str>, FontGetter>>>;
#[derive(Debug)]
enum Whitelist {
    Exclusive{cache: Cache, whitelist: HashSet<String>},
    Open{default_cache: Cache, whitelist_cache: Cache, whitelist: HashSet<String>}
    Exclusive{cache: UserCache, whitelist: HashSet<String>},
    Open{default_cache: UserCache, whitelist_cache: UserCache, whitelist: HashSet<String>}
}
#[derive(Debug)]
pub struct State {
    api_key: Arc<str>,
    lastfm_api_key: Arc<str>,
    google_api_key: Option<Arc<str>>,
    whitelist: Whitelist,
    port: u16,

    handlebars: Handlebars<'static>,
    default_theme: Arc<str>,
    send_refresh_header: bool,
    http: Client,

    google_fonts_cache: FontCache,

    default_refresh: Duration,
    whitelist_refresh: Duration,
}


@@ 68,33 100,33 @@ pub struct State {
impl State {
    fn new() -> Arc<Self> {
        let duration_from_var = |v: &str, d: u64| -> Duration {var(v).map(|r| ds::parse(&r).expect("bad duration string")).unwrap_or_else(|_| Duration::from_secs(d))};
        let cache_from_duration = |d: Duration| -> Cache {
            Arc::new(RwLock::new(AsyncCache::new(d, getter as Getter)))
        let user_cache_from_duration = |d: Duration| -> UserCache {
            Arc::new(RwLock::new(AsyncCache::new(d, user_getter as UserGetter)))
        };
        let default_refresh = duration_from_var("LFME_DEFAULT_REFRESH", 300);
        let whitelist_refresh = duration_from_var("LFME_WHITELIST_REFRESH", 60);
        let default_cache = cache_from_duration(default_refresh);
        let whitelist_cache = cache_from_duration(whitelist_refresh);
        let default_cache = user_cache_from_duration(default_refresh);
        let whitelist_cache = user_cache_from_duration(whitelist_refresh);

        Arc::new(State {
            api_key: var("LFME_API_KEY").expect("API key must be set").into(),
            lastfm_api_key: var("LFME_LASTFM_API_KEY").expect("last.fm API key must be set").into(),
            port: var("LFME_PORT").map(|p| p.parse().expect("cannot parse as a port number")).unwrap_or(9999),
            send_refresh_header: !var("LFME_NO_REFRESH").map(|h| &h == "1").unwrap_or(false),
            http: Client::builder().https_only(true).user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))).build().unwrap(),
            

            handlebars: {
                let mut hb = Handlebars::new();
                

                handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s));

                hb.register_helper("url-encode", Box::new(url_encode));
                

                for (key, fulltext) in INTERNAL_THEMES {
                    log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`");
                    hb.register_template_string(key, fulltext).unwrap();
                }
                hb.set_dev_mode(var("LFME_THEME_DEV").map(|h| &h == "1").unwrap_or(false));
                

                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();


@@ 103,14 135,17 @@ impl State {
                hb
            },
            default_theme: var("LFME_THEME_DEFAULT").map(Into::into).unwrap_or_else(|_| "plain".into()),
            

            google_api_key: var("LFME_GOOGLE_API_KEY").map(Into::into).ok(),
            google_fonts_cache: Arc::new(RwLock::new(AsyncCache::new(Duration::from_secs(86400), font_getter as FontGetter))),

            whitelist: {
                let load_whitelist = || -> Option<HashSet<String>> {
                    var("LFME_WHITELIST").ok().map(
                        |w| w.split(",").map(|s| s.trim().to_string()).collect()                           
                        |w| w.split(",").map(|s| s.trim().to_string()).collect()
                    )
                };
                

                match var("LFME_WHITELIST_MODE").map(|m| m.to_ascii_lowercase()).unwrap_or_else(|_| "open".into()).as_str() {
                    "open" => {
                        Whitelist::Open{default_cache, whitelist_cache, whitelist: load_whitelist().unwrap_or_default()}


@@ 127,9 162,13 @@ impl State {
            whitelist_refresh: whitelist_refresh + Duration::from_secs(1)
        })
    }
    

    pub fn port(&self) -> u16 { self.port }
    pub fn send_refresh_header(&self) -> bool { self.send_refresh_header }
    pub async fn get_fontinfo(&self, font: &String) -> Result<Arc<str>, (StatusCode, &'static str)> {
      self.google_fonts_cache.write().await.get_owned(font).await
    }
    pub fn has_google_api_key(&self) -> bool { self.google_api_key.is_some() }
    pub async fn get_userinfo(&self, user: &String) -> (Result<Arc<(User, Track)>, (StatusCode, &'static str)>, Duration) {
        match &self.whitelist {
            Whitelist::Open{default_cache, whitelist_cache, whitelist} => {

M src/ctx.rs => src/ctx.rs +26 -9
@@ 3,6 3,7 @@ use super::deserialize as de;
use std::sync::Arc;
use std::collections::BTreeMap;
use super::font::FontQuery;
use super::config::STATE;

pub mod model {
    use std::sync::Arc;


@@ 70,7 71,7 @@ pub mod model {
    #[derive(serde::Serialize, Debug)]
    #[serde(untagged)]
    pub enum Font {
        Url { css: Arc<str> },
        External { css: Arc<str>, name: Arc<str> },
        Name { name: Arc<str> },
    }



@@ 89,10 90,9 @@ pub mod model {
#[derive(Debug)]
pub struct ResponseCtx(pub model::Data, pub StatusCode);

impl From<(Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, Option<FontQuery>, BTreeMap<String, String>)> for ResponseCtx {
    fn from(v: (Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, Option<FontQuery>, BTreeMap<String, String>)) -> ResponseCtx {
        let (v, f, q) = v;
        match v {
impl ResponseCtx {
    pub async fn create(api_result: Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, font_query: Option<FontQuery>, query: BTreeMap<String, String>) -> ResponseCtx {
        match api_result {
            Ok(a) => {
                let (user, track) = a.as_ref();
                ResponseCtx(model::Data::Data {


@@ 123,12 123,29 @@ impl From<(Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, Optio
                        url: track.url.clone(),
                        loved: track.loved.unwrap_or(false)
                    },
                    font: match f {
                        Some(FontQuery { font: Some(s), include_font: None }) => Some(model::Font::Name { name: s }),
                        Some(FontQuery { include_font: Some(s), .. }) => Some(model::Font::Url { css: format!("@font-face {{ font-family: 'included_font'; src: url('{}'); }}", s.replace("\\", "\\\\").replace("'", "\\'")).into() }),
                    font: match font_query {
                        Some(FontQuery { google_font: Some(f), .. }) if STATE.has_google_api_key() => {
                          let css = match STATE.get_fontinfo(&f.to_string()).await {
                            Ok(css) => css,
                            Err((status, error)) => { return ResponseCtx(model::Data::Error {error}, status); }
                          };
                          Some(model::Font::External {
                              css,
                              name: f
                          })
                        },
                        Some(FontQuery { include_font: Some(f), .. }) => Some(
                          model::Font::External {
                            css: format!(
                              "@font-face {{ font-family: 'included_font'; src: url('{}'); }}",
                              f.replace("\\", "\\\\")
                               .replace("'", "\\'")).into(),
                            name: "included_font".into()
                        }),
                        Some(FontQuery { font: Some(s), .. }) => Some(model::Font::Name { name: s }),
                        _ => None,
                    },
                    query: q
                    query
                }, StatusCode::OK)
            },
            Err((status, error)) => {

M src/font.rs => src/font.rs +1 -0
@@ 6,5 6,6 @@ use std::sync::Arc;
pub struct FontQuery {
  pub font: Option<Arc<str>>,
  pub include_font: Option<Arc<str>>,
  pub google_font: Option<Arc<str>>,
//  pub small_font: Option<()>
}

M src/main.rs => src/main.rs +2 -1
@@ 10,6 10,7 @@ use lfm_embed::font::FontQuery;
use warp::Filter;

#[derive(serde::Deserialize, Debug)]
#[serde(rename = "kebab-case")]
struct UserQuery {
    #[serde(default)]
    theme: Option<Arc<str>>,


@@ 44,7 45,7 @@ async fn main() {
        .then(|s, q: UserQuery| async move {
            log::debug!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}");
            let (ctx, dur) = STATE.get_userinfo(&s).await;
            let ResponseCtx(mut data, status) = (ctx, q.font, q.rest).into();
            let ResponseCtx(mut data, status) = ResponseCtx::create(ctx, q.font, q.rest).await;
            
            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}");

M src/themes/plain.hbs => src/themes/plain.hbs +3 -5
@@ 3,6 3,7 @@
  <head>
    <meta charset="UTF-8"/>
    <title>{{#if error}}Error!{{else}}@{{user.name}}'s Last.fm Stats{{/if}}</title>
    <style>{{#if font.css}}{{{font.css}}}{{/if}}</style>
    <style>
      {{#if (eq query.dark null)}}
        :root { --b: black; color: black; background-color: white; }


@@ 11,12 12,9 @@
        a:visited { color: pink }
        a { color: cyan; }
      {{/if}}
      * { font-size: {{#if font}}17px{{else}}20px{{/if}}; }
      {{#if font.css}}
        {{{font.css}}}
      {{/if}}
      * { font-size: {{#if font}}17px{{else}}{{#if font.scale}}calc(20px * {{font.scale}}){{else}}20px{{/if}}{{/if}}; }
      {{#if (or font.name font.css)}}
        * { font-family: '{{#if font.name}}{{font.name}}{{/if}}{{#if font.url}}included_font{{/if}}' }
        * { font-family: '{{#if font.name}}{{font.name}}{{/if}}' }
      {{/if}}
      p { margin: 0px; padding: 0px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
    </style>