Compare commits
No commits in common. "owl" and "main" have entirely different histories.
14 changed files with 780 additions and 816 deletions
956
Cargo.lock
generated
956
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -4,14 +4,13 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
based_auth = { git = "https://git.hydrar.de/jmarya/based_auth" }
|
|
||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
rayon = "1.7.0"
|
rayon = "1.7.0"
|
||||||
regex = "1.9.5"
|
regex = "1.9.5"
|
||||||
ring = "0.16.20"
|
ring = "0.16.20"
|
||||||
walkdir = "2.4.0"
|
walkdir = "2.4.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4.38", features = ["serde"] }
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
log = "0.4.20"
|
log = "0.4.20"
|
||||||
rocket = { version = "0.5.1", features = ["json"] }
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
|
@ -27,7 +26,6 @@ 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 = "https://git.hydrar.de/red/owl" }
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
reqwest = { version = "0.11", features = ["blocking"] }
|
reqwest = { version = "0.11", features = ["blocking"] }
|
||||||
|
|
17
src/asset.rs
17
src/asset.rs
|
@ -52,15 +52,12 @@ pub trait AssetRoutes {
|
||||||
|
|
||||||
impl AssetRoutes for rocket::Rocket<Build> {
|
impl AssetRoutes for rocket::Rocket<Build> {
|
||||||
fn mount_assets(self) -> Self {
|
fn mount_assets(self) -> Self {
|
||||||
self.mount(
|
self.mount("/", routes![
|
||||||
"/",
|
crate::asset::htmx_script_route,
|
||||||
routes![
|
crate::asset::flowbite_css,
|
||||||
crate::asset::htmx_script_route,
|
crate::asset::flowbite_js,
|
||||||
crate::asset::flowbite_css,
|
crate::asset::material_css,
|
||||||
crate::asset::flowbite_js,
|
crate::asset::material_font
|
||||||
crate::asset::material_css,
|
])
|
||||||
crate::asset::material_font
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
24
src/auth/auth.sql
Normal file
24
src/auth/auth.sql
Normal 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
49
src/auth/csrf.rs
Normal 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
31
src/auth/mod.rs
Normal 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
31
src/auth/profile_pic.rs
Normal 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
118
src/auth/session.rs
Normal 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
264
src/auth/user.rs
Normal 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, ()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,9 +4,7 @@ use rand::RngCore;
|
||||||
use tokio::sync::OnceCell;
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
pub mod asset;
|
pub mod asset;
|
||||||
pub mod auth {
|
pub mod auth;
|
||||||
pub use based_auth::*;
|
|
||||||
}
|
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod ogp;
|
pub mod ogp;
|
||||||
pub mod request;
|
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>>) {
|
pub async fn batch_insert(table: &str, values: &[String], entries: Vec<Vec<DatabaseType>>) {
|
||||||
assert_eq!(values.len(), entries.first().unwrap().len());
|
assert_eq!(values.len(), entries.first().unwrap().len());
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,11 @@ 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;
|
||||||
|
|
||||||
|
// TODO: Implement file based response
|
||||||
|
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
file: Option<String>,
|
file: Option<String>,
|
||||||
raw: Option<Vec<u8>>,
|
raw: Option<Vec<u8>>,
|
||||||
|
|
|
@ -29,32 +29,29 @@ pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) ->
|
||||||
) -> (String, PreEscaped<String>) {
|
) -> (String, PreEscaped<String>) {
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
(
|
(format!("modal-{id}"), html! {
|
||||||
format!("modal-{id}"),
|
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" {
|
||||||
html! {
|
div class="relative p-4 w-full max-w-2xl max-h-full" {
|
||||||
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" {
|
|
||||||
|
|
||||||
div class="relative bg-white rounded-lg shadow dark:bg-gray-700" {
|
div class="relative bg-white rounded-lg shadow dark:bg-gray-700" {
|
||||||
div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" {
|
div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" {
|
||||||
h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) }
|
h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) }
|
||||||
button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide=(format!("modal-{id}")) {
|
button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide=(format!("modal-{id}")) {
|
||||||
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
||||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
||||||
};
|
};
|
||||||
span class="sr-only" { "Close modal" };
|
span class="sr-only" { "Close modal" };
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
div class="p-4 md:p-5 space-y-4" {
|
|
||||||
(body)
|
|
||||||
};
|
|
||||||
|
|
||||||
div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" {
|
|
||||||
(footer(format!("modal-{id}")))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}};
|
|
||||||
},
|
div class="p-4 md:p-5 space-y-4" {
|
||||||
)
|
(body)
|
||||||
|
};
|
||||||
|
|
||||||
|
div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" {
|
||||||
|
(footer(format!("modal-{id}")))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}};
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -287,13 +287,10 @@ pub fn BottomNavigationTile<T: UIWidget + 'static>(
|
||||||
) -> ClassicWidget<LinkWidget> {
|
) -> ClassicWidget<LinkWidget> {
|
||||||
Classic(
|
Classic(
|
||||||
"inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group",
|
"inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group",
|
||||||
Link(
|
Link(reference, html! {
|
||||||
reference,
|
(icon.map(|x| x.render()).unwrap_or_default());
|
||||||
html! {
|
span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) };
|
||||||
(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) };
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -274,25 +274,21 @@ impl GridElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn span(mut self, value: GridElementValue) -> Self {
|
pub fn span(mut self, value: GridElementValue) -> Self {
|
||||||
self.1.push(format!(
|
self.1.push(format!("{}-span-{}", self.2, match value {
|
||||||
"{}-span-{}",
|
GridElementValue::_1 => "1",
|
||||||
self.2,
|
GridElementValue::_2 => "2",
|
||||||
match value {
|
GridElementValue::_3 => "3",
|
||||||
GridElementValue::_1 => "1",
|
GridElementValue::_4 => "4",
|
||||||
GridElementValue::_2 => "2",
|
GridElementValue::_5 => "5",
|
||||||
GridElementValue::_3 => "3",
|
GridElementValue::_6 => "6",
|
||||||
GridElementValue::_4 => "4",
|
GridElementValue::_7 => "7",
|
||||||
GridElementValue::_5 => "5",
|
GridElementValue::_8 => "8",
|
||||||
GridElementValue::_6 => "6",
|
GridElementValue::_9 => "9",
|
||||||
GridElementValue::_7 => "7",
|
GridElementValue::_10 => "10",
|
||||||
GridElementValue::_8 => "8",
|
GridElementValue::_11 => "11",
|
||||||
GridElementValue::_9 => "9",
|
GridElementValue::_12 => "12",
|
||||||
GridElementValue::_10 => "10",
|
GridElementValue::Auto => "full",
|
||||||
GridElementValue::_11 => "11",
|
}));
|
||||||
GridElementValue::_12 => "12",
|
|
||||||
GridElementValue::Auto => "full",
|
|
||||||
}
|
|
||||||
));
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue