Compare commits

...

2 commits

Author SHA1 Message Date
09c6b833e7
Merge branch 'owl' of git.hydrar.de:jmarya/based into owl 2025-04-28 15:42:21 +02:00
c74e159ce0
port to owldb 2025-04-28 15:41:23 +02:00
9 changed files with 843 additions and 363 deletions

949
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@ data-encoding = "2.6.0"
bcrypt = "0.16.0" bcrypt = "0.16.0"
dashmap = "6.1.0" dashmap = "6.1.0"
async-stream = "0.3.6" async-stream = "0.3.6"
owl = { git = "ssh://git@git.hydrar.de/red/owl" }
[build-dependencies] [build-dependencies]
reqwest = { version = "0.11", features = ["blocking"] } reqwest = { version = "0.11", features = ["blocking"] }

View file

@ -1,24 +0,0 @@
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
);

View file

@ -1,7 +1,8 @@
use maud::PreEscaped; use maud::PreEscaped;
use owl::{query, update};
use super::User; use super::User;
use crate::{get_pg, ui::prelude::script}; use crate::{auth::Session, ui::prelude::script};
use std::str::FromStr; use std::str::FromStr;
pub trait CSRF { pub trait CSRF {
@ -23,23 +24,20 @@ impl CSRF for User {
/// Get CSRF Token for the current session /// Get CSRF Token for the current session
async fn get_csrf(&self) -> uuid::Uuid { async fn get_csrf(&self) -> uuid::Uuid {
let res: (uuid::Uuid,) = sqlx::query_as("SELECT csrf FROM user_session WHERE token = $1") assert!(!self.session.is_empty());
.bind(&self.session)
.fetch_one(get_pg!())
.await
.unwrap();
res.0 let res = query!(|s: &Session| { s.token == self.session });
res.first().unwrap().read().csrf
} }
/// Verify CSRF and generate a new one /// Verify CSRF and generate a new one
async fn verify_csrf(&self, csrf: &str) -> bool { async fn verify_csrf(&self, csrf: &str) -> bool {
if self.get_csrf().await == uuid::Uuid::from_str(csrf).unwrap_or_default() { 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") let mut res = query!(|s: &Session| { s.token == self.session });
.bind(&self.session) update!(&mut res, |s: &mut Session| {
.execute(get_pg!()) s.csrf = uuid::Uuid::new_v4();
.await });
.unwrap();
return true; return true;
} }

View file

@ -8,6 +8,7 @@ pub use user::APIUser;
pub use user::AdminUser; pub use user::AdminUser;
pub use user::MaybeUser; pub use user::MaybeUser;
pub use user::User; pub use user::User;
pub use user::UserAuth;
pub use user::UserRole; pub use user::UserRole;
/// A macro to check if a user has admin privileges. /// A macro to check if a user has admin privileges.

View file

@ -1,6 +1,5 @@
use crate::get_pg;
use super::User; use super::User;
use owl::{db::model::file::File, dereference, get, prelude::*, update};
pub trait ProfilePic { pub trait ProfilePic {
fn profile_pic(&self) -> impl std::future::Future<Output = Option<Vec<u8>>>; 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 = ()>; fn set_profile_pic(&self, image: Vec<u8>) -> impl std::future::Future<Output = ()>;
@ -9,23 +8,19 @@ pub trait ProfilePic {
impl ProfilePic for User { impl ProfilePic for User {
/// Get a user's profile picture from the database /// Get a user's profile picture from the database
async fn profile_pic(&self) -> Option<Vec<u8>> { async fn profile_pic(&self) -> Option<Vec<u8>> {
let res: Option<(Vec<u8>,)> = self.profile_picture
sqlx::query_as("SELECT image FROM user_profile_pic WHERE username = $1;") .as_ref()
.bind(&self.username) .map(|x| dereference!(x).read_file(&owl::DB.get().unwrap()))
.fetch_optional(get_pg!())
.await
.unwrap();
res.map(|x| x.0)
} }
/// Set a user's profile picture in the database /// Set a user's profile picture in the database
async fn set_profile_pic(&self, image: Vec<u8>) { 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", let mut target = vec![get!(self.id.clone()).unwrap()];
)
.bind(&self.username) let file = File::new(image, None, &owl::DB.get().unwrap());
.bind(&image)
.execute(get_pg!()) update!(&mut target, |u: &mut User| {
.await.unwrap(); u.profile_picture = Some(file.reference());
})
} }
} }

View file

@ -1,23 +1,25 @@
use chrono::Utc; use chrono::Utc;
use owl::{dereference, prelude::*, query, save};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use uuid::Uuid;
use crate::{gen_random, get_pg}; use crate::gen_random;
use super::{User, UserRole}; use super::{User, UserRole};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone)]
#[model]
pub struct Session { pub struct Session {
/// The unique ID of the session token /// The unique ID of the session token
pub id: uuid::Uuid, pub id: Id,
/// The generated session token /// The generated session token
pub token: String, pub token: String,
/// The username associated with the session token /// The username associated with the session token
pub user: String, pub user: IdRef<User>,
/// Session creation time /// Session creation time
pub created: chrono::DateTime<Utc>, pub created: chrono::DateTime<Utc>,
/// Internal CSRF value /// Internal CSRF value
csrf: uuid::Uuid, pub csrf: uuid::Uuid,
/// Named session value /// Named session value
pub name: Option<String>, pub name: Option<String>,
/// Kind of session /// Kind of session
@ -33,57 +35,57 @@ pub enum SessionKind {
} }
pub trait Sessions { pub trait Sessions {
fn from_session(session: String) -> impl std::future::Future<Output = Option<User>>; fn from_session(session: String) -> impl std::future::Future<Output = Option<Model<User>>>;
fn login( fn login(
username: &str, username: &str,
password: &str, password: &str,
) -> impl std::future::Future<Output = Option<(Session, UserRole)>>; ) -> impl std::future::Future<Output = Option<(Model<Session>, UserRole)>>;
fn api_key(&self, name: &str) -> impl std::future::Future<Output = Session>; fn api_key(&self, name: &str) -> impl std::future::Future<Output = Model<Session>>;
fn session(&self) -> impl std::future::Future<Output = Session>; fn session(&self) -> impl std::future::Future<Output = Model<Session>>;
fn list_sessions(&self) -> impl std::future::Future<Output = Vec<Session>>; fn list_sessions(&self) -> impl std::future::Future<Output = Vec<Model<Session>>>;
fn end_session(&self, id: &uuid::Uuid) -> impl std::future::Future<Output = ()>; fn end_session(&self, id: &uuid::Uuid) -> impl std::future::Future<Output = ()>;
} }
impl Sessions for User { impl Sessions for User {
/// Generate a new API Key session /// Generate a new API Key session
async fn api_key(&self, name: &str) -> Session { async fn api_key(&self, name: &str) -> Model<Session> {
sqlx::query_as( save!(Session {
"INSERT INTO user_session (token, \"user\", kind, name) VALUES ($1, $2, $3, $4) RETURNING *", id: Id::new_ulid(),
) token: gen_random(64),
.bind(gen_random(64)) user: self.reference(),
.bind(&self.username) created: chrono::Utc::now(),
.bind(SessionKind::API) csrf: Uuid::new_v4(),
.bind(name) name: Some(name.to_string()),
.fetch_one(get_pg!()) kind: SessionKind::API
.await })
.unwrap()
} }
/// End a user session /// End a user session
async fn end_session(&self, id: &uuid::Uuid) { async fn end_session(&self, id: &uuid::Uuid) {
/* TODO : deletion
sqlx::query("DELETE FROM user_session WHERE id = $1 AND \"user\" = $2") sqlx::query("DELETE FROM user_session WHERE id = $1 AND \"user\" = $2")
.bind(id) .bind(id)
.bind(&self.username) .bind(&self.username)
.execute(get_pg!()) .execute(get_pg!())
.await .await
.unwrap(); .unwrap();
*/
} }
/// Get all sessions for a user /// Get all sessions for a user
async fn list_sessions(&self) -> Vec<Session> { async fn list_sessions(&self) -> Vec<Model<Session>> {
sqlx::query_as("SELECT * FROM user_session WHERE \"user\" = $1") query!(|ses: &Session| ses.user.to_string() == self.reference().to_string())
.bind(&self.username)
.fetch_all(get_pg!())
.await
.unwrap()
} }
// Get a user from session ID // Get a user from session ID
async fn from_session(session: String) -> Option<User> { async fn from_session(session_token: String) -> Option<Model<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(); let session = query!(|ses: &Session| ses.token == session_token);
let session = session.first();
if let Some(mut user) = user { if let Some(ses) = session {
user.session = session; let mut user = dereference!(ses.read().user);
user.write_raw_inline(|u: &mut _| {
u.session = session_token.to_string();
});
return Some(user); return Some(user);
} }
@ -91,28 +93,29 @@ impl Sessions for User {
} }
/// Login a user with the given username and password /// Login a user with the given username and password
async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> { async fn login(username: &str, password: &str) -> Option<(Model<Session>, UserRole)> {
let u = Self::find(username).await?; let u = Self::find(username).await?;
let u = u.read();
if !u.verify_pw(password) { if !u.verify_pw(password) {
return None; return None;
} }
Some((u.session().await, u.user_role)) Some((u.session().await, u.user_role.clone()))
} }
/// Generate a new session token for the user /// Generate a new session token for the user
/// ///
/// Returns a Session instance containing the generated token and associated user /// Returns a Session instance containing the generated token and associated user
async fn session(&self) -> Session { async fn session(&self) -> Model<Session> {
sqlx::query_as( save!(Session {
"INSERT INTO user_session (token, \"user\", kind) VALUES ($1, $2, $3) RETURNING *", id: Id::new_ulid(),
) token: gen_random(64),
.bind(gen_random(64)) user: self.reference(),
.bind(&self.username) created: chrono::Utc::now(),
.bind(SessionKind::USER) csrf: Uuid::new_v4(),
.fetch_one(get_pg!()) name: None,
.await kind: SessionKind::USER
.unwrap() })
} }
} }

View file

@ -1,10 +1,10 @@
use owl::{db::model::file::File, get, prelude::*, query, save, update};
use rocket::{Request, http::Status, outcome::Outcome, request::FromRequest}; use rocket::{Request, http::Status, outcome::Outcome, request::FromRequest};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use sqlx::FromRow;
use super::Sessions; use super::Sessions;
use crate::{get_pg, request::api::ToAPI}; use crate::request::api::ToAPI;
// TODO : 2FA // TODO : 2FA
@ -20,16 +20,18 @@ use crate::{get_pg, request::api::ToAPI};
/// ... /// ...
/// } /// }
/// ``` /// ```
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone)]
#[model]
pub struct User { pub struct User {
/// The username chosen by the user /// The username chosen by the user
pub username: String, pub id: Id,
/// The hashed password for the user /// The hashed password for the user
pub password: String, pub password: String,
/// The role of the user /// The role of the user
pub user_role: UserRole, pub user_role: UserRole,
#[sqlx(default)] #[serde(skip)]
pub session: String, pub session: String,
pub profile_picture: Option<IdRef<File>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
@ -43,52 +45,39 @@ pub enum UserRole {
impl User { impl User {
/// Find a user by their username /// Find a user by their username
pub async fn find(username: &str) -> Option<Self> { pub async fn find(username: &str) -> Option<Model<Self>> {
sqlx::query_as("SELECT * FROM users WHERE username = $1") get!(username)
.bind(username)
.fetch_optional(get_pg!())
.await
.unwrap()
} }
/// Create a new user with the given details /// 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 /// 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> { pub async fn create(username: String, password: &str, role: UserRole) -> Option<Model<Self>> {
// Check if a user already exists with the same username // Check if a user already exists with the same username
if Self::find(&username).await.is_some() { if Self::find(&username).await.is_some() {
return None; return None;
} }
let u = Self { let u = Self {
username, id: Id::String(username),
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(), password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
user_role: role, user_role: role,
profile_picture: None,
session: String::new(), session: String::new(),
}; };
sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)") Some(save!(u))
.bind(&u.username)
.bind(&u.password)
.bind(&u.user_role)
.execute(get_pg!())
.await
.unwrap();
Some(u)
} }
/// Change the password of a User /// Change the password of a User
/// ///
/// Returns a Result indicating whether the password change was successful or not /// Returns a Result indicating whether the password change was successful or not
pub async fn passwd(self, old: &str, new: &str) -> Result<(), ()> { pub async fn passwd(&self, old: &str, new: &str) -> Result<(), ()> {
if self.verify_pw(old) { if self.verify_pw(old) {
sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;") let mut target = vec![get!(self.id.clone()).unwrap()];
.bind(bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap()) update!(&mut target, |u: &mut User| {
.bind(&self.username) u.password = bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap();
.execute(get_pg!()) });
.await
.unwrap();
return Ok(()); return Ok(());
} }
@ -98,11 +87,8 @@ impl User {
/// Find all users in the system /// Find all users in the system
#[must_use] #[must_use]
pub async fn find_all() -> Vec<Self> { pub async fn find_all() -> Vec<Model<Self>> {
sqlx::query_as("SELECT * FROM users") query!(|_| true)
.fetch_all(get_pg!())
.await
.unwrap()
} }
/// Check if the user is an admin /// Check if the user is an admin
@ -123,14 +109,14 @@ impl User {
impl ToAPI for User { impl ToAPI for User {
async fn api(&self) -> serde_json::Value { async fn api(&self) -> serde_json::Value {
json!({ json!({
"username": self.username, "username": self.id.to_string(),
"role": self.user_role "role": self.user_role
}) })
} }
} }
/// extracts a user from a request with `session` cookie /// extracts a user from a request with `session` cookie
async fn extract_user(request: &Request<'_>) -> Option<User> { async fn extract_user(request: &Request<'_>) -> Option<Model<User>> {
if let Some(session_id) = request.cookies().get("session") { if let Some(session_id) = request.cookies().get("session") {
if let Some(user) = User::from_session(session_id.value().to_string()).await { if let Some(user) = User::from_session(session_id.value().to_string()).await {
return Some(user); return Some(user);
@ -141,20 +127,22 @@ async fn extract_user(request: &Request<'_>) -> Option<User> {
None None
} }
pub struct UserAuth(pub Model<User>);
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for User { impl<'r> FromRequest<'r> for UserAuth {
type Error = (); type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await { if let Some(user) = extract_user(request).await {
return Outcome::Success(user); return Outcome::Success(UserAuth(user));
} }
Outcome::Error((Status::Unauthorized, ())) Outcome::Error((Status::Unauthorized, ()))
} }
} }
/// Struct which extracts a user with session from `Token` HTTP Header. /// Struct which extracts a user with session from `Token` HTTP Header.
pub struct APIUser(pub User); pub struct APIUser(pub Model<User>);
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for APIUser { impl<'r> FromRequest<'r> for APIUser {
@ -191,7 +179,7 @@ impl<'r> FromRequest<'r> for APIUser {
/// } /// }
/// ``` /// ```
pub enum MaybeUser { pub enum MaybeUser {
User(User), User(Model<User>),
Anonymous, Anonymous,
} }
@ -208,7 +196,7 @@ impl<'r> FromRequest<'r> for MaybeUser {
} }
} }
impl From<MaybeUser> for Option<User> { impl From<MaybeUser> for Option<Model<User>> {
fn from(value: MaybeUser) -> Self { fn from(value: MaybeUser) -> Self {
value.take_user() value.take_user()
} }
@ -216,7 +204,7 @@ impl From<MaybeUser> for Option<User> {
impl MaybeUser { impl MaybeUser {
#[must_use] #[must_use]
pub const fn user(&self) -> Option<&User> { pub const fn user(&self) -> Option<&Model<User>> {
match self { match self {
MaybeUser::User(user) => Some(user), MaybeUser::User(user) => Some(user),
MaybeUser::Anonymous => None, MaybeUser::Anonymous => None,
@ -224,7 +212,7 @@ impl MaybeUser {
} }
#[must_use] #[must_use]
pub fn take_user(self) -> Option<User> { pub fn take_user(self) -> Option<Model<User>> {
match self { match self {
MaybeUser::User(user) => Some(user), MaybeUser::User(user) => Some(user),
MaybeUser::Anonymous => None, MaybeUser::Anonymous => None,
@ -246,7 +234,7 @@ impl MaybeUser {
/// ... /// ...
/// } /// }
/// ``` /// ```
pub struct AdminUser(pub User); pub struct AdminUser(pub Model<User>);
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminUser { impl<'r> FromRequest<'r> for AdminUser {
@ -254,7 +242,7 @@ impl<'r> FromRequest<'r> for AdminUser {
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await { if let Some(user) = extract_user(request).await {
if user.is_admin() { if user.read().is_admin() {
return Outcome::Success(AdminUser(user)); return Outcome::Success(AdminUser(user));
} }
} }

View file

@ -7,7 +7,6 @@ use rocket::response::Responder;
use std::io::Cursor; use std::io::Cursor;
use std::io::Read; use std::io::Read;
use std::io::Seek; use std::io::Seek;
use std::os::unix::fs::FileExt;
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
pub struct Data { pub struct Data {