Compare commits
6 commits
Author | SHA1 | Date | |
---|---|---|---|
41f0319b5e | |||
8fd5103479 | |||
be9189a474 | |||
e647e54916 | |||
187508b8f3 | |||
24d9f17bd4 |
17 changed files with 746 additions and 517 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
||||||
/target
|
/target
|
||||||
|
app/fluffle.toml
|
||||||
|
migration.js
|
||||||
|
|
63
Cargo.lock
generated
63
Cargo.lock
generated
|
@ -243,9 +243,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.9.1"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
|
@ -542,9 +542,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "emojis"
|
name = "emojis"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
|
checksum = "f52f3d011046a013bdefbc63a5523b06ad0c0f1e227941baf98475496229d634"
|
||||||
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.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -607,13 +607,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fluffle"
|
name = "fluffle"
|
||||||
version = "0.4.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"glob",
|
"glob",
|
||||||
"nanoneo",
|
"nanoneo",
|
||||||
|
"oiseau",
|
||||||
"pathbufd",
|
"pathbufd",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -623,7 +624,7 @@ dependencies = [
|
||||||
"tetratto-core",
|
"tetratto-core",
|
||||||
"tetratto-shared",
|
"tetratto-shared",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.9.4",
|
"toml 0.9.5",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
@ -809,7 +810,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.1",
|
"bitflags 2.9.2",
|
||||||
"ignore",
|
"ignore",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
@ -1181,7 +1182,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.1",
|
"bitflags 2.9.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
@ -1494,7 +1495,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.1",
|
"bitflags 2.9.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
|
@ -1831,7 +1832,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.1",
|
"bitflags 2.9.2",
|
||||||
"getopts",
|
"getopts",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pulldown-cmark-escape",
|
"pulldown-cmark-escape",
|
||||||
|
@ -1963,7 +1964,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.1",
|
"bitflags 2.9.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2012,9 +2013,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.22"
|
version = "0.12.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
|
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
@ -2078,11 +2079,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.1",
|
"bitflags 2.9.2",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2166,7 +2167,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.1",
|
"bitflags 2.9.2",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
@ -2498,7 +2499,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.1",
|
"bitflags 2.9.2",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
|
@ -2523,7 +2524,7 @@ dependencies = [
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2561,14 +2562,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto-core"
|
name = "tetratto-core"
|
||||||
version = "12.0.2"
|
version = "15.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a367ac3ced8ff302080e1b4a82a67acd24fa606245c4381a6f77dbaaf6ef4b58"
|
checksum = "c7aeb9dcc5631ec6188bb9438dc97015c6662b6f59e650e5afa865775f170c9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"base16ct",
|
"base16ct",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.9.1",
|
"bitflags 2.9.2",
|
||||||
"emojis",
|
"emojis",
|
||||||
"md-5",
|
"md-5",
|
||||||
"oiseau",
|
"oiseau",
|
||||||
|
@ -2581,7 +2582,7 @@ dependencies = [
|
||||||
"tetratto-l10n",
|
"tetratto-l10n",
|
||||||
"tetratto-shared",
|
"tetratto-shared",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.9.4",
|
"toml 0.9.5",
|
||||||
"totp-rs",
|
"totp-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -2593,7 +2594,7 @@ checksum = "d96f5e41633c757e3519efb47c9b85d00d14322c1961360e126d0ecc0ea79b86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pathbufd",
|
"pathbufd",
|
||||||
"serde",
|
"serde",
|
||||||
"toml 0.9.4",
|
"toml 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2834,9 +2835,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.4"
|
version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
|
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -2881,9 +2882,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
|
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
@ -2939,7 +2940,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.1",
|
"bitflags 2.9.2",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
@ -3395,7 +3396,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.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3637,7 +3638,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.1",
|
"bitflags 2.9.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "fluffle"
|
name = "fluffle"
|
||||||
version = "0.4.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["trisuaso"]
|
authors = ["trisuaso"]
|
||||||
repository = "https://trisua.com/t/fluffle"
|
repository = "https://trisua.com/t/fluffle"
|
||||||
|
@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later"
|
||||||
homepage = "https://fluffle.cc"
|
homepage = "https://fluffle.cc"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tetratto-core = "12.0.2"
|
tetratto-core = "15.0.1"
|
||||||
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,3 +31,4 @@ serde_json = "1.0.142"
|
||||||
toml = "0.9.4"
|
toml = "0.9.4"
|
||||||
serde_valid = { version = "1.0.5", features = ["toml"] }
|
serde_valid = { version = "1.0.5", features = ["toml"] }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] }
|
||||||
|
|
20
README.md
20
README.md
|
@ -2,27 +2,11 @@
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
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:
|
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.
|
||||||
|
|
||||||
```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
|
||||||
|
|
||||||
|
@ -38,7 +22,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` environment variable.
|
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.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
|
|
|
@ -283,6 +283,14 @@ video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown .inner.left {
|
||||||
|
right: unset;
|
||||||
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown .inner.open {
|
.dropdown .inner.open {
|
||||||
|
@ -809,7 +817,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);
|
||||||
|
@ -823,3 +831,23 @@ dialog::backdrop {
|
||||||
dialog:is(.dark *)::backdrop {
|
dialog:is(.dark *)::backdrop {
|
||||||
background: hsla(0, 0%, 100%, 15%);
|
background: hsla(0, 0%, 100%, 15%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* menus */
|
||||||
|
menu {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu .button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu .button.active {
|
||||||
|
background: var(--color-super-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.col {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
|
@ -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 "{{ 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 "{{ config.service_hosts.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" "{{ tetratto }}/mail/compose?receivers={{ tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
|
("href" "{{ config.service_hosts.tetratto }}/mail/compose?receivers={{ metadata.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" "{{ tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
|
("href" "{{ config.service_hosts.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 %}")
|
||||||
|
|
|
@ -143,7 +143,7 @@
|
||||||
const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
|
const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
|
||||||
load();
|
load();
|
||||||
|
|
||||||
fetch(\"/api/v1/entries/{{ entry.slug }}\", {
|
fetch(\"/api/v1/entries/{{ entry.id }}\", {
|
||||||
method: \"POST\",
|
method: \"POST\",
|
||||||
headers: {
|
headers: {
|
||||||
\"Content-Type\": \"application/json\",
|
\"Content-Type\": \"application/json\",
|
||||||
|
|
|
@ -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" "{{ tetratto }}/css/utility.css?v={{ build_code }}"))
|
(link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css"))
|
||||||
(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")
|
("class" "inner left")
|
||||||
(a
|
(a
|
||||||
("class" "button")
|
("class" "button")
|
||||||
("href" "/")
|
("href" "/")
|
||||||
|
|
|
@ -46,16 +46,18 @@
|
||||||
(text "Owner:")
|
(text "Owner:")
|
||||||
(a
|
(a
|
||||||
("class" "flex items_center gap_2")
|
("class" "flex items_center gap_2")
|
||||||
("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}")
|
("href" "{{ config.service_hosts.tetratto }}/@{{ metadata.tetratto_owner_username }}")
|
||||||
|
(text "{% if metadata.tetratto_owner_id -%}")
|
||||||
(img
|
(img
|
||||||
("class" "avatar")
|
("class" "avatar")
|
||||||
("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username"))
|
("src" "{{ config.service_hosts.buckets }}/avatars/{{ metadata.tetratto_owner_id }}"))
|
||||||
|
(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: {{ views }}"))
|
(span (text "Views: {{ entry.views }}"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
|
||||||
; easy-to-read
|
; easy-to-read
|
||||||
|
|
115
src/config.rs
Normal file
115
src/config.rs
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
316
src/database/entries.rs
Normal file
316
src/database/entries.rs
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
use super::{DataManager, NAME_REGEX};
|
||||||
|
use crate::model::{Entry, EntryMetadata};
|
||||||
|
use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row};
|
||||||
|
use serde_valid::Validate;
|
||||||
|
use tetratto_core::{
|
||||||
|
auto_method,
|
||||||
|
model::{Error, Result},
|
||||||
|
};
|
||||||
|
use tetratto_shared::{hash::hash, unix_epoch_timestamp};
|
||||||
|
|
||||||
|
impl DataManager {
|
||||||
|
/// Get an [`Entry`] from an SQL row.
|
||||||
|
pub(crate) fn get_entry_from_row(x: &PostgresRow) -> Entry {
|
||||||
|
Entry {
|
||||||
|
id: get!(x->0(i64)) as usize,
|
||||||
|
slug: get!(x->1(String)),
|
||||||
|
edit_code: get!(x->2(String)),
|
||||||
|
salt: get!(x->3(String)),
|
||||||
|
created: get!(x->4(i64)) as usize,
|
||||||
|
edited: get!(x->5(i64)) as usize,
|
||||||
|
content: get!(x->6(String)),
|
||||||
|
metadata: get!(x->7(String)),
|
||||||
|
last_edit_from: get!(x->8(String)),
|
||||||
|
modify_code: get!(x->9(String)),
|
||||||
|
views: get!(x->10(i64)) as usize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_method!(get_entry_by_id(usize as i64)@get_entry_from_row -> "SELECT * FROM entries WHERE id = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
|
||||||
|
auto_method!(get_entry_by_slug(&str)@get_entry_from_row -> "SELECT * FROM entries WHERE slug = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
|
||||||
|
|
||||||
|
fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
|
||||||
|
// hash passwords
|
||||||
|
let do_update_metadata = (!metadata.option_view_password.is_empty()
|
||||||
|
|| !metadata.option_source_password.is_empty())
|
||||||
|
&& (!metadata.option_view_password.starts_with("h:")
|
||||||
|
|| !metadata.option_source_password.starts_with("h:"));
|
||||||
|
|
||||||
|
if !metadata.option_view_password.is_empty()
|
||||||
|
&& !metadata.option_view_password.starts_with("h:")
|
||||||
|
{
|
||||||
|
metadata.option_view_password =
|
||||||
|
format!("h:{}", hash(metadata.option_view_password.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !metadata.option_source_password.is_empty()
|
||||||
|
&& !metadata.option_source_password.starts_with("h:")
|
||||||
|
{
|
||||||
|
metadata.option_source_password =
|
||||||
|
format!("h:{}", hash(metadata.option_source_password.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if do_update_metadata {
|
||||||
|
if let Ok(x) = toml::to_string_pretty(&metadata) {
|
||||||
|
return (true, x);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(false, String::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new entry in the database.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `data` - a mock [`Entry`] object to insert
|
||||||
|
pub async fn create_entry(&self, mut data: Entry) -> Result<Entry> {
|
||||||
|
// check values
|
||||||
|
if data.slug.trim().len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("slug".to_string()));
|
||||||
|
} else if data.slug.len() > 128 {
|
||||||
|
return Err(Error::DataTooLong("slug".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.content.len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.content.len() > 150_000 {
|
||||||
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check characters
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&data.slug).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This slug contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for existing
|
||||||
|
if self.get_entry_by_slug(&data.slug).await.is_ok() {
|
||||||
|
return Err(Error::MiscError("Slug is already in use".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check metadata
|
||||||
|
let mut metadata: EntryMetadata =
|
||||||
|
match toml::from_str(&EntryMetadata::ini_to_toml(&data.metadata)) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => return Err(Error::MiscError(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = metadata.validate() {
|
||||||
|
return Err(Error::MiscError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
|
||||||
|
if do_update_metadata {
|
||||||
|
data.metadata = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"INSERT INTO entries VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||||
|
params![
|
||||||
|
&(data.id as i64),
|
||||||
|
&data.slug,
|
||||||
|
&data.edit_code,
|
||||||
|
&data.salt,
|
||||||
|
&(data.created as i64),
|
||||||
|
&(data.edited as i64),
|
||||||
|
&data.content,
|
||||||
|
&data.metadata,
|
||||||
|
&data.last_edit_from,
|
||||||
|
&data.modify_code,
|
||||||
|
&(data.views as i64)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing entry.
|
||||||
|
pub async fn update_entry(
|
||||||
|
&self,
|
||||||
|
id: usize,
|
||||||
|
edit_code: String,
|
||||||
|
mut new_slug: String,
|
||||||
|
new_content: String,
|
||||||
|
mut new_metadata: String,
|
||||||
|
mut new_edit_code: String,
|
||||||
|
mut new_modify_code: String,
|
||||||
|
by_ip: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
// check values
|
||||||
|
if !new_slug.is_empty() {
|
||||||
|
if new_slug.trim().len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("slug".to_string()));
|
||||||
|
} else if new_slug.len() > 128 {
|
||||||
|
return Err(Error::DataTooLong("slug".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content.len() < 2 {
|
||||||
|
return Err(Error::DataTooShort("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content.len() > 150_000 {
|
||||||
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check characters
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&new_slug).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This slug contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check metadata
|
||||||
|
let mut metadata: EntryMetadata =
|
||||||
|
match toml::from_str(&EntryMetadata::ini_to_toml(&new_metadata)) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => return Err(Error::MiscError(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = metadata.validate() {
|
||||||
|
return Err(Error::MiscError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
|
||||||
|
if do_update_metadata {
|
||||||
|
new_metadata = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get stored version of entry
|
||||||
|
let entry = self.get_entry_by_id(id).await?;
|
||||||
|
|
||||||
|
// check password
|
||||||
|
let using_modify = hash(edit_code.clone() + &entry.salt) == entry.modify_code;
|
||||||
|
|
||||||
|
if !using_modify && edit_code != self.0.0.master_pass {
|
||||||
|
if !entry.check_password(edit_code) {
|
||||||
|
return Err(Error::NotAllowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove cached
|
||||||
|
self.cache_clear_entry(&entry).await;
|
||||||
|
|
||||||
|
// hash junk
|
||||||
|
if !using_modify {
|
||||||
|
if new_slug.is_empty() {
|
||||||
|
// use original; no change
|
||||||
|
new_slug = entry.slug;
|
||||||
|
} else {
|
||||||
|
// make sure slug is all lowercase
|
||||||
|
new_slug = new_slug.to_lowercase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !new_edit_code.is_empty() {
|
||||||
|
new_edit_code = hash(new_edit_code + &entry.salt);
|
||||||
|
} else {
|
||||||
|
// use original; no change
|
||||||
|
new_edit_code = entry.edit_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !new_modify_code.is_empty() {
|
||||||
|
new_modify_code = hash(new_modify_code + &entry.salt);
|
||||||
|
} else {
|
||||||
|
// use original; no change
|
||||||
|
new_modify_code = entry.modify_code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// using modify code; no change
|
||||||
|
new_slug = entry.slug;
|
||||||
|
new_edit_code = entry.edit_code;
|
||||||
|
new_modify_code = entry.modify_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"UPDATE entries SET slug = $1, edit_code = $2, modify_code = $3, content = $4, metadata = $5, edited = $6, last_edit_from = $7 WHERE id = $8",
|
||||||
|
params![
|
||||||
|
&new_slug,
|
||||||
|
&new_edit_code,
|
||||||
|
&new_modify_code,
|
||||||
|
&new_content,
|
||||||
|
&new_metadata,
|
||||||
|
&(unix_epoch_timestamp() as i64),
|
||||||
|
&by_ip,
|
||||||
|
&(id as i64),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an existing entry.
|
||||||
|
pub async fn delete_entry(&self, id: usize, edit_code: String) -> Result<()> {
|
||||||
|
// get entry
|
||||||
|
let entry = self.get_entry_by_id(id).await?;
|
||||||
|
|
||||||
|
// check password
|
||||||
|
if edit_code != self.0.0.master_pass {
|
||||||
|
if !entry.check_password(edit_code) {
|
||||||
|
return Err(Error::NotAllowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = execute!(
|
||||||
|
&conn,
|
||||||
|
"DELETE FROM entries WHERE id = $1",
|
||||||
|
params![&(id as i64)]
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache_clear_entry(&entry).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an [`Entry`] from the cache.
|
||||||
|
pub async fn cache_clear_entry(&self, entry: &Entry) -> bool {
|
||||||
|
self.0.1.remove(format!("fluf.entry:{}", entry.id)).await
|
||||||
|
&& self.0.1.remove(format!("fluf.entry:{}", entry.slug)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_method!(incr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --incr);
|
||||||
|
auto_method!(decr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --decr=views);
|
||||||
|
}
|
30
src/database/mod.rs
Normal file
30
src/database/mod.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
mod entries;
|
||||||
|
mod sql;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult};
|
||||||
|
use tetratto_core::model::{Error, Result};
|
||||||
|
|
||||||
|
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DataManager(pub OiseauManager<Config>);
|
||||||
|
|
||||||
|
impl DataManager {
|
||||||
|
/// Create a new [`DataManager`].
|
||||||
|
pub async fn new(config: Config) -> PgResult<Self> {
|
||||||
|
Ok(Self(OiseauManager::new(config).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize tables.
|
||||||
|
pub async fn init(&self) -> Result<()> {
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
execute!(&conn, sql::CREATE_TABLE_ENTRIES).unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
13
src/database/sql/create_entries.sql
Normal file
13
src/database/sql/create_entries.sql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
edit_code TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
edited BIGINT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL,
|
||||||
|
last_edit_from TEXT NOT NULL,
|
||||||
|
modify_code TEXT NOT NULL,
|
||||||
|
views BIGINT NOT NULL
|
||||||
|
)
|
1
src/database/sql/mod.rs
Normal file
1
src/database/sql/mod.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub const CREATE_TABLE_ENTRIES: &str = include_str!("./create_entries.sql");
|
16
src/main.rs
16
src/main.rs
|
@ -1,13 +1,17 @@
|
||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.md")]
|
||||||
|
mod config;
|
||||||
|
mod database;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod model;
|
mod model;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
|
use crate::database::DataManager;
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
|
use config::Config;
|
||||||
use nanoneo::core::element::Render;
|
use nanoneo::core::element::Render;
|
||||||
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
|
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
|
||||||
use tera::{Tera, Value};
|
use tera::{Tera, Value};
|
||||||
use tetratto_core::{html, sdk::DataClient};
|
use tetratto_core::html;
|
||||||
use tetratto_shared::hash::salt;
|
use tetratto_shared::hash::salt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
@ -16,7 +20,7 @@ use tower_http::{
|
||||||
};
|
};
|
||||||
use tracing::{Level, info};
|
use tracing::{Level, info};
|
||||||
|
|
||||||
pub(crate) type InnerState = (DataClient, Tera, String);
|
pub(crate) type InnerState = (DataManager, Tera, String);
|
||||||
pub(crate) type State = Arc<RwLock<InnerState>>;
|
pub(crate) type State = Arc<RwLock<InnerState>>;
|
||||||
|
|
||||||
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
|
@ -57,10 +61,10 @@ async fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let database = DataClient::new(
|
let database = DataManager::new(Config::read())
|
||||||
Some(var("TETRATTO").unwrap_or("https://tetratto.com".to_string())),
|
.await
|
||||||
var("API_KEY").expect("API_KEY environment variable required"),
|
.expect("failed to connect to database");
|
||||||
);
|
database.init().await.expect("failed to init database");
|
||||||
|
|
||||||
// build lisp
|
// build lisp
|
||||||
create_dir_if_not_exists!("./templates_build");
|
create_dir_if_not_exists!("./templates_build");
|
||||||
|
|
90
src/model.rs
90
src/model.rs
|
@ -2,9 +2,15 @@ use crate::markdown::is_numeric;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_valid::Validate;
|
use serde_valid::Validate;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
use tetratto_shared::{
|
||||||
|
hash::{hash, salt},
|
||||||
|
snow::Snowflake,
|
||||||
|
unix_epoch_timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
|
pub id: usize,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub edit_code: String,
|
pub edit_code: String,
|
||||||
pub salt: String,
|
pub salt: String,
|
||||||
|
@ -19,6 +25,42 @@ pub struct Entry {
|
||||||
/// An edit code that can only be used to change the entry's content.
|
/// An edit code that can only be used to change the entry's content.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modify_code: String,
|
pub modify_code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub views: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry {
|
||||||
|
/// Create a new [`Entry`].
|
||||||
|
pub fn new(
|
||||||
|
slug: String,
|
||||||
|
edit_code: String,
|
||||||
|
content: String,
|
||||||
|
metadata: String,
|
||||||
|
last_edit_from: String,
|
||||||
|
) -> Self {
|
||||||
|
let salt = salt();
|
||||||
|
let edit_code = hash(edit_code.clone() + &salt);
|
||||||
|
let created = unix_epoch_timestamp();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||||
|
slug,
|
||||||
|
edit_code,
|
||||||
|
salt,
|
||||||
|
created,
|
||||||
|
edited: created,
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
last_edit_from,
|
||||||
|
modify_code: String::new(),
|
||||||
|
views: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the given password against the entry's stored password hash.
|
||||||
|
pub fn check_password(&self, supplied: String) -> bool {
|
||||||
|
hash(supplied + &self.salt) == self.edit_code
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
@ -402,6 +444,9 @@ 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 {
|
||||||
|
@ -558,6 +603,40 @@ 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();
|
||||||
|
|
||||||
|
@ -596,6 +675,17 @@ 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",
|
||||||
|
|
542
src/routes.rs
542
src/routes.rs
|
@ -1,7 +1,6 @@
|
||||||
use std::env::var;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
State,
|
State,
|
||||||
|
config::Config,
|
||||||
model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
|
model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -16,19 +15,8 @@ use pathbufd::PathBufD;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_valid::Validate;
|
use serde_valid::Validate;
|
||||||
use tera::Context;
|
use tera::Context;
|
||||||
use tetratto_core::{
|
use tetratto_core::model::{ApiReturn, Error};
|
||||||
model::{
|
use tetratto_shared::{hash::salt, unix_epoch_timestamp};
|
||||||
ApiReturn, Error,
|
|
||||||
apps::{AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery},
|
|
||||||
},
|
|
||||||
sdk::{DataClient, SimplifiedQuery},
|
|
||||||
};
|
|
||||||
use tetratto_shared::{
|
|
||||||
hash::{hash, salt},
|
|
||||||
unix_epoch_timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
|
|
||||||
|
|
||||||
pub fn routes() -> Router {
|
pub fn routes() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
@ -44,28 +32,21 @@ pub fn routes() -> Router {
|
||||||
.route("/{slug}/edit", get(editor_request))
|
.route("/{slug}/edit", get(editor_request))
|
||||||
.route("/{slug}/claim", get(reclaim_request))
|
.route("/{slug}/claim", get(reclaim_request))
|
||||||
// api
|
// api
|
||||||
.route("/api/v1/util/ip", get(util_ip))
|
|
||||||
.route("/api/v1/render", post(render_request))
|
.route("/api/v1/render", post(render_request))
|
||||||
.route("/api/v1/entries", post(create_request))
|
.route("/api/v1/entries", post(create_request))
|
||||||
.route("/api/v1/entries/{slug}", post(edit_request))
|
.route("/api/v1/entries/{slug}", post(edit_request))
|
||||||
.route("/api/v1/entries/{slug}", get(exists_request))
|
.route("/api/v1/entries/{slug}", get(exists_request))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_context(data: &DataClient, build_code: &str) -> Context {
|
fn default_context(config: &Config, build_code: &str) -> Context {
|
||||||
let mut ctx = Context::new();
|
let mut ctx = Context::new();
|
||||||
ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
|
ctx.insert("name", &config.name);
|
||||||
ctx.insert(
|
ctx.insert("theme_color", &config.theme_color);
|
||||||
"theme_color",
|
ctx.insert("config", &config);
|
||||||
&var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()),
|
ctx.insert("what_page_slug", &config.what_page_slug);
|
||||||
);
|
|
||||||
ctx.insert("tetratto", &data.host);
|
|
||||||
ctx.insert(
|
|
||||||
"what_page_slug",
|
|
||||||
&var("WHAT_SLUG").unwrap_or("what".to_string()),
|
|
||||||
);
|
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"tetratto_handler_account_username",
|
"tetratto_handler_account_username",
|
||||||
&var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()),
|
&config.tetratto_handler_account_username,
|
||||||
);
|
);
|
||||||
ctx.insert("build_code", &build_code);
|
ctx.insert("build_code", &build_code);
|
||||||
ctx
|
ctx
|
||||||
|
@ -75,7 +56,7 @@ fn default_context(data: &DataClient, build_code: &str) -> Context {
|
||||||
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("page".to_string()).to_string(),
|
&Error::GeneralNotFound("page".to_string()).to_string(),
|
||||||
|
@ -86,7 +67,7 @@ async fn not_found_request(Extension(data): Extension<State>) -> impl IntoRespon
|
||||||
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
Html(
|
Html(
|
||||||
tera.render("index.lisp", &default_context(&data, &build_code))
|
tera.render("index.lisp", &default_context(&data.0.0, &build_code))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -99,7 +80,7 @@ async fn view_doc_request(
|
||||||
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
|
let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
|
||||||
|
|
||||||
if !std::fs::exists(&path).unwrap_or(false) {
|
if !std::fs::exists(&path).unwrap_or(false) {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -110,13 +91,13 @@ async fn view_doc_request(
|
||||||
let text = match std::fs::read_to_string(&path) {
|
let text = match std::fs::read_to_string(&path) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
|
ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
|
||||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("text", &text);
|
ctx.insert("text", &text);
|
||||||
ctx.insert("file_name", &name);
|
ctx.insert("file_name", &name);
|
||||||
|
@ -151,19 +132,10 @@ async fn view_request(
|
||||||
format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
|
format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -184,7 +156,7 @@ async fn view_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
if let Err(e) = metadata.validate() {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &e.to_string());
|
ctx.insert("error", &e.to_string());
|
||||||
return (
|
return (
|
||||||
[viewed_header],
|
[viewed_header],
|
||||||
|
@ -196,7 +168,7 @@ async fn view_request(
|
||||||
if !metadata.option_view_password.is_empty()
|
if !metadata.option_view_password.is_empty()
|
||||||
&& metadata.option_view_password != props.key.clone()
|
&& metadata.option_view_password != props.key.clone()
|
||||||
{
|
{
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
return (
|
return (
|
||||||
[viewed_header],
|
[viewed_header],
|
||||||
|
@ -205,56 +177,24 @@ async fn view_request(
|
||||||
}
|
}
|
||||||
|
|
||||||
// pull views
|
// pull views
|
||||||
let views = if !metadata.option_disable_views {
|
if jar.get("Atto-Viewed").is_none() {
|
||||||
match data
|
// the Atto-Viewed cookie tells us if we've already viewed this
|
||||||
.query(&SimplifiedQuery {
|
// entry recently (at all in the past week)
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
if let Err(e) = data.incr_entry_views(entry.id).await {
|
||||||
mode: AppDataSelectMode::One(0),
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
})
|
ctx.insert("error", &e.to_string());
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => {
|
|
||||||
// count view
|
|
||||||
let views = r.value.parse::<usize>().unwrap();
|
|
||||||
|
|
||||||
if jar.get("Atto-Viewed").is_none() {
|
return (
|
||||||
// the Atto-Viewed cookie tells us if we've already viewed this
|
[viewed_header],
|
||||||
// entry recently (at all in the past week)
|
Html(tera.render("error.lisp", &ctx).unwrap()),
|
||||||
if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
|
);
|
||||||
let mut ctx = default_context(&data, &build_code);
|
|
||||||
ctx.insert("error", &e.to_string());
|
|
||||||
|
|
||||||
return (
|
|
||||||
[viewed_header],
|
|
||||||
Html(tera.render("error.lisp", &ctx).unwrap()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
views
|
|
||||||
}
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
let mut ctx = default_context(&data, &build_code);
|
|
||||||
ctx.insert("error", &e.to_string());
|
|
||||||
|
|
||||||
return (
|
|
||||||
[viewed_header],
|
|
||||||
Html(tera.render("error.lisp", &ctx).unwrap()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("views", &views);
|
|
||||||
ctx.insert("metadata", &metadata);
|
ctx.insert("metadata", &metadata);
|
||||||
ctx.insert("metadata_head", &metadata.head_tags());
|
ctx.insert("metadata_head", &metadata.head_tags());
|
||||||
ctx.insert("metadata_css", &metadata.css());
|
ctx.insert("metadata_css", &metadata.css());
|
||||||
|
@ -284,19 +224,10 @@ async fn editor_request(
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
slug = slug.to_lowercase();
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -320,13 +251,13 @@ async fn editor_request(
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
} {
|
} {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
return Html(tera.render("password.lisp", &ctx).unwrap());
|
return Html(tera.render("password.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("password", &props.key);
|
ctx.insert("password", &props.key);
|
||||||
|
@ -343,19 +274,10 @@ async fn reclaim_request(
|
||||||
let (ref data, ref tera, ref build_code) = *data.read().await;
|
let (ref data, ref tera, ref build_code) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
slug = slug.to_lowercase();
|
||||||
|
|
||||||
let entry = match data
|
let entry = match data.get_entry_by_slug(&slug).await {
|
||||||
.query(&SimplifiedQuery {
|
Ok(x) => x,
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r {
|
|
||||||
AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
|
|
||||||
AppDataQueryResult::Many(_) => unreachable!(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert(
|
ctx.insert(
|
||||||
"error",
|
"error",
|
||||||
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
&Error::GeneralNotFound("entry".to_string()).to_string(),
|
||||||
|
@ -373,13 +295,13 @@ async fn reclaim_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
if let Err(e) = metadata.validate() {
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
ctx.insert("error", &e.to_string());
|
ctx.insert("error", &e.to_string());
|
||||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let mut ctx = default_context(&data, &build_code);
|
let mut ctx = default_context(&data.0.0, &build_code);
|
||||||
|
|
||||||
ctx.insert("entry", &entry);
|
ctx.insert("entry", &entry);
|
||||||
ctx.insert("metadata", &metadata);
|
ctx.insert("metadata", &metadata);
|
||||||
|
@ -424,25 +346,10 @@ async fn exists_request(
|
||||||
Json(ApiReturn {
|
Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Success".to_string(),
|
message: "Success".to_string(),
|
||||||
payload: data
|
payload: data.get_entry_by_slug(&slug).await.is_ok(),
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.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,
|
||||||
|
@ -458,35 +365,6 @@ fn default_random() -> String {
|
||||||
salt()
|
salt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
|
|
||||||
// hash passwords
|
|
||||||
let do_update_metadata = (!metadata.option_view_password.is_empty()
|
|
||||||
|| !metadata.option_source_password.is_empty())
|
|
||||||
&& (!metadata.option_view_password.starts_with("h:")
|
|
||||||
|| !metadata.option_source_password.starts_with("h:"));
|
|
||||||
|
|
||||||
if !metadata.option_view_password.is_empty() && !metadata.option_view_password.starts_with("h:")
|
|
||||||
{
|
|
||||||
metadata.option_view_password =
|
|
||||||
format!("h:{}", hash(metadata.option_view_password.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !metadata.option_source_password.is_empty()
|
|
||||||
&& !metadata.option_source_password.starts_with("h:")
|
|
||||||
{
|
|
||||||
metadata.option_source_password =
|
|
||||||
format!("h:{}", hash(metadata.option_source_password.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if do_update_metadata {
|
|
||||||
if let Ok(x) = toml::to_string_pretty(&metadata) {
|
|
||||||
return (true, x);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
(false, String::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The time that must be waited between each entry creation.
|
/// The time that must be waited between each entry creation.
|
||||||
const CREATE_WAIT_TIME: usize = 15000;
|
const CREATE_WAIT_TIME: usize = 15000;
|
||||||
|
|
||||||
|
@ -501,18 +379,18 @@ async fn create_request(
|
||||||
|
|
||||||
// get real ip
|
// get real ip
|
||||||
let real_ip = headers
|
let real_ip = headers
|
||||||
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
.get(&data.0.0.real_ip_header)
|
||||||
.unwrap_or(&HeaderValue::from_static(""))
|
.unwrap_or(&HeaderValue::from_static(""))
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// check for ip ban
|
// check for ip ban
|
||||||
if !real_ip.is_empty() {
|
// if !real_ip.is_empty() {
|
||||||
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
||||||
return Err(Json(Error::NotAllowed.into()));
|
// return Err(Json(Error::NotAllowed.into()));
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// check wait time
|
// check wait time
|
||||||
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
|
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
|
||||||
|
@ -529,92 +407,15 @@ async fn create_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check lengths
|
|
||||||
if req.slug.len() < 2 {
|
|
||||||
return Err(Json(Error::DataTooShort("slug".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.slug.len() > 32 {
|
|
||||||
return Err(Json(Error::DataTooLong("slug".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.content.len() < 2 {
|
|
||||||
return Err(Json(Error::DataTooShort("content".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.content.len() > 150_000 {
|
|
||||||
return Err(Json(Error::DataTooLong("content".to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// check slug
|
|
||||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
|
||||||
.multi_line(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if regex.captures(&req.slug).is_some() {
|
|
||||||
return Err(Json(
|
|
||||||
Error::MiscError("This slug contains invalid characters".to_string()).into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// check metadata
|
|
||||||
let mut metadata: EntryMetadata =
|
|
||||||
match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
|
|
||||||
Ok(x) => x,
|
|
||||||
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
|
||||||
return Err(Json(Error::MiscError(e.to_string()).into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (do_update_metadata, updated) = hash_passwords(&mut metadata);
|
|
||||||
if do_update_metadata {
|
|
||||||
req.metadata = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for existing
|
|
||||||
if data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
return Err(Json(
|
|
||||||
Error::MiscError("Slug already in use".to_string()).into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// create
|
// create
|
||||||
let created = unix_epoch_timestamp();
|
|
||||||
let salt = salt();
|
|
||||||
|
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.insert(
|
.create_entry(Entry::new(
|
||||||
format!("entries('{}')", req.slug),
|
req.slug.clone(),
|
||||||
serde_json::to_string(&Entry {
|
req.edit_code.clone(),
|
||||||
slug: req.slug.clone(),
|
req.content,
|
||||||
edit_code: hash(req.edit_code.clone() + &salt),
|
req.metadata,
|
||||||
salt,
|
real_ip,
|
||||||
created,
|
))
|
||||||
edited: created,
|
|
||||||
content: req.content,
|
|
||||||
metadata: req.metadata,
|
|
||||||
last_edit_from: real_ip,
|
|
||||||
modify_code: String::new(),
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Err(Json(e.into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.insert(format!("entries.views('{}')", req.slug), 0.to_string())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(Json(e.into()));
|
return Err(Json(e.into()));
|
||||||
|
@ -656,216 +457,57 @@ struct EditEntry {
|
||||||
async fn edit_request(
|
async fn edit_request(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Path(mut slug): Path<String>,
|
Path(id): Path<usize>,
|
||||||
Json(mut req): Json<EditEntry>,
|
Json(req): Json<EditEntry>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let (ref data, _, _) = *data.read().await;
|
let (ref data, _, _) = *data.read().await;
|
||||||
slug = slug.to_lowercase();
|
|
||||||
|
|
||||||
// get real ip
|
// get real ip
|
||||||
let real_ip = headers
|
let real_ip = headers
|
||||||
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
.get(&data.0.0.real_ip_header)
|
||||||
.unwrap_or(&HeaderValue::from_static(""))
|
.unwrap_or(&HeaderValue::from_static(""))
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// check for ip ban
|
// check for ip ban
|
||||||
if !real_ip.is_empty() {
|
// if !real_ip.is_empty() {
|
||||||
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
// if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
||||||
return Json(Error::NotAllowed.into());
|
// return Json(Error::NotAllowed.into());
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// check content length
|
// handle delete
|
||||||
if req.content.len() < 2 {
|
if req.delete {
|
||||||
return Json(Error::DataTooShort("content".to_string()).into());
|
return match data.delete_entry(id, req.edit_code).await {
|
||||||
}
|
Ok(_) => Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
if req.content.len() > 150_000 {
|
message: "Success".to_string(),
|
||||||
return Json(Error::DataTooLong("content".to_string()).into());
|
payload: None,
|
||||||
}
|
}),
|
||||||
|
Err(e) => return Json(e.into()),
|
||||||
// check metadata
|
|
||||||
let mut metadata: EntryMetadata =
|
|
||||||
match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
|
|
||||||
Ok(x) => x,
|
|
||||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = metadata.validate() {
|
|
||||||
return Json(Error::MiscError(e.to_string()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (do_update_metadata, updated) = hash_passwords(&mut metadata);
|
|
||||||
if do_update_metadata {
|
|
||||||
req.metadata = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
let (id, mut entry) = match data
|
|
||||||
.query(&SimplifiedQuery {
|
|
||||||
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
|
|
||||||
mode: AppDataSelectMode::One(0),
|
|
||||||
})
|
|
||||||
.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
|
||||||
Json(ApiReturn {
|
match data
|
||||||
ok: true,
|
.update_entry(
|
||||||
message: "Success".to_string(),
|
id,
|
||||||
payload: Some(entry.slug),
|
req.edit_code,
|
||||||
})
|
req.new_slug.unwrap_or_default(),
|
||||||
|
req.content,
|
||||||
|
req.metadata,
|
||||||
|
req.new_edit_code.unwrap_or_default(),
|
||||||
|
req.new_modify_code.unwrap_or_default(),
|
||||||
|
real_ip,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(x) => Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "Success".to_string(),
|
||||||
|
payload: Some(x),
|
||||||
|
}),
|
||||||
|
Err(e) => Json(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue