diff --git a/Cargo.lock b/Cargo.lock index 80b8d4a..b15285a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" +dependencies = [ + "const-str-proc-macro", +] + +[[package]] +name = "const-str-proc-macro" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7913ec01ed98b697e62f8d3fd63c86dc6cccaf983c7eebc64d0e563b0ad9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1155,6 +1175,7 @@ dependencies = [ "anstyle", "anyhow", "clap", + "const-str", "libc", "magic", "moka", diff --git a/Cargo.toml b/Cargo.toml index e5dc4e3..eff92cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["full"] } toml = "1.0.3" users = "0.11.0" +const-str = { version = "1.1.0", features = ["proc"] } [lints.clippy] cargo = { level = "warn", priority = -1 } diff --git a/src/consts.rs b/src/consts.rs index 7491c54..b7ba5d4 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -11,9 +11,19 @@ pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high /// Default user image to use if users have none pub const DEFAULT_USER_PFP: &[u8] = include_bytes!("../assets/default-pfp.png"); pub const DEFAULT_USER_PFP_MIME: &str = "image/png; charset=binary"; +/// Basically [`USER_CACHES_HEADER`] but much longer for the [`DEFAULT_USER_PFP`] +pub const DEFAULT_USER_PFP_CACHES_HEADER: &str = + crate::utils::web::make_static_cache_header!(Duration::from_hours(2), Duration::from_days(1)); -/// TTL for cached user information +/// TTL for cached user information, both in server memory and http's cache-control pub const USER_CACHES_TTL: Duration = Duration::from_mins(15); +/// TTL for cached user information in CDNs, (stale-while-revalidate), only for non-urgent info +/// (e.g. pfp's) +pub const USER_CDN_CACHES_TTL: Duration = Duration::from_hours(1); +/// Compile-time formatted string for the cache-control header of [`USER_CACHES_TTL`] and +/// [`USER_CDN_CACHES_TTL`] +pub const USER_CACHES_HEADER: &str = + crate::utils::web::make_static_cache_header!(USER_CACHES_TTL, USER_CDN_CACHES_TTL); /// Paths relative to a user's home to search profile pictures in pub const USER_PFP_PATHS: &[&str] = &[ @@ -25,3 +35,7 @@ pub const USER_PFP_PATHS: &[&str] = &[ "logo.jpg", ".face", ]; + +pub mod web_scopes { + pub const IMAGES: &str = "/image"; +} diff --git a/src/main.rs b/src/main.rs index ba88db9..d60f4c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![feature(seek_stream_len, once_cell_try)] +#![feature(seek_stream_len, once_cell_try, decl_macro, duration_constructors)] #![allow(clippy::future_not_send)] // will get to fix these later //! # About, Licensing and More @@ -24,7 +24,7 @@ use std::fs::File; use clap::Parser; -use crate::ext::FileExt; +use crate::ext::FileExt as _; pub mod args; pub mod auth; @@ -33,6 +33,7 @@ pub mod consts; pub mod ext; pub mod serdes; pub mod server; +pub mod utils; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/src/server/mod.rs b/src/server/mod.rs index e0061b7..889ddf5 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -14,13 +14,12 @@ pub struct AppState { /// Leaks memory for the sake of not atomic'ing all over. #[expect(clippy::missing_errors_doc)] pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> io::Result<()> { + use crate::consts::web_scopes as ws; + let config = Box::leak(Box::new(config)); let cache = caches::AppCache::new( config.unix.magic_paths.clone(), - // ["/usr/share/file/misc/magic"] - // .try_into() - // .expect("To be valid DB paths"), &config.unix.groups, ); @@ -38,8 +37,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> HttpServer::new(move || { App::new() .app_data(web::Data::new(app)) - .service(services::get_image) - // .service(factory) + .service(services::images::make_scope(ws::IMAGES)) }) .bind(&app.config.server.listen)? .run() diff --git a/src/server/services/images.rs b/src/server/services/images.rs new file mode 100644 index 0000000..fa7873a --- /dev/null +++ b/src/server/services/images.rs @@ -0,0 +1,60 @@ +//! Scope for the image get backend. +//! - `/`: Gives the default image. +//! - `/{username}`: Gives username's pfp or redirects to the default image's path (for better +//! cache control) if there's no image. +//! +//! Must be scoped at [`ws::IMAGES`] + +// TODO: etags + +use actix_web::{ + HttpResponse, get, + http::header, + web::{self, Redirect}, +}; + +use crate::{ + consts::{self, web_scopes as ws}, + server, +}; + +#[must_use] +pub fn make_scope(path: &str) -> actix_web::Scope { + web::scope(path) + .service(get_default_image) + .service(get_image) +} + +#[get("/")] +async fn get_default_image( + _data: web::Data<&server::AppState>, + _username: web::Path, +) -> HttpResponse { + HttpResponse::Ok() + .insert_header(( + header::CACHE_CONTROL, + consts::DEFAULT_USER_PFP_CACHES_HEADER, + )) + .content_type(consts::DEFAULT_USER_PFP_MIME) + .body(web::Bytes::from_static(consts::DEFAULT_USER_PFP)) +} + +#[get("/{username}")] +async fn get_image( + data: web::Data<&server::AppState>, + username: web::Path, +) -> web::Either { + let cached_pfp = data.cache.get_pfp(username.to_string()).await; + + cached_pfp.as_ref().map_or_else( + || web::Either::Left(web::Redirect::to(ws::IMAGES).temporary()), + |img| { + web::Either::Right( + HttpResponse::Ok() + .insert_header((header::CACHE_CONTROL, consts::USER_CACHES_HEADER)) + .content_type(img.mime.as_ref()) + .body(web::Bytes::copy_from_slice(img.bytes.as_ref())), + ) + }, + ) +} diff --git a/src/server/services/mod.rs b/src/server/services/mod.rs index 89bb951..8f0da9f 100644 --- a/src/server/services/mod.rs +++ b/src/server/services/mod.rs @@ -1,30 +1 @@ -use actix_web::{HttpResponse, get, web}; - -use crate::{consts, server}; - -// TODO: cache control -// TODO: etags -// TODO: canonical redirect for default image and better cache control -#[get("/image/{username}")] -pub async fn get_image( - data: web::Data<&server::AppState>, - username: web::Path, -) -> HttpResponse { - let cached_pfp = data.cache.get_pfp(username.to_string()).await; - let (mime, bytes) = cached_pfp.as_ref().map_or_else( - || { - ( - consts::DEFAULT_USER_PFP_MIME, - web::Bytes::from_static(consts::DEFAULT_USER_PFP), - ) - }, - |img| { - ( - img.mime.as_ref(), - web::Bytes::copy_from_slice(img.bytes.as_ref()), - ) - }, - ); - - HttpResponse::Ok().content_type(mime).body(bytes) -} +pub mod images; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..3afc129 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,39 @@ +pub mod web { + /// Macro shenanigans to format a str at compile time + pub macro make_static_cache_header($max_age:expr, $stale_revalidate:expr) {{ + // type guards lmfao + const _: ::std::time::Duration = $max_age; + const _: ::std::time::Duration = $stale_revalidate; + + // hopefully ident encapsulation will save my ass here + const MAX_AGE: u64 = $max_age.as_secs(); + const STALE_REVALIDATE: u64 = $stale_revalidate.as_secs(); + + const_str::format!( + "public, max-age={}, stale-while-revalidate={}", + MAX_AGE, + STALE_REVALIDATE + ) + }} + + #[cfg(test)] + mod test { + use std::time::Duration; + + #[test] + fn const_concat_worked() { + const MA: Duration = Duration::from_mins(15); + const SR: Duration = Duration::from_hours(1); + + const NAME: &str = super::make_static_cache_header!(MA, SR); + assert_eq!( + NAME, + format!( + "public, max-age={}, stale-while-revalidate={}", + MA.as_secs(), + SR.as_secs() + ) + ); + } + } +}