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 /target
app/fluffle.toml
migration.js

63
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package] [package]
name = "fluffle" name = "fluffle"
version = "1.0.0" version = "0.4.0"
edition = "2024" edition = "2024"
authors = ["trisuaso"] authors = ["trisuaso"]
repository = "https://trisua.com/t/fluffle" repository = "https://trisua.com/t/fluffle"
@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later"
homepage = "https://fluffle.cc" homepage = "https://fluffle.cc"
[dependencies] [dependencies]
tetratto-core = "15.0.1" tetratto-core = "12.0.2"
tetratto-shared = "12.0.6" tetratto-shared = "12.0.6"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
pathbufd = "0.1.4" pathbufd = "0.1.4"
@ -31,4 +31,3 @@ 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",] }

View file

@ -2,11 +2,27 @@
Fluffle is a familiar Markdown pastebin-y site :) 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 ## Usage
Once you've cloned the repository, cd into the `app` directory and run `cargo run -r`. 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 ## 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. 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 ## Attribution

View file

@ -283,14 +283,6 @@ 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 {
@ -817,7 +809,7 @@ dialog {
border: 0; border: 0;
} }
dialog .inner { dialog.inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--pad-2); gap: var(--pad-2);
@ -831,23 +823,3 @@ 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%;
}

View file

@ -6,7 +6,7 @@
(div (div
("class" "card container") ("class" "card container")
(h1 (text "{{ entry.slug }}")) (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.")) (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 -%}") (text "{% if metadata.tetratto_owner_username -%}")
@ -24,13 +24,13 @@
(text "{% if metadata.tetratto_owner_username -%}") (text "{% if metadata.tetratto_owner_username -%}")
; contact owner button ; contact owner button
(a (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") ("class" "button surface no_fill")
(text "{{ icon \"external-link\" }} Contact owner")) (text "{{ icon \"external-link\" }} Contact owner"))
(text "{%- endif %}") (text "{%- endif %}")
(a (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") ("class" "button surface no_fill")
(text "{{ icon \"external-link\" }} Submit request")) (text "{{ icon \"external-link\" }} Submit request"))
(text "{% else %}") (text "{% else %}")

View file

@ -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.id }}\", { fetch(\"/api/v1/entries/{{ entry.slug }}\", {
method: \"POST\", method: \"POST\",
headers: { headers: {
\"Content-Type\": \"application/json\", \"Content-Type\": \"application/json\",

View file

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

View file

@ -46,18 +46,16 @@
(text "Owner:") (text "Owner:")
(a (a
("class" "flex items_center gap_2") ("class" "flex items_center gap_2")
("href" "{{ config.service_hosts.tetratto }}/@{{ metadata.tetratto_owner_username }}") ("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}")
(text "{% if metadata.tetratto_owner_id -%}")
(img (img
("class" "avatar") ("class" "avatar")
("src" "{{ config.service_hosts.buckets }}/avatars/{{ metadata.tetratto_owner_id }}")) ("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username"))
(text "{%- endif %}")
(text "{{ metadata.tetratto_owner_username }}"))) (text "{{ metadata.tetratto_owner_username }}")))
(text "{%- endif %}") (text "{%- endif %}")
; views ; views
(text "{% if not metadata.option_disable_views -%}") (text "{% if not metadata.option_disable_views -%}")
(span (text "Views: {{ entry.views }}")) (span (text "Views: {{ views }}"))
(text "{%- endif %}") (text "{%- endif %}")
; easy-to-read ; 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")] #![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; use tetratto_core::{html, sdk::DataClient};
use tetratto_shared::hash::salt; use tetratto_shared::hash::salt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower_http::{ use tower_http::{
@ -20,7 +16,7 @@ use tower_http::{
}; };
use tracing::{Level, info}; 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>>; 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> {
@ -61,10 +57,10 @@ async fn main() {
}; };
// ... // ...
let database = DataManager::new(Config::read()) let database = DataClient::new(
.await Some(var("TETRATTO").unwrap_or("https://tetratto.com".to_string())),
.expect("failed to connect to database"); var("API_KEY").expect("API_KEY environment variable required"),
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");

View file

@ -2,15 +2,9 @@ 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,
@ -25,42 +19,6 @@ 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)]
@ -444,9 +402,6 @@ pub struct EntryMetadata {
#[serde(default, alias = "TETRATTO_OWNER_USERNAME")] #[serde(default, alias = "TETRATTO_OWNER_USERNAME")]
#[validate(max_length = 32)] #[validate(max_length = 32)]
pub tetratto_owner_username: String, 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 { macro_rules! metadata_css {
@ -603,40 +558,6 @@ impl EntryMetadata {
input.replace("}", "").replace(";", "").replace("/*", "") 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 { pub fn css(&self) -> String {
let mut output = "<style>".to_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-link" !important, self.content_link_color->output);
metadata_css!("*, html *", "--color-text" !important, self.content_text_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 { if self.content_text_align != TextAlignment::Left {
output.push_str(&format!( output.push_str(&format!(
".container {{ text-align: {}; }}\n", ".container {{ text-align: {}; }}\n",

View file

@ -1,6 +1,7 @@
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::{
@ -15,8 +16,19 @@ 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::model::{ApiReturn, Error}; use tetratto_core::{
use tetratto_shared::{hash::salt, unix_epoch_timestamp}; 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 { pub fn routes() -> Router {
Router::new() Router::new()
@ -32,21 +44,28 @@ 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(config: &Config, build_code: &str) -> Context { fn default_context(data: &DataClient, build_code: &str) -> Context {
let mut ctx = Context::new(); let mut ctx = Context::new();
ctx.insert("name", &config.name); ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
ctx.insert("theme_color", &config.theme_color); ctx.insert(
ctx.insert("config", &config); "theme_color",
ctx.insert("what_page_slug", &config.what_page_slug); &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( ctx.insert(
"tetratto_handler_account_username", "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.insert("build_code", &build_code);
ctx 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 { 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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert( ctx.insert(
"error", "error",
&Error::GeneralNotFound("page".to_string()).to_string(), &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 { 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.0.0, &build_code)) tera.render("index.lisp", &default_context(&data, &build_code))
.unwrap(), .unwrap(),
) )
} }
@ -80,7 +99,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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert( ctx.insert(
"error", "error",
&Error::GeneralNotFound("entry".to_string()).to_string(), &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) { 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.0.0, &build_code); let mut ctx = default_context(&data, &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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert("text", &text); ctx.insert("text", &text);
ctx.insert("file_name", &name); ctx.insert("file_name", &name);
@ -132,10 +151,19 @@ 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.get_entry_by_slug(&slug).await { let entry = match data
Ok(x) => x, .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(_) => { Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert( ctx.insert(
"error", "error",
&Error::GeneralNotFound("entry".to_string()).to_string(), &Error::GeneralNotFound("entry".to_string()).to_string(),
@ -156,7 +184,7 @@ async fn view_request(
}; };
if let Err(e) = metadata.validate() { 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()); ctx.insert("error", &e.to_string());
return ( return (
[viewed_header], [viewed_header],
@ -168,7 +196,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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry); ctx.insert("entry", &entry);
return ( return (
[viewed_header], [viewed_header],
@ -177,24 +205,56 @@ async fn view_request(
} }
// pull views // pull views
if jar.get("Atto-Viewed").is_none() { let views = if !metadata.option_disable_views {
// the Atto-Viewed cookie tells us if we've already viewed this match data
// entry recently (at all in the past week) .query(&SimplifiedQuery {
if let Err(e) = data.incr_entry_views(entry.id).await { query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
let mut ctx = default_context(&data.0.0, &build_code); mode: AppDataSelectMode::One(0),
ctx.insert("error", &e.to_string()); })
.await
{
Ok(r) => match r {
AppDataQueryResult::One(r) => {
// count view
let views = r.value.parse::<usize>().unwrap();
return ( if jar.get("Atto-Viewed").is_none() {
[viewed_header], // the Atto-Viewed cookie tells us if we've already viewed this
Html(tera.render("error.lisp", &ctx).unwrap()), // entry recently (at all in the past week)
); 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.0.0, &build_code); let mut ctx = default_context(&data, &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());
@ -224,10 +284,19 @@ 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.get_entry_by_slug(&slug).await { let entry = match data
Ok(x) => x, .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(_) => { Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert( ctx.insert(
"error", "error",
&Error::GeneralNotFound("entry".to_string()).to_string(), &Error::GeneralNotFound("entry".to_string()).to_string(),
@ -251,13 +320,13 @@ async fn editor_request(
} else { } else {
false false
} { } {
let mut ctx = default_context(&data.0.0, &build_code); let mut ctx = default_context(&data, &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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry); ctx.insert("entry", &entry);
ctx.insert("password", &props.key); ctx.insert("password", &props.key);
@ -274,10 +343,19 @@ 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.get_entry_by_slug(&slug).await { let entry = match data
Ok(x) => x, .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(_) => { Err(_) => {
let mut ctx = default_context(&data.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert( ctx.insert(
"error", "error",
&Error::GeneralNotFound("entry".to_string()).to_string(), &Error::GeneralNotFound("entry".to_string()).to_string(),
@ -295,13 +373,13 @@ async fn reclaim_request(
}; };
if let Err(e) = metadata.validate() { 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()); 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.0.0, &build_code); let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry); ctx.insert("entry", &entry);
ctx.insert("metadata", &metadata); ctx.insert("metadata", &metadata);
@ -346,10 +424,25 @@ async fn exists_request(
Json(ApiReturn { Json(ApiReturn {
ok: true, ok: true,
message: "Success".to_string(), 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)] #[derive(Deserialize)]
struct CreateEntry { struct CreateEntry {
content: String, content: String,
@ -365,6 +458,35 @@ 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;
@ -379,18 +501,18 @@ async fn create_request(
// get real ip // get real ip
let real_ip = headers 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("")) .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") {
@ -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 // create
let created = unix_epoch_timestamp();
let salt = salt();
if let Err(e) = data if let Err(e) = data
.create_entry(Entry::new( .insert(
req.slug.clone(), format!("entries('{}')", req.slug),
req.edit_code.clone(), serde_json::to_string(&Entry {
req.content, slug: req.slug.clone(),
req.metadata, edit_code: hash(req.edit_code.clone() + &salt),
real_ip, 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 .await
{ {
return Err(Json(e.into())); return Err(Json(e.into()));
@ -457,57 +656,216 @@ struct EditEntry {
async fn edit_request( async fn edit_request(
headers: HeaderMap, headers: HeaderMap,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(id): Path<usize>, Path(mut slug): Path<String>,
Json(req): Json<EditEntry>, Json(mut 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(&data.0.0.real_ip_header) .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
.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());
// } }
// } }
// handle delete // check content length
if req.delete { if req.content.len() < 2 {
return match data.delete_entry(id, req.edit_code).await { return Json(Error::DataTooShort("content".to_string()).into());
Ok(_) => Json(ApiReturn { }
ok: true,
message: "Success".to_string(), if req.content.len() > 150_000 {
payload: None, return Json(Error::DataTooLong("content".to_string()).into());
}), }
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),
})
.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),
})
.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),
})
.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(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
match data Json(ApiReturn {
.update_entry( ok: true,
id, message: "Success".to_string(),
req.edit_code, payload: Some(entry.slug),
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()),
}
} }