Compare commits
3 Commits
054c3ff1f8
...
a0699273f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
a0699273f1
|
|||
|
12388a9908
|
|||
|
fb62111c7f
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
1702
Cargo.lock
generated
1702
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@@ -1,7 +1,16 @@
|
||||
[package]
|
||||
name = "oidc"
|
||||
description = "OpenID Connect server implementation with unix-like modular authentication backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-2.0-only"
|
||||
repository = "https://git.javalsai.tuxcord.net/tuxcord/authy-oidc"
|
||||
|
||||
keywords = ["oauth", "oauth2", "oidc", "OpenID"]
|
||||
categories = ["web-programming::http-server", "authentication"]
|
||||
|
||||
[profile.release]
|
||||
strip = "symbols" # almost half of binary size smh
|
||||
|
||||
[features]
|
||||
default = ["pamsock"]
|
||||
@@ -12,18 +21,28 @@ pamsock = { version = "0.2.0", git = "https://git.javalsai.tuxcord.net/tuxcord/a
|
||||
"async",
|
||||
], optional = true }
|
||||
|
||||
actix-web = "4.13.0"
|
||||
anstyle = "1.0.13"
|
||||
anyhow = "1.0.102"
|
||||
clap = { version = "4.5.60", features = ["derive"] }
|
||||
libc = "0.2.182"
|
||||
# https://github.com/robo9k/rust-magic/issues/434
|
||||
magic = { version = "0.16.7", git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
|
||||
moka = { version = "0.12.13", features = ["async-lock", "future"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
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]
|
||||
pedantic = { level = "deny", priority = -1 }
|
||||
cargo = { level = "warn", priority = -1 }
|
||||
multiple_crate_versions = { level = "allow" } # otherwise there's too much
|
||||
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
nursery = { level = "deny", priority = -1 }
|
||||
pedantic = { level = "deny", priority = -1 }
|
||||
perf = { level = "deny", priority = -1 }
|
||||
style = { level = "deny", priority = -1 }
|
||||
unwrap_used = "deny"
|
||||
|
||||
8
assets/config.toml
Normal file
8
assets/config.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
# example file to show/test configuration
|
||||
|
||||
[unix]
|
||||
groups = [ "wheel", 0, "users" ]
|
||||
# magic_paths = [ "/usr/share/file/misc/magic" ]
|
||||
|
||||
# [server]
|
||||
# listen = "127.0.0.1:8080"
|
||||
BIN
assets/default-pfp.png
LFS
Normal file
BIN
assets/default-pfp.png
LFS
Normal file
Binary file not shown.
@@ -1,3 +1,5 @@
|
||||
//! Program arguments
|
||||
|
||||
use std::{
|
||||
os::{linux::net::SocketAddrExt, unix::net::SocketAddr},
|
||||
path::PathBuf,
|
||||
@@ -10,9 +12,11 @@ use clap::Parser;
|
||||
#[command(version, about, long_about = None)]
|
||||
#[command(styles = get_clap_styles())]
|
||||
pub struct Args {
|
||||
/// Config file path to parse
|
||||
#[arg(short, default_value = "/etc/authy-oidc.toml")]
|
||||
pub conf: PathBuf,
|
||||
|
||||
/// Pamsock abstract name to connect to
|
||||
#[cfg(feature = "pamsock")]
|
||||
#[arg(long, default_value = "pam")]
|
||||
#[arg(value_parser = parse_as_abstract_name)]
|
||||
|
||||
@@ -6,16 +6,15 @@ use pamsock::prot::ServerResponse;
|
||||
|
||||
use super::AuthenticateResponse;
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<AuthenticateResponse<&'static str>> for ServerResponse {
|
||||
fn into(self) -> AuthenticateResponse<&'static str> {
|
||||
use AuthenticateResponse as AR;
|
||||
impl From<ServerResponse> for AuthenticateResponse<&'static str> {
|
||||
fn from(value: ServerResponse) -> Self {
|
||||
use ServerResponse as SR;
|
||||
|
||||
match self {
|
||||
Self::ServerError => AR::Failed("unknown server error"),
|
||||
Self::Locked => AR::Failed("account locked, too many login attempts"),
|
||||
Self::Failed => AR::Failed("wrong credentials"),
|
||||
Self::Succeeded => AR::Success,
|
||||
match value {
|
||||
SR::ServerError => Self::Failed("unknown server error"),
|
||||
SR::Locked => Self::Failed("account locked, too many login attempts"),
|
||||
SR::Failed => Self::Failed("wrong credentials"),
|
||||
SR::Succeeded => Self::Success,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
src/conf.rs
55
src/conf.rs
@@ -1,40 +1,41 @@
|
||||
//! Configuration file structure. (TODO, docs)
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
fs::File,
|
||||
io::{self, Read, Seek},
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||
};
|
||||
|
||||
use libc::gid_t;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Either<T, U> {
|
||||
Left(T),
|
||||
Right(U),
|
||||
}
|
||||
use crate::serdes::{DatabasePaths, Group};
|
||||
|
||||
impl<T: Debug, U: Debug> Debug for Either<T, U> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Left(l) => l.fmt(f),
|
||||
Self::Right(r) => r.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Group = Either<String, gid_t>;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Unix {
|
||||
groups: Vec<Group>,
|
||||
pub groups: Vec<Group>,
|
||||
pub magic_paths: DatabasePaths,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Server {
|
||||
pub listen: SocketAddr,
|
||||
}
|
||||
impl Default for Server {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8080)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
unix: Unix,
|
||||
pub unix: Unix,
|
||||
pub server: Server,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@@ -50,12 +51,14 @@ impl Config {
|
||||
/// # Errors
|
||||
///
|
||||
/// For IO errors reading the file or parsing errors.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If file's u64 length might not fit in memory.
|
||||
pub fn from_toml_file(f: &mut File) -> Result<Self, ConfigParseError> {
|
||||
let mut buf = Vec::new();
|
||||
if let Ok(len) = f.stream_len() {
|
||||
// There's no way a config file passes machine's memory limitations
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
buf.reserve_exact(len as usize);
|
||||
buf.reserve_exact(len.try_into().expect("file's u64 len to fit in memory"));
|
||||
}
|
||||
f.read_to_end(&mut buf)?;
|
||||
|
||||
|
||||
41
src/consts.rs
Normal file
41
src/consts.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Constant behavior paramenters.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Max [`moka`] pfp cache capacity
|
||||
pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
|
||||
|
||||
/// Max filesize of accepted user profile pictures, to prevent huge cache and serving times
|
||||
pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high for prototyping
|
||||
|
||||
/// 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, 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] = &[
|
||||
".config/logo.avif",
|
||||
".config/logo.webp",
|
||||
".config/logo.png",
|
||||
".config/logo.jpg",
|
||||
"logo.png",
|
||||
"logo.jpg",
|
||||
".face",
|
||||
];
|
||||
|
||||
pub mod web_scopes {
|
||||
pub const IMAGES: &str = "/image";
|
||||
}
|
||||
29
src/main.rs
29
src/main.rs
@@ -1,4 +1,24 @@
|
||||
#![feature(seek_stream_len)]
|
||||
#![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
|
||||
//!
|
||||
//! Check the README.md for entry-level documentation.
|
||||
//!
|
||||
//! # Where is my documentation?
|
||||
//!
|
||||
//! For ease of development and centralized **corrent** information, this codebase will serve both
|
||||
//! as project documentation AND documentation for the behavior of the OpenID Connect server.
|
||||
//!
|
||||
//! Might be hard to figure out how the program behaves based on the code, but I will try to put
|
||||
//! behavior parameters in [`consts`], so that might be a good starting point to know some stuff
|
||||
//! (e.g. profile profile picture search path).
|
||||
//!
|
||||
//! Checking out [`conf`] might be useful too to know what could've been configured by server
|
||||
//! administrators and less likely but maybe there can also be certain parameters in [`args`].
|
||||
//!
|
||||
//! I will try to keep those 3 modules as documented as possible, please feel free to open any
|
||||
//! issues/PRs regarding information in there.
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
@@ -9,7 +29,11 @@ use crate::ext::FileExt;
|
||||
pub mod args;
|
||||
pub mod auth;
|
||||
pub mod conf;
|
||||
pub mod consts;
|
||||
pub mod ext;
|
||||
pub mod serdes;
|
||||
pub mod server;
|
||||
pub mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -22,8 +46,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
println!("{conf:#?}");
|
||||
|
||||
let res = auth::authenticate(&args, "javalsai", "test").await;
|
||||
println!("{res:?}");
|
||||
server::start_app(args, conf).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
121
src/serdes/inner_helpers.rs
Normal file
121
src/serdes/inner_helpers.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
use libc::gid_t;
|
||||
use serde::{Deserialize, Deserializer, Serialize, de::Error};
|
||||
|
||||
/// Private module until its worth it to promote from size to a crate module.
|
||||
mod __libc_wrappers {
|
||||
use std::{ffi::CStr, mem::MaybeUninit};
|
||||
|
||||
use libc::c_char;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetgrnamError {
|
||||
#[error("no matching group record found")]
|
||||
NoMatchingRecord,
|
||||
/// ace variant for unexpected errors, error handling is shallow here
|
||||
#[error("unexpected getgrnam_r error")]
|
||||
Unexpected,
|
||||
}
|
||||
|
||||
pub fn get_group_by_name(cname: &CStr) -> Result<libc::gid_t, GetgrnamError> {
|
||||
use GetgrnamError as E;
|
||||
|
||||
let name = cname.as_ptr();
|
||||
let mut grp = MaybeUninit::uninit();
|
||||
let mut buf = [c_char::from(0); 1024];
|
||||
let mut result: MaybeUninit<*mut libc::group> = MaybeUninit::uninit();
|
||||
let ret = unsafe {
|
||||
libc::getgrnam_r(
|
||||
name,
|
||||
grp.as_mut_ptr(),
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
result.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
// SAFETY: Pointers inside `grp` point may point to the buffer `buf` so they cannot be
|
||||
// moved outside of this function.
|
||||
|
||||
// > On success, getgrnam_r() [..] return zero, and set `*result` to `grp`.
|
||||
//
|
||||
// > If no matching group record was found, these functions return 0 and store NULL in
|
||||
// `*result`.
|
||||
//
|
||||
// > In case of error, an error number is returned, and NULL stored in `*result`.
|
||||
//
|
||||
// SAFETY: Either way, `result` is initialized to a nullable pointer.
|
||||
let result_ptr = unsafe { result.assume_init() };
|
||||
if ret == 0 && result_ptr == grp.as_mut_ptr() {
|
||||
let grp_gid = unsafe { grp.assume_init() }.gr_gid;
|
||||
Ok(grp_gid)
|
||||
} else if ret == 0 && result_ptr.is_null() {
|
||||
Err(E::NoMatchingRecord)
|
||||
} else {
|
||||
// ret should be != 0 and result NULL, but any other case is also unexpected and I'm
|
||||
// not doing much FFI error handling rn
|
||||
Err(E::Unexpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EitherTyp {
|
||||
Gid(gid_t),
|
||||
Groupname(String),
|
||||
}
|
||||
|
||||
/// Deserialize either a gid number or a groupname into a [`gid_t`]
|
||||
#[expect(clippy::missing_errors_doc)]
|
||||
pub fn deserialize_group<'de, D>(d: D) -> Result<gid_t, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let either = EitherTyp::deserialize(d)?;
|
||||
|
||||
match either {
|
||||
EitherTyp::Gid(gid) => Ok(gid),
|
||||
EitherTyp::Groupname(groupname) => {
|
||||
use __libc_wrappers::GetgrnamError as E;
|
||||
use serde::de::Unexpected;
|
||||
|
||||
let cname = CString::new(groupname).map_err(|nul_err| {
|
||||
D::Error::invalid_value(
|
||||
Unexpected::Bytes(&nul_err.into_vec()),
|
||||
&"a string without null bytes",
|
||||
)
|
||||
})?;
|
||||
|
||||
let gid = __libc_wrappers::get_group_by_name(&cname).map_err(|err| match err {
|
||||
E::NoMatchingRecord => D::Error::invalid_value(
|
||||
Unexpected::Other("non-existent group"),
|
||||
&"existent group",
|
||||
),
|
||||
E::Unexpected => D::Error::custom("caught an unexpected getgrnam error"),
|
||||
})?;
|
||||
|
||||
Ok(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize into [`magic::cookie::DatabasePaths`] as if it were a [`Vec<String>`]
|
||||
#[expect(clippy::missing_errors_doc)]
|
||||
pub fn deserialize_magic_paths<'de, D>(d: D) -> Result<magic::cookie::DatabasePaths, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Unexpected;
|
||||
|
||||
let paths = Vec::<String>::deserialize(d)?;
|
||||
|
||||
// TODO: could use this `_err`
|
||||
paths.try_into().map_err(|_err| {
|
||||
D::Error::invalid_value(
|
||||
Unexpected::Other("invalid magic db path"),
|
||||
&"valid magic db path",
|
||||
)
|
||||
})
|
||||
}
|
||||
53
src/serdes/mod.rs
Normal file
53
src/serdes/mod.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
use libc::gid_t;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod inner_helpers;
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Group(#[serde(deserialize_with = "inner_helpers::deserialize_group")] pub gid_t);
|
||||
|
||||
impl Debug for Group {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Debug::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct DatabasePaths(
|
||||
#[serde(deserialize_with = "inner_helpers::deserialize_magic_paths")]
|
||||
pub magic::cookie::DatabasePaths,
|
||||
);
|
||||
|
||||
// impl Default for DatabasePaths {
|
||||
// fn default() -> Self {
|
||||
// Self(["/usr/share/file/misc/magic"].try_into().expect(""))
|
||||
// }
|
||||
// }
|
||||
|
||||
impl Debug for DatabasePaths {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Debug::fmt(&self.0, f)
|
||||
// f.write_str("<magic paths>")
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DatabasePaths {
|
||||
type Target = magic::cookie::DatabasePaths;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for DatabasePaths {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
68
src/server/caches/mime.rs
Normal file
68
src/server/caches/mime.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Mime mmagic analogous to [`magic::Cookie`] BUT [`Sync`]
|
||||
|
||||
// I don't think I can do better with thread exclusive `libmagic`
|
||||
|
||||
use std::cell::OnceCell;
|
||||
|
||||
use magic::{Cookie, cookie};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NewCookieError {
|
||||
#[error(transparent)]
|
||||
MimeCookieOpen(#[from] magic::cookie::OpenError),
|
||||
#[error(transparent)]
|
||||
MimeDBLoad(#[from] magic::cookie::LoadError<magic::cookie::Open>),
|
||||
// #[error(transparent)]
|
||||
// MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError),
|
||||
}
|
||||
|
||||
pub struct Mime {
|
||||
magic_dbs: magic::cookie::DatabasePaths,
|
||||
}
|
||||
|
||||
impl Mime {
|
||||
thread_local! {
|
||||
static COOKIE_CELL: OnceCell<Cookie<cookie::Load>> = const { OnceCell::new() };
|
||||
}
|
||||
|
||||
fn use_cookie<F, T>(&self, f: F) -> Result<T, NewCookieError>
|
||||
where
|
||||
F: FnOnce(&Cookie<cookie::Load>) -> T,
|
||||
{
|
||||
Self::COOKIE_CELL.with(|cookie| {
|
||||
let may_cookie = cookie.get_or_try_init::<_, NewCookieError>(move || {
|
||||
let cookie = magic::Cookie::open(magic::cookie::Flags::MIME)?;
|
||||
Ok(cookie.load(&self.magic_dbs)?)
|
||||
});
|
||||
|
||||
match may_cookie {
|
||||
Ok(c) => Ok(f(c)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn new(magic_dbs: magic::cookie::DatabasePaths) -> Self {
|
||||
Self { magic_dbs }
|
||||
}
|
||||
|
||||
/// Cookie initialization is delayed, so each call might be the creation of the cookie if its
|
||||
/// the first use of the cookie in this thread.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// First layer error fails if new cookie creation failed. Second layer error represents if the
|
||||
/// mime search was successful.
|
||||
pub fn buffer(
|
||||
&self,
|
||||
buffer: &[u8],
|
||||
) -> Result<Result<String, magic::cookie::Error>, NewCookieError> {
|
||||
self.use_cookie(|c| c.buffer(buffer))
|
||||
}
|
||||
}
|
||||
|
||||
// fn __ace(c: Mime) {
|
||||
// fn __ace_inner(c: impl Sync + Send) {}
|
||||
// __ace_inner(c);
|
||||
// }
|
||||
176
src/server/caches/mod.rs
Normal file
176
src/server/caches/mod.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use std::{ffi::OsStr, io, os::unix::fs::MetadataExt, path::Path, sync::Arc};
|
||||
|
||||
use ::users::os::unix::UserExt;
|
||||
use moka::future::{Cache, CacheBuilder};
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
|
||||
use crate::{
|
||||
consts,
|
||||
server::caches::{mime::Mime, users::UsersCache},
|
||||
};
|
||||
|
||||
pub mod mime;
|
||||
pub mod users;
|
||||
|
||||
const MIME_IMAGE_PREFIX: &str = "image/";
|
||||
|
||||
pub struct MimeWithBytes {
|
||||
pub mime: Box<str>,
|
||||
pub bytes: Box<[u8]>,
|
||||
}
|
||||
|
||||
pub type Image = Arc<MimeWithBytes>;
|
||||
|
||||
// TODO: most of the cache methods here block the executor, if we wanna commit to async we'd have
|
||||
// to consider that
|
||||
pub struct AppCache<'a> {
|
||||
only_groups: &'a [crate::serdes::Group],
|
||||
// FIXME: blocks
|
||||
user_cache: UsersCache,
|
||||
// FIXME: blocks
|
||||
magic_mime_cookie: Mime,
|
||||
|
||||
/// MUST only contain users from an accepted group, we do not want to cache arbitrary usernames
|
||||
/// and blow memory up.
|
||||
///
|
||||
/// [`Option<Image>`] because users may not have a pfp.
|
||||
pfp_cache: Cache<String, Option<Image>>,
|
||||
}
|
||||
|
||||
impl<'a> AppCache<'a> {
|
||||
/// # Errors
|
||||
///
|
||||
/// Errors if anything failed opening the magic cookie.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If weighter's usize doesn't fit in its u32
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
magic_dbs: magic::cookie::DatabasePaths,
|
||||
only_groups: &'a [crate::serdes::Group],
|
||||
) -> Self {
|
||||
Self {
|
||||
only_groups,
|
||||
|
||||
user_cache: UsersCache::new(),
|
||||
|
||||
magic_mime_cookie: Mime::new(magic_dbs),
|
||||
|
||||
pfp_cache: CacheBuilder::new(consts::MAX_PFP_CACHE_CAPACITY)
|
||||
.time_to_live(consts::USER_CACHES_TTL)
|
||||
.weigher(|_, v: &Option<Image>| {
|
||||
v.as_ref()
|
||||
.map_or(1, |v| v.bytes.len())
|
||||
.try_into()
|
||||
.expect("size of image to fit in weigher's size")
|
||||
})
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_member_groups(&self, groups: Option<impl AsRef<[::users::Group]>>) -> bool {
|
||||
groups.is_some_and(|groups| {
|
||||
groups.as_ref().iter().any(|group| {
|
||||
let gid = group.gid();
|
||||
|
||||
self.only_groups.iter().any(|from_gr| from_gr.0 == gid)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_member_user_by_name<S: AsRef<OsStr> + ?Sized>(
|
||||
&self,
|
||||
username: &S,
|
||||
) -> Option<Arc<::users::User>> {
|
||||
let user = self.user_cache.get_user_by_name(username).await?;
|
||||
|
||||
// FIXME: `user.groups()` is not cached and could be a DoS point. But I don't think caching
|
||||
// if a user is member of any group is the proper way for this lmfao
|
||||
if self.is_member_groups(user.groups()) {
|
||||
Some(user)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_logo_from_home(&self, home: &Path) -> Option<Image> {
|
||||
async fn read_limited_path<const MAXSIZE: u64>(path: &Path) -> io::Result<Vec<u8>> {
|
||||
let f = File::open(path).await?;
|
||||
let size = f.metadata().await?.size();
|
||||
|
||||
if size > MAXSIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::FileTooLarge,
|
||||
"filesize is bigger than MAXSIZE",
|
||||
));
|
||||
}
|
||||
let mut buf = Vec::with_capacity(size.try_into().expect("u64 to fit in usize"));
|
||||
// `.take()` just in case an open fd happens to grow, `.metadata()` SHOULD take
|
||||
// properties from the fd and lock the read fd until its closed but still
|
||||
f.take(MAXSIZE).read_to_end(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
for subpath in consts::USER_PFP_PATHS {
|
||||
let path = home.join(subpath);
|
||||
|
||||
if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await
|
||||
&& let Ok(Ok(mime)) = self.magic_mime_cookie.buffer(&img_buf) // TODO: first layer
|
||||
// error is actually
|
||||
// relevant
|
||||
&& mime.starts_with(MIME_IMAGE_PREFIX)
|
||||
{
|
||||
return Some(Arc::new(MimeWithBytes {
|
||||
mime: mime.into_boxed_str(),
|
||||
bytes: img_buf.into_boxed_slice(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Doesn't differenciate users without pfp and nonexistent ones
|
||||
///
|
||||
/// So ig a feature not a bug? Less scraping? As in, scraping a user without pfp will just
|
||||
/// default to default pfp.
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// `T` is very generic, usually just takes the path of [`AsRef<str>`], but rarely it can take
|
||||
/// the [`ToOwned<Owned = String>`] path. That means, if you only have access to a type like
|
||||
/// [`str`], use it. But if by any chance you have a [`String`] of the value and it's not going
|
||||
/// to be used anymore, that might be more performant.
|
||||
///
|
||||
/// The loss is mainly just the allocation time, just a username, should be small enough but
|
||||
/// still, just giving it flexibility. Also maybe a [`std::borrow::Cow<str>`] will work
|
||||
/// perfectly too.
|
||||
///
|
||||
/// # Security
|
||||
///
|
||||
/// Images ultimately come from users home directories, so they could be anything, not only
|
||||
/// images (though there's a MIME check, but not designed to be relied upon), make sure to
|
||||
/// provide the mime type and `X-Content-Type-Options: nosniff` when serving it via http/s.
|
||||
pub async fn get_pfp<T>(&self, username: T) -> Option<Image>
|
||||
where
|
||||
T: AsRef<str> + ToOwned<Owned = String>,
|
||||
{
|
||||
// If caching is done properly, it will take advantage of async.
|
||||
if let Some(cached_pfp) = self.pfp_cache.get(username.as_ref()).await {
|
||||
return cached_pfp;
|
||||
}
|
||||
|
||||
// This blocks for now, so if we win with caching better. Non-member username requests
|
||||
// won't cache, we win with actual user-cache and not pushing those away, but will make
|
||||
// DDoS miss cache constantly.
|
||||
let user = self.get_member_user_by_name(username.as_ref()).await?;
|
||||
let img = self.read_logo_from_home(user.home_dir()).await?;
|
||||
|
||||
self.pfp_cache
|
||||
.insert(username.to_owned(), Some(img.clone()))
|
||||
.await;
|
||||
|
||||
Some(img)
|
||||
}
|
||||
}
|
||||
32
src/server/caches/users.rs
Normal file
32
src/server/caches/users.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Users cache analogous to [`users::UsersCache`] BUT [`Sync`]
|
||||
|
||||
use std::{ffi::OsStr, sync::Arc};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use users::Users as _;
|
||||
|
||||
pub struct UsersCache {
|
||||
cache: Mutex<users::UsersCache>,
|
||||
}
|
||||
|
||||
impl UsersCache {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(users::UsersCache::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_by_name<S: AsRef<OsStr> + ?Sized>(
|
||||
&self,
|
||||
username: &S,
|
||||
) -> Option<Arc<users::User>> {
|
||||
self.cache.lock().await.get_user_by_name(username)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UsersCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
48
src/server/mod.rs
Normal file
48
src/server/mod.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::io;
|
||||
|
||||
use actix_web::{App, HttpServer, web};
|
||||
|
||||
pub mod caches;
|
||||
pub mod services;
|
||||
|
||||
pub struct AppState {
|
||||
pub args: crate::args::Args,
|
||||
pub config: &'static crate::conf::Config,
|
||||
pub cache: caches::AppCache<'static>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
|
||||
let app: &AppState = Box::leak(Box::new(AppState {
|
||||
args,
|
||||
config,
|
||||
cache,
|
||||
}));
|
||||
|
||||
println!(
|
||||
"\x1b[32mINF\x1b[0m: Trying to listen on {:?}",
|
||||
app.config.server.listen
|
||||
);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(app))
|
||||
.service(services::images::make_scope(ws::IMAGES))
|
||||
})
|
||||
.bind(&app.config.server.listen)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
60
src/server/services/images.rs
Normal file
60
src/server/services/images.rs
Normal 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())),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
1
src/server/services/mod.rs
Normal file
1
src/server/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod images;
|
||||
39
src/utils.rs
Normal file
39
src/utils.rs
Normal 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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user