Compare commits

..

No commits in common. "master" and "0.4.0" have entirely different histories.

17 changed files with 517 additions and 746 deletions

2
.gitignore vendored
View file

@ -1,3 +1 @@
/target
app/fluffle.toml
migration.js

63
Cargo.lock generated
View file

@ -243,9 +243,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.2"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "block-buffer"
@ -542,9 +542,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "emojis"
version = "0.7.2"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52f3d011046a013bdefbc63a5523b06ad0c0f1e227941baf98475496229d634"
checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
dependencies = [
"phf 0.12.1",
]
@ -571,7 +571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -607,14 +607,13 @@ dependencies = [
[[package]]
name = "fluffle"
version = "1.0.0"
version = "0.4.0"
dependencies = [
"axum",
"axum-extra",
"dotenv",
"glob",
"nanoneo",
"oiseau",
"pathbufd",
"regex",
"serde",
@ -624,7 +623,7 @@ dependencies = [
"tetratto-core",
"tetratto-shared",
"tokio",
"toml 0.9.5",
"toml 0.9.4",
"tower-http",
"tracing",
"tracing-subscriber",
@ -810,7 +809,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"ignore",
"walkdir",
]
@ -1182,7 +1181,7 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"cfg-if",
"libc",
]
@ -1495,7 +1494,7 @@ version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"cfg-if",
"foreign-types",
"libc",
@ -1832,7 +1831,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"getopts",
"memchr",
"pulldown-cmark-escape",
@ -1964,7 +1963,7 @@ version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
]
[[package]]
@ -2013,9 +2012,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.23"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"base64",
"bytes",
@ -2079,11 +2078,11 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -2167,7 +2166,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"core-foundation",
"core-foundation-sys",
"libc",
@ -2499,7 +2498,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"core-foundation",
"system-configuration-sys",
]
@ -2524,7 +2523,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -2562,14 +2561,14 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "15.0.1"
version = "12.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7aeb9dcc5631ec6188bb9438dc97015c6662b6f59e650e5afa865775f170c9c"
checksum = "a367ac3ced8ff302080e1b4a82a67acd24fa606245c4381a6f77dbaaf6ef4b58"
dependencies = [
"async-recursion",
"base16ct",
"base64",
"bitflags 2.9.2",
"bitflags 2.9.1",
"emojis",
"md-5",
"oiseau",
@ -2582,7 +2581,7 @@ dependencies = [
"tetratto-l10n",
"tetratto-shared",
"tokio",
"toml 0.9.5",
"toml 0.9.4",
"totp-rs",
]
@ -2594,7 +2593,7 @@ checksum = "d96f5e41633c757e3519efb47c9b85d00d14322c1961360e126d0ecc0ea79b86"
dependencies = [
"pathbufd",
"serde",
"toml 0.9.5",
"toml 0.9.4",
]
[[package]]
@ -2835,9 +2834,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.5"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
dependencies = [
"indexmap",
"serde",
@ -2882,9 +2881,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.2"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
dependencies = [
"winnow",
]
@ -2940,7 +2939,7 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
"bytes",
"futures-core",
"futures-util",
@ -3396,7 +3395,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -3638,7 +3637,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.2",
"bitflags 2.9.1",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "fluffle"
version = "1.0.0"
version = "0.4.0"
edition = "2024"
authors = ["trisuaso"]
repository = "https://trisua.com/t/fluffle"
@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later"
homepage = "https://fluffle.cc"
[dependencies]
tetratto-core = "15.0.1"
tetratto-core = "12.0.2"
tetratto-shared = "12.0.6"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
pathbufd = "0.1.4"
@ -31,4 +31,3 @@ serde_json = "1.0.142"
toml = "0.9.4"
serde_valid = { version = "1.0.5", features = ["toml"] }
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] }

View file

@ -2,11 +2,27 @@
Fluffle is a familiar Markdown pastebin-y site :)
Since Tetratto is used as a backend, you'll obviously need to create an app at <https://tetratto.com/developer>. Once you've created the app, scroll down to "Secret key" and roll the key. Copy the key since you'll need it for later.
## Usage
Once you've cloned the repository, cd into the `app` directory and run `cargo run -r`.
After you start the server the first time, a `fluffle.toml` file will be created in the current directory. You'll need to edit that file to configure your PostgreSQL connection, instance name/theme color, etc.
Before you run the server, however, you should create a `.env` file to store your API key (that you rolled earlier). In the `.env` file, put the following:
```ini
API_KEY=<your key>
```
So if your key was "ABCD123", you would have:
```ini
API_KEY=ABCD123
```
Once this file is in place, you can safely run the server. You can also optionally add a `PORT` variable in there to change the port number. If you don't change the port, you can find the server at `http://localhost:9119`.
It's important to note that you're fairly limited on app storage without the Tetratto developer pass. You can manage your billing settings at <https://tetratto.com/settings#/account/billing>.
## Customization
@ -22,7 +38,7 @@ Once you've built the binary, it'll be located at (from the root `fluffle/` dire
All templates are compiled with [nanoneo](https://trisua.com/t/nanoneo), so it's recommended that you familiarize yourself with that syntax.
You can set a master password to be able to freely edit all entries by using the `master_pass` configuration key. A password is automatically generated on first start as well. Please note that this value is stored in plain text.
You can set a master password to be able to freely edit all entries by using the `MASTER_PASS` environment variable.
## Attribution

View file

@ -283,14 +283,6 @@ video {
position: absolute;
z-index: 2;
top: 100%;
right: 0;
width: max-content;
max-width: 15rem;
}
.dropdown .inner.left {
right: unset;
left: 0;
}
.dropdown .inner.open {
@ -817,7 +809,7 @@ dialog {
border: 0;
}
dialog .inner {
dialog.inner {
display: flex;
flex-direction: column;
gap: var(--pad-2);
@ -831,23 +823,3 @@ dialog::backdrop {
dialog:is(.dark *)::backdrop {
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%;
}

View file

@ -6,7 +6,7 @@
(div
("class" "card container")
(h1 (text "{{ entry.slug }}"))
(p (text "Custom slug reclaims are handled through ") (b (text "{{ config.service_hosts.tetratto }}")) (text ". You'll need to have an account there to submit a claim request."))
(p (text "Custom slug reclaims are handled through ") (b (text "{{ tetratto }}")) (text ". You'll need to have an account there to submit a claim request."))
(p (text "Please note that you are unlikely to receive a response unless your claim is accepted. Please do not submit additional requests for the same slug."))
(text "{% if metadata.tetratto_owner_username -%}")
@ -24,13 +24,13 @@
(text "{% if metadata.tetratto_owner_username -%}")
; contact owner button
(a
("href" "{{ config.service_hosts.tetratto }}/mail/compose?receivers={{ metadata.tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
("class" "button surface no_fill")
(text "{{ icon \"external-link\" }} Contact owner"))
(text "{%- endif %}")
(a
("href" "{{ config.service_hosts.tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
("class" "button surface no_fill")
(text "{{ icon \"external-link\" }} Submit request"))
(text "{% else %}")

View file

@ -143,7 +143,7 @@
const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
load();
fetch(\"/api/v1/entries/{{ entry.id }}\", {
fetch(\"/api/v1/entries/{{ entry.slug }}\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",

View file

@ -6,7 +6,7 @@
(meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0"))
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))
(link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css"))
(link ("rel" "stylesheet") ("href" "{{ tetratto }}/css/utility.css?v={{ build_code }}"))
(link ("rel" "stylesheet") ("href" "/public/style.css?v={{ build_code }}"))
(style (text ":root { --color-primary: {{ theme_color }}; }"))
@ -33,7 +33,7 @@
("class" "button camo fade")
(text "{{ icon \"menu\" }}"))
(div
("class" "inner left")
("class" "inner")
(a
("class" "button")
("href" "/")

View file

@ -46,18 +46,16 @@
(text "Owner:")
(a
("class" "flex items_center gap_2")
("href" "{{ config.service_hosts.tetratto }}/@{{ metadata.tetratto_owner_username }}")
(text "{% if metadata.tetratto_owner_id -%}")
("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}")
(img
("class" "avatar")
("src" "{{ config.service_hosts.buckets }}/avatars/{{ metadata.tetratto_owner_id }}"))
(text "{%- endif %}")
("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username"))
(text "{{ metadata.tetratto_owner_username }}")))
(text "{%- endif %}")
; views
(text "{% if not metadata.option_disable_views -%}")
(span (text "Views: {{ entry.views }}"))
(span (text "Views: {{ views }}"))
(text "{%- endif %}")
; easy-to-read

View file

@ -1,115 +0,0 @@
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 ServiceHostsConfig {
pub tetratto: String,
pub buckets: String,
}
#[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 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 host URL of required services.
#[serde(default = "default_service_hosts")]
pub service_hosts: ServiceHostsConfig,
/// 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_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()
}
fn default_service_hosts() -> ServiceHostsConfig {
ServiceHostsConfig {
tetratto: "https://tetratto.com".to_string(),
buckets: "https://assetdelivery.tetratto.com".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(),
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(),
service_hosts: default_service_hosts(),
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")
}
}

View file

@ -1,316 +0,0 @@
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);
}

View file

@ -1,30 +0,0 @@
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(())
}
}

View file

@ -1,13 +0,0 @@
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
)

View file

@ -1 +0,0 @@
pub const CREATE_TABLE_ENTRIES: &str = include_str!("./create_entries.sql");

View file

@ -1,17 +1,13 @@
#![doc = include_str!("../README.md")]
mod config;
mod database;
mod markdown;
mod model;
mod routes;
use crate::database::DataManager;
use axum::{Extension, Router};
use config::Config;
use nanoneo::core::element::Render;
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
use tera::{Tera, Value};
use tetratto_core::html;
use tetratto_core::{html, sdk::DataClient};
use tetratto_shared::hash::salt;
use tokio::sync::RwLock;
use tower_http::{
@ -20,7 +16,7 @@ use tower_http::{
};
use tracing::{Level, info};
pub(crate) type InnerState = (DataManager, Tera, String);
pub(crate) type InnerState = (DataClient, Tera, String);
pub(crate) type State = Arc<RwLock<InnerState>>;
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
@ -61,10 +57,10 @@ async fn main() {
};
// ...
let database = DataManager::new(Config::read())
.await
.expect("failed to connect to database");
database.init().await.expect("failed to init database");
let database = DataClient::new(
Some(var("TETRATTO").unwrap_or("https://tetratto.com".to_string())),
var("API_KEY").expect("API_KEY environment variable required"),
);
// build lisp
create_dir_if_not_exists!("./templates_build");

View file

@ -2,15 +2,9 @@ use crate::markdown::is_numeric;
use serde::{Deserialize, Serialize};
use serde_valid::Validate;
use std::fmt::Display;
use tetratto_shared::{
hash::{hash, salt},
snow::Snowflake,
unix_epoch_timestamp,
};
#[derive(Serialize, Deserialize)]
pub struct Entry {
pub id: usize,
pub slug: String,
pub edit_code: String,
pub salt: String,
@ -25,42 +19,6 @@ pub struct Entry {
/// An edit code that can only be used to change the entry's content.
#[serde(default)]
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)]
@ -444,9 +402,6 @@ pub struct EntryMetadata {
#[serde(default, alias = "TETRATTO_OWNER_USERNAME")]
#[validate(max_length = 32)]
pub tetratto_owner_username: String,
/// The ID of the owner of this entry on the Tetratto instance.
#[serde(default, alias = "TETRATTO_OWNER_ID")]
pub tetratto_owner_id: usize,
}
macro_rules! metadata_css {
@ -603,40 +558,6 @@ impl EntryMetadata {
input.replace("}", "").replace(";", "").replace("/*", "")
}
/// Split the given input string by the given character while skipping over
/// CSS colors.
pub fn css_color_split(c: char, input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut buffer = String::new();
let mut in_function = false;
for x in input.chars() {
if x == c && !in_function {
out.push(buffer.clone());
buffer.clear();
continue;
}
match x {
'(' => {
in_function = true;
buffer.push(x);
}
')' => {
in_function = false;
buffer.push(x);
}
_ => buffer.push(x),
}
}
if !buffer.is_empty() {
out.push(buffer);
}
out
}
pub fn css(&self) -> String {
let mut output = "<style>".to_string();
@ -675,17 +596,6 @@ impl EntryMetadata {
metadata_css!("*, html *", "--color-link" !important, self.content_link_color->output);
metadata_css!("*, html *", "--color-text" !important, self.content_text_color->output);
if !self.content_text_color.is_empty() {
let slices = Self::css_color_split(' ', &self.content_text_color);
let light = slices.get(0).unwrap();
let dark = slices.get(1).unwrap_or(light);
output.push_str(&format!(
"html * {{ --color-text: {light} !important; }}\n.dark * {{ --color-text: {dark} !important; }}\n"
));
}
if self.content_text_align != TextAlignment::Left {
output.push_str(&format!(
".container {{ text-align: {}; }}\n",

View file

@ -1,6 +1,7 @@
use std::env::var;
use crate::{
State,
config::Config,
model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
};
use axum::{
@ -15,8 +16,19 @@ use pathbufd::PathBufD;
use serde::Deserialize;
use serde_valid::Validate;
use tera::Context;
use tetratto_core::model::{ApiReturn, Error};
use tetratto_shared::{hash::salt, unix_epoch_timestamp};
use tetratto_core::{
model::{
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 {
Router::new()
@ -32,21 +44,28 @@ pub fn routes() -> Router {
.route("/{slug}/edit", get(editor_request))
.route("/{slug}/claim", get(reclaim_request))
// api
.route("/api/v1/util/ip", get(util_ip))
.route("/api/v1/render", post(render_request))
.route("/api/v1/entries", post(create_request))
.route("/api/v1/entries/{slug}", post(edit_request))
.route("/api/v1/entries/{slug}", get(exists_request))
}
fn default_context(config: &Config, build_code: &str) -> Context {
fn default_context(data: &DataClient, build_code: &str) -> Context {
let mut ctx = Context::new();
ctx.insert("name", &config.name);
ctx.insert("theme_color", &config.theme_color);
ctx.insert("config", &config);
ctx.insert("what_page_slug", &config.what_page_slug);
ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
ctx.insert(
"theme_color",
&var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()),
);
ctx.insert("tetratto", &data.host);
ctx.insert(
"what_page_slug",
&var("WHAT_SLUG").unwrap_or("what".to_string()),
);
ctx.insert(
"tetratto_handler_account_username",
&config.tetratto_handler_account_username,
&var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()),
);
ctx.insert("build_code", &build_code);
ctx
@ -56,7 +75,7 @@ fn default_context(config: &Config, build_code: &str) -> Context {
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("page".to_string()).to_string(),
@ -67,7 +86,7 @@ async fn not_found_request(Extension(data): Extension<State>) -> impl IntoRespon
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await;
Html(
tera.render("index.lisp", &default_context(&data.0.0, &build_code))
tera.render("index.lisp", &default_context(&data, &build_code))
.unwrap(),
)
}
@ -80,7 +99,7 @@ async fn view_doc_request(
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
if !std::fs::exists(&path).unwrap_or(false) {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
@ -91,13 +110,13 @@ async fn view_doc_request(
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
};
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("text", &text);
ctx.insert("file_name", &name);
@ -132,10 +151,19 @@ async fn view_request(
format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
);
let entry = match data.get_entry_by_slug(&slug).await {
Ok(x) => x,
let entry = match data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
mode: AppDataSelectMode::One(0),
})
.await
{
Ok(r) => match r {
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
AppDataQueryResult::Many(_) => unreachable!(),
},
Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
@ -156,7 +184,7 @@ async fn view_request(
};
if let Err(e) = metadata.validate() {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("error", &e.to_string());
return (
[viewed_header],
@ -168,7 +196,7 @@ async fn view_request(
if !metadata.option_view_password.is_empty()
&& metadata.option_view_password != props.key.clone()
{
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
return (
[viewed_header],
@ -177,11 +205,24 @@ async fn view_request(
}
// pull views
let views = if !metadata.option_disable_views {
match data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
mode: AppDataSelectMode::One(0),
})
.await
{
Ok(r) => match r {
AppDataQueryResult::One(r) => {
// count view
let views = r.value.parse::<usize>().unwrap();
if jar.get("Atto-Viewed").is_none() {
// the Atto-Viewed cookie tells us if we've already viewed this
// entry recently (at all in the past week)
if let Err(e) = data.incr_entry_views(entry.id).await {
let mut ctx = default_context(&data.0.0, &build_code);
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 (
@ -191,10 +232,29 @@ async fn view_request(
}
}
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.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
ctx.insert("views", &views);
ctx.insert("metadata", &metadata);
ctx.insert("metadata_head", &metadata.head_tags());
ctx.insert("metadata_css", &metadata.css());
@ -224,10 +284,19 @@ async fn editor_request(
let (ref data, ref tera, ref build_code) = *data.read().await;
slug = slug.to_lowercase();
let entry = match data.get_entry_by_slug(&slug).await {
Ok(x) => x,
let entry = match data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
mode: AppDataSelectMode::One(0),
})
.await
{
Ok(r) => match r {
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
AppDataQueryResult::Many(_) => unreachable!(),
},
Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
@ -251,13 +320,13 @@ async fn editor_request(
} else {
false
} {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
return Html(tera.render("password.lisp", &ctx).unwrap());
}
// ...
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
ctx.insert("password", &props.key);
@ -274,10 +343,19 @@ async fn reclaim_request(
let (ref data, ref tera, ref build_code) = *data.read().await;
slug = slug.to_lowercase();
let entry = match data.get_entry_by_slug(&slug).await {
Ok(x) => x,
let entry = match data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
mode: AppDataSelectMode::One(0),
})
.await
{
Ok(r) => match r {
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
AppDataQueryResult::Many(_) => unreachable!(),
},
Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
@ -295,13 +373,13 @@ async fn reclaim_request(
};
if let Err(e) = metadata.validate() {
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("error", &e.to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
// ...
let mut ctx = default_context(&data.0.0, &build_code);
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
ctx.insert("metadata", &metadata);
@ -346,8 +424,23 @@ async fn exists_request(
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: data.get_entry_by_slug(&slug).await.is_ok(),
payload: data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
mode: AppDataSelectMode::One(0),
})
.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)]
@ -365,6 +458,35 @@ fn default_random() -> String {
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.
const CREATE_WAIT_TIME: usize = 15000;
@ -379,18 +501,18 @@ async fn create_request(
// get real ip
let real_ip = headers
.get(&data.0.0.real_ip_header)
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
.unwrap_or(&HeaderValue::from_static(""))
.to_str()
.unwrap_or("")
.to_string();
// check for ip ban
// if !real_ip.is_empty() {
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
// return Err(Json(Error::NotAllowed.into()));
// }
// }
if !real_ip.is_empty() {
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
return Err(Json(Error::NotAllowed.into()));
}
}
// check wait time
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
@ -407,15 +529,92 @@ 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),
})
.await
.is_ok()
{
return Err(Json(
Error::MiscError("Slug already in use".to_string()).into(),
));
}
// create
let created = unix_epoch_timestamp();
let salt = salt();
if let Err(e) = data
.create_entry(Entry::new(
req.slug.clone(),
req.edit_code.clone(),
req.content,
req.metadata,
real_ip,
))
.insert(
format!("entries('{}')", req.slug),
serde_json::to_string(&Entry {
slug: req.slug.clone(),
edit_code: hash(req.edit_code.clone() + &salt),
salt,
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
{
return Err(Json(e.into()));
@ -457,57 +656,216 @@ struct EditEntry {
async fn edit_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<EditEntry>,
Path(mut slug): Path<String>,
Json(mut req): Json<EditEntry>,
) -> impl IntoResponse {
let (ref data, _, _) = *data.read().await;
slug = slug.to_lowercase();
// get real ip
let real_ip = headers
.get(&data.0.0.real_ip_header)
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
.unwrap_or(&HeaderValue::from_static(""))
.to_str()
.unwrap_or("")
.to_string();
// check for ip ban
// if !real_ip.is_empty() {
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
// return Json(Error::NotAllowed.into());
// }
// }
if !real_ip.is_empty() {
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
return Json(Error::NotAllowed.into());
}
}
// check content length
if req.content.len() < 2 {
return Json(Error::DataTooShort("content".to_string()).into());
}
if req.content.len() > 150_000 {
return Json(Error::DataTooLong("content".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 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),
})
.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 {
return match data.delete_entry(id, req.edit_code).await {
let views_id = match data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
mode: AppDataSelectMode::One(0),
})
.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) => return Json(e.into()),
Err(e) => Json(e.into()),
},
Err(e) => Json(e.into()),
};
}
// return
match data
.update_entry(
id,
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,
)
// 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),
})
.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),
})
.await
{
Ok(x) => Json(ApiReturn {
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
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
payload: Some(entry.slug),
})
}