From 58a661d0edfd2553b5dbf1ba6a466d914bdd7145 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Wed, 18 Dec 2024 19:24:38 +0100 Subject: [PATCH] BASED --- Cargo.lock | 43 ++++++++++ Cargo.toml | 1 + src/library/mod.rs | 36 -------- src/library/user.rs | 179 ---------------------------------------- src/library/video.rs | 7 +- src/main.rs | 31 +------ src/meta.rs | 1 - src/pages/assets.rs | 1 - src/pages/components.rs | 172 ++++++++------------------------------ src/pages/index.rs | 31 ++++--- src/pages/mod.rs | 17 ---- src/pages/user.rs | 26 ++---- src/pages/watch.rs | 26 +++--- src/pages/yt.rs | 3 +- 14 files changed, 118 insertions(+), 456 deletions(-) delete mode 100644 src/library/user.rs diff --git a/Cargo.lock b/Cargo.lock index 44488db..1ec01eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,34 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "based" +version = "0.1.0" +source = "git+https://git.hydrar.de/jmarya/based#98048ce522db134fe3e863538db6793068085b80" +dependencies = [ + "bcrypt", + "chrono", + "dashmap", + "data-encoding", + "env_logger", + "futures", + "hex", + "log", + "maud", + "rand", + "rayon", + "regex", + "ring", + "rocket", + "rocket_cors", + "serde", + "serde_json", + "sqlx", + "tokio", + "uuid", + "walkdir", +] + [[package]] name = "bcrypt" version = "0.16.0" @@ -370,6 +398,20 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -2912,6 +2954,7 @@ checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" name = "watchdogs" version = "0.1.0" dependencies = [ + "based", "bcrypt", "chrono", "data-encoding", diff --git a/Cargo.toml b/Cargo.toml index e5e5bf7..c3cfbbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ maud = "0.26.0" rand = "0.8.5" data-encoding = "2.6.0" bcrypt = "0.16.0" +based = { git = "https://git.hydrar.de/jmarya/based", features = ["cache"] } diff --git a/src/library/mod.rs b/src/library/mod.rs index d0afb8e..ebe2f8d 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -1,4 +1,3 @@ -use serde_json::json; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -10,7 +9,6 @@ pub use video::Video; use crate::meta; mod func; -pub mod user; mod video; #[derive(Debug, Clone)] @@ -271,37 +269,3 @@ impl Library { videos } } - -/// A trait to generate a Model API representation in JSON format. -pub trait ToAPI: Sized { - /// Generate public API JSON - fn api(&self) -> impl std::future::Future; -} - -/// Converts a slice of items implementing the `ToAPI` trait into a `Vec` of JSON values. -pub async fn vec_to_api(items: &[impl ToAPI]) -> Vec { - let mut ret = Vec::with_capacity(items.len()); - - for e in items { - ret.push(e.api().await); - } - - ret -} - -pub fn to_uuid(id: &str) -> Result { - uuid::Uuid::from_str(id).map_err(|_| no_uuid_error()) -} - -type ApiError = rocket::response::status::BadRequest; -type FallibleApiResponse = Result; - -pub fn no_uuid_error() -> ApiError { - api_error("No valid UUID") -} - -pub fn api_error(msg: &str) -> ApiError { - rocket::response::status::BadRequest(json!({ - "error": msg - })) -} diff --git a/src/library/user.rs b/src/library/user.rs deleted file mode 100644 index f26ef41..0000000 --- a/src/library/user.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::str::FromStr; - -use data_encoding::HEXUPPER; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::FromRow; - -use crate::pages::ToAPI; - -fn gen_token(token_length: usize) -> String { - let mut token_bytes = vec![0u8; token_length]; - - rand::thread_rng().fill_bytes(&mut token_bytes); - - HEXUPPER.encode(&token_bytes) -} - -#[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, -} - -#[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, -} - -pub struct UserManager { - conn: sqlx::PgPool, -} - -impl UserManager { - pub fn new(conn: sqlx::PgPool) -> Self { - Self { conn } - } - - /// Find a user by their username - pub async fn find(&self, username: &str) -> Option { - sqlx::query_as("SELECT * FROM users WHERE username = $1") - .bind(username) - .fetch_optional(&self.conn) - .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(&self, username: &str, password: &str, role: UserRole) -> Option { - // Check if a user already exists with the same username - if self.find(username).await.is_some() { - return None; - } - - let u = User { - username: username.to_string(), - password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(), - user_role: role, - }; - - sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)") - .bind(&u.username) - .bind(&u.password) - .bind(&u.user_role) - .execute(&self.conn) - .await - .unwrap(); - - Some(u) - } - - /// Login a user with the given username and password - pub async fn login(&self, username: &str, password: &str) -> Option<(Session, UserRole)> { - let u = self.find(username).await?; - - if !u.verify_pw(password) { - return None; - } - - Some((u.session(&self.conn).await, u.user_role)) - } - - /// Find all users in the system - pub async fn find_all(&self) -> Vec { - sqlx::query_as("SELECT * FROM users") - .fetch_all(&self.conn) - .await - .unwrap() - } - - pub async fn verify(&self, session_id: &str) -> Option { - let ses: Option = sqlx::query_as("SELECT * FROM user_session WHERE id = $1") - .bind(uuid::Uuid::from_str(session_id).unwrap_or(uuid::Uuid::nil())) - .fetch_optional(&self.conn) - .await - .unwrap(); - - if ses.is_some() { - self.find(&ses.unwrap().user).await - } else { - None - } - } -} - -impl User { - /// Generate a new session token for the user - /// - /// Returns a Session instance containing the generated token and associated user - pub async fn session(&self, conn: &sqlx::PgPool) -> Session { - sqlx::query_as( - "INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"", - ) - .bind(gen_token(64)) - .bind(&self.username) - .fetch_one(conn) - .await - .unwrap() - } - - /// Check if the user is an admin - 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 - pub fn verify_pw(&self, password: &str) -> bool { - bcrypt::verify(password, &self.password).unwrap() - } - - /// 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, conn: &sqlx::PgPool) -> 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) - .fetch_one(conn) - .await - .unwrap(); - - return Ok(()); - } - - Err(()) - } -} - -impl ToAPI for User { - async fn api(&self) -> serde_json::Value { - json!({ - "username": self.username, - "role": self.user_role - }) - } -} - -#[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, -} diff --git a/src/library/video.rs b/src/library/video.rs index 31e1dad..ba49d1e 100644 --- a/src/library/video.rs +++ b/src/library/video.rs @@ -1,8 +1,5 @@ -use crate::{ - get_pg, - pages::ToAPI, - yt_meta::{self, get_vid_duration}, -}; +use crate::yt_meta::{self, get_vid_duration}; +use based::{get_pg, request::api::ToAPI}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::prelude::FromRow; diff --git a/src/main.rs b/src/main.rs index 1a21083..01fe616 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,11 @@ -use std::path::Path; - -use library::user::UserManager; +use based::{auth::User, get_pg}; use rocket::{http::Method, routes}; -use tokio::sync::OnceCell; - +use std::path::Path; mod library; mod meta; mod pages; mod yt_meta; -pub static PG: OnceCell = OnceCell::const_new(); - -#[macro_export] -macro_rules! get_pg { - () => { - if let Some(client) = $crate::PG.get() { - client - } else { - let client = sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); - $crate::PG.set(client).unwrap(); - $crate::PG.get().unwrap() - } - }; -} - #[rocket::launch] async fn launch() -> _ { std::env::set_var("RUST_LOG", "info"); @@ -48,10 +26,8 @@ async fn launch() -> _ { sqlx::migrate!("./migrations").run(pg).await.unwrap(); let lib = library::Library::new().await; - let um = UserManager::new(pg.clone()); - um.create("admin", "admin", library::user::UserRole::Admin) - .await; + User::create("admin", "admin", based::auth::UserRole::Admin).await; let library = lib.clone(); @@ -94,5 +70,4 @@ async fn launch() -> _ { ) .attach(cors) .manage(lib) - .manage(um) } diff --git a/src/meta.rs b/src/meta.rs index 55febad..f678f99 100644 --- a/src/meta.rs +++ b/src/meta.rs @@ -1,4 +1,3 @@ -use std::io::{self, Read}; use std::process::{Command, Stdio}; pub fn extract_video_thumbnail(path: &str, time: f64) -> Vec { diff --git a/src/pages/assets.rs b/src/pages/assets.rs index 72f6b4d..f6d355d 100644 --- a/src/pages/assets.rs +++ b/src/pages/assets.rs @@ -1,5 +1,4 @@ use rocket::{ - fs::NamedFile, get, http::{ContentType, Status}, State, diff --git a/src/pages/components.rs b/src/pages/components.rs index 331d025..f2ef633 100644 --- a/src/pages/components.rs +++ b/src/pages/components.rs @@ -1,126 +1,42 @@ -use core::num; - +use crate::library::Video; +use based::{ + auth::User, + format::{format_date, format_number, format_seconds_to_hhmmss}, + page::script, + request::{RequestContext, StringResponse}, +}; use maud::{html, PreEscaped}; -use crate::library::{ - user::{User, UserManager}, - Video, -}; - -use rocket::{ - http::{ContentType, Status}, - request::{self, FromRequest, Request}, -}; - -pub struct HTMX { - pub is_htmx: bool, - pub session: Option, - user: Option, -} - -impl HTMX { - pub async fn user(&mut self, um: &UserManager) -> Option { - if let Some(user) = &self.user { - return Some(user.clone()); - } - - let user = um.verify(&self.session.clone().unwrap_or_default()).await; - self.user = user.clone(); - user - } -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for HTMX { - type Error = (); - - async fn from_request(req: &'r Request<'_>) -> request::Outcome { - rocket::outcome::Outcome::Success(HTMX { - is_htmx: !req - .headers() - .get("HX-Request") - .collect::>() - .is_empty(), - session: req - .cookies() - .get("session_id") - .map(|x| x.value().to_string()), - user: None, - }) - } -} - -pub fn htmx_link( - url: &str, - class: &str, - onclick: &str, - content: PreEscaped, -) -> PreEscaped { - html!( - a class=(class) onclick=(onclick) href=(url) hx-get=(url) hx-target="#main_content" hx-push-url="true" hx-swap="innerHTML" { - (content); - }; - ) -} - -pub fn script(script: &str) -> PreEscaped { - html!( - script { - (PreEscaped(script)) - }; - ) -} - -pub fn shell(content: PreEscaped, title: &str, user: Option) -> PreEscaped { - html! { - html { - head { - title { (title) }; - script src="https://cdn.tailwindcss.com" {}; - script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous" {}; - meta name="viewport" content="width=device-width, initial-scale=1.0"; - }; - body class="bg-black text-white" { - header class="bg-gray-800 text-white shadow-md py-2" { - (script(include_str!("../scripts/header.js"))); - - div class="flex justify-between px-6" { - - a href="/" class="flex items-center space-x-2" { - img src="/favicon" alt="Logo" class="w-10 h-10 rounded-md"; - span class="font-semibold text-xl" { "WatchDogs" }; - }; - - @if user.is_some() { - p { (user.unwrap().username) }; - }; - - }; - - }; - }; - - div id="main_content" { - (content) - }; - }; - } -} - pub async fn render_page( - htmx: HTMX, + ctx: RequestContext, content: PreEscaped, title: &str, user: Option, -) -> (Status, (ContentType, String)) { - if !htmx.is_htmx { - ( - Status::Ok, - (ContentType::HTML, shell(content, title, user).into_string()), - ) - } else { - (Status::Ok, (ContentType::HTML, content.into_string())) - } +) -> StringResponse { + based::page::render_page(content, title, ctx, &based::page::Shell::new( + html! { + script src="https://cdn.tailwindcss.com" {}; + script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous" {}; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + }, + html! { + header class="bg-gray-800 text-white shadow-md py-2" { + (script(include_str!("../scripts/header.js"))); + + div class="flex justify-between px-6" { + a href="/" class="flex items-center space-x-2" { + img src="/favicon" alt="Logo" class="w-10 h-10 rounded-md"; + span class="font-semibold text-xl" { "WatchDogs" }; + }; + + @if user.is_some() { + p { (user.unwrap().username) }; + }; + + }; + + }; +}, Some(String::from("bg-black text-white")))).await } pub fn loading_spinner() -> PreEscaped { @@ -144,28 +60,6 @@ pub fn search_bar(query: &str) -> PreEscaped { } } -fn format_seconds_to_hhmmss(seconds: f64) -> String { - let total_seconds = seconds as u64; - let hours = total_seconds / 3600; - let minutes = (total_seconds % 3600) / 60; - let seconds = total_seconds % 60; - if hours != 0 { - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) - } else { - format!("{:02}:{:02}", minutes, seconds) - } -} - -pub fn format_date(date: &chrono::NaiveDate) -> String { - // TODO : Implement - date.to_string() -} - -pub fn format_number(num: i32) -> String { - // TODO : Implement - num.to_string() -} - pub async fn video_element_wide(video: &Video) -> PreEscaped { html!( a href=(format!("/watch?v={}", video.id)) class="flex items-center w-full p-4 bg-gray-900 shadow-lg rounded-lg overflow-hidden mb-2 mt-2" { diff --git a/src/pages/index.rs b/src/pages/index.rs index 22ebc76..29a98b0 100644 --- a/src/pages/index.rs +++ b/src/pages/index.rs @@ -1,3 +1,8 @@ +use based::{ + auth::User, + page::htmx_link, + request::{api::vec_to_api, RequestContext, StringResponse}, +}; use maud::html; use rocket::{ get, @@ -6,15 +11,11 @@ use rocket::{ }; use serde_json::json; -use crate::{ - library::{user::UserManager, Library}, - pages::components::{htmx_link, video_element}, -}; +use crate::{library::Library, pages::components::video_element}; use super::{ api_response, - components::{render_page, video_element_wide, HTMX}, - vec_to_api, + components::{render_page, video_element_wide}, }; #[get("/search?&")] @@ -35,13 +36,11 @@ pub async fn search( #[get("/d/")] pub async fn channel_page( - mut htmx: HTMX, + ctx: RequestContext, dir: &str, library: &State, - um: &State, -) -> (Status, (ContentType, String)) { - let user = htmx.user(um).await; - + user: User, +) -> StringResponse { if dir.ends_with(".json") { let dir_videos = library .get_directory_videos(dir.split_once(".json").map(|x| x.0).unwrap_or_default()) @@ -60,17 +59,15 @@ pub async fn channel_page( }; ); - render_page(htmx, content, dir, user).await + render_page(ctx, content, dir, Some(user)).await } #[get("/")] pub async fn index_page( - mut htmx: HTMX, + ctx: RequestContext, library: &State, - um: &State, + user: User, ) -> (Status, (ContentType, String)) { - let user = htmx.user(um).await; - let content = html!( h1 class="text-center text-4xl font-extrabold leading-tight mt-4" { "Random Videos" }; div class="lg:grid grid-cols-3 gap-6 p-6" { @@ -87,5 +84,5 @@ pub async fn index_page( }; ); - render_page(htmx, content, "WatchDogs", user).await + render_page(ctx, content, "WatchDogs", Some(user)).await } diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 283e932..35a0658 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -7,23 +7,6 @@ pub mod user; pub mod watch; pub mod yt; -/// A trait to generate a Model API representation in JSON format. -pub trait ToAPI: Sized { - /// Generate public API JSON - fn api(&self) -> impl std::future::Future; -} - -/// Converts a slice of items implementing the `ToAPI` trait into a `Vec` of JSON values. -pub async fn vec_to_api(items: &[impl ToAPI]) -> Vec { - let mut ret = Vec::with_capacity(items.len()); - - for e in items { - ret.push(e.api().await); - } - - ret -} - pub fn api_response(json: &serde_json::Value) -> (Status, (ContentType, String)) { ( Status::Ok, diff --git a/src/pages/user.rs b/src/pages/user.rs index 930b638..e0a17f9 100644 --- a/src/pages/user.rs +++ b/src/pages/user.rs @@ -1,7 +1,4 @@ -use crate::{ - library::{user::UserManager, Library}, - pages::components::{htmx_link, video_element}, -}; +use based::{auth::User, request::RequestContext}; use maud::html; use rocket::http::CookieJar; use rocket::{ @@ -10,20 +7,13 @@ use rocket::{ http::{ContentType, Cookie, Status}, post, response::Redirect, - FromForm, State, + FromForm, }; -use serde_json::json; -use super::{ - api_response, - components::{render_page, video_element_wide, HTMX}, - vec_to_api, -}; +use super::components::render_page; #[get("/login")] -pub async fn login(mut htmx: HTMX, um: &State) -> (Status, (ContentType, String)) { - let user = htmx.user(um).await; - +pub async fn login(ctx: RequestContext, user: User) -> (Status, (ContentType, String)) { let content = html!( h2 { "Login" }; form action="/login" method="POST" { @@ -33,7 +23,7 @@ pub async fn login(mut htmx: HTMX, um: &State) -> (Status, (Content } ); - render_page(htmx, content, "Login", user).await + render_page(ctx, content, "Login", Some(user)).await } #[derive(FromForm)] @@ -45,14 +35,14 @@ pub struct LoginForm { #[post("/login", data = "")] pub async fn login_post( login_form: Form, - um: &State, + user: User, cookies: &CookieJar<'_>, ) -> Option { let login_data = login_form.into_inner(); - let (session, _) = um.login(&login_data.username, &login_data.password).await?; + let (session, _) = User::login(&login_data.username, &login_data.password).await?; - let session_cookie = Cookie::build(("session_id", session.id.to_string())) + let session_cookie = Cookie::build(("session", session.id.to_string())) .path("/") // Set the cookie path to the root so it’s available for the whole app .http_only(true) // Make the cookie HTTP only for security .max_age(rocket::time::Duration::days(7)) // Set the cookie expiration (7 days in this case) diff --git a/src/pages/watch.rs b/src/pages/watch.rs index d2eba99..7d48e67 100644 --- a/src/pages/watch.rs +++ b/src/pages/watch.rs @@ -1,30 +1,22 @@ +use based::{auth::User, format::format_date, request::RequestContext}; use maud::html; use rocket::{ get, http::{ContentType, Status}, State, }; -use serde_json::json; -use crate::{ - library::{self, user::UserManager, Library}, - pages::components::{format_date, video_element, video_element_wide}, -}; +use crate::{library::Library, pages::components::video_element_wide}; -use super::{ - components::{render_page, HTMX}, - vec_to_api, -}; +use super::components::render_page; #[get("/watch?")] pub async fn watch_page( - mut htmx: HTMX, + ctx: RequestContext, library: &State, v: String, - um: &State, + user: User, ) -> (Status, (ContentType, String)) { - let user = htmx.user(um).await; - let video = if let Some(video) = library.get_video_by_id(&v).await { video } else { @@ -70,5 +62,11 @@ pub async fn watch_page( }; ); - render_page(htmx, content, &format!("{} - WatchDogs", video.title), user).await + render_page( + ctx, + content, + &format!("{} - WatchDogs", video.title), + Some(user), + ) + .await } diff --git a/src/pages/yt.rs b/src/pages/yt.rs index 403f031..b96757d 100644 --- a/src/pages/yt.rs +++ b/src/pages/yt.rs @@ -1,7 +1,8 @@ +use based::request::api::vec_to_api; use rocket::{get, State}; use serde_json::json; -use crate::{library::Library, pages::vec_to_api}; +use crate::library::Library; #[get("/yt/tags")] pub async fn yt_tags(library: &State) -> serde_json::Value {