M .gitignore => .gitignore +1 -0
@@ 1,3 1,4 @@
+/.env
/Cargo.lock
/target
*~
M Cargo.toml => Cargo.toml +6 -0
@@ 6,5 6,11 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
+dotenv = "0.15.0"
+duration-str = "0.5.1"
+env_logger = "0.10.0"
+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"] }
A src/README.md => src/README.md +69 -0
@@ 0,0 1,69 @@
+# `lfm_embed`
+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.
+
+## Usage
+`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/<last.fm username>` 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.
+
+### `LMFE_API_KEY` (Required)
+Your last.fm API key. You'll need to create one [here](https://www.last.fm/api/account/create) for self-hosting.
+
+### `LFME_WHITELIST_MODE` (Default: open)
+The following(case-insensitive) values are supported:
+- `open`: Allow requests for all users.
+- `exclusive`: Only allow requests for users in `LFME_WHITELIST`, returning HTTP 403 for all others. `LFME_WHITELIST` _must_ be set if this mode is enabled.
+
+If the user requested has their listen history private, a 403 will be returned.
+
+### `LFME_WHITELIST` (Default: "")
+This is expected to be a sequence of comma-separated usernames.
+Leading/trailing whitespace will be stripped, and unicode is fully supported.
+
+### `LFME_WHITELIST_REFRESH` (Default: "1m")
+The amount of time to cache whitelisted user info for.
+It is interpreted as a sequence of `<num><suffix>` values, where `num` is a positive integer,
+and `suffix` is one of `y`,`mon`,`w`,`d`,`h`,`m`,`s`, `ms`, `µs`, or `ns`, each of which
+corresponds to a self-explanatory unit of time.
+For most practical applications, one should only use `m` and `s`, as caching your current listen for more than that time has the potential to omit most songs.
+Parsing is delegated to the [`duration_str`](https://docs.rs/duration-str/latest/duration_str/) crate, and further info may be found there.
+
+### `LFME_DEFAULT_REFRESH` (Default: "5m")
+The amount of time to cache non-whitelisted user info for.
+See `LFME_WHITELIST_REFRESH` for more info.
+
+### `LFME_PORT` (Default: 9999)
+The port to serve on locally.
+
+### `LFME_THEMES_DIR`
+If set, must be a valid path to a directory containing CSS files.
+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.
+
+### `LFME_LOG_LEVEL` (Default: Warn)
+The loglevel. This is actually parsed as an [`env_logger`](https://docs.rs/env_logger/latest/env_logger) filter string.
+Read the docs for more info.
+
+### `LFME_LOG_FILE`
+If set, logs will be written to the specified file. Otherwise, logs are written to `stderr`.
+
+## Example Configuration
+```dotenv
+LFME_API_KEY=0123456789abcdef0123456789abcdef
+LFME_LOG_LEVEL=error
+LFME_PORT=3000
+
+LFME_WHITELIST_REFRESH=30s
+LFME_WHITELIST_MODE=exclusive
+LFME_WHITELIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345
+```
M src/cache.rs => src/cache.rs +2 -2
@@ 1,5 1,5 @@
use std::{future::Future, time::*, collections::HashMap, hash::Hash};
-
+#[derive(Debug)]
pub struct AsyncCache<K, V, F> {
func: F,
cache: HashMap<K, (Instant, V)>,
@@ 12,7 12,7 @@ where
K: Hash + PartialEq + Eq + Clone,
Fut: Future<Output = Result<V, &'static str>>
{
- pub fn new(interval: Duration, mut func: F) -> Self {
+ pub fn new(interval: Duration, func: F) -> Self {
Self{
cache: HashMap::new(),
interval, func
A src/config.rs => src/config.rs +96 -0
@@ 0,0 1,96 @@
+use std::collections::{HashMap, HashSet};
+use std::error::Error;
+use std::sync::LazyLock;
+use std::sync::Arc;
+use std::future::Future;
+use std::pin::Pin;
+use std::fs;
+use std::time::*;
+use duration_str as ds;
+
+use super::cache::AsyncCache;
+use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User};
+
+use reqwest::Client;
+use dotenv::var;
+use tokio::sync::RwLock;
+
+pub static STATE: LazyLock<Arc<State>> = LazyLock::new(|| {
+ State::new()
+});
+
+fn getter(username: &String) -> Pin<Box<dyn Future<Output = Result<(User, Track), &'static str>>>> {
+ Box::pin(async{Err("nope")})
+}
+
+type Cache = Arc<RwLock<AsyncCache<String, (User, Track), fn(&String) -> Pin<Box<dyn Future<Output = Result<(User, Track), &'static str>>>>>>>;
+#[derive(Debug)]
+enum Whitelist {
+ Exclusive{cache: Cache, whitelist: HashSet<String>},
+ Open{default_cache: Cache, whitelist_cache: Cache, whitelist: HashSet<String>}
+}
+#[derive(Debug)]
+pub struct State {
+ api_key: Arc<str>,
+ whitelist: Whitelist,
+ port: u16,
+ custom_themes: HashMap<String, Arc<str>>,
+ send_refresh_header: bool
+}
+
+impl State {
+ fn new() -> Arc<Self> {
+ Arc::new(State {
+ api_key: var("LFME_API_KEY").expect("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_SET_HEADER").map(|h| &h == "1").unwrap_or(false),
+
+ custom_themes: {
+ if let Ok(themes_dir) = var("LFME_THEMES_DIR") {
+ fs::read_dir(themes_dir).expect("error reading LFME_THEMES_DIR")
+ .map(|a| a.expect("error reading LFME_THEMES_DIR"))
+ .filter_map(|a| {
+ let path = a.path();
+ if fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false) &&
+ path.extension() == Some("css".as_ref()) {
+ Some((path.file_stem().unwrap().to_str().expect("bad filename").to_string(), fs::read_to_string(&path).expect("couldn't read theme CSS").into()))
+ }
+ else { None }
+ })
+ .collect()
+ }
+ else { HashMap::new() }
+ },
+
+ whitelist: {
+ let cache_from_var = |v: &str, d: u64| -> Cache {
+ let refresh = var(v).map(|r| ds::parse(&r).expect("bad duration string")).unwrap_or_else(|_| Duration::from_secs(d));
+ Arc::new(RwLock::new(AsyncCache::new(refresh, getter as _)))
+ };
+ let default_cache = || cache_from_var("LFME_DEFAULT_REFRESH", 300);
+ let whitelist_cache = || cache_from_var("LFME_WHITELIST_REFRESH", 60);
+
+ let load_whitelist = || -> Option<HashSet<String>> {
+ var("LFME_WHITELIST").ok().map(
+ |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: default_cache(), whitelist_cache: whitelist_cache(), whitelist: load_whitelist().unwrap_or_default()}
+ },
+ "exclusive" => {
+ Whitelist::Exclusive{cache: whitelist_cache(), whitelist: load_whitelist().expect("LFME_WHITELIST not set, unable to serve anyone")}
+ },
+ m => {
+ panic!("Bad whitelist mode: `{m}`");
+ }
+ }
+ }
+ })
+ }
+
+ pub fn api_key(&self) -> Arc<str> { self.api_key.clone() }
+ pub fn port(&self) -> u16 { self.port }
+}
M src/lib.rs => src/lib.rs +6 -3
@@ 1,4 1,7 @@
-#![feature(entry_insert)]
+#![feature(lazy_cell)]
-mod deserialize;
-mod cache;
+pub mod deserialize;
+pub mod cache;
+pub mod config;
+
+pub use config::STATE;
M src/main.rs => src/main.rs +25 -1
@@ 1,3 1,27 @@
+#![feature(lazy_cell)]
+
+use dotenv::var;
+use log::LevelFilter;
+use std::fs::File;
+use lfm_embed::STATE;
+
fn main() {
- println!("Hello, world!");
+ env_logger::Builder::new()
+ .filter_level(LevelFilter::Warn)
+ .parse_filters(&var("LFME_LOG_LEVEL").unwrap_or_default())
+ .target(
+ var("LFME_LOG_FILE").ok()
+ .map(
+ |f| env_logger::Target::Pipe(
+ Box::new(File::options()
+ .append(true)
+ .open(f)
+ .expect("couldn't open LFME_LOG_FILE")))
+ )
+ .unwrap_or(env_logger::Target::Stderr)
+ ).init();
+
+ std::sync::LazyLock::force(&STATE);
+
+
}
A themes/test.css => themes/test.css +0 -0