add: change database to postgres
This commit is contained in:
parent
24d9f17bd4
commit
187508b8f3
15 changed files with 647 additions and 471 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
||||||
/target
|
/target
|
||||||
|
app/fluffle.toml
|
||||||
|
migration.js
|
||||||
|
|
3
Cargo.lock
generated
3
Cargo.lock
generated
|
@ -607,13 +607,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fluffle"
|
name = "fluffle"
|
||||||
version = "0.4.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"glob",
|
"glob",
|
||||||
"nanoneo",
|
"nanoneo",
|
||||||
|
"oiseau",
|
||||||
"pathbufd",
|
"pathbufd",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "fluffle"
|
name = "fluffle"
|
||||||
version = "0.4.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["trisuaso"]
|
authors = ["trisuaso"]
|
||||||
repository = "https://trisua.com/t/fluffle"
|
repository = "https://trisua.com/t/fluffle"
|
||||||
|
@ -31,3 +31,4 @@ serde_json = "1.0.142"
|
||||||
toml = "0.9.4"
|
toml = "0.9.4"
|
||||||
serde_valid = { version = "1.0.5", features = ["toml"] }
|
serde_valid = { version = "1.0.5", features = ["toml"] }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] }
|
||||||
|
|
|
@ -283,6 +283,14 @@ video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown .inner.left {
|
||||||
|
right: unset;
|
||||||
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown .inner.open {
|
.dropdown .inner.open {
|
||||||
|
@ -823,3 +831,23 @@ dialog::backdrop {
|
||||||
dialog:is(.dark *)::backdrop {
|
dialog:is(.dark *)::backdrop {
|
||||||
background: hsla(0, 0%, 100%, 15%);
|
background: hsla(0, 0%, 100%, 15%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* menus */
|
||||||
|
menu {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu .button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu .button.active {
|
||||||
|
background: var(--color-super-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.col {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
|
@ -143,7 +143,7 @@
|
||||||
const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
|
const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
|
||||||
load();
|
load();
|
||||||
|
|
||||||
fetch(\"/api/v1/entries/{{ entry.slug }}\", {
|
fetch(\"/api/v1/entries/{{ entry.id }}\", {
|
||||||
method: \"POST\",
|
method: \"POST\",
|
||||||
headers: {
|
headers: {
|
||||||
\"Content-Type\": \"application/json\",
|
\"Content-Type\": \"application/json\",
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
("class" "button camo fade")
|
("class" "button camo fade")
|
||||||
(text "{{ icon \"menu\" }}"))
|
(text "{{ icon \"menu\" }}"))
|
||||||
(div
|
(div
|
||||||
("class" "inner")
|
("class" "inner left")
|
||||||
(a
|
(a
|
||||||
("class" "button")
|
("class" "button")
|
||||||
("href" "/")
|
("href" "/")
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
|
|
||||||
; views
|
; views
|
||||||
(text "{% if not metadata.option_disable_views -%}")
|
(text "{% if not metadata.option_disable_views -%}")
|
||||||
(span (text "Views: {{ views }}"))
|
(span (text "Views: {{ entry.views }}"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
|
||||||
; easy-to-read
|
; easy-to-read
|
||||||
|
|
106
src/config.rs
Normal file
106
src/config.rs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
use oiseau::config::{Configuration, DatabaseConfig};
|
||||||
|
use pathbufd::PathBufD;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tetratto_shared::hash::random_id;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// The name of the site. Shown in the UI.
|
||||||
|
#[serde(default = "default_name")]
|
||||||
|
pub name: String,
|
||||||
|
/// The (CSS) theme color of the site. Shown in the UI.
|
||||||
|
#[serde(default = "default_theme_color")]
|
||||||
|
pub theme_color: String,
|
||||||
|
/// The URL of the Tetratto host associated with this instance.
|
||||||
|
#[serde(default = "default_tetratto")]
|
||||||
|
pub tetratto: String,
|
||||||
|
/// The slug of the instance's information page.
|
||||||
|
///
|
||||||
|
/// Should be the pathname WITHOUT the leading slash.
|
||||||
|
#[serde(default = "default_what_page_slug")]
|
||||||
|
pub what_page_slug: String,
|
||||||
|
/// The username of the handler account in charge of this instance on the
|
||||||
|
/// linked Tetratto host.
|
||||||
|
#[serde(default = "default_tetratto_handler_account_username")]
|
||||||
|
pub tetratto_handler_account_username: String,
|
||||||
|
/// Database configuration.
|
||||||
|
#[serde(default = "default_database")]
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
/// Real IP header (for reverse proxy).
|
||||||
|
#[serde(default = "default_real_ip_header")]
|
||||||
|
pub real_ip_header: String,
|
||||||
|
/// The master password which is allowed to do anything without password checks.
|
||||||
|
pub master_pass: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_name() -> String {
|
||||||
|
"Fluffle".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_theme_color() -> String {
|
||||||
|
"#a3b3ff".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tetratto() -> String {
|
||||||
|
"https://tetratto.com".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_what_page_slug() -> String {
|
||||||
|
"what".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tetratto_handler_account_username() -> String {
|
||||||
|
"fluffle".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_database() -> DatabaseConfig {
|
||||||
|
DatabaseConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_real_ip_header() -> String {
|
||||||
|
"CF-Connecting-IP".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Configuration for Config {
|
||||||
|
fn db_config(&self) -> DatabaseConfig {
|
||||||
|
self.database.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: default_name(),
|
||||||
|
theme_color: default_theme_color(),
|
||||||
|
tetratto: default_tetratto(),
|
||||||
|
what_page_slug: default_what_page_slug(),
|
||||||
|
tetratto_handler_account_username: default_tetratto_handler_account_username(),
|
||||||
|
database: default_database(),
|
||||||
|
real_ip_header: default_real_ip_header(),
|
||||||
|
master_pass: random_id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Read the configuration file.
|
||||||
|
pub fn read() -> Self {
|
||||||
|
toml::from_str(
|
||||||
|
&match std::fs::read_to_string(PathBufD::current().join("fluffle.toml")) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(_) => {
|
||||||
|
let x = Config::default();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
PathBufD::current().join("fluffle.toml"),
|
||||||
|
&toml::to_string_pretty(&x).expect("failed to serialize config"),
|
||||||
|
)
|
||||||
|
.expect("failed to write config");
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("failed to deserialize config")
|
||||||
|
}
|
||||||
|
}
|
316
src/database/entries.rs
Normal file
316
src/database/entries.rs
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
use super::{DataManager, NAME_REGEX};
|
||||||
|
use crate::model::{Entry, EntryMetadata};
|
||||||
|
use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row};
|
||||||
|
use serde_valid::Validate;
|
||||||
|
use tetratto_core::{
|
||||||
|
auto_method,
|
||||||
|
model::{Error, Result},
|
||||||
|
};
|
||||||
|
use tetratto_shared::{hash::hash, unix_epoch_timestamp};
|
||||||
|
|
||||||
|
impl DataManager {
|
||||||
|
/// Get an [`Entry`] from an SQL row.
|
||||||
|
pub(crate) fn get_entry_from_row(x: &PostgresRow) -> Entry {
|
||||||
|
Entry {
|
||||||
|
id: get!(x->0(i64)) as usize,
|
||||||
|
slug: get!(x->1(String)),
|
||||||
|
edit_code: get!(x->2(String)),
|
||||||
|
salt: get!(x->3(String)),
|
||||||
|
created: get!(x->4(i64)) as usize,
|
||||||
|
edited: get!(x->5(i64)) as usize,
|
||||||
|
content: get!(x->6(String)),
|
||||||
|
metadata: get!(x->7(String)),
|
||||||
|
last_edit_from: get!(x->8(String)),
|
||||||
|
modify_code: get!(x->9(String)),
|
||||||
|
views: get!(x->10(i64)) as usize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_method!(get_entry_by_id(usize as i64)@get_entry_from_row -> "SELECT * FROM entries WHERE id = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
|
||||||
|
auto_method!(get_entry_by_slug(&str)@get_entry_from_row -> "SELECT * FROM entries WHERE slug = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
|
||||||
|
|
||||||
|
fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
|
||||||
|
// hash passwords
|
||||||
|
let do_update_metadata = (!metadata.option_view_password.is_empty()
|
||||||
|
|| !metadata.option_source_password.is_empty())
|
||||||
|
&& (!metadata.option_view_password.starts_with("h:")
|
||||||
|
|| !metadata.option_source_password.starts_with("h:"));
|
||||||
|
|
||||||
|
if !metadata.option_view_password.is_empty()
|
||||||
|
&& !metadata.option_view_password.starts_with("h:")
|
||||||
|
{
|
||||||
|
metadata.option_view_password =
|
||||||
|
format!("h:{}", hash(metadata.option_view_password.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !metadata.option_source_password.is_empty()
|
||||||
|
&& !metadata.option_source_password.starts_with("h:")
|
||||||
|
{
|
||||||
|
metadata.option_source_password =
|
||||||
|
format!("h:{}", hash(metadata.option_source_password.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if do_update_metadata {
|
||||||
|
if let Ok(x) = toml::to_string_pretty(&metadata) {
|
||||||
|
return (true, x);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(false, String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new entry in the database.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `data` - a mock [`Entry`] object to insert
|
||||||
|
pub async fn create_entry(&self, mut data: Entry) -> Result<Entry> {
|
||||||
|
// check values
|
||||||
|
if data.slug.trim().len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("slug".to_string()));
|
||||||
|
} else if data.slug.len() > 128 {
|
||||||
|
return Err(Error::DataTooLong("slug".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.content.len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.content.len() > 150_000 {
|
||||||
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check characters
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&data.slug).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This slug contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for existing
|
||||||
|
if self.get_entry_by_slug(&data.slug).await.is_ok() {
|
||||||
|
return Err(Error::MiscError("Slug is already in use".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check metadata
|
||||||
|
let mut metadata: EntryMetadata =
|
||||||
|
match toml::from_str(&EntryMetadata::ini_to_toml(&data.metadata)) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => return Err(Error::MiscError(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = metadata.validate() {
|
||||||
|
return Err(Error::MiscError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
|
||||||
|
if do_update_metadata {
|
||||||
|
data.metadata = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"INSERT INTO entries VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||||
|
params![
|
||||||
|
&(data.id as i64),
|
||||||
|
&data.slug,
|
||||||
|
&data.edit_code,
|
||||||
|
&data.salt,
|
||||||
|
&(data.created as i64),
|
||||||
|
&(data.edited as i64),
|
||||||
|
&data.content,
|
||||||
|
&data.metadata,
|
||||||
|
&data.last_edit_from,
|
||||||
|
&data.modify_code,
|
||||||
|
&(data.views as i64)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing entry.
|
||||||
|
pub async fn update_entry(
|
||||||
|
&self,
|
||||||
|
id: usize,
|
||||||
|
edit_code: String,
|
||||||
|
mut new_slug: String,
|
||||||
|
new_content: String,
|
||||||
|
mut new_metadata: String,
|
||||||
|
mut new_edit_code: String,
|
||||||
|
mut new_modify_code: String,
|
||||||
|
by_ip: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
// check values
|
||||||
|
if !new_slug.is_empty() {
|
||||||
|
if new_slug.trim().len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("slug".to_string()));
|
||||||
|
} else if new_slug.len() > 128 {
|
||||||
|
return Err(Error::DataTooLong("slug".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content.len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content.len() > 150_000 {
|
||||||
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check characters
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&new_slug).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This slug contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check metadata
|
||||||
|
let mut metadata: EntryMetadata =
|
||||||
|
match toml::from_str(&EntryMetadata::ini_to_toml(&new_metadata)) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => return Err(Error::MiscError(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = metadata.validate() {
|
||||||
|
return Err(Error::MiscError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
|
||||||
|
if do_update_metadata {
|
||||||
|
new_metadata = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get stored version of entry
|
||||||
|
let entry = self.get_entry_by_id(id).await?;
|
||||||
|
|
||||||
|
// check password
|
||||||
|
let using_modify = hash(edit_code.clone() + &entry.salt) == entry.modify_code;
|
||||||
|
|
||||||
|
if !using_modify && edit_code != self.0.0.master_pass {
|
||||||
|
if !entry.check_password(edit_code) {
|
||||||
|
return Err(Error::NotAllowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove cached
|
||||||
|
self.cache_clear_entry(&entry).await;
|
||||||
|
|
||||||
|
// hash junk
|
||||||
|
if !using_modify {
|
||||||
|
if new_slug.is_empty() {
|
||||||
|
// use original; no change
|
||||||
|
new_slug = entry.slug;
|
||||||
|
} else {
|
||||||
|
// make sure slug is all lowercase
|
||||||
|
new_slug = new_slug.to_lowercase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !new_edit_code.is_empty() {
|
||||||
|
new_edit_code = hash(new_edit_code + &entry.salt);
|
||||||
|
} else {
|
||||||
|
// use original; no change
|
||||||
|
new_edit_code = entry.edit_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !new_modify_code.is_empty() {
|
||||||
|
new_modify_code = hash(new_modify_code + &entry.salt);
|
||||||
|
} else {
|
||||||
|
// use original; no change
|
||||||
|
new_modify_code = entry.modify_code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// using modify code; no change
|
||||||
|
new_slug = entry.slug;
|
||||||
|
new_edit_code = entry.edit_code;
|
||||||
|
new_modify_code = entry.modify_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"UPDATE entries SET slug = $1, edit_code = $2, modify_code = $3, content = $4, metadata = $5, edited = $6, last_edit_from = $7 WHERE id = $8",
|
||||||
|
params![
|
||||||
|
&new_slug,
|
||||||
|
&new_edit_code,
|
||||||
|
&new_modify_code,
|
||||||
|
&new_content,
|
||||||
|
&new_metadata,
|
||||||
|
&(unix_epoch_timestamp() as i64),
|
||||||
|
&by_ip,
|
||||||
|
&(id as i64),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an existing entry.
|
||||||
|
pub async fn delete_entry(&self, id: usize, edit_code: String) -> Result<()> {
|
||||||
|
// get entry
|
||||||
|
let entry = self.get_entry_by_id(id).await?;
|
||||||
|
|
||||||
|
// check password
|
||||||
|
if edit_code != self.0.0.master_pass {
|
||||||
|
if !entry.check_password(edit_code) {
|
||||||
|
return Err(Error::NotAllowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"DELETE FROM entries WHERE id = $1",
|
||||||
|
params![&(id as i64)]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache_clear_entry(&entry).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an [`Entry`] from the cache.
|
||||||
|
pub async fn cache_clear_entry(&self, entry: &Entry) -> bool {
|
||||||
|
self.0.1.remove(format!("fluf.entry:{}", entry.id)).await
|
||||||
|
&& self.0.1.remove(format!("fluf.entry:{}", entry.slug)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_method!(incr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --incr);
|
||||||
|
auto_method!(decr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --decr=views);
|
||||||
|
}
|
30
src/database/mod.rs
Normal file
30
src/database/mod.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
mod entries;
|
||||||
|
mod sql;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult};
|
||||||
|
use tetratto_core::model::{Error, Result};
|
||||||
|
|
||||||
|
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DataManager(pub OiseauManager<Config>);
|
||||||
|
|
||||||
|
impl DataManager {
|
||||||
|
/// Create a new [`DataManager`].
|
||||||
|
pub async fn new(config: Config) -> PgResult<Self> {
|
||||||
|
Ok(Self(OiseauManager::new(config).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize tables.
|
||||||
|
pub async fn init(&self) -> Result<()> {
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
execute!(&conn, sql::CREATE_TABLE_ENTRIES).unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
13
src/database/sql/create_entries.sql
Normal file
13
src/database/sql/create_entries.sql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
edit_code TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
edited BIGINT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL,
|
||||||
|
last_edit_from TEXT NOT NULL,
|
||||||
|
modify_code TEXT NOT NULL,
|
||||||
|
views BIGINT NOT NULL
|
||||||
|
)
|
1
src/database/sql/mod.rs
Normal file
1
src/database/sql/mod.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub const CREATE_TABLE_ENTRIES: &str = include_str!("./create_entries.sql");
|
16
src/main.rs
16
src/main.rs
|
@ -1,13 +1,17 @@
|
||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.md")]
|
||||||
|
mod config;
|
||||||
|
mod database;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod model;
|
mod model;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
|
use crate::database::DataManager;
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
|
use config::Config;
|
||||||
use nanoneo::core::element::Render;
|
use nanoneo::core::element::Render;
|
||||||
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
|
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
|
||||||
use tera::{Tera, Value};
|
use tera::{Tera, Value};
|
||||||
use tetratto_core::{html, sdk::DataClient};
|
use tetratto_core::html;
|
||||||
use tetratto_shared::hash::salt;
|
use tetratto_shared::hash::salt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
@ -16,7 +20,7 @@ use tower_http::{
|
||||||
};
|
};
|
||||||
use tracing::{Level, info};
|
use tracing::{Level, info};
|
||||||
|
|
||||||
pub(crate) type InnerState = (DataClient, Tera, String);
|
pub(crate) type InnerState = (DataManager, Tera, String);
|
||||||
pub(crate) type State = Arc<RwLock<InnerState>>;
|
pub(crate) type State = Arc<RwLock<InnerState>>;
|
||||||
|
|
||||||
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
|
@ -57,10 +61,10 @@ async fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let database = DataClient::new(
|
let database = DataManager::new(Config::read())
|
||||||
Some(var("TETRATTO").unwrap_or("https://tetratto.com".to_string())),
|
.await
|
||||||
var("API_KEY").expect("API_KEY environment variable required"),
|
.expect("failed to connect to database");
|
||||||
);
|
database.init().await.expect("failed to init database");
|
||||||
|
|
||||||
// build lisp
|
// build lisp
|
||||||
create_dir_if_not_exists!("./templates_build");
|
create_dir_if_not_exists!("./templates_build");
|
||||||
|
|
42
src/model.rs
42
src/model.rs
|
@ -2,9 +2,15 @@ use crate::markdown::is_numeric;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_valid::Validate;
|
use serde_valid::Validate;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
use tetratto_shared::{
|
||||||
|
hash::{hash, salt},
|
||||||
|
snow::Snowflake,
|
||||||
|
unix_epoch_timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
|
pub id: usize,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub edit_code: String,
|
pub edit_code: String,
|
||||||
pub salt: String,
|
pub salt: String,
|
||||||
|
@ -19,6 +25,42 @@ pub struct Entry {
|
||||||
/// An edit code that can only be used to change the entry's content.
|
/// An edit code that can only be used to change the entry's content.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modify_code: String,
|
pub modify_code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub views: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry {
|
||||||
|
/// Create a new [`Entry`].
|
||||||
|
pub fn new(
|
||||||
|
slug: String,
|
||||||
|
edit_code: String,
|
||||||
|
content: String,
|
||||||
|
metadata: String,
|
||||||
|
last_edit_from: String,
|
||||||
|
) -> Self {
|
||||||
|
let salt = salt();
|
||||||
|
let edit_code = hash(edit_code.clone() + &salt);
|
||||||
|
let created = unix_epoch_timestamp();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||||
|
slug,
|
||||||
|
edit_code,
|
||||||
|
salt,
|
||||||
|
created,
|
||||||
|
edited: created,
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
last_edit_from,
|
||||||
|
modify_code: String::new(),
|
||||||
|
views: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the given password against the entry's stored password hash.
|
||||||
|
pub fn check_password(&self, supplied: String) -> bool {
|
||||||
|
hash(supplied + &self.salt) == self.edit_code
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
552
src/routes.rs
552
src/routes.rs
|
@ -1,7 +1,6 @@
|
||||||
use std::env::var;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
State,
|
State,
|
||||||
|
config::Config,
|
||||||
model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
|
model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -16,19 +15,8 @@ use pathbufd::PathBufD;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_valid::Validate;
|
use serde_valid::Validate;
|
||||||
use tera::Context;
|
use tera::Context;
|
||||||
use tetratto_core::{
|
use tetratto_core::model::{ApiReturn, Error};
|
||||||
model::{
|
use tetratto_shared::{hash::salt, unix_epoch_timestamp};
|
||||||
ApiReturn, Error,
|
|
||||||
apps::{AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery},
|
|
||||||
},
|
|
||||||
sdk::{DataClient, SimplifiedQuery},
|
|
||||||
};
|
|
||||||
use tetratto_shared::{
|
|
||||||
hash::{hash, salt},
|
|
||||||
unix_epoch_timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
|
|
||||||
|
|
||||||
pub fn routes() -> Router {
|
pub fn routes() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
@ -44,28 +32,21 @@ pub fn routes() -> Router {
|
||||||
.route("/{slug}/edit", get(editor_request))
|
.route("/{slug}/edit", get(editor_request))
|
||||||
.route("/{slug}/claim", get(reclaim_request))
|
.route("/{slug}/claim", get(reclaim_request))
|
||||||
// api
|
// api
|
||||||
.route("/api/v1/util/ip", get(util_ip))
|
|
||||||
.route("/api/v1/render", post(render_request))
|
.route("/api/v1/render", post(render_request))
|
||||||
.route("/api/v1/entries", post(create_request))
|
.route("/api/v1/entries", post(create_request))
|
||||||
.route("/api/v1/entries/{slug}", post(edit_request))
|
.route("/api/v1/entries/{slug}", post(edit_request))
|
||||||
.route("/api/v1/entries/{slug}", get(exists_request))
|
.route("/api/v1/entries/{slug}", get(exists_request))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_context(data: &DataClient, build_code: &str) -> Context {
|
fn default_context(config: &Config, build_code: &str) -> Context {
|
||||||
let mut ctx = Context::new();
|
let mut ctx = Context::new();
|
||||||
ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
|
ctx.insert("name", &config.name);
|
||||||
ctx.insert(
|
ctx.insert("theme_color", &config.theme_color);
|
||||||
"theme_color",
|
ctx.insert("tetratto", &config.tetratto);
|
||||||
&var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()),
|
ctx.insert("what_page_slug", &config.what_page_slug);
|
||||||
);
|
|
||||||
ctx.insert("tetratto", &data.host);
|
|
||||||
ctx.insert(
|
|
||||||
"what_page_slug",
|
|
||||||
&var("WHAT_SLUG").unwrap_or("what".to_string()),
|
|
||||||
);
|
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"tetratto_handler_account_username",
|
"tetratto_handler_account_username",
|
||||||
&var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()),
|
&config.tetratto_handler_account_username,
|
||||||
);
|
);
|
||||||
ctx.insert("build_code", &build_code);
|
ctx.insert("build_code", &build_code);
|
||||||
ctx
|
ctx
|
||||||
|
@ -75,7 +56,7 @@ fn default_context(data: &DataClient, build_code: &str) -> Context {
|
||||||
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("page".to_string()).to_string(),
|
&Error::GeneralNotFound("page".to_string()).to_string(),
|
||||||
|
@ -86,7 +67,7 @@ async fn not_found_request(Extension(data): Extension<State>) -> impl IntoRespon
|
||||||
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
Html(
|
Html(
|
||||||
tera.render("index.lisp", &default_context(&data, &build_code))
|
tera.render("index.lisp", &default_context(&data.0.0, &build_code))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -99,7 +80,7 @@ async fn view_doc_request(
|
||||||
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
|
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
|
||||||
|
|
||||||
if !std::fs::exists(&path).unwrap_or(false) {
|
if !std::fs::exists(&path).unwrap_or(false) {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -110,13 +91,13 @@ async fn view_doc_request(
|
||||||
let text = match std::fs::read_to_string(&path) {
|
let text = match std::fs::read_to_string(&path) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
|
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
|
||||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("text", &text);
|
ctx.insert("text", &text);
|
||||||
ctx.insert("file_name", &name);
|
ctx.insert("file_name", &name);
|
||||||
|
@ -151,20 +132,10 @@ async fn view_request(
|
||||||
format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
|
format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -185,7 +156,7 @@ async fn view_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
if let Err(e) = metadata.validate() {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &e.to_string());
|
ctx.insert("error", &e.to_string());
|
||||||
return (
|
return (
|
||||||
[viewed_header],
|
[viewed_header],
|
||||||
|
@ -197,7 +168,7 @@ async fn view_request(
|
||||||
if !metadata.option_view_password.is_empty()
|
if !metadata.option_view_password.is_empty()
|
||||||
&& metadata.option_view_password != props.key.clone()
|
&& metadata.option_view_password != props.key.clone()
|
||||||
{
|
{
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
return (
|
return (
|
||||||
[viewed_header],
|
[viewed_header],
|
||||||
|
@ -206,57 +177,24 @@ async fn view_request(
|
||||||
}
|
}
|
||||||
|
|
||||||
// pull views
|
// pull views
|
||||||
let views = if !metadata.option_disable_views {
|
if jar.get("Atto-Viewed").is_none() {
|
||||||
match data
|
// the Atto-Viewed cookie tells us if we've already viewed this
|
||||||
.query(&SimplifiedQuery {
|
// entry recently (at all in the past week)
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
if let Err(e) = data.incr_entry_views(entry.id).await {
|
||||||
mode: AppDataSelectMode::One(0),
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
cache: false,
|
ctx.insert("error", &e.to_string());
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => {
|
|
||||||
// count view
|
|
||||||
let views = r.value.parse::<usize>().unwrap();
|
|
||||||
|
|
||||||
if jar.get("Atto-Viewed").is_none() {
|
return (
|
||||||
// the Atto-Viewed cookie tells us if we've already viewed this
|
[viewed_header],
|
||||||
// entry recently (at all in the past week)
|
Html(tera.render("error.lisp", &ctx).unwrap()),
|
||||||
if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
|
);
|
||||||
let mut ctx = default_context(&data, &build_code);
|
|
||||||
ctx.insert("error", &e.to_string());
|
|
||||||
|
|
||||||
return (
|
|
||||||
[viewed_header],
|
|
||||||
Html(tera.render("error.lisp", &ctx).unwrap()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
views
|
|
||||||
}
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
|
||||||
ctx.insert("error", &e.to_string());
|
|
||||||
|
|
||||||
return (
|
|
||||||
[viewed_header],
|
|
||||||
Html(tera.render("error.lisp", &ctx).unwrap()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("views", &views);
|
|
||||||
ctx.insert("metadata", &metadata);
|
ctx.insert("metadata", &metadata);
|
||||||
ctx.insert("metadata_head", &metadata.head_tags());
|
ctx.insert("metadata_head", &metadata.head_tags());
|
||||||
ctx.insert("metadata_css", &metadata.css());
|
ctx.insert("metadata_css", &metadata.css());
|
||||||
|
@ -286,20 +224,10 @@ async fn editor_request(
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
slug = slug.to_lowercase();
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -323,13 +251,13 @@ async fn editor_request(
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
} {
|
} {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
return Html(tera.render("password.lisp", &ctx).unwrap());
|
return Html(tera.render("password.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("password", &props.key);
|
ctx.insert("password", &props.key);
|
||||||
|
@ -346,20 +274,10 @@ async fn reclaim_request(
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
slug = slug.to_lowercase();
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -377,13 +295,13 @@ async fn reclaim_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
if let Err(e) = metadata.validate() {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &e.to_string());
|
ctx.insert("error", &e.to_string());
|
||||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("metadata", &metadata);
|
ctx.insert("metadata", &metadata);
|
||||||
|
@ -428,26 +346,10 @@ async fn exists_request(
|
||||||
Json(ApiReturn {
|
Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Success".to_string(),
|
message: "Success".to_string(),
|
||||||
payload: data
|
payload: data.get_entry_by_slug(&slug).await.is_ok(),
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.is_ok(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn util_ip(headers: HeaderMap) -> impl IntoResponse {
|
|
||||||
headers
|
|
||||||
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
|
||||||
.unwrap_or(&HeaderValue::from_static(""))
|
|
||||||
.to_str()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateEntry {
|
struct CreateEntry {
|
||||||
content: String,
|
content: String,
|
||||||
|
@ -463,35 +365,6 @@ fn default_random() -> String {
|
||||||
salt()
|
salt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
|
|
||||||
// hash passwords
|
|
||||||
let do_update_metadata = (!metadata.option_view_password.is_empty()
|
|
||||||
|| !metadata.option_source_password.is_empty())
|
|
||||||
&& (!metadata.option_view_password.starts_with("h:")
|
|
||||||
|| !metadata.option_source_password.starts_with("h:"));
|
|
||||||
|
|
||||||
if !metadata.option_view_password.is_empty() && !metadata.option_view_password.starts_with("h:")
|
|
||||||
{
|
|
||||||
metadata.option_view_password =
|
|
||||||
format!("h:{}", hash(metadata.option_view_password.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !metadata.option_source_password.is_empty()
|
|
||||||
&& !metadata.option_source_password.starts_with("h:")
|
|
||||||
{
|
|
||||||
metadata.option_source_password =
|
|
||||||
format!("h:{}", hash(metadata.option_source_password.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if do_update_metadata {
|
|
||||||
if let Ok(x) = toml::to_string_pretty(&metadata) {
|
|
||||||
return (true, x);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
(false, String::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The time that must be waited between each entry creation.
|
/// The time that must be waited between each entry creation.
|
||||||
const CREATE_WAIT_TIME: usize = 15000;
|
const CREATE_WAIT_TIME: usize = 15000;
|
||||||
|
|
||||||
|
@ -506,18 +379,18 @@ async fn create_request(
|
||||||
|
|
||||||
// get real ip
|
// get real ip
|
||||||
let real_ip = headers
|
let real_ip = headers
|
||||||
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
.get(&data.0.0.real_ip_header)
|
||||||
.unwrap_or(&HeaderValue::from_static(""))
|
.unwrap_or(&HeaderValue::from_static(""))
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// check for ip ban
|
// check for ip ban
|
||||||
if !real_ip.is_empty() {
|
// if !real_ip.is_empty() {
|
||||||
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
||||||
return Err(Json(Error::NotAllowed.into()));
|
// return Err(Json(Error::NotAllowed.into()));
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// check wait time
|
// check wait time
|
||||||
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
|
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
|
||||||
|
@ -534,93 +407,15 @@ async fn create_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check lengths
|
|
||||||
if req.slug.len() < 2 {
|
|
||||||
return Err(Json(Error::DataTooShort("slug".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.slug.len() > 32 {
|
|
||||||
return Err(Json(Error::DataTooLong("slug".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.content.len() < 2 {
|
|
||||||
return Err(Json(Error::DataTooShort("content".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.content.len() > 150_000 {
|
|
||||||
return Err(Json(Error::DataTooLong("content".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// check slug
|
|
||||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
|
||||||
.multi_line(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if regex.captures(&req.slug).is_some() {
|
|
||||||
return Err(Json(
|
|
||||||
Error::MiscError("This slug contains invalid characters".to_string()).into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// check metadata
|
|
||||||
let mut metadata: EntryMetadata =
|
|
||||||
match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
|
|
||||||
Ok(x) => x,
|
|
||||||
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
|
||||||
return Err(Json(Error::MiscError(e.to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (do_update_metadata, updated) = hash_passwords(&mut metadata);
|
|
||||||
if do_update_metadata {
|
|
||||||
req.metadata = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for existing
|
|
||||||
if data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
return Err(Json(
|
|
||||||
Error::MiscError("Slug already in use".to_string()).into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// create
|
// create
|
||||||
let created = unix_epoch_timestamp();
|
|
||||||
let salt = salt();
|
|
||||||
|
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.insert(
|
.create_entry(Entry::new(
|
||||||
format!("entries('{}')", req.slug),
|
req.slug.clone(),
|
||||||
serde_json::to_string(&Entry {
|
req.edit_code.clone(),
|
||||||
slug: req.slug.clone(),
|
req.content,
|
||||||
edit_code: hash(req.edit_code.clone() + &salt),
|
req.metadata,
|
||||||
salt,
|
real_ip,
|
||||||
created,
|
))
|
||||||
edited: created,
|
|
||||||
content: req.content,
|
|
||||||
metadata: req.metadata,
|
|
||||||
last_edit_from: real_ip,
|
|
||||||
modify_code: String::new(),
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Err(Json(e.into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.insert(format!("entries.views('{}')", req.slug), 0.to_string())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(Json(e.into()));
|
return Err(Json(e.into()));
|
||||||
|
@ -662,220 +457,57 @@ struct EditEntry {
|
||||||
async fn edit_request(
|
async fn edit_request(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Path(mut slug): Path<String>,
|
Path(id): Path<usize>,
|
||||||
Json(mut req): Json<EditEntry>,
|
Json(req): Json<EditEntry>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let (ref data, _, _) = *data.read().await;
|
let (ref data, _, _) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
|
||||||
|
|
||||||
// get real ip
|
// get real ip
|
||||||
let real_ip = headers
|
let real_ip = headers
|
||||||
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
.get(&data.0.0.real_ip_header)
|
||||||
.unwrap_or(&HeaderValue::from_static(""))
|
.unwrap_or(&HeaderValue::from_static(""))
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// check for ip ban
|
// check for ip ban
|
||||||
if !real_ip.is_empty() {
|
// if !real_ip.is_empty() {
|
||||||
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
||||||
return Json(Error::NotAllowed.into());
|
// return Json(Error::NotAllowed.into());
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// check content length
|
// handle delete
|
||||||
if req.content.len() < 2 {
|
if req.delete {
|
||||||
return Json(Error::DataTooShort("content".to_string()).into());
|
return match data.delete_entry(id, req.edit_code).await {
|
||||||
}
|
Ok(_) => Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
if req.content.len() > 150_000 {
|
message: "Success".to_string(),
|
||||||
return Json(Error::DataTooLong("content".to_string()).into());
|
payload: None,
|
||||||
}
|
}),
|
||||||
|
Err(e) => return Json(e.into()),
|
||||||
// check metadata
|
|
||||||
let mut metadata: EntryMetadata =
|
|
||||||
match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
|
|
||||||
Ok(x) => x,
|
|
||||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
|
||||||
return Json(Error::MiscError(e.to_string()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (do_update_metadata, updated) = hash_passwords(&mut metadata);
|
|
||||||
if do_update_metadata {
|
|
||||||
req.metadata = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
let (id, mut entry) = match data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => (r.id, serde_json::from_str::<Entry>(&r.value).unwrap()),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(e) => return Json(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let edit_code = hash(req.edit_code.clone() + &entry.salt);
|
|
||||||
let using_modify_code = edit_code == entry.modify_code;
|
|
||||||
|
|
||||||
// check edit code
|
|
||||||
let mut using_master = false;
|
|
||||||
if let Ok(master_pass) = var("MASTER_PASS") {
|
|
||||||
if req.edit_code == master_pass {
|
|
||||||
using_master = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !using_master
|
|
||||||
&& edit_code
|
|
||||||
!= *if using_modify_code {
|
|
||||||
&entry.modify_code
|
|
||||||
} else {
|
|
||||||
&entry.edit_code
|
|
||||||
}
|
|
||||||
{
|
|
||||||
return Json(Error::NotAllowed.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
if !using_modify_code {
|
|
||||||
// handle delete
|
|
||||||
if req.delete {
|
|
||||||
let views_id = match data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => r.id,
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(e) => return Json(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
return match data.remove(id).await {
|
|
||||||
Ok(_) => match data.remove(views_id).await {
|
|
||||||
Ok(_) => Json(ApiReturn {
|
|
||||||
ok: true,
|
|
||||||
message: "Success".to_string(),
|
|
||||||
payload: None,
|
|
||||||
}),
|
|
||||||
Err(e) => Json(e.into()),
|
|
||||||
},
|
|
||||||
Err(e) => Json(e.into()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// check edited slug and edit code
|
|
||||||
if let Some(mut new_slug) = req.new_slug {
|
|
||||||
new_slug = new_slug.to_lowercase();
|
|
||||||
|
|
||||||
if new_slug.len() < 2 {
|
|
||||||
return Json(Error::DataTooShort("slug".to_string()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if new_slug.len() > 32 {
|
|
||||||
return Json(Error::DataTooLong("slug".to_string()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// check slug
|
|
||||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
|
||||||
.multi_line(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if regex.captures(&new_slug).is_some() {
|
|
||||||
return Json(
|
|
||||||
Error::MiscError("This slug contains invalid characters".to_string()).into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for existing
|
|
||||||
if data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", new_slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
return Json(Error::MiscError("Slug already in use".to_string()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let views_id = match data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => r.id,
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(e) => return Json(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// rename
|
|
||||||
if let Err(e) = data.rename(id, format!("entries('{}')", new_slug)).await {
|
|
||||||
return Json(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.rename(views_id, format!("entries.views('{}')", new_slug))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Json(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.slug = new_slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(new_edit_code) = req.new_edit_code {
|
|
||||||
entry.salt = salt();
|
|
||||||
entry.edit_code = hash(new_edit_code + &entry.salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update modify code
|
|
||||||
if let Some(new_modify_code) = req.new_modify_code {
|
|
||||||
entry.modify_code = hash(new_modify_code + &entry.salt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update
|
|
||||||
entry.content = req.content;
|
|
||||||
entry.edited = unix_epoch_timestamp();
|
|
||||||
|
|
||||||
if !using_modify_code {
|
|
||||||
entry.metadata = req.metadata;
|
|
||||||
entry.last_edit_from = real_ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.update(id, serde_json::to_string(&entry).unwrap())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Json(e.into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// return
|
// return
|
||||||
Json(ApiReturn {
|
match data
|
||||||
ok: true,
|
.update_entry(
|
||||||
message: "Success".to_string(),
|
id,
|
||||||
payload: Some(entry.slug),
|
req.edit_code,
|
||||||
})
|
req.new_slug.unwrap_or_default(),
|
||||||
|
req.content,
|
||||||
|
req.metadata,
|
||||||
|
req.new_edit_code.unwrap_or_default(),
|
||||||
|
req.new_modify_code.unwrap_or_default(),
|
||||||
|
real_ip,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(x) => Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "Success".to_string(),
|
||||||
|
payload: Some(x),
|
||||||
|
}),
|
||||||
|
Err(e) => Json(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue