Compare commits
93 commits
Author | SHA1 | Date | |
---|---|---|---|
46e38042ce | |||
29155ddb0c | |||
a337e0c7c1 | |||
e78c43ab62 | |||
8786cb4781 | |||
9aed5de097 | |||
c757ddb77a | |||
46849ba66c | |||
fe2e61118a | |||
3f70a8f465 | |||
55460fc60a | |||
d58e47cbbe | |||
270d7550d6 | |||
6f2d556c65 | |||
35b66c94d0 | |||
7d30d65a3b | |||
fe1e53c47a | |||
f05074ffc5 | |||
63d3c2350d | |||
9ccbc69405 | |||
0138bf4cd4 | |||
884a89904e | |||
02f3d08926 | |||
636ecce9f4 | |||
e393221b4f | |||
22aea48cc5 | |||
9f61d9ce6a | |||
440ca81c25 | |||
f423daf2fc | |||
5c520f4308 | |||
f802a1c8ab | |||
d1c3643574 | |||
b25bda29b8 | |||
0256f38e5d | |||
70ecc6f96e | |||
959a125992 | |||
8dfd307919 | |||
e0e38b2b32 | |||
3b5b0ce1a1 | |||
292d302304 | |||
052ddf862f | |||
73d8e9ab49 | |||
2c83ed3d9d | |||
f94570f74c | |||
cf2af1e1e9 | |||
2be2409d66 | |||
ea13526515 | |||
2705608903 | |||
aea764948c | |||
e4468e4768 | |||
fdaa81422a | |||
227cd3d2ac | |||
6af56ed2b2 | |||
4d49fc3cdf | |||
cfcc2358f4 | |||
9aee80493f | |||
14f3bf849e | |||
bdd8f9a869 | |||
4e152b07be | |||
7960f1ed41 | |||
69067145ce | |||
7ead0ce775 | |||
22a2545aa0 | |||
e72ccf9139 | |||
65e5d5f4e9 | |||
388ccbf58c | |||
e7febc7c7e | |||
78c9b3349d | |||
4ebd7e6c2b | |||
d67e7c9c33 | |||
3fc0872867 | |||
c4de17058b | |||
07a23f505b | |||
9ba6320d46 | |||
e5b6b5a4d4 | |||
1dc0611298 | |||
2ec8d86edf | |||
0aa2ea362f | |||
ee2f7c7cbb | |||
b493b2ade8 | |||
c83d0a9fc0 | |||
0634819278 | |||
973373426a | |||
d90b08720a | |||
d6348f7d67 | |||
f5faed7762 | |||
14936b8b90 | |||
b501a7c5f0 | |||
50f4592de2 | |||
0272985b81 | |||
0163391380 | |||
a799c777ea | |||
8d70f65863 |
184 changed files with 10608 additions and 1075 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
debug/
|
||||
.dev
|
||||
|
|
229
Cargo.lock
generated
229
Cargo.lock
generated
|
@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
|
|||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364"
|
||||
checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
|
@ -337,12 +337,6 @@ dependencies = [
|
|||
"tokio-postgres",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bberry"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.2"
|
||||
|
@ -488,7 +482,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -498,7 +492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
|
@ -648,7 +642,7 @@ dependencies = [
|
|||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
|
@ -728,11 +722,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|||
|
||||
[[package]]
|
||||
name = "emojis"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4"
|
||||
checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -961,6 +955,15 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
|
@ -1124,12 +1127,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.31.0"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c"
|
||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
@ -1576,6 +1578,17 @@ dependencies = [
|
|||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
|
@ -1739,20 +1752,11 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
|
||||
dependencies = [
|
||||
"unicode-id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.16.1"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a"
|
||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
|
@ -1761,9 +1765,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -1871,6 +1875,12 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanoneo"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
|
@ -2164,7 +2174,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2174,7 +2193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2183,7 +2202,7 @@ version = "0.11.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
|
@ -2194,7 +2213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
|
@ -2209,6 +2228,15 @@ dependencies = [
|
|||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
|
@ -2327,6 +2355,25 @@ dependencies = [
|
|||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
|
@ -2621,9 +2668,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
|||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.20"
|
||||
version = "0.12.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
|
@ -2641,6 +2688,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
|
@ -2856,9 +2904,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
version = "1.0.141"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
|
@ -2907,6 +2955,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
@ -3067,7 +3124,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
|||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
@ -3079,7 +3136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
@ -3173,7 +3230,7 @@ dependencies = [
|
|||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
|
@ -3231,19 +3288,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "10.0.0"
|
||||
version = "12.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"bberry",
|
||||
"cf-turnstile",
|
||||
"contrasted",
|
||||
"cookie",
|
||||
"emojis",
|
||||
"futures-util",
|
||||
"image",
|
||||
"mime_guess",
|
||||
"nanoneo",
|
||||
"pathbufd",
|
||||
"regex",
|
||||
"reqwest",
|
||||
|
@ -3262,7 +3320,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "10.0.0"
|
||||
version = "12.0.2"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
@ -3271,6 +3329,7 @@ dependencies = [
|
|||
"emojis",
|
||||
"md-5",
|
||||
"oiseau",
|
||||
"paste",
|
||||
"pathbufd",
|
||||
"regex",
|
||||
"reqwest",
|
||||
|
@ -3278,28 +3337,30 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tetratto-l10n",
|
||||
"tetratto-shared",
|
||||
"toml",
|
||||
"tokio",
|
||||
"toml 0.9.2",
|
||||
"totp-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tetratto-l10n"
|
||||
version = "10.0.0"
|
||||
version = "12.0.0"
|
||||
dependencies = [
|
||||
"pathbufd",
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tetratto-shared"
|
||||
version = "10.0.0"
|
||||
version = "12.0.6"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"chrono",
|
||||
"hex_fmt",
|
||||
"markdown",
|
||||
"pulldown-cmark",
|
||||
"rand 0.9.1",
|
||||
"regex",
|
||||
"serde",
|
||||
"sha2",
|
||||
"snowflaked",
|
||||
|
@ -3425,16 +3486,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.45.1"
|
||||
version = "1.46.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
|
||||
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.52.0",
|
||||
|
@ -3476,7 +3539,7 @@ dependencies = [
|
|||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
|
@ -3529,11 +3592,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 1.0.0",
|
||||
"toml_datetime 0.7.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
|
@ -3543,6 +3621,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
|
@ -3551,17 +3638,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
name = "toml_parser"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
|
@ -3796,12 +3891,6 @@ version = "0.3.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-id"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
|
@ -3823,6 +3912,12 @@ version = "0.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
@ -4066,7 +4161,7 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
|
|
|
@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"]
|
|||
package.authors = ["trisuaso"]
|
||||
package.repository = "https://trisua.com/t/tetratto"
|
||||
package.license = "AGPL-3.0-or-later"
|
||||
package.homepage = "https://tetratto.com"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
|
|
@ -35,6 +35,8 @@ A `docs` directory will be generated in the same directory that you ran the `tet
|
|||
|
||||
You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries.
|
||||
|
||||
You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects.
|
||||
|
||||
## Usage (as a user)
|
||||
|
||||
Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "10.0.0"
|
||||
version = "12.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pathbufd = "0.1.4"
|
||||
|
@ -9,19 +13,23 @@ serde = { version = "1.0.219", features = ["derive"] }
|
|||
tera = "1.20.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] }
|
||||
tower-http = { version = "0.6.6", features = [
|
||||
"trace",
|
||||
"fs",
|
||||
"catch-panic",
|
||||
"set-header",
|
||||
] }
|
||||
axum = { version = "0.8.4", features = ["macros", "ws"] }
|
||||
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
|
||||
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
|
||||
ammonia = "4.1.0"
|
||||
ammonia = "4.1.1"
|
||||
tetratto-shared = { path = "../shared" }
|
||||
tetratto-core = { path = "../core" }
|
||||
tetratto-l10n = { path = "../l10n" }
|
||||
|
||||
image = "0.25.6"
|
||||
reqwest = { version = "0.12.20", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12.22", features = ["json", "stream"] }
|
||||
regex = "1.11.1"
|
||||
serde_json = "1.0.140"
|
||||
serde_json = "1.0.141"
|
||||
mime_guess = "2.0.5"
|
||||
cf-turnstile = "0.2.0"
|
||||
contrasted = "0.1.3"
|
||||
|
@ -32,7 +40,9 @@ async-stripe = { version = "0.41.0", features = [
|
|||
"webhook-events",
|
||||
"billing",
|
||||
"runtime-tokio-hyper",
|
||||
"connect",
|
||||
] }
|
||||
emojis = "0.6.4"
|
||||
emojis = "0.7.0"
|
||||
webp = "0.3.0"
|
||||
bberry = "0.2.0"
|
||||
nanoneo = "0.2.0"
|
||||
cookie = "0.18.1"
|
||||
|
|
|
@ -1,21 +1,17 @@
|
|||
use bberry::{
|
||||
use nanoneo::{
|
||||
core::element::{Element, Render},
|
||||
text, read_param,
|
||||
};
|
||||
use pathbufd::PathBufD;
|
||||
use regex::Regex;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{exists, read_to_string, write},
|
||||
sync::LazyLock,
|
||||
time::SystemTime,
|
||||
};
|
||||
use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime};
|
||||
use tera::Context;
|
||||
use tetratto_core::{
|
||||
config::Config,
|
||||
html::{pull_icons, ICONS},
|
||||
model::{
|
||||
auth::{DefaultTimelineChoice, User},
|
||||
permissions::FinePermission,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
},
|
||||
};
|
||||
use tetratto_l10n::LangFile;
|
||||
|
@ -40,6 +36,8 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
|
|||
pub const ME_JS: &str = include_str!("./public/js/me.js");
|
||||
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
|
||||
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
|
||||
pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js");
|
||||
pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js");
|
||||
|
||||
// html
|
||||
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
||||
|
@ -57,6 +55,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
|
|||
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
|
||||
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp");
|
||||
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp");
|
||||
pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp");
|
||||
|
||||
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp");
|
||||
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp");
|
||||
|
@ -70,6 +69,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp
|
|||
pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp");
|
||||
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
|
||||
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
|
||||
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
|
||||
|
||||
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
|
||||
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
|
||||
|
@ -131,6 +131,14 @@ pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp
|
|||
|
||||
pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp");
|
||||
|
||||
pub const LITTLEWEB_SERVICES: &str = include_str!("./public/html/littleweb/services.lisp");
|
||||
pub const LITTLEWEB_DOMAINS: &str = include_str!("./public/html/littleweb/domains.lisp");
|
||||
pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/service.lisp");
|
||||
pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp");
|
||||
pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp");
|
||||
|
||||
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
|
||||
|
||||
// langs
|
||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||
|
||||
|
@ -138,44 +146,13 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
|||
|
||||
pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg");
|
||||
pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg");
|
||||
pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg");
|
||||
|
||||
pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp");
|
||||
|
||||
pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
|
||||
LazyLock::new(|| RwLock::new(String::new()));
|
||||
|
||||
/// A container for all loaded icons.
|
||||
pub(crate) static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// Pull an icon given its name and insert it into [`ICONS`].
|
||||
pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
|
||||
let writer = &mut ICONS.write().await;
|
||||
|
||||
let icon_url = format!(
|
||||
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
|
||||
);
|
||||
|
||||
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
|
||||
|
||||
if exists(&file_path).unwrap() {
|
||||
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
|
||||
return;
|
||||
}
|
||||
|
||||
println!("download icon: {icon}");
|
||||
let svg = reqwest::get(icon_url)
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.replace("\n", "");
|
||||
|
||||
write(&file_path, &svg).unwrap();
|
||||
writer.insert(icon.to_string(), svg);
|
||||
}
|
||||
|
||||
macro_rules! vendor_icon {
|
||||
($name:literal, $icon:ident, $icons_dir:expr) => {{
|
||||
let writer = &mut ICONS.write().await;
|
||||
|
@ -228,7 +205,7 @@ pub(crate) async fn replace_in_html(
|
|||
input.to_string()
|
||||
} else {
|
||||
let start = SystemTime::now();
|
||||
let parsed = bberry::parse(input);
|
||||
let parsed = nanoneo::parse(input);
|
||||
println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros());
|
||||
|
||||
if let Some(plugins) = plugins {
|
||||
|
@ -248,56 +225,8 @@ pub(crate) async fn replace_in_html(
|
|||
input = input.replace(cap.get(0).unwrap().as_str(), &replace_with);
|
||||
}
|
||||
|
||||
// icon (with class)
|
||||
let icon_with_class =
|
||||
Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_with_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, &config.dirs.icons).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader.get(icon).unwrap().replace(
|
||||
"<svg",
|
||||
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
|
||||
);
|
||||
|
||||
input = input.replace(
|
||||
&format!(
|
||||
"{{{{ icon \"{cap_str}\" c({}) }}}}",
|
||||
cap.get(4).unwrap().as_str()
|
||||
),
|
||||
&icon_text,
|
||||
);
|
||||
}
|
||||
|
||||
// icon (without class)
|
||||
let icon_without_class = Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
|
||||
|
||||
for cap in icon_without_class.captures_iter(&input.clone()) {
|
||||
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
|
||||
let icon = &(if cap_str.contains(" }}") {
|
||||
cap_str.split(" }}").next().unwrap().to_string()
|
||||
} else {
|
||||
cap_str.to_string()
|
||||
});
|
||||
|
||||
pull_icon(icon, &config.dirs.icons).await;
|
||||
|
||||
let reader = ICONS.read().await;
|
||||
let icon_text = reader
|
||||
.get(icon)
|
||||
.unwrap()
|
||||
.replace("<svg", "<svg class=\"icon\"");
|
||||
|
||||
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
|
||||
}
|
||||
// icons
|
||||
input = pull_icons(input, &config.dirs.icons).await;
|
||||
|
||||
// return
|
||||
input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1);
|
||||
|
@ -335,6 +264,7 @@ pub(crate) fn lisp_plugins() -> HashMap<String, Box<dyn FnMut(Element) -> Elemen
|
|||
pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
||||
vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons);
|
||||
vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons);
|
||||
vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons);
|
||||
bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets);
|
||||
|
||||
// ...
|
||||
|
@ -356,6 +286,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins);
|
||||
|
@ -369,6 +300,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
|
||||
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
|
||||
|
@ -425,6 +357,14 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
|
||||
write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"littleweb/services.html"(crate::assets::LITTLEWEB_SERVICES) -d "littleweb" --config=config --lisp plugins);
|
||||
write_template!(html_path->"littleweb/domains.html"(crate::assets::LITTLEWEB_DOMAINS) --config=config --lisp plugins);
|
||||
write_template!(html_path->"littleweb/service.html"(crate::assets::LITTLEWEB_SERVICE) --config=config --lisp plugins);
|
||||
write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins);
|
||||
write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
|
||||
|
||||
html_path
|
||||
}
|
||||
|
||||
|
@ -492,6 +432,11 @@ pub(crate) async fn initial_context(
|
|||
"is_supporter",
|
||||
&ua.permissions.check(FinePermission::SUPPORTER),
|
||||
);
|
||||
ctx.insert(
|
||||
"has_developer_pass",
|
||||
&ua.secondary_permissions
|
||||
.check(SecondaryPermission::DEVELOPER_PASS),
|
||||
);
|
||||
ctx.insert("home", &ua.settings.default_timeline.relative_url());
|
||||
} else {
|
||||
ctx.insert("is_helper", &false);
|
||||
|
|
68
crates/app/src/cookie.rs
Normal file
68
crates/app/src/cookie.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use std::convert::Infallible;
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, HeaderMap},
|
||||
};
|
||||
use cookie::{Cookie, CookieJar as CookieCookieJar};
|
||||
|
||||
/// This is required because "Cookie" his a forbidden header for some fucking reason.
|
||||
/// Stupidest thing I've ever encountered in JavaScript, absolute fucking insanity.
|
||||
///
|
||||
/// Anyway, most of this shit is just from the original source for axum_extra::extract::CookieJar,
|
||||
/// just edited to use X-Cookie instead.
|
||||
///
|
||||
/// Stuff from axum_extra will have links to the original provided.
|
||||
pub struct CookieJar {
|
||||
jar: CookieCookieJar,
|
||||
}
|
||||
|
||||
/// <https://docs.rs/axum-extra/latest/src/axum_extra/extract/cookie/mod.rs.html#92-101>
|
||||
impl<S> FromRequestParts<S> for CookieJar
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(Self::from_headers(&parts.headers))
|
||||
}
|
||||
}
|
||||
|
||||
fn cookies_from_request(
|
||||
header: String,
|
||||
headers: &HeaderMap,
|
||||
) -> impl Iterator<Item = Cookie<'static>> + '_ {
|
||||
headers
|
||||
.get_all(header)
|
||||
.into_iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(';'))
|
||||
.filter_map(|cookie| Cookie::parse_encoded(cookie.to_owned()).ok())
|
||||
}
|
||||
|
||||
impl CookieJar {
|
||||
/// <https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html#method.from_headers>
|
||||
///
|
||||
/// Modified only to prefer "X-Cookie" header.
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
let mut jar = CookieCookieJar::new();
|
||||
|
||||
for cookie in cookies_from_request(
|
||||
if headers.contains_key("X-Cookie") {
|
||||
"X-Cookie".to_string()
|
||||
} else {
|
||||
"Cookie".to_string()
|
||||
},
|
||||
headers,
|
||||
) {
|
||||
jar.add_original(cookie.clone());
|
||||
}
|
||||
|
||||
Self { jar }
|
||||
}
|
||||
|
||||
/// <https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html#method.get>
|
||||
pub fn get(&self, name: &str) -> Option<&Cookie<'static>> {
|
||||
self.jar.get(name)
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ version = "1.0.0"
|
|||
"general:link.search" = "Search"
|
||||
"general:link.journals" = "Journals"
|
||||
"general:link.achievements" = "Achievements"
|
||||
"general:link.little_web" = "Little web"
|
||||
"general:action.save" = "Save"
|
||||
"general:action.delete" = "Delete"
|
||||
"general:action.purge" = "Purge"
|
||||
|
@ -29,7 +30,9 @@ version = "1.0.0"
|
|||
"general:action.open" = "Open"
|
||||
"general:action.view" = "View"
|
||||
"general:action.copy_link" = "Copy link"
|
||||
"general:action.copy_id" = "Copy ID"
|
||||
"general:action.post" = "Post"
|
||||
"general:action.apply" = "Apply"
|
||||
"general:label.account" = "Account"
|
||||
"general:label.safety" = "Safety"
|
||||
"general:label.share" = "Share"
|
||||
|
@ -43,6 +46,8 @@ version = "1.0.0"
|
|||
"general:label.could_not_find_post" = "Could not find original post..."
|
||||
"general:label.timeline_end" = "That's a wrap!"
|
||||
"general:label.loading" = "Working on it!"
|
||||
"general:label.send_anonymously" = "Send anonymously"
|
||||
"general:label.must_activate_account" = "You need to activate your account!"
|
||||
|
||||
"general:label.supporter_motivation" = "Become a supporter!"
|
||||
"general:action.become_supporter" = "Become supporter"
|
||||
|
@ -74,6 +79,7 @@ version = "1.0.0"
|
|||
"auth:label.recent_replies" = "Recent replies"
|
||||
"auth:label.recent_posts_with_media" = "Recent posts (with media)"
|
||||
"auth:label.posts" = "Posts"
|
||||
"auth:label.responses" = "Answers"
|
||||
"auth:label.replies" = "Replies"
|
||||
"auth:label.media" = "Media"
|
||||
"auth:label.outbox" = "Outbox"
|
||||
|
@ -87,6 +93,9 @@ version = "1.0.0"
|
|||
"auth:action.message" = "Message"
|
||||
"auth:label.banned" = "Banned"
|
||||
"auth:label.banned_message" = "This user has been banned for breaking the site's rules."
|
||||
"auth:action.create_account" = "Create account"
|
||||
"auth:action.purchase_account" = "Purchase account"
|
||||
"auth:action.continue" = "Continue"
|
||||
|
||||
"communities:action.create" = "Create"
|
||||
"communities:action.select" = "Select"
|
||||
|
@ -122,6 +131,7 @@ version = "1.0.0"
|
|||
"communities:label.edit_content" = "Edit content"
|
||||
"communities:label.repost" = "Repost"
|
||||
"communities:label.quote_post" = "Quote post"
|
||||
"communities:label.ask_about_this" = "Ask about this"
|
||||
"communities:label.search_results" = "Search results"
|
||||
"communities:label.query" = "Query"
|
||||
"communities:label.join_new" = "Join new"
|
||||
|
@ -153,6 +163,7 @@ version = "1.0.0"
|
|||
"settings:tab.sessions" = "Sessions"
|
||||
"settings:tab.connections" = "Connections"
|
||||
"settings:tab.images" = "Images"
|
||||
"settings:tab.presets" = "Presets"
|
||||
"settings:label.change_password" = "Change password"
|
||||
"settings:label.current_password" = "Current password"
|
||||
"settings:label.delete_account" = "Delete account"
|
||||
|
@ -169,8 +180,14 @@ version = "1.0.0"
|
|||
"settings:label.export" = "Export"
|
||||
"settings:label.manage_blocks" = "Manage blocks"
|
||||
"settings:label.users" = "Users"
|
||||
"settings:label.ips" = "IPs"
|
||||
"settings:label.generate_invites" = "Generate invites"
|
||||
"settings:label.add_to_stack" = "Add to stack"
|
||||
"settings:label.alt_text" = "Alt text"
|
||||
"settings:label.deactivate_account" = "Deactivate account"
|
||||
"settings:label.activate_account" = "Activate account"
|
||||
"settings:label.deactivate" = "Deactivate"
|
||||
"settings:label.account_deactivated" = "Account deactivated"
|
||||
"settings:tab.security" = "Security"
|
||||
"settings:tab.blocks" = "Blocks"
|
||||
"settings:tab.billing" = "Billing"
|
||||
|
@ -185,6 +202,7 @@ version = "1.0.0"
|
|||
"mod_panel:label.associations" = "Associations"
|
||||
"mod_panel:label.invited_by" = "Invited by"
|
||||
"mod_panel:label.send_debug_payload" = "Send debug payload"
|
||||
"mod_panel:label.ban_reason" = "Ban reason"
|
||||
"mod_panel:action.send" = "Send"
|
||||
|
||||
"requests:label.requests" = "Requests"
|
||||
|
@ -208,6 +226,8 @@ version = "1.0.0"
|
|||
"chats:action.add_someone" = "Add someone"
|
||||
"chats:action.kick_member" = "Kick member"
|
||||
"chats:action.mention_user" = "Mention user"
|
||||
"chats:action.mute" = "Mute"
|
||||
"chats:action.unmute" = "Unmute"
|
||||
|
||||
"stacks:link.stacks" = "Stacks"
|
||||
"stacks:label.my_stacks" = "My stacks"
|
||||
|
@ -220,6 +240,7 @@ version = "1.0.0"
|
|||
"stacks:label.block_all" = "Block all"
|
||||
"stacks:label.unblock_all" = "Unblock all"
|
||||
|
||||
"forge:label.forges" = "Forges"
|
||||
"forge:label.my_forges" = "My forges"
|
||||
"forge:label.create_new" = "Create new forge"
|
||||
"forge:tab.info" = "Info"
|
||||
|
@ -228,6 +249,7 @@ version = "1.0.0"
|
|||
"forge:action.close" = "Close"
|
||||
|
||||
"developer:label.for_developers" = "for Developers"
|
||||
"developer:label.apps" = "Apps"
|
||||
"developer:label.my_apps" = "My apps"
|
||||
"developer:label.create_new" = "Create new app"
|
||||
"developer:label.homepage" = "Homepage"
|
||||
|
@ -236,9 +258,13 @@ version = "1.0.0"
|
|||
"developer:label.change_homepage" = "Change homepage"
|
||||
"developer:label.change_redirect" = "Change redirect URL"
|
||||
"developer:label.change_quota_status" = "Change quota status"
|
||||
"developer:label.change_storage_capacity" = "Change storage capacity"
|
||||
"developer:label.manage_scopes" = "Manage scopes"
|
||||
"developer:label.scopes" = "Scopes"
|
||||
"developer:label.guides_and_help" = "Guides & help"
|
||||
"developer:label.secret_key" = "Secret key"
|
||||
"developer:label.roll_key" = "Roll key"
|
||||
"developer:label.data_usage" = "Data usage"
|
||||
"developer:action.delete" = "Delete app"
|
||||
"developer:action.authorize" = "Authorize"
|
||||
|
||||
|
@ -260,3 +286,25 @@ version = "1.0.0"
|
|||
"journals:action.publish" = "Publish"
|
||||
"journals:action.unpublish" = "Unpublish"
|
||||
"journals:action.view" = "View"
|
||||
|
||||
"littleweb:label.create_new" = "Create new site"
|
||||
"littleweb:label.create_new_domain" = "Create new domain"
|
||||
"littleweb:label.my_services" = "My sites"
|
||||
"littleweb:label.my_domains" = "My domains"
|
||||
"littleweb:label.browser" = "Browser"
|
||||
"littleweb:label.tld" = "Top-level domain"
|
||||
"littleweb:label.services" = "Sites"
|
||||
"littleweb:label.domains" = "Domains"
|
||||
"littleweb:label.domain_data" = "Domain data"
|
||||
"littleweb:label.type" = "Type"
|
||||
"littleweb:label.name" = "Name"
|
||||
"littleweb:label.value" = "Value"
|
||||
"littleweb:action.edit_site_name" = "Edit site name"
|
||||
"littleweb:action.rename" = "Rename"
|
||||
"littleweb:action.add" = "Add"
|
||||
|
||||
"marketplace:label.products" = "Products"
|
||||
"marketplace:label.status" = "Status"
|
||||
"marketplace:action.get_started" = "Get started"
|
||||
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
|
||||
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"
|
||||
|
|
|
@ -87,7 +87,10 @@ macro_rules! get_user_from_token {
|
|||
{
|
||||
Ok(ua) => {
|
||||
if ua.permissions.check_banned() {
|
||||
Some(tetratto_core::model::auth::User::banned())
|
||||
let mut banned_user = tetratto_core::model::auth::User::banned();
|
||||
banned_user.ban_reason = ua.ban_reason;
|
||||
|
||||
Some(banned_user)
|
||||
} else {
|
||||
Some(ua)
|
||||
}
|
||||
|
@ -109,7 +112,7 @@ macro_rules! get_user_from_token {
|
|||
Ok((grant, ua)) => {
|
||||
if grant.scopes.contains(&$grant_scope) {
|
||||
if ua.permissions.check_banned() {
|
||||
Some(tetratto_core::model::auth::User::banned())
|
||||
None
|
||||
} else {
|
||||
Some(ua)
|
||||
}
|
||||
|
@ -140,6 +143,20 @@ macro_rules! get_user_from_token {
|
|||
None
|
||||
}
|
||||
}};
|
||||
|
||||
(--browser_session=$browser_session:expr, $db:expr) => {{
|
||||
// browser session id
|
||||
match $db.get_user_by_browser_session(&$browser_session).await {
|
||||
Ok(ua) => {
|
||||
if ua.permissions.check_banned() {
|
||||
None
|
||||
} else {
|
||||
Some(ua)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
|
@ -166,7 +183,7 @@ macro_rules! user_banned {
|
|||
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
|
||||
context.insert("profile", &$other_user);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/banned.html", &context).unwrap(),
|
||||
));
|
||||
};
|
||||
|
@ -175,6 +192,27 @@ macro_rules! user_banned {
|
|||
#[macro_export]
|
||||
macro_rules! check_user_blocked_or_private {
|
||||
($user:expr, $other_user:ident, $data:ident, $jar:ident) => {
|
||||
// check is_deactivated
|
||||
if ($user.is_none() && $other_user.is_deactivated)
|
||||
| ($user.is_some()
|
||||
&& !$user
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
&& $other_user.is_deactivated)
|
||||
{
|
||||
return Err(Html(
|
||||
render_error(
|
||||
Error::GeneralNotFound("user".to_string()),
|
||||
&$jar,
|
||||
&$data,
|
||||
&$user,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
|
||||
// check require_account
|
||||
if $user.is_none() && $other_user.settings.require_account {
|
||||
return Err(Html(
|
||||
|
@ -233,7 +271,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
.is_ok(),
|
||||
);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/blocked.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -281,7 +319,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
.is_ok(),
|
||||
);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/private.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -293,7 +331,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
context.insert("follow_requested", &false);
|
||||
context.insert("is_following", &false);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/private.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -352,7 +390,14 @@ macro_rules! ignore_users_gen {
|
|||
($user:ident, $data:ident) => {
|
||||
if let Some(ref ua) = $user {
|
||||
[
|
||||
$data.0.get_userblocks_receivers(ua.id).await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_receivers(
|
||||
ua.id,
|
||||
&ua.associated,
|
||||
ua.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data.0.get_userblocks_initiator_by_receivers(ua.id).await,
|
||||
$data.0.get_user_stack_blocked_users(ua.id).await,
|
||||
]
|
||||
|
@ -364,7 +409,14 @@ macro_rules! ignore_users_gen {
|
|||
|
||||
($user:ident!, $data:ident) => {{
|
||||
[
|
||||
$data.0.get_userblocks_receivers($user.id).await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_receivers(
|
||||
$user.id,
|
||||
&$user.associated,
|
||||
$user.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_initiator_by_receivers($user.id)
|
||||
|
@ -376,9 +428,29 @@ macro_rules! ignore_users_gen {
|
|||
|
||||
($user:ident!, #$data:ident) => {
|
||||
[
|
||||
$data.get_userblocks_receivers($user.id).await,
|
||||
$data
|
||||
.get_userblocks_receivers(
|
||||
$user.id,
|
||||
&$user.associated,
|
||||
$user.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data.get_userblocks_initiator_by_receivers($user.id).await,
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! get_app_from_key {
|
||||
($db:ident, $headers:ident) => {
|
||||
if let Some(token) = $headers.get("Atto-Secret-Key") {
|
||||
match $db.get_app_by_api_key(token.to_str().unwrap()).await {
|
||||
Ok(x) => Some(x),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,13 +2,18 @@
|
|||
#![doc(html_favicon_url = "/public/favicon.svg")]
|
||||
#![doc(html_logo_url = "/public/tetratto_bunny.webp")]
|
||||
mod assets;
|
||||
mod cookie;
|
||||
mod image;
|
||||
mod macros;
|
||||
mod routes;
|
||||
mod sanitize;
|
||||
|
||||
use assets::{init_dirs, write_assets};
|
||||
use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji};
|
||||
use stripe::Client as StripeClient;
|
||||
use tetratto_core::model::{
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
uploads::CustomEmoji,
|
||||
};
|
||||
pub use tetratto_core::*;
|
||||
|
||||
use axum::{
|
||||
|
@ -27,15 +32,17 @@ use tracing::{Level, info};
|
|||
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub(crate) type State = Arc<RwLock<(DataManager, Tera, Client)>>;
|
||||
pub(crate) type InnerState = (DataManager, Tera, Client, Option<StripeClient>);
|
||||
pub(crate) type State = Arc<RwLock<InnerState>>;
|
||||
|
||||
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(value.as_str().unwrap()))
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
.into(),
|
||||
Ok(tetratto_shared::markdown::render_markdown(
|
||||
&CustomEmoji::replace(value.as_str().unwrap()),
|
||||
true,
|
||||
)
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
.into())
|
||||
}
|
||||
|
||||
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
|
@ -53,6 +60,15 @@ fn check_supporter(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
|
|||
.into())
|
||||
}
|
||||
|
||||
fn check_dev_pass(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(
|
||||
SecondaryPermission::from_bits(value.as_u64().unwrap() as u32)
|
||||
.unwrap()
|
||||
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32)
|
||||
.unwrap()
|
||||
|
@ -107,16 +123,42 @@ async fn main() {
|
|||
tera.register_filter("markdown", render_markdown);
|
||||
tera.register_filter("color", color_escape);
|
||||
tera.register_filter("has_supporter", check_supporter);
|
||||
tera.register_filter("has_dev_pass", check_dev_pass);
|
||||
tera.register_filter("has_staff_badge", check_staff_badge);
|
||||
tera.register_filter("has_banned", check_banned);
|
||||
tera.register_filter("remove_script_tags", remove_script_tags);
|
||||
tera.register_filter("emojis", render_emojis);
|
||||
|
||||
let client = Client::new();
|
||||
let mut app = Router::new();
|
||||
|
||||
let app = Router::new()
|
||||
.merge(routes::routes(&config))
|
||||
.layer(Extension(Arc::new(RwLock::new((database, tera, client)))))
|
||||
// create stripe client
|
||||
let stripe_client = if let Some(ref stripe) = config.stripe {
|
||||
Some(StripeClient::new(stripe.secret.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// add correct routes
|
||||
if var("LITTLEWEB").is_ok() {
|
||||
app = app.merge(routes::lw_routes());
|
||||
} else {
|
||||
app = app
|
||||
.merge(routes::routes(&config))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors 'self'"),
|
||||
));
|
||||
}
|
||||
|
||||
// add junk
|
||||
app = app
|
||||
.layer(Extension(Arc::new(RwLock::new((
|
||||
database,
|
||||
tera,
|
||||
client,
|
||||
stripe_client,
|
||||
)))))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(
|
||||
var("BODY_LIMIT")
|
||||
.unwrap_or("8388608".to_string())
|
||||
|
@ -128,12 +170,9 @@ async fn main() {
|
|||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||
)
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"),
|
||||
))
|
||||
.layer(CatchPanicLayer::new());
|
||||
|
||||
// ...
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
@ -38,6 +38,10 @@
|
|||
--pad-2: 0.5rem;
|
||||
--pad-3: 0.75rem;
|
||||
--pad-4: 1rem;
|
||||
|
||||
--online: var(--color-green);
|
||||
--idle: var(--color-yellow);
|
||||
--offline: hsl(0, 0%, 50%);
|
||||
}
|
||||
|
||||
.dark,
|
||||
|
@ -263,7 +267,7 @@ span,
|
|||
code {
|
||||
max-width: 100%;
|
||||
overflow-wrap: normal;
|
||||
text-wrap: pretty;
|
||||
text-wrap: stable;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
|
|
@ -404,7 +404,7 @@ select:focus {
|
|||
.poll_bar {
|
||||
background-color: var(--color-primary);
|
||||
border-radius: var(--radius);
|
||||
height: 25px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.poll_option {
|
||||
|
@ -413,6 +413,22 @@ select:focus {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.progress_bar {
|
||||
background: var(--color-super-lowered);
|
||||
border-radius: var(--circle);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.progress_bar .poll_bar {
|
||||
border-radius: var(--circle);
|
||||
height: 14px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
--color: #c9b1bc;
|
||||
appearance: none;
|
||||
|
@ -582,6 +598,10 @@ input[type="checkbox"]:checked {
|
|||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
height: max-content;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification.tr {
|
||||
|
@ -596,6 +616,11 @@ input[type="checkbox"]:checked {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.notification:not(.chip) .icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* chip */
|
||||
.chip {
|
||||
background: var(--color-primary);
|
||||
|
@ -670,7 +695,7 @@ nav .button:not(.title):not(.active):hover {
|
|||
margin-bottom: 0;
|
||||
backdrop-filter: none;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
height: max-content;
|
||||
top: unset;
|
||||
}
|
||||
|
@ -930,7 +955,7 @@ dialog::backdrop {
|
|||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.dropdown:has(.inner.open) .dropdown-arrow {
|
||||
.dropdown:has(.inner.open) .dropdown_arrow {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
|
||||
|
@ -1110,7 +1135,7 @@ details[open] > summary {
|
|||
margin-bottom: var(--pad-1);
|
||||
}
|
||||
|
||||
details[open] > summary::after {
|
||||
details[open]:not(.accordion) > summary::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
|
@ -1133,8 +1158,7 @@ details.accordion {
|
|||
}
|
||||
|
||||
details.accordion summary {
|
||||
background: var(--background);
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
background: var(--color-lowered);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-3) var(--pad-4);
|
||||
margin: 0;
|
||||
|
@ -1142,11 +1166,15 @@ details.accordion summary {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
details.accordion summary .icon {
|
||||
details.accordion summary:hover {
|
||||
background: var(--color-super-lowered);
|
||||
}
|
||||
|
||||
details.accordion summary .icon.dropdown_arrow {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
details.accordion[open] summary .icon {
|
||||
details.accordion[open] summary .icon.dropdown_arrow {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
|
||||
|
@ -1156,13 +1184,11 @@ details.accordion[open] summary {
|
|||
}
|
||||
|
||||
details.accordion .inner {
|
||||
background: var(--background);
|
||||
background: var(--color-raised);
|
||||
padding: var(--pad-3) var(--pad-4);
|
||||
border-radius: var(--radius);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* codemirror */
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"root.html\" %} {% block body %}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "max-width: 25rem")
|
||||
("style" "max-width: 48ch")
|
||||
(h2
|
||||
("class" "w-full text-center")
|
||||
; block for title
|
||||
|
|
|
@ -48,7 +48,8 @@
|
|||
("name" "totp")
|
||||
("id" "totp"))))
|
||||
(button
|
||||
(text "Submit")))
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "auth:action.continue"))))
|
||||
|
||||
(script
|
||||
(text "let flow_page = 1;
|
||||
|
|
|
@ -37,16 +37,31 @@
|
|||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
("oninput" "check_should_show_purchase(event)")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(text "Invite code (optional)")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("required" "")
|
||||
("name" "invite_code")
|
||||
("id" "invite_code")))
|
||||
|
||||
(script
|
||||
(text "function check_should_show_purchase(e) {
|
||||
if (e.target.value.length > 0) {
|
||||
document.querySelector('[ui_ident=purchase_account]').classList.add('hidden');
|
||||
document.querySelector('[ui_ident=create_account]').classList.remove('hidden');
|
||||
globalThis.DO_PURCHASE = false;
|
||||
} else {
|
||||
document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden');
|
||||
document.querySelector('[ui_ident=create_account]').classList.add('hidden');
|
||||
globalThis.DO_PURCHASE = true;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.DO_PURCHASE = true;"))
|
||||
(text "{%- endif %}")
|
||||
(hr)
|
||||
(div
|
||||
|
@ -84,8 +99,33 @@
|
|||
("class" "cf-turnstile")
|
||||
("data-sitekey" "{{ config.turnstile.site_key }}"))
|
||||
(hr)
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "w-full flex gap-2 justify-between")
|
||||
("ui_ident" "purchase_account")
|
||||
|
||||
(button
|
||||
(icon (text "credit-card"))
|
||||
(str (text "auth:action.purchase_account")))
|
||||
|
||||
(button
|
||||
("class" "small square lowered")
|
||||
("type" "button")
|
||||
("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')")
|
||||
(icon (text "circle-question-mark"))))
|
||||
|
||||
(div
|
||||
("class" "hidden lowered card w-full no_p_margin")
|
||||
("ui_ident" "purchase_help")
|
||||
(b (text "What does \"Purchase account\" mean?"))
|
||||
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}."))
|
||||
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
|
||||
(text "{%- endif %}")
|
||||
(button
|
||||
(text "Submit")))
|
||||
("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}")
|
||||
("ui_ident" "create_account")
|
||||
(icon (text "plus"))
|
||||
(str (text "auth:action.create_account"))))
|
||||
|
||||
(script
|
||||
(text "async function register(e) {
|
||||
|
@ -104,6 +144,7 @@
|
|||
\"[name=cf-turnstile-response]\",
|
||||
).value,
|
||||
invite_code: (e.target.invite_code || { value: \"\" }).value,
|
||||
purchase: globalThis.DO_PURCHASE,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
|
25
crates/app/src/public/html/auth/seller_connection.lisp
Normal file
25
crates/app/src/public/html/auth/seller_connection.lisp
Normal file
|
@ -0,0 +1,25 @@
|
|||
(text "{% extends \"auth/base.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Connection"))
|
||||
|
||||
(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}")
|
||||
(div
|
||||
("class" "w-full flex-col gap-2")
|
||||
("id" "status")
|
||||
(b
|
||||
(text "Working...")))
|
||||
|
||||
(text "{% if connection_type == \"refresh\" %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
trigger(\"seller::onboarding\");
|
||||
}, 1000);"))
|
||||
(text "{% elif connection_type == \"return\" %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
document.getElementById(\"status\").innerHTML =
|
||||
`<b>Account updated.</b> You can now close this tab.`;
|
||||
}, 1000);"))
|
||||
(text "{%- endif %} {% endblock %}")
|
|
@ -94,6 +94,8 @@
|
|||
atto[\"hooks::spotify_time_text\"](); // spotify durations
|
||||
atto[\"hooks::verify_emoji\"]();
|
||||
|
||||
fix_atto_links();
|
||||
|
||||
if (document.getElementById(\"tokens\")) {
|
||||
trigger(\"me::render_token_picker\", [
|
||||
document.getElementById(\"tokens\"),
|
||||
|
@ -101,6 +103,11 @@
|
|||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (globalThis.notifs_stream_init) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.notifs_stream_init = true;
|
||||
trigger(\"me::notifications_stream\");
|
||||
}, 250);
|
||||
});
|
||||
|
@ -158,6 +165,40 @@
|
|||
(icon (text "x"))
|
||||
(str (text "dialog:action.cancel"))))))
|
||||
|
||||
(dialog
|
||||
("id" "littleweb")
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/net")
|
||||
(icon (text "globe"))
|
||||
(str (text "littleweb:label.browser")))
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/services")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_services")))
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/domains")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_domains")))
|
||||
|
||||
(hr ("class" "margin"))
|
||||
(div
|
||||
("class" "flex gap-2 justify-between")
|
||||
(div null?)
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("type" "button")
|
||||
("onclick", "document.getElementById('littleweb').close()")
|
||||
(icon (text "x"))
|
||||
(str (text "dialog:action.cancel"))))))
|
||||
|
||||
(dialog
|
||||
("id" "web_api_prompt")
|
||||
(div
|
||||
|
|
|
@ -210,6 +210,30 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.mute_channel = async (id, mute = true) => {
|
||||
await trigger(\"atto::debounce\", [\"channels::mute\"]);
|
||||
fetch(`/api/v1/channels/${id}/mute`, {
|
||||
method: mute ? \"POST\" : \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
if (mute) {
|
||||
document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\");
|
||||
document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\");
|
||||
} else {
|
||||
document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\");
|
||||
document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.update_channel_title = async (id) => {
|
||||
await trigger(\"atto::debounce\", [\"channels::update_title\"]);
|
||||
const title = await trigger(\"atto::prompt\", [\"New channel title:\"]);
|
||||
|
|
|
@ -31,6 +31,22 @@
|
|||
(text "{{ icon \"user-plus\" }}")
|
||||
(span
|
||||
(text "{{ text \"chats:action.add_someone\" }}")))
|
||||
; mute/unmute
|
||||
(button
|
||||
("class" "lowered small {% if channel.id in user.channel_mutes -%} hidden {%- endif %}")
|
||||
("ui_ident" "channel.mute:{{ channel.id }}")
|
||||
("onclick" "mute_channel('{{ channel.id }}')")
|
||||
(icon (text "bell-off"))
|
||||
(span
|
||||
(str (text "chats:action.mute"))))
|
||||
(button
|
||||
("class" "lowered small {% if not channel.id in user.channel_mutes -%} hidden {%- endif %}")
|
||||
("ui_ident" "channel.unmute:{{ channel.id }}")
|
||||
("onclick" "mute_channel('{{ channel.id }}', false)")
|
||||
(icon (text "bell-ring"))
|
||||
(span
|
||||
(str (text "chats:action.unmute"))))
|
||||
; ...
|
||||
(text "{%- endif %}")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
|
||||
(div
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
("class" "flex gap-2")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"requests:label.answer\" }}")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
("maxlength" "32")
|
||||
("value" "{{ text }}")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"dialog:action.continue\" }}"))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
|
|
|
@ -135,7 +135,6 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))))
|
||||
|
@ -190,7 +189,6 @@
|
|||
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
|
||||
("class" "w-content"))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}"))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
|
@ -213,7 +211,6 @@
|
|||
("accept" "image/png,image/jpeg,image/avif,image/webp")
|
||||
("class" "w-content"))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")))
|
||||
(span
|
||||
("class" "fade")
|
||||
|
@ -245,7 +242,6 @@
|
|||
("required" "")
|
||||
("minlength" "18")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.select\" }}")))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 w-full")
|
||||
|
@ -296,7 +292,6 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{% for channel in channels %}")
|
||||
(div
|
||||
|
|
|
@ -102,22 +102,33 @@
|
|||
("class" "flush")
|
||||
("style" "font-weight: 600")
|
||||
("target" "_top")
|
||||
(text "{{ self::username(user=user) }}"))
|
||||
(text "{% if user.permissions|has_banned -%}")
|
||||
(del ("class" "fade") (text "{{ self::username(user=user) }}"))
|
||||
(text "{% else %}")
|
||||
(text "{{ self::username(user=user) }}")
|
||||
(text "{%- endif %}"))
|
||||
(text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}")
|
||||
(span
|
||||
("title" "Verified")
|
||||
("style" "color: var(--color-primary)")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"badge-check\" }}"))
|
||||
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
|
||||
(span
|
||||
("title" "Staff")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"shield-user\" }}"))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}"))
|
||||
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
|
||||
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false, is_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
|
||||
(div
|
||||
("class" "card-nest post_outer:{{ post.id }} post_outer")
|
||||
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")
|
||||
("is_repost" "{{ is_repost }}")
|
||||
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}")
|
||||
(div
|
||||
("class" "card small")
|
||||
(a
|
||||
|
@ -172,6 +183,12 @@
|
|||
("class" "flex items-center")
|
||||
("style" "color: var(--color-primary)")
|
||||
(text "{{ icon \"square-asterisk\" }}"))
|
||||
(text "{%- endif %} {% if post.context.full_unlist -%}")
|
||||
(span
|
||||
("title" "Unlisted")
|
||||
("class" "flex items-center")
|
||||
("style" "color: var(--color-primary)")
|
||||
(icon (text "eye-off")))
|
||||
(text "{%- endif %} {% if post.stack -%}")
|
||||
(a
|
||||
("title" "Posted to a stack you're in")
|
||||
|
@ -220,7 +237,7 @@
|
|||
("hook" "long")
|
||||
(text "{{ post.title }}"))
|
||||
|
||||
(button ("class" "small lowered") (icon (text "ellipsis"))))
|
||||
(button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis"))))
|
||||
(text "{% else %}")
|
||||
(text "{% if not post.context.content_warning -%}")
|
||||
(span
|
||||
|
@ -235,7 +252,7 @@
|
|||
|
||||
; content
|
||||
(span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}"))
|
||||
(text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
|
||||
(text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false, is_repost=true) }} {% else %}")
|
||||
(div
|
||||
("class" "card lowered red flex items-center gap-2")
|
||||
(text "{{ icon \"frown\" }}")
|
||||
|
@ -314,13 +331,13 @@
|
|||
("class" "button camo small")
|
||||
("target" "_blank")
|
||||
(text "{{ icon \"external-link\" }}"))
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -328,6 +345,7 @@
|
|||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:label.share\" }}"))
|
||||
(text "{% if user -%}")
|
||||
(button
|
||||
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])")
|
||||
(text "{{ icon \"repeat-2\" }}")
|
||||
|
@ -350,7 +368,16 @@
|
|||
(span
|
||||
(text "BlueSky")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if user.id != post.owner -%}")
|
||||
(text "{% if owner.settings.enable_questions -%}")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/@{{ owner.username }}?asking_about={{ post.id }}")
|
||||
(icon (text "reply"))
|
||||
(span
|
||||
(str (text "communities:label.ask_about_this"))))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endif %}")
|
||||
(text "{% if user and user.id != post.owner -%}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:label.safety\" }}"))
|
||||
|
@ -360,12 +387,12 @@
|
|||
(text "{{ icon \"flag\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.report\" }}")))
|
||||
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
|
||||
(text "{%- endif %} {% if user and (user.id == post.owner) or is_helper or can_manage_post %}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:action.manage\" }}"))
|
||||
; forge stuff
|
||||
(text "{% if community and community.is_forge -%} {% if post.is_open -%}")
|
||||
(text "{% if user and community and community.is_forge -%} {% if post.is_open -%}")
|
||||
(button
|
||||
("class" "green")
|
||||
("onclick" "trigger('me::update_open', ['{{ post.id }}', false])")
|
||||
|
@ -381,7 +408,7 @@
|
|||
(text "{{ text \"forge:action.reopen\" }}")))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
; owner stuff
|
||||
(text "{% if user.id == post.owner -%}")
|
||||
(text "{% if user and user.id == post.owner -%}")
|
||||
(a
|
||||
("href" "/post/{{ post.id }}#/edit")
|
||||
(text "{{ icon \"pen\" }}")
|
||||
|
@ -413,8 +440,7 @@
|
|||
(text "{{ icon \"undo\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.restore\" }}")))
|
||||
(text "{%- endif %} {%- endif %}")))
|
||||
(text "{%- endif %}"))))
|
||||
(text "{%- endif %} {%- endif %}"))))))
|
||||
(text "{% if community and show_community and community.id != config.town_square or question %}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
|
||||
|
@ -426,7 +452,6 @@
|
|||
("alt" "Image upload")
|
||||
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
|
||||
(text "{% endfor %}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
|
||||
(div
|
||||
("class" "w-full card-nest")
|
||||
|
@ -527,7 +552,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-green)")
|
||||
("style" "fill: var(--online)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -540,7 +565,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-yellow)")
|
||||
("style" "fill: var(--idle)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -553,7 +578,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: hsl(0, 0%, 50%)")
|
||||
("style" "fill: var(--offline)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -610,7 +635,8 @@
|
|||
(text "{%- endif %}")
|
||||
(div
|
||||
("style" "display: none;")
|
||||
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}")
|
||||
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }}
|
||||
{{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}")
|
||||
(style
|
||||
(text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
@ -622,10 +648,10 @@
|
|||
--{{ css }}: {{ color|color }} !important;
|
||||
}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}")
|
||||
(div
|
||||
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
|
||||
(text "{% if owner.id == 0 -%}")
|
||||
(text "{% if owner.id == 0 or question.context.mask_owner -%}")
|
||||
(span
|
||||
(text "{% if profile and profile.settings.anonymous_avatar_url -%}")
|
||||
(img
|
||||
|
@ -634,7 +660,7 @@
|
|||
("class" "avatar shadow")
|
||||
("loading" "lazy")
|
||||
("style" "--size: 52px"))
|
||||
(text "{% else %} {{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
|
||||
(text "{% else %} {{ self::avatar(username=\"anonymous\", selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
|
||||
(text "{% else %}")
|
||||
(a
|
||||
("href" "/@{{ owner.username }}")
|
||||
|
@ -646,7 +672,7 @@
|
|||
("class" "flex items-center gap-2 flex-wrap")
|
||||
(span
|
||||
("class" "name")
|
||||
(text "{% if owner.id == 0 -%} {% if profile and profile.settings.anonymous_username -%}")
|
||||
(text "{% if owner.id == 0 or question.context.mask_owner -%} {% if profile and profile.settings.anonymous_username -%}")
|
||||
(span
|
||||
("class" "flex items-center gap-2")
|
||||
(b
|
||||
|
@ -692,9 +718,13 @@
|
|||
(text "{{ question.content|markdown|safe }}"))
|
||||
; question drawings
|
||||
(text "{{ self::post_media(upload_ids=question.drawings) }}")
|
||||
; asking about
|
||||
(text "{% if asking_about -%}")
|
||||
(text "{{ self::post(post=asking_about[1], owner=asking_about[0], secondary=not secondary, show_community=false) }}")
|
||||
(text "{%- endif %}")
|
||||
; anonymous user ip thing
|
||||
; this is only shown if the post author is anonymous AND we are a helper
|
||||
(text "{% if is_helper and owner.id == 0 %}")
|
||||
(text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}")
|
||||
(details
|
||||
("class" "card tiny lowered w-full")
|
||||
(summary
|
||||
|
@ -703,12 +733,22 @@
|
|||
(span (text "View IP")))
|
||||
|
||||
(pre (code (text "{{ question.ip }}"))))
|
||||
(text "{% endif %}")
|
||||
|
||||
(text "{% if question.context.mask_owner -%}")
|
||||
(details
|
||||
("class" "card tiny lowered w-full")
|
||||
(summary
|
||||
("class" "w-full flex gap-2 flex-wrap items-center")
|
||||
(icon (text "venetian-mask"))
|
||||
(span (text "Unmask")))
|
||||
|
||||
(text "{{ self::full_username(user=owner) }}"))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
; ...
|
||||
(div
|
||||
("class" "flex gap-2 items-center justify-between"))))
|
||||
|
||||
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
|
||||
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false, allow_anonymous=false) -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
|
@ -718,6 +758,7 @@
|
|||
("class" "no_p_margin")
|
||||
(text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}")))
|
||||
(form
|
||||
("id" "create_question_form")
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_question_from_form(event)")
|
||||
(div
|
||||
|
@ -740,54 +781,78 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
("class" "flex w-full justify-between gap-2 flex-collapse")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
|
||||
(text "{% if drawing_enabled -%}")
|
||||
(button
|
||||
("class" "lowered")
|
||||
("ui_ident" "add_drawing")
|
||||
("onclick" "attach_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.draw\" }}"))
|
||||
(text "{% if drawing_enabled -%}")
|
||||
(button
|
||||
("class" "lowered")
|
||||
("ui_ident" "add_drawing")
|
||||
("onclick" "attach_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.draw\" }}"))
|
||||
|
||||
(button
|
||||
("class" "lowered red hidden")
|
||||
("ui_ident" "remove_drawing")
|
||||
("onclick" "remove_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.remove_drawing\" }}"))
|
||||
(button
|
||||
("class" "lowered red hidden")
|
||||
("ui_ident" "remove_drawing")
|
||||
("onclick" "remove_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.remove_drawing\" }}"))
|
||||
|
||||
(script
|
||||
(text "globalThis.attach_drawing = async () => {
|
||||
globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
|
||||
globalThis.gerald.create_canvas();
|
||||
(script
|
||||
(text "globalThis.attach_drawing = async () => {
|
||||
globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
|
||||
globalThis.gerald.create_canvas();
|
||||
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
|
||||
}
|
||||
|
||||
globalThis.remove_drawing = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
|
||||
}
|
||||
|
||||
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
|
||||
globalThis.gerald = null;
|
||||
globalThis.remove_drawing = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
|
||||
}"))
|
||||
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
|
||||
globalThis.gerald = null;
|
||||
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
|
||||
}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% if not is_global and allow_anonymous and user -%}")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("name" "mask_owner")
|
||||
("id" "mask_owner")
|
||||
("class" "w-content"))
|
||||
|
||||
(label
|
||||
("for" "mask_owner")
|
||||
(b (str (text "general:label.send_anonymously")))))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script
|
||||
(text "globalThis.gerald = null;
|
||||
// asking about
|
||||
globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\") || \"\";
|
||||
|
||||
if (asking_about) {
|
||||
document.getElementById(\"create_question_form\").innerHTML +=
|
||||
`<hr /><span class=\"fade\">Asking about: <a href=\"/post/${asking_about}\" target=\"_blank\">${asking_about}</a> <a href=\"?\" class=\"red\">(cancel)</a></span>`;
|
||||
}
|
||||
|
||||
// ...
|
||||
async function create_question_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"questions::create\"]);
|
||||
|
@ -809,6 +874,8 @@
|
|||
receiver: \"{{ receiver }}\",
|
||||
community: \"{{ community }}\",
|
||||
is_global: \"{{ is_global }}\" == \"true\",
|
||||
mask_owner: (e.target.mask_owner || { checked:false }).checked,
|
||||
asking_about,
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -837,7 +904,7 @@
|
|||
(text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(text "{{ self::question(question=question[0], owner=question[1], show_community=show_community) }}")
|
||||
(text "{{ self::question(question=question[0], owner=question[1], asking_about=false, show_community=show_community) }}")
|
||||
(div
|
||||
("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}")
|
||||
(div
|
||||
|
@ -864,6 +931,7 @@
|
|||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -977,6 +1045,7 @@
|
|||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -1079,14 +1148,12 @@
|
|||
(text "{{ icon \"circle-user-round\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:link.my_profile\" }}")))
|
||||
(a
|
||||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
(str (text "general:link.journals")))
|
||||
(text "{% if not user.settings.disable_achievements -%}")
|
||||
(a
|
||||
("href" "/achievements")
|
||||
(icon (text "award"))
|
||||
(str (text "general:link.achievements")))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("href" "/settings")
|
||||
(text "{{ icon \"settings\" }}")
|
||||
|
@ -1124,22 +1191,18 @@
|
|||
(icon (text "code"))
|
||||
(str (text "general:link.source_code")))
|
||||
|
||||
(a
|
||||
("href" "/reference/tetratto/index.html")
|
||||
("class" "button")
|
||||
("data-turbo" "false")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])")
|
||||
(icon (text "rabbit"))
|
||||
(str (text "general:link.reference")))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.terms_of_service }}")
|
||||
("class" "button")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])")
|
||||
(icon (text "heart-handshake"))
|
||||
(text "Terms of service"))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.privacy }}")
|
||||
("class" "button")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])")
|
||||
(icon (text "cookie"))
|
||||
(text "Privacy policy"))
|
||||
(b ("class" "title") (str (text "general:label.account")))
|
||||
|
@ -1211,6 +1274,7 @@
|
|||
("class" "camo small square")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -1394,7 +1458,9 @@
|
|||
});
|
||||
})();"))
|
||||
|
||||
(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
|
||||
(div
|
||||
("class" "card w-full supporter_ad")
|
||||
("ui_ident" "supporter_ad")
|
||||
|
@ -1414,8 +1480,9 @@
|
|||
(text "{{ icon \"heart\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.become_supporter\" }}")))))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
|
||||
(text "{% macro create_post_options() -%}")
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
|
||||
|
@ -1432,6 +1499,7 @@
|
|||
("title" "More options")
|
||||
("onclick" "document.getElementById('post_options_dialog').showModal()")
|
||||
("type" "button")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
|
||||
(label
|
||||
|
@ -1474,6 +1542,7 @@
|
|||
is_nsfw: false,
|
||||
content_warning: \"\",
|
||||
tags: [],
|
||||
full_unlist: false,
|
||||
};
|
||||
|
||||
window.BLANK_INITIAL_SETTINGS = JSON.stringify(
|
||||
|
@ -1510,6 +1579,11 @@
|
|||
// window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
|
||||
// \"checkbox\",
|
||||
// ],
|
||||
[
|
||||
[\"full_unlist\", \"Unlist from timelines\"],
|
||||
window.POST_INITIAL_SETTINGS.full_unlist.toString(),
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"content_warning\", \"Content warning\"],
|
||||
window.POST_INITIAL_SETTINGS.content_warning,
|
||||
|
@ -1726,8 +1800,8 @@
|
|||
(span ("class" "notification chip") (text "{{ total }} votes"))
|
||||
(text "{% if not poll[2] -%}")
|
||||
(span
|
||||
("class" "notification chip")
|
||||
(text "Expires in ")
|
||||
("class" "notification chip flex items-center gap-1")
|
||||
(text "Expires in")
|
||||
(span
|
||||
("class" "poll_date")
|
||||
("data-created" "{{ poll[0].created }}")
|
||||
|
@ -1803,7 +1877,6 @@
|
|||
("id" "join_or_leave")
|
||||
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
|
||||
(button
|
||||
("class" "primary")
|
||||
("onclick" "join_community()")
|
||||
(text "{{ icon \"circle-plus\" }}")
|
||||
(span
|
||||
|
@ -2017,6 +2090,7 @@
|
|||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -2043,6 +2117,7 @@
|
|||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -2134,6 +2209,7 @@
|
|||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -2213,6 +2289,7 @@
|
|||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
("title" "More options")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -2256,3 +2333,121 @@
|
|||
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro become_supporter_button() -%}")
|
||||
(p
|
||||
(text "You're ")
|
||||
(b
|
||||
(text "not "))
|
||||
(text "currently a supporter! No
|
||||
pressure, but it helps us do some pretty cool
|
||||
things! As a supporter, you'll get:"))
|
||||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Vanity badge on profile"))
|
||||
(li
|
||||
(text "No more supporter ads (duh)"))
|
||||
(li
|
||||
(text "Ability to upload gif avatars/banners"))
|
||||
(li
|
||||
(text "Be an admin/owner of up to 10 communities"))
|
||||
(li
|
||||
(text "Use custom CSS on your profile"))
|
||||
(li
|
||||
(text "Use community emojis outside of
|
||||
their community"))
|
||||
(li
|
||||
(text "Upload and use gif emojis"))
|
||||
(li
|
||||
(text "Create infinite stack timelines"))
|
||||
(li
|
||||
(text "Upload images to posts"))
|
||||
(li
|
||||
(text "Save infinite post drafts"))
|
||||
(li
|
||||
(text "Ability to search through all posts"))
|
||||
(li
|
||||
(text "Create up to 10 stack blocks"))
|
||||
(li
|
||||
(text "Add unlimited users to stacks"))
|
||||
(li
|
||||
(text "Increased proxied image size"))
|
||||
(li
|
||||
(text "Create infinite journals"))
|
||||
(li
|
||||
(text "Create infinite notes in each journal"))
|
||||
(li
|
||||
(text "Publish up to 50 notes"))
|
||||
(li
|
||||
(text "Create infinite Littleweb sites"))
|
||||
(li
|
||||
(text "Create infinite Littleweb domains"))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(li
|
||||
(text "Create up to 48 invite codes")
|
||||
(sup (a ("href" "#footnote-1") (text "1"))))
|
||||
(text "{%- endif %}"))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
("target" "_blank")
|
||||
(text "Become a supporter ({{ config.stripe.price_texts.supporter }})"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Please use your")
|
||||
(b
|
||||
(text " real email "))
|
||||
(text "when completing payment. It is required to manage your billing settings."))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(span
|
||||
("class" "fade")
|
||||
("id" "footnote-1")
|
||||
(b (text "1: ")) (text "After your account is at least 1 month old"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro get_developer_pass_button() -%}")
|
||||
(p
|
||||
(text "You currently do not hold a developer pass. With a developer pass, you'll get:"))
|
||||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Increased app storage limit (500 KB->25 MB)"))
|
||||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
(text "Ability to create more than 1 app"))
|
||||
(li
|
||||
(text "Developer pass profile badge")))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
("target" "_blank")
|
||||
(text "Continue ({{ config.stripe.price_texts.dev_pass }})"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Please use your")
|
||||
(b
|
||||
(text " real email "))
|
||||
(text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there."))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}")
|
||||
(div
|
||||
("class" "card w-full supporter_ad")
|
||||
("ui_ident" "supporter_ad")
|
||||
("onclick" "window.location.href = '/settings#/account/billing'")
|
||||
(div
|
||||
("class" "card w-full flex flex-wrap items-center gap-2 justify-between")
|
||||
(b
|
||||
(text "{{ body }}"))
|
||||
(a
|
||||
("href" "/settings#/account/billing")
|
||||
("class" "button small")
|
||||
(icon (text "arrow-right"))
|
||||
(span
|
||||
(str (text "dialog:action.continue"))))))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
|
|
@ -10,11 +10,27 @@
|
|||
(div
|
||||
("id" "manage_fields")
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "database"))
|
||||
(b (str (text "developer:label.data_usage"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit."))
|
||||
(text "{% set percentage = (app.data_used / data_limit) * 100 %}")
|
||||
(div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))
|
||||
(div
|
||||
("class" "w-full flex justify-between items-center")
|
||||
(span (text "{{ app.data_used|filesizeformat }}"))
|
||||
(span (text "{{ data_limit|filesizeformat }}")))))
|
||||
(text "{% if is_helper -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "infinity"))
|
||||
(b (str (text "developer:label.change_quota_status"))))
|
||||
(div
|
||||
("class" "card")
|
||||
|
@ -28,11 +44,34 @@
|
|||
("value" "Unlimited")
|
||||
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
|
||||
(text "Unlimited")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "database-zap"))
|
||||
(b (str (text "developer:label.change_storage_capacity"))))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "save_storage_capacity(event)")
|
||||
(option
|
||||
("value" "Tier1")
|
||||
("selected" "{% if app.storage_capacity == 'Tier1' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 1 (25 MB)"))
|
||||
(option
|
||||
("value" "Tier2")
|
||||
("selected" "{% if app.storage_capacity == 'Tier2' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 2 (50 MB)"))
|
||||
(option
|
||||
("value" "Tier3")
|
||||
("selected" "{% if app.storage_capacity == 'Tier3' -%}true{% else %}false{%- endif %}")
|
||||
(text "Tier 3 (100 MB)")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "pencil"))
|
||||
(b (str (text "developer:label.change_title"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
|
@ -50,14 +89,14 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "house"))
|
||||
(b (str (text "developer:label.change_homepage"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
|
@ -75,14 +114,14 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "goal"))
|
||||
(b (str (text "developer:label.change_redirect"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
|
@ -100,14 +139,14 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "telescope"))
|
||||
(b (str (text "developer:label.manage_scopes"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
|
@ -140,10 +179,22 @@
|
|||
(icon (text "external-link")) (text "Docs"))))
|
||||
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))))
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "rotate-ccw-key"))
|
||||
(b (str (text "developer:label.secret_key"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "Your app's API key can only be seen once, so don't lose it. Rolling the key will invalidate the old one."))
|
||||
(pre (code ("id" "new_key")))
|
||||
(button
|
||||
("onclick" "roll_key()")
|
||||
(str (text "developer:label.roll_key"))))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(ul
|
||||
|
@ -151,7 +202,8 @@
|
|||
(li (b (text "Redirect URL: ")) (text "{{ app.redirect }}"))
|
||||
(li (b (text "Quota status: ")) (text "{{ app.quota_status }}"))
|
||||
(li (b (text "User grants: ")) (text "{{ app.grants }}"))
|
||||
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")))
|
||||
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}"))
|
||||
(li (b (text "App ID (for SDK): ")) (text "{{ app.id }}")))
|
||||
|
||||
(a
|
||||
("class" "button")
|
||||
|
@ -202,6 +254,26 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.save_storage_capacity = (event) => {
|
||||
const selected = event.target.selectedOptions[0];
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/storage_capacity\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
storage_capacity: selected.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.change_title = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
@ -323,6 +395,31 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.roll_key = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/apps/{{ app.id }}/roll\", {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
document.getElementById(\"new_key\").innerText = res.payload;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.delete_app = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
|
|
|
@ -41,23 +41,19 @@
|
|||
("id" "homepage")
|
||||
("placeholder" "homepage")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
("minlength" "2")))
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "title")
|
||||
(text "{{ text \"developer:label.redirect\" }}"))
|
||||
(text "{{ text \"developer:label.redirect\" }} (optional)"))
|
||||
(input
|
||||
("type" "url")
|
||||
("name" "redirect")
|
||||
("id" "redirect")
|
||||
("placeholder" "redirect URL")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
|
||||
; app listing
|
||||
|
@ -126,7 +122,7 @@
|
|||
body: JSON.stringify({
|
||||
title: e.target.title.value,
|
||||
homepage: e.target.homepage.value,
|
||||
redirect: e.target.redirect.value,
|
||||
redirect: e.target.redirect.value || \"\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
|
|
@ -39,6 +39,13 @@
|
|||
(str (text "dialog:action.cancel")))))))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
// {% if app.redirect|length == 0 %}
|
||||
alert(\"App has an invalid redirect. Please contact the owner for help.\");
|
||||
window.close();
|
||||
return;
|
||||
// {% endif %}
|
||||
|
||||
// ...
|
||||
globalThis.authorize = async (event) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
|
@ -76,6 +83,7 @@
|
|||
const search = new URLSearchParams(window.location.search);
|
||||
search.append(\"verifier\", verifier);
|
||||
search.append(\"token\", res.payload);
|
||||
search.append(\"uid\", \"{{ user.id }}\");
|
||||
|
||||
window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
; create new
|
||||
(text "{% if user.permissions|has_supporter -%}")
|
||||
(text "{% if user.secondary_permissions|has_dev_pass -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
|
@ -30,10 +30,9 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}")
|
||||
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
|
||||
(text "{%- endif %}")
|
||||
|
||||
; forge listing
|
||||
|
|
|
@ -253,7 +253,6 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))))
|
||||
|
@ -379,7 +378,6 @@
|
|||
("name" "tags")
|
||||
("id" "tags")
|
||||
("placeholder" "tags")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")
|
||||
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
|
||||
|
|
227
crates/app/src/public/html/littleweb/browser.lisp
Normal file
227
crates/app/src/public/html/littleweb/browser.lisp
Normal file
|
@ -0,0 +1,227 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "{{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %}")
|
||||
(div
|
||||
("id" "panel")
|
||||
("class" "flex flex-row gap-2")
|
||||
(a
|
||||
("class" "button camo")
|
||||
("href" "/")
|
||||
(icon (text "house")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "back()")
|
||||
(icon (text "arrow-left")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "forward()")
|
||||
(icon (text "arrow-right")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "reload()")
|
||||
(icon (text "rotate-cw")))
|
||||
|
||||
(form
|
||||
("class" "w-full flex gap-1 flex-row")
|
||||
("onsubmit" "event.preventDefault(); littleweb_navigate(event.target.uri.getAttribute('true_value'))")
|
||||
(input
|
||||
("type" "uri")
|
||||
("class" "w-full")
|
||||
("true_value" "")
|
||||
("name" "uri")
|
||||
("id" "uri"))
|
||||
|
||||
(button ("class" "lowered small square") (icon (text "arrow-right"))))
|
||||
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "flex-row camo")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "gap: var(--pad-1) !important")
|
||||
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow")))
|
||||
|
||||
(text "{{ components::user_menu() }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(iframe
|
||||
("id" "browser_iframe")
|
||||
("frameborder" "0")
|
||||
("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}"))
|
||||
|
||||
(style
|
||||
("data-turbo-temporary" "true")
|
||||
(text ":root {
|
||||
--panel-height: 45px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#panel {
|
||||
width: 100dvw;
|
||||
height: var(--panel-height);
|
||||
padding: var(--pad-2);
|
||||
}
|
||||
|
||||
#panel input {
|
||||
border: none;
|
||||
background: var(--color-lowered);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#panel input:focus {
|
||||
background: var(--color-super-lowered);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#panel input:focus {
|
||||
position: fixed;
|
||||
width: calc(100dvw - (62px + var(--pad-2) * 2)) !important;
|
||||
left: var(--pad-2);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
#panel button:not(.inner *),
|
||||
#panel a.button:not(.inner *),
|
||||
#panel input {
|
||||
--h: 28.2px;
|
||||
height: var(--h);
|
||||
min-height: var(--h);
|
||||
max-height: var(--h);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#panel button:not(.inner *),
|
||||
#panel a.button:not(.inner *) {
|
||||
padding: var(--pad-1) var(--pad-2);
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100dvw;
|
||||
height: calc(100dvh - var(--panel-height));
|
||||
}"))
|
||||
|
||||
(script
|
||||
(text "globalThis.SECRET_SESSION = \"{{ session }}\";
|
||||
function littleweb_navigate(uri) {
|
||||
if (!uri.includes(\".html\")) {
|
||||
uri = `${uri}/index.html`;
|
||||
}
|
||||
|
||||
// ...
|
||||
console.log(\"navigate\", uri);
|
||||
document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`;
|
||||
|
||||
if (!uri.includes(\"atto://\")) {
|
||||
document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`);
|
||||
} else {
|
||||
document.getElementById(\"uri\").setAttribute(\"true_value\", uri);
|
||||
}
|
||||
|
||||
document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
}
|
||||
|
||||
document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => {
|
||||
console.log(\"web content loaded\");
|
||||
});
|
||||
|
||||
window.addEventListener(\"message\", (e) => {
|
||||
if (typeof e.data !== \"string\") {
|
||||
console.log(\"refuse message (bad type)\");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (!data.t) {
|
||||
console.log(\"refuse message (not for tetratto)\");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(\"received message\");
|
||||
|
||||
if (data.event === \"change_url\") {
|
||||
const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length);
|
||||
window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`);
|
||||
|
||||
if (!uri.includes(\"atto://\")) {
|
||||
document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`);
|
||||
} else {
|
||||
document.getElementById(\"uri\").setAttribute(\"true_value\", uri);
|
||||
}
|
||||
|
||||
document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
}
|
||||
});
|
||||
|
||||
function back() {
|
||||
post_message({ t: true, event: \"back\" });
|
||||
}
|
||||
|
||||
function forward() {
|
||||
post_message({ t: true, event: \"forward\" });
|
||||
}
|
||||
|
||||
function reload() {
|
||||
post_message({ t: true, event: \"reload\" });
|
||||
}
|
||||
|
||||
function post_message(data) {
|
||||
const origin = new URL(document.getElementById(\"browser_iframe\").src).origin;
|
||||
document.getElementById(\"browser_iframe\").contentWindow.postMessage(JSON.stringify(data), origin);
|
||||
}
|
||||
|
||||
// handle dropdowns
|
||||
window.addEventListener(\"blur\", () => {
|
||||
trigger(\"atto::hooks::dropdown.close\");
|
||||
});
|
||||
|
||||
// url bar focus
|
||||
document.getElementById(\"uri\").addEventListener(\"input\", (e) => {
|
||||
e.target.setAttribute(\"true_value\", e.target.value);
|
||||
});
|
||||
|
||||
let is_focused = false;
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"mouseenter\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\");
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"mouseleave\", (e) => {
|
||||
if (is_focused) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"focus\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\");
|
||||
is_focused = true;
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"blur\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
is_focused = false;
|
||||
});
|
||||
|
||||
// navigate
|
||||
if ({{ path|length }} > 0) {
|
||||
littleweb_navigate(\"{{ path|safe }}\");
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
278
crates/app/src/public/html/littleweb/domain.lisp
Normal file
278
crates/app/src/public/html/littleweb/domain.lisp
Normal file
|
@ -0,0 +1,278 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "{{ domain.name }}.{{ domain.tld|lower }}")))
|
||||
(div
|
||||
("class" "flex flex-col gap-2 card")
|
||||
(code
|
||||
("class" "w-content")
|
||||
(a
|
||||
("href" "atto://{{ domain.name }}.{{ domain.tld|lower }}")
|
||||
(text "atto://{{ domain.name }}.{{ domain.tld|lower }}")))
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap")
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_domain()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete"))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.domain_data"))))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "small lowered")
|
||||
("title" "Help")
|
||||
("onclick" "document.getElementById('domain_help').classList.toggle('hidden')")
|
||||
(icon (text "circle-question-mark")))
|
||||
|
||||
(button
|
||||
("class" "small")
|
||||
("onclick" "document.getElementById('add_data').classList.toggle('hidden')")
|
||||
(icon (text "plus"))
|
||||
(str (text "littleweb:action.add")))))
|
||||
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin")
|
||||
("id" "domain_help")
|
||||
(p (text "To link your domain to a site, go to the site and press \"Copy ID\"."))
|
||||
(p (text "After you have the site's ID, click \"Add\" on this page, then paste the ID into the \"value\" field."))
|
||||
(p (text "If you've ever managed a real domain's DNS, this should be familiar."))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
; add data
|
||||
(form
|
||||
("id" "add_data")
|
||||
("class" "card hidden w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "add_data_from_form(event)")
|
||||
(div
|
||||
("class" "flex gap-2 flex-collapse")
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(str (text "littleweb:label.type")))
|
||||
(select
|
||||
("type" "text")
|
||||
("name" "type")
|
||||
("id" "type")
|
||||
("placeholder" "type")
|
||||
("required" "")
|
||||
(option ("value" "Service") (text "Site ID"))
|
||||
(option ("value" "Text") (text "Text"))))
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(str (text "littleweb:label.name")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("minlength" "1")
|
||||
("maxlength" "32"))
|
||||
(span ("class" "fade") (text "Use \"@\" for root.")))
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "value")
|
||||
(str (text "littleweb:label.value")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "value")
|
||||
("id" "value")
|
||||
("placeholder" "value")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "256"))))
|
||||
(div
|
||||
("class" "flex w-full justify-between")
|
||||
(div)
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))))
|
||||
; data
|
||||
(div
|
||||
("class" "w-full")
|
||||
("style" "max-width: 100%; overflow: auto; min-height: 512px")
|
||||
(table
|
||||
("class" "w-full")
|
||||
(thead
|
||||
(tr
|
||||
(th (text "Name"))
|
||||
(th (text "Type"))
|
||||
(th (text "Value"))
|
||||
(th (text "Actions"))))
|
||||
|
||||
(tbody
|
||||
(text "{% for item in domain.data -%}")
|
||||
(tr
|
||||
(td (text "{{ item[0] }}"))
|
||||
(text "{% for k,v in item[1] -%}")
|
||||
(td (text "{{ k }}"))
|
||||
(td (text "{{ v }}"))
|
||||
(text "{%- endfor %}")
|
||||
(td
|
||||
("style" "overflow: auto")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "ellipsis")))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "rename_data('{{ item[0] }}')")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.rename")))
|
||||
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "remove_data('{{ item[0] }}')")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))))
|
||||
(text "{%- endfor %}")))))))
|
||||
|
||||
(script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}"))
|
||||
(script
|
||||
(text "globalThis.DOMAIN_DATA = JSON.parse(document.getElementById(\"domain_data\").innerText);
|
||||
async function save_data() {
|
||||
await trigger(\"atto::debounce\", [\"domains::update_data\"]);
|
||||
fetch(\"/api/v1/domains/{{ domain.id }}/data\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: DOMAIN_DATA,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function add_data_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::add_data\"]);
|
||||
|
||||
const x = {};
|
||||
x[e.target.type.selectedOptions[0].value] = e.target.value.value;
|
||||
|
||||
if (e.target.name.value === \"\") {
|
||||
e.target.name.value = \"@\";
|
||||
}
|
||||
|
||||
const name = e.target.name.value.replace(\" \", \"_\");
|
||||
if (DOMAIN_DATA.find((x) => x[0] === name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DOMAIN_DATA.push([name, x]);
|
||||
await save_data();
|
||||
e.target.reset();
|
||||
}
|
||||
|
||||
async function delete_data(name) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::delete_data\"]);
|
||||
|
||||
delete DOMAIN_DATA.find((x) => x[0] === name);
|
||||
await save_data();
|
||||
}
|
||||
|
||||
async function delete_domain() {
|
||||
await trigger(\"atto::debounce\", [\"domains::delete\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/domains/{{ domain.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function rename_data(selector) {
|
||||
await trigger(\"atto::debounce\", [\"domains::rename_data\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
DOMAIN_DATA.find((x) => x[0] === selector)[0] = name.replaceAll(\" \", \"_\");
|
||||
await save_data();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function remove_data(name) {
|
||||
await trigger(\"atto::debounce\", [\"domains::remove_data\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
DOMAIN_DATA.find((x) => {
|
||||
i += 1;
|
||||
return x[0] === name;
|
||||
});
|
||||
|
||||
DOMAIN_DATA.splice(i - 1, 1);
|
||||
await save_data();
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
134
crates/app/src/public/html/littleweb/domains.lisp
Normal file
134
crates/app/src/public/html/littleweb/domains.lisp
Normal file
|
@ -0,0 +1,134 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My domains - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
|
||||
; viewing other user's domains warning
|
||||
(text "{% if profile.id != user.id -%}")
|
||||
(div
|
||||
("class" "card w-full red flex gap-2 items-center")
|
||||
(text "{{ icon \"skull\" }}")
|
||||
(b
|
||||
(text "Viewing other user's domains! Please be careful.")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; ...
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "littleweb:label.create_new_domain"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_domain_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(text "{{ text \"communities:label.name\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "tld")
|
||||
(str (text "littleweb:label.tld")))
|
||||
(select
|
||||
("type" "text")
|
||||
("name" "tld")
|
||||
("id" "tld")
|
||||
("placeholder" "tld")
|
||||
("required" "")
|
||||
(text "{% for tld in tlds -%}")
|
||||
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
|
||||
(text "{%- endfor %}")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
|
||||
(details
|
||||
(summary
|
||||
(icon (text "circle-alert"))
|
||||
(text "Disclaimer"))
|
||||
|
||||
(div
|
||||
("class" "card lowered no_p_margin")
|
||||
(p (text "Domains are registered into {{ config.name }}'s closed web."))
|
||||
(p (text "This means that domains are only accessible through {{ config.name }}, as well as other supporting sites."))
|
||||
(p (text "If you would prefer a public-facing domain, those cost money and cannot be bought from {{ config.name }}."))
|
||||
(p (text "All domains have first-class support on {{ config.name }}, meaning all links to them will work properly on this site."))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.my_domains")))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for item in list %}")
|
||||
(a
|
||||
("href" "/domains/{{ item.id }}")
|
||||
("class" "card secondary flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "globe"))
|
||||
(b
|
||||
(text "{{ item.name }}.{{ item.tld|lower }}")))
|
||||
(span
|
||||
(text "Created ")
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ item.created }}"))
|
||||
(text "; {{ item.data|length }} entries")))
|
||||
(text "{% endfor %}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_domain_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::create\"]);
|
||||
|
||||
fetch(\"/api/v1/domains\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
tld: e.target.tld.selectedOptions[0].value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/domains/${res.payload}`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
373
crates/app/src/public/html/littleweb/service.lisp
Normal file
373
crates/app/src/public/html/littleweb/service.lisp
Normal file
|
@ -0,0 +1,373 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") ("class" "active") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex w-full gap-2 justify-between")
|
||||
(b
|
||||
(text "{{ service.name }}"))
|
||||
|
||||
(button
|
||||
("class" "small lowered")
|
||||
("title" "Help")
|
||||
("onclick" "document.getElementById('site_help').classList.toggle('hidden')")
|
||||
(icon (text "circle-question-mark"))))
|
||||
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin")
|
||||
("id" "site_help")
|
||||
(p (text "Your site should include an \"index.html\" file in order to show content on its homepage."))
|
||||
(p (text "In the HTML editor, you can type `!` and use the provided suggestion to get an HTML boilerplate."))
|
||||
(p (text "After you've created a page, you can click \"Copy ID\" and go to manage a domain you own. On the domain management page, click \"Add\" and paste the ID you copied into the value field."))))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap card")
|
||||
(text "{% if file and file.children|length == 0 -%}")
|
||||
(button
|
||||
("onclick" "update_content()")
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "update_name()")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.edit_site_name")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "trigger('atto::copy_text', ['{{ service.id }}'])")
|
||||
(icon (text "copy"))
|
||||
(str (text "general:action.copy_id")))
|
||||
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_service()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "folder-open"))
|
||||
(span (text "{% if path -%} {{ path }} {%- else -%} / {%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
("onclick" "go_up()")
|
||||
(icon (text "arrow-up")))
|
||||
|
||||
(text "{% if not file or file.content|length == 0 -%}")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
("onclick" "create_file()")
|
||||
(icon (text "plus"))
|
||||
(str (text "communities:action.create")))
|
||||
(text "{%- endif %}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% if not file or file.children|length > 0 -%}")
|
||||
; directory browser
|
||||
(div
|
||||
("class" "w-full")
|
||||
("style" "max-width: 100%; overflow: auto; min-height: 512px")
|
||||
(table
|
||||
("class" "w-full")
|
||||
(thead
|
||||
(tr
|
||||
(th (text "Name"))
|
||||
(th (text "Type"))
|
||||
(th (text "Children"))
|
||||
(th (text "Actions"))))
|
||||
|
||||
(tbody
|
||||
(text "{% for item in files %}")
|
||||
(tr
|
||||
(td
|
||||
("class" "flex gap-2 items-center")
|
||||
(text "{% if item.children|length > 0 -%}")
|
||||
(icon (text "folder"))
|
||||
(text "{% else %}")
|
||||
(icon (text "file"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(a
|
||||
("href" "?path={{ path }}/{{ item.name }}")
|
||||
("data-turbo" "false")
|
||||
(text "{{ item.name }}")))
|
||||
(td (text "{{ item.mime }}"))
|
||||
(td (text "{{ item.children|length }}"))
|
||||
(td
|
||||
("style" "overflow: auto")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "ellipsis")))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "rename_file('{{ item.id }}')")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.rename")))
|
||||
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "remove_file('{{ item.id }}')")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))))
|
||||
(text "{% endfor %}"))))
|
||||
(text "{% else %}")
|
||||
; file editor
|
||||
(div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px"))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script ("id" "all_service_files") ("type" "application/json") (text "{{ service.files|json_encode()|remove_script_tags|safe }}"))
|
||||
(script ("id" "service_files") ("type" "application/json") (text "{{ files|json_encode()|remove_script_tags|safe }}"))
|
||||
(script ("id" "id_path") ("type" "application/json") (text "{{ id_path|json_encode()|remove_script_tags|safe }}"))
|
||||
|
||||
(script
|
||||
(text "globalThis.SERVICE_FILES = JSON.parse(document.getElementById(\"service_files\").innerText);
|
||||
globalThis.EXTENSION_MIMES = {
|
||||
\"html\": \"text/html\",
|
||||
\"js\": \"text/javascript\",
|
||||
\"css\": \"text/css\",
|
||||
\"json\": \"application/json\",
|
||||
\"txt\": \"text/plain\",
|
||||
}
|
||||
|
||||
globalThis.MIME_MODES = {
|
||||
\"Html\": \"html\",
|
||||
\"Js\": \"javascript\",
|
||||
\"Css\": \"css\",
|
||||
\"Json\": \"json\",
|
||||
\"Plain\": \"txt\",
|
||||
}
|
||||
|
||||
function go_up() {
|
||||
const x = JSON.parse(document.getElementById(\"id_path\").innerText);
|
||||
const y = JSON.parse(document.getElementById(\"all_service_files\").innerText);
|
||||
|
||||
x.pop();
|
||||
let path = \"\";
|
||||
|
||||
for (id of x) {
|
||||
path += `/${y.find((x) => x.id == id).name}`;
|
||||
}
|
||||
|
||||
window.location.href = `?path=${path}`;
|
||||
}
|
||||
|
||||
async function update_name() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_name\"]);
|
||||
|
||||
const name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/services/{{ service.id }}/name\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_service() {
|
||||
await trigger(\"atto::debounce\", [\"services::delete\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/services/{{ service.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_content() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_content\"]);
|
||||
const content = globalThis.editor.getValue();
|
||||
fetch(\"/api/v1/services/{{ service.id }}/content\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
id_path: JSON.parse(document.getElementById(\"id_path\").innerText),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_files() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_files\"]);
|
||||
fetch(\"/api/v1/services/{{ service.id }}/files\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: SERVICE_FILES,
|
||||
id_path: JSON.parse(document.getElementById(\"id_path\").innerText),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function create_file() {
|
||||
await trigger(\"atto::debounce\", [\"services::create_file\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"Name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s = name.split(\".\");
|
||||
SERVICE_FILES.push({
|
||||
id: window.crypto.randomUUID(),
|
||||
name,
|
||||
mime: EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"],
|
||||
children: [],
|
||||
content: \"\",
|
||||
});
|
||||
|
||||
await update_files();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function rename_file(id) {
|
||||
await trigger(\"atto::debounce\", [\"services::rename_file\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file_ref = SERVICE_FILES.find((x) => x.id === id);
|
||||
file_ref.name = name;
|
||||
|
||||
const s = name.split(\".\");
|
||||
file_ref.mime = EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"];
|
||||
|
||||
await update_files();
|
||||
}
|
||||
|
||||
async function remove_file(id) {
|
||||
await trigger(\"atto::debounce\", [\"services::remove_file\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
SERVICE_FILES.find((x) => {
|
||||
i += 1;
|
||||
return x.id === id;
|
||||
});
|
||||
|
||||
SERVICE_FILES.splice(i - 1, 1);
|
||||
await update_files();
|
||||
}"))
|
||||
|
||||
(text "{% if file and file.mime != 'Plain' -%}")
|
||||
(script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js"))
|
||||
(script ("src" "https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js"))
|
||||
(script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}"))
|
||||
(script
|
||||
(text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } });
|
||||
|
||||
require([\"vs/editor/editor.main\"], () => {
|
||||
const shadow = document.getElementById(\"editor_container\").attachShadow({
|
||||
mode: \"closed\",
|
||||
});
|
||||
|
||||
const inner = document.createElement(\"div\");
|
||||
inner.style.width = window.getComputedStyle(document.getElementById(\"editor_container\")).width;
|
||||
inner.style.height = window.getComputedStyle(document.getElementById(\"editor_container\")).height;
|
||||
shadow.appendChild(inner);
|
||||
|
||||
const style = document.createElement(\"style\");
|
||||
style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";';
|
||||
shadow.appendChild(style);
|
||||
|
||||
emmetMonaco.emmetHTML();
|
||||
emmetMonaco.emmetCSS();
|
||||
|
||||
globalThis.editor = monaco.editor.create(inner, {
|
||||
value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"</script\" + \">\"),
|
||||
language: MIME_MODES[\"{{ file.mime }}\"],
|
||||
theme: \"vs-dark\",
|
||||
suggest: {
|
||||
snippetsPreventQuickSuggestions: false,
|
||||
},
|
||||
});
|
||||
});"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %}")
|
110
crates/app/src/public/html/littleweb/services.lisp
Normal file
110
crates/app/src/public/html/littleweb/services.lisp
Normal file
|
@ -0,0 +1,110 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
|
||||
; viewing other user's services warning
|
||||
(text "{% if profile.id != user.id -%}")
|
||||
(div
|
||||
("class" "card w-full red flex gap-2 items-center")
|
||||
(text "{{ icon \"skull\" }}")
|
||||
(b
|
||||
(text "Viewing other user's sites! Please be careful.")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; ...
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") ("class" "active") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "littleweb:label.create_new"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_service_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(text "{{ text \"communities:label.name\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.my_services")))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for item in list %}")
|
||||
(a
|
||||
("href" "/services/{{ item.id }}")
|
||||
("class" "card secondary flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "globe"))
|
||||
(b
|
||||
(text "{{ item.name }}")))
|
||||
(span
|
||||
(text "Created ")
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ item.created }}"))
|
||||
(text "; Updated ")
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ item.revision }}"))))
|
||||
(text "{% endfor %}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_service_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"services::create\"]);
|
||||
|
||||
fetch(\"/api/v1/services\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/services/${res.payload}`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -39,12 +39,6 @@
|
|||
("title" "Create post")
|
||||
(icon (text "square-pen")))
|
||||
|
||||
(a
|
||||
("href" "/chats/0/0")
|
||||
("class" "button {% if selected == 'chats' -%}active{%- endif %}")
|
||||
("title" "Chats")
|
||||
(icon (text "message-circle")))
|
||||
|
||||
(a
|
||||
("href" "/requests")
|
||||
("class" "button {% if selected == 'requests' -%}active{%- endif %}")
|
||||
|
@ -65,16 +59,54 @@
|
|||
("id" "notifications_span")
|
||||
(text "{{ user.notification_count }}")))
|
||||
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "flex-row {% if selected == 'chats' or selected == 'journals' -%}active{%- endif %}")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("title" "More services")
|
||||
(icon (text "grip")))
|
||||
|
||||
(div
|
||||
("class" "inner")
|
||||
(a
|
||||
("href" "/chats/0/0")
|
||||
("title" "Chats")
|
||||
(icon (text "message-circle"))
|
||||
(str (text "communities:label.chats")))
|
||||
(a
|
||||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
(str (text "general:link.journals")))
|
||||
(a
|
||||
("href" "/forges")
|
||||
(icon (text "anvil"))
|
||||
(str (text "forge:label.forges")))
|
||||
(a
|
||||
("href" "/developer")
|
||||
(icon (text "code"))
|
||||
(str (text "developer:label.apps")))
|
||||
(text "{% if config.lw_host -%}")
|
||||
(button
|
||||
("onclick" "document.getElementById('littleweb').showModal()")
|
||||
(icon (text "globe"))
|
||||
(str (text "general:link.little_web")))
|
||||
(text "{%- endif %}")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if not hide_user_menu -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "flex-row title")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exlude" "dropdown")
|
||||
("exclude" "dropdown")
|
||||
("style" "gap: var(--pad-1) !important")
|
||||
("title" "Account options")
|
||||
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow")))
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow")))
|
||||
|
||||
(text "{{ components::user_menu() }}"))
|
||||
(text "{%- endif %} {% else %}")
|
||||
|
@ -84,7 +116,7 @@
|
|||
("class" "title")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow")))
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow")))
|
||||
|
||||
(div
|
||||
("class" "inner")
|
||||
|
@ -252,10 +284,17 @@
|
|||
("class" "pillmenu")
|
||||
(text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}")
|
||||
("href" "/@{{ profile.username }}?f=true")
|
||||
("class" "{% if selected == 'posts' -%}active{%- endif %}")
|
||||
(str (text "auth:label.posts")))
|
||||
|
||||
(text "{% if profile.settings.enable_questions -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}?r=true")
|
||||
("class" "{% if selected == 'responses' -%}active{%- endif %}")
|
||||
(str (text "auth:label.responses")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(a
|
||||
("href" "/@{{ profile.username }}/replies")
|
||||
("class" "{% if selected == 'replies' -%}active{%- endif %}")
|
||||
|
@ -311,8 +350,9 @@
|
|||
(span
|
||||
(text "{{ text \"settings:tab.theme\" }}")))
|
||||
(a
|
||||
("href" "#")
|
||||
("data-tab-button" "sessions")
|
||||
("href" "#/sessions")
|
||||
("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])")
|
||||
(text "{{ icon \"cookie\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.sessions\" }}")))
|
||||
|
@ -323,3 +363,17 @@
|
|||
(span
|
||||
(text "{{ text \"settings:tab.connections\" }}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro seller_settings_nav_options() -%}")
|
||||
(a
|
||||
("data-tab-button" "account")
|
||||
("class" "active")
|
||||
("href" "#/account")
|
||||
(icon (text "smile"))
|
||||
(span (str (text "settings:tab.account"))))
|
||||
(a
|
||||
("data-tab-button" "products")
|
||||
("href" "#/products")
|
||||
(icon (text "package"))
|
||||
(span (str (text "marketplace:label.products"))))
|
||||
(text "{%- endmacro %}")
|
||||
|
|
79
crates/app/src/public/html/marketplace/seller.lisp
Normal file
79
crates/app/src/public/html/marketplace/seller.lisp
Normal file
|
@ -0,0 +1,79 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Seller settings - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
|
||||
; nav
|
||||
(div
|
||||
("class" "mobile_nav mobile")
|
||||
; primary nav
|
||||
(div
|
||||
("class" "dropdown")
|
||||
("style" "width: max-content")
|
||||
(button
|
||||
("class" "raised small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "sliders-horizontal"))
|
||||
(span ("class" "current_tab_text") (text "account")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(text "{{ macros::seller_settings_nav_options() }}"))))
|
||||
|
||||
; nav desktop
|
||||
(div
|
||||
("class" "desktop pillmenu")
|
||||
(text "{{ macros::seller_settings_nav_options() }}"))
|
||||
|
||||
; ...
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
("data-tab" "account")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(div
|
||||
("class" "notification")
|
||||
("style" "width: 46px")
|
||||
(icon (text "stripe")))
|
||||
|
||||
(b (str (text "marketplace:label.status"))))
|
||||
|
||||
(div
|
||||
("class" "card")
|
||||
(text "{% if user.seller_data.account_id -%}")
|
||||
(text "{% if user.seller_data.completed_onboarding -%}")
|
||||
; completed onboarding + has stripe account linked
|
||||
(button
|
||||
("onclick" "trigger('seller::login')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.open_seller_dashboard")))
|
||||
(text "{% else %}")
|
||||
; not completed onboarding
|
||||
(p (text "You've not finished setting up your Stripe account."))
|
||||
(p (text "Please complete onboarding to accept payments."))
|
||||
|
||||
(button
|
||||
("onclick" "trigger('seller::onboarding')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.finsh_setting_up_account")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% else %}")
|
||||
; doesn't have a stripe account linked
|
||||
(button
|
||||
("onclick" "trigger('seller::register')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.get_started")))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(div
|
||||
("class" "card w-full lowered hidden flex flex-col gap-2")
|
||||
("data-tab" "products")
|
||||
(div
|
||||
("class" "card w-full flex flex-wrap gap-2")
|
||||
)))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -12,9 +12,12 @@
|
|||
(icon (text "coffee"))
|
||||
(span (text "Welcome to {{ config.name }}!")))
|
||||
(div
|
||||
("class" "card no_p_margin")
|
||||
("class" "card no_p_margin flex flex-col gap-2")
|
||||
(p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!"))
|
||||
(p (text "You'll find out what each achievement is when you get it, so look around!"))))
|
||||
(p (text "You'll find out what each achievement is when you get it, so look around!"))
|
||||
(hr)
|
||||
(span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%"))
|
||||
(div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
|
|
|
@ -62,12 +62,15 @@
|
|||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(text "{{ icon \"user-plus\" }}")
|
||||
(a
|
||||
("href" "/api/v1/auth/user/find/{{ request.id }}")
|
||||
(text "{{ components::avatar(username=request.id, selector_type=\"id\") }}"))
|
||||
(span
|
||||
(text "{{ text \"requests:label.user_follow_request\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(span
|
||||
("class" "flex items-center gap-2")
|
||||
(text "{{ text \"requests:label.user_follow_request_message\" }}"))
|
||||
(div
|
||||
("class" "card flex flex-wrap w-full secondary gap-2")
|
||||
|
@ -92,7 +95,7 @@
|
|||
(text "{%- endif %} {% endfor %} {% for question in questions %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(text "{{ components::question(question=question[0], owner=question[1], profile=user) }}")
|
||||
(text "{{ components::question(question=question[0], owner=question[1], asking_about=question[2], profile=user) }}")
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')")
|
||||
|
@ -129,7 +132,6 @@
|
|||
(text "{{ text \"auth:action.ip_block\" }}")))
|
||||
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"requests:label.answer\" }}")))))
|
||||
(text "{% endfor %}")))
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
("required" "")
|
||||
("minlength" "16")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
|
||||
(script
|
||||
|
|
|
@ -50,6 +50,18 @@
|
|||
(span
|
||||
("class" "notification")
|
||||
(text "{{ profile.request_count }}")))
|
||||
(a
|
||||
("href" "/services?id={{ profile.id }}")
|
||||
("class" "button lowered")
|
||||
(icon (text "globe"))
|
||||
(span
|
||||
(text "Sites")))
|
||||
(a
|
||||
("href" "/domains?id={{ profile.id }}")
|
||||
("class" "button lowered")
|
||||
(icon (text "globe"))
|
||||
(span
|
||||
(text "Domains")))
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_account(event)")
|
||||
|
@ -72,7 +84,7 @@
|
|||
const ui = await ns(\"ui\");
|
||||
const element = document.getElementById(\"mod_options\");
|
||||
|
||||
async function profile_request(do_confirm, path, body) {
|
||||
globalThis.profile_request = async (do_confirm, path, body) => {
|
||||
if (do_confirm) {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
|
@ -155,6 +167,33 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.update_user_secondary_role = async (new_role) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/auth/user/{{ profile.id }}/role/2`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
role: Number.parseInt(new_role),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
ui.refresh_container(element, [\"actions\"]);
|
||||
|
||||
setTimeout(() => {
|
||||
|
@ -168,11 +207,26 @@
|
|||
\"{{ profile.is_verified }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"awaiting_purchase\", \"Awaiting purchase\"],
|
||||
\"{{ profile.awaiting_purchase }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"is_deactivated\", \"Is deactivated\"],
|
||||
\"{{ profile.is_deactivated }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"role\", \"Permission level\"],
|
||||
\"{{ profile.permissions }}\",
|
||||
\"input\",
|
||||
],
|
||||
[
|
||||
[\"secondary_role\", \"Secondary permission level\"],
|
||||
\"{{ profile.secondary_permissions }}\",
|
||||
\"input\",
|
||||
],
|
||||
],
|
||||
null,
|
||||
{
|
||||
|
@ -181,9 +235,22 @@
|
|||
is_verified: value,
|
||||
});
|
||||
},
|
||||
awaiting_purchase: (value) => {
|
||||
profile_request(false, \"awaiting_purchase\", {
|
||||
awaiting_purchase: value,
|
||||
});
|
||||
},
|
||||
is_deactivated: (value) => {
|
||||
profile_request(false, \"deactivated\", {
|
||||
is_deactivated: value,
|
||||
});
|
||||
},
|
||||
role: (new_role) => {
|
||||
return update_user_role(new_role);
|
||||
},
|
||||
secondary_role: (new_role) => {
|
||||
return update_user_secondary_role(new_role);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 100);
|
||||
|
@ -216,6 +283,32 @@
|
|||
("class" "card lowered flex flex-wrap gap-2")
|
||||
(text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "scale"))
|
||||
(span
|
||||
(str (text "mod_panel:label.ban_reason")))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "event.preventDefault(); profile_request(false, 'ban_reason', { reason: event.target.reason.value || '' })")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "title")
|
||||
(str (text "mod_panel:label.ban_reason")))
|
||||
(textarea
|
||||
("type" "text")
|
||||
("name" "reason")
|
||||
("id" "reason")
|
||||
("placeholder" "ban reason")
|
||||
("minlength" "2")
|
||||
(text "{{ profile.ban_reason|remove_script_tags|safe }}")))
|
||||
(button
|
||||
(str (text "general:action.save")))))
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
|
@ -234,6 +327,24 @@
|
|||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
("id" "permission_builder")))
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(text "{{ icon \"blocks\" }}")
|
||||
(span
|
||||
(text "{{ text \"mod_panel:label.permissions_level_builder\" }}")))
|
||||
(button
|
||||
("class" "small lowered")
|
||||
("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
("id" "secondary_permission_builder")))
|
||||
(script
|
||||
(text "setTimeout(async () => {
|
||||
const get_permissions_html = await trigger(
|
||||
|
@ -281,6 +392,33 @@
|
|||
Number.parseInt(\"{{ profile.permissions }}\"),
|
||||
\"permission_builder\",
|
||||
);
|
||||
}, 250);
|
||||
|
||||
setTimeout(async () => {
|
||||
const get_permissions_html = await trigger(
|
||||
\"ui::generate_permissions_ui\",
|
||||
[
|
||||
{
|
||||
// https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.SecondaryPermission.html
|
||||
DEFAULT: 1 << 0,
|
||||
ADMINISTRATOR: 1 << 1,
|
||||
MANAGE_DOMAINS: 1 << 2,
|
||||
MANAGE_SERVICES: 1 << 3,
|
||||
MANAGE_PRODUCTS: 1 << 4,
|
||||
DEVELOPER_PASS: 1 << 5,
|
||||
MANAGE_LETTERS: 1 << 6,
|
||||
},
|
||||
\"secondary_role\",
|
||||
\"add_permission_to_secondary_role\",
|
||||
\"remove_permission_to_secondary_role\",
|
||||
],
|
||||
);
|
||||
|
||||
document.getElementById(\"secondary_permission_builder\").innerHTML =
|
||||
get_permissions_html(
|
||||
Number.parseInt(\"{{ profile.secondary_permissions }}\"),
|
||||
\"secondary_permission_builder\",
|
||||
);
|
||||
}, 250);")))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -37,7 +37,6 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
|
|
|
@ -71,7 +71,6 @@
|
|||
("name" "content")
|
||||
("id" "content")
|
||||
("placeholder" "content")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(div
|
||||
|
@ -81,7 +80,6 @@
|
|||
("class" "flex gap-2")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
|
@ -125,7 +123,6 @@
|
|||
(text "{{ icon \"settings\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:action.configure\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "flex flex-col gap-2 hidden")
|
||||
("data-tab" "configure")
|
||||
|
@ -201,7 +198,7 @@
|
|||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"is_nsfw\", \"Hide from public timelines\"],
|
||||
[\"is_nsfw\", \"Mark as NSFW\"],
|
||||
\"{{ community.context.is_nsfw }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
|
@ -210,6 +207,11 @@
|
|||
settings.content_warning,
|
||||
\"textarea\",
|
||||
],
|
||||
[
|
||||
[\"full_unlist\", \"Unlist from timelines\"],
|
||||
\"{{ user.settings.auto_full_unlist }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"tags\", \"Tags\"],
|
||||
settings.tags.join(\", \"),
|
||||
|
@ -245,6 +247,7 @@
|
|||
},
|
||||
});
|
||||
}, 250);")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if user and user.id == post.owner -%}")
|
||||
(div
|
||||
("class" "card-nest w-full hidden")
|
||||
|
@ -275,7 +278,6 @@
|
|||
("class" "flex gap-2")
|
||||
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"general:action.save\" }}")))))
|
||||
(script
|
||||
(text "async function edit_post_from_form(e) {
|
||||
|
|
|
@ -72,19 +72,25 @@
|
|||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"badge-check\" }}"))
|
||||
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
|
||||
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
|
||||
(span
|
||||
("title" "Supporter")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"star\" }}"))
|
||||
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
|
||||
(text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}")
|
||||
(span
|
||||
("title" "Developer pass")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"id-card-lanyard\" }}"))
|
||||
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
|
||||
(span
|
||||
("title" "Staff")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items-center")
|
||||
(text "{{ icon \"shield-user\" }}"))
|
||||
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
|
||||
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
|
||||
(span
|
||||
("title" "Banned")
|
||||
("style" "color: var(--color-primary);")
|
||||
|
@ -101,6 +107,7 @@
|
|||
(p
|
||||
(text "{{ profile.settings.status }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}")
|
||||
(div
|
||||
("class" "w-full flex")
|
||||
(a
|
||||
|
@ -117,6 +124,7 @@
|
|||
(text "{{ profile.following_count }}"))
|
||||
(span
|
||||
(text "{{ text \"auth:label.following\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if is_following_you -%}")
|
||||
(b
|
||||
("class" "notification chip w-content flex items-center gap-2")
|
||||
|
@ -219,12 +227,24 @@
|
|||
(text "{{ icon \"user-minus\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.unfollow\" }}")))
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -278,7 +298,7 @@
|
|||
]);
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/follow\",
|
||||
\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
|
@ -342,6 +362,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))))
|
||||
(text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}")
|
||||
(div
|
||||
|
|
|
@ -24,12 +24,24 @@
|
|||
(div
|
||||
("class" "card w-full secondary flex gap-2")
|
||||
(text "{% if user -%} {% if not is_blocking -%}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -58,6 +70,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
|
||||
(div
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
|
||||
(div
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
|
|
|
@ -20,7 +20,11 @@
|
|||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "{{ text \"auth:label.private_profile_message\" }}"))
|
||||
(span
|
||||
("class" "no_p_margin")
|
||||
(text "{{ profile.settings.private_biography|markdown|safe }}"))
|
||||
(div
|
||||
("class" "card w-full secondary flex gap-2")
|
||||
(text "{% if user -%} {% if not is_following -%}")
|
||||
|
@ -31,6 +35,7 @@
|
|||
(text "{{ icon \"user-plus\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.request_to_follow\" }}")))
|
||||
(text "{% if follow_requested -%}")
|
||||
(button
|
||||
("onclick" "cancel_follow_user(event)")
|
||||
("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}")
|
||||
|
@ -38,7 +43,7 @@
|
|||
(text "{{ icon \"user-minus\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.cancel_follow_request\" }}")))
|
||||
(text "{% else %}")
|
||||
(text "{%- endif %} {% else %}")
|
||||
(button
|
||||
("onclick" "toggle_follow_user(event)")
|
||||
("class" "lowered red")
|
||||
|
@ -47,12 +52,24 @@
|
|||
(span
|
||||
(text "{{ text \"auth:action.unfollow\" }}")))
|
||||
(text "{%- endif %} {% if not is_blocking -%}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -64,7 +81,7 @@
|
|||
(script
|
||||
(text "globalThis.toggle_follow_user = async (e) => {
|
||||
await trigger(\"atto::debounce\", [\"users::follow\"]);
|
||||
fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", {
|
||||
fetch(\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
@ -151,6 +168,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
|
||||
(div
|
||||
|
|
55
crates/app/src/public/html/profile/responses.lisp
Normal file
55
crates/app/src/public/html/profile/responses.lisp
Normal file
|
@ -0,0 +1,55 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex gap-2 items-center")
|
||||
(text "{{ icon \"pin\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.pinned\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-4")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex gap-2 justify-between items-center")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(text "{% if not tag -%} {{ icon \"clock\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.recent_posts\" }}"))
|
||||
(text "{% else %} {{ icon \"tag\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.recent_with_tag\" }}: ")
|
||||
(b
|
||||
(text "{{ tag }}")))
|
||||
(text "{%- endif %}"))
|
||||
(text "{% if user -%}")
|
||||
(a
|
||||
("href" "/search?profile={{ profile.id }}")
|
||||
("class" "button lowered small")
|
||||
(text "{{ icon \"search\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:link.search\" }}")))
|
||||
(text "{%- endif %}"))
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(async () => {
|
||||
await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
(await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
|
||||
console.log(\"created profile timeline\");
|
||||
}, 1000);"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -35,6 +35,87 @@
|
|||
(text "{{ macros::profile_settings_nav_options() }}"))
|
||||
|
||||
; ...
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
("data-tab" "presets")
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(a
|
||||
("href" "#/account")
|
||||
("class" "button secondary")
|
||||
(icon (text "arrow-left"))
|
||||
(span
|
||||
(str (text "general:action.back"))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex items-center gap-2 small")
|
||||
(icon (text "cooking-pot"))
|
||||
(span
|
||||
(str (text "settings:tab.presets"))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 secondary")
|
||||
(p (text "Not sure where to start? Try some settings presets!"))
|
||||
(details
|
||||
("class" "w-full accordion")
|
||||
(summary
|
||||
(icon (text "rss"))
|
||||
(text "Microblogging"))
|
||||
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "Focus on yourself and your communities."))
|
||||
(ul ("id" "preset_microblogging_ul"))
|
||||
(button
|
||||
("onclick" "apply_preset(PRESET_MICROBLOGGING)")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.apply")))))
|
||||
|
||||
(details
|
||||
("class" "w-full accordion")
|
||||
(summary
|
||||
(icon (text "message-circle-heart"))
|
||||
(text "Q&A"))
|
||||
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "Just like Neospring!"))
|
||||
(ul ("id" "preset_questions_ul"))
|
||||
(button
|
||||
("onclick" "apply_preset(PRESET_QUESTIONS)")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.apply")))))
|
||||
|
||||
(details
|
||||
("class" "w-full accordion")
|
||||
(summary
|
||||
(icon (text "key"))
|
||||
(text "Private"))
|
||||
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following."))
|
||||
(ul ("id" "preset_private_ul"))
|
||||
(button
|
||||
("onclick" "apply_preset(PRESET_PRIVATE)")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.apply")))))
|
||||
|
||||
(details
|
||||
("class" "w-full accordion")
|
||||
(summary
|
||||
(icon (text "eye-closed"))
|
||||
(text "NSFW"))
|
||||
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly."))
|
||||
(ul ("id" "preset_nsfw_ul"))
|
||||
(button
|
||||
("onclick" "apply_preset(PRESET_NSFW)")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.apply")))))))))
|
||||
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2")
|
||||
("data-tab" "account")
|
||||
|
@ -56,6 +137,12 @@
|
|||
(text "{{ icon \"rss\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.following\" }}")))
|
||||
(a
|
||||
("data-tab-button" "account/followers")
|
||||
("href" "#/account/followers")
|
||||
(text "{{ icon \"rss\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.followers\" }}")))
|
||||
(a
|
||||
("data-tab-button" "account/blocks")
|
||||
("href" "#/account/blocks")
|
||||
|
@ -134,15 +221,16 @@
|
|||
("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}")
|
||||
(text "All (questions)"))
|
||||
(text "{% for stack in stacks %}")
|
||||
(option
|
||||
("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}")
|
||||
("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}")
|
||||
(text "{{ stack.name }} (stack)"))
|
||||
(text "<option
|
||||
value='{\"Stack\":\"{{ stack.id }}\"}'
|
||||
selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
|
||||
>
|
||||
{{ stack.name }} (stack)
|
||||
</option>")
|
||||
(text "{% endfor %}"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "This represents the timeline the home button takes you
|
||||
to."))))
|
||||
(text "This represents the timeline the home button takes you to."))))
|
||||
(div
|
||||
("class" "card-nest desktop")
|
||||
("ui_ident" "notifications")
|
||||
|
@ -188,7 +276,6 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))))
|
||||
|
@ -197,30 +284,50 @@
|
|||
("ui_ident" "delete_account")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2 red")
|
||||
(text "{{ icon \"skull\" }}")
|
||||
(b
|
||||
(text "{{ text \"settings:label.delete_account\" }}")))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "delete_account(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "current_password")
|
||||
(text "{{ text \"settings:label.current_password\" }}"))
|
||||
(input
|
||||
("type" "password")
|
||||
("name" "current_password")
|
||||
("id" "current_password")
|
||||
("placeholder" "current_password")
|
||||
("required" "")
|
||||
("minlength" "6")
|
||||
("autocomplete" "off")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"trash\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.delete\" }}")))))
|
||||
(icon (text "skull"))
|
||||
(b (str (text "communities:label.danger_zone"))))
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(details
|
||||
("class" "accordion")
|
||||
(summary
|
||||
("class" "flex items-center gap-2")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(str (text "settings:label.deactivate_account")))
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion."))
|
||||
(button
|
||||
("onclick" "deactivate_account()")
|
||||
(icon (text "lock"))
|
||||
(span
|
||||
(str (text "settings:label.deactivate"))))))
|
||||
(details
|
||||
("class" "accordion")
|
||||
(summary
|
||||
("class" "flex items-center gap-2")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(str (text "settings:label.delete_account")))
|
||||
(form
|
||||
("class" "inner flex flex-col gap-2")
|
||||
("onsubmit" "delete_account(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "current_password")
|
||||
(text "{{ text \"settings:label.current_password\" }}"))
|
||||
(input
|
||||
("type" "password")
|
||||
("name" "current_password")
|
||||
("id" "current_password")
|
||||
("placeholder" "current_password")
|
||||
("required" "")
|
||||
("minlength" "6")
|
||||
("autocomplete" "off")))
|
||||
(button
|
||||
(text "{{ icon \"trash\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.delete\" }}")))))))
|
||||
(button
|
||||
("onclick" "save_settings()")
|
||||
("id" "save_button")
|
||||
|
@ -331,7 +438,6 @@
|
|||
("minlength" "6")
|
||||
("autocomplete" "off")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))))))
|
||||
|
@ -375,7 +481,7 @@
|
|||
(text "{{ icon \"external-link\" }}")
|
||||
(span
|
||||
(text "{{ text \"requests:action.view_profile\" }}")))))
|
||||
(text "{% endfor %}"))))
|
||||
(text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}"))))
|
||||
(script
|
||||
(text "globalThis.toggle_follow_user = async (uid) => {
|
||||
await trigger(\"atto::debounce\", [\"users::follow\"]);
|
||||
|
@ -391,6 +497,62 @@
|
|||
]);
|
||||
});
|
||||
};")))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
("data-tab" "account/followers")
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(a
|
||||
("href" "#/account")
|
||||
("class" "button secondary")
|
||||
(text "{{ icon \"arrow-left\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.back\" }}")))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex items-center gap-2 small")
|
||||
(text "{{ icon \"rss\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.followers\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for userfollow in followers %} {% set user = userfollow[1] %}")
|
||||
(div
|
||||
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "lowered red small")
|
||||
("onclick" "force_unfollow_me('{{ user.id }}')")
|
||||
(text "{{ icon \"user-minus\" }}")
|
||||
(span
|
||||
(str (text "stacks:label.remove"))))
|
||||
(a
|
||||
("href" "/@{{ user.username }}")
|
||||
("class" "button lowered small")
|
||||
(text "{{ icon \"external-link\" }}")
|
||||
(span
|
||||
(text "{{ text \"requests:action.view_profile\" }}")))))
|
||||
(text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}"))))
|
||||
(script
|
||||
(text "globalThis.force_unfollow_me = async (uid) => {
|
||||
await trigger(\"atto::debounce\", [\"users::follow\"]);
|
||||
|
||||
fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};")))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
("data-tab" "account/blocks")
|
||||
|
@ -446,6 +608,30 @@
|
|||
("class" "button lowered small")
|
||||
(icon (text "external-link"))
|
||||
(span (str (text "requests:action.view_profile"))))))
|
||||
(text "{% endfor %}")))
|
||||
|
||||
; ip blocks
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex items-center gap-2 small")
|
||||
(text "{{ icon \"wifi\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:label.ips\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for ip in ipblocks %}")
|
||||
(div
|
||||
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
|
||||
(span
|
||||
(text "Block from: ") (span ("class" "date") (text "{{ ip.created }}")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])")
|
||||
("class" "lowered small red")
|
||||
(icon (text "x"))
|
||||
(span (str (text "auth:action.unblock"))))))
|
||||
(text "{% endfor %}")))))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
|
@ -468,32 +654,51 @@
|
|||
(div
|
||||
("class" "card flex flex-col gap-2 secondary")
|
||||
(text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}")
|
||||
(div
|
||||
("class" "card flex flex-wrap gap-2 items-center justify-between")
|
||||
(details
|
||||
("class" "accordion w-full")
|
||||
(summary
|
||||
("class" "card flex flex-wrap gap-2 items-center justify-between")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(icon_class (text "chevron-down") (text "dropdown_arrow"))
|
||||
(b
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ upload.created }}"))
|
||||
(text " ({{ upload.what }})")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "raised small")
|
||||
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
|
||||
(text "{{ icon \"view\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.view\" }}")))
|
||||
(button
|
||||
("class" "raised small red")
|
||||
("onclick" "remove_upload('{{ upload.id }}')")
|
||||
(text "{{ icon \"x\" }}")
|
||||
(span
|
||||
(text "{{ text \"stacks:label.remove\" }}")))))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
|
||||
("style" "cursor: pointer")
|
||||
(text "{{ icon \"file-image\" }}")
|
||||
(b
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ upload.created }}"))
|
||||
(text "({{ upload.what }})")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
|
||||
(text "{{ icon \"view\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.view\" }}")))
|
||||
(button
|
||||
("class" "lowered small red")
|
||||
("onclick" "remove_upload('{{ upload.id }}')")
|
||||
(text "{{ icon \"x\" }}")
|
||||
(span
|
||||
(text "{{ text \"stacks:label.remove\" }}")))))
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(form
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
("onsubmit" "update_upload_alt(event, '{{ upload.id }}')")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text"))))
|
||||
(textarea
|
||||
("id" "alt_{{ upload.id }}")
|
||||
("name" "alt")
|
||||
("class" "w-full")
|
||||
("placeholder" "Alternative text")
|
||||
(text "{{ upload.alt|safe }}")))
|
||||
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save"))))))
|
||||
(text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}")
|
||||
(script
|
||||
(text "globalThis.remove_upload = async (id) => {
|
||||
|
@ -515,6 +720,26 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.update_upload_alt = async (e, id) => {
|
||||
e.preventDefault();
|
||||
fetch(`/api/v1/uploads/${id}/alt`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
alt: e.target.alt.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))))))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
|
@ -619,6 +844,29 @@
|
|||
(div
|
||||
("class" "card flex flex-col gap-2 secondary")
|
||||
(text "{% if config.stripe -%}")
|
||||
(text "{% if has_developer_pass or is_supporter -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "supporter_card")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "credit-card"))
|
||||
(b
|
||||
(text "Manage billing")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(p
|
||||
(text "You currently have a subscription! You can manage your billing information below. ")
|
||||
(b
|
||||
(text "Please use your email address you supplied when paying to log into the billing portal."))
|
||||
(text " You can manage all of your active subscriptions through this page."))
|
||||
(a
|
||||
("href" "{{ config.stripe.billing_portal_url }}")
|
||||
("class" "button lowered")
|
||||
("target" "_blank")
|
||||
(text "Manage billing"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "supporter_card")
|
||||
|
@ -628,92 +876,55 @@
|
|||
(b
|
||||
(text "Supporter status")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
("class" "card flex flex-col gap-2 no_p_margin")
|
||||
(text "{% if is_supporter -%}")
|
||||
(p
|
||||
(text "You ")
|
||||
(b
|
||||
(text "are "))
|
||||
(text "a supporter! Thank you for all
|
||||
that you do. You can manage your billing
|
||||
information below.")
|
||||
(b
|
||||
(text "Please use your email address you supplied
|
||||
when paying to login to the billing
|
||||
portal.")))
|
||||
(a
|
||||
("href" "{{ config.stripe.billing_portal_url }}")
|
||||
("class" "button lowered")
|
||||
("target" "_blank")
|
||||
(text "Manage billing"))
|
||||
(b (text "are "))
|
||||
(text "a supporter! Thank you for all that you do."))
|
||||
(text "{% else %}")
|
||||
(p
|
||||
(text "You're ")
|
||||
(b
|
||||
(text "not "))
|
||||
(text "currently a supporter! No
|
||||
pressure, but it helps us do some pretty cool
|
||||
things! As a supporter, you'll get:"))
|
||||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Vanity badge on profile"))
|
||||
(li
|
||||
(text "No more supporter ads (duh)"))
|
||||
(li
|
||||
(text "Ability to upload gif avatars/banners"))
|
||||
(li
|
||||
(text "Be an admin/owner of up to 10 communities"))
|
||||
(li
|
||||
(text "Use custom CSS on your profile"))
|
||||
(li
|
||||
(text "Use community emojis outside of
|
||||
their community"))
|
||||
(li
|
||||
(text "Upload and use gif emojis"))
|
||||
(li
|
||||
(text "Create infinite stack timelines"))
|
||||
(li
|
||||
(text "Upload images to posts"))
|
||||
(li
|
||||
(text "Save infinite post drafts"))
|
||||
(li
|
||||
(text "Ability to search through all posts"))
|
||||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
(text "Create more than 1 app"))
|
||||
(li
|
||||
(text "Create up to 10 stack blocks"))
|
||||
(li
|
||||
(text "Add unlimited users to stacks"))
|
||||
(li
|
||||
(text "Increased proxied image size"))
|
||||
(li
|
||||
(text "Create infinite journals"))
|
||||
(li
|
||||
(text "Create infinite notes in each journal"))
|
||||
(li
|
||||
(text "Publish up to 50 notes"))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(li
|
||||
(text "Create up to 48 invite codes"))
|
||||
(text "{%- endif %}"))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
("target" "_blank")
|
||||
(text "Become a supporter"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Please use your")
|
||||
(b
|
||||
(text "real email"))
|
||||
(text "when
|
||||
completing payment. It is required to manage
|
||||
your billing settings."))
|
||||
(text "{{ components::become_supporter_button() }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "supporter_card")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "id-card-lanyard"))
|
||||
(b
|
||||
(text "Developer pass status")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 no_p_margin")
|
||||
(text "{% if has_developer_pass -%}")
|
||||
(p
|
||||
(text "You currently have a developer pass!"))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::get_developer_pass_button() }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
|
||||
(form
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "update_invite_code(event)")
|
||||
(p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling."))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("name" "invite_code")
|
||||
("required" "")
|
||||
("id" "invite_code")))
|
||||
|
||||
(button
|
||||
(text "Submit")))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endif %}")))))
|
||||
(div
|
||||
("class" "w-full hidden flex flex-col gap-2")
|
||||
|
@ -743,7 +954,6 @@
|
|||
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
|
||||
("class" "w-content"))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")))
|
||||
(span
|
||||
("class" "fade")
|
||||
|
@ -771,11 +981,48 @@
|
|||
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
|
||||
("class" "w-content"))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Use an image of 1100x350px for the best results.")))))
|
||||
(text "Use an image of 1100x350px for the best results."))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "default_profile_page")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "Default profile tab")))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)")
|
||||
(option
|
||||
("value" "Posts")
|
||||
("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}")
|
||||
(text "Posts"))
|
||||
(option
|
||||
("value" "Responses")
|
||||
("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}")
|
||||
(text "Responses")))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "This represents the timeline that is shown on your profile by default."))))
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("ui_ident" "show_presets")
|
||||
(hr ("class" "margin"))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "Not sure what to do?")))
|
||||
(div
|
||||
("class" "card no_p_margin")
|
||||
(p
|
||||
(text "Quickly set up your account with ")
|
||||
(a ("href" "/settings#/presets") (text "settings presets"))
|
||||
(text "!"))))))
|
||||
(button
|
||||
("onclick" "save_settings()")
|
||||
("id" "save_button")
|
||||
|
@ -851,7 +1098,6 @@
|
|||
("class" "card w-full flex flex-wrap gap-2")
|
||||
("ui_ident" "import_export")
|
||||
(button
|
||||
("class" "primary")
|
||||
("onclick" "import_theme_settings()")
|
||||
(text "{{ icon \"upload\" }}")
|
||||
(span
|
||||
|
@ -1174,6 +1420,11 @@
|
|||
globalThis.delete_account = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// {% if user.permissions|has_supporter %}
|
||||
alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\");
|
||||
return;
|
||||
// {% endif %}
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
|
@ -1357,6 +1608,112 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.update_invite_code = async (e) => {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"invite_codes::try\"]);
|
||||
fetch(\"/api/v1/auth/user/me/invite_code\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
invite_code: e.target.invite_code.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.deactivate_account = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you want to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({ is_deactivated: true }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
// presets
|
||||
globalThis.apply_preset = async (preset) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This will change all listed settings to their listed values.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const x of preset) {
|
||||
window.SETTING_SET_FUNCTIONS[0](x[0], x[1])
|
||||
}
|
||||
|
||||
save_settings();
|
||||
}
|
||||
|
||||
globalThis.render_preset_lis = (preset, id) => {
|
||||
for (const x of preset) {
|
||||
document.getElementById(id).innerHTML += `<li><b>${x[0]}:</b> ${x[1]}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.PRESET_MICROBLOGGING = [
|
||||
[\"default_timeline\", \"All\"],
|
||||
[\"all_timeline_hide_answers\", true],
|
||||
];
|
||||
|
||||
globalThis.PRESET_QUESTIONS = [
|
||||
[\"default_timeline\", \"Following\"],
|
||||
[\"auto_full_unlist\", true],
|
||||
[\"enable_questions\", true],
|
||||
[\"allow_anonymous_questions\", true],
|
||||
[\"enable_drawings\", true],
|
||||
[\"hide_extra_post_tabs\", true],
|
||||
];
|
||||
|
||||
globalThis.PRESET_PRIVATE = [
|
||||
[\"private_profile\", true],
|
||||
[\"private_last_seen\", true],
|
||||
[\"private_communities\", true],
|
||||
[\"private_chats\", true],
|
||||
[\"require_account\", true],
|
||||
];
|
||||
|
||||
globalThis.PRESET_NSFW = [
|
||||
[\"auto_unlist\", true],
|
||||
[\"show_nsfw\", true],
|
||||
];
|
||||
|
||||
render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\");
|
||||
render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\");
|
||||
render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\");
|
||||
render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\");
|
||||
|
||||
// ...
|
||||
const account_settings =
|
||||
document.getElementById(\"account_settings\");
|
||||
const profile_settings =
|
||||
|
@ -1375,6 +1732,8 @@
|
|||
\"supporter_ad\",
|
||||
\"change_avatar\",
|
||||
\"change_banner\",
|
||||
\"default_profile_page\",
|
||||
\"show_presets\",
|
||||
]);
|
||||
ui.refresh_container(theme_settings, [
|
||||
\"supporter_ad\",
|
||||
|
@ -1397,6 +1756,15 @@
|
|||
settings.biography,
|
||||
\"textarea\",
|
||||
],
|
||||
[
|
||||
[\"private_biography\", \"Private biography\"],
|
||||
settings.private_biography,
|
||||
\"textarea\",
|
||||
{
|
||||
embed_html:
|
||||
'<span class=\"fade\">This biography is only shown to users you are not following while your account is private.</span>',
|
||||
},
|
||||
],
|
||||
[[\"status\", \"Status\"], settings.status, \"textarea\"],
|
||||
[
|
||||
[\"warning\", \"Profile warning\"],
|
||||
|
@ -1490,11 +1858,45 @@
|
|||
\"{{ profile.settings.auto_unlist }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"auto_full_unlist\", \"Only publish my posts to my profile\"],
|
||||
\"{{ profile.settings.auto_full_unlist }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[],
|
||||
\"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\",
|
||||
\"text\",
|
||||
],
|
||||
[
|
||||
[\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
|
||||
\"{{ profile.settings.all_timeline_hide_answers }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_associated_blocked_users\",
|
||||
\"Hide users that you've blocked on your other accounts from timelines\",
|
||||
],
|
||||
\"{{ profile.settings.hide_associated_blocked_users }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_from_social_lists\",
|
||||
\"Hide my profile from social lists (followers/following)\",
|
||||
],
|
||||
\"{{ profile.settings.hide_from_social_lists }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_social_follows\",
|
||||
\"Hide followers/following links on my profile\",
|
||||
],
|
||||
\"{{ profile.settings.hide_social_follows }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Questions\", \"title\"],
|
||||
[
|
||||
[
|
||||
|
@ -1553,6 +1955,11 @@
|
|||
\"{{ profile.settings.disable_gpa_fun }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"disable_achievements\", \"Disable achievements\"],
|
||||
\"{{ profile.settings.disable_achievements }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
@ -1729,6 +2136,35 @@
|
|||
description: \"Hover state for secondary buttons.\",
|
||||
},
|
||||
],
|
||||
// online indicator
|
||||
[[], \"\", \"divider\"],
|
||||
[
|
||||
[\"theme_color_online\", \"Online indicator (online)\"],
|
||||
\"{{ profile.settings.theme_color_online }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The green dot next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
[
|
||||
[\"theme_color_idle\", \"Online indicator (idle)\"],
|
||||
\"{{ profile.settings.theme_color_idle }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The yellow dot next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
[
|
||||
[\"theme_color_offline\", \"Online indicator (offline)\"],
|
||||
\"{{ profile.settings.theme_color_offline }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The grey next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
if (can_use_custom_css) {
|
||||
|
|
|
@ -35,10 +35,12 @@
|
|||
|
||||
globalThis.no_policy = false;
|
||||
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
|
||||
globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\";
|
||||
</script>")
|
||||
|
||||
(script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
(script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
(script ("defer" "true") ("src" "/js/proto_links.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
|
||||
(meta ("name" "theme-color") ("content" "{{ config.color }}"))
|
||||
(meta ("name" "description") ("content" "{{ config.description }}"))
|
||||
|
@ -68,11 +70,130 @@
|
|||
(str (text "general:label.account_banned")))
|
||||
|
||||
(div
|
||||
("class" "card")
|
||||
(str (text "general:label.account_banned_body"))))))
|
||||
|
||||
("class" "card flex flex-col gap-2 no_p_margin")
|
||||
(str (text "general:label.account_banned_body"))
|
||||
(hr)
|
||||
(span ("class" "fade") (text "The following reason was provided by a moderator:"))
|
||||
(div
|
||||
("class" "card lowered w-full")
|
||||
(text "{{ user.ban_reason|markdown|safe }}"))))))
|
||||
; if we aren't banned, just show the page body
|
||||
(text "{% else %} {% block body %}{% endblock %} {%- endif %}")
|
||||
(text "{% elif user and user.awaiting_purchase %}")
|
||||
; account waiting for payment message
|
||||
(article
|
||||
(main
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2 red")
|
||||
(icon (text "frown"))
|
||||
(str (text "general:label.must_activate_account")))
|
||||
|
||||
(div
|
||||
("class" "card no_p_margin flex flex-col gap-2")
|
||||
(p (text "Since you didn't provide an invite code, you'll need to activate your account to use it."))
|
||||
(p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code."))
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
(text "{{ components::become_supporter_button() }}"))
|
||||
(p (text "Alternatively, you can provide an invite code to activate your account."))
|
||||
(form
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "update_invite_code(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("name" "invite_code")
|
||||
("required" "")
|
||||
("id" "invite_code")))
|
||||
|
||||
(button
|
||||
(text "Submit")))
|
||||
|
||||
(script
|
||||
(text "async function update_invite_code(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"invite_codes::try\"]);
|
||||
fetch(\"/api/v1/auth/user/me/invite_code\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
invite_code: e.target.invite_code.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))))))
|
||||
(text "{% elif user.is_deactivated -%}")
|
||||
; account deactivated message
|
||||
(article
|
||||
(main
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2 red")
|
||||
(icon (text "frown"))
|
||||
(str (text "settings:label.account_deactivated")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 no_p_margin")
|
||||
(p (text "You have deactivated your account. You can undo this with the button below if you'd like."))
|
||||
(hr)
|
||||
(button
|
||||
("onclick" "activate_account()")
|
||||
(icon (text "lock-open"))
|
||||
(str (text "settings:label.activate_account")))))))
|
||||
|
||||
(script
|
||||
(text "globalThis.activate_account = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you want to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({ is_deactivated: false }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
};"))
|
||||
(text "{% else %}")
|
||||
; page body
|
||||
(text "{% block body %}{% endblock %}")
|
||||
(text "{%- endif %}")
|
||||
(text "<!-- html_footer_goes_here -->"))
|
||||
|
||||
(text "{% include \"body.html\" %}")))
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
|
|
|
@ -114,7 +114,6 @@
|
|||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}"))))))
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -24,6 +24,18 @@
|
|||
(a
|
||||
("href" "/communities/search")
|
||||
(text "searching for a community to join!")))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "Need help getting started?")))
|
||||
(div
|
||||
("class" "card no_p_margin")
|
||||
(p
|
||||
(text "Quickly set up your account with ")
|
||||
(a ("href" "/settings#/presets") (text "settings presets"))
|
||||
(text "!"))))
|
||||
(text "{% else %}")
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}")
|
||||
(text "{% for post in list %}
|
||||
{% if post[2].read_access == \"Everybody\" -%}
|
||||
{% if post[0].context.repost and post[0].context.repost.reposting -%}
|
||||
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
|
||||
{% else %}
|
||||
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }}
|
||||
{%- endif %}
|
||||
{% if post[0].context.repost and post[0].context.repost.reposting -%}
|
||||
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
|
||||
{% else %}
|
||||
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }}
|
||||
{%- endif %}
|
||||
{% endfor %}")
|
||||
(datalist
|
||||
("ui_ident" "list_posts_{{ page }}")
|
||||
(text "{% for post in list -%}")
|
||||
(option ("value" "{{ post[0].id }}"))
|
||||
(option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}"))
|
||||
(text "{%- endfor %}"))
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(div
|
||||
|
|
|
@ -6,4 +6,11 @@
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="460" height="460" fill="#E793B9" />
|
||||
<ellipse cx="125" cy="205" rx="23" ry="24" fill="#FFBFDD" />
|
||||
<circle cx="334" cy="205" r="24" fill="#FFBFDD" />
|
||||
<path
|
||||
d="M281.204 235.5C284.405 235.5 287.05 238.115 286.488 241.266C285.823 244.997 284.514 248.655 282.585 252.147C279.67 257.424 275.398 262.22 270.012 266.259C264.626 270.298 258.233 273.503 251.196 275.689C244.159 277.875 236.617 279 229 279C221.383 279 213.841 277.875 206.804 275.689C199.767 273.503 193.374 270.298 187.988 266.259C182.602 262.22 178.33 257.424 175.415 252.147C173.486 248.655 172.177 244.997 171.512 241.266C170.95 238.115 173.595 235.5 176.796 235.5V235.5C179.998 235.5 182.533 238.125 183.23 241.25C183.809 243.841 184.779 246.381 186.125 248.819C188.458 253.042 191.876 256.879 196.185 260.111C200.495 263.343 205.61 265.907 211.241 267.656C216.871 269.405 222.906 270.305 229 270.305C235.094 270.305 241.129 269.405 246.759 267.656C252.39 265.907 257.505 263.343 261.815 260.111C266.124 256.879 269.542 253.042 271.875 248.819C273.221 246.381 274.191 243.841 274.77 241.25C275.467 238.125 278.002 235.5 281.204 235.5V235.5Z"
|
||||
fill="#FFBFDD"
|
||||
fill-opacity="0.984314"
|
||||
/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 1.3 KiB |
1
crates/app/src/public/images/vendor/stripe.svg
vendored
Normal file
1
crates/app/src/public/images/vendor/stripe.svg
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 468 222.5" style="enable-background:new 0 0 468 222.5" xml:space="preserve"><style>.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#635bff}</style><path class="st0" d="M414 113.4c0-25.6-12.4-45.8-36.1-45.8-23.8 0-38.2 20.2-38.2 45.6 0 30.1 17 45.3 41.4 45.3 11.9 0 20.9-2.7 27.7-6.5v-20c-6.8 3.4-14.6 5.5-24.5 5.5-9.7 0-18.3-3.4-19.4-15.2h48.9c0-1.3.2-6.5.2-8.9zm-49.4-9.5c0-11.3 6.9-16 13.2-16 6.1 0 12.6 4.7 12.6 16h-25.8zM301.1 67.6c-9.8 0-16.1 4.6-19.6 7.8l-1.3-6.2h-22v116.6l25-5.3.1-28.3c3.6 2.6 8.9 6.3 17.7 6.3 17.9 0 34.2-14.4 34.2-46.1-.1-29-16.6-44.8-34.1-44.8zm-6 68.9c-5.9 0-9.4-2.1-11.8-4.7l-.1-37.1c2.6-2.9 6.2-4.9 11.9-4.9 9.1 0 15.4 10.2 15.4 23.3 0 13.4-6.2 23.4-15.4 23.4zM223.8 61.7l25.1-5.4V36l-25.1 5.3zM223.8 69.3h25.1v87.5h-25.1zM196.9 76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7 15.9-6.3 19-5.2v-23c-3.2-1.2-14.9-3.4-20.8 7.4zM146.9 47.6l-24.4 5.2-.1 80.1c0 14.8 11.1 25.7 25.9 25.7 8.2 0 14.2-1.5 17.5-3.3V135c-3.2 1.3-19 5.9-19-8.9V90.6h19V69.3h-19l.1-21.7zM79.3 94.7c0-3.9 3.2-5.4 8.5-5.4 7.6 0 17.2 2.3 24.8 6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6C67.5 67.6 54 78.2 54 95.9c0 27.6 38 23.2 38 35.1 0 4.6-4 6.1-9.6 6.1-8.3 0-18.9-3.4-27.3-8v23.8c9.3 4 18.7 5.7 27.3 5.7 20.8 0 35.1-10.3 35.1-28.2-.1-29.8-38.2-24.5-38.2-35.7z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
338
crates/app/src/public/js/app_sdk.js
Normal file
338
crates/app/src/public/js/app_sdk.js
Normal file
|
@ -0,0 +1,338 @@
|
|||
import {
|
||||
JSONParse as json_parse,
|
||||
JSONStringify as json_stringify,
|
||||
} from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js";
|
||||
|
||||
/// PKCE key generation.
|
||||
export const PKCE = {
|
||||
/// Create a verifier for [`PKCE::challenge`].
|
||||
verifier: async (length) => {
|
||||
let text = "";
|
||||
const possible =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
text += possible.charAt(
|
||||
Math.floor(Math.random() * possible.length),
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
/// Create the challenge needed to request a user token.
|
||||
challenge: async (verifier) => {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await window.crypto.subtle.digest("SHA-256", data);
|
||||
return btoa(
|
||||
String.fromCharCode.apply(null, [...new Uint8Array(digest)]),
|
||||
)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
},
|
||||
};
|
||||
|
||||
export default function tetratto({
|
||||
host = "https://tetratto.com",
|
||||
api_key = null,
|
||||
app_id = 0n,
|
||||
user_token = null,
|
||||
user_verifier = null,
|
||||
user_id = 0n,
|
||||
}) {
|
||||
const GRANT_URL = `${host}/auth/connections_link/app/${app_id}`;
|
||||
|
||||
function api_promise(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (res.ok) {
|
||||
resolve(res.payload);
|
||||
} else {
|
||||
reject(res.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// app data
|
||||
async function app() {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/app`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function check_ip(ip) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/bans/${ip}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function query(body) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
body: json_stringify(body),
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function insert(key, value) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
body: json_stringify({
|
||||
key,
|
||||
value,
|
||||
}),
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function update(id, value) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/${id}/value`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
body: json_stringify({
|
||||
value,
|
||||
}),
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function rename(id, key) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/${id}/key`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
body: json_stringify({
|
||||
key,
|
||||
}),
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function remove_query(body) {
|
||||
if (!api_key) {
|
||||
throw Error("No API key provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/app_data/query`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Atto-Secret-Key": api_key,
|
||||
},
|
||||
body: json_stringify(body),
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// user connection
|
||||
/// Extract the verifier, token, and user ID from the URL.
|
||||
function extract_verifier_token_uid() {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return [
|
||||
search.get("verifier"),
|
||||
search.get("token"),
|
||||
BigInt(search.get("uid")),
|
||||
];
|
||||
}
|
||||
|
||||
/// Accept a connection grant and store it in localStorage.
|
||||
function localstorage_accept_connection() {
|
||||
const [verifier, token, uid] = extract_verifier_token_uid();
|
||||
window.localStorage.setItem("atto:grant.verifier", verifier);
|
||||
window.localStorage.setItem("atto:grant.token", token);
|
||||
window.localStorage.setItem("atto:grant.user_id", uid);
|
||||
}
|
||||
|
||||
async function refresh_token() {
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(
|
||||
`${host}/api/v1/auth/user/${user_id}/grants/${app_id}/refresh`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Cookie": `Atto-Grant=${user_token}`,
|
||||
},
|
||||
body: json_stringify({
|
||||
verifier: user_verifier,
|
||||
}),
|
||||
},
|
||||
)
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function request({
|
||||
route,
|
||||
method = "POST",
|
||||
content_type = "application/json",
|
||||
body = {},
|
||||
}) {
|
||||
if (!user_token) {
|
||||
throw Error("No user token provided.");
|
||||
}
|
||||
|
||||
return api_promise(
|
||||
json_parse(
|
||||
await (
|
||||
await fetch(`${host}/api/v1/${route}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
method === "GET" ? null : content_type,
|
||||
"X-Cookie": `Atto-Grant=${user_token}`,
|
||||
},
|
||||
body:
|
||||
method === "GET"
|
||||
? null
|
||||
: content_type === "application/json"
|
||||
? json_stringify(body)
|
||||
: body,
|
||||
})
|
||||
).text(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ...
|
||||
return {
|
||||
user_id,
|
||||
user_token,
|
||||
user_verifier,
|
||||
app_id,
|
||||
api_key,
|
||||
// app data
|
||||
app,
|
||||
check_ip,
|
||||
query,
|
||||
insert,
|
||||
update,
|
||||
rename,
|
||||
remove,
|
||||
remove_query,
|
||||
// user connection
|
||||
GRANT_URL,
|
||||
extract_verifier_token_uid,
|
||||
refresh_token,
|
||||
localstorage_accept_connection,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export function from_localstorage({
|
||||
host = "https://tetratto.com",
|
||||
app_id = 0n,
|
||||
}) {
|
||||
const user_verifier = window.localStorage.getItem("atto:grant.verifier");
|
||||
const user_token = window.localStorage.getItem("atto:grant.token");
|
||||
const user_id = window.localStorage.getItem("atto:grant.user_id");
|
||||
|
||||
return tetratto({
|
||||
host,
|
||||
app_id,
|
||||
user_verifier,
|
||||
user_id,
|
||||
user_token,
|
||||
});
|
||||
}
|
|
@ -156,14 +156,12 @@ media_theme_pref();
|
|||
.replaceAll(" year ago", "y");
|
||||
}
|
||||
|
||||
element.innerText =
|
||||
pretty === undefined ? then.toLocaleDateString() : pretty;
|
||||
|
||||
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
||||
element.style.display = "inline-block";
|
||||
}
|
||||
});
|
||||
|
||||
self.define("clean_poll_date_codes", ({ $ }) => {
|
||||
self.define("clean_poll_date_codes", async ({ $ }) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(".poll_date"),
|
||||
)) {
|
||||
|
@ -183,7 +181,7 @@ media_theme_pref();
|
|||
element.setAttribute("title", then.toLocaleString());
|
||||
|
||||
const pretty =
|
||||
$.rel_date(then)
|
||||
(await $.rel_date(then))
|
||||
.replaceAll(" minutes ago", "m")
|
||||
.replaceAll(" minute ago", "m")
|
||||
.replaceAll(" hours ago", "h")
|
||||
|
@ -198,9 +196,7 @@ media_theme_pref();
|
|||
.replaceAll(" year ago", "y")
|
||||
.replaceAll("Yesterday", "1d") || "";
|
||||
|
||||
element.innerText =
|
||||
pretty === undefined ? then.toLocaleDateString() : pretty;
|
||||
|
||||
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
||||
element.style.display = "inline-block";
|
||||
}
|
||||
});
|
||||
|
@ -409,39 +405,45 @@ media_theme_pref();
|
|||
}
|
||||
});
|
||||
|
||||
self.define("hooks::long", (_, element, full_text) => {
|
||||
self.define("hooks::long", ({ $ }, element, full_text) => {
|
||||
element.classList.remove("hook:long.hidden_text");
|
||||
element.innerHTML = full_text;
|
||||
|
||||
$.clean_date_codes();
|
||||
$.clean_poll_date_codes();
|
||||
$.link_filter();
|
||||
});
|
||||
|
||||
self.define("hooks::long_text.init", (_) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=long]") || [],
|
||||
)) {
|
||||
const is_long = element.innerText.length >= 64 * 8;
|
||||
setTimeout(() => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=long]") || [],
|
||||
)) {
|
||||
const is_long = element.innerText.length >= 64 * 8;
|
||||
|
||||
if (!is_long) {
|
||||
continue;
|
||||
if (!is_long) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.classList.add("hook:long.hidden_text");
|
||||
|
||||
if (element.getAttribute("hook-arg") === "lowered") {
|
||||
element.classList.add("hook:long.hidden_text+lowered");
|
||||
}
|
||||
|
||||
const html = element.innerHTML;
|
||||
const short = html.slice(0, 64 * 8);
|
||||
element.innerHTML = `${short}...`;
|
||||
|
||||
// event
|
||||
const listener = () => {
|
||||
self["hooks::long"](element, html);
|
||||
element.removeEventListener("click", listener);
|
||||
};
|
||||
|
||||
element.addEventListener("click", listener);
|
||||
}
|
||||
|
||||
element.classList.add("hook:long.hidden_text");
|
||||
|
||||
if (element.getAttribute("hook-arg") === "lowered") {
|
||||
element.classList.add("hook:long.hidden_text+lowered");
|
||||
}
|
||||
|
||||
const html = element.innerHTML;
|
||||
const short = html.slice(0, 64 * 8);
|
||||
element.innerHTML = `${short}...`;
|
||||
|
||||
// event
|
||||
const listener = () => {
|
||||
self["hooks::long"](element, html);
|
||||
element.removeEventListener("click", listener);
|
||||
};
|
||||
|
||||
element.addEventListener("click", listener);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
self.define("hooks::alt", (_) => {
|
||||
|
@ -505,7 +507,7 @@ media_theme_pref();
|
|||
return now - last_seen <= maximum_time_to_be_considered_idle;
|
||||
});
|
||||
|
||||
self.define("hooks::online_indicator", ({ $ }) => {
|
||||
self.define("hooks::online_indicator", async ({ $ }) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=online_indicator]") || [],
|
||||
)) {
|
||||
|
@ -513,8 +515,8 @@ media_theme_pref();
|
|||
element.getAttribute("hook-arg:last_seen"),
|
||||
);
|
||||
|
||||
const is_online = $.last_seen_just_now(last_seen);
|
||||
const is_idle = $.last_seen_recently(last_seen);
|
||||
const is_online = await $.last_seen_just_now(last_seen);
|
||||
const is_idle = await $.last_seen_recently(last_seen);
|
||||
|
||||
const offline = element.querySelector("[hook_ui_ident=offline]");
|
||||
const online = element.querySelector("[hook_ui_ident=online]");
|
||||
|
@ -687,7 +689,7 @@ media_theme_pref();
|
|||
});
|
||||
|
||||
self.define("hooks::check_message_reactions", async ({ $ }) => {
|
||||
const observer = $.offload_work_to_client_when_in_view(
|
||||
const observer = await $.offload_work_to_client_when_in_view(
|
||||
async (element) => {
|
||||
const reactions = await (
|
||||
await fetch(
|
||||
|
@ -851,7 +853,8 @@ media_theme_pref();
|
|||
anchor.href.startsWith("https://tetratto.com") ||
|
||||
anchor.href.startsWith("https://buy.stripe.com") ||
|
||||
anchor.href.startsWith("https://billing.stripe.com") ||
|
||||
anchor.href.startsWith("https://last.fm")
|
||||
anchor.href.startsWith("https://last.fm") ||
|
||||
anchor.href.startsWith("atto://")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
@ -917,18 +920,18 @@ media_theme_pref();
|
|||
|
||||
if (option.input_element_type === "checkbox") {
|
||||
into_element.innerHTML += `<div class="card flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
|
||||
placeholder="${option.key}"
|
||||
name="${option.key}"
|
||||
id="${option.key}"
|
||||
${option.value === "true" ? "checked" : ""}
|
||||
class="w-content"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
|
||||
placeholder="${option.key}"
|
||||
name="${option.key}"
|
||||
id="${option.key}"
|
||||
${option.value === "true" ? "checked" : ""}
|
||||
class="w-content"
|
||||
/>
|
||||
|
||||
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
|
||||
</div>`;
|
||||
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
|
||||
</div>`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -1064,7 +1067,13 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
// permissions ui
|
||||
self.define(
|
||||
"generate_permissions_ui",
|
||||
(_, permissions, field_id = "role") => {
|
||||
(
|
||||
_,
|
||||
permissions,
|
||||
field_id = "role",
|
||||
add_name = "add_permission_to_role",
|
||||
remove_name = "remove_permission_from_role",
|
||||
) => {
|
||||
function all_matching_permissions(role) {
|
||||
const matching = [];
|
||||
const not_matching = [];
|
||||
|
@ -1094,7 +1103,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
function get_permissions_html(role, id) {
|
||||
const [matching, not_matching] = all_matching_permissions(role);
|
||||
|
||||
globalThis.remove_permission_from_role = (permission) => {
|
||||
globalThis[remove_name] = (permission) => {
|
||||
matching.splice(matching.indexOf(permission), 1);
|
||||
not_matching.push(permission);
|
||||
|
||||
|
@ -1102,7 +1111,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
get_permissions_html(rebuild_role(matching), id);
|
||||
};
|
||||
|
||||
globalThis.add_permission_to_role = (permission) => {
|
||||
globalThis[add_name] = (permission) => {
|
||||
not_matching.splice(not_matching.indexOf(permission), 1);
|
||||
matching.push(permission);
|
||||
|
||||
|
@ -1115,14 +1124,14 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
for (const match of matching) {
|
||||
permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
|
||||
<span>${match} <code>${permissions[match]}</code></span>
|
||||
<button class="red lowered" onclick="remove_permission_from_role('${match}')">Remove</button>
|
||||
<button class="red lowered" onclick="${remove_name}('${match}')">Remove</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
for (const match of not_matching) {
|
||||
permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
|
||||
<span>${match} <code>${permissions[match]}</code></span>
|
||||
<button class="green lowered" onclick="add_permission_to_role('${match}')">Add</button>
|
||||
<button class="green lowered" onclick="${add_name}('${match}')">Add</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
@ -1134,8 +1143,15 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
);
|
||||
|
||||
// lightbox
|
||||
self.define("lightbox_open", (_, src) => {
|
||||
self.define("lightbox_open", async (_, src) => {
|
||||
document.getElementById("lightbox_img").src = src;
|
||||
|
||||
const data = await (await fetch(`${src}/data`)).json();
|
||||
document
|
||||
.getElementById("lightbox_img")
|
||||
.setAttribute("alt", data.payload.alt);
|
||||
document.getElementById("lightbox_img").title = data.payload.alt;
|
||||
|
||||
document.getElementById("lightbox_img_a").href = src;
|
||||
document.getElementById("lightbox").classList.remove("hidden");
|
||||
});
|
||||
|
@ -1208,6 +1224,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
|
||||
self.IO_DATA_DISCONNECTED = false;
|
||||
self.IO_DATA_DISABLE_RELOAD = false;
|
||||
self.IO_DATA_LOAD_BEFORE = 0;
|
||||
|
||||
if (!paginated_mode) {
|
||||
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
|
||||
|
@ -1252,7 +1269,9 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
|
||||
// ...
|
||||
const text = await (
|
||||
await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
|
||||
await fetch(
|
||||
`${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`,
|
||||
)
|
||||
).text();
|
||||
|
||||
self.IO_DATA_WAITING = false;
|
||||
|
@ -1270,11 +1289,22 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
}
|
||||
|
||||
if (
|
||||
text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
|
||||
text.includes(
|
||||
`!<!-- observer_disconnect_${window.BUILD_CODE} -->`,
|
||||
) ||
|
||||
document.documentElement.innerHTML.includes("observer_disconnect")
|
||||
) {
|
||||
console.log("io_data_end; disconnect");
|
||||
self.IO_DATA_OBSERVER.disconnect();
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
|
||||
if (
|
||||
!document.documentElement.innerHTML.includes(
|
||||
"observer_disconnect",
|
||||
)
|
||||
) {
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
}
|
||||
|
||||
self.IO_DATA_DISCONNECTED = true;
|
||||
return;
|
||||
}
|
||||
|
@ -1287,30 +1317,6 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
self.IO_DATA_ELEMENT.children.length - 1
|
||||
].after(self.IO_DATA_MARKER);
|
||||
|
||||
// remove posts we've already seen
|
||||
function remove_elements(id, outer = false) {
|
||||
let idx = 0;
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(
|
||||
`.post${outer ? "_outer" : ""}\\:${id}`,
|
||||
),
|
||||
)) {
|
||||
if (idx === 0) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// everything that isn't the first element should be removed
|
||||
element.remove();
|
||||
console.log("removed duplicate post");
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of self.IO_DATA_SEEN_IDS) {
|
||||
remove_elements(id, false);
|
||||
remove_elements(id, true); // scoop up questions
|
||||
}
|
||||
|
||||
// push ids
|
||||
for (const opt of Array.from(
|
||||
document.querySelectorAll(
|
||||
|
@ -1322,6 +1328,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
if (!self.IO_DATA_SEEN_IDS[v]) {
|
||||
self.IO_DATA_SEEN_IDS.push(v);
|
||||
}
|
||||
|
||||
self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created");
|
||||
}
|
||||
}, 150);
|
||||
|
||||
|
@ -1337,6 +1345,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
atto["hooks::online_indicator"]();
|
||||
atto["hooks::verify_emoji"]();
|
||||
atto["hooks::check_reactions"]();
|
||||
|
||||
fix_atto_links();
|
||||
});
|
||||
})();
|
||||
|
||||
|
@ -1378,7 +1388,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
JSON.stringify(accepted_warnings),
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
await trigger("me::achievement", ["AcceptProfileWarning"]);
|
||||
window.history.back();
|
||||
}, 100);
|
||||
});
|
||||
|
|
|
@ -193,9 +193,13 @@
|
|||
like.classList.add("green");
|
||||
like.querySelector("svg").classList.add("filled");
|
||||
|
||||
dislike.classList.remove("red");
|
||||
if (dislike) {
|
||||
dislike.classList.remove("red");
|
||||
}
|
||||
} else {
|
||||
dislike.classList.add("red");
|
||||
if (dislike) {
|
||||
dislike.classList.add("red");
|
||||
}
|
||||
|
||||
like.classList.remove("green");
|
||||
like.querySelector("svg").classList.remove("filled");
|
||||
|
@ -342,6 +346,36 @@
|
|||
},
|
||||
);
|
||||
|
||||
self.define("achievement", (_, name) => {
|
||||
return new Promise((resolve) => {
|
||||
fetch("/api/v1/auth/user/me/achievement", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "success" : "error",
|
||||
res.message,
|
||||
]);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.define("achievement_link", async (_, name, href) => {
|
||||
await self.achievement(name);
|
||||
Turbo.visit(href);
|
||||
});
|
||||
|
||||
self.define("report", (_, asset, asset_type) => {
|
||||
window.open(
|
||||
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`,
|
||||
|
@ -402,8 +436,30 @@
|
|||
});
|
||||
});
|
||||
|
||||
self.define("remove_ip_block", async (_, id) => {
|
||||
if (
|
||||
!(await trigger("atto::confirm", [
|
||||
"Are you sure you want to do this?",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/auth/ip/${id}/unblock_ip`, {
|
||||
method: "POST",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "success" : "error",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
self.define("notifications_stream", ({ _, streams }) => {
|
||||
const element = document.getElementById("notifications_span");
|
||||
let current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
streams.subscribe("notifs");
|
||||
streams.event("notifs", "message", (data) => {
|
||||
|
@ -414,13 +470,12 @@
|
|||
|
||||
const inner_data = JSON.parse(data.data);
|
||||
if (data.method.Packet.Crud === "Create") {
|
||||
const current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
if (current <= 0) {
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
|
||||
element.innerText = current + 1;
|
||||
current += 1;
|
||||
element.innerText = current;
|
||||
|
||||
// check if we're already connected
|
||||
const connected =
|
||||
|
@ -456,16 +511,19 @@
|
|||
console.info("notification created");
|
||||
}
|
||||
} else if (data.method.Packet.Crud === "Delete") {
|
||||
const current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
if (current - 1 <= 0) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
|
||||
element.innerText = current - 1;
|
||||
current -= 1;
|
||||
element.innerText = current;
|
||||
} else {
|
||||
console.warn("correct packet type but with wrong data");
|
||||
}
|
||||
|
||||
if (element.innerText !== current) {
|
||||
element.innerText = current;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -979,7 +1037,13 @@
|
|||
|
||||
self.define(
|
||||
"timestamp",
|
||||
({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => {
|
||||
async (
|
||||
{ $ },
|
||||
updated_,
|
||||
progress_ms_,
|
||||
duration_ms_,
|
||||
display = "full",
|
||||
) => {
|
||||
if (duration_ms_ === "0") {
|
||||
return;
|
||||
}
|
||||
|
@ -1003,7 +1067,7 @@
|
|||
}
|
||||
|
||||
if (display === "full") {
|
||||
return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
|
||||
return `${await $.ms_time_text(progress_ms)}/${await $.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
|
||||
}
|
||||
|
||||
if (display === "left") {
|
||||
|
@ -1141,3 +1205,60 @@
|
|||
]);
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const self = reg_ns("seller");
|
||||
|
||||
self.define("register", async () => {
|
||||
await trigger("atto::debounce", ["seller::register"]);
|
||||
|
||||
if (
|
||||
!(await trigger("atto::confirm", [
|
||||
"Are you sure you want to do this?",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/register", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
self.onboarding();
|
||||
});
|
||||
|
||||
self.define("onboarding", async () => {
|
||||
await trigger("atto::debounce", ["seller::onboarding"]);
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/onboarding", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
});
|
||||
|
||||
self.define("login", async () => {
|
||||
await trigger("atto::debounce", ["seller::login"]);
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/login", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
140
crates/app/src/public/js/proto_links.js
Normal file
140
crates/app/src/public/js/proto_links.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
if (!globalThis.TETRATTO_LINK_HANDLER_CTX) {
|
||||
globalThis.TETRATTO_LINK_HANDLER_CTX = "embed";
|
||||
}
|
||||
|
||||
// create little link preview box
|
||||
function create_link_preview() {
|
||||
globalThis.TETRATTO_LINK_PREVIEW = document.createElement("div");
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.position = "fixed";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.bottom = "0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.left = "0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.background = "#323232";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.color = "#ffffff";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.borderRadius = "4px";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.padding = "0.25rem 0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.display = "none";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.id = "tetratto_link_preview";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.setAttribute(
|
||||
"data-turbo-permanent",
|
||||
"true",
|
||||
);
|
||||
document.body.appendChild(globalThis.TETRATTO_LINK_PREVIEW);
|
||||
}
|
||||
|
||||
/// Clean up all "atto://" links on the page.
|
||||
function fix_atto_links() {
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById("tetratto_link_preview")) {
|
||||
create_link_preview();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
if (TETRATTO_LINK_HANDLER_CTX === "embed") {
|
||||
// relative links for embeds
|
||||
const path = window.location.pathname
|
||||
.replace("atto://", "")
|
||||
.slice("/api/v1/net/".length);
|
||||
|
||||
function fix_element(
|
||||
selector = "a",
|
||||
property = "href",
|
||||
relative = true,
|
||||
) {
|
||||
for (const y of Array.from(document.querySelectorAll(selector))) {
|
||||
if (!y[property].startsWith(window.location.origin)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = new URL(y[property]).pathname.replace("atto://", "");
|
||||
let x = p.startsWith("/api/v1/net/")
|
||||
? p.replace("/api/v1/net/", "")
|
||||
: p.startsWith("/")
|
||||
? `${path.split("/")[0]}${p}`
|
||||
: p;
|
||||
|
||||
if (!x.includes(".html")) {
|
||||
x = `${x}/index.html`;
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
y[property] = `atto://${x}`;
|
||||
} else {
|
||||
y[property] =
|
||||
`/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fix_element("a", "href", true);
|
||||
fix_element("img", "src", false);
|
||||
|
||||
// send message
|
||||
window.top.postMessage(
|
||||
JSON.stringify({
|
||||
t: true,
|
||||
event: "change_url",
|
||||
target: window.location.href,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
// handle messages
|
||||
window.addEventListener("message", (e) => {
|
||||
if (typeof e.data !== "string") {
|
||||
console.log("refuse message (bad type)");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (!data.t) {
|
||||
console.log("refuse message (not for tetratto)");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("received message");
|
||||
|
||||
if (data.event === "back") {
|
||||
window.history.back();
|
||||
} else if (data.event === "forward") {
|
||||
window.history.forward();
|
||||
} else if (data.event === "reload") {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const anchor of Array.from(document.querySelectorAll("a"))) {
|
||||
if (
|
||||
!anchor.href.startsWith("atto://") ||
|
||||
anchor.getAttribute("data-checked") === "true"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const href = structuredClone(anchor.href);
|
||||
anchor.addEventListener("click", () => {
|
||||
if (TETRATTO_LINK_HANDLER_CTX === "net") {
|
||||
window.location.href = `/net/${href.replace("atto://", "")}`;
|
||||
} else {
|
||||
window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`;
|
||||
}
|
||||
});
|
||||
|
||||
anchor.addEventListener("mouseenter", () => {
|
||||
TETRATTO_LINK_PREVIEW.innerText = href;
|
||||
TETRATTO_LINK_PREVIEW.style.display = "block";
|
||||
});
|
||||
|
||||
anchor.addEventListener("mouseleave", () => {
|
||||
TETRATTO_LINK_PREVIEW.style.display = "none";
|
||||
});
|
||||
|
||||
anchor.removeAttribute("href");
|
||||
anchor.style.cursor = "pointer";
|
||||
anchor.setAttribute("data-checked", "true");
|
||||
}
|
||||
}
|
||||
|
||||
fix_atto_links();
|
||||
create_link_preview();
|
|
@ -43,6 +43,12 @@
|
|||
};
|
||||
|
||||
socket.addEventListener("message", async (event) => {
|
||||
const sock = await $.sock(stream);
|
||||
|
||||
if (!sock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === "Ping") {
|
||||
return socket.send("Pong");
|
||||
}
|
||||
|
@ -54,7 +60,7 @@
|
|||
return console.info(`${stream} ${data.data}`);
|
||||
}
|
||||
|
||||
return (await $.sock(stream)).events.message(data);
|
||||
return sock.events.message(data);
|
||||
});
|
||||
|
||||
return $.STREAMS[stream];
|
||||
|
|
277
crates/app/src/routes/api/v1/app_data.rs
Normal file
277
crates/app/src/routes/api/v1/app_data.rs
Normal file
|
@ -0,0 +1,277 @@
|
|||
use crate::{
|
||||
get_app_from_key,
|
||||
routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue, UpdateAppDataKey},
|
||||
State,
|
||||
};
|
||||
use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use tetratto_core::model::{
|
||||
apps::{AppData, AppDataQuery, AppDataQueryResult},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
|
||||
pub async fn get_app_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(app),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn query_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<QueryAppData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.query_app_data(AppDataQuery {
|
||||
app: app.id,
|
||||
query: req.query,
|
||||
mode: req.mode,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<InsertAppData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let owner = match data.get_user_by_id(app.owner).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// check size
|
||||
let new_size = app.data_used + req.value.len();
|
||||
if new_size > AppData::user_limit(&owner, &app) {
|
||||
return Json(Error::AppHitStorageLimit.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.create_app_data(AppData::new(app.id, req.key, req.value))
|
||||
.await
|
||||
{
|
||||
Ok(s) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Data inserted".to_string(),
|
||||
payload: s.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_key_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAppDataKey>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let app_data = match data.get_app_data_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if app_data.app != app.id {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data.update_app_data_key(id, &req.key).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Data updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_value_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAppDataValue>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let owner = match data.get_user_by_id(app.owner).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let app_data = match data.get_app_data_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if app_data.app != app.id {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// check size
|
||||
let size_without = app.data_used - app_data.value.len();
|
||||
let new_size = size_without + req.value.len();
|
||||
|
||||
if new_size > AppData::user_limit(&owner, &app) {
|
||||
return Json(Error::AppHitStorageLimit.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
// we only need to add the delta size (the next size - the old size)
|
||||
if let Err(e) = data
|
||||
.add_app_data_used(
|
||||
app.id,
|
||||
(req.value.len() as i32) - (app_data.value.len() as i32),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data.update_app_data_value(id, &req.value).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Data updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let app_data = match data.get_app_data_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if app_data.app != app.id {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
if let Err(e) = data
|
||||
.add_app_data_used(app.id, -(app_data.value.len() as i32))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data.delete_app_data(id).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Data deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_query_request(
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<QueryAppData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let app = match get_app_from_key!(data, headers) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// ...
|
||||
let rows = match data
|
||||
.query_app_data(AppDataQuery {
|
||||
app: app.id,
|
||||
query: req.query.clone(),
|
||||
mode: req.mode.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(x) => match x {
|
||||
AppDataQueryResult::One(x) => vec![x],
|
||||
AppDataQueryResult::Many(x) => x,
|
||||
},
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let mut subtract_amount: usize = 0;
|
||||
for row in &rows {
|
||||
subtract_amount += row.value.len();
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if let Err(e) = data
|
||||
.add_app_data_used(app.id, -(subtract_amount as i32))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.query_delete_app_data(AppDataQuery {
|
||||
app: app.id,
|
||||
query: req.query,
|
||||
mode: req.mode,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Data deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ use crate::{
|
|||
State,
|
||||
};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
apps::{AppQuota, ThirdPartyApp},
|
||||
oauth::{AuthGrant, PkceChallengeMethod},
|
||||
|
@ -15,7 +15,7 @@ use tetratto_core::model::{
|
|||
ApiReturn, Error,
|
||||
};
|
||||
use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
|
||||
use super::CreateApp;
|
||||
use super::{CreateApp, UpdateAppStorageCapacity};
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
|
@ -138,6 +138,35 @@ pub async fn update_quota_status_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_storage_capacity_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAppStorageCapacity>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !user.permissions.check(FinePermission::MANAGE_APPS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.update_app_storage_capacity(id, req.storage_capacity)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "App updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_scopes_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -239,3 +268,34 @@ pub async fn grant_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn roll_api_key_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let app = match data.get_app_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != app.owner {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let new_key = tetratto_shared::hash::random_id_salted_len(32);
|
||||
match data.update_app_api_key(id, &new_key).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "App updated".to_string(),
|
||||
payload: Some(new_key),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::{
|
||||
database::connections::last_fm::LastFmConnection,
|
||||
model::{
|
||||
|
|
|
@ -5,7 +5,7 @@ pub mod stripe;
|
|||
use std::collections::HashMap;
|
||||
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
auth::{ConnectionService, ExternalConnectionData},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::{
|
||||
database::connections::spotify::SpotifyConnection,
|
||||
model::{
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
use std::time::Duration;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
|
||||
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
auth::{User, Notification},
|
||||
auth::{Notification, User},
|
||||
moderation::AuditLogEntry,
|
||||
permissions::FinePermission,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use stripe::{EventObject, EventType};
|
||||
use crate::State;
|
||||
use crate::{get_user_from_token, State};
|
||||
|
||||
pub async fn stripe_webhook(
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -17,9 +18,10 @@ pub async fn stripe_webhook(
|
|||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
if data.0.0.stripe.is_none() {
|
||||
return Json(Error::MiscError("Disabled".to_string()).into());
|
||||
}
|
||||
let stripe_cnf = match data.0.0.stripe {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::MiscError("Disabled".to_string()).into()),
|
||||
};
|
||||
|
||||
let sig = match headers.get("Stripe-Signature") {
|
||||
Some(s) => s,
|
||||
|
@ -56,7 +58,7 @@ pub async fn stripe_webhook(
|
|||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
tracing::info!("subscribe {} (stripe: {})", user.id, customer_id);
|
||||
tracing::info!("payment {} (stripe: {})", user.id, customer_id);
|
||||
if let Err(e) = data
|
||||
.update_user_stripe_id(user.id, customer_id.as_str())
|
||||
.await
|
||||
|
@ -74,6 +76,48 @@ pub async fn stripe_webhook(
|
|||
};
|
||||
|
||||
let customer_id = invoice.customer.unwrap().id();
|
||||
let lines = invoice.lines.unwrap();
|
||||
|
||||
if lines.total_count.unwrap() > 1 {
|
||||
if let Err(e) = data
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
0,
|
||||
format!("too many invoice line items: stripe {customer_id}"),
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
return Json(Error::MiscError("Too many line items".to_string()).into());
|
||||
}
|
||||
|
||||
let item = match lines.data.get(0) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if let Err(e) = data
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
0,
|
||||
format!("too few invoice line items: stripe {customer_id}"),
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
return Json(Error::MiscError("Too few line items".to_string()).into());
|
||||
}
|
||||
};
|
||||
|
||||
let product_id = item
|
||||
.price
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.product
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id()
|
||||
.to_string();
|
||||
|
||||
// pull user and update role
|
||||
let mut retries: usize = 0;
|
||||
|
@ -118,36 +162,91 @@ pub async fn stripe_webhook(
|
|||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
tracing::info!("found subscription user in {retries} tries");
|
||||
|
||||
if user.permissions.check(FinePermission::SUPPORTER) {
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Already applied".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
if product_id == stripe_cnf.product_ids.supporter {
|
||||
// supporter
|
||||
tracing::info!("found subscription user in {retries} tries");
|
||||
|
||||
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
|
||||
if user.permissions.check(FinePermission::SUPPORTER) {
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Already applied".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Welcome new supporter!".to_string(),
|
||||
"Thank you for your support! Your account has been updated with your new role."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, false, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Welcome new supporter!".to_string(),
|
||||
"Thank you for your support! Your account has been updated with your new role."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||
// dev pass
|
||||
tracing::info!("found subscription user in {retries} tries");
|
||||
|
||||
if user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||
{
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Already applied".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions =
|
||||
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Welcome new developer!".to_string(),
|
||||
"Thank you for your support! Your account has been updated with your new role."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||
);
|
||||
return Json(Error::MiscError("Unknown product ID".to_string()).into());
|
||||
}
|
||||
}
|
||||
EventType::CustomerSubscriptionDeleted => {
|
||||
|
@ -158,22 +257,72 @@ pub async fn stripe_webhook(
|
|||
};
|
||||
|
||||
let customer_id = subscription.customer.id();
|
||||
let product_id = subscription
|
||||
.items
|
||||
.data
|
||||
.get(0)
|
||||
.as_ref()
|
||||
.expect("cancelled nothing?")
|
||||
.plan
|
||||
.as_ref()
|
||||
.expect("no subscription plan?")
|
||||
.product
|
||||
.as_ref()
|
||||
.expect("plan with no product?")
|
||||
.id()
|
||||
.to_string();
|
||||
|
||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
// handle each subscription item
|
||||
if product_id == stripe_cnf.product_ids.supporter {
|
||||
// supporter
|
||||
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes
|
||||
&& user.was_purchased
|
||||
&& user.invite_code == 0
|
||||
{
|
||||
// user doesn't come from an invite code, and is a purchased account
|
||||
// this means their account must be locked if they stop paying
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||
// dev pass
|
||||
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
||||
let new_user_permissions =
|
||||
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||
);
|
||||
return Json(Error::MiscError("Unknown product ID".to_string()).into());
|
||||
}
|
||||
|
||||
// send notification
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Sorry to see you go... :(".to_string(),
|
||||
|
@ -186,6 +335,133 @@ pub async fn stripe_webhook(
|
|||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
EventType::InvoicePaymentFailed => {
|
||||
// payment failed
|
||||
let invoice = match req.data.object {
|
||||
EventObject::Invoice(i) => i,
|
||||
_ => unreachable!("cannot be this"),
|
||||
};
|
||||
|
||||
let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id();
|
||||
|
||||
let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if let Err(e) = data
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
0,
|
||||
format!("too few invoice line items: stripe {customer_id}"),
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
return Json(Error::MiscError("Too few line items".to_string()).into());
|
||||
}
|
||||
};
|
||||
|
||||
let product_id = item
|
||||
.price
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.product
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id()
|
||||
.to_string();
|
||||
|
||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// handle each subscription item
|
||||
if product_id == stripe_cnf.product_ids.supporter {
|
||||
// supporter
|
||||
if !user.permissions.check(FinePermission::SUPPORTER) {
|
||||
// the user isn't currently a supporter, there's no reason to send this notification
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"unsubscribe (pay fail) {} (stripe: {})",
|
||||
user.id,
|
||||
customer_id
|
||||
);
|
||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes
|
||||
&& user.was_purchased
|
||||
&& user.invite_code == 0
|
||||
{
|
||||
// user doesn't come from an invite code, and is a purchased account
|
||||
// this means their account must be locked if they stop paying
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||
// dev pass
|
||||
if !user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||
{
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"unsubscribe (pay fail) {} (stripe: {})",
|
||||
user.id,
|
||||
customer_id
|
||||
);
|
||||
let new_user_permissions =
|
||||
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||
);
|
||||
return Json(Error::MiscError("Unknown product ID".to_string()).into());
|
||||
}
|
||||
|
||||
// send notification
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"It seems your recent payment has failed :(".to_string(),
|
||||
"No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
_ => return Json(Error::Unknown.into()),
|
||||
}
|
||||
|
||||
|
@ -195,3 +471,145 @@ pub async fn stripe_webhook(
|
|||
payload: (),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn onboarding_account_link_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_some() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
match stripe::AccountLink::create(
|
||||
&client,
|
||||
stripe::CreateAccountLink {
|
||||
account: match user.seller_data.account_id {
|
||||
Some(id) => stripe::AccountId::from_str(&id).unwrap(),
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
},
|
||||
type_: stripe::AccountLinkType::AccountOnboarding,
|
||||
collect: None,
|
||||
expand: &[],
|
||||
refresh_url: Some(&format!(
|
||||
"{}/auth/connections_link/seller/refresh",
|
||||
data.0.0.0.host
|
||||
)),
|
||||
return_url: Some(&format!(
|
||||
"{}/auth/connections_link/seller/return",
|
||||
data.0.0.0.host
|
||||
)),
|
||||
collection_options: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: Some(x.url),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_seller_account_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let mut user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_some() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
let account = match stripe::Account::create(
|
||||
&client,
|
||||
stripe::CreateAccount {
|
||||
type_: Some(stripe::AccountType::Express),
|
||||
capabilities: Some(stripe::CreateAccountCapabilities {
|
||||
card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments {
|
||||
requested: Some(true),
|
||||
}),
|
||||
transfers: Some(stripe::CreateAccountCapabilitiesTransfers {
|
||||
requested: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
user.seller_data.account_id = Some(account.id.to_string());
|
||||
match data
|
||||
.0
|
||||
.update_user_seller_data(user.id, user.seller_data)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_link_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
match stripe::LoginLink::create(
|
||||
&client,
|
||||
&stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(),
|
||||
&data.0.0.0.host,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: Some(x.url),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use pathbufd::{PathBufD, pathd};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
|
|
|
@ -1,12 +1,34 @@
|
|||
use crate::{
|
||||
State, get_user_from_token,
|
||||
get_app_from_key, get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::CreateIpBan,
|
||||
State,
|
||||
};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission};
|
||||
|
||||
/// Check if the given IP is banned.
|
||||
pub async fn check_request(
|
||||
headers: HeaderMap,
|
||||
Path(ip): Path<String>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
if get_app_from_key!(data, headers).is_none() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: data
|
||||
.get_ipban_by_addr(&RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
.is_ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new IP ban.
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
|
|
|
@ -16,7 +16,7 @@ use axum::{
|
|||
response::{IntoResponse, Redirect},
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::addr::RemoteAddr;
|
||||
use tetratto_shared::hash::hash;
|
||||
|
@ -54,7 +54,7 @@ pub async fn register_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
@ -88,41 +88,46 @@ pub async fn register_request(
|
|||
|
||||
// check invite code
|
||||
if data.0.0.security.enable_invite_codes {
|
||||
if props.invite_code.is_empty() {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("Missing invite code".to_string()).into()),
|
||||
);
|
||||
if !props.purchase {
|
||||
if props.invite_code.is_empty() {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("Missing invite code".to_string()).into()),
|
||||
);
|
||||
}
|
||||
|
||||
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Json(e.into())),
|
||||
};
|
||||
|
||||
if invite_code.is_used {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("This code has already been used".to_string()).into()),
|
||||
);
|
||||
}
|
||||
|
||||
// let owner = match data.get_user_by_id(invite_code.owner).await {
|
||||
// Ok(u) => u,
|
||||
// Err(e) => return (None, Json(e.into())),
|
||||
// };
|
||||
|
||||
// if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
// return (
|
||||
// None,
|
||||
// Json(
|
||||
// Error::MiscError("Invite code owner must be an active supporter".to_string())
|
||||
// .into(),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
user.invite_code = invite_code.id;
|
||||
} else {
|
||||
// this account is being purchased
|
||||
user.awaiting_purchase = true;
|
||||
}
|
||||
|
||||
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Json(e.into())),
|
||||
};
|
||||
|
||||
if invite_code.is_used {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("This code has already been used".to_string()).into()),
|
||||
);
|
||||
}
|
||||
|
||||
// let owner = match data.get_user_by_id(invite_code.owner).await {
|
||||
// Ok(u) => u,
|
||||
// Err(e) => return (None, Json(e.into())),
|
||||
// };
|
||||
|
||||
// if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
// return (
|
||||
// None,
|
||||
// Json(
|
||||
// Error::MiscError("Invite code owner must be an active supporter".to_string())
|
||||
// .into(),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
user.invite_code = invite_code.id;
|
||||
}
|
||||
|
||||
// push initial token
|
||||
|
@ -133,7 +138,7 @@ pub async fn register_request(
|
|||
match data.create_user(user).await {
|
||||
Ok(_) => {
|
||||
// mark invite as used
|
||||
if data.0.0.security.enable_invite_codes {
|
||||
if data.0.0.security.enable_invite_codes && !props.purchase {
|
||||
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Json(e.into())),
|
||||
|
@ -189,7 +194,7 @@ pub async fn login_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
use std::time::Duration;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::{
|
||||
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole,
|
||||
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
|
||||
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason,
|
||||
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword,
|
||||
UpdateUserRole, UpdateUserUsername,
|
||||
},
|
||||
State,
|
||||
};
|
||||
|
@ -16,12 +18,12 @@ use axum::{
|
|||
response::{IntoResponse, Redirect},
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||
use tetratto_core::{
|
||||
cache::Cache,
|
||||
model::{
|
||||
auth::{AchievementName, InviteCode, Token, UserSettings},
|
||||
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
|
||||
moderation::AuditLogEntry,
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
|
@ -153,7 +155,7 @@ pub async fn update_user_settings_request(
|
|||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditSettings.into())
|
||||
.add_achievement(&mut user, AchievementName::EditSettings.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -342,6 +344,62 @@ pub async fn update_user_is_verified_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Update the verification status of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_awaiting_purchase_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserAwaitingPurchase>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_user_awaiting_purchased_status(id, req.awaiting_purchase, user, true)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Awaiting purchase status updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the deactivated status of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_is_deactivated_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserIsDeactivated>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_user_is_deactivated(id, req.is_deactivated, user)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Deactivated status updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the role of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
|
@ -395,6 +453,35 @@ pub async fn update_user_secondary_role_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Update the ban reason of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_ban_reason_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserBanReason>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data.update_user_ban_reason(id, &req.reason).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the current user's last seen value.
|
||||
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
@ -422,8 +509,8 @@ pub async fn delete_user_request(
|
|||
Extension(data): Extension<State>,
|
||||
Json(req): Json<DeleteUser>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
let data = &(data.read().await);
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
@ -432,6 +519,7 @@ pub async fn delete_user_request(
|
|||
return Json(Error::NotAllowed.into());
|
||||
} else if user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
if let Err(e) = data
|
||||
.0
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
user.id,
|
||||
format!("invoked `delete_user` with x value `{id}`"),
|
||||
|
@ -443,14 +531,32 @@ pub async fn delete_user_request(
|
|||
}
|
||||
|
||||
match data
|
||||
.0
|
||||
.delete_user(id, &req.password, user.permissions.check_manager())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Ok(ua) => {
|
||||
// delete stripe user
|
||||
if let Some(stripe_id) = ua.seller_data.account_id
|
||||
&& let Some(ref client) = data.3
|
||||
{
|
||||
if let Err(e) = stripe::Account::delete(
|
||||
&client,
|
||||
&stripe::AccountId::from_str(&stripe_id).unwrap(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User deleted".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
@ -464,11 +570,20 @@ pub async fn enable_totp_request(
|
|||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Enable2fa.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.enable_totp(id, user).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
|
@ -911,3 +1026,83 @@ pub async fn generate_invite_codes_request(
|
|||
payload: Some((out_string, errors_string)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Award an achievement to the current user.
|
||||
/// Only works with specific "self-serve" achievements.
|
||||
pub async fn self_serve_achievement_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<AwardAchievement>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !SELF_SERVE_ACHIEVEMENTS.contains(&req.name) {
|
||||
return Json(Error::MiscError("Cannot grant this achievement manually".to_string()).into());
|
||||
}
|
||||
|
||||
// award achievement
|
||||
match data.add_achievement(&mut user, req.name.into(), true).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Achievement granted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the verification status of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_invite_code_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserInviteCode>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if req.invite_code.is_empty() {
|
||||
return Json(Error::MiscError("Missing invite code".to_string()).into());
|
||||
}
|
||||
|
||||
let invite_code = match data.get_invite_code_by_code(&req.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if invite_code.is_used {
|
||||
return Json(Error::MiscError("This code has already been used".to_string()).into());
|
||||
}
|
||||
|
||||
if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.update_user_invite_code(user.id, invite_code.id as i64)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
match data
|
||||
.update_user_awaiting_purchased_status(user.id, false, user, false)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Invite code updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,14 +9,15 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
|
||||
oauth,
|
||||
};
|
||||
|
||||
/// Toggle following on the given user.
|
||||
pub async fn follow_request(
|
||||
pub async fn toggle_follow_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -61,7 +62,7 @@ pub async fn follow_request(
|
|||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::FollowUser.into())
|
||||
.add_achievement(&mut user, AchievementName::FollowUser.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -153,6 +154,96 @@ pub async fn accept_follow_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn follow_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if data
|
||||
.get_userfollow_by_initiator_receiver(user.id, id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Json(Error::MiscError("Already following user".to_string()).into());
|
||||
} else {
|
||||
match data
|
||||
.create_userfollow(UserFollow::new(user.id, id), &user, false)
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
if r == FollowResult::Followed {
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Somebody has followed you!".to_string(),
|
||||
format!(
|
||||
"You have been followed by [@{}](/api/v1/auth/user/find/{}).",
|
||||
user.username, user.id
|
||||
),
|
||||
id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::FollowUser.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User followed".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
} else {
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Asked to follow user".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn force_unfollow_me_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await {
|
||||
match data.delete_userfollow(userfollow.id, &user, false).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User is no longer following you".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
} else {
|
||||
return Json(Error::GeneralNotFound("user follow".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle blocking on the given user.
|
||||
pub async fn block_request(
|
||||
jar: CookieJar,
|
||||
|
@ -228,7 +319,10 @@ pub async fn ip_block_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await {
|
||||
if let Ok(ipblock) = data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
{
|
||||
// delete
|
||||
match data.delete_ipblock(ipblock.id, user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
|
@ -274,7 +368,10 @@ pub async fn followers_request(
|
|||
Ok(f) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: match data.fill_userfollows_with_initiator(f).await {
|
||||
payload: match data
|
||||
.fill_userfollows_with_initiator(f, &Some(user.clone()), id == user.id)
|
||||
.await
|
||||
{
|
||||
Ok(f) => Some(data.userfollows_user_filter(&f)),
|
||||
Err(e) => return Json(e.into()),
|
||||
},
|
||||
|
@ -306,7 +403,10 @@ pub async fn following_request(
|
|||
Ok(f) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: match data.fill_userfollows_with_receiver(f).await {
|
||||
payload: match data
|
||||
.fill_userfollows_with_receiver(f, &Some(user.clone()), id == user.id)
|
||||
.await
|
||||
{
|
||||
Ok(f) => Some(data.userfollows_user_filter(&f)),
|
||||
Err(e) => return Json(e.into()),
|
||||
},
|
||||
|
@ -314,3 +414,64 @@ pub async fn following_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ip_block_profile_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateIpBlock) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// get other user
|
||||
let other_user = match data.get_user_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
for (ip, _, _) in other_user.tokens {
|
||||
// check for an existing ip block
|
||||
if data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// create ip block
|
||||
if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "IP(s) blocked".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn remove_ip_block_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageBlocks) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_ipblock(id, user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "IP unblocked".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission};
|
||||
|
||||
/// Create a new user warning.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error};
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
|
@ -293,3 +293,62 @@ pub async fn get_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mute_channel_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.channel_mutes.contains(&id) {
|
||||
return Json(Error::MiscError("Channel already muted".to_string()).into());
|
||||
}
|
||||
|
||||
user.channel_mutes.push(id);
|
||||
match data
|
||||
.update_user_channel_mutes(user.id, user.channel_mutes)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Channel muted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unmute_channel_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let pos = match user.channel_mutes.iter().position(|x| *x == id) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::MiscError("Channel not muted".to_string()).into()),
|
||||
};
|
||||
|
||||
user.channel_mutes.remove(pos);
|
||||
match data
|
||||
.update_user_channel_mutes(user.id, user.channel_mutes)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Channel muted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
|
|
|
@ -7,7 +7,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::{
|
||||
cache::{Cache, redis::Commands},
|
||||
model::{
|
||||
|
|
|
@ -3,7 +3,7 @@ use axum::{
|
|||
extract::Path,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
auth::Notification,
|
||||
communities::{Community, CommunityMembership},
|
||||
|
@ -292,11 +292,10 @@ pub async fn create_membership(
|
|||
};
|
||||
|
||||
match data
|
||||
.create_membership(CommunityMembership::new(
|
||||
user.id,
|
||||
id,
|
||||
CommunityPermission::default(),
|
||||
))
|
||||
.create_membership(
|
||||
CommunityMembership::new(user.id, id, CommunityPermission::default()),
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(m) => Json(ApiReturn {
|
||||
|
|
|
@ -3,8 +3,8 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error};
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::{
|
||||
|
@ -20,11 +20,20 @@ pub async fn create_request(
|
|||
Json(req): Json<CreatePostDraft>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDraft.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data
|
||||
.create_draft(PostDraft::new(req.content, user.id))
|
||||
.await
|
||||
|
|
|
@ -7,7 +7,7 @@ use crate::{
|
|||
State,
|
||||
};
|
||||
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
oauth,
|
||||
uploads::{CustomEmoji, MediaType, MediaUpload},
|
||||
|
@ -17,6 +17,8 @@ use tetratto_core::model::{
|
|||
/// Expand a unicode emoji into its Gemoji shortcode.
|
||||
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
|
||||
match emoji.as_str() {
|
||||
// matches `CustomEmoji::replace`
|
||||
"💯" => "100".to_string(),
|
||||
"👍" => "thumbs_up".to_string(),
|
||||
"👎" => "thumbs_down".to_string(),
|
||||
_ => match emojis::get(&emoji) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use pathbufd::{PathBufD, pathd};
|
||||
use std::fs::exists;
|
||||
use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth};
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::AchievementName,
|
||||
|
@ -67,7 +67,7 @@ pub async fn create_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
@ -152,10 +152,11 @@ pub async fn create_request(
|
|||
}
|
||||
|
||||
// ...
|
||||
match data.create_post(props.clone()).await {
|
||||
let uploads = props.uploads.clone();
|
||||
match data.create_post(props).await {
|
||||
Ok(id) => {
|
||||
// write to uploads
|
||||
for (i, upload_id) in props.uploads.iter().enumerate() {
|
||||
for (i, upload_id) in uploads.iter().enumerate() {
|
||||
let image = match images.get(i) {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
|
@ -181,7 +182,7 @@ pub async fn create_request(
|
|||
|
||||
// achievements
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreatePost.into())
|
||||
.add_achievement(&mut user, AchievementName::CreatePost.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -189,7 +190,7 @@ pub async fn create_request(
|
|||
|
||||
if user.post_count >= 49 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create50Posts.into())
|
||||
.add_achievement(&mut user, AchievementName::Create50Posts.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -198,7 +199,7 @@ pub async fn create_request(
|
|||
|
||||
if user.post_count >= 99 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create100Posts.into())
|
||||
.add_achievement(&mut user, AchievementName::Create100Posts.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -207,7 +208,7 @@ pub async fn create_request(
|
|||
|
||||
if user.post_count >= 999 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create1000Posts.into())
|
||||
.add_achievement(&mut user, AchievementName::Create1000Posts.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -341,11 +342,20 @@ pub async fn update_content_request(
|
|||
Json(req): Json<UpdatePostContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditPost.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_post_content(id, user, req.content).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
|
@ -714,7 +724,7 @@ pub async fn from_communities_request(
|
|||
};
|
||||
|
||||
match data
|
||||
.get_posts_from_user_communities(user.id, 12, props.page)
|
||||
.get_posts_from_user_communities(user.id, 12, props.page, &user)
|
||||
.await
|
||||
{
|
||||
Ok(posts) => {
|
||||
|
@ -829,7 +839,7 @@ pub async fn all_request(
|
|||
};
|
||||
|
||||
match data
|
||||
.get_latest_posts(12, props.page, &Some(user.clone()))
|
||||
.get_latest_posts(12, props.page, &Some(user.clone()), props.before)
|
||||
.await
|
||||
{
|
||||
Ok(posts) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::{AchievementName, IpBlock},
|
||||
|
@ -43,7 +43,7 @@ pub async fn create_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
@ -55,7 +55,7 @@ pub async fn create_request(
|
|||
let mut user = user.clone();
|
||||
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateQuestion.into())
|
||||
.add_achievement(&mut user, AchievementName::CreateQuestion.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -63,7 +63,7 @@ pub async fn create_request(
|
|||
|
||||
if drawings.len() > 0 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDrawing.into())
|
||||
.add_achievement(&mut user, AchievementName::CreateDrawing.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -92,6 +92,17 @@ pub async fn create_request(
|
|||
}
|
||||
}
|
||||
|
||||
if req.mask_owner && !req.is_global {
|
||||
props.context.mask_owner = true;
|
||||
}
|
||||
|
||||
if !req.asking_about.is_empty() && !req.is_global {
|
||||
props.context.asking_about = match req.asking_about.parse::<usize>() {
|
||||
Ok(x) => Some(x),
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
match data
|
||||
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
|
||||
.await
|
||||
|
@ -145,7 +156,7 @@ pub async fn ip_block_request(
|
|||
|
||||
// check for an existing ip block
|
||||
if data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &question.ip)
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(question.ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
|
221
crates/app/src/routes/api/v1/domains.rs
Normal file
221
crates/app/src/routes/api/v1/domains.rs
Normal file
|
@ -0,0 +1,221 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{CreateDomain, UpdateDomainData},
|
||||
State,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
auth::AchievementName,
|
||||
littleweb::{Domain, ServiceFsMime},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_domain_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_domains_by_user(user.id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateDomain>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDomain.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data
|
||||
.create_domain(Domain::new(req.name, req.tld, user.id))
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_data_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateDomainData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_domain_data(id, &user, req.data).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_domain(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetFileQuery {
|
||||
#[serde(default, alias = "s")]
|
||||
pub session: String,
|
||||
}
|
||||
|
||||
pub async fn get_file_request(
|
||||
Path(mut addr): Path<String>,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<GetFileQuery>,
|
||||
) -> impl IntoResponse {
|
||||
if !addr.starts_with("atto://") {
|
||||
addr = format!("atto://{addr}");
|
||||
}
|
||||
|
||||
// ...
|
||||
let data = &(data.read().await).0;
|
||||
let user = get_user_from_token!(--browser_session = props.session, data);
|
||||
let (subdomain, domain, tld, path) = Domain::from_str(&addr);
|
||||
|
||||
if path.starts_with("$") && user.is_none() {
|
||||
return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string()));
|
||||
} else if let Some(ref ua) = user
|
||||
&& path.starts_with("$paid")
|
||||
&& !ua.permissions.check(FinePermission::SUPPORTER)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Error::RequiresSupporter.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// resolve domain
|
||||
let domain = match data.get_domain_by_name_tld(&domain, &tld).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
return Err((StatusCode::BAD_REQUEST, e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// resolve service
|
||||
let service = match domain.service(&subdomain) {
|
||||
Some(id) => match data.get_service_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
return Err((StatusCode::BAD_REQUEST, e.to_string()));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Error::GeneralNotFound("service".to_string()).to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// resolve file
|
||||
match service.file(&path) {
|
||||
Some((f, _)) => Ok((
|
||||
[("Content-Type".to_string(), f.mime.to_string())],
|
||||
if f.mime == ServiceFsMime::Html {
|
||||
f.content
|
||||
.replace(
|
||||
"</body>",
|
||||
&format!(
|
||||
"<script src=\"{}/js/proto_links.js\" defer></script><script>
|
||||
globalThis.SECRET_SESSION = \"{}\";
|
||||
</script></body>",
|
||||
data.0.0.host, props.session
|
||||
),
|
||||
)
|
||||
.replace(
|
||||
".js\"",
|
||||
&format!(".js?r={}&s={}\"", service.revision, props.session),
|
||||
)
|
||||
.replace(
|
||||
".css\"",
|
||||
&format!(".css?r={}&s={}\"", service.revision, props.session),
|
||||
)
|
||||
} else {
|
||||
f.content
|
||||
}
|
||||
.replace("atto://", "/api/v1/net/"),
|
||||
)),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Error::GeneralNotFound("file".to_string()).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ use axum::{
|
|||
extract::{Json, Path},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_shared::snow::Snowflake;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
|
@ -110,7 +110,7 @@ pub async fn create_request(
|
|||
Ok(x) => {
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateJournal.into())
|
||||
.add_achievement(&mut user, AchievementName::CreateJournal.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
pub mod app_data;
|
||||
pub mod apps;
|
||||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod communities;
|
||||
pub mod domains;
|
||||
pub mod journals;
|
||||
pub mod notes;
|
||||
pub mod notifications;
|
||||
pub mod products;
|
||||
pub mod reactions;
|
||||
pub mod reports;
|
||||
pub mod requests;
|
||||
pub mod services;
|
||||
pub mod stacks;
|
||||
pub mod uploads;
|
||||
pub mod util;
|
||||
|
@ -18,15 +22,18 @@ use axum::{
|
|||
};
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
apps::AppQuota,
|
||||
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
|
||||
auth::AchievementName,
|
||||
communities::{
|
||||
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
|
||||
PollOption, PostContext,
|
||||
},
|
||||
communities_permissions::CommunityPermission,
|
||||
journals::JournalPrivacyPermission,
|
||||
littleweb::{DomainData, DomainTld, ServiceFsEntry},
|
||||
oauth::AppScope,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
products::{ProductPrice, ProductType},
|
||||
reactions::AssetType,
|
||||
stacks::{StackMode, StackPrivacy, StackSort},
|
||||
};
|
||||
|
@ -279,6 +286,10 @@ pub fn routes() -> Router {
|
|||
.route("/auth/user/{id}/avatar", get(auth::images::avatar_request))
|
||||
.route("/auth/user/{id}/banner", get(auth::images::banner_request))
|
||||
.route("/auth/user/{id}/follow", post(auth::social::follow_request))
|
||||
.route(
|
||||
"/auth/user/{id}/follow/toggle",
|
||||
post(auth::social::toggle_follow_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/follow/cancel",
|
||||
post(auth::social::cancel_follow_request),
|
||||
|
@ -287,7 +298,19 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/follow/accept",
|
||||
post(auth::social::accept_follow_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/force_unfollow_me",
|
||||
post(auth::social::force_unfollow_me_request),
|
||||
)
|
||||
.route("/auth/user/{id}/block", post(auth::social::block_request))
|
||||
.route(
|
||||
"/auth/user/{id}/block_ip",
|
||||
post(auth::social::ip_block_profile_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/ip/{id}/unblock_ip",
|
||||
post(auth::social::remove_ip_block_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/settings",
|
||||
post(auth::profile::update_user_settings_request),
|
||||
|
@ -300,6 +323,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/role/2",
|
||||
post(auth::profile::update_user_secondary_role_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/ban_reason",
|
||||
post(auth::profile::update_user_ban_reason_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}",
|
||||
delete(auth::profile::delete_user_request),
|
||||
|
@ -320,6 +347,14 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/verified",
|
||||
post(auth::profile::update_user_is_verified_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/awaiting_purchase",
|
||||
post(auth::profile::update_user_awaiting_purchase_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/deactivate",
|
||||
post(auth::profile::update_user_is_deactivated_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/totp",
|
||||
post(auth::profile::enable_totp_request),
|
||||
|
@ -379,8 +414,17 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/grants/{app}/refresh",
|
||||
post(auth::profile::refresh_grant_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/me/achievement",
|
||||
post(auth::profile::self_serve_achievement_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/me/invite_code",
|
||||
post(auth::profile::update_user_invite_code_request),
|
||||
)
|
||||
// apps
|
||||
.route("/apps", post(apps::create_request))
|
||||
.route("/apps/{id}", delete(apps::delete_request))
|
||||
.route("/apps/{id}/title", post(apps::update_title_request))
|
||||
.route("/apps/{id}/homepage", post(apps::update_homepage_request))
|
||||
.route("/apps/{id}/redirect", post(apps::update_redirect_request))
|
||||
|
@ -388,9 +432,21 @@ pub fn routes() -> Router {
|
|||
"/apps/{id}/quota_status",
|
||||
post(apps::update_quota_status_request),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id}/storage_capacity",
|
||||
post(apps::update_storage_capacity_request),
|
||||
)
|
||||
.route("/apps/{id}/scopes", post(apps::update_scopes_request))
|
||||
.route("/apps/{id}", delete(apps::delete_request))
|
||||
.route("/apps/{id}/grant", post(apps::grant_request))
|
||||
.route("/apps/{id}/roll", post(apps::roll_api_key_request))
|
||||
// app data
|
||||
.route("/app_data", post(app_data::create_request))
|
||||
.route("/app_data/app", get(app_data::get_app_request))
|
||||
.route("/app_data/{id}", delete(app_data::delete_request))
|
||||
.route("/app_data/{id}/key", post(app_data::update_key_request))
|
||||
.route("/app_data/{id}/value", post(app_data::update_value_request))
|
||||
.route("/app_data/query", post(app_data::query_request))
|
||||
.route("/app_data/query", delete(app_data::delete_query_request))
|
||||
// warnings
|
||||
.route("/warnings/{id}", get(auth::user_warnings::get_request))
|
||||
.route("/warnings/{id}", post(auth::user_warnings::create_request))
|
||||
|
@ -439,6 +495,7 @@ pub fn routes() -> Router {
|
|||
post(communities::communities::update_membership_role),
|
||||
)
|
||||
// ipbans
|
||||
.route("/bans/{ip}", get(auth::ipbans::check_request))
|
||||
.route("/bans/{ip}", post(auth::ipbans::create_request))
|
||||
.route("/bans/{ip}", delete(auth::ipbans::delete_request))
|
||||
// reports
|
||||
|
@ -488,6 +545,18 @@ pub fn routes() -> Router {
|
|||
"/service_hooks/stripe",
|
||||
post(auth::connections::stripe::stripe_webhook),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/register",
|
||||
post(auth::connections::stripe::create_seller_account_request),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/onboarding",
|
||||
post(auth::connections::stripe::onboarding_account_link_request),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/login",
|
||||
post(auth::connections::stripe::login_link_request),
|
||||
)
|
||||
// channels
|
||||
.route("/channels", post(channels::channels::create_request))
|
||||
.route(
|
||||
|
@ -511,6 +580,14 @@ pub fn routes() -> Router {
|
|||
"/channels/{id}/kick",
|
||||
post(channels::channels::kick_member_request),
|
||||
)
|
||||
.route(
|
||||
"/channels/{id}/mute",
|
||||
post(channels::channels::mute_channel_request),
|
||||
)
|
||||
.route(
|
||||
"/channels/{id}/mute",
|
||||
delete(channels::channels::unmute_channel_request),
|
||||
)
|
||||
.route("/channels/{id}", get(channels::channels::get_request))
|
||||
.route(
|
||||
"/channels/community/{id}",
|
||||
|
@ -599,6 +676,40 @@ pub fn routes() -> Router {
|
|||
// uploads
|
||||
.route("/uploads/{id}", get(uploads::get_request))
|
||||
.route("/uploads/{id}", delete(uploads::delete_request))
|
||||
.route("/uploads/{id}/data", get(uploads::get_json_request))
|
||||
.route("/uploads/{id}/alt", post(uploads::update_alt_request))
|
||||
// services
|
||||
.route("/services", get(services::list_request))
|
||||
.route("/services", post(services::create_request))
|
||||
.route("/services/{id}", get(services::get_request))
|
||||
.route("/services/{id}", delete(services::delete_request))
|
||||
.route("/services/{id}/name", post(services::update_name_request))
|
||||
.route("/services/{id}/files", post(services::update_files_request))
|
||||
.route(
|
||||
"/services/{id}/content",
|
||||
post(services::update_content_request),
|
||||
)
|
||||
// domains
|
||||
.route("/domains", get(domains::list_request))
|
||||
.route("/domains", post(domains::create_request))
|
||||
.route("/domains/{id}", get(domains::get_request))
|
||||
.route("/domains/{id}", delete(domains::delete_request))
|
||||
.route("/domains/{id}/data", post(domains::update_data_request))
|
||||
// products
|
||||
.route("/products", get(products::list_request))
|
||||
.route("/products", post(products::create_request))
|
||||
.route("/products/{id}", get(products::get_request))
|
||||
.route("/products/{id}", delete(products::delete_request))
|
||||
.route("/products/{id}/name", post(products::update_name_request))
|
||||
.route(
|
||||
"/products/{id}/description",
|
||||
post(products::update_description_request),
|
||||
)
|
||||
.route("/products/{id}/price", post(products::update_price_request))
|
||||
}
|
||||
|
||||
pub fn lw_routes() -> Router {
|
||||
Router::new().route("/net/{*addr}", get(domains::get_file_request))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -617,6 +728,12 @@ pub struct RegisterProps {
|
|||
pub captcha_response: String,
|
||||
#[serde(default)]
|
||||
pub invite_code: String,
|
||||
/// If this is true, invite_code should be empty.
|
||||
///
|
||||
/// If invite codes are enabled, but purchase is false, the invite_code MUST
|
||||
/// be checked and MUST be valid.
|
||||
#[serde(default)]
|
||||
pub purchase: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -724,6 +841,16 @@ pub struct UpdateUserIsVerified {
|
|||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserAwaitingPurchase {
|
||||
pub awaiting_purchase: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserIsDeactivated {
|
||||
pub is_deactivated: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNotificationRead {
|
||||
pub read: bool,
|
||||
|
@ -749,6 +876,16 @@ pub struct UpdateSecondaryUserRole {
|
|||
pub role: SecondaryPermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserBanReason {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserInviteCode {
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUser {
|
||||
pub password: String,
|
||||
|
@ -777,6 +914,10 @@ pub struct CreateQuestion {
|
|||
pub receiver: String,
|
||||
#[serde(default)]
|
||||
pub community: String,
|
||||
#[serde(default)]
|
||||
pub mask_owner: bool,
|
||||
#[serde(default)]
|
||||
pub asking_about: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -871,6 +1012,7 @@ pub struct UpdatePostIsOpen {
|
|||
pub struct CreateApp {
|
||||
pub title: String,
|
||||
pub homepage: String,
|
||||
#[serde(default)]
|
||||
pub redirect: String,
|
||||
}
|
||||
|
||||
|
@ -894,6 +1036,11 @@ pub struct UpdateAppQuotaStatus {
|
|||
pub quota_status: AppQuota,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppStorageCapacity {
|
||||
pub storage_capacity: DeveloperPassStorageQuota,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppScopes {
|
||||
pub scopes: Vec<AppScope>,
|
||||
|
@ -968,7 +1115,96 @@ pub struct AddJournalDir {
|
|||
pub struct RemoveJournalDir {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteTags {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AwardAchievement {
|
||||
pub name: AchievementName,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateService {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateServiceName {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateServiceFiles {
|
||||
pub files: Vec<ServiceFsEntry>,
|
||||
pub id_path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateServiceFileContent {
|
||||
pub content: String,
|
||||
pub id_path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDomain {
|
||||
pub name: String,
|
||||
pub tld: DomainTld,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateDomainData {
|
||||
pub data: Vec<(String, DomainData)>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProduct {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub product_type: ProductType,
|
||||
pub price: ProductPrice,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductName {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductDescription {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductPrice {
|
||||
pub price: ProductPrice,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUploadAlt {
|
||||
pub alt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppDataKey {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAppDataValue {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InsertAppData {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryAppData {
|
||||
pub query: AppDataSelectQuery,
|
||||
pub mode: AppDataSelectMode,
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ use axum::{
|
|||
extract::{Json, Path},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
|
@ -16,6 +16,7 @@ use crate::{
|
|||
use tetratto_core::{
|
||||
database::NAME_REGEX,
|
||||
model::{
|
||||
auth::AchievementName,
|
||||
journals::{JournalPrivacyPermission, Note},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
|
@ -190,11 +191,20 @@ pub async fn update_content_request(
|
|||
Json(props): Json<UpdateNoteContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditNote.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_content(id, &user, &props.content).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = data
|
||||
|
@ -257,7 +267,7 @@ pub async fn delete_by_dir_request(
|
|||
}
|
||||
|
||||
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content), true)
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{oauth, ApiReturn, Error};
|
||||
|
||||
pub async fn delete_request(
|
||||
|
|
234
crates/app/src/routes/api/v1/products.rs
Normal file
234
crates/app/src/routes/api/v1/products.rs
Normal file
|
@ -0,0 +1,234 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
image::{save_webp_buffer, JsonMultipart},
|
||||
routes::{
|
||||
api::v1::{
|
||||
communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription,
|
||||
UpdateProductName, UpdateProductPrice,
|
||||
},
|
||||
pages::PaginatedQuery,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
oauth,
|
||||
products::Product,
|
||||
uploads::{MediaType, MediaUpload},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_product_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_products_by_user(user.id, 12, props.page).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if uploads.len() > 4 {
|
||||
return Json(
|
||||
Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut product = Product::new(
|
||||
user.id,
|
||||
req.name,
|
||||
req.description,
|
||||
req.price,
|
||||
req.product_type,
|
||||
);
|
||||
|
||||
// check sizes
|
||||
for img in &uploads {
|
||||
if img.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
}
|
||||
|
||||
// create uploads
|
||||
for _ in 0..uploads.len() {
|
||||
product.uploads.push(
|
||||
match data
|
||||
.create_upload(MediaUpload::new(MediaType::Webp, product.owner))
|
||||
.await
|
||||
{
|
||||
Ok(u) => u.id,
|
||||
Err(e) => return Json(e.into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let product_uploads = product.uploads.clone();
|
||||
match data.create_product(product).await {
|
||||
Ok(x) => {
|
||||
// store uploads
|
||||
for (i, upload_id) in product_uploads.iter().enumerate() {
|
||||
let image = match uploads.get(i) {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
if let Err(e) = data.delete_upload(*upload_id).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let upload = match data.get_upload_by_id(*upload_id).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None)
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_name_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductName>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_product_name(id, &user, &req.name).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_description_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductDescription>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_product_description(id, &user, &req.description)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_price_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductPrice>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_product_price(id, &user, req.price).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_product(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
use crate::{State, get_user_from_token, routes::api::v1::CreateReaction};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction};
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
|
@ -26,6 +31,7 @@ pub async fn get_request(
|
|||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateReaction>,
|
||||
) -> impl IntoResponse {
|
||||
|
@ -40,6 +46,20 @@ pub async fn create_request(
|
|||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
// get real ip
|
||||
let real_ip = headers
|
||||
.get(data.0.0.security.real_ip_header.to_owned())
|
||||
.unwrap_or(&HeaderValue::from_static(""))
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// check for ip ban
|
||||
let addr = RemoteAddr::from(real_ip.as_str());
|
||||
if data.get_ipban_by_addr(&addr).await.is_ok() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// check for existing reaction
|
||||
if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await {
|
||||
match data.delete_reaction(r.id, &user).await {
|
||||
|
@ -63,6 +83,7 @@ pub async fn create_request(
|
|||
.create_reaction(
|
||||
Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
|
||||
&user,
|
||||
&addr,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use super::CreateReport;
|
||||
use crate::{State, get_user_from_token};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{ApiReturn, Error, moderation::Report};
|
||||
|
||||
pub async fn create_request(
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{oauth, ApiReturn, Error};
|
||||
|
||||
pub async fn delete_request(
|
||||
|
|
194
crates/app/src/routes/api/v1/services.rs
Normal file
194
crates/app/src/routes/api/v1/services.rs
Normal file
|
@ -0,0 +1,194 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{
|
||||
CreateService, UpdateServiceFileContent, UpdateServiceFiles, UpdateServiceName,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error};
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_service_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_services_by_user(user.id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateService>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateSite.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.create_service(Service::new(req.name, user.id)).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_name_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceName>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_service_name(id, &user, &req.name).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_files_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceFiles>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut service = match data.get_service_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if req.id_path.is_empty() {
|
||||
service.files = req.files;
|
||||
} else {
|
||||
match service.file_mut(req.id_path) {
|
||||
Some(f) => f.children = req.files,
|
||||
None => return Json(Error::GeneralNotFound("file".to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
match data.update_service_files(id, &user, service.files).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_content_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceFileContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut service = match data.get_service_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// update
|
||||
let file = match service.file_mut(req.id_path) {
|
||||
Some(f) => f,
|
||||
None => return Json(Error::GeneralNotFound("file".to_string()).into()),
|
||||
};
|
||||
|
||||
file.content = req.content;
|
||||
|
||||
// ...
|
||||
match data.update_service_files(id, &user, service.files).await {
|
||||
Ok(_) => match data
|
||||
.update_service_revision(id, unix_epoch_timestamp() as i64)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
},
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_service(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::{
|
||||
model::{
|
||||
oauth,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use std::fs::exists;
|
||||
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use pathbufd::PathBufD;
|
||||
use crate::{get_user_from_token, State};
|
||||
use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State};
|
||||
use super::auth::images::read_image;
|
||||
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
|
||||
|
||||
|
@ -52,6 +52,24 @@ pub async fn get_request(
|
|||
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
|
||||
}
|
||||
|
||||
pub async fn get_json_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
let upload = match data.get_upload_by_id(id).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(upload),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -72,3 +90,25 @@ pub async fn delete_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_alt_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(props): Json<UpdateUploadAlt>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_upload_alt(id, &user, &props.alt).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Upload updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::cookie::CookieJar;
|
||||
use pathbufd::PathBufD;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::permissions::FinePermission;
|
||||
|
|
|
@ -19,3 +19,5 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
|
|||
serve_asset!(me_js_request: ME_JS("text/javascript"));
|
||||
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
||||
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
|
||||
serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript"));
|
||||
serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript"));
|
||||
|
|
|
@ -20,6 +20,8 @@ pub fn routes(config: &Config) -> Router {
|
|||
.route("/js/me.js", get(assets::me_js_request))
|
||||
.route("/js/streams.js", get(assets::streams_js_request))
|
||||
.route("/js/carp.js", get(assets::carp_js_request))
|
||||
.route("/js/proto_links.js", get(assets::proto_links_request))
|
||||
.route("/js/app_sdk.js", get(assets::app_sdk_request))
|
||||
.nest_service(
|
||||
"/public",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||
|
@ -42,3 +44,14 @@ pub fn routes(config: &Config) -> Router {
|
|||
// pages
|
||||
.merge(pages::routes())
|
||||
}
|
||||
|
||||
/// These routes are only used when you provide the `LITTLEWEB` environment variable.
|
||||
///
|
||||
/// These routes are NOT for editing. These routes are only for viewing littleweb sites.
|
||||
pub fn lw_routes() -> Router {
|
||||
Router::new()
|
||||
// api
|
||||
.nest("/api/v1", api::v1::lw_routes())
|
||||
// pages
|
||||
.merge(pages::lw_routes())
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue