parent
764bef6457
commit
58a661d0ed
14 changed files with 118 additions and 456 deletions
43
Cargo.lock
generated
43
Cargo.lock
generated
|
@ -149,6 +149,34 @@ version = "1.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
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]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
@ -370,6 +398,20 @@ dependencies = [
|
||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
|
@ -2912,6 +2954,7 @@ checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
|
||||||
name = "watchdogs"
|
name = "watchdogs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"based",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
|
|
|
@ -24,3 +24,4 @@ maud = "0.26.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
data-encoding = "2.6.0"
|
data-encoding = "2.6.0"
|
||||||
bcrypt = "0.16.0"
|
bcrypt = "0.16.0"
|
||||||
|
based = { git = "https://git.hydrar.de/jmarya/based", features = ["cache"] }
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
use serde_json::json;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
@ -10,7 +9,6 @@ pub use video::Video;
|
||||||
|
|
||||||
use crate::meta;
|
use crate::meta;
|
||||||
mod func;
|
mod func;
|
||||||
pub mod user;
|
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -271,37 +269,3 @@ impl Library {
|
||||||
videos
|
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<Output = serde_json::Value>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<serde_json::Value> {
|
|
||||||
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, ApiError> {
|
|
||||||
uuid::Uuid::from_str(id).map_err(|_| no_uuid_error())
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiError = rocket::response::status::BadRequest<serde_json::Value>;
|
|
||||||
type FallibleApiResponse = Result<serde_json::Value, ApiError>;
|
|
||||||
|
|
||||||
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
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
|
@ -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<User> {
|
|
||||||
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<User> {
|
|
||||||
// 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<User> {
|
|
||||||
sqlx::query_as("SELECT * FROM users")
|
|
||||||
.fetch_all(&self.conn)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn verify(&self, session_id: &str) -> Option<User> {
|
|
||||||
let ses: Option<Session> = 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,
|
|
||||||
}
|
|
|
@ -1,8 +1,5 @@
|
||||||
use crate::{
|
use crate::yt_meta::{self, get_vid_duration};
|
||||||
get_pg,
|
use based::{get_pg, request::api::ToAPI};
|
||||||
pages::ToAPI,
|
|
||||||
yt_meta::{self, get_vid_duration},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::prelude::FromRow;
|
use sqlx::prelude::FromRow;
|
||||||
|
|
31
src/main.rs
31
src/main.rs
|
@ -1,33 +1,11 @@
|
||||||
use std::path::Path;
|
use based::{auth::User, get_pg};
|
||||||
|
|
||||||
use library::user::UserManager;
|
|
||||||
use rocket::{http::Method, routes};
|
use rocket::{http::Method, routes};
|
||||||
use tokio::sync::OnceCell;
|
use std::path::Path;
|
||||||
|
|
||||||
mod library;
|
mod library;
|
||||||
mod meta;
|
mod meta;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod yt_meta;
|
mod yt_meta;
|
||||||
|
|
||||||
pub static PG: OnceCell<sqlx::PgPool> = 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]
|
#[rocket::launch]
|
||||||
async fn launch() -> _ {
|
async fn launch() -> _ {
|
||||||
std::env::set_var("RUST_LOG", "info");
|
std::env::set_var("RUST_LOG", "info");
|
||||||
|
@ -48,10 +26,8 @@ async fn launch() -> _ {
|
||||||
sqlx::migrate!("./migrations").run(pg).await.unwrap();
|
sqlx::migrate!("./migrations").run(pg).await.unwrap();
|
||||||
|
|
||||||
let lib = library::Library::new().await;
|
let lib = library::Library::new().await;
|
||||||
let um = UserManager::new(pg.clone());
|
|
||||||
|
|
||||||
um.create("admin", "admin", library::user::UserRole::Admin)
|
User::create("admin", "admin", based::auth::UserRole::Admin).await;
|
||||||
.await;
|
|
||||||
|
|
||||||
let library = lib.clone();
|
let library = lib.clone();
|
||||||
|
|
||||||
|
@ -94,5 +70,4 @@ async fn launch() -> _ {
|
||||||
)
|
)
|
||||||
.attach(cors)
|
.attach(cors)
|
||||||
.manage(lib)
|
.manage(lib)
|
||||||
.manage(um)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
use std::io::{self, Read};
|
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
pub fn extract_video_thumbnail(path: &str, time: f64) -> Vec<u8> {
|
pub fn extract_video_thumbnail(path: &str, time: f64) -> Vec<u8> {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use rocket::{
|
use rocket::{
|
||||||
fs::NamedFile,
|
|
||||||
get,
|
get,
|
||||||
http::{ContentType, Status},
|
http::{ContentType, Status},
|
||||||
State,
|
State,
|
||||||
|
|
|
@ -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 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<String>,
|
|
||||||
user: Option<User>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HTMX {
|
|
||||||
pub async fn user(&mut self, um: &UserManager) -> Option<User> {
|
|
||||||
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<Self, Self::Error> {
|
|
||||||
rocket::outcome::Outcome::Success(HTMX {
|
|
||||||
is_htmx: !req
|
|
||||||
.headers()
|
|
||||||
.get("HX-Request")
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.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<String>,
|
|
||||||
) -> PreEscaped<String> {
|
|
||||||
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<String> {
|
|
||||||
html!(
|
|
||||||
script {
|
|
||||||
(PreEscaped(script))
|
|
||||||
};
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shell(content: PreEscaped<String>, title: &str, user: Option<User>) -> PreEscaped<String> {
|
|
||||||
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(
|
pub async fn render_page(
|
||||||
htmx: HTMX,
|
ctx: RequestContext,
|
||||||
content: PreEscaped<String>,
|
content: PreEscaped<String>,
|
||||||
title: &str,
|
title: &str,
|
||||||
user: Option<User>,
|
user: Option<User>,
|
||||||
) -> (Status, (ContentType, String)) {
|
) -> StringResponse {
|
||||||
if !htmx.is_htmx {
|
based::page::render_page(content, title, ctx, &based::page::Shell::new(
|
||||||
(
|
html! {
|
||||||
Status::Ok,
|
script src="https://cdn.tailwindcss.com" {};
|
||||||
(ContentType::HTML, shell(content, title, user).into_string()),
|
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";
|
||||||
} else {
|
},
|
||||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
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<String> {
|
pub fn loading_spinner() -> PreEscaped<String> {
|
||||||
|
@ -144,28 +60,6 @@ pub fn search_bar(query: &str) -> PreEscaped<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String> {
|
pub async fn video_element_wide(video: &Video) -> PreEscaped<String> {
|
||||||
html!(
|
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" {
|
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" {
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
use based::{
|
||||||
|
auth::User,
|
||||||
|
page::htmx_link,
|
||||||
|
request::{api::vec_to_api, RequestContext, StringResponse},
|
||||||
|
};
|
||||||
use maud::html;
|
use maud::html;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
get,
|
get,
|
||||||
|
@ -6,15 +11,11 @@ use rocket::{
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{library::Library, pages::components::video_element};
|
||||||
library::{user::UserManager, Library},
|
|
||||||
pages::components::{htmx_link, video_element},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
api_response,
|
api_response,
|
||||||
components::{render_page, video_element_wide, HTMX},
|
components::{render_page, video_element_wide},
|
||||||
vec_to_api,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[get("/search?<query>&<offset>")]
|
#[get("/search?<query>&<offset>")]
|
||||||
|
@ -35,13 +36,11 @@ pub async fn search(
|
||||||
|
|
||||||
#[get("/d/<dir>")]
|
#[get("/d/<dir>")]
|
||||||
pub async fn channel_page(
|
pub async fn channel_page(
|
||||||
mut htmx: HTMX,
|
ctx: RequestContext,
|
||||||
dir: &str,
|
dir: &str,
|
||||||
library: &State<Library>,
|
library: &State<Library>,
|
||||||
um: &State<UserManager>,
|
user: User,
|
||||||
) -> (Status, (ContentType, String)) {
|
) -> StringResponse {
|
||||||
let user = htmx.user(um).await;
|
|
||||||
|
|
||||||
if dir.ends_with(".json") {
|
if dir.ends_with(".json") {
|
||||||
let dir_videos = library
|
let dir_videos = library
|
||||||
.get_directory_videos(dir.split_once(".json").map(|x| x.0).unwrap_or_default())
|
.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("/")]
|
#[get("/")]
|
||||||
pub async fn index_page(
|
pub async fn index_page(
|
||||||
mut htmx: HTMX,
|
ctx: RequestContext,
|
||||||
library: &State<Library>,
|
library: &State<Library>,
|
||||||
um: &State<UserManager>,
|
user: User,
|
||||||
) -> (Status, (ContentType, String)) {
|
) -> (Status, (ContentType, String)) {
|
||||||
let user = htmx.user(um).await;
|
|
||||||
|
|
||||||
let content = html!(
|
let content = html!(
|
||||||
h1 class="text-center text-4xl font-extrabold leading-tight mt-4" { "Random Videos" };
|
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" {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,23 +7,6 @@ pub mod user;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
pub mod yt;
|
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<Output = serde_json::Value>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<serde_json::Value> {
|
|
||||||
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)) {
|
pub fn api_response(json: &serde_json::Value) -> (Status, (ContentType, String)) {
|
||||||
(
|
(
|
||||||
Status::Ok,
|
Status::Ok,
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
use crate::{
|
use based::{auth::User, request::RequestContext};
|
||||||
library::{user::UserManager, Library},
|
|
||||||
pages::components::{htmx_link, video_element},
|
|
||||||
};
|
|
||||||
use maud::html;
|
use maud::html;
|
||||||
use rocket::http::CookieJar;
|
use rocket::http::CookieJar;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
@ -10,20 +7,13 @@ use rocket::{
|
||||||
http::{ContentType, Cookie, Status},
|
http::{ContentType, Cookie, Status},
|
||||||
post,
|
post,
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
FromForm, State,
|
FromForm,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use super::{
|
use super::components::render_page;
|
||||||
api_response,
|
|
||||||
components::{render_page, video_element_wide, HTMX},
|
|
||||||
vec_to_api,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[get("/login")]
|
#[get("/login")]
|
||||||
pub async fn login(mut htmx: HTMX, um: &State<UserManager>) -> (Status, (ContentType, String)) {
|
pub async fn login(ctx: RequestContext, user: User) -> (Status, (ContentType, String)) {
|
||||||
let user = htmx.user(um).await;
|
|
||||||
|
|
||||||
let content = html!(
|
let content = html!(
|
||||||
h2 { "Login" };
|
h2 { "Login" };
|
||||||
form action="/login" method="POST" {
|
form action="/login" method="POST" {
|
||||||
|
@ -33,7 +23,7 @@ pub async fn login(mut htmx: HTMX, um: &State<UserManager>) -> (Status, (Content
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render_page(htmx, content, "Login", user).await
|
render_page(ctx, content, "Login", Some(user)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromForm)]
|
#[derive(FromForm)]
|
||||||
|
@ -45,14 +35,14 @@ pub struct LoginForm {
|
||||||
#[post("/login", data = "<login_form>")]
|
#[post("/login", data = "<login_form>")]
|
||||||
pub async fn login_post(
|
pub async fn login_post(
|
||||||
login_form: Form<LoginForm>,
|
login_form: Form<LoginForm>,
|
||||||
um: &State<UserManager>,
|
user: User,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
) -> Option<Redirect> {
|
) -> Option<Redirect> {
|
||||||
let login_data = login_form.into_inner();
|
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
|
.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
|
.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)
|
.max_age(rocket::time::Duration::days(7)) // Set the cookie expiration (7 days in this case)
|
||||||
|
|
|
@ -1,30 +1,22 @@
|
||||||
|
use based::{auth::User, format::format_date, request::RequestContext};
|
||||||
use maud::html;
|
use maud::html;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
get,
|
get,
|
||||||
http::{ContentType, Status},
|
http::{ContentType, Status},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{library::Library, pages::components::video_element_wide};
|
||||||
library::{self, user::UserManager, Library},
|
|
||||||
pages::components::{format_date, video_element, video_element_wide},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
use super::components::render_page;
|
||||||
components::{render_page, HTMX},
|
|
||||||
vec_to_api,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[get("/watch?<v>")]
|
#[get("/watch?<v>")]
|
||||||
pub async fn watch_page(
|
pub async fn watch_page(
|
||||||
mut htmx: HTMX,
|
ctx: RequestContext,
|
||||||
library: &State<Library>,
|
library: &State<Library>,
|
||||||
v: String,
|
v: String,
|
||||||
um: &State<UserManager>,
|
user: User,
|
||||||
) -> (Status, (ContentType, String)) {
|
) -> (Status, (ContentType, String)) {
|
||||||
let user = htmx.user(um).await;
|
|
||||||
|
|
||||||
let video = if let Some(video) = library.get_video_by_id(&v).await {
|
let video = if let Some(video) = library.get_video_by_id(&v).await {
|
||||||
video
|
video
|
||||||
} else {
|
} 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
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
|
use based::request::api::vec_to_api;
|
||||||
use rocket::{get, State};
|
use rocket::{get, State};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{library::Library, pages::vec_to_api};
|
use crate::library::Library;
|
||||||
|
|
||||||
#[get("/yt/tags")]
|
#[get("/yt/tags")]
|
||||||
pub async fn yt_tags(library: &State<Library>) -> serde_json::Value {
|
pub async fn yt_tags(library: &State<Library>) -> serde_json::Value {
|
||||||
|
|
Loading…
Add table
Reference in a new issue