This commit is contained in:
parent
b98beff824
commit
ae36928791
11 changed files with 410 additions and 15 deletions
51
Cargo.lock
generated
51
Cargo.lock
generated
|
@ -149,6 +149,19 @@ version = "1.6.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blowfish",
|
||||
"getrandom",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "binascii"
|
||||
version = "0.1.4"
|
||||
|
@ -173,6 +186,16 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blowfish"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
|
@ -227,6 +250,16 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
@ -337,6 +370,12 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.9"
|
||||
|
@ -1044,6 +1083,15 @@ version = "0.1.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.13"
|
||||
|
@ -2864,12 +2912,15 @@ checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
|
|||
name = "watchdogs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hex",
|
||||
"log",
|
||||
"maud",
|
||||
"rand",
|
||||
"rayon",
|
||||
"regex",
|
||||
"ring",
|
||||
|
|
|
@ -21,3 +21,6 @@ tokio = { version = "1.35.1", features = ["full"] }
|
|||
uuid = { version = "1.8.0", features = ["v4", "serde"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "derive", "uuid", "chrono", "json"] }
|
||||
maud = "0.26.0"
|
||||
rand = "0.8.5"
|
||||
data-encoding = "2.6.0"
|
||||
bcrypt = "0.16.0"
|
||||
|
|
14
migrations/003_add_users.sql
Normal file
14
migrations/003_add_users.sql
Normal file
|
@ -0,0 +1,14 @@
|
|||
CREATE TYPE user_role AS ENUM ('regular', 'admin');
|
||||
|
||||
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,
|
||||
FOREIGN KEY("user") REFERENCES users(username)
|
||||
);
|
|
@ -1,3 +1,4 @@
|
|||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
@ -6,6 +7,7 @@ use walkdir::WalkDir;
|
|||
use func::is_video_file;
|
||||
pub use video::Video;
|
||||
mod func;
|
||||
pub mod user;
|
||||
mod video;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -208,3 +210,37 @@ 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<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
|
||||
}))
|
||||
}
|
||||
|
|
179
src/library/user.rs
Normal file
179
src/library/user.rs
Normal file
|
@ -0,0 +1,179 @@
|
|||
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,
|
||||
}
|
10
src/main.rs
10
src/main.rs
|
@ -1,5 +1,6 @@
|
|||
use std::path::Path;
|
||||
|
||||
use library::user::UserManager;
|
||||
use rocket::{http::Method, routes};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
|
@ -46,6 +47,10 @@ 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;
|
||||
|
||||
let library = lib.clone();
|
||||
|
||||
|
@ -81,9 +86,12 @@ async fn launch() -> _ {
|
|||
pages::yt::yt_tag_page,
|
||||
pages::yt::yt_channel_page,
|
||||
pages::index::index_page,
|
||||
pages::watch::watch_page
|
||||
pages::watch::watch_page,
|
||||
pages::user::login,
|
||||
pages::user::login_post
|
||||
],
|
||||
)
|
||||
.attach(cors)
|
||||
.manage(lib)
|
||||
.manage(um)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,10 @@ use core::num;
|
|||
|
||||
use maud::{html, PreEscaped};
|
||||
|
||||
use crate::library::Video;
|
||||
use crate::library::{
|
||||
user::{User, UserManager},
|
||||
Video,
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
http::{ContentType, Status},
|
||||
|
@ -10,7 +13,21 @@ use rocket::{
|
|||
};
|
||||
|
||||
pub struct HTMX {
|
||||
is_htmx: bool,
|
||||
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]
|
||||
|
@ -24,6 +41,11 @@ impl<'r> FromRequest<'r> for HTMX {
|
|||
.get("HX-Request")
|
||||
.collect::<Vec<&str>>()
|
||||
.is_empty(),
|
||||
session: req
|
||||
.cookies()
|
||||
.get("session_id")
|
||||
.map(|x| x.value().to_string()),
|
||||
user: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +71,7 @@ pub fn script(script: &str) -> PreEscaped<String> {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn shell(content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
||||
pub fn shell(content: PreEscaped<String>, title: &str, user: Option<User>) -> PreEscaped<String> {
|
||||
html! {
|
||||
html {
|
||||
head {
|
||||
|
@ -62,13 +84,17 @@ pub fn shell(content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
|||
header class="bg-gray-800 text-white shadow-md py-2" {
|
||||
(script(include_str!("../scripts/header.js")));
|
||||
|
||||
div class="flex justify-start px-6" {
|
||||
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) };
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
@ -81,15 +107,16 @@ pub fn shell(content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_page(
|
||||
pub async fn render_page(
|
||||
htmx: HTMX,
|
||||
content: PreEscaped<String>,
|
||||
title: &str,
|
||||
user: Option<User>,
|
||||
) -> (Status, (ContentType, String)) {
|
||||
if !htmx.is_htmx {
|
||||
(
|
||||
Status::Ok,
|
||||
(ContentType::HTML, shell(content, title).into_string()),
|
||||
(ContentType::HTML, shell(content, title, user).into_string()),
|
||||
)
|
||||
} else {
|
||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
||||
|
|
|
@ -7,7 +7,7 @@ use rocket::{
|
|||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
library::Library,
|
||||
library::{user::UserManager, Library},
|
||||
pages::components::{htmx_link, video_element},
|
||||
};
|
||||
|
||||
|
@ -35,10 +35,13 @@ pub async fn search(
|
|||
|
||||
#[get("/d/<dir>")]
|
||||
pub async fn channel_page(
|
||||
htmx: HTMX,
|
||||
mut htmx: HTMX,
|
||||
dir: &str,
|
||||
library: &State<Library>,
|
||||
um: &State<UserManager>,
|
||||
) -> (Status, (ContentType, String)) {
|
||||
let user = htmx.user(um).await;
|
||||
|
||||
if dir.ends_with(".json") {
|
||||
let dir_videos = library
|
||||
.get_directory_videos(dir.split_once(".json").map(|x| x.0).unwrap_or_default())
|
||||
|
@ -57,11 +60,17 @@ pub async fn channel_page(
|
|||
};
|
||||
);
|
||||
|
||||
render_page(htmx, content, dir)
|
||||
render_page(htmx, content, dir, user).await
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index_page(htmx: HTMX, library: &State<Library>) -> (Status, (ContentType, String)) {
|
||||
pub async fn index_page(
|
||||
mut htmx: HTMX,
|
||||
library: &State<Library>,
|
||||
um: &State<UserManager>,
|
||||
) -> (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="grid grid-cols-3 gap-6 p-6" {
|
||||
|
@ -78,5 +87,5 @@ pub async fn index_page(htmx: HTMX, library: &State<Library>) -> (Status, (Conte
|
|||
};
|
||||
);
|
||||
|
||||
render_page(htmx, content, "WatchDogs")
|
||||
render_page(htmx, content, "WatchDogs", user).await
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ use rocket::http::{ContentType, Status};
|
|||
pub mod assets;
|
||||
pub mod components;
|
||||
pub mod index;
|
||||
pub mod user;
|
||||
pub mod watch;
|
||||
pub mod yt;
|
||||
|
||||
|
|
64
src/pages/user.rs
Normal file
64
src/pages/user.rs
Normal file
|
@ -0,0 +1,64 @@
|
|||
use crate::{
|
||||
library::{user::UserManager, Library},
|
||||
pages::components::{htmx_link, video_element},
|
||||
};
|
||||
use maud::html;
|
||||
use rocket::http::CookieJar;
|
||||
use rocket::{
|
||||
form::Form,
|
||||
get,
|
||||
http::{ContentType, Cookie, Status},
|
||||
post,
|
||||
response::Redirect,
|
||||
FromForm, State,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
api_response,
|
||||
components::{render_page, video_element_wide, HTMX},
|
||||
vec_to_api,
|
||||
};
|
||||
|
||||
#[get("/login")]
|
||||
pub async fn login(mut htmx: HTMX, um: &State<UserManager>) -> (Status, (ContentType, String)) {
|
||||
let user = htmx.user(um).await;
|
||||
|
||||
let content = html!(
|
||||
h2 { "Login" };
|
||||
form action="/login" method="POST" {
|
||||
input type="text" name="username" placeholder="Username" required;
|
||||
input type="password" name="password" placeholder="Password" required;
|
||||
input type="submit" value="Login";
|
||||
}
|
||||
);
|
||||
|
||||
render_page(htmx, content, "Login", user).await
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[post("/login", data = "<login_form>")]
|
||||
pub async fn login_post(
|
||||
login_form: Form<LoginForm>,
|
||||
um: &State<UserManager>,
|
||||
cookies: &CookieJar<'_>,
|
||||
) -> Option<Redirect> {
|
||||
let login_data = login_form.into_inner();
|
||||
|
||||
let (session, _) = um.login(&login_data.username, &login_data.password).await?;
|
||||
|
||||
let session_cookie = Cookie::build(("session_id", 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)
|
||||
.build();
|
||||
|
||||
cookies.add(session_cookie);
|
||||
|
||||
Some(Redirect::to("/"))
|
||||
}
|
|
@ -7,7 +7,7 @@ use rocket::{
|
|||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
library::{self, Library},
|
||||
library::{self, user::UserManager, Library},
|
||||
pages::components::{format_date, video_element},
|
||||
};
|
||||
|
||||
|
@ -18,10 +18,13 @@ use super::{
|
|||
|
||||
#[get("/watch?<v>")]
|
||||
pub async fn watch_page(
|
||||
htmx: HTMX,
|
||||
mut htmx: HTMX,
|
||||
library: &State<Library>,
|
||||
v: String,
|
||||
um: &State<UserManager>,
|
||||
) -> (Status, (ContentType, String)) {
|
||||
let user = htmx.user(um).await;
|
||||
|
||||
let video = if let Some(video) = library.get_video_by_id(&v).await {
|
||||
video
|
||||
} else {
|
||||
|
@ -61,5 +64,5 @@ pub async fn watch_page(
|
|||
};
|
||||
);
|
||||
|
||||
render_page(htmx, content, &format!("{} - WatchDogs", video.title))
|
||||
render_page(htmx, content, &format!("{} - WatchDogs", video.title), user).await
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue