✨ port to owldb
This commit is contained in:
parent
1f77f4efdc
commit
c74e159ce0
10 changed files with 844 additions and 365 deletions
949
Cargo.lock
generated
949
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -26,6 +26,7 @@ data-encoding = "2.6.0"
|
|||
bcrypt = "0.16.0"
|
||||
dashmap = "6.1.0"
|
||||
async-stream = "0.3.6"
|
||||
owl = { git = "ssh://git@git.hydrar.de/red/owl" }
|
||||
|
||||
[build-dependencies]
|
||||
reqwest = { version = "0.11", features = ["blocking"] }
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -1,7 +1,8 @@
|
|||
use maud::PreEscaped;
|
||||
use owl::{query, update};
|
||||
|
||||
use super::User;
|
||||
use crate::{get_pg, ui::prelude::script};
|
||||
use crate::{auth::Session, ui::prelude::script};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub trait CSRF {
|
||||
|
@ -23,23 +24,20 @@ impl CSRF for User {
|
|||
|
||||
/// 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();
|
||||
assert!(!self.session.is_empty());
|
||||
|
||||
res.0
|
||||
let res = query!(|s: &Session| { s.token == self.session });
|
||||
|
||||
res.first().unwrap().read().csrf
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut res = query!(|s: &Session| { s.token == self.session });
|
||||
update!(&mut res, |s: &mut Session| {
|
||||
s.csrf = uuid::Uuid::new_v4();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ pub use user::APIUser;
|
|||
pub use user::AdminUser;
|
||||
pub use user::MaybeUser;
|
||||
pub use user::User;
|
||||
pub use user::UserAuth;
|
||||
pub use user::UserRole;
|
||||
|
||||
/// A macro to check if a user has admin privileges.
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use crate::get_pg;
|
||||
|
||||
use super::User;
|
||||
use owl::{db::model::file::File, dereference, get, prelude::*, update};
|
||||
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 = ()>;
|
||||
|
@ -9,23 +8,19 @@ pub trait ProfilePic {
|
|||
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)
|
||||
self.profile_picture
|
||||
.as_ref()
|
||||
.map(|x| dereference!(x).read_file(&owl::DB.get().unwrap()))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut target = vec![get!(self.id.clone()).unwrap()];
|
||||
|
||||
let file = File::new(image, None, &owl::DB.get().unwrap());
|
||||
|
||||
update!(&mut target, |u: &mut User| {
|
||||
u.profile_picture = Some(file.reference());
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
use chrono::Utc;
|
||||
use owl::{dereference, prelude::*, query, save};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{gen_random, get_pg};
|
||||
use crate::gen_random;
|
||||
|
||||
use super::{User, UserRole};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Session {
|
||||
/// The unique ID of the session token
|
||||
pub id: uuid::Uuid,
|
||||
pub id: Id,
|
||||
/// The generated session token
|
||||
pub token: String,
|
||||
/// The username associated with the session token
|
||||
pub user: String,
|
||||
pub user: IdRef<User>,
|
||||
/// Session creation time
|
||||
pub created: chrono::DateTime<Utc>,
|
||||
/// Internal CSRF value
|
||||
csrf: uuid::Uuid,
|
||||
pub csrf: uuid::Uuid,
|
||||
/// Named session value
|
||||
pub name: Option<String>,
|
||||
/// Kind of session
|
||||
|
@ -33,57 +35,57 @@ pub enum SessionKind {
|
|||
}
|
||||
|
||||
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(
|
||||
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>>;
|
||||
) -> impl std::future::Future<Output = Option<(Model<Session>, UserRole)>>;
|
||||
fn api_key(&self, name: &str) -> impl std::future::Future<Output = Model<Session>>;
|
||||
fn session(&self) -> impl std::future::Future<Output = Model<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 = ()>;
|
||||
}
|
||||
|
||||
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()
|
||||
async fn api_key(&self, name: &str) -> Model<Session> {
|
||||
save!(Session {
|
||||
id: Id::new_ulid(),
|
||||
token: gen_random(64),
|
||||
user: self.reference(),
|
||||
created: chrono::Utc::now(),
|
||||
csrf: Uuid::new_v4(),
|
||||
name: Some(name.to_string()),
|
||||
kind: SessionKind::API
|
||||
})
|
||||
}
|
||||
|
||||
/// End a user session
|
||||
async fn end_session(&self, id: &uuid::Uuid) {
|
||||
/* TODO : deletion
|
||||
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()
|
||||
async fn list_sessions(&self) -> Vec<Model<Session>> {
|
||||
query!(|ses: &Session| ses.user.to_string() == self.reference().to_string())
|
||||
}
|
||||
|
||||
// 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;
|
||||
async fn from_session(session_token: String) -> Option<Model<User>> {
|
||||
let session = query!(|ses: &Session| ses.token == session_token);
|
||||
let session = session.first();
|
||||
if let Some(ses) = session {
|
||||
let mut user = dereference!(ses.read().user);
|
||||
user.write_raw_inline(|u: &mut _| {
|
||||
u.session = session_token.to_string();
|
||||
});
|
||||
return Some(user);
|
||||
}
|
||||
|
||||
|
@ -91,28 +93,29 @@ impl Sessions for User {
|
|||
}
|
||||
|
||||
/// 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 = u.read();
|
||||
|
||||
if !u.verify_pw(password) {
|
||||
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
|
||||
///
|
||||
/// 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()
|
||||
async fn session(&self) -> Model<Session> {
|
||||
save!(Session {
|
||||
id: Id::new_ulid(),
|
||||
token: gen_random(64),
|
||||
user: self.reference(),
|
||||
created: chrono::Utc::now(),
|
||||
csrf: Uuid::new_v4(),
|
||||
name: None,
|
||||
kind: SessionKind::USER
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use super::Sessions;
|
||||
use crate::{get_pg, request::api::ToAPI};
|
||||
use crate::request::api::ToAPI;
|
||||
|
||||
// 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 {
|
||||
/// The username chosen by the user
|
||||
pub username: String,
|
||||
pub id: Id,
|
||||
/// The hashed password for the user
|
||||
pub password: String,
|
||||
/// The role of the user
|
||||
pub user_role: UserRole,
|
||||
#[sqlx(default)]
|
||||
#[serde(skip)]
|
||||
pub session: String,
|
||||
pub profile_picture: Option<IdRef<File>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
||||
|
@ -43,52 +45,39 @@ pub enum UserRole {
|
|||
|
||||
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()
|
||||
pub async fn find(username: &str) -> Option<Model<Self>> {
|
||||
get!(username)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub async fn create(username: String, password: &str, role: UserRole) -> Option<Model<Self>> {
|
||||
// Check if a user already exists with the same username
|
||||
if Self::find(&username).await.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let u = Self {
|
||||
username,
|
||||
id: Id::String(username),
|
||||
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
|
||||
user_role: role,
|
||||
profile_picture: None,
|
||||
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)
|
||||
Some(save!(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<(), ()> {
|
||||
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();
|
||||
let mut target = vec![get!(self.id.clone()).unwrap()];
|
||||
update!(&mut target, |u: &mut User| {
|
||||
u.password = bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap();
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
@ -98,11 +87,8 @@ impl User {
|
|||
|
||||
/// 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()
|
||||
pub async fn find_all() -> Vec<Model<Self>> {
|
||||
query!(|_| true)
|
||||
}
|
||||
|
||||
/// Check if the user is an admin
|
||||
|
@ -123,14 +109,14 @@ impl User {
|
|||
impl ToAPI for User {
|
||||
async fn api(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"username": self.username,
|
||||
"username": self.id.to_string(),
|
||||
"role": self.user_role
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(user) = User::from_session(session_id.value().to_string()).await {
|
||||
return Some(user);
|
||||
|
@ -141,20 +127,22 @@ async fn extract_user(request: &Request<'_>) -> Option<User> {
|
|||
None
|
||||
}
|
||||
|
||||
pub struct UserAuth(pub Model<User>);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for User {
|
||||
impl<'r> FromRequest<'r> for UserAuth {
|
||||
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);
|
||||
return Outcome::Success(UserAuth(user));
|
||||
}
|
||||
Outcome::Error((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
impl<'r> FromRequest<'r> for APIUser {
|
||||
|
@ -191,7 +179,7 @@ impl<'r> FromRequest<'r> for APIUser {
|
|||
/// }
|
||||
/// ```
|
||||
pub enum MaybeUser {
|
||||
User(User),
|
||||
User(Model<User>),
|
||||
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 {
|
||||
value.take_user()
|
||||
}
|
||||
|
@ -216,7 +204,7 @@ impl From<MaybeUser> for Option<User> {
|
|||
|
||||
impl MaybeUser {
|
||||
#[must_use]
|
||||
pub const fn user(&self) -> Option<&User> {
|
||||
pub const fn user(&self) -> Option<&Model<User>> {
|
||||
match self {
|
||||
MaybeUser::User(user) => Some(user),
|
||||
MaybeUser::Anonymous => None,
|
||||
|
@ -224,7 +212,7 @@ impl MaybeUser {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn take_user(self) -> Option<User> {
|
||||
pub fn take_user(self) -> Option<Model<User>> {
|
||||
match self {
|
||||
MaybeUser::User(user) => Some(user),
|
||||
MaybeUser::Anonymous => None,
|
||||
|
@ -246,7 +234,7 @@ impl MaybeUser {
|
|||
/// ...
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AdminUser(pub User);
|
||||
pub struct AdminUser(pub Model<User>);
|
||||
|
||||
#[rocket::async_trait]
|
||||
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> {
|
||||
if let Some(user) = extract_user(request).await {
|
||||
if user.is_admin() {
|
||||
if user.read().is_admin() {
|
||||
return Outcome::Success(AdminUser(user));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -169,6 +169,7 @@ 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());
|
||||
|
||||
|
|
|
@ -7,11 +7,8 @@ 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>>,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue