From bcec6af49b742b0a73241cd5483f8d8e27b82e4d Mon Sep 17 00:00:00 2001 From: javalsai Date: Thu, 26 Mar 2026 23:42:47 +0100 Subject: [PATCH] dev: monolithic app state reasons stated at the top of such mod.rs file --- src/consts.rs | 2 + src/db/mod.rs | 9 +- src/main.rs | 3 +- src/serdes/mod.rs | 11 +- src/server/caches/mod.rs | 213 ------------------ src/server/mod.rs | 35 +-- src/server/services/images.rs | 6 +- src/server/services/login.rs | 40 +++- src/server/state/db.rs | 13 ++ src/server/{caches/mime.rs => state/magic.rs} | 25 +- src/server/state/mod.rs | 89 ++++++++ src/server/state/pfp.rs | 100 ++++++++ src/server/state/ssh.rs | 63 ++++++ src/server/{caches => state}/users.rs | 2 + 14 files changed, 331 insertions(+), 280 deletions(-) delete mode 100644 src/server/caches/mod.rs create mode 100644 src/server/state/db.rs rename src/server/{caches/mime.rs => state/magic.rs} (73%) create mode 100644 src/server/state/mod.rs create mode 100644 src/server/state/pfp.rs create mode 100644 src/server/state/ssh.rs rename src/server/{caches => state}/users.rs (96%) diff --git a/src/consts.rs b/src/consts.rs index 1477115..88bff67 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -50,6 +50,8 @@ pub const ERROR_ASCII_ARTS: &[&str] = &[ ]; pub mod mime { + pub const MIME_IMAGE_PREFIX: &str = "image/"; + pub const TEXT: &str = "text/plain; charset=utf-8"; pub const HTML: &str = "text/html; charset=utf-8"; } diff --git a/src/db/mod.rs b/src/db/mod.rs index 33535f4..5e5804a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,9 +7,10 @@ use std::collections::HashSet; use tokio::sync::RwLock; +use crate::server::state::users::UID; + pub struct DB { - // TODO: should I use some sort of UIDs instead of the username everywhere? - enabled_users: RwLock>, + enabled_users: RwLock>, } impl DB { @@ -21,12 +22,12 @@ impl DB { } } - pub async fn enable_user(&mut self, user: String) { + pub async fn enable_user(&self, user: UID) { self.enabled_users.write().await.insert(user); } #[must_use] - pub async fn user_is_enabled(&self, user: &str) -> bool { + pub async fn user_is_enabled(&self, user: &UID) -> bool { self.enabled_users.read().await.contains(user) } } diff --git a/src/main.rs b/src/main.rs index 35fa6fa..a9d5dfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,7 +91,8 @@ async fn main() -> anyhow::Result<()> { // (idek japanese but im vibing) println!("\n\x1b[1;3;4;33mConfiguration\x1b[0m: {conf:#?}\n"); - server::start_app(args, conf, db::DB::new()).await?; + let app_state = server::state::AppState::new(args, conf, db::DB::new()); + server::start_app(Box::leak(Box::new(app_state))).await?; Ok(()) } diff --git a/src/serdes/mod.rs b/src/serdes/mod.rs index c2a9404..71dcf94 100644 --- a/src/serdes/mod.rs +++ b/src/serdes/mod.rs @@ -9,8 +9,8 @@ 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); +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct Group(#[serde(deserialize_with = "inner_helpers::deserialize_group")] gid_t); impl Debug for Group { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -18,6 +18,13 @@ impl Debug for Group { } } +impl Group { + #[must_use] + pub const fn gid(self) -> gid_t { + self.0 + } +} + #[repr(transparent)] #[derive(Deserialize, Default)] pub struct DatabasePaths( diff --git a/src/server/caches/mod.rs b/src/server/caches/mod.rs deleted file mode 100644 index dae1d32..0000000 --- a/src/server/caches/mod.rs +++ /dev/null @@ -1,213 +0,0 @@ -use std::{ffi::OsStr, io, path::Path, sync::Arc}; - -use ::users::os::unix::UserExt as _; -use moka::future::{Cache, CacheBuilder}; -use sha2::Digest as _; -use ssh_key::PublicKey; - -use crate::{ - consts, - server::caches::{mime::Mime, users::UsersCache}, - utils::fs::{read_limited_path, read_limited_path_str}, -}; - -pub mod mime; -pub mod users; - -const MIME_IMAGE_PREFIX: &str = "image/"; - -pub struct ImageInfo { - pub mime: Box, - pub bytes: Box<[u8]>, - pub shasum: Box, -} - -pub type Image = Arc; - -// 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<'a>, - - /// MUST only contain users from an accepted group, we do not want to cache arbitrary usernames - /// and blow memory up. - /// - /// [`Option`] because users may not have a pfp. - pfp_cache: Cache>, -} - -/// Most of the methods in here fail silently with file i/o and some might cache results, I chose -/// either arbitrarily on the spot but I should make this more explicit. -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: &'a 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| { - 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>) -> 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 + ?Sized>( - &self, - username: &S, - ) -> Option> { - 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 { - for subpath in consts::USER_PFP_PATHS { - let path = home.join(subpath); - - // I'm relying too much on this condition - 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) - { - let shasum = sha2::Sha256::digest(&img_buf); - let shasum = format!( - "\"{}\"", - crate::utils::shasum::sha256sum_to_hex_string(&shasum) - ); - - return Some(Arc::new(ImageInfo { - mime: mime.into_boxed_str(), - bytes: img_buf.into_boxed_slice(), - shasum: shasum.into_boxed_str(), - })); - } - } - - 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`], but rarely it can take - /// the [`ToOwned`] 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`] 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(&self, username: T) -> Option - where - T: AsRef + ToOwned, - { - // 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) - } - - /// Gets [`consts::AUTHORIZED_KEYS_PATH`] from user's home. - /// - /// # Errors - /// - /// In any of [`GetAuthorizedKeysError`] - pub async fn get_authorized_keys( - &self, - username: &str, - ) -> Result, GetAuthorizedKeysError> { - async fn read_users_authorized_keys( - home_dir: &Path, - ) -> Result, GetAuthorizedKeysError> { - let buf = read_limited_path_str::<{ consts::MAX_AUTHORIZED_KEYS_SIZE }>( - &home_dir.join(consts::AUTHORIZED_KEYS_PATH), - ) - .await - .map_err(GetAuthorizedKeysError::ReadSshFile)?; - - Ok(buf.lines().map(PublicKey::from_openssh).try_collect()?) - } - - let user = self - .get_member_user_by_name(username) - .await - .ok_or(GetAuthorizedKeysError::UserNotFound)?; - - Ok(read_users_authorized_keys(user.home_dir()) - .await? - .into_boxed_slice()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum GetAuthorizedKeysError { - #[error("said user was not found")] - UserNotFound, - #[error("failure reading ssh file: {0}")] - ReadSshFile(io::Error), - #[error("error parsing ssh key: {0}")] - ParseError(#[from] ssh_key::Error), -} diff --git a/src/server/mod.rs b/src/server/mod.rs index e35db0e..309965f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2,55 +2,30 @@ use std::io; use actix_web::{App, HttpServer, middleware, web}; -pub mod caches; pub mod services; +pub mod state; pub mod static_app_data; -pub struct AppState { - pub args: crate::args::Args, - pub config: &'static crate::conf::Config, - pub cache: caches::AppCache<'static>, - pub db: crate::db::DB, -} - -/// Type alias to be used just as `data: AppData,` extractor in [`actix_web`] request handlers. -pub type AppData = static_app_data::StaticAppDataExtractor<&'static 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, - db: crate::db::DB, -) -> io::Result<()> { +pub async fn start_app(state: &'static state::AppState) -> 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, &config.unix.groups); - - let app: &AppState = Box::leak(Box::new(AppState { - args, - config, - cache, - db, - })); - println!( "\x1b[34mINF\x1b[0m: Trying to listen on \x1b[35m{:?}\x1b[0m", - app.config.server.listen + state.config.server.listen ); HttpServer::new(move || { App::new() - .app_data(app) + .app_data(state) .wrap(middleware::Logger::new("%a (%{r}a) %r -> %s, %b B in %T s")) .wrap(middleware::NormalizePath::trim()) .service(services::images::make_scope(ws::IMAGES)) .service(services::login::make_scope(ws::LOGIN)) .default_service(web::to(services::not_found::not_found)) }) - .bind(&app.config.server.listen)? + .bind(&state.config.server.listen)? .run() .await } diff --git a/src/server/services/images.rs b/src/server/services/images.rs index 8a62fa3..62a71dc 100644 --- a/src/server/services/images.rs +++ b/src/server/services/images.rs @@ -13,7 +13,7 @@ use actix_web::{ use crate::{ consts::{self, web_scopes as ws}, - server::AppData, + server::state::DataExtractor, }; #[must_use] @@ -40,10 +40,10 @@ async fn get_default_image() -> HttpResponse { #[get("/user/{username}")] async fn get_image( - data: AppData, + data: DataExtractor, username: web::Path, ) -> web::Either { - let cached_pfp = data.cache.get_pfp(username.to_string()).await; + let cached_pfp = data.get_pfp(username.to_string()).await; cached_pfp.as_ref().map_or_else( || { diff --git a/src/server/services/login.rs b/src/server/services/login.rs index 7c380c4..6af0d63 100644 --- a/src/server/services/login.rs +++ b/src/server/services/login.rs @@ -3,7 +3,7 @@ use actix_web::{ web::{self, Path}, }; -use crate::{consts, server::AppData}; +use crate::{consts, server::state::DataExtractor}; #[must_use] pub fn make_scope(path: &str) -> actix_web::Scope { @@ -14,34 +14,48 @@ pub fn make_scope(path: &str) -> actix_web::Scope { } #[get("")] -pub async fn login(_data: AppData) -> HttpResponse { +pub async fn login(_data: DataExtractor) -> HttpResponse { HttpResponse::Ok().content_type(consts::mime::HTML).body( "\
\

Username

\ - \ + \

Password

\ - \ + \ \
\ ", ) } +#[derive(serde::Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +/// Expects a form's post data in the form of [`LoginForm`] #[post("")] -pub async fn login_post(_data: AppData) -> HttpResponse { +pub async fn login_post(data: DataExtractor, form: web::Form) -> HttpResponse { + let Some(user) = data.get_user_by_name(&form.username).await else { + return HttpResponse::BadRequest() + .content_type(consts::mime::TEXT) + .body("user does not exist"); + }; + let user_can_login = data.is_user_enabled(&user.uid()).await; + HttpResponse::NotImplemented() .content_type(consts::mime::HTML) - .body( + .body(format!( "\ -

TODO

\ +

TODO, can you login? ({user_can_login})

\ ", - ) + )) } #[get("/activate/{username}")] -pub async fn activate(data: AppData, username: Path) -> HttpResponse { - let keys = data.cache.get_authorized_keys(&username).await; +pub async fn activate(data: DataExtractor, username: Path) -> HttpResponse { + let keys = data.get_authorized_ssh_keys(&username).await; match keys { Ok(keys) => HttpResponse::Ok() @@ -49,6 +63,10 @@ pub async fn activate(data: AppData, username: Path) -> HttpResponse { .body(format!("{keys:#?}")), Err(err) => HttpResponse::BadRequest() .content_type(consts::mime::TEXT) - .body(err.to_string()), + .body(if let Some(recommendation) = err.recommendation() { + format!("{err}\n\nsuggestion: {recommendation}") + } else { + err.to_string() + }), } } diff --git a/src/server/state/db.rs b/src/server/state/db.rs new file mode 100644 index 0000000..1d25bdb --- /dev/null +++ b/src/server/state/db.rs @@ -0,0 +1,13 @@ +use crate::server::state::users::UID; + +use super::AppState; + +impl AppState { + pub fn enable_user(&self, user: UID) -> impl Future { + self.db.enable_user(user) + } + + pub fn is_user_enabled(&self, user: &UID) -> impl Future { + self.db.user_is_enabled(user) + } +} diff --git a/src/server/caches/mime.rs b/src/server/state/magic.rs similarity index 73% rename from src/server/caches/mime.rs rename to src/server/state/magic.rs index afa2151..3b76d4e 100644 --- a/src/server/caches/mime.rs +++ b/src/server/state/magic.rs @@ -6,6 +6,8 @@ use std::cell::OnceCell; use magic::{Cookie, cookie}; +use super::AppState; + #[derive(Debug, thiserror::Error)] pub enum NewCookieError { #[error(transparent)] @@ -16,23 +18,19 @@ pub enum NewCookieError { // MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError), } -pub struct Mime<'a> { - magic_dbs: &'a magic::cookie::DatabasePaths, -} - -impl<'a> Mime<'a> { +impl AppState { thread_local! { - static COOKIE_CELL: OnceCell> = const { OnceCell::new() }; + static MAGIC_COOKIE_CELL: OnceCell> = const { OnceCell::new() }; } - fn use_cookie(&self, f: F) -> Result + fn use_magic_cookie(&self, f: F) -> Result where F: FnOnce(&Cookie) -> T, { - Self::COOKIE_CELL.with(|cookie| { + Self::MAGIC_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)?) + Ok(cookie.load(&self.config.unix.magic_paths)?) }); match may_cookie { @@ -42,11 +40,6 @@ impl<'a> Mime<'a> { }) } - #[must_use] - pub const fn new(magic_dbs: &'a 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. /// @@ -54,11 +47,11 @@ impl<'a> Mime<'a> { /// /// First layer error fails if new cookie creation failed. Second layer error represents if the /// mime search was successful. - pub fn buffer( + pub fn magic_from_buffer( &self, buffer: &[u8], ) -> Result, NewCookieError> { - self.use_cookie(|c| c.buffer(buffer)) + self.use_magic_cookie(|c| c.buffer(buffer)) } } diff --git a/src/server/state/mod.rs b/src/server/state/mod.rs new file mode 100644 index 0000000..abba49e --- /dev/null +++ b/src/server/state/mod.rs @@ -0,0 +1,89 @@ +//! Here down is a little mess but it aims to be a monolithic app state. +//! +//! This was decided to avoid complicated self-references between all structs and instead method +//! names are carefully selected for higene, with separate submodules that include the extra types +//! and implement themselves on the general app state, having access to DB, cache, configuration or +//! anything. + +use std::{ffi::OsStr, sync::Arc}; + +use moka::future::{Cache, CacheBuilder}; + +use crate::{ + consts, serdes, + server::{state::users::UsersCache, static_app_data}, +}; + +pub mod db; +pub mod magic; +pub mod pfp; +pub mod ssh; +pub mod users; + +pub struct AppState { + pub args: crate::args::Args, + pub config: crate::conf::Config, + + // ### DB ### + db: crate::db::DB, + + // `users.rs` + user_cache: UsersCache, + + // `pfp.rs` + /// MUST only contain users from an accepted group, we do not want to cache arbitrary usernames + /// and blow memory up. + /// + /// [`Option`] because users may not have a pfp. + pfp_cache: Cache>, +} + +impl AppState { + pub fn new(args: crate::args::Args, config: crate::conf::Config, db: crate::db::DB) -> Self { + Self { + args, + config, + db, + + // `users.rs` + user_cache: UsersCache::new(), + + // `pfp.rs` + pfp_cache: CacheBuilder::new(consts::MAX_PFP_CACHE_CAPACITY) + .time_to_live(consts::USER_CACHES_TTL) + .weigher(pfp::weigther) + .build(), + } + } + + /// Only gets users from valid groups, de-facto method to access this information as it does + /// the appropiate checks. + pub async fn get_user_by_name + ?Sized>( + &self, + username: &S, + ) -> Option> { + fn is_member_groups( + only_groups: &[serdes::Group], + groups: Option>, + ) -> bool { + groups.is_some_and(|groups| { + groups.as_ref().iter().any(|group| { + let gid = group.gid(); + + only_groups.iter().any(|from_gr| from_gr.gid() == gid) + }) + }) + } + + let user = self.user_cache.get_user_by_name(username).await?; + + if is_member_groups(&self.config.unix.groups, user.groups()) { + Some(user) + } else { + None + } + } +} + +/// Type alias to be used just as `data: AppData,` extractor in [`actix_web`] request handlers. +pub type DataExtractor = static_app_data::StaticAppDataExtractor<&'static AppState>; diff --git a/src/server/state/pfp.rs b/src/server/state/pfp.rs new file mode 100644 index 0000000..ce08e34 --- /dev/null +++ b/src/server/state/pfp.rs @@ -0,0 +1,100 @@ +use std::{path::Path, sync::Arc}; + +use sha2::Digest as _; +use users::os::unix::UserExt as _; + +use crate::{consts, utils::fs::read_limited_path}; + +use super::AppState; + +pub struct ImageInfo { + pub mime: Box, + pub bytes: Box<[u8]>, + pub shasum: Box, +} + +pub type Image = Arc; + +/// # Panics +/// +/// If file's image size doesn't fit in the weighter's size. +#[must_use] +pub fn weigther(_: &String, v: &Option) -> u32 { + v.as_ref() + .map_or(1, |v| v.bytes.len()) + .try_into() + .expect("size of image to fit in weigher's size") +} + +impl AppState { + async fn read_pfp_from_home(&self, home: &Path) -> Option { + for subpath in consts::USER_PFP_PATHS { + let path = home.join(subpath); + + // I'm relying too much on this condition + if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await + && let Ok(Ok(mime)) = self.magic_from_buffer(&img_buf) // TODO: first layer + // error is actually + // relevant + && mime.starts_with(consts::mime::MIME_IMAGE_PREFIX) + { + let shasum = sha2::Sha256::digest(&img_buf); + let shasum = format!( + "\"{}\"", + crate::utils::shasum::sha256sum_to_hex_string(&shasum) + ); + + return Some(Arc::new(ImageInfo { + mime: mime.into_boxed_str(), + bytes: img_buf.into_boxed_slice(), + shasum: shasum.into_boxed_str(), + })); + } + } + + 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`], but rarely it can take + /// the [`ToOwned`] 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`] 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(&self, username: T) -> Option + where + T: AsRef + ToOwned, + { + // 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_user_by_name(username.as_ref()).await?; + let img = self.read_pfp_from_home(user.home_dir()).await?; + + self.pfp_cache + .insert(username.to_owned(), Some(img.clone())) + .await; + + Some(img) + } +} diff --git a/src/server/state/ssh.rs b/src/server/state/ssh.rs new file mode 100644 index 0000000..49a7e24 --- /dev/null +++ b/src/server/state/ssh.rs @@ -0,0 +1,63 @@ +use std::{io, path::Path}; + +use ssh_key::PublicKey; +use users::os::unix::UserExt as _; + +use crate::{consts, utils::fs::read_limited_path_str}; + +use super::AppState; + +#[derive(Debug, thiserror::Error)] +pub enum GetKeysError { + #[error("said user was not found")] + UserNotFound, + #[error("failure reading ssh file: {0}")] + ReadSshFile(io::Error), + #[error("error parsing ssh key: {0}")] + ParseError(#[from] ssh_key::Error), +} + +impl GetKeysError { + // TODO: make the &str a beautiful html once we have a type from the template engine + #[must_use] + #[expect(clippy::missing_const_for_fn)] + pub fn recommendation(&self) -> Option<&str> { + match self { + Self::ReadSshFile(_) => Some("consider running `chmod o+r` if you haven't already"), + _ => None, + } + } +} + +impl AppState { + /// Gets [`consts::AUTHORIZED_KEYS_PATH`] from user's home. + /// + /// # Errors + /// + /// In any of [`GetAuthorizedKeysError`] + pub async fn get_authorized_ssh_keys( + &self, + username: &str, + ) -> Result, GetKeysError> { + async fn read_users_authorized_keys( + home_dir: &Path, + ) -> Result, GetKeysError> { + let buf = read_limited_path_str::<{ consts::MAX_AUTHORIZED_KEYS_SIZE }>( + &home_dir.join(consts::AUTHORIZED_KEYS_PATH), + ) + .await + .map_err(GetKeysError::ReadSshFile)?; + + Ok(buf.lines().map(PublicKey::from_openssh).try_collect()?) + } + + let user = self + .get_user_by_name(username) + .await + .ok_or(GetKeysError::UserNotFound)?; + + Ok(read_users_authorized_keys(user.home_dir()) + .await? + .into_boxed_slice()) + } +} diff --git a/src/server/caches/users.rs b/src/server/state/users.rs similarity index 96% rename from src/server/caches/users.rs rename to src/server/state/users.rs index d73f916..fe91e04 100644 --- a/src/server/caches/users.rs +++ b/src/server/state/users.rs @@ -5,6 +5,8 @@ use std::{ffi::OsStr, sync::Arc}; use tokio::sync::Mutex; use users::Users as _; +pub type UID = u32; + pub struct UsersCache { cache: Mutex, }