Compare commits

...

2 commits

Author SHA1 Message Date
hkau be99332a20 [add] new markdown renderer
[chore] bump version (v0.8.2 -> v0.9.0)
2024-02-25 18:16:21 -05:00
hkau ddaf4a0eb9 [add] paste cache
[remove] booklist
[chore] bump version (v0.8.1 -> v0.8.2)
2024-02-25 15:33:52 -05:00
19 changed files with 708 additions and 739 deletions

View file

@ -3,7 +3,7 @@ name = "bundlrs"
authors = ["hkau"]
license = "MIT"
version = "0.8.1"
version = "0.9.0"
edition = "2021"
rust-version = "1.75"
@ -19,11 +19,14 @@ default = ["sqlite"]
[dependencies]
actix-files = "0.6.5"
actix-web = "4.4.1"
comrak = "0.21.0"
dotenv = "0.15.0"
either = "1.9.0"
env_logger = "0.11.2"
hex_fmt = "0.3.0"
idna = "0.5.0"
pulldown-cmark = "0.9.3"
pest = "2.7.7"
pest_derive = "2.7.7"
regex = "1.10.2"
serde = "1.0.195"
serde_json = "1.0.111"

View file

@ -1,3 +1,5 @@
use std::sync::Mutex;
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use crate::{db::bundlesdb::AppData, utility};
@ -13,7 +15,10 @@ struct LoginInfo {
}
#[post("/api/auth/register")]
pub async fn register(body: web::Json<RegisterInfo>, data: web::Data<AppData>) -> impl Responder {
pub async fn register(
body: web::Json<RegisterInfo>,
data: web::Data<Mutex<AppData>>,
) -> impl Responder {
// if server disabled registration, return
let disabled = crate::config::get_var("REGISTRATION_DISABLED");
@ -24,7 +29,12 @@ pub async fn register(body: web::Json<RegisterInfo>, data: web::Data<AppData>) -
// ...
let username = &body.username.trim();
let res = data.db.create_user(username.to_string()).await;
let res = data
.lock()
.unwrap()
.db
.create_user(username.to_string())
.await;
let c = res.clone();
let set_cookie = if res.success && res.payload.is_some() {
@ -41,11 +51,13 @@ pub async fn register(body: web::Json<RegisterInfo>, data: web::Data<AppData>) -
}
#[post("/api/auth/login")]
pub async fn login(body: web::Json<LoginInfo>, data: web::Data<AppData>) -> impl Responder {
pub async fn login(body: web::Json<LoginInfo>, data: web::Data<Mutex<AppData>>) -> impl Responder {
let id = body.uid.trim();
let id_hashed = utility::hash(id.to_string());
let res = data
.lock()
.unwrap()
.db
.get_user_by_hashed(id_hashed) // if the user is returned, that means the ID is valid
.await;
@ -65,7 +77,7 @@ pub async fn login(body: web::Json<LoginInfo>, data: web::Data<AppData>) -> impl
}
#[get("/api/auth/logout")]
pub async fn logout(req: HttpRequest, data: web::Data<AppData>) -> impl Responder {
pub async fn logout(req: HttpRequest, data: web::Data<Mutex<AppData>>) -> impl Responder {
let cookie = req.cookie("__Secure-Token");
if cookie.is_none() {
@ -73,6 +85,8 @@ pub async fn logout(req: HttpRequest, data: web::Data<AppData>) -> impl Responde
}
let res = data
.lock()
.unwrap()
.db
.get_user_by_hashed(cookie.unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await;

View file

@ -1,3 +1,5 @@
use std::sync::Mutex;
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use crate::db::bundlesdb::{self, AtomicPasteFSFile};
@ -49,7 +51,7 @@ struct MetadataInfo {
pub async fn render_request(body: web::Json<RenderInfo>) -> impl Responder {
return HttpResponse::Ok()
.append_header(("Content-Type", "text/html"))
.body(markdown::parse_markdown(&body.text));
.body(markdown::render::parse_markdown(&body.text));
}
#[post("/api/ssm")]
@ -62,10 +64,10 @@ pub async fn render_ssm_request(body: web::Json<RenderInfo>) -> impl Responder {
#[get("/api/ssm/{url:.*}")]
pub async fn render_paste_ssm_request(
req: HttpRequest,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let res = data.db.get_paste_by_url(custom_url).await;
let res = data.lock().unwrap().db.get_paste_by_url(custom_url).await;
if !res.success {
return HttpResponse::NotFound()
@ -98,7 +100,7 @@ pub async fn render_paste_ssm_request(
pub async fn create_request(
req: HttpRequest,
body: web::Json<CreateInfo>,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = body.custom_url.trim().to_string();
let edit_password: &String = &body.edit_password;
@ -115,7 +117,9 @@ pub async fn create_request(
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
data.lock()
.unwrap()
.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
@ -140,6 +144,8 @@ pub async fn create_request(
// create paste
let res = data
.lock()
.unwrap()
.db
.create_paste(
&mut bundlesdb::Paste {
@ -147,7 +153,7 @@ pub async fn create_request(
id: String::new(), // reassigned anyways, this doesn't matter
edit_password: edit_password.to_string(),
content: content.clone(),
content_html: crate::markdown::parse_markdown(&content), // go ahead and render the content
content_html: crate::markdown::render::parse_markdown(&content), // go ahead and render the content
pub_date: utility::unix_epoch_timestamp(),
edit_date: utility::unix_epoch_timestamp(),
group_name: g_name_for_real.to_string(),
@ -173,7 +179,7 @@ pub async fn create_request(
pub async fn edit_request(
req: HttpRequest,
body: web::Json<EditInfo>,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = body.custom_url.trim().to_string();
let content: String = body.content.trim().to_string();
@ -185,7 +191,9 @@ pub async fn edit_request(
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
data.lock()
.unwrap()
.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
@ -202,6 +210,8 @@ pub async fn edit_request(
// ...
let res = data
.lock()
.unwrap()
.db
.edit_paste_by_url(
custom_url,
@ -228,7 +238,7 @@ pub async fn edit_request(
pub async fn edit_atomic_request(
req: HttpRequest,
body: web::Json<EditAtomicInfo>,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
// this is essentially the same as edit_request but it handles the atomic JSON file system
// ...it does NOT accept an edit password! users must be authenticated
@ -240,7 +250,9 @@ pub async fn edit_atomic_request(
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
data.lock()
.unwrap()
.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
@ -256,8 +268,12 @@ pub async fn edit_atomic_request(
}
// get paste
let paste: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
let paste: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> = data
.lock()
.unwrap()
.db
.get_paste_by_url(custom_url.clone())
.await;
if paste.success == false {
return HttpResponse::Ok()
@ -298,6 +314,8 @@ pub async fn edit_atomic_request(
// ...
let res = data
.lock()
.unwrap()
.db
.edit_paste_by_url(
custom_url,
@ -324,7 +342,7 @@ pub async fn edit_atomic_request(
pub async fn delete_request(
req: HttpRequest,
body: web::Json<DeleteInfo>,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = body.custom_url.trim().to_string();
let edit_password: String = body.edit_password.to_owned();
@ -333,7 +351,9 @@ pub async fn delete_request(
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
data.lock()
.unwrap()
.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
@ -350,6 +370,8 @@ pub async fn delete_request(
// delete
let res = data
.lock()
.unwrap()
.db
.delete_paste_by_url(
custom_url,
@ -373,7 +395,7 @@ pub async fn delete_request(
pub async fn metadata_request(
req: HttpRequest,
body: web::Json<MetadataInfo>,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = body.custom_url.trim().to_string();
let edit_password: String = body.edit_password.to_owned();
@ -385,7 +407,9 @@ pub async fn metadata_request(
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
data.lock()
.unwrap()
.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
@ -402,6 +426,8 @@ pub async fn metadata_request(
// ...
let res = data
.lock()
.unwrap()
.db
.edit_paste_metadata_by_url(
custom_url,
@ -425,10 +451,10 @@ pub async fn metadata_request(
/// Check if a paste exists
pub async fn exists_request(
req: HttpRequest,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let res = data.db.get_paste_by_url(custom_url).await;
let res = data.lock().unwrap().db.get_paste_by_url(custom_url).await;
// return
return HttpResponse::Ok()
@ -440,11 +466,11 @@ pub async fn exists_request(
/// Get paste by `custom_url`
pub async fn get_from_url_request(
req: HttpRequest,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let res: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_url(custom_url).await;
data.lock().unwrap().db.get_paste_by_url(custom_url).await;
// if res.metadata contains '"private_source":"on"', return NotFound
if res.payload.is_some()
@ -487,11 +513,11 @@ pub async fn get_from_url_request(
/// Get paste by ID
pub async fn get_from_id_request(
req: HttpRequest,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let id: String = req.match_info().get("id").unwrap().to_string();
let res: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_id(id).await;
data.lock().unwrap().db.get_paste_by_id(id).await;
// if res.metadata contains '"private_source":"on"', return NotFound
if res.payload.is_some()
@ -534,11 +560,11 @@ pub async fn get_from_id_request(
/// Get all pastes by owner
pub async fn get_from_owner_request(
req: HttpRequest,
data: web::Data<bundlesdb::AppData>,
data: web::Data<Mutex<bundlesdb::AppData>>,
) -> impl Responder {
let username: String = req.match_info().get("username").unwrap().to_string();
let res: bundlesdb::DefaultReturn<Option<Vec<bundlesdb::PasteIdentifier>>> =
data.db.get_pastes_by_owner(username).await;
data.lock().unwrap().db.get_pastes_by_owner(username).await;
// return
return HttpResponse::Ok()

View file

@ -1,68 +0,0 @@
//! The "booklist" is a file stored in the cwd which contains a list of blocked URLs.
//!
//! These URLs can be claimed, but will always return the same dummy record when fetched from the database.
//!
//! # Example:
//!
//! ```
//! ;
//! url1;url2;url3
//! ```
//!
//! # Example:
//!
//! ```
//! ,
//! url1, url2, url3
//! ```
//!
//! The first line of a booklist defines the separator (\n works)
use std::fs;
#[allow(dead_code)]
#[derive(Debug)]
struct BookList {
separator: String,
full: String,
}
#[allow(dead_code)]
fn fetch_booklist() -> BookList {
let content = fs::read_to_string(format!("booklist.txt"));
if content.is_err() {
return BookList {
separator: String::new(),
full: String::new(),
};
}
// get sep
let content = content.unwrap();
let sep = &content.lines().next();
// return
return BookList {
separator: if sep.is_some() {
sep.unwrap().to_string()
} else {
String::from("\n")
},
full: content,
};
}
pub fn check_booklist(looking_for: &String) -> bool {
// load booklist
let list = fetch_booklist();
// check for word
let split: Vec<String> = list
.full
.split(&list.separator)
.map(|s| s.to_string())
.collect::<Vec<String>>();
return split.contains(looking_for) | split.contains(&format!("\n{}", looking_for));
}

View file

@ -1,7 +1,10 @@
//! # BundlesDB
//! Database handler for all database types
use super::sql::{self, Database, DatabaseOpts};
use super::{
cache::CacheStore,
sql::{self, Database, DatabaseOpts},
};
use sqlx::{Column, Row};
use crate::utility;
@ -139,12 +142,14 @@ pub struct BundlesDB {
#[cfg(feature = "sqlite")]
pub struct BundlesDB {
pub db: Database<sqlx::SqlitePool>,
pub paste_cache: CacheStore<Paste<String>>,
}
impl BundlesDB {
pub async fn new(options: DatabaseOpts) -> BundlesDB {
return BundlesDB {
db: sql::create_db(options).await,
paste_cache: CacheStore::new(),
};
}
@ -206,7 +211,7 @@ impl BundlesDB {
}
#[cfg(feature = "sqlite")]
fn textify_row(&self, row: sqlx::sqlite::SqliteRow) -> DatabaseReturn {
fn textify_row(&mut self, row: sqlx::sqlite::SqliteRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
@ -223,7 +228,7 @@ impl BundlesDB {
}
#[cfg(feature = "postgres")]
fn textify_row(&self, row: sqlx::postgres::PgRow) -> DatabaseReturn {
fn textify_row(&mut self, row: sqlx::postgres::PgRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
@ -240,7 +245,7 @@ impl BundlesDB {
}
#[cfg(feature = "mysql")]
fn textify_row(&self, row: sqlx::mysql::MySqlRow) -> DatabaseReturn {
fn textify_row(&mut self, row: sqlx::mysql::MySqlRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
@ -277,7 +282,7 @@ impl BundlesDB {
///
/// # Arguments:
/// * `hashed` - `String` of the user's hashed ID
pub async fn get_user_by_hashed(&self, hashed: String) -> DefaultReturn<Option<UserState>> {
pub async fn get_user_by_hashed(&mut self, hashed: String) -> DefaultReturn<Option<UserState>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Users\" WHERE \"id_hashed\" = ?"
} else {
@ -319,7 +324,10 @@ impl BundlesDB {
///
/// # Arguments:
/// * `username` - `String` of the user's username
pub async fn get_user_by_username(&self, username: String) -> DefaultReturn<Option<UserState>> {
pub async fn get_user_by_username(
&mut self,
username: String,
) -> DefaultReturn<Option<UserState>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Users\" WHERE \"username\" = ?"
} else {
@ -362,7 +370,7 @@ impl BundlesDB {
///
/// # Arguments:
/// * `username` - `String` of the user's `username`
pub async fn create_user(&self, username: String) -> DefaultReturn<Option<String>> {
pub async fn create_user(&mut self, username: String) -> DefaultReturn<Option<String>> {
// make sure user doesn't already exists
let existing = &self.get_user_by_username(username.clone()).await;
if existing.success {
@ -438,7 +446,7 @@ impl BundlesDB {
///
/// # Arguments:
/// * `id` - `String` of the log's `id`
pub async fn get_log_by_id(&self, id: String) -> DefaultReturn<Option<Log>> {
pub async fn get_log_by_id(&mut self, id: String) -> DefaultReturn<Option<Log>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Logs\" WHERE \"id\" = ?"
} else {
@ -480,7 +488,7 @@ impl BundlesDB {
/// * `logtype` - `String` of the log's `logtype`
/// * `content` - `String` of the log's `content`
pub async fn create_log(
&self,
&mut self,
logtype: String,
content: String,
) -> DefaultReturn<Option<String>> {
@ -522,7 +530,7 @@ impl BundlesDB {
/// # Arguments:
/// * `id` - `String` of the log's `id`
/// * `content` - `String` of the log's new content
pub async fn edit_log(&self, id: String, content: String) -> DefaultReturn<Option<String>> {
pub async fn edit_log(&mut self, id: String, content: String) -> DefaultReturn<Option<String>> {
// make sure log exists
let existing = &self.get_log_by_id(id.clone()).await;
if !existing.success {
@ -567,7 +575,7 @@ impl BundlesDB {
///
/// # Arguments:
/// * `id` - `String` of the log's `id`
pub async fn delete_log(&self, id: String) -> DefaultReturn<Option<String>> {
pub async fn delete_log(&mut self, id: String) -> DefaultReturn<Option<String>> {
// make sure log exists
let existing = &self.get_log_by_id(id.clone()).await;
if !existing.success {
@ -606,46 +614,65 @@ impl BundlesDB {
// pastes
/// Count the `view_paste` logs for a specific [`Paste`]
async fn count_paste_views(&mut self, custom_url: String) -> usize {
let c = &self.db.client;
// count views
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Logs\" WHERE \"logtype\" = 'view_paste' AND \"content\" LIKE ?"
} else {
"SELECT * FROM \"Logs\" WHERE \"logtype\" = 'view_paste' AND \"content\" LIKE $1"
};
let views_res = sqlx::query(query)
.bind::<&String>(&format!("{}::%", &custom_url))
.fetch_all(c)
.await;
if views_res.is_err() {
return 0;
}
return views_res.unwrap().len();
}
/// Build a [`Paste`] query with information about it
async fn build_result_from_query(
&self,
&mut self,
query: &str,
selector: &str,
allow_cache: bool,
) -> DefaultReturn<Option<Paste<String>>> {
// check if we're fetching a booklist url
let is_banned = crate::booklist::check_booklist(&selector.to_lowercase());
// ...
let exists_in_cache = &self.paste_cache.load(selector).is_some();
if is_banned == true {
if (exists_in_cache == &true) && allow_cache {
// get views
// if allow_cache is true, `selector` should ALWAYS be the custom_url since the cache stores by that, not ID
let views = &self.count_paste_views(selector.to_owned()).await;
let paste = &self.paste_cache.load(selector).unwrap();
// return
return DefaultReturn {
success: true,
message: String::from("Paste exists (booklist)"),
message: String::from("Paste exists"),
payload: Option::Some(Paste {
custom_url: selector.to_string(),
id: String::new(),
group_name: String::new(),
edit_password: String::new(),
pub_date: 0,
edit_date: 0,
content: String::new(),
content_html: String::from(
"This custom URL has been blocked by the server booklist.txt file. This is an automatically generated body content.",
),
metadata: serde_json::to_string::<PasteMetadata>(&PasteMetadata {
owner: String::from(""),
private_source: String::from("on"),
title: Option::Some(String::new()),
description: Option::Some(String::new()),
favicon: Option::None,
embed_color: Option::None,
view_password: Option::None,
})
.unwrap(),
views: 0,
custom_url: paste.custom_url.to_string(),
id: paste.id.to_string(),
group_name: paste.group_name.to_string(),
edit_password: paste.edit_password.to_string(),
pub_date: paste.pub_date,
edit_date: paste.edit_date,
content: paste.content.to_string(),
content_html: paste.content_html.to_string(),
metadata: paste.metadata.to_string(),
views: views.to_owned(),
}),
};
}
// ...
// fetch from db
let c = &self.db.client;
let res = sqlx::query(query)
.bind::<&String>(&selector.to_lowercase())
@ -664,42 +691,35 @@ impl BundlesDB {
let row = res.unwrap();
let row = self.textify_row(row).data;
// count views
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Logs\" WHERE \"logtype\" = 'view_paste' AND \"content\" LIKE ?"
} else {
"SELECT * FROM \"Logs\" WHERE \"logtype\" = 'view_paste' AND \"content\" LIKE $1"
};
let views_res = sqlx::query(query)
.bind::<&String>(&format!("{}::%", &row.get("custom_url").unwrap()))
.fetch_all(c)
// get views
let views = &self
.count_paste_views(row.get("custom_url").unwrap().to_owned())
.await;
if views_res.is_err() {
return DefaultReturn {
success: false,
message: String::from(views_res.err().unwrap().to_string()),
payload: Option::None,
};
// add to cache
let paste = Paste {
custom_url: row.get("custom_url").unwrap().to_string(),
id: row.get("id").unwrap().to_string(),
group_name: row.get("group_name").unwrap().to_string(),
edit_password: row.get("edit_password").unwrap().to_string(),
pub_date: row.get("pub_date").unwrap().parse::<u128>().unwrap(),
edit_date: row.get("edit_date").unwrap().parse::<u128>().unwrap(),
content: row.get("content").unwrap().to_string(),
content_html: row.get("content_html").unwrap().to_string(),
metadata: row.get("metadata").unwrap().to_string(),
views: views.to_owned(),
};
if allow_cache {
self.paste_cache
.store(row.get("custom_url").unwrap().to_string(), paste.clone());
}
// return
return DefaultReturn {
success: true,
message: String::from("Paste exists"),
payload: Option::Some(Paste {
custom_url: row.get("custom_url").unwrap().to_string(),
id: row.get("id").unwrap().to_string(),
group_name: row.get("group_name").unwrap().to_string(),
edit_password: row.get("edit_password").unwrap().to_string(),
pub_date: row.get("pub_date").unwrap().parse::<u128>().unwrap(),
edit_date: row.get("edit_date").unwrap().parse::<u128>().unwrap(),
content: row.get("content").unwrap().to_string(),
content_html: row.get("content_html").unwrap().to_string(),
metadata: row.get("metadata").unwrap().to_string(),
views: views_res.unwrap().len(),
}),
payload: Option::Some(paste),
};
}
@ -708,28 +728,28 @@ impl BundlesDB {
///
/// # Arguments:
/// * `url` - `String` of the paste's `custom_url`
pub async fn get_paste_by_url(&self, url: String) -> DefaultReturn<Option<Paste<String>>> {
pub async fn get_paste_by_url(&mut self, url: String) -> DefaultReturn<Option<Paste<String>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Pastes\" WHERE \"custom_url\" = ?"
} else {
"SELECT * FROM \"Pastes\" WHERE \"custom_url\" = $1"
};
return self.build_result_from_query(query, &url).await;
return self.build_result_from_query(query, &url, true).await;
}
/// Get a [`Paste`] given its `id`
///
/// # Arguments:
/// * `id` - `String` of the paste's `id`
pub async fn get_paste_by_id(&self, id: String) -> DefaultReturn<Option<Paste<String>>> {
pub async fn get_paste_by_id(&mut self, id: String) -> DefaultReturn<Option<Paste<String>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Pastes\" WHERE \"id\" = ?"
} else {
"SELECT * FROM \"Pastes\" WHERE \"id\" = $1"
};
return self.build_result_from_query(query, &id).await;
return self.build_result_from_query(query, &id, false).await;
}
/// Get all [pastes](Paste) owned by a specific user
@ -737,7 +757,7 @@ impl BundlesDB {
/// # Arguments:
/// * `owner` - `String` of the owner's `username`
pub async fn get_pastes_by_owner(
&self,
&mut self,
owner: String,
) -> DefaultReturn<Option<Vec<PasteIdentifier>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
@ -784,7 +804,7 @@ impl BundlesDB {
/// # Arguments:
/// * `owner` - `String` of the owner's `username`
pub async fn get_atomic_pastes_by_owner(
&self,
&mut self,
owner: String,
) -> DefaultReturn<Option<Vec<PasteIdentifier>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
@ -834,7 +854,7 @@ impl BundlesDB {
/// * `props` - [`Paste<String>`](Paste)
/// * `as_user` - The ID of the user creating the paste
pub async fn create_paste(
&self,
&mut self,
props: &mut Paste<String>,
as_user: Option<String>, // id of paste owner
) -> DefaultReturn<Option<Paste<String>>> {
@ -1010,7 +1030,7 @@ impl BundlesDB {
/// Edit an existing [`Paste`] given its `custom_url`
pub async fn edit_paste_by_url(
&self,
&mut self,
url: String,
content: String,
edit_password: String,
@ -1085,7 +1105,7 @@ impl BundlesDB {
let c = &self.db.client;
let res = sqlx::query(query)
.bind::<&String>(&content)
.bind::<&String>(&crate::markdown::parse_markdown(&content))
.bind::<&String>(&crate::markdown::render::parse_markdown(&content))
.bind::<&String>(&edit_password_hash)
.bind::<&String>(&custom_url)
.bind::<&String>(&utility::unix_epoch_timestamp().to_string()) // update edit_date
@ -1101,6 +1121,10 @@ impl BundlesDB {
};
}
// we're not even going to update the cache, just purge the paste from the cache
// this also means we don't have to handle any decisions on if the paste custom_url changed or not
self.paste_cache.clear(&custom_url);
// return
return DefaultReturn {
success: true,
@ -1111,7 +1135,7 @@ impl BundlesDB {
/// Update a [`Paste`]'s metadata by its `custom_url`
pub async fn edit_paste_metadata_by_url(
&self,
&mut self,
url: String,
metadata: PasteMetadata,
edit_password: String,
@ -1184,6 +1208,10 @@ impl BundlesDB {
};
}
// we're not even going to update the cache, just purge the paste from the cache
// this also means we don't have to handle any decisions on if the paste custom_url changed or not
self.paste_cache.clear(&url);
// return
return DefaultReturn {
success: true,
@ -1197,7 +1225,7 @@ impl BundlesDB {
/// # Arguments:
/// * `view_as` - The username of the account that viewed the paste
pub async fn add_view_to_url(
&self,
&mut self,
url: &String,
view_as: &String, // username of account that is viewing this paste
) -> DefaultReturn<Option<String>> {
@ -1262,7 +1290,7 @@ impl BundlesDB {
/// Delete a [`Paste`] given its `custom_url` and `edit_password`
pub async fn delete_paste_by_url(
&self,
&mut self,
url: String,
edit_password: String,
delete_as: Option<String>,
@ -1350,6 +1378,9 @@ impl BundlesDB {
};
}
// remove from cache
self.paste_cache.clear(&url);
// return
return DefaultReturn {
success: true,
@ -1365,7 +1396,7 @@ impl BundlesDB {
///
/// # Arguments:
/// * `url` - group name
pub async fn get_group_by_name(&self, url: String) -> DefaultReturn<Option<Group<String>>> {
pub async fn get_group_by_name(&mut self, url: String) -> DefaultReturn<Option<Group<String>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Groups\" WHERE \"name\" = ?"
} else {
@ -1403,7 +1434,10 @@ impl BundlesDB {
///
/// # Arguments:
/// * `props` - [`Group<GroupMetadata>`](Group)
pub async fn create_group(&self, props: Group<GroupMetadata>) -> DefaultReturn<Option<String>> {
pub async fn create_group(
&mut self,
props: Group<GroupMetadata>,
) -> DefaultReturn<Option<String>> {
let p: &Group<GroupMetadata> = &props; // borrowed props
// make sure group does not exist

26
src/db/cache.rs Normal file
View file

@ -0,0 +1,26 @@
use std::collections::HashMap;
#[derive(Debug, Default, Clone)]
pub struct CacheStore<T> {
pub objects: HashMap<String, T>,
}
impl<T> CacheStore<T> {
pub fn new() -> CacheStore<T> {
CacheStore {
objects: HashMap::new(),
}
}
pub fn store(&mut self, key: String, value: T) -> Option<T> {
self.objects.insert(key, value)
}
pub fn load(&mut self, key: &str) -> Option<&T> {
self.objects.get(key)
}
pub fn clear(&mut self, key: &str) -> () {
self.objects.remove(key);
}
}

View file

@ -1,3 +1,4 @@
//! Database handling
pub mod bundlesdb;
pub mod cache;
pub mod sql;

View file

@ -5,7 +5,6 @@
#![doc(issue_tracker_base_url = "https://code.stellular.org/SentryTwo/bundlrs/issues/")]
pub mod api;
pub mod booklist;
mod components;
mod config;
pub mod db;

View file

@ -1,3 +1,5 @@
use std::sync::Mutex;
use actix_files as fs;
use actix_web::{web, App, HttpResponse, HttpServer};
use dotenv;
@ -16,8 +18,6 @@ mod pages;
mod markdown;
mod ssm;
mod booklist;
use crate::db::bundlesdb::{AppData, BundlesDB};
use crate::db::sql::DatabaseOpts;
@ -25,6 +25,9 @@ use crate::db::sql::DatabaseOpts;
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
// std::env::set_var("RUST_LOG", "debug");
env_logger::init();
// ...
let args: Vec<String> = config::collect_arguments();
@ -79,10 +82,13 @@ async fn main() -> std::io::Result<()> {
// start server
println!("Starting server at: http://localhost:{port}");
// create data
let data = web::Data::new(Mutex::new(AppData { db: db.clone() }));
// serve routes
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(AppData { db: db.clone() }))
.app_data(web::Data::clone(&data))
// middleware
.wrap(actix_web::middleware::Logger::default())
// static dir

View file

@ -1,530 +0,0 @@
use crate::ssm;
use regex::RegexBuilder;
#[allow(dead_code)]
struct Heading<'l> {
pub text: &'l str,
pub level: usize,
pub id: String,
}
/// Parse raw Markdown input into HTML
///
/// # Arguments:
/// * `input` - `String` containing the Markdown input to be parsed
pub fn parse_markdown(input: &String) -> String {
let mut out: String = input.to_owned();
// escape < and >
out = regex_replace(&out, "<", "&lt;");
out = regex_replace(&out, ">", "&gt;");
// unescape arrow alignment
out = regex_replace(&out, "-&gt;&gt;", "->>");
out = regex_replace(&out, "&lt;&lt;-", "<<-");
out = regex_replace(&out, "-&gt;", "->");
out = regex_replace(&out, "&lt;-", "<-");
// allowed elements
let allowed_elements: Vec<&str> = Vec::from([
"hue", "sat", "lit", "theme", "comment", "p", "span", "style",
]);
for element in allowed_elements {
out = regex_replace(
&out,
&format!("&lt;{}&gt;", element),
&format!("<{}>", element),
);
out = regex_replace(
&out,
&format!("&lt;/{}&gt;", element),
&format!("</{}>", element),
);
}
// HTML escapes
out = regex_replace(&out, "(&!)(.*?);", "&$2;");
// backslash escapes
out = out.replace(r"\*", "&ast;");
// backslash line continuation
out = out.replace("\\\n", "");
// fenced code blocks
let mut fenced_code_block_count: i32 = 0;
let fenced_code_block_regex = RegexBuilder::new("^(`{3})(.*?)\\n(.*?)(`{3})$")
.multi_line(true)
.dot_matches_new_line(true)
.build()
.unwrap();
for capture in fenced_code_block_regex.captures_iter(&out.clone()) {
let lang = capture.get(2).unwrap().as_str();
let mut content = capture.get(3).unwrap().as_str().to_string();
fenced_code_block_count += 1;
// run replacements
content = content.replace("*", "&!temp-ast;");
content = content.replace("`", "&!temp-back;");
content = content.replace("\\n", "&nbsp;1;\\n");
content = content.replace("#", "&#35;");
content = content.replace("(", "&lpar;");
// build line numbers
let mut line_numbers: String = String::new();
let mut _current_ln: i32 = 0;
for line in content.split("\n") {
if line.is_empty() {
continue;
};
_current_ln += 1;
line_numbers = format!(
"{}<a class=\"line-number\" href=\"#B{}L{}\" id=\"B{}L{}\">{}</a>\n",
line_numbers,
fenced_code_block_count,
_current_ln,
fenced_code_block_count,
_current_ln,
_current_ln
);
}
// replace
out = out.replace( capture.get(0).unwrap().as_str(), &format!("<pre class=\"flex\" style=\"position: relative;\">
<div class=\"line-numbers code\">{line_numbers}</div>
<code class=\"language-${lang}\" id=\"B{fenced_code_block_count}C\" style=\"display: block;\">{content}</code>
<button
onclick=\"window.navigator.clipboard.writeText(document.getElementById('B{fenced_code_block_count}C').innerText);\"
class=\"secondary copy-button\"
title=\"Copy Code\"
>
<svg
xmlns=\"http://www.w3.org/2000/svg\"
width=\"18\"
height=\"18\"
viewBox=\"0 0 24 24\"
fill=\"none\"
stroke=\"currentColor\"
stroke-width=\"2\"
stroke-linecap=\"round\"
stroke-linejoin=\"round\"
class=\"lucide lucide-clipboard-copy\"
>
<rect
width=\"8\"
height=\"4\"
x=\"8\"
y=\"2\"
rx=\"1\"
ry=\"1\"
/>
<path d=\"M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2\" />
<path d=\"M16 4h2a2 2 0 0 1 2 2v4\" />
<path d=\"M21 14H11\" />
<path d=\"m15 10-4 4 4 4\" />
</svg>
</button>
</pre>"));
}
// inline code block
out = regex_replace(&out, "(`{1})(.*?)(`{1})", "<code>$2</code>");
// headings
let table_of_contents: &mut Vec<Heading> = &mut Vec::new();
let heading_regex = RegexBuilder::new("^(\\#+)\\s(.*?)$")
.multi_line(true)
.build()
.unwrap();
for capture in heading_regex.captures_iter(&out.clone()) {
let heading_type = capture.get(1).unwrap().as_str().len();
let content = capture.get(2).unwrap().as_str();
// get suffix
// (get all headings with the same text, suffix is the number of those)
// (helps prevent duplicate ids)
let same_headings = table_of_contents.iter().filter(|h| h.text == content);
let count = same_headings.count() as i32;
let suffix = if &count == &0 {
"".to_string()
} else {
format!("-{}", count)
};
// add to TOC
let heading_id = regex_replace(
&format!("{content}{suffix}").to_lowercase(),
"[^A-Za-z0-9-]",
"",
);
table_of_contents.push(Heading {
text: content,
level: heading_type,
id: heading_id.clone(),
});
// return
out = out.replace(
capture.get(0).unwrap().as_str(),
format!("<h{heading_type} id=\"{heading_id}\">{content}</h{heading_type}>\n").as_str(),
)
}
// remove frontmatter
regex_replace_exp(
&out,
RegexBuilder::new("^(\\-{3})F\\n(?<CONTENT>.*?)\\n(\\-{3})F$")
.multi_line(true)
.dot_matches_new_line(true),
"",
);
// horizontal rule
out = regex_replace(&out, "^\\*{3,}", "\n<hr />\n");
out = regex_replace(&out, "^\\-{3,}", "\n<hr />\n");
out = regex_replace(&out, "^\\_{3,}", "\n<hr />\n");
// special custom element syntax (rs)
let custom_element_regex = RegexBuilder::new("(e\\#)(?<NAME>.*?)\\s(?<ATRS>.*?)\\#")
.multi_line(true)
.build()
.unwrap();
for capture in custom_element_regex.captures_iter(&out.clone()) {
let name = capture.name("NAME").unwrap().as_str();
let atrs = capture.name("ATRS").unwrap().as_str().replace("$", "#");
let mut atrs_split: Vec<String> = atrs.split("+").map(|s| s.to_string()).collect();
// make sure everything exists (before we try to call .unwrap on them!)
if atrs_split.get(0).is_none() {
atrs_split.insert(0, String::new())
}
if atrs_split.get(1).is_none() {
atrs_split.insert(1, String::new())
}
if atrs_split.get(2).is_none() {
atrs_split.insert(2, String::new())
}
if atrs_split.get(3).is_none() {
atrs_split.insert(3, String::new())
}
if atrs_split.get(4).is_none() {
atrs_split.insert(4, String::new())
}
// possibilities
let possible_error_block =
&"\n!!! error parsing error: invalid element class in element block".to_string();
let possible_theme_block = &format!("<theme>{}</theme>", atrs_split.get(0).unwrap());
let possible_hsl_block = &format!(
"<{}>{}</{}>",
atrs_split.get(0).unwrap(),
atrs_split.get(1).unwrap(),
atrs_split.get(0).unwrap()
);
let possible_html_block = &format!("<{}>", atrs_split.get(0).unwrap());
let possible_chtml_block = &format!("</{}>", atrs_split.get(0).unwrap());
let possible_class_block = &format!("<span class=\"{}\">", atrs.replace("+", " "));
let possible_id_block = &format!("<span id=\"{}\">", atrs_split.get(0).unwrap());
let possible_close_block = &format!("</span>");
let possible_animation_block = &format!(
"<span role=\"animation\" style=\"
animation:{} {} ease-in-out {} forwards running;
display: block;\"
>{}",
// name
atrs_split.get(0).unwrap(),
// duration