feat: todo's, add image cache control

This commit is contained in:
2026-03-20 21:47:48 +01:00
parent 12388a9908
commit 0dbc9dcbc1
8 changed files with 143 additions and 38 deletions

21
Cargo.lock generated
View File

@@ -457,6 +457,26 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.10.0"
@@ -1155,6 +1175,7 @@ dependencies = [
"anstyle", "anstyle",
"anyhow", "anyhow",
"clap", "clap",
"const-str",
"libc", "libc",
"magic", "magic",
"moka", "moka",

View File

@@ -34,6 +34,7 @@ thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
toml = "1.0.3" toml = "1.0.3"
users = "0.11.0" users = "0.11.0"
const-str = { version = "1.1.0", features = ["proc"] }
[lints.clippy] [lints.clippy]
cargo = { level = "warn", priority = -1 } cargo = { level = "warn", priority = -1 }

View File

@@ -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 /// 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: &[u8] = include_bytes!("../assets/default-pfp.png");
pub const DEFAULT_USER_PFP_MIME: &str = "image/png; charset=binary"; 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); 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 /// Paths relative to a user's home to search profile pictures in
pub const USER_PFP_PATHS: &[&str] = &[ pub const USER_PFP_PATHS: &[&str] = &[
@@ -25,3 +35,7 @@ pub const USER_PFP_PATHS: &[&str] = &[
"logo.jpg", "logo.jpg",
".face", ".face",
]; ];
pub mod web_scopes {
pub const IMAGES: &str = "/image";
}

View File

@@ -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 #![allow(clippy::future_not_send)] // will get to fix these later
//! # About, Licensing and More //! # About, Licensing and More
@@ -24,7 +24,7 @@ use std::fs::File;
use clap::Parser; use clap::Parser;
use crate::ext::FileExt; use crate::ext::FileExt as _;
pub mod args; pub mod args;
pub mod auth; pub mod auth;
@@ -33,6 +33,7 @@ pub mod consts;
pub mod ext; pub mod ext;
pub mod serdes; pub mod serdes;
pub mod server; pub mod server;
pub mod utils;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {

View File

@@ -14,13 +14,12 @@ pub struct AppState {
/// Leaks memory for the sake of not atomic'ing all over. /// Leaks memory for the sake of not atomic'ing all over.
#[expect(clippy::missing_errors_doc)] #[expect(clippy::missing_errors_doc)]
pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> io::Result<()> { 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 config = Box::leak(Box::new(config));
let cache = caches::AppCache::new( let cache = caches::AppCache::new(
config.unix.magic_paths.clone(), config.unix.magic_paths.clone(),
// ["/usr/share/file/misc/magic"]
// .try_into()
// .expect("To be valid DB paths"),
&config.unix.groups, &config.unix.groups,
); );
@@ -38,8 +37,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) ->
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.app_data(web::Data::new(app)) .app_data(web::Data::new(app))
.service(services::get_image) .service(services::images::make_scope(ws::IMAGES))
// .service(factory)
}) })
.bind(&app.config.server.listen)? .bind(&app.config.server.listen)?
.run() .run()

View File

@@ -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<String>,
) -> 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<String>,
) -> web::Either<Redirect, HttpResponse> {
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())),
)
},
)
}

View File

@@ -1,30 +1 @@
use actix_web::{HttpResponse, get, web}; pub mod images;
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<String>,
) -> 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)
}

39
src/utils.rs Normal file
View File

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