Compare commits

...

12 Commits

Author SHA1 Message Date
19630506b4 dev: integrate tera into app data 2026-03-27 00:27:10 +01:00
bcec6af49b dev: monolithic app state
reasons stated at the top of such mod.rs file
2026-03-26 23:42:52 +01:00
4785ab529c feat: lay the ground for login 2026-03-26 21:56:23 +01:00
afc5e94adf dev: misc improve here and there 2026-03-26 21:47:10 +01:00
90dde65c91 feat: add logging 2026-03-26 21:39:49 +01:00
eddb3acacd test: add some test 2026-03-26 12:57:14 +01:00
60f81f6d4e dev: reduce build sizes (especially debug) 2026-03-24 23:48:33 +01:00
7a4876b0e8 feat: add image etag support 2026-03-24 23:47:38 +01:00
70f77f3501 perf: use const-str's include_asset! 2026-03-24 22:31:22 +01:00
8a65bb2c92 dev: do something about HEAD requests 2026-03-24 21:47:20 +01:00
af3d1a567b perf(neglible): completely veto Arc from AppState 2026-03-24 15:37:31 +01:00
4449607449 fix: bit mixing
so the fn was basically $ka + kb + kc + ... = k(a + b + c + d)$, so completely useless, this mixes better, and also position independent

and now my lovely dancing lain falls under the root domain path hash
2026-03-24 14:37:40 +01:00
26 changed files with 1708 additions and 277 deletions

996
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,23 @@ keywords = ["oauth", "oauth2", "oidc", "OpenID"]
categories = ["web-programming::http-server", "authentication"]
[profile.release]
lto = true
strip = "symbols" # almost half of binary size smh
# so I don't need "full" just yet and the debug builds are HUGE
[profile.dev]
debug = 1
[profile.dev.package.actix-web]
opt-level = 3
debug = false
[profile.dev.package.tokio]
opt-level = 3
debug = false
[features]
default = ["pamsock"]
default = ["pamsock", "log"]
pamsock = ["dep:pamsock"]
log = ["dep:log", "dep:env_logger"]
[dependencies]
pamsock = { version = "0.2.0", git = "https://git.javalsai.tuxcord.net/tuxcord/authy-pamsock.git", features = [
@@ -25,17 +37,26 @@ actix-web = "4.13"
anstyle = "1.0"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
const-macros = { git = "https://git.javalsai.tuxcord.net/rust/const-macros.git", version = "0.1.2" }
const-str = { version = "1.1", features = ["proc"] }
env_logger = { version = "0.11.10", optional = true }
futures-util = "0.3"
libc = "0.2"
# https://github.com/robo9k/rust-magic/issues/434
magic = { version = "0.16", git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
log = { version = "0.4.29", optional = true }
magic = "0.16"
moka = { version = "0.12", features = ["async-lock", "future"] }
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.11"
ssh-key = "0.6.7"
thiserror = "2.0"
tokio = { version = "1.49", features = ["full"] }
toml = "1.0"
users = "0.11"
tera = "1.20.1"
[patch.crates-io]
# https://github.com/robo9k/rust-magic/issues/434
magic = { git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
[lints.clippy]
cargo = { level = "warn", priority = -1 }
@@ -43,6 +64,8 @@ multiple_crate_versions = { level = "allow" } # otherwise there's too much
correctness = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
option_if_let_else = { level = "allow" } # I personally sometimes prefer what this prevents
pedantic = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 }
style = { level = "deny", priority = -1 }

View File

@@ -11,3 +11,7 @@ This is a reimplementation of the oauth2 server I was making and forgot where I
# License
All code licensed under the GPL-2.0-only.
# Known Issues
HTTP `HEAD` requests are not responded to properly, I'm exploring solutions but each one has its drawbacks to consider.

View File

@@ -4,5 +4,9 @@
groups = ["wheel", 0, "users"]
# magic_paths = [ "/usr/share/file/misc/magic" ]
# [server]
[server]
# listen = "127.0.0.1:8080"
templates = "assets/templates/**/*"
[render]
title = "My Server"

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }} - Login</title>
<link href="css/style.css" rel="stylesheet" />
</head>
<body>
{% include "partials/header.html" %}
<main>
<form method="post">
<h2>Username</h2>
<input type="text" name="username" placeholder="username" />
<h2>Password</h2>
<input type="password" name="password" placeholder="password" />
<button type="submit">Login</button>
</form>
</main>
</body>
</html>

View File

@@ -0,0 +1,3 @@
<nav class="header">
<span>header</header>
</nav>

View File

@@ -22,11 +22,27 @@ pub struct Unix {
#[serde(default)]
pub struct Server {
pub listen: SocketAddr,
pub templates: String,
}
impl Default for Server {
fn default() -> Self {
Self {
listen: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8080)),
templates: String::from("/var/lib/authy-oidc/templates/**/*"),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Render {
pub title: String,
}
impl Default for Render {
fn default() -> Self {
Self {
title: String::from("OpenID Connect Server"),
}
}
}
@@ -36,6 +52,7 @@ impl Default for Server {
pub struct Config {
pub unix: Unix,
pub server: Server,
pub render: Render,
}
#[derive(thiserror::Error, Debug)]

View File

@@ -2,6 +2,8 @@
use std::time::Duration;
use const_macros::{file::FileAsset, include_asset};
/// Max [`moka`] pfp cache capacity
pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
@@ -9,8 +11,7 @@ pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
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";
pub const DEFAULT_USER_PFP: FileAsset = include_asset!("assets/default-pfp.png");
/// 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));
@@ -36,6 +37,11 @@ pub const USER_PFP_PATHS: &[&str] = &[
".face",
];
/// Path relative to a user's home to ssh's `.authorized_keys`, used to verify user's identity when
/// distrusted
pub const AUTHORIZED_KEYS_PATH: &str = ".ssh/authorized_keys";
pub const MAX_AUTHORIZED_KEYS_SIZE: u64 = 64 * 1024; // 64 KiB
pub const ERROR_ASCII_ARTS: &[&str] = &[
include_str!("../assets/lain/lain-dancing.txt"),
include_str!("../assets/lain/lain-head.txt"),
@@ -43,6 +49,14 @@ pub const ERROR_ASCII_ARTS: &[&str] = &[
include_str!("../assets/lain/lain-teddy.txt"),
];
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";
}
pub mod web_scopes {
pub const IMAGES: &str = "/image";
pub const LOGIN: &str = "/login";
}

33
src/db/mod.rs Normal file
View File

@@ -0,0 +1,33 @@
//! Everything that interacts with DB (or generally persistent state)
//!
//! Currently it's all in memory or with default values until I model all the data properly and
//! choose a DB provider.
use std::collections::HashSet;
use tokio::sync::RwLock;
use crate::server::state::users::UID;
pub struct DB {
enabled_users: RwLock<HashSet<UID>>,
}
impl DB {
#[must_use]
#[expect(clippy::new_without_default)]
pub fn new() -> Self {
Self {
enabled_users: RwLock::new(HashSet::new()),
}
}
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: &UID) -> bool {
self.enabled_users.read().await.contains(user)
}
}

View File

@@ -2,6 +2,7 @@
#![feature(
decl_macro,
duration_constructors,
iterator_try_collect,
never_type,
once_cell_try,
seek_stream_len
@@ -26,6 +27,34 @@
//!
//! I will try to keep those 3 modules as documented as possible, please feel free to open any
//! issues/PRs regarding information in there.
//!
//! # Public Information
//!
//! To make sure this application doesn't expose any public imformation it's important to define
//! what public information we are willing to expose. The application deals with user information
//! so it must leak at least some information, to make sure we don't overreach, we must have clear
//! where we draw the line.
//!
//! By default all information is private, but this application might leak by default:
//!
//! - **User system information:** Unix's UID of a given username.
//! - **User profile pictures:** See [`consts::USER_PFP_PATHS`].
//! - **User's `autorized_ssh_keys`:** See [`consts::AUTHORIZED_KEYS_PATH`].
//!
//! Note that no file information within user's home can be accessed until the user adds `o+x`
//! permissions on their home directory. Once this is done, only state of files regarding the
//! previous can be publicly accessible, there's no arbirtary path reading.
//!
//! Any user information is checked ASAP against the allowed groups (see [`conf::Unix::groups`]) to
//! fail fast without exposing any personal information for users alien to these groups. That means
//! that any reference to the "user", will assume its already from an allowed group, if its not a
//! group member, it will be treated as nonexistent.
//!
//! Information about existance of a user alien to the configured groups might vulnerable to timing
//! attacks though.
//!
//! TODO: This was clearly defined after some API was already written so these assumptions will
//! need to be reviewed for the old code (notably pfp logic).
use std::fs::File;
@@ -37,6 +66,7 @@ pub mod args;
pub mod auth;
pub mod conf;
pub mod consts;
pub mod db;
pub mod ext;
pub mod serdes;
pub mod server;
@@ -45,6 +75,8 @@ pub mod utils;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = args::Args::parse();
#[cfg(feature = "log")]
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let conf = if let Some(conf) = File::try_open(&args.conf) {
conf::Config::from_toml_file(&mut conf?)?
} else {
@@ -59,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).await?;
let app_state = server::state::AppState::new(args, conf, db::DB::new())?;
server::start_app(Box::leak(Box::new(app_state))).await?;
Ok(())
}

View File

@@ -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(

View File

@@ -1,176 +0,0 @@
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<'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>>,
}
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> {
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)
}
}

View File

@@ -1,46 +1,31 @@
use std::io;
use actix_web::{App, HttpServer, web};
use actix_web::{App, HttpServer, middleware, 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>,
}
pub mod state;
pub mod static_app_data;
/// 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<()> {
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,
}));
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(web::Data::new(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
}

View File

@@ -1,12 +1,10 @@
//! 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
//! - `/default`: Gives the default image.
//! - `/user/{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,
@@ -15,7 +13,7 @@ use actix_web::{
use crate::{
consts::{self, web_scopes as ws},
server,
server::state::DataExtractor,
};
#[must_use]
@@ -25,33 +23,39 @@ pub fn make_scope(path: &str) -> actix_web::Scope {
.service(get_image)
}
#[get("/")]
async fn get_default_image(
_data: web::Data<&server::AppState>,
_username: web::Path<String>,
) -> HttpResponse {
#[get("/default")]
async fn get_default_image() -> 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))
.insert_header((
header::ETAG,
const_str::concat!('"', consts::DEFAULT_USER_PFP.shasum_str, '"'),
))
.content_type(consts::DEFAULT_USER_PFP.mime)
.body(web::Bytes::from_static(consts::DEFAULT_USER_PFP.bytes))
}
#[get("/{username}")]
#[get("/user/{username}")]
async fn get_image(
data: web::Data<&server::AppState>,
data: DataExtractor,
username: web::Path<String>,
) -> 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(
|| web::Either::Left(web::Redirect::to(ws::IMAGES).temporary()),
|| {
web::Either::Left(
web::Redirect::to(const_str::concat!(ws::IMAGES, "/default")).temporary(),
)
},
|img| {
web::Either::Right(
HttpResponse::Ok()
.insert_header((header::CACHE_CONTROL, consts::USER_CACHES_HEADER))
.insert_header((header::ETAG, img.shasum.as_ref()))
.content_type(img.mime.as_ref())
.body(web::Bytes::copy_from_slice(img.bytes.as_ref())),
)

View File

@@ -0,0 +1,72 @@
use actix_web::{
HttpResponse, get, post,
web::{self, Path},
};
use crate::{consts, server::state::DataExtractor};
#[must_use]
pub fn make_scope(path: &str) -> actix_web::Scope {
web::scope(path)
.service(login)
.service(login_post)
.service(activate)
}
#[get("")]
pub async fn login(data: DataExtractor) -> HttpResponse {
let ctx = data.make_default_tera_ctx();
let Ok(rendered) = data.tera.render("login.html", &ctx) else {
return HttpResponse::InternalServerError()
.content_type(consts::mime::TEXT)
.body("Internal server error, please report");
};
HttpResponse::Ok()
.content_type(consts::mime::HTML)
.body(rendered)
}
#[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: 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()
.content_type(consts::mime::HTML)
.body(format!(
"<html><body>\
<h1>TODO, can you login? ({user_can_login})</h1>\
</body></html>",
))
}
#[get("/activate/{username}")]
pub async fn activate(data: DataExtractor, username: Path<String>) -> HttpResponse {
let keys = data.get_authorized_ssh_keys(&username).await;
match keys {
Ok(keys) => HttpResponse::Ok()
.content_type(consts::mime::TEXT)
.body(format!("{keys:#?}")),
Err(err) => HttpResponse::BadRequest()
.content_type(consts::mime::TEXT)
.body(if let Some(recommendation) = err.recommendation() {
format!("{err}\n\nsuggestion: {recommendation}")
} else {
err.to_string()
}),
}
}

View File

@@ -1,2 +1,3 @@
pub mod images;
pub mod not_found;
pub mod login;

View File

@@ -1,7 +1,4 @@
use actix_web::{
HttpRequest, HttpResponse,
web::{self, Bytes},
};
use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::stream;
use crate::consts;
@@ -11,9 +8,10 @@ fn mix_u32s(seed: impl Iterator<Item = u32>) -> u32 {
const U32_BIT_MIXING_CONSTANT: u32 = 0x7feb_352d;
const U32_SELF_BIT_MIXING: u32 = 0x846c_a68b;
let mut random = seed.fold(0_u32, |acc, e| {
let mut random = seed.fold(0_u32, |mut acc, e| {
// mix bits
acc.wrapping_add(e.wrapping_mul(U32_BIT_MIXING_CONSTANT))
acc ^= e.wrapping_mul(e);
acc.wrapping_mul(U32_BIT_MIXING_CONSTANT).wrapping_add(e)
});
// GPT's suggestion for a nicer distribution, like 8 cpu instructions so whatever
@@ -25,6 +23,16 @@ fn mix_u32s(seed: impl Iterator<Item = u32>) -> u32 {
}
pub async fn not_found(req: HttpRequest) -> HttpResponse {
if req.method().as_str().eq_ignore_ascii_case("HEAD") {
return HttpResponse::NotImplemented()
.insert_header((
"Unimplemented",
"The HEAD method is not yet implemented \
and this response is not valid for this endpoint",
))
.body(());
}
let seed = req.path().as_bytes();
let random = mix_u32s(seed.iter().copied().map(u32::from));
@@ -34,12 +42,12 @@ pub async fn not_found(req: HttpRequest) -> HttpResponse {
let url = req.path();
HttpResponse::NotFound()
.content_type("text/plain; charset=utf-8")
.content_type(consts::mime::TEXT)
.streaming(stream::iter([
Ok::<_, !>(web::Bytes::from(format!(
"> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n"
))),
Ok(Bytes::from_static(res.as_bytes())),
Ok(web::Bytes::from_static(res.as_bytes())),
]))
// .body(format!(
// "> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n{res}"

13
src/server/state/db.rs Normal file
View 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)
}
}

View File

@@ -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<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
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 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<Result<String, magic::cookie::Error>, NewCookieError> {
self.use_cookie(|c| c.buffer(buffer))
self.use_magic_cookie(|c| c.buffer(buffer))
}
}

103
src/server/state/mod.rs Normal file
View File

@@ -0,0 +1,103 @@
//! 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 tera;
pub mod users;
pub struct AppState {
pub args: crate::args::Args,
pub config: crate::conf::Config,
// `db.rs`
db: crate::db::DB,
// `tera.rs`
pub tera: ::tera::Tera,
// `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 {
/// # Errors
///
/// If [`tera`] failed to load templates path.
pub fn new(
args: crate::args::Args,
config: crate::conf::Config,
db: crate::db::DB,
) -> Result<Self, ::tera::Error> {
let tera = ::tera::Tera::new(&config.server.templates)?;
Ok(Self {
args,
config,
db,
tera,
// `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
View 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
View 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())
}
}

19
src/server/state/tera.rs Normal file
View File

@@ -0,0 +1,19 @@
//! File that contains most logic for render logic.
//!
//! Notable stuff:
//! - [`AppState::make_default_tera_ctx`]
use super::AppState;
impl AppState {
/// Makes the default context for the template engine, read source to know which tera variables
/// should be available by default.
///
/// For a guide on how to write tera templates: <https://keats.github.io/tera/docs>
pub fn make_default_tera_ctx(&self) -> tera::Context {
let mut ctx = tera::Context::new();
ctx.insert("title", &self.config.render.title);
ctx
}
}

View File

@@ -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<users::UsersCache>,
}

View File

@@ -0,0 +1,38 @@
use std::{future::Ready, ops::Deref};
use actix_web::{error::InternalError, http::StatusCode};
/// App data extractor without any [`Arc`] inbetween, perefct if the app data is a simple
/// `&'static` reference that can be copied, no atomic anywhere.
pub struct StaticAppDataExtractor<T> {
pub data: T,
}
impl<T> Deref for StaticAppDataExtractor<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl<T: Copy + 'static> actix_web::FromRequest for StaticAppDataExtractor<T> {
type Error = InternalError<&'static str>;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
let val = match req.app_data::<T>() {
Some(&data) => Ok(Self { data }),
None => Err(InternalError::new(
"Requested application data is not configured correctly. \
View/enable debug logs for more details.",
StatusCode::INTERNAL_SERVER_ERROR,
)),
};
std::future::ready(val)
}
}

View File

@@ -37,3 +37,103 @@ pub mod web {
}
}
}
pub mod shasum {
#[must_use]
pub fn sha256sum_to_hex_string(sha256sum: &[u8]) -> String {
let mut out = String::with_capacity(sha256sum.len() * 2);
for &b in sha256sum {
/// Only correct as long as `n` is ranged within a nibble
#[inline]
const fn nibble_to_hex(n: u8) -> char {
(match n {
0..=9 => b'0' + n,
_ => b'a' + (n - 10),
}) as char
}
let hi = (b >> 4) & 0x0f;
let lo = b & 0x0f;
out.push(nibble_to_hex(hi));
out.push(nibble_to_hex(lo));
}
out
}
#[cfg(test)]
mod test {
use crate::utils::shasum::sha256sum_to_hex_string;
#[test]
fn sha256sum_string() {
let null_sha = sha256sum_to_hex_string(&[0; 32]);
let full_sha = sha256sum_to_hex_string(&[0xff; 32]);
assert_eq!(
null_sha,
"0000000000000000000000000000000000000000000000000000000000000000"
);
assert_eq!(
full_sha,
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
);
}
}
}
pub mod fs {
use std::{io, os::unix::fs::MetadataExt as _, path::Path};
use tokio::{fs::File, io::AsyncReadExt};
/// # Errors
///
/// On any underlaying file i/o error.
///
/// # Panics
///
/// If a file's [`u64`] doesn't fit in memory.
pub 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)
}
/// # Errors
///
/// On any underlaying file i/o error.
///
/// # Panics
///
/// If a file's [`u64`] doesn't fit in memory.
pub async fn read_limited_path_str<const MAXSIZE: u64>(path: &Path) -> io::Result<String> {
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 = String::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_string(&mut buf).await?;
Ok(buf)
}
}