Compare commits

..

No commits in common. "owl" and "main" have entirely different histories.
owl ... main

14 changed files with 780 additions and 816 deletions

956
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,13 @@ version = "0.1.0"
edition = "2024"
[dependencies]
based_auth = { git = "https://git.hydrar.de/jmarya/based_auth" }
env_logger = "0.10.0"
hex = "0.4.3"
rayon = "1.7.0"
regex = "1.9.5"
ring = "0.16.20"
walkdir = "2.4.0"
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4.38", features = ["serde"] }
futures = "0.3.30"
log = "0.4.20"
rocket = { version = "0.5.1", features = ["json"] }
@ -27,7 +26,6 @@ data-encoding = "2.6.0"
bcrypt = "0.16.0"
dashmap = "6.1.0"
async-stream = "0.3.6"
owl = { git = "https://git.hydrar.de/red/owl" }
[build-dependencies]
reqwest = { version = "0.11", features = ["blocking"] }

View file

@ -52,15 +52,12 @@ pub trait AssetRoutes {
impl AssetRoutes for rocket::Rocket<Build> {
fn mount_assets(self) -> Self {
self.mount(
"/",
routes![
self.mount("/", routes![
crate::asset::htmx_script_route,
crate::asset::flowbite_css,
crate::asset::flowbite_js,
crate::asset::material_css,
crate::asset::material_font
],
)
])
}
}

24
src/auth/auth.sql Normal file
View file

@ -0,0 +1,24 @@
CREATE TYPE user_role AS ENUM ('regular', 'admin');
CREATE TYPE session_kind AS ENUM ('api', 'user');
CREATE TABLE IF NOT EXISTS users (
username VARCHAR(255) NOT NULL PRIMARY KEY,
"password" text NOT NULL,
user_role user_role NOT NULL DEFAULT 'regular'
);
CREATE TABLE IF NOT EXISTS user_session (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
token text NOT NULL,
"user" varchar(255) NOT NULL,
"created" timestamptz NOT NULL DEFAULT NOW(),
"csrf" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" varchar(255),
kind session_kind NOT NULL DEFAULT 'user',
FOREIGN KEY("user") REFERENCES users(username)
);
CREATE TABLE IF NOT EXISTS user_profile_pic (
username VARCHAR(255) NOT NULL PRIMARY KEY,
"image" bytea NOT NULL
);

49
src/auth/csrf.rs Normal file
View file

@ -0,0 +1,49 @@
use maud::PreEscaped;
use super::User;
use crate::{get_pg, ui::prelude::script};
use std::str::FromStr;
pub trait CSRF {
fn get_csrf(&self) -> impl std::future::Future<Output = uuid::Uuid>;
fn verify_csrf(&self, csrf: &str) -> impl std::future::Future<Output = bool>;
fn update_csrf(&self) -> impl std::future::Future<Output = PreEscaped<String>>;
}
impl CSRF for User {
/// Javascript to update the `value` of an element with id `csrf`.
///
/// This is useful for htmx requests to update the CSRF token in place.
async fn update_csrf(&self) -> PreEscaped<String> {
script(&format!(
"document.querySelectorAll('.csrf').forEach(element => {{ element.value = '{}'; }});",
self.get_csrf().await
))
}
/// Get CSRF Token for the current session
async fn get_csrf(&self) -> uuid::Uuid {
let res: (uuid::Uuid,) = sqlx::query_as("SELECT csrf FROM user_session WHERE token = $1")
.bind(&self.session)
.fetch_one(get_pg!())
.await
.unwrap();
res.0
}
/// Verify CSRF and generate a new one
async fn verify_csrf(&self, csrf: &str) -> bool {
if self.get_csrf().await == uuid::Uuid::from_str(csrf).unwrap_or_default() {
sqlx::query("UPDATE user_session SET csrf = gen_random_uuid() WHERE token = $1")
.bind(&self.session)
.execute(get_pg!())
.await
.unwrap();
return true;
}
false
}
}

31
src/auth/mod.rs Normal file
View file

@ -0,0 +1,31 @@
pub mod csrf;
pub mod profile_pic;
mod session;
mod user;
pub use session::Session;
pub use session::Sessions;
pub use user::APIUser;
pub use user::AdminUser;
pub use user::MaybeUser;
pub use user::User;
pub use user::UserRole;
/// A macro to check if a user has admin privileges.
///
/// This macro checks whether the provided user has admin privileges by calling the `is_admin` method on it.
/// If the user is not an admin, it returns a `Forbidden` error with a message indicating the restriction.
///
/// # Arguments
/// * `$u` - The user to check.
///
/// # Returns
/// The macro does not return a value directly but controls the flow of execution. If the user is not an admin,
/// it returns a `Forbidden` error immediately and prevents further execution.
#[macro_export]
macro_rules! check_admin {
($u:ident) => {
if !$u.is_admin() {
return Err($crate::request::api::api_error("Forbidden"));
}
};
}

31
src/auth/profile_pic.rs Normal file
View file

@ -0,0 +1,31 @@
use crate::get_pg;
use super::User;
pub trait ProfilePic {
fn profile_pic(&self) -> impl std::future::Future<Output = Option<Vec<u8>>>;
fn set_profile_pic(&self, image: Vec<u8>) -> impl std::future::Future<Output = ()>;
}
impl ProfilePic for User {
/// Get a user's profile picture from the database
async fn profile_pic(&self) -> Option<Vec<u8>> {
let res: Option<(Vec<u8>,)> =
sqlx::query_as("SELECT image FROM user_profile_pic WHERE username = $1;")
.bind(&self.username)
.fetch_optional(get_pg!())
.await
.unwrap();
res.map(|x| x.0)
}
/// Set a user's profile picture in the database
async fn set_profile_pic(&self, image: Vec<u8>) {
sqlx::query("INSERT INTO user_profile_pic (username, image) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET image = EXCLUDED.image",
)
.bind(&self.username)
.bind(&image)
.execute(get_pg!())
.await.unwrap();
}
}

118
src/auth/session.rs Normal file
View file

@ -0,0 +1,118 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::{gen_random, get_pg};
use super::{User, UserRole};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
/// The unique ID of the session token
pub id: uuid::Uuid,
/// The generated session token
pub token: String,
/// The username associated with the session token
pub user: String,
/// Session creation time
pub created: chrono::DateTime<Utc>,
/// Internal CSRF value
csrf: uuid::Uuid,
/// Named session value
pub name: Option<String>,
/// Kind of session
pub kind: SessionKind,
}
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "session_kind", rename_all = "lowercase")]
pub enum SessionKind {
API,
USER,
}
pub trait Sessions {
fn from_session(session: String) -> impl std::future::Future<Output = Option<User>>;
fn login(
username: &str,
password: &str,
) -> impl std::future::Future<Output = Option<(Session, UserRole)>>;
fn api_key(&self, name: &str) -> impl std::future::Future<Output = Session>;
fn session(&self) -> impl std::future::Future<Output = Session>;
fn list_sessions(&self) -> impl std::future::Future<Output = Vec<Session>>;
fn end_session(&self, id: &uuid::Uuid) -> impl std::future::Future<Output = ()>;
}
impl Sessions for User {
/// Generate a new API Key session
async fn api_key(&self, name: &str) -> Session {
sqlx::query_as(
"INSERT INTO user_session (token, \"user\", kind, name) VALUES ($1, $2, $3, $4) RETURNING *",
)
.bind(gen_random(64))
.bind(&self.username)
.bind(SessionKind::API)
.bind(name)
.fetch_one(get_pg!())
.await
.unwrap()
}
/// End a user session
async fn end_session(&self, id: &uuid::Uuid) {
sqlx::query("DELETE FROM user_session WHERE id = $1 AND \"user\" = $2")
.bind(id)
.bind(&self.username)
.execute(get_pg!())
.await
.unwrap();
}
/// Get all sessions for a user
async fn list_sessions(&self) -> Vec<Session> {
sqlx::query_as("SELECT * FROM user_session WHERE \"user\" = $1")
.bind(&self.username)
.fetch_all(get_pg!())
.await
.unwrap()
}
// Get a user from session ID
async fn from_session(session: String) -> Option<User> {
let user: Option<Self> = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(&session).fetch_optional(get_pg!()).await.unwrap();
if let Some(mut user) = user {
user.session = session;
return Some(user);
}
None
}
/// Login a user with the given username and password
async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> {
let u = Self::find(username).await?;
if !u.verify_pw(password) {
return None;
}
Some((u.session().await, u.user_role))
}
/// Generate a new session token for the user
///
/// Returns a Session instance containing the generated token and associated user
async fn session(&self) -> Session {
sqlx::query_as(
"INSERT INTO user_session (token, \"user\", kind) VALUES ($1, $2, $3) RETURNING *",
)
.bind(gen_random(64))
.bind(&self.username)
.bind(SessionKind::USER)
.fetch_one(get_pg!())
.await
.unwrap()
}
}

264
src/auth/user.rs Normal file
View file

@ -0,0 +1,264 @@
use rocket::{Request, http::Status, outcome::Outcome, request::FromRequest};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::FromRow;
use super::Sessions;
use crate::{get_pg, request::api::ToAPI};
// TODO : 2FA
/// User
///
/// # Example:
///
/// ```ignore
///
/// // Needs login
/// #[get("/myaccount")]
/// pub async fn account_page(ctx: RequestContext, user: User) -> StringResponse {
/// ...
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
/// The username chosen by the user
pub username: String,
/// The hashed password for the user
pub password: String,
/// The role of the user
pub user_role: UserRole,
#[sqlx(default)]
pub session: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
/// A regular user with limited permissions
Regular,
/// An admin user with full system privileges
Admin,
}
impl User {
/// Find a user by their username
pub async fn find(username: &str) -> Option<Self> {
sqlx::query_as("SELECT * FROM users WHERE username = $1")
.bind(username)
.fetch_optional(get_pg!())
.await
.unwrap()
}
/// Create a new user with the given details
///
/// Returns an Option containing the created user, or None if a user already exists with the same username
pub async fn create(username: String, password: &str, role: UserRole) -> Option<Self> {
// Check if a user already exists with the same username
if Self::find(&username).await.is_some() {
return None;
}
let u = Self {
username,
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
user_role: role,
session: String::new(),
};
sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)")
.bind(&u.username)
.bind(&u.password)
.bind(&u.user_role)
.execute(get_pg!())
.await
.unwrap();
Some(u)
}
/// Change the password of a User
///
/// Returns a Result indicating whether the password change was successful or not
pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> {
if self.verify_pw(old) {
sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;")
.bind(bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap())
.bind(&self.username)
.execute(get_pg!())
.await
.unwrap();
return Ok(());
}
Err(())
}
/// Find all users in the system
#[must_use]
pub async fn find_all() -> Vec<Self> {
sqlx::query_as("SELECT * FROM users")
.fetch_all(get_pg!())
.await
.unwrap()
}
/// Check if the user is an admin
#[must_use]
pub const fn is_admin(&self) -> bool {
matches!(self.user_role, UserRole::Admin)
}
/// Verify that a provided password matches the hashed password for the user
///
/// Returns a boolean indicating whether the passwords match or not
#[must_use]
pub fn verify_pw(&self, password: &str) -> bool {
bcrypt::verify(password, &self.password).unwrap()
}
}
impl ToAPI for User {
async fn api(&self) -> serde_json::Value {
json!({
"username": self.username,
"role": self.user_role
})
}
}
/// extracts a user from a request with `session` cookie
async fn extract_user(request: &Request<'_>) -> Option<User> {
if let Some(session_id) = request.cookies().get("session") {
if let Some(user) = User::from_session(session_id.value().to_string()).await {
return Some(user);
}
return None;
}
None
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for User {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await {
return Outcome::Success(user);
}
Outcome::Error((Status::Unauthorized, ()))
}
}
/// Struct which extracts a user with session from `Token` HTTP Header.
pub struct APIUser(pub User);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
match request.headers().get_one("token") {
Some(key) => {
if let Some(user) = User::from_session(key.to_string()).await {
return Outcome::Success(APIUser(user));
}
return Outcome::Error((Status::Unauthorized, ()));
}
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
/// Maybe User?
///
/// This struct extracts a user if possible, but also allows anybody.
///
/// # Example:
///
/// ```ignore
///
/// // Publicly accessable
/// #[get("/")]
/// pub async fn index(ctx: RequestContext, user: MaybeUser) -> StringResponse {
/// match user {
/// MaybeUser::User(user) => println!("You are {}", user.username),
/// MaybeUser::Anonymous => println!("Who are you?")
/// }
/// }
/// ```
pub enum MaybeUser {
User(User),
Anonymous,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for MaybeUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await {
return Outcome::Success(MaybeUser::User(user));
}
Outcome::Success(MaybeUser::Anonymous)
}
}
impl From<MaybeUser> for Option<User> {
fn from(value: MaybeUser) -> Self {
value.take_user()
}
}
impl MaybeUser {
#[must_use]
pub const fn user(&self) -> Option<&User> {
match self {
MaybeUser::User(user) => Some(user),
MaybeUser::Anonymous => None,
}
}
#[must_use]
pub fn take_user(self) -> Option<User> {
match self {
MaybeUser::User(user) => Some(user),
MaybeUser::Anonymous => None,
}
}
}
/// Admin User
///
/// This struct expects an Admin User and returns `Forbidden` otherwise.
///
/// # Example:
///
/// ```ignore
///
/// // Only admin users can access this route
/// #[get("/admin")]
/// pub async fn admin_panel(ctx: RequestContext, user: AdminUser) -> StringResponse {
/// ...
/// }
/// ```
pub struct AdminUser(pub User);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await {
if user.is_admin() {
return Outcome::Success(AdminUser(user));
}
}
Outcome::Error((Status::Unauthorized, ()))
}
}

View file

@ -4,9 +4,7 @@ use rand::RngCore;
use tokio::sync::OnceCell;
pub mod asset;
pub mod auth {
pub use based_auth::*;
}
pub mod auth;
pub mod format;
pub mod ogp;
pub mod request;
@ -171,7 +169,6 @@ impl DatabaseType {
}
}
// TODO : fix errors on conflict -> ignore already inserted ones
pub async fn batch_insert(table: &str, values: &[String], entries: Vec<Vec<DatabaseType>>) {
assert_eq!(values.len(), entries.first().unwrap().len());

View file

@ -7,8 +7,11 @@ use rocket::response::Responder;
use std::io::Cursor;
use std::io::Read;
use std::io::Seek;
use std::os::unix::fs::FileExt;
use std::os::unix::fs::MetadataExt;
// TODO: Implement file based response
pub struct Data {
file: Option<String>,
raw: Option<Vec<u8>>,

View file

@ -29,9 +29,7 @@ pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) ->
) -> (String, PreEscaped<String>) {
let id = uuid::Uuid::new_v4().to_string();
(
format!("modal-{id}"),
html! {
(format!("modal-{id}"), html! {
div id=(format!("modal-{id}")) tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full" {
div class="relative p-4 w-full max-w-2xl max-h-full" {
@ -55,6 +53,5 @@ pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) ->
};
};
}};
},
)
})
}

View file

@ -287,13 +287,10 @@ pub fn BottomNavigationTile<T: UIWidget + 'static>(
) -> ClassicWidget<LinkWidget> {
Classic(
"inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group",
Link(
reference,
html! {
Link(reference, html! {
(icon.map(|x| x.render()).unwrap_or_default());
span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) };
},
),
}),
)
}

View file

@ -274,10 +274,7 @@ impl GridElement {
}
pub fn span(mut self, value: GridElementValue) -> Self {
self.1.push(format!(
"{}-span-{}",
self.2,
match value {
self.1.push(format!("{}-span-{}", self.2, match value {
GridElementValue::_1 => "1",
GridElementValue::_2 => "2",
GridElementValue::_3 => "3",
@ -291,8 +288,7 @@ impl GridElement {
GridElementValue::_11 => "11",
GridElementValue::_12 => "12",
GridElementValue::Auto => "full",
}
));
}));
self
}