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>