dev: monolithic app state
reasons stated at the top of such mod.rs file
This commit is contained in:
@@ -50,6 +50,8 @@ pub const ERROR_ASCII_ARTS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
pub mod mime {
|
pub mod mime {
|
||||||
|
pub const MIME_IMAGE_PREFIX: &str = "image/";
|
||||||
|
|
||||||
pub const TEXT: &str = "text/plain; charset=utf-8";
|
pub const TEXT: &str = "text/plain; charset=utf-8";
|
||||||
pub const HTML: &str = "text/html; charset=utf-8";
|
pub const HTML: &str = "text/html; charset=utf-8";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::server::state::users::UID;
|
||||||
|
|
||||||
pub struct DB {
|
pub struct DB {
|
||||||
// TODO: should I use some sort of UIDs instead of the username everywhere?
|
enabled_users: RwLock<HashSet<UID>>,
|
||||||
enabled_users: RwLock<HashSet<String>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DB {
|
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);
|
self.enabled_users.write().await.insert(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[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)
|
self.enabled_users.read().await.contains(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// (idek japanese but im vibing)
|
// (idek japanese but im vibing)
|
||||||
println!("\n\x1b[1;3;4;33mConfiguration\x1b[0m: {conf:#?}\n");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub mod inner_helpers;
|
pub mod inner_helpers;
|
||||||
|
|
||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||||
pub struct Group(#[serde(deserialize_with = "inner_helpers::deserialize_group")] pub gid_t);
|
pub struct Group(#[serde(deserialize_with = "inner_helpers::deserialize_group")] gid_t);
|
||||||
|
|
||||||
impl Debug for Group {
|
impl Debug for Group {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
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)]
|
#[repr(transparent)]
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct DatabasePaths(
|
pub struct DatabasePaths(
|
||||||
|
|||||||
@@ -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<str>,
|
|
||||||
pub bytes: Box<[u8]>,
|
|
||||||
pub shasum: Box<str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Image = Arc<ImageInfo>;
|
|
||||||
|
|
||||||
// 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<Image>`] because users may not have a pfp.
|
|
||||||
pfp_cache: Cache<String, Option<Image>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<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> {
|
|
||||||
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<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets [`consts::AUTHORIZED_KEYS_PATH`] from user's home.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// In any of [`GetAuthorizedKeysError`]
|
|
||||||
pub async fn get_authorized_keys(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
) -> Result<Box<[PublicKey]>, GetAuthorizedKeysError> {
|
|
||||||
async fn read_users_authorized_keys(
|
|
||||||
home_dir: &Path,
|
|
||||||
) -> Result<Vec<PublicKey>, 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),
|
|
||||||
}
|
|
||||||
@@ -2,55 +2,30 @@ use std::io;
|
|||||||
|
|
||||||
use actix_web::{App, HttpServer, middleware, web};
|
use actix_web::{App, HttpServer, middleware, web};
|
||||||
|
|
||||||
pub mod caches;
|
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod state;
|
||||||
pub mod static_app_data;
|
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.
|
/// 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(
|
pub async fn start_app(state: &'static state::AppState) -> io::Result<()> {
|
||||||
args: crate::args::Args,
|
|
||||||
config: crate::conf::Config,
|
|
||||||
db: crate::db::DB,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
use crate::consts::web_scopes as ws;
|
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!(
|
println!(
|
||||||
"\x1b[34mINF\x1b[0m: Trying to listen on \x1b[35m{:?}\x1b[0m",
|
"\x1b[34mINF\x1b[0m: Trying to listen on \x1b[35m{:?}\x1b[0m",
|
||||||
app.config.server.listen
|
state.config.server.listen
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(app)
|
.app_data(state)
|
||||||
.wrap(middleware::Logger::new("%a (%{r}a) %r -> %s, %b B in %T s"))
|
.wrap(middleware::Logger::new("%a (%{r}a) %r -> %s, %b B in %T s"))
|
||||||
.wrap(middleware::NormalizePath::trim())
|
.wrap(middleware::NormalizePath::trim())
|
||||||
.service(services::images::make_scope(ws::IMAGES))
|
.service(services::images::make_scope(ws::IMAGES))
|
||||||
.service(services::login::make_scope(ws::LOGIN))
|
.service(services::login::make_scope(ws::LOGIN))
|
||||||
.default_service(web::to(services::not_found::not_found))
|
.default_service(web::to(services::not_found::not_found))
|
||||||
})
|
})
|
||||||
.bind(&app.config.server.listen)?
|
.bind(&state.config.server.listen)?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use actix_web::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
consts::{self, web_scopes as ws},
|
consts::{self, web_scopes as ws},
|
||||||
server::AppData,
|
server::state::DataExtractor,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -40,10 +40,10 @@ async fn get_default_image() -> HttpResponse {
|
|||||||
|
|
||||||
#[get("/user/{username}")]
|
#[get("/user/{username}")]
|
||||||
async fn get_image(
|
async fn get_image(
|
||||||
data: AppData,
|
data: DataExtractor,
|
||||||
username: web::Path<String>,
|
username: web::Path<String>,
|
||||||
) -> web::Either<Redirect, HttpResponse> {
|
) -> web::Either<Redirect, HttpResponse> {
|
||||||
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(
|
cached_pfp.as_ref().map_or_else(
|
||||||
|| {
|
|| {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use actix_web::{
|
|||||||
web::{self, Path},
|
web::{self, Path},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{consts, server::AppData};
|
use crate::{consts, server::state::DataExtractor};
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn make_scope(path: &str) -> actix_web::Scope {
|
pub fn make_scope(path: &str) -> actix_web::Scope {
|
||||||
@@ -14,34 +14,48 @@ pub fn make_scope(path: &str) -> actix_web::Scope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("")]
|
#[get("")]
|
||||||
pub async fn login(_data: AppData) -> HttpResponse {
|
pub async fn login(_data: DataExtractor) -> HttpResponse {
|
||||||
HttpResponse::Ok().content_type(consts::mime::HTML).body(
|
HttpResponse::Ok().content_type(consts::mime::HTML).body(
|
||||||
"<html><body>\
|
"<html><body>\
|
||||||
<form method=\"post\">\
|
<form method=\"post\">\
|
||||||
<h2>Username</h2>\
|
<h2>Username</h2>\
|
||||||
<input type=\"text\" placeholder=\"username\"/>\
|
<input type=\"text\" name=\"username\" placeholder=\"username\"/>\
|
||||||
<h2>Password</h2>\
|
<h2>Password</h2>\
|
||||||
<input type=\"password\" placeholder=\"password\"/>\
|
<input type=\"password\" name=\"password\" placeholder=\"password\"/>\
|
||||||
<button type=\"submit\">Login</button>\
|
<button type=\"submit\">Login</button>\
|
||||||
</form>\
|
</form>\
|
||||||
</body></html>",
|
</body></html>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct LoginForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects a form's post data in the form of [`LoginForm`]
|
||||||
#[post("")]
|
#[post("")]
|
||||||
pub async fn login_post(_data: AppData) -> HttpResponse {
|
pub async fn login_post(data: DataExtractor, form: web::Form<LoginForm>) -> 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()
|
HttpResponse::NotImplemented()
|
||||||
.content_type(consts::mime::HTML)
|
.content_type(consts::mime::HTML)
|
||||||
.body(
|
.body(format!(
|
||||||
"<html><body>\
|
"<html><body>\
|
||||||
<h1>TODO</h1>\
|
<h1>TODO, can you login? ({user_can_login})</h1>\
|
||||||
</body></html>",
|
</body></html>",
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/activate/{username}")]
|
#[get("/activate/{username}")]
|
||||||
pub async fn activate(data: AppData, username: Path<String>) -> HttpResponse {
|
pub async fn activate(data: DataExtractor, username: Path<String>) -> HttpResponse {
|
||||||
let keys = data.cache.get_authorized_keys(&username).await;
|
let keys = data.get_authorized_ssh_keys(&username).await;
|
||||||
|
|
||||||
match keys {
|
match keys {
|
||||||
Ok(keys) => HttpResponse::Ok()
|
Ok(keys) => HttpResponse::Ok()
|
||||||
@@ -49,6 +63,10 @@ pub async fn activate(data: AppData, username: Path<String>) -> HttpResponse {
|
|||||||
.body(format!("{keys:#?}")),
|
.body(format!("{keys:#?}")),
|
||||||
Err(err) => HttpResponse::BadRequest()
|
Err(err) => HttpResponse::BadRequest()
|
||||||
.content_type(consts::mime::TEXT)
|
.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()
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/server/state/db.rs
Normal file
13
src/server/state/db.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::server::state::users::UID;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn enable_user(&self, user: UID) -> impl Future<Output = ()> {
|
||||||
|
self.db.enable_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_user_enabled(&self, user: &UID) -> impl Future<Output = bool> {
|
||||||
|
self.db.user_is_enabled(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use std::cell::OnceCell;
|
|||||||
|
|
||||||
use magic::{Cookie, cookie};
|
use magic::{Cookie, cookie};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum NewCookieError {
|
pub enum NewCookieError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -16,23 +18,19 @@ pub enum NewCookieError {
|
|||||||
// MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError),
|
// MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Mime<'a> {
|
impl AppState {
|
||||||
magic_dbs: &'a magic::cookie::DatabasePaths,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Mime<'a> {
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static COOKIE_CELL: OnceCell<Cookie<cookie::Load>> = const { OnceCell::new() };
|
static MAGIC_COOKIE_CELL: OnceCell<Cookie<cookie::Load>> = const { OnceCell::new() };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn use_cookie<F, T>(&self, f: F) -> Result<T, NewCookieError>
|
fn use_magic_cookie<F, T>(&self, f: F) -> Result<T, NewCookieError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&Cookie<cookie::Load>) -> T,
|
F: FnOnce(&Cookie<cookie::Load>) -> T,
|
||||||
{
|
{
|
||||||
Self::COOKIE_CELL.with(|cookie| {
|
Self::MAGIC_COOKIE_CELL.with(|cookie| {
|
||||||
let may_cookie = cookie.get_or_try_init::<_, NewCookieError>(move || {
|
let may_cookie = cookie.get_or_try_init::<_, NewCookieError>(move || {
|
||||||
let cookie = magic::Cookie::open(magic::cookie::Flags::MIME)?;
|
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 {
|
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
|
/// 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.
|
/// 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
|
/// First layer error fails if new cookie creation failed. Second layer error represents if the
|
||||||
/// mime search was successful.
|
/// mime search was successful.
|
||||||
pub fn buffer(
|
pub fn magic_from_buffer(
|
||||||
&self,
|
&self,
|
||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
) -> Result<Result<String, magic::cookie::Error>, NewCookieError> {
|
) -> Result<Result<String, magic::cookie::Error>, NewCookieError> {
|
||||||
self.use_cookie(|c| c.buffer(buffer))
|
self.use_magic_cookie(|c| c.buffer(buffer))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
89
src/server/state/mod.rs
Normal file
89
src/server/state/mod.rs
Normal file
@@ -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<Image>`] because users may not have a pfp.
|
||||||
|
pfp_cache: Cache<String, Option<pfp::Image>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<S: AsRef<OsStr> + ?Sized>(
|
||||||
|
&self,
|
||||||
|
username: &S,
|
||||||
|
) -> Option<Arc<::users::User>> {
|
||||||
|
fn is_member_groups(
|
||||||
|
only_groups: &[serdes::Group],
|
||||||
|
groups: Option<impl AsRef<[::users::Group]>>,
|
||||||
|
) -> 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>;
|
||||||
100
src/server/state/pfp.rs
Normal file
100
src/server/state/pfp.rs
Normal file
@@ -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<str>,
|
||||||
|
pub bytes: Box<[u8]>,
|
||||||
|
pub shasum: Box<str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Image = Arc<ImageInfo>;
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If file's image size doesn't fit in the weighter's size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn weigther(_: &String, v: &Option<Image>) -> 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<Image> {
|
||||||
|
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<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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/server/state/ssh.rs
Normal file
63
src/server/state/ssh.rs
Normal file
@@ -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<Box<[PublicKey]>, GetKeysError> {
|
||||||
|
async fn read_users_authorized_keys(
|
||||||
|
home_dir: &Path,
|
||||||
|
) -> Result<Vec<PublicKey>, 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ use std::{ffi::OsStr, sync::Arc};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use users::Users as _;
|
use users::Users as _;
|
||||||
|
|
||||||
|
pub type UID = u32;
|
||||||
|
|
||||||
pub struct UsersCache {
|
pub struct UsersCache {
|
||||||
cache: Mutex<users::UsersCache>,
|
cache: Mutex<users::UsersCache>,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user