Compare commits
22 commits
Author | SHA1 | Date | |
---|---|---|---|
e9e27e6feb | |||
a5ecf145a9 | |||
37f65b6353 | |||
95469da4f8 | |||
7feae78de0 | |||
eccbc3c3d6 | |||
29cf990363 | |||
cb0806f690 | |||
567981c6b5 | |||
901af1c43c | |||
dde84caa53 | |||
86dbdcf75d | |||
00bb6f152d | |||
d6555edc29 | |||
cd140f0160 | |||
439467f730 | |||
3837302161 | |||
04852f2fbc | |||
7d6d301e36 | |||
4e3107ce08 | |||
e5fe40e4be | |||
d7a55f6579 |
19 changed files with 728 additions and 136 deletions
19
README.md
19
README.md
|
@ -9,24 +9,7 @@ Based is a micro framework providing web dev primitives.
|
|||
- Templates (Shell)
|
||||
|
||||
## User Auth
|
||||
To use the user auth feature, make sure a migration has added the following to your PostgresDB:
|
||||
|
||||
```sql
|
||||
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)
|
||||
);
|
||||
```
|
||||
To use the user auth feature, make sure a migration has added [these tables](src/auth/auth.sql) to your PostgresDB:
|
||||
|
||||
## HTMX
|
||||
Based has a route for serving HTMX at `based::htmx::htmx_script_route` which you can include in your rocket `routes!`. The HTMX script will be available at `/assets/htmx.min.js`.
|
||||
|
|
4
build.rs
4
build.rs
|
@ -5,7 +5,7 @@ fn main() {
|
|||
let url = "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js";
|
||||
let dest_path = Path::new("src/htmx.min.js");
|
||||
|
||||
println!("Downloading htmx.min.js from {}", url);
|
||||
println!("Downloading htmx.min.js from {url}");
|
||||
let response = reqwest::blocking::get(url)
|
||||
.expect("Failed to send HTTP request")
|
||||
.error_for_status()
|
||||
|
@ -13,7 +13,7 @@ fn main() {
|
|||
|
||||
let content = response.bytes().expect("Failed to read response body");
|
||||
|
||||
fs::write(&dest_path, &content).expect("Failed to write htmx.min.js to destination");
|
||||
fs::write(dest_path, &content).expect("Failed to write htmx.min.js to destination");
|
||||
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ use rocket::routes;
|
|||
pub async fn index_page<'r>(ctx: RequestContext) -> impl Responder<'r, 'static> {
|
||||
based::request::assets::DataResponse::new(
|
||||
include_bytes!("../Cargo.toml").to_vec(),
|
||||
"text/toml",
|
||||
"text/toml".to_string(),
|
||||
Some(60 * 60 * 3),
|
||||
)
|
||||
}
|
||||
|
|
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
|
||||
);
|
46
src/auth/csrf.rs
Normal file
46
src/auth/csrf.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
use super::User;
|
||||
use crate::get_pg;
|
||||
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> {
|
||||
html! { script { (PreEscaped(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
|
||||
}
|
||||
}
|
|
@ -1,32 +1,15 @@
|
|||
use data_encoding::HEXUPPER;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
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;
|
||||
|
||||
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 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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
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()
|
||||
}
|
||||
}
|
|
@ -3,9 +3,11 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::json;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use super::{Session, gen_token};
|
||||
use super::Sessions;
|
||||
use crate::{get_pg, request::api::ToAPI};
|
||||
|
||||
// TODO : 2FA
|
||||
|
||||
/// User
|
||||
///
|
||||
/// # Example:
|
||||
|
@ -26,6 +28,8 @@ pub struct 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)]
|
||||
|
@ -38,11 +42,6 @@ pub enum UserRole {
|
|||
}
|
||||
|
||||
impl User {
|
||||
// Get a user from session ID
|
||||
pub async fn from_session(session: &str) -> 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()
|
||||
}
|
||||
|
||||
/// Find a user by their username
|
||||
pub async fn find(username: &str) -> Option<Self> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE username = $1")
|
||||
|
@ -55,16 +54,17 @@ impl User {
|
|||
/// 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: &str, password: &str, role: UserRole) -> Option<Self> {
|
||||
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() {
|
||||
if Self::find(&username).await.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let u = Self {
|
||||
username: username.to_string(),
|
||||
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)")
|
||||
|
@ -78,17 +78,6 @@ impl User {
|
|||
Some(u)
|
||||
}
|
||||
|
||||
/// Login a user with the given username and password
|
||||
pub 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))
|
||||
}
|
||||
|
||||
/// Change the password of a User
|
||||
///
|
||||
/// Returns a Result indicating whether the password change was successful or not
|
||||
|
@ -97,7 +86,7 @@ impl User {
|
|||
sqlx::query("UPDATE users SET \"password\" = $1 WHERE username = $2;")
|
||||
.bind(bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap())
|
||||
.bind(&self.username)
|
||||
.fetch_one(get_pg!())
|
||||
.execute(get_pg!())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -108,6 +97,7 @@ impl User {
|
|||
}
|
||||
|
||||
/// Find all users in the system
|
||||
#[must_use]
|
||||
pub async fn find_all() -> Vec<Self> {
|
||||
sqlx::query_as("SELECT * FROM users")
|
||||
.fetch_all(get_pg!())
|
||||
|
@ -115,21 +105,8 @@ impl User {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
/// Generate a new session token for the user
|
||||
///
|
||||
/// Returns a Session instance containing the generated token and associated user
|
||||
pub async fn session(&self) -> 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(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)
|
||||
}
|
||||
|
@ -137,6 +114,7 @@ impl User {
|
|||
/// 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()
|
||||
}
|
||||
|
@ -151,13 +129,13 @@ impl ToAPI for User {
|
|||
}
|
||||
}
|
||||
|
||||
async fn extract_user<'r>(request: &'r Request<'_>) -> Option<User> {
|
||||
/// 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()).await {
|
||||
if let Some(user) = User::from_session(session_id.value().to_string()).await {
|
||||
return Some(user);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
|
@ -170,14 +148,13 @@ impl<'r> FromRequest<'r> for User {
|
|||
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);
|
||||
} else {
|
||||
return Outcome::Error((Status::Unauthorized, ()));
|
||||
}
|
||||
Outcome::Error((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Struct which extracts a user with session from `Token` HTTP Header.
|
||||
pub struct APIUser(User);
|
||||
pub struct APIUser(pub User);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for APIUser {
|
||||
|
@ -186,11 +163,10 @@ impl<'r> FromRequest<'r> for APIUser {
|
|||
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).await {
|
||||
if let Some(user) = User::from_session(key.to_string()).await {
|
||||
return Outcome::Success(APIUser(user));
|
||||
} else {
|
||||
return Outcome::Error((Status::Unauthorized, ()));
|
||||
}
|
||||
return Outcome::Error((Status::Unauthorized, ()));
|
||||
}
|
||||
None => Outcome::Error((Status::Unauthorized, ())),
|
||||
}
|
||||
|
@ -226,9 +202,9 @@ impl<'r> FromRequest<'r> for MaybeUser {
|
|||
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));
|
||||
} else {
|
||||
return Outcome::Success(MaybeUser::Anonymous);
|
||||
}
|
||||
|
||||
Outcome::Success(MaybeUser::Anonymous)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -239,13 +215,15 @@ impl From<MaybeUser> for Option<User> {
|
|||
}
|
||||
|
||||
impl MaybeUser {
|
||||
pub fn user(&self) -> Option<&User> {
|
||||
#[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),
|
||||
|
@ -268,7 +246,7 @@ impl MaybeUser {
|
|||
/// ...
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AdminUser(User);
|
||||
pub struct AdminUser(pub User);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for AdminUser {
|
||||
|
@ -279,8 +257,8 @@ impl<'r> FromRequest<'r> for AdminUser {
|
|||
if user.is_admin() {
|
||||
return Outcome::Success(AdminUser(user));
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
Outcome::Error((Status::Unauthorized, ()))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
/// let formatted = format_date(&date);
|
||||
/// assert_eq!(formatted, "2023-12-18");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn format_date(date: &chrono::NaiveDate) -> String {
|
||||
// TODO : Implement custom formatting
|
||||
date.to_string()
|
||||
|
@ -35,11 +36,23 @@ pub fn format_date(date: &chrono::NaiveDate) -> String {
|
|||
///
|
||||
/// let number = 12345;
|
||||
/// let formatted = format_number(number);
|
||||
/// assert_eq!(formatted, "12345");
|
||||
/// assert_eq!(formatted, "12.345");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn format_number(num: i32) -> String {
|
||||
// TODO : Implement custom formatting
|
||||
num.to_string()
|
||||
let mut str = num.to_string();
|
||||
let mut result = String::new();
|
||||
|
||||
str = str.chars().rev().collect();
|
||||
|
||||
for (i, c) in str.chars().enumerate() {
|
||||
if i != 0 && i % 3 == 0 {
|
||||
result.push('.');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
|
||||
result.chars().rev().collect()
|
||||
}
|
||||
|
||||
/// Converts a number of seconds into a formatted string in `HH:MM:SS` or `MM:SS` format.
|
||||
|
@ -66,14 +79,15 @@ pub fn format_number(num: i32) -> String {
|
|||
/// let formatted = format_seconds_to_hhmmss(short_duration);
|
||||
/// assert_eq!(formatted, "00:59");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub 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)
|
||||
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
||||
} else {
|
||||
format!("{:02}:{:02}", minutes, seconds)
|
||||
format!("{minutes:02}:{seconds:02}")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,5 @@ use crate::request::{StringResponse, respond_script};
|
|||
|
||||
#[get("/assets/htmx.min.js")]
|
||||
pub fn htmx_script_route() -> StringResponse {
|
||||
respond_script(include_str!("htmx.min.js"))
|
||||
respond_script(include_str!("htmx.min.js").to_string())
|
||||
}
|
||||
|
|
16
src/lib.rs
16
src/lib.rs
|
@ -1,3 +1,5 @@
|
|||
use data_encoding::HEXUPPER;
|
||||
use rand::RngCore;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
pub mod auth;
|
||||
|
@ -8,15 +10,13 @@ pub mod page;
|
|||
pub mod request;
|
||||
pub mod result;
|
||||
|
||||
// TODO : API Pagination?
|
||||
// TODO : CORS?
|
||||
// TODO : CSRF?
|
||||
|
||||
// Postgres
|
||||
|
||||
pub static PG: OnceCell<sqlx::PgPool> = OnceCell::const_new();
|
||||
|
||||
/// A macro to retrieve or initialize the PostgreSQL connection pool.
|
||||
/// A macro to retrieve or initialize the `PostgreSQL` connection pool.
|
||||
///
|
||||
/// This macro provides a convenient way to access the `PgPool`. If the pool is not already initialized,
|
||||
/// it creates a new pool using the connection string from the `$DATABASE_URL` environment variable.
|
||||
|
@ -34,7 +34,7 @@ macro_rules! get_pg {
|
|||
client
|
||||
} else {
|
||||
let client = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.max_connections(12)
|
||||
.connect(&std::env::var("DATABASE_URL").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
@ -43,3 +43,11 @@ macro_rules! get_pg {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn gen_random(token_length: usize) -> String {
|
||||
let mut token_bytes = vec![0u8; token_length];
|
||||
|
||||
rand::thread_rng().fill_bytes(&mut token_bytes);
|
||||
|
||||
HEXUPPER.encode(&token_bytes)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
pub mod search;
|
||||
|
||||
use crate::request::{RequestContext, StringResponse};
|
||||
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
@ -26,7 +28,8 @@ impl Shell {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `Shell` instance encapsulating the provided HTML content and attributes.
|
||||
pub fn new(
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
head: PreEscaped<String>,
|
||||
body_content: PreEscaped<String>,
|
||||
body_class: Option<String>,
|
||||
|
@ -46,6 +49,7 @@ impl Shell {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the full HTML page content.
|
||||
#[must_use]
|
||||
pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
html {
|
||||
|
@ -94,7 +98,9 @@ pub async fn render_page(
|
|||
ctx: RequestContext,
|
||||
shell: &Shell,
|
||||
) -> StringResponse {
|
||||
if !ctx.is_htmx {
|
||||
if ctx.is_htmx {
|
||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
||||
} else {
|
||||
(
|
||||
Status::Ok,
|
||||
(
|
||||
|
@ -102,8 +108,6 @@ pub async fn render_page(
|
|||
shell.render(content, title).into_string(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,6 +123,7 @@ pub async fn render_page(
|
|||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the rendered HTML link element.
|
||||
#[must_use]
|
||||
pub fn htmx_link(
|
||||
url: &str,
|
||||
class: &str,
|
||||
|
@ -142,6 +147,7 @@ pub fn htmx_link(
|
|||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the rendered `<script>` element.
|
||||
#[must_use]
|
||||
pub fn script(script: &str) -> PreEscaped<String> {
|
||||
html!(
|
||||
script {
|
||||
|
|
160
src/page/search.rs
Normal file
160
src/page/search.rs
Normal file
|
@ -0,0 +1,160 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
use crate::request::{RequestContext, api::Pager};
|
||||
|
||||
/// Represents a search form with configurable options such as heading, placeholder, and CSS class.
|
||||
pub struct Search {
|
||||
post_url: String,
|
||||
heading: Option<PreEscaped<String>>,
|
||||
placeholder: Option<String>,
|
||||
search_class: Option<String>,
|
||||
}
|
||||
|
||||
impl Search {
|
||||
/// Creates a new `Search` instance with the specified post URL.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `post_url` - The URL where the search form will send the POST request.
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `Search` instance with default settings.
|
||||
#[must_use]
|
||||
pub const fn new(post_url: String) -> Self {
|
||||
Self {
|
||||
heading: None,
|
||||
placeholder: None,
|
||||
post_url,
|
||||
search_class: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the placeholder text for the search input field.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `placeholder` - The placeholder text to display in the search input.
|
||||
///
|
||||
/// # Returns
|
||||
/// The updated `Search` instance.
|
||||
#[must_use]
|
||||
pub fn placeholder(mut self, placeholder: String) -> Self {
|
||||
self.placeholder = Some(placeholder);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the heading to be displayed above the search form.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `heading` - The heading element to display.
|
||||
///
|
||||
/// # Returns
|
||||
/// The updated `Search` instance.
|
||||
#[must_use]
|
||||
pub fn heading(mut self, heading: PreEscaped<String>) -> Self {
|
||||
self.heading = Some(heading);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the CSS class for the search input field.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `class` - The CSS class to apply to the search input.
|
||||
///
|
||||
/// # Returns
|
||||
/// The updated `Search` instance.
|
||||
#[must_use]
|
||||
pub fn search_class(mut self, class: String) -> Self {
|
||||
self.search_class = Some(class);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the HTML for search results based on the current page and query.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pager` - The `Pager` instance to paginate results.
|
||||
/// * `page` - The current page number.
|
||||
/// * `query` - The search query string.
|
||||
/// * `result_ui` - A function that transforms each result into HTML.
|
||||
///
|
||||
/// # Returns
|
||||
/// The HTML string containing the search results.
|
||||
pub fn build_results<T>(
|
||||
&self,
|
||||
pager: Pager<T>,
|
||||
page: i64,
|
||||
query: &str,
|
||||
result_ui: impl Fn(&T) -> PreEscaped<String>,
|
||||
) -> PreEscaped<String> {
|
||||
let results = pager.page(page as u64);
|
||||
let reslen = results.len();
|
||||
|
||||
html! {
|
||||
|
||||
@for res in results {
|
||||
(result_ui(res))
|
||||
}
|
||||
|
||||
@if reslen as u64 == pager.items_per_page {
|
||||
div hx-get=(format!("{}?query={}&page={}", self.post_url, query, page+1))
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML" {};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the full response based on the context (HTMX or full HTML response).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ctx` - The request context, used to determine if HTMX is enabled.
|
||||
/// * `results` - The `Pager` instance containing the search results.
|
||||
/// * `page` - The current page number.
|
||||
/// * `query` - The search query string.
|
||||
/// * `result_ui` - A function that transforms each result into HTML.
|
||||
///
|
||||
/// # Returns
|
||||
/// The HTML string containing either the HTMX response or full page content.
|
||||
pub fn build_response<T>(
|
||||
&self,
|
||||
ctx: &RequestContext,
|
||||
results: Pager<T>,
|
||||
page: i64,
|
||||
query: &str,
|
||||
result_ui: impl Fn(&T) -> PreEscaped<String>,
|
||||
) -> PreEscaped<String> {
|
||||
if ctx.is_htmx {
|
||||
// Return HTMX Search elements
|
||||
self.build_results(results, page, query, result_ui)
|
||||
} else {
|
||||
// Return full rendered site
|
||||
let first_page = self.build_results(results, page, query, result_ui);
|
||||
self.build(query, first_page)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the full search form and first search page results.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - The search query string.
|
||||
/// * `first_page` - The HTML string containing the first page of search results.
|
||||
///
|
||||
/// # Returns
|
||||
/// The HTML string containing the entire search form and results UI.
|
||||
#[must_use]
|
||||
pub fn build(&self, query: &str, first_page: PreEscaped<String>) -> PreEscaped<String> {
|
||||
let no_html = PreEscaped(String::new());
|
||||
html! {
|
||||
(self.heading.as_ref().unwrap_or_else(|| &no_html))
|
||||
input type="search" name="query"
|
||||
value=(query)
|
||||
placeholder=(self.placeholder.as_ref().unwrap_or(&"Search...".to_string())) hx-get=(self.post_url)
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter'], load"
|
||||
hx-target="#search_results" hx-push-url="true"
|
||||
class=(self.search_class.as_deref().unwrap_or_default()) {};
|
||||
|
||||
div id="search_results" {
|
||||
@if !query.is_empty() {
|
||||
(first_page)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -62,6 +62,7 @@ pub fn to_uuid(id: &str) -> Result<uuid::Uuid, ApiError> {
|
|||
///
|
||||
/// # Returns
|
||||
/// * `ApiError` - A `BadRequest` error with a JSON payload describing the issue.
|
||||
#[must_use]
|
||||
pub fn no_uuid_error() -> ApiError {
|
||||
api_error("No valid UUID")
|
||||
}
|
||||
|
@ -76,8 +77,174 @@ pub fn no_uuid_error() -> ApiError {
|
|||
///
|
||||
/// # Returns
|
||||
/// * `ApiError` - A `BadRequest` error with a JSON payload describing the issue.
|
||||
#[must_use]
|
||||
pub fn api_error(msg: &str) -> ApiError {
|
||||
BadRequest(json!({
|
||||
"error": msg
|
||||
}))
|
||||
}
|
||||
|
||||
/// A `Pager` that manages paginated items, with the ability to handle incomplete data.
|
||||
pub struct Pager<T> {
|
||||
inner: Vec<T>,
|
||||
pub items_per_page: u64,
|
||||
complete_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl<T> Pager<T> {
|
||||
/// Creates a new `Pager` instance with the given items and items per page.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `items` - A vector of items to paginate.
|
||||
/// * `per_page` - Number of items per page.
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `Pager` instance.
|
||||
#[must_use]
|
||||
pub const fn new(items: Vec<T>, per_page: u64) -> Self {
|
||||
Self {
|
||||
inner: items,
|
||||
items_per_page: per_page,
|
||||
complete_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new `Pager` instance for an incomplete dataset, starting from a specific page.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `items` - A vector of items to paginate.
|
||||
/// * `per_page` - Number of items per page.
|
||||
/// * `at_page` - The page where data starts, meaning there are no pages before it.
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `Pager` instance.
|
||||
#[must_use]
|
||||
pub const fn new_incomplete(items: Vec<T>, per_page: u64, at_page: u64) -> Self {
|
||||
Self {
|
||||
inner: items,
|
||||
items_per_page: per_page,
|
||||
complete_at: Some(at_page),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the offset for the given page number.
|
||||
///
|
||||
/// This method considers whether the pager has an incomplete dataset and adjusts the offset accordingly.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `page` - The page number for which the offset is calculated.
|
||||
///
|
||||
/// # Returns
|
||||
/// The calculated offset.
|
||||
#[must_use]
|
||||
pub const fn offset(&self, page: u64) -> u64 {
|
||||
if let Some(incomplete) = self.complete_at {
|
||||
let page = page - incomplete;
|
||||
return self.items_per_page * page;
|
||||
}
|
||||
|
||||
self.items_per_page * (page - 1)
|
||||
}
|
||||
|
||||
/// Retrieves the items for a specific page.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `page` - The page number to retrieve.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if trying to access a page before the `complete_at` page in an incomplete pager.
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of items on the requested page.
|
||||
#[must_use]
|
||||
pub fn page(&self, page: u64) -> Vec<&T> {
|
||||
if let Some(incomplete) = self.complete_at {
|
||||
assert!(
|
||||
page >= incomplete,
|
||||
"Tried to access illegal page on incomplete Pager"
|
||||
);
|
||||
}
|
||||
|
||||
self.inner
|
||||
.iter()
|
||||
.skip(self.offset(page).try_into().unwrap())
|
||||
.take(self.items_per_page.try_into().unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `GeneratedPager` is a paginated generator that fetches items dynamically from a generator function.
|
||||
pub struct GeneratedPager<T, G, I>
|
||||
where
|
||||
G: Fn(I, u64, u64) -> futures::future::BoxFuture<'static, Vec<T>>,
|
||||
{
|
||||
generator: G,
|
||||
pub items_per_page: u64,
|
||||
_marker: std::marker::PhantomData<(T, I)>,
|
||||
}
|
||||
|
||||
impl<T, G, I> GeneratedPager<T, G, I>
|
||||
where
|
||||
G: Fn(I, u64, u64) -> futures::future::BoxFuture<'static, Vec<T>>,
|
||||
{
|
||||
/// Creates a new `GeneratedPager` instance with the provided generator and pagination settings.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `generator` - A function that generates a page of items based on the input and pagination settings.
|
||||
/// * `items_per_page` - Number of items per page.
|
||||
///
|
||||
/// # Generator
|
||||
/// The generator function should take the following arguments:
|
||||
/// * `input` - Generic input to the generator
|
||||
/// * `offset` - Offset value
|
||||
/// * `limit` - Limit value (items per page)
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `GeneratedPager` instance.
|
||||
pub const fn new(generator: G, items_per_page: u64) -> Self {
|
||||
Self {
|
||||
generator,
|
||||
items_per_page,
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the offset for the given page number.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `page` - The page number for which the offset is calculated.
|
||||
///
|
||||
/// # Returns
|
||||
/// The calculated offset.
|
||||
pub const fn offset(&self, page: u64) -> u64 {
|
||||
self.items_per_page * (page - 1)
|
||||
}
|
||||
|
||||
/// Asynchronously retrieves the items for a specific page from the generator.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `page` - The page number to retrieve.
|
||||
/// * `input` - The input that is passed to the generator.
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of items on the requested page.
|
||||
pub async fn page(&self, page: u64, input: I) -> Vec<T> {
|
||||
let offset = self.offset(page);
|
||||
(self.generator)(input, offset, self.items_per_page).await
|
||||
}
|
||||
|
||||
/// Converts the `GeneratedPager` into a regular `Pager` for a given page of items.
|
||||
///
|
||||
/// This method allows you to use a `GeneratedPager` like a regular `Pager`, with a pre-generated dataset.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `page` - The page number to retrieve.
|
||||
/// * `input` - The input to pass to the generator function.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Pager` instance containing the requested page of items.
|
||||
pub async fn pager(&self, page: u64, input: I) -> Pager<T> {
|
||||
let content = self.page(page, input).await;
|
||||
Pager::new_incomplete(content, self.items_per_page, page)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ use rocket::http::Status;
|
|||
use rocket::response::Responder;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::gen_random;
|
||||
|
||||
// TODO: Implement file based response
|
||||
|
||||
pub struct DataResponse {
|
||||
|
@ -14,10 +16,11 @@ pub struct DataResponse {
|
|||
}
|
||||
|
||||
impl DataResponse {
|
||||
pub fn new(data: Vec<u8>, content_type: &str, cache_duration: Option<u64>) -> Self {
|
||||
#[must_use]
|
||||
pub const fn new(data: Vec<u8>, content_type: String, cache_duration: Option<u64>) -> Self {
|
||||
Self {
|
||||
data,
|
||||
content_type: content_type.to_string(),
|
||||
content_type,
|
||||
cache_duration,
|
||||
}
|
||||
}
|
||||
|
@ -28,10 +31,9 @@ impl<'r> Responder<'r, 'static> for DataResponse {
|
|||
fn respond_to(self, req: &'r Request<'_>) -> rocket::response::Result<'static> {
|
||||
// Handle Range requests
|
||||
if let Some(range) = req.headers().get_one("Range") {
|
||||
let ranges = range.split(",").collect::<Vec<_>>();
|
||||
if ranges.len() == 1 {
|
||||
if let Some((start, end)) = parse_range_header(range, self.data.len()) {
|
||||
// TODO : Reject invalid ranges
|
||||
// TODO : Multiple ranges?
|
||||
|
||||
let sliced_data = &self.data[start..=end];
|
||||
return Ok(Response::build()
|
||||
.header(Header::new(
|
||||
|
@ -44,14 +46,56 @@ impl<'r> Responder<'r, 'static> for DataResponse {
|
|||
.streamed_body(Cursor::new(sliced_data.to_vec()))
|
||||
.finalize());
|
||||
}
|
||||
} else {
|
||||
let mut multipart_body: Vec<u8> = Vec::new();
|
||||
let boundary = gen_random(32);
|
||||
|
||||
for range in ranges {
|
||||
if let Some((start, end)) = parse_range_header(range, self.data.len()) {
|
||||
let sliced_data = &self.data[start..=end];
|
||||
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"Content-Range: bytes {}-{}/{}\r\n",
|
||||
start,
|
||||
end,
|
||||
self.data.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(
|
||||
format!("Content-Type: {}\r\n\r\n", self.content_type.clone())
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(sliced_data);
|
||||
body.extend_from_slice("\r\n".as_bytes());
|
||||
|
||||
multipart_body.extend_from_slice(&body);
|
||||
}
|
||||
}
|
||||
|
||||
multipart_body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
return Ok(Response::build()
|
||||
.header(Header::new("Accept-Ranges", "bytes"))
|
||||
.header(Header::new(
|
||||
"Content-Type",
|
||||
format!("multipart/byteranges; boundary={boundary}"),
|
||||
))
|
||||
.status(Status::PartialContent)
|
||||
.streamed_body(Cursor::new(multipart_body.to_vec()))
|
||||
.finalize());
|
||||
}
|
||||
}
|
||||
|
||||
// Add caching headers for static files
|
||||
let cache_control_header = if let Some(duration) = self.cache_duration {
|
||||
Header::new("Cache-Control", format!("public, max-age={}", duration))
|
||||
} else {
|
||||
Header::new("Cache-Control", "no-cache")
|
||||
};
|
||||
let cache_control_header = self.cache_duration.map_or_else(
|
||||
|| Header::new("Cache-Control", "no-cache"),
|
||||
|duration| Header::new("Cache-Control", format!("public, max-age={duration}")),
|
||||
);
|
||||
|
||||
Ok(Response::build()
|
||||
.header(cache_control_header)
|
||||
|
@ -69,10 +113,16 @@ fn parse_range_header(range: &str, total_len: usize) -> Option<(usize, usize)> {
|
|||
}
|
||||
|
||||
let range = &range[6..];
|
||||
|
||||
if range.starts_with('-') {
|
||||
let neg: usize = range.trim_start_matches('-').parse().ok()?;
|
||||
return Some((total_len - neg, total_len));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = range.split('-').collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
return Some((parts[0].parse().ok()?, total_len));
|
||||
}
|
||||
|
||||
let start = parts[0].parse::<usize>().ok();
|
||||
|
|
|
@ -4,6 +4,7 @@ use rocket::{
|
|||
};
|
||||
|
||||
/// Represents contextual information about an HTTP request.
|
||||
#[derive(Default)]
|
||||
pub struct RequestContext {
|
||||
/// A flag indicating if the request is an HTMX request.
|
||||
///
|
||||
|
@ -17,11 +18,7 @@ impl<'r> FromRequest<'r> for RequestContext {
|
|||
|
||||
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
rocket::outcome::Outcome::Success(RequestContext {
|
||||
is_htmx: !req
|
||||
.headers()
|
||||
.get("HX-Request")
|
||||
.collect::<Vec<&str>>()
|
||||
.is_empty(),
|
||||
is_htmx: req.headers().get("HX-Request").next().is_some(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ impl RespondRaw for StringResponse {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `StringResponse` with status `200 OK`, content type `application/json`, and the JSON-encoded body.
|
||||
#[must_use]
|
||||
pub fn respond_json(json: &serde_json::Value) -> StringResponse {
|
||||
(
|
||||
Status::Ok,
|
||||
|
@ -65,8 +66,9 @@ pub fn respond_json(json: &serde_json::Value) -> StringResponse {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `StringResponse` with status `200 OK`, content type `text/html`, and the HTML content as the body.
|
||||
pub fn respond_html(html: &str) -> StringResponse {
|
||||
(Status::Ok, (ContentType::HTML, html.to_string()))
|
||||
#[must_use]
|
||||
pub const fn respond_html(html: String) -> StringResponse {
|
||||
(Status::Ok, (ContentType::HTML, html))
|
||||
}
|
||||
|
||||
/// Helper function to create an JS HTTP response.
|
||||
|
@ -76,8 +78,9 @@ pub fn respond_html(html: &str) -> StringResponse {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `StringResponse` with status `200 OK`, content type `text/javascript`, and the JS content as the body.
|
||||
pub fn respond_script(script: &str) -> StringResponse {
|
||||
(Status::Ok, (ContentType::JavaScript, script.to_string()))
|
||||
#[must_use]
|
||||
pub const fn respond_script(script: String) -> StringResponse {
|
||||
(Status::Ok, (ContentType::JavaScript, script))
|
||||
}
|
||||
|
||||
/// Creates a custom HTTP response with the specified status, content type, and body.
|
||||
|
@ -89,6 +92,7 @@ pub fn respond_script(script: &str) -> StringResponse {
|
|||
///
|
||||
/// # Returns
|
||||
/// A `RawResponse` containing the provided status, content type, and body.
|
||||
pub fn respond_with(status: Status, content_type: ContentType, body: Vec<u8>) -> RawResponse {
|
||||
#[must_use]
|
||||
pub const fn respond_with(status: Status, content_type: ContentType, body: Vec<u8>) -> RawResponse {
|
||||
(status, (content_type, body))
|
||||
}
|
||||
|
|
|
@ -30,3 +30,26 @@ impl<T, E: std::fmt::Debug> LogAndIgnore for Result<T, E> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LogNoneAndPass {
|
||||
fn log_warn_none_and_pass(self, msg: impl Fn() -> String) -> Self;
|
||||
fn log_err_none_and_pass(self, msg: impl Fn() -> String) -> Self;
|
||||
}
|
||||
|
||||
impl<T> LogNoneAndPass for Option<T> {
|
||||
fn log_warn_none_and_pass(self, msg: impl Fn() -> String) -> Option<T> {
|
||||
if matches!(self, None) {
|
||||
log::warn!("{}", msg());
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
fn log_err_none_and_pass(self, msg: impl Fn() -> String) -> Option<T> {
|
||||
if matches!(self, None) {
|
||||
log::error!("{}", msg());
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue