Compare commits

...

93 commits

Author SHA1 Message Date
46e38042ce fix: user notification/request counts 2025-07-26 22:28:45 -04:00
29155ddb0c add: mail base 2025-07-26 22:18:32 -04:00
a337e0c7c1 add: hide_social_follows setting 2025-07-25 13:39:34 -04:00
e78c43ab62 add: check ip ban endpoint 2025-07-23 14:44:47 -04:00
8786cb4781 add: render_markdown_dirty 2025-07-21 22:29:16 -04:00
9aed5de097 add: extended app storage limits 2025-07-20 20:19:33 -04:00
c757ddb77a fix: markdown autolinking with images 2025-07-20 16:41:50 -04:00
46849ba66c fix: hyphens in links 2025-07-20 16:18:56 -04:00
fe2e61118a add: use pulldown-cmark instead 2025-07-20 15:28:44 -04:00
3f70a8f465 chore: bump and publish shared 2025-07-20 15:09:34 -04:00
55460fc60a add: actually parse arrow alignment for markdown 2025-07-20 15:04:16 -04:00
d58e47cbbe fix: only add delta bytes when changing app data value 2025-07-20 03:33:03 -04:00
270d7550d6 fix: app data limits 2025-07-20 03:12:27 -04:00
6f2d556c65 add: app data rename method 2025-07-19 23:21:01 -04:00
35b66c94d0 chore: publish l10n, shared, and core 2025-07-19 21:30:41 -04:00
7d30d65a3b fix: profile panic 2025-07-19 15:38:58 -04:00
fe1e53c47a add: apps rust sdk 2025-07-19 15:31:06 -04:00
f05074ffc5 fix: delete apps and app_data when deleting user 2025-07-19 03:20:13 -04:00
63d3c2350d add: user is_deactivated 2025-07-19 03:17:21 -04:00
9ccbc69405 add: app sdk client auth flow example 2025-07-19 02:00:04 -04:00
0138bf4cd4 add: user requests in js app sdk 2025-07-19 00:44:12 -04:00
884a89904e add: channel mutes 2025-07-18 20:04:26 -04:00
02f3d08926 add: developer pass 2025-07-18 14:52:00 -04:00
636ecce9f4 add: apps js sdk 2025-07-18 13:22:25 -04:00
e393221b4f fix: check muted phrases while creating questions 2025-07-18 12:22:50 -04:00
22aea48cc5 add: better app data queries 2025-07-18 00:14:52 -04:00
9f61d9ce6a fix: post creation form 2025-07-17 13:51:56 -04:00
440ca81c25 fix: properly update app usage 2025-07-17 13:46:20 -04:00
f423daf2fc add: app_data api 2025-07-17 13:34:10 -04:00
5c520f4308 add: app_data table 2025-07-17 01:30:27 -04:00
f802a1c8ab chore: bump deps 2025-07-17 00:44:05 -04:00
d1c3643574 add: user ban_reason 2025-07-16 20:18:39 -04:00
b25bda29b8 fix: can_manage_posts permission 2025-07-16 18:36:56 -04:00
0256f38e5d fix: don't toggle follow when following back 2025-07-15 15:59:05 -04:00
70ecc6f96e add: manage followers page 2025-07-15 00:08:49 -04:00
959a125992 add: change default avatar 2025-07-14 22:05:59 -04:00
8dfd307919 fix: stripe notification spam 2025-07-14 16:54:55 -04:00
e0e38b2b32 add: upload alt text 2025-07-14 15:30:17 -04:00
3b5b0ce1a1 add: product uploads 2025-07-13 23:15:00 -04:00
292d302304 fix: regular question asking 2025-07-13 19:58:59 -04:00
052ddf862f fix: check permissions before asking about a post 2025-07-13 19:05:17 -04:00
73d8e9ab49 fix: don't show "ask about this" if owner has questions disabled 2025-07-13 18:43:36 -04:00
2c83ed3d9d add: "ask about this" from neospring 2025-07-13 18:42:08 -04:00
f94570f74c add: settings presets 2025-07-13 17:54:12 -04:00
cf2af1e1e9 add: products api 2025-07-13 15:28:55 -04:00
2be2409d66 fix: InvoicePaymentFailed event 2025-07-13 12:42:28 -04:00
ea13526515 add: product types 2025-07-13 00:50:16 -04:00
2705608903 add: product types 2025-07-13 00:05:28 -04:00
aea764948c add: ability to create seller account 2025-07-12 21:05:45 -04:00
e4468e4768 add: user seller_data 2025-07-12 18:06:36 -04:00
fdaa81422a add: better stripe endpoint 2025-07-12 16:30:57 -04:00
227cd3d2ac fix: user follows panic 2025-07-12 14:44:50 -04:00
6af56ed2b2 fix: atto links (relative) 2025-07-12 00:07:37 -04:00
4d49fc3cdf fix: littleweb browser url 2025-07-11 19:39:46 -04:00
cfcc2358f4 add: service edit date + browser session ids 2025-07-11 18:56:49 -04:00
9aee80493f fix: anonymous post page panic 2025-07-11 12:35:47 -04:00
14f3bf849e add: post full unlist option 2025-07-10 18:43:54 -04:00
bdd8f9a869 add: hide_from_social_lists user setting 2025-07-10 13:32:43 -04:00
4e152b07be add: littleweb (common) achievements 2025-07-09 22:59:28 -04:00
7960f1ed41 fix: nsfw posts in all/communities timelines 2025-07-09 22:29:54 -04:00
69067145ce fix: home timeline setting 2025-07-09 21:44:49 -04:00
7ead0ce775 fix: don't change link hrefs in littleweb browser 2025-07-08 18:29:59 -04:00
22a2545aa0 fix: littleweb browser page url bar 2025-07-08 18:25:47 -04:00
e72ccf9139 fix: mod panel secondary role builder 2025-07-08 17:52:39 -04:00
65e5d5f4e9 fix: user domains view for staff 2025-07-08 17:44:49 -04:00
388ccbf58c add: small littleweb browser changes 2025-07-08 17:38:24 -04:00
e7febc7c7e add: allow direct "atto://" links to work for script tags 2025-07-08 15:33:51 -04:00
78c9b3349d add: better domain editor ui 2025-07-08 15:21:57 -04:00
4ebd7e6c2b fix: "ask anonymously" checkbox 2025-07-08 14:36:14 -04:00
d67e7c9c33 add: littleweb full 2025-07-08 13:35:23 -04:00
3fc0872867 add: littleweb api + scopes 2025-07-07 16:32:18 -04:00
c4de17058b add: littleweb base 2025-07-07 14:45:30 -04:00
07a23f505b add: dedicated responses tab for profiles 2025-07-06 13:34:20 -04:00
9ba6320d46 fix: register page captcha 2025-07-05 11:58:51 -04:00
e5b6b5a4d4 fix: duplicated posts in all timeline 2025-07-04 17:41:58 -04:00
1dc0611298 add: allow published notes to be shown through iframe 2025-07-03 23:58:42 -04:00
2ec8d86edf add: purchased accounts 2025-07-03 21:56:21 -04:00
0aa2ea362f chore: refactor auto_method macro for SecondaryPermission 2025-07-02 23:10:58 -04:00
ee2f7c7cbb fix: render dates in quotes with long text 2025-07-02 22:41:10 -04:00
b493b2ade8 add: layouts api 2025-07-02 20:14:04 -04:00
c83d0a9fc0 add: layouts types 2025-07-02 17:08:40 -04:00
0634819278 add: ability to mask your account when creating a question 2025-07-01 14:50:19 -04:00
973373426a add: policy achievements 2025-06-30 18:49:41 -04:00
d90b08720a add: move new block feature to a setting 2025-06-30 18:10:00 -04:00
d6348f7d67 fix: force auto_unlist when editing post context 2025-06-30 16:25:02 -04:00
f5faed7762 add: use RemoteAddr for ip blocks as well 2025-06-30 15:35:18 -04:00
14936b8b90 fix: notifs stream reconnection 2025-06-30 12:20:44 -04:00
b501a7c5f0 add: 4 more achievements 2025-06-29 18:38:32 -04:00
50f4592de2 fix: user community membership checks for timelines 2025-06-29 12:26:22 -04:00
0272985b81 add: put ip block button on blocked page as well 2025-06-28 13:33:25 -04:00
0163391380 add: ability to ip block users from their profile 2025-06-28 13:15:37 -04:00
a799c777ea add: 8 more achievements 2025-06-27 14:21:42 -04:00
8d70f65863 add: achievements progress bar 2025-06-27 13:36:10 -04:00
184 changed files with 10608 additions and 1075 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target /target
debug/ debug/
.dev

229
Cargo.lock generated
View file

@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
[[package]] [[package]]
name = "ammonia" name = "ammonia"
version = "4.1.0" version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f"
dependencies = [ dependencies = [
"cssparser", "cssparser",
"html5ever", "html5ever",
@ -337,12 +337,6 @@ dependencies = [
"tokio-postgres", "tokio-postgres",
] ]
[[package]]
name = "bberry"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458"
[[package]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.2" version = "0.10.2"
@ -488,7 +482,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz-build", "chrono-tz-build",
"phf", "phf 0.11.3",
] ]
[[package]] [[package]]
@ -498,7 +492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [ dependencies = [
"parse-zoneinfo", "parse-zoneinfo",
"phf", "phf 0.11.3",
"phf_codegen", "phf_codegen",
] ]
@ -648,7 +642,7 @@ dependencies = [
"cssparser-macros", "cssparser-macros",
"dtoa-short", "dtoa-short",
"itoa", "itoa",
"phf", "phf 0.11.3",
"smallvec", "smallvec",
] ]
@ -728,11 +722,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "emojis" name = "emojis"
version = "0.6.4" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
dependencies = [ dependencies = [
"phf", "phf 0.12.1",
] ]
[[package]] [[package]]
@ -961,6 +955,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@ -1124,12 +1127,11 @@ dependencies = [
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.31.0" version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [ dependencies = [
"log", "log",
"mac",
"markup5ever", "markup5ever",
"match_token", "match_token",
] ]
@ -1576,6 +1578,17 @@ dependencies = [
"syn 2.0.101", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@ -1739,20 +1752,11 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.16.1" version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [ dependencies = [
"log", "log",
"tendril", "tendril",
@ -1761,9 +1765,9 @@ dependencies = [
[[package]] [[package]]
name = "match_token" name = "match_token"
version = "0.1.0" version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1871,6 +1875,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "nanoneo"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892"
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@ -2164,7 +2174,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [ dependencies = [
"phf_macros", "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]] [[package]]
@ -2174,7 +2193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
] ]
[[package]] [[package]]
@ -2183,7 +2202,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared 0.11.3",
"rand 0.8.5", "rand 0.8.5",
] ]
@ -2194,7 +2213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.101", "syn 2.0.101",
@ -2209,6 +2228,15 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@ -2327,6 +2355,25 @@ dependencies = [
"syn 2.0.101", "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]] [[package]]
name = "qoi" name = "qoi"
version = "0.4.1" version = "0.4.1"
@ -2621,9 +2668,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.20" version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@ -2641,6 +2688,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@ -2856,9 +2904,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.140" version = "1.0.141"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -2907,6 +2955,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_spanned"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -3067,7 +3124,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared", "phf_shared 0.11.3",
"precomputed-hash", "precomputed-hash",
"serde", "serde",
] ]
@ -3079,7 +3136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
] ]
@ -3173,7 +3230,7 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck", "heck",
"pkg-config", "pkg-config",
"toml", "toml 0.8.23",
"version-compare", "version-compare",
] ]
@ -3231,19 +3288,20 @@ dependencies = [
[[package]] [[package]]
name = "tetratto" name = "tetratto"
version = "10.0.0" version = "12.0.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"async-stripe", "async-stripe",
"axum", "axum",
"axum-extra", "axum-extra",
"bberry",
"cf-turnstile", "cf-turnstile",
"contrasted", "contrasted",
"cookie",
"emojis", "emojis",
"futures-util", "futures-util",
"image", "image",
"mime_guess", "mime_guess",
"nanoneo",
"pathbufd", "pathbufd",
"regex", "regex",
"reqwest", "reqwest",
@ -3262,7 +3320,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-core" name = "tetratto-core"
version = "10.0.0" version = "12.0.2"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"base16ct", "base16ct",
@ -3271,6 +3329,7 @@ dependencies = [
"emojis", "emojis",
"md-5", "md-5",
"oiseau", "oiseau",
"paste",
"pathbufd", "pathbufd",
"regex", "regex",
"reqwest", "reqwest",
@ -3278,28 +3337,30 @@ dependencies = [
"serde_json", "serde_json",
"tetratto-l10n", "tetratto-l10n",
"tetratto-shared", "tetratto-shared",
"toml", "tokio",
"toml 0.9.2",
"totp-rs", "totp-rs",
] ]
[[package]] [[package]]
name = "tetratto-l10n" name = "tetratto-l10n"
version = "10.0.0" version = "12.0.0"
dependencies = [ dependencies = [
"pathbufd", "pathbufd",
"serde", "serde",
"toml", "toml 0.9.2",
] ]
[[package]] [[package]]
name = "tetratto-shared" name = "tetratto-shared"
version = "10.0.0" version = "12.0.6"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"chrono", "chrono",
"hex_fmt", "hex_fmt",
"markdown", "pulldown-cmark",
"rand 0.9.1", "rand 0.9.1",
"regex",
"serde", "serde",
"sha2", "sha2",
"snowflaked", "snowflaked",
@ -3425,16 +3486,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.45.1" version = "1.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"io-uring",
"libc", "libc",
"mio", "mio",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"slab",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -3476,7 +3539,7 @@ dependencies = [
"log", "log",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"phf", "phf 0.11.3",
"pin-project-lite", "pin-project-lite",
"postgres-protocol", "postgres-protocol",
"postgres-types", "postgres-types",
@ -3529,11 +3592,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.11" version = "0.6.11"
@ -3543,6 +3621,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.27" version = "0.22.27"
@ -3551,17 +3638,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write",
"winnow", "winnow",
] ]
[[package]] [[package]]
name = "toml_write" name = "toml_parser"
version = "0.1.2" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "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]] [[package]]
name = "totp-rs" name = "totp-rs"
@ -3796,12 +3891,6 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-id"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@ -3823,6 +3912,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -4066,7 +4161,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b"
dependencies = [ dependencies = [
"phf", "phf 0.11.3",
"phf_codegen", "phf_codegen",
"string_cache", "string_cache",
"string_cache_codegen", "string_cache_codegen",

View file

@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"]
package.authors = ["trisuaso"] package.authors = ["trisuaso"]
package.repository = "https://trisua.com/t/tetratto" package.repository = "https://trisua.com/t/tetratto"
package.license = "AGPL-3.0-or-later" package.license = "AGPL-3.0-or-later"
package.homepage = "https://tetratto.com"
[profile.dev] [profile.dev]
incremental = true incremental = true

View file

@ -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 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) ## 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! 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!

View file

@ -1,7 +1,11 @@
[package] [package]
name = "tetratto" name = "tetratto"
version = "10.0.0" version = "12.0.0"
edition = "2024" edition = "2024"
authors.workspace = true
repository.workspace = true
license.workspace = true
homepage.workspace = true
[dependencies] [dependencies]
pathbufd = "0.1.4" pathbufd = "0.1.4"
@ -9,19 +13,23 @@ serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0" tera = "1.20.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 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"] } 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"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
ammonia = "4.1.0" ammonia = "4.1.1"
tetratto-shared = { path = "../shared" } tetratto-shared = { path = "../shared" }
tetratto-core = { path = "../core" } tetratto-core = { path = "../core" }
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
image = "0.25.6" image = "0.25.6"
reqwest = { version = "0.12.20", features = ["json", "stream"] } reqwest = { version = "0.12.22", features = ["json", "stream"] }
regex = "1.11.1" regex = "1.11.1"
serde_json = "1.0.140" serde_json = "1.0.141"
mime_guess = "2.0.5" mime_guess = "2.0.5"
cf-turnstile = "0.2.0" cf-turnstile = "0.2.0"
contrasted = "0.1.3" contrasted = "0.1.3"
@ -32,7 +40,9 @@ async-stripe = { version = "0.41.0", features = [
"webhook-events", "webhook-events",
"billing", "billing",
"runtime-tokio-hyper", "runtime-tokio-hyper",
"connect",
] } ] }
emojis = "0.6.4" emojis = "0.7.0"
webp = "0.3.0" webp = "0.3.0"
bberry = "0.2.0" nanoneo = "0.2.0"
cookie = "0.18.1"

View file

@ -1,21 +1,17 @@
use bberry::{ use nanoneo::{
core::element::{Element, Render}, core::element::{Element, Render},
text, read_param, text, read_param,
}; };
use pathbufd::PathBufD; use pathbufd::PathBufD;
use regex::Regex; use regex::Regex;
use std::{ use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime};
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
time::SystemTime,
};
use tera::Context; use tera::Context;
use tetratto_core::{ use tetratto_core::{
config::Config, config::Config,
html::{pull_icons, ICONS},
model::{ model::{
auth::{DefaultTimelineChoice, User}, auth::{DefaultTimelineChoice, User},
permissions::FinePermission, permissions::{FinePermission, SecondaryPermission},
}, },
}; };
use tetratto_l10n::LangFile; 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 ME_JS: &str = include_str!("./public/js/me.js");
pub const STREAMS_JS: &str = include_str!("./public/js/streams.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 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 // html
pub const BODY: &str = include_str!("./public/html/body.lisp"); 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_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.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_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_BASE: &str = include_str!("./public/html/profile/base.lisp");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.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_REPLIES: &str = include_str!("./public/html/profile/replies.lisp");
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.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_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_LIST: &str = include_str!("./public/html/communities/list.lisp");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.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 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 // langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); 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_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_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 const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp");
pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> = pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
LazyLock::new(|| RwLock::new(String::new())); 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 { macro_rules! vendor_icon {
($name:literal, $icon:ident, $icons_dir:expr) => {{ ($name:literal, $icon:ident, $icons_dir:expr) => {{
let writer = &mut ICONS.write().await; let writer = &mut ICONS.write().await;
@ -228,7 +205,7 @@ pub(crate) async fn replace_in_html(
input.to_string() input.to_string()
} else { } else {
let start = SystemTime::now(); let start = SystemTime::now();
let parsed = bberry::parse(input); let parsed = nanoneo::parse(input);
println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros()); println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros());
if let Some(plugins) = plugins { 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); input = input.replace(cap.get(0).unwrap().as_str(), &replace_with);
} }
// icon (with class) // icons
let icon_with_class = input = pull_icons(input, &config.dirs.icons).await;
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);
}
// return // return
input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1); 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 { pub(crate) async fn write_assets(config: &Config) -> PathBufD {
vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons);
vendor_icon!("last_fm", VENDOR_LAST_FM_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); 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/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/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/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/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); 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/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/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/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/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); 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->"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 html_path
} }
@ -492,6 +432,11 @@ pub(crate) async fn initial_context(
"is_supporter", "is_supporter",
&ua.permissions.check(FinePermission::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()); ctx.insert("home", &ua.settings.default_timeline.relative_url());
} else { } else {
ctx.insert("is_helper", &false); ctx.insert("is_helper", &false);

68
crates/app/src/cookie.rs Normal file
View 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)
}
}

View file

@ -18,6 +18,7 @@ version = "1.0.0"
"general:link.search" = "Search" "general:link.search" = "Search"
"general:link.journals" = "Journals" "general:link.journals" = "Journals"
"general:link.achievements" = "Achievements" "general:link.achievements" = "Achievements"
"general:link.little_web" = "Little web"
"general:action.save" = "Save" "general:action.save" = "Save"
"general:action.delete" = "Delete" "general:action.delete" = "Delete"
"general:action.purge" = "Purge" "general:action.purge" = "Purge"
@ -29,7 +30,9 @@ version = "1.0.0"
"general:action.open" = "Open" "general:action.open" = "Open"
"general:action.view" = "View" "general:action.view" = "View"
"general:action.copy_link" = "Copy link" "general:action.copy_link" = "Copy link"
"general:action.copy_id" = "Copy ID"
"general:action.post" = "Post" "general:action.post" = "Post"
"general:action.apply" = "Apply"
"general:label.account" = "Account" "general:label.account" = "Account"
"general:label.safety" = "Safety" "general:label.safety" = "Safety"
"general:label.share" = "Share" "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.could_not_find_post" = "Could not find original post..."
"general:label.timeline_end" = "That's a wrap!" "general:label.timeline_end" = "That's a wrap!"
"general:label.loading" = "Working on it!" "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:label.supporter_motivation" = "Become a supporter!"
"general:action.become_supporter" = "Become supporter" "general:action.become_supporter" = "Become supporter"
@ -74,6 +79,7 @@ version = "1.0.0"
"auth:label.recent_replies" = "Recent replies" "auth:label.recent_replies" = "Recent replies"
"auth:label.recent_posts_with_media" = "Recent posts (with media)" "auth:label.recent_posts_with_media" = "Recent posts (with media)"
"auth:label.posts" = "Posts" "auth:label.posts" = "Posts"
"auth:label.responses" = "Answers"
"auth:label.replies" = "Replies" "auth:label.replies" = "Replies"
"auth:label.media" = "Media" "auth:label.media" = "Media"
"auth:label.outbox" = "Outbox" "auth:label.outbox" = "Outbox"
@ -87,6 +93,9 @@ version = "1.0.0"
"auth:action.message" = "Message" "auth:action.message" = "Message"
"auth:label.banned" = "Banned" "auth:label.banned" = "Banned"
"auth:label.banned_message" = "This user has been banned for breaking the site's rules." "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.create" = "Create"
"communities:action.select" = "Select" "communities:action.select" = "Select"
@ -122,6 +131,7 @@ version = "1.0.0"
"communities:label.edit_content" = "Edit content" "communities:label.edit_content" = "Edit content"
"communities:label.repost" = "Repost" "communities:label.repost" = "Repost"
"communities:label.quote_post" = "Quote post" "communities:label.quote_post" = "Quote post"
"communities:label.ask_about_this" = "Ask about this"
"communities:label.search_results" = "Search results" "communities:label.search_results" = "Search results"
"communities:label.query" = "Query" "communities:label.query" = "Query"
"communities:label.join_new" = "Join new" "communities:label.join_new" = "Join new"
@ -153,6 +163,7 @@ version = "1.0.0"
"settings:tab.sessions" = "Sessions" "settings:tab.sessions" = "Sessions"
"settings:tab.connections" = "Connections" "settings:tab.connections" = "Connections"
"settings:tab.images" = "Images" "settings:tab.images" = "Images"
"settings:tab.presets" = "Presets"
"settings:label.change_password" = "Change password" "settings:label.change_password" = "Change password"
"settings:label.current_password" = "Current password" "settings:label.current_password" = "Current password"
"settings:label.delete_account" = "Delete account" "settings:label.delete_account" = "Delete account"
@ -169,8 +180,14 @@ version = "1.0.0"
"settings:label.export" = "Export" "settings:label.export" = "Export"
"settings:label.manage_blocks" = "Manage blocks" "settings:label.manage_blocks" = "Manage blocks"
"settings:label.users" = "Users" "settings:label.users" = "Users"
"settings:label.ips" = "IPs"
"settings:label.generate_invites" = "Generate invites" "settings:label.generate_invites" = "Generate invites"
"settings:label.add_to_stack" = "Add to stack" "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.security" = "Security"
"settings:tab.blocks" = "Blocks" "settings:tab.blocks" = "Blocks"
"settings:tab.billing" = "Billing" "settings:tab.billing" = "Billing"
@ -185,6 +202,7 @@ version = "1.0.0"
"mod_panel:label.associations" = "Associations" "mod_panel:label.associations" = "Associations"
"mod_panel:label.invited_by" = "Invited by" "mod_panel:label.invited_by" = "Invited by"
"mod_panel:label.send_debug_payload" = "Send debug payload" "mod_panel:label.send_debug_payload" = "Send debug payload"
"mod_panel:label.ban_reason" = "Ban reason"
"mod_panel:action.send" = "Send" "mod_panel:action.send" = "Send"
"requests:label.requests" = "Requests" "requests:label.requests" = "Requests"
@ -208,6 +226,8 @@ version = "1.0.0"
"chats:action.add_someone" = "Add someone" "chats:action.add_someone" = "Add someone"
"chats:action.kick_member" = "Kick member" "chats:action.kick_member" = "Kick member"
"chats:action.mention_user" = "Mention user" "chats:action.mention_user" = "Mention user"
"chats:action.mute" = "Mute"
"chats:action.unmute" = "Unmute"
"stacks:link.stacks" = "Stacks" "stacks:link.stacks" = "Stacks"
"stacks:label.my_stacks" = "My stacks" "stacks:label.my_stacks" = "My stacks"
@ -220,6 +240,7 @@ version = "1.0.0"
"stacks:label.block_all" = "Block all" "stacks:label.block_all" = "Block all"
"stacks:label.unblock_all" = "Unblock all" "stacks:label.unblock_all" = "Unblock all"
"forge:label.forges" = "Forges"
"forge:label.my_forges" = "My forges" "forge:label.my_forges" = "My forges"
"forge:label.create_new" = "Create new forge" "forge:label.create_new" = "Create new forge"
"forge:tab.info" = "Info" "forge:tab.info" = "Info"
@ -228,6 +249,7 @@ version = "1.0.0"
"forge:action.close" = "Close" "forge:action.close" = "Close"
"developer:label.for_developers" = "for Developers" "developer:label.for_developers" = "for Developers"
"developer:label.apps" = "Apps"
"developer:label.my_apps" = "My apps" "developer:label.my_apps" = "My apps"
"developer:label.create_new" = "Create new app" "developer:label.create_new" = "Create new app"
"developer:label.homepage" = "Homepage" "developer:label.homepage" = "Homepage"
@ -236,9 +258,13 @@ version = "1.0.0"
"developer:label.change_homepage" = "Change homepage" "developer:label.change_homepage" = "Change homepage"
"developer:label.change_redirect" = "Change redirect URL" "developer:label.change_redirect" = "Change redirect URL"
"developer:label.change_quota_status" = "Change quota status" "developer:label.change_quota_status" = "Change quota status"
"developer:label.change_storage_capacity" = "Change storage capacity"
"developer:label.manage_scopes" = "Manage scopes" "developer:label.manage_scopes" = "Manage scopes"
"developer:label.scopes" = "Scopes" "developer:label.scopes" = "Scopes"
"developer:label.guides_and_help" = "Guides & help" "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.delete" = "Delete app"
"developer:action.authorize" = "Authorize" "developer:action.authorize" = "Authorize"
@ -260,3 +286,25 @@ version = "1.0.0"
"journals:action.publish" = "Publish" "journals:action.publish" = "Publish"
"journals:action.unpublish" = "Unpublish" "journals:action.unpublish" = "Unpublish"
"journals:action.view" = "View" "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"

View file

@ -87,7 +87,10 @@ macro_rules! get_user_from_token {
{ {
Ok(ua) => { Ok(ua) => {
if ua.permissions.check_banned() { 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 { } else {
Some(ua) Some(ua)
} }
@ -109,7 +112,7 @@ macro_rules! get_user_from_token {
Ok((grant, ua)) => { Ok((grant, ua)) => {
if grant.scopes.contains(&$grant_scope) { if grant.scopes.contains(&$grant_scope) {
if ua.permissions.check_banned() { if ua.permissions.check_banned() {
Some(tetratto_core::model::auth::User::banned()) None
} else { } else {
Some(ua) Some(ua)
} }
@ -140,6 +143,20 @@ macro_rules! get_user_from_token {
None 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] #[macro_export]
@ -166,7 +183,7 @@ macro_rules! user_banned {
let mut context = initial_context(&$data.0.0.0, lang, &$user).await; let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
context.insert("profile", &$other_user); context.insert("profile", &$other_user);
return Ok(Html( return Err(Html(
$data.1.render("profile/banned.html", &context).unwrap(), $data.1.render("profile/banned.html", &context).unwrap(),
)); ));
}; };
@ -175,6 +192,27 @@ macro_rules! user_banned {
#[macro_export] #[macro_export]
macro_rules! check_user_blocked_or_private { macro_rules! check_user_blocked_or_private {
($user:expr, $other_user:ident, $data:ident, $jar:ident) => { ($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 // check require_account
if $user.is_none() && $other_user.settings.require_account { if $user.is_none() && $other_user.settings.require_account {
return Err(Html( return Err(Html(
@ -233,7 +271,7 @@ macro_rules! check_user_blocked_or_private {
.is_ok(), .is_ok(),
); );
return Ok(Html( return Err(Html(
$data.1.render("profile/blocked.html", &context).unwrap(), $data.1.render("profile/blocked.html", &context).unwrap(),
)); ));
} }
@ -281,7 +319,7 @@ macro_rules! check_user_blocked_or_private {
.is_ok(), .is_ok(),
); );
return Ok(Html( return Err(Html(
$data.1.render("profile/private.html", &context).unwrap(), $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("follow_requested", &false);
context.insert("is_following", &false); context.insert("is_following", &false);
return Ok(Html( return Err(Html(
$data.1.render("profile/private.html", &context).unwrap(), $data.1.render("profile/private.html", &context).unwrap(),
)); ));
} }
@ -352,7 +390,14 @@ macro_rules! ignore_users_gen {
($user:ident, $data:ident) => { ($user:ident, $data:ident) => {
if let Some(ref ua) = $user { 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_userblocks_initiator_by_receivers(ua.id).await,
$data.0.get_user_stack_blocked_users(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) => {{ ($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 $data
.0 .0
.get_userblocks_initiator_by_receivers($user.id) .get_userblocks_initiator_by_receivers($user.id)
@ -376,9 +428,29 @@ macro_rules! ignore_users_gen {
($user:ident!, #$data:ident) => { ($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, $data.get_userblocks_initiator_by_receivers($user.id).await,
] ]
.concat() .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
}
};
}

View file

@ -2,13 +2,18 @@
#![doc(html_favicon_url = "/public/favicon.svg")] #![doc(html_favicon_url = "/public/favicon.svg")]
#![doc(html_logo_url = "/public/tetratto_bunny.webp")] #![doc(html_logo_url = "/public/tetratto_bunny.webp")]
mod assets; mod assets;
mod cookie;
mod image; mod image;
mod macros; mod macros;
mod routes; mod routes;
mod sanitize; mod sanitize;
use assets::{init_dirs, write_assets}; 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::*; pub use tetratto_core::*;
use axum::{ use axum::{
@ -27,15 +32,17 @@ use tracing::{Level, info};
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
use tokio::sync::RwLock; 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> { fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok( Ok(tetratto_shared::markdown::render_markdown(
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(value.as_str().unwrap())) &CustomEmoji::replace(value.as_str().unwrap()),
.replace("\\@", "@") true,
.replace("%5C@", "@")
.into(),
) )
.replace("\\@", "@")
.replace("%5C@", "@")
.into())
} }
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> { 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()) .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> { fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32) Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32)
.unwrap() .unwrap()
@ -107,16 +123,42 @@ async fn main() {
tera.register_filter("markdown", render_markdown); tera.register_filter("markdown", render_markdown);
tera.register_filter("color", color_escape); tera.register_filter("color", color_escape);
tera.register_filter("has_supporter", check_supporter); 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_staff_badge", check_staff_badge);
tera.register_filter("has_banned", check_banned); tera.register_filter("has_banned", check_banned);
tera.register_filter("remove_script_tags", remove_script_tags); tera.register_filter("remove_script_tags", remove_script_tags);
tera.register_filter("emojis", render_emojis); tera.register_filter("emojis", render_emojis);
let client = Client::new(); let client = Client::new();
let mut app = Router::new();
let app = Router::new() // create stripe client
.merge(routes::routes(&config)) let stripe_client = if let Some(ref stripe) = config.stripe {
.layer(Extension(Arc::new(RwLock::new((database, tera, client))))) 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( .layer(axum::extract::DefaultBodyLimit::max(
var("BODY_LIMIT") var("BODY_LIMIT")
.unwrap_or("8388608".to_string()) .unwrap_or("8388608".to_string())
@ -128,12 +170,9 @@ async fn main() {
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::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()); .layer(CatchPanicLayer::new());
// ...
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
.await .await
.unwrap(); .unwrap();

View file

@ -38,6 +38,10 @@
--pad-2: 0.5rem; --pad-2: 0.5rem;
--pad-3: 0.75rem; --pad-3: 0.75rem;
--pad-4: 1rem; --pad-4: 1rem;
--online: var(--color-green);
--idle: var(--color-yellow);
--offline: hsl(0, 0%, 50%);
} }
.dark, .dark,
@ -263,7 +267,7 @@ span,
code { code {
max-width: 100%; max-width: 100%;
overflow-wrap: normal; overflow-wrap: normal;
text-wrap: pretty; text-wrap: stable;
word-wrap: break-word; word-wrap: break-word;
} }

View file

@ -404,7 +404,7 @@ select:focus {
.poll_bar { .poll_bar {
background-color: var(--color-primary); background-color: var(--color-primary);
border-radius: var(--radius); border-radius: var(--radius);
height: 25px; height: 24px;
} }
.poll_option { .poll_option {
@ -413,6 +413,22 @@ select:focus {
overflow-wrap: anywhere; 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"] { input[type="checkbox"] {
--color: #c9b1bc; --color: #c9b1bc;
appearance: none; appearance: none;
@ -582,6 +598,10 @@ input[type="checkbox"]:checked {
font-size: 12px; font-size: 12px;
border-radius: 6px; border-radius: 6px;
height: max-content; height: max-content;
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
} }
.notification.tr { .notification.tr {
@ -596,6 +616,11 @@ input[type="checkbox"]:checked {
padding: 0; padding: 0;
} }
.notification:not(.chip) .icon {
width: 100%;
height: 100%;
}
/* chip */ /* chip */
.chip { .chip {
background: var(--color-primary); background: var(--color-primary);
@ -670,7 +695,7 @@ nav .button:not(.title):not(.active):hover {
margin-bottom: 0; margin-bottom: 0;
backdrop-filter: none; backdrop-filter: none;
bottom: 0; bottom: 0;
position: absolute; position: fixed;
height: max-content; height: max-content;
top: unset; top: unset;
} }
@ -930,7 +955,7 @@ dialog::backdrop {
transition: transform 0.15s; transition: transform 0.15s;
} }
.dropdown:has(.inner.open) .dropdown-arrow { .dropdown:has(.inner.open) .dropdown_arrow {
transform: rotateZ(180deg); transform: rotateZ(180deg);
} }
@ -1110,7 +1135,7 @@ details[open] > summary {
margin-bottom: var(--pad-1); margin-bottom: var(--pad-1);
} }
details[open] > summary::after { details[open]:not(.accordion) > summary::after {
top: 0; top: 0;
left: 0; left: 0;
width: 5px; width: 5px;
@ -1133,8 +1158,7 @@ details.accordion {
} }
details.accordion summary { details.accordion summary {
background: var(--background); background: var(--color-lowered);
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius); border-radius: var(--radius);
padding: var(--pad-3) var(--pad-4); padding: var(--pad-3) var(--pad-4);
margin: 0; margin: 0;
@ -1142,11 +1166,15 @@ details.accordion summary {
user-select: none; 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; transition: transform 0.15s;
} }
details.accordion[open] summary .icon { details.accordion[open] summary .icon.dropdown_arrow {
transform: rotateZ(180deg); transform: rotateZ(180deg);
} }
@ -1156,13 +1184,11 @@ details.accordion[open] summary {
} }
details.accordion .inner { details.accordion .inner {
background: var(--background); background: var(--color-raised);
padding: var(--pad-3) var(--pad-4); padding: var(--pad-3) var(--pad-4);
border-radius: var(--radius); border-radius: var(--radius);
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
border: solid 1px var(--color-super-lowered);
border-top: none;
} }
/* codemirror */ /* codemirror */

View file

@ -1,7 +1,7 @@
(text "{% extends \"root.html\" %} {% block body %}") (text "{% extends \"root.html\" %} {% block body %}")
(main (main
("class" "flex flex-col gap-2") ("class" "flex flex-col gap-2")
("style" "max-width: 25rem") ("style" "max-width: 48ch")
(h2 (h2
("class" "w-full text-center") ("class" "w-full text-center")
; block for title ; block for title

View file

@ -48,7 +48,8 @@
("name" "totp") ("name" "totp")
("id" "totp")))) ("id" "totp"))))
(button (button
(text "Submit"))) (icon (text "arrow-right"))
(str (text "auth:action.continue"))))
(script (script
(text "let flow_page = 1; (text "let flow_page = 1;

View file

@ -37,16 +37,31 @@
(text "{% if config.security.enable_invite_codes -%}") (text "{% if config.security.enable_invite_codes -%}")
(div (div
("class" "flex flex-col gap-1") ("class" "flex flex-col gap-1")
("oninput" "check_should_show_purchase(event)")
(label (label
("for" "invite_code") ("for" "invite_code")
(b (b
(text "Invite code"))) (text "Invite code (optional)")))
(input (input
("type" "text") ("type" "text")
("placeholder" "invite code") ("placeholder" "invite code")
("required" "")
("name" "invite_code") ("name" "invite_code")
("id" "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 %}") (text "{%- endif %}")
(hr) (hr)
(div (div
@ -84,8 +99,33 @@
("class" "cf-turnstile") ("class" "cf-turnstile")
("data-sitekey" "{{ config.turnstile.site_key }}")) ("data-sitekey" "{{ config.turnstile.site_key }}"))
(hr) (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 (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 (script
(text "async function register(e) { (text "async function register(e) {
@ -104,6 +144,7 @@
\"[name=cf-turnstile-response]\", \"[name=cf-turnstile-response]\",
).value, ).value,
invite_code: (e.target.invite_code || { value: \"\" }).value, invite_code: (e.target.invite_code || { value: \"\" }).value,
purchase: globalThis.DO_PURCHASE,
}), }),
}) })
.then((res) => res.json()) .then((res) => res.json())

View 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 %}")

View file

@ -94,6 +94,8 @@
atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::spotify_time_text\"](); // spotify durations
atto[\"hooks::verify_emoji\"](); atto[\"hooks::verify_emoji\"]();
fix_atto_links();
if (document.getElementById(\"tokens\")) { if (document.getElementById(\"tokens\")) {
trigger(\"me::render_token_picker\", [ trigger(\"me::render_token_picker\", [
document.getElementById(\"tokens\"), document.getElementById(\"tokens\"),
@ -101,6 +103,11 @@
} }
setTimeout(() => { setTimeout(() => {
if (globalThis.notifs_stream_init) {
return;
}
globalThis.notifs_stream_init = true;
trigger(\"me::notifications_stream\"); trigger(\"me::notifications_stream\");
}, 250); }, 250);
}); });
@ -158,6 +165,40 @@
(icon (text "x")) (icon (text "x"))
(str (text "dialog:action.cancel")))))) (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 (dialog
("id" "web_api_prompt") ("id" "web_api_prompt")
(div (div

View file

@ -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) => { globalThis.update_channel_title = async (id) => {
await trigger(\"atto::debounce\", [\"channels::update_title\"]); await trigger(\"atto::debounce\", [\"channels::update_title\"]);
const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); const title = await trigger(\"atto::prompt\", [\"New channel title:\"]);

View file

@ -31,6 +31,22 @@
(text "{{ icon \"user-plus\" }}") (text "{{ icon \"user-plus\" }}")
(span (span
(text "{{ text \"chats:action.add_someone\" }}"))) (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 %}") (text "{%- endif %}")
(button (button
("class" "lowered small") ("class" "lowered small")

View file

@ -29,7 +29,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (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 %}") (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
(div (div

View file

@ -39,7 +39,6 @@
("class" "flex gap-2") ("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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button (button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))))) (text "{{ text \"requests:label.answer\" }}")))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div

View file

@ -28,7 +28,6 @@
("maxlength" "32") ("maxlength" "32")
("value" "{{ text }}"))) ("value" "{{ text }}")))
(button (button
("class" "primary")
(text "{{ text \"dialog:action.continue\" }}")))) (text "{{ text \"dialog:action.continue\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")

View file

@ -135,7 +135,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))
@ -190,7 +189,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}")))) (text "{{ icon \"check\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")
@ -213,7 +211,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp") ("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("class" "fade")
@ -245,7 +242,6 @@
("required" "") ("required" "")
("minlength" "18"))) ("minlength" "18")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.select\" }}"))))) (text "{{ text \"communities:action.select\" }}")))))
(div (div
("class" "card flex flex-col gap-2 w-full") ("class" "card flex flex-col gap-2 w-full")
@ -296,7 +292,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{% for channel in channels %}") (text "{% for channel in channels %}")
(div (div

View file

@ -102,22 +102,33 @@
("class" "flush") ("class" "flush")
("style" "font-weight: 600") ("style" "font-weight: 600")
("target" "_top") ("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 -%}") (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}")
(span (span
("title" "Verified") ("title" "Verified")
("style" "color: var(--color-primary)") ("style" "color: var(--color-primary)")
("class" "flex items-center") ("class" "flex items-center")
(text "{{ icon \"badge-check\" }}")) (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 %}"))
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
(div (div
("style" "display: contents") ("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 "{{ 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 (div
("class" "card-nest post_outer:{{ post.id }} post_outer") ("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 (div
("class" "card small") ("class" "card small")
(a (a
@ -172,6 +183,12 @@
("class" "flex items-center") ("class" "flex items-center")
("style" "color: var(--color-primary)") ("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}")) (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 -%}") (text "{%- endif %} {% if post.stack -%}")
(a (a
("title" "Posted to a stack you're in") ("title" "Posted to a stack you're in")
@ -220,7 +237,7 @@
("hook" "long") ("hook" "long")
(text "{{ post.title }}")) (text "{{ post.title }}"))
(button ("class" "small lowered") (icon (text "ellipsis")))) (button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis"))))
(text "{% else %}") (text "{% else %}")
(text "{% if not post.context.content_warning -%}") (text "{% if not post.context.content_warning -%}")
(span (span
@ -235,7 +252,7 @@
; content ; content
(span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}")) (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 (div
("class" "card lowered red flex items-center gap-2") ("class" "card lowered red flex items-center gap-2")
(text "{{ icon \"frown\" }}") (text "{{ icon \"frown\" }}")
@ -314,13 +331,13 @@
("class" "button camo small") ("class" "button camo small")
("target" "_blank") ("target" "_blank")
(text "{{ icon \"external-link\" }}")) (text "{{ icon \"external-link\" }}"))
(text "{% if user -%}")
(div (div
("class" "dropdown") ("class" "dropdown")
(button (button
("class" "camo small") ("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -328,6 +345,7 @@
(b (b
("class" "title") ("class" "title")
(text "{{ text \"general:label.share\" }}")) (text "{{ text \"general:label.share\" }}"))
(text "{% if user -%}")
(button (button
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])")
(text "{{ icon \"repeat-2\" }}") (text "{{ icon \"repeat-2\" }}")
@ -350,7 +368,16 @@
(span (span
(text "BlueSky"))) (text "BlueSky")))
(text "{%- endif %}") (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 (b
("class" "title") ("class" "title")
(text "{{ text \"general:label.safety\" }}")) (text "{{ text \"general:label.safety\" }}"))
@ -360,12 +387,12 @@
(text "{{ icon \"flag\" }}") (text "{{ icon \"flag\" }}")
(span (span
(text "{{ text \"general:action.report\" }}"))) (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 (b
("class" "title") ("class" "title")
(text "{{ text \"general:action.manage\" }}")) (text "{{ text \"general:action.manage\" }}"))
; forge stuff ; 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 (button
("class" "green") ("class" "green")
("onclick" "trigger('me::update_open', ['{{ post.id }}', false])") ("onclick" "trigger('me::update_open', ['{{ post.id }}', false])")
@ -381,7 +408,7 @@
(text "{{ text \"forge:action.reopen\" }}"))) (text "{{ text \"forge:action.reopen\" }}")))
(text "{%- endif %} {%- endif %}") (text "{%- endif %} {%- endif %}")
; owner stuff ; owner stuff
(text "{% if user.id == post.owner -%}") (text "{% if user and user.id == post.owner -%}")
(a (a
("href" "/post/{{ post.id }}#/edit") ("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}") (text "{{ icon \"pen\" }}")
@ -413,8 +440,7 @@
(text "{{ icon \"undo\" }}") (text "{{ icon \"undo\" }}")
(span (span
(text "{{ text \"general:action.restore\" }}"))) (text "{{ text \"general:action.restore\" }}")))
(text "{%- endif %} {%- endif %}"))) (text "{%- endif %} {%- endif %}"))))))
(text "{%- endif %}"))))
(text "{% if community and show_community and community.id != config.town_square or question %}")) (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 -%}") (text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
@ -426,7 +452,6 @@
("alt" "Image upload") ("alt" "Image upload")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
(text "{% endfor %}")) (text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
(div (div
("class" "w-full card-nest") ("class" "w-full card-nest")
@ -527,7 +552,7 @@
("width" "24") ("width" "24")
("height" "24") ("height" "24")
("viewBox" "0 0 24 24") ("viewBox" "0 0 24 24")
("style" "fill: var(--color-green)") ("style" "fill: var(--online)")
(circle (circle
("cx" "12") ("cx" "12")
("cy" "12") ("cy" "12")
@ -540,7 +565,7 @@
("width" "24") ("width" "24")
("height" "24") ("height" "24")
("viewBox" "0 0 24 24") ("viewBox" "0 0 24 24")
("style" "fill: var(--color-yellow)") ("style" "fill: var(--idle)")
(circle (circle
("cx" "12") ("cx" "12")
("cy" "12") ("cy" "12")
@ -553,7 +578,7 @@
("width" "24") ("width" "24")
("height" "24") ("height" "24")
("viewBox" "0 0 24 24") ("viewBox" "0 0 24 24")
("style" "fill: hsl(0, 0%, 50%)") ("style" "fill: var(--offline)")
(circle (circle
("cx" "12") ("cx" "12")
("cy" "12") ("cy" "12")
@ -610,7 +635,8 @@
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("style" "display: none;") ("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 (style
(text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}"))
(text "{%- endif %}")) (text "{%- endif %}"))
@ -622,10 +648,10 @@
--{{ css }}: {{ color|color }} !important; --{{ 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 (div
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") ("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 (span
(text "{% if profile and profile.settings.anonymous_avatar_url -%}") (text "{% if profile and profile.settings.anonymous_avatar_url -%}")
(img (img
@ -634,7 +660,7 @@
("class" "avatar shadow") ("class" "avatar shadow")
("loading" "lazy") ("loading" "lazy")
("style" "--size: 52px")) ("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 %}") (text "{% else %}")
(a (a
("href" "/@{{ owner.username }}") ("href" "/@{{ owner.username }}")
@ -646,7 +672,7 @@
("class" "flex items-center gap-2 flex-wrap") ("class" "flex items-center gap-2 flex-wrap")
(span (span
("class" "name") ("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 (span
("class" "flex items-center gap-2") ("class" "flex items-center gap-2")
(b (b
@ -692,9 +718,13 @@
(text "{{ question.content|markdown|safe }}")) (text "{{ question.content|markdown|safe }}"))
; question drawings ; question drawings
(text "{{ self::post_media(upload_ids=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 ; anonymous user ip thing
; this is only shown if the post author is anonymous AND we are a helper ; 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 (details
("class" "card tiny lowered w-full") ("class" "card tiny lowered w-full")
(summary (summary
@ -703,12 +733,22 @@
(span (text "View IP"))) (span (text "View IP")))
(pre (code (text "{{ question.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 (div
("class" "flex gap-2 items-center justify-between")))) ("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 (div
("class" "card-nest") ("class" "card-nest")
(div (div
@ -718,6 +758,7 @@
("class" "no_p_margin") ("class" "no_p_margin")
(text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}")))
(form (form
("id" "create_question_form")
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
("onsubmit" "create_question_from_form(event)") ("onsubmit" "create_question_from_form(event)")
(div (div
@ -740,54 +781,78 @@
("minlength" "2") ("minlength" "2")
("maxlength" "4096"))) ("maxlength" "4096")))
(div (div
("class" "flex gap-2") ("class" "flex w-full justify-between gap-2 flex-collapse")
(button (div
("class" "primary") ("class" "flex gap-2")
(text "{{ text \"communities:action.create\" }}")) (button
(text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}") (text "{% if drawing_enabled -%}")
(button (button
("class" "lowered") ("class" "lowered")
("ui_ident" "add_drawing") ("ui_ident" "add_drawing")
("onclick" "attach_drawing()") ("onclick" "attach_drawing()")
("type" "button") ("type" "button")
(text "{{ text \"communities:action.draw\" }}")) (text "{{ text \"communities:action.draw\" }}"))
(button (button
("class" "lowered red hidden") ("class" "lowered red hidden")
("ui_ident" "remove_drawing") ("ui_ident" "remove_drawing")
("onclick" "remove_drawing()") ("onclick" "remove_drawing()")
("type" "button") ("type" "button")
(text "{{ text \"communities:action.remove_drawing\" }}")) (text "{{ text \"communities:action.remove_drawing\" }}"))
(script (script
(text "globalThis.attach_drawing = async () => { (text "globalThis.attach_drawing = async () => {
globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]); globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
globalThis.gerald.create_canvas(); globalThis.gerald.create_canvas();
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\"); document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"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=carp_canvas_field]\").innerHTML = \"\"; globalThis.remove_drawing = async () => {
globalThis.gerald = null; 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=carp_canvas_field]\").innerHTML = \"\";
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\"); 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 %}")))) (text "{%- endif %}"))))
(script (script
(text "globalThis.gerald = null; (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) { async function create_question_from_form(e) {
e.preventDefault(); e.preventDefault();
await trigger(\"atto::debounce\", [\"questions::create\"]); await trigger(\"atto::debounce\", [\"questions::create\"]);
@ -809,6 +874,8 @@
receiver: \"{{ receiver }}\", receiver: \"{{ receiver }}\",
community: \"{{ community }}\", community: \"{{ community }}\",
is_global: \"{{ is_global }}\" == \"true\", 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) -%}") (text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}")
(div (div
("class" "card-nest") ("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 (div
("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}") ("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}")
(div (div
@ -864,6 +931,7 @@
("class" "camo small") ("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -977,6 +1045,7 @@
("class" "camo small") ("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -1079,14 +1148,12 @@
(text "{{ icon \"circle-user-round\" }}") (text "{{ icon \"circle-user-round\" }}")
(span (span
(text "{{ text \"auth:link.my_profile\" }}"))) (text "{{ text \"auth:link.my_profile\" }}")))
(a (text "{% if not user.settings.disable_achievements -%}")
("href" "/journals/0/0")
(icon (text "notebook"))
(str (text "general:link.journals")))
(a (a
("href" "/achievements") ("href" "/achievements")
(icon (text "award")) (icon (text "award"))
(str (text "general:link.achievements"))) (str (text "general:link.achievements")))
(text "{%- endif %}")
(a (a
("href" "/settings") ("href" "/settings")
(text "{{ icon \"settings\" }}") (text "{{ icon \"settings\" }}")
@ -1124,22 +1191,18 @@
(icon (text "code")) (icon (text "code"))
(str (text "general:link.source_code"))) (str (text "general:link.source_code")))
(a (button
("href" "/reference/tetratto/index.html") ("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])")
("class" "button")
("data-turbo" "false")
(icon (text "rabbit")) (icon (text "rabbit"))
(str (text "general:link.reference"))) (str (text "general:link.reference")))
(a (button
("href" "{{ config.policies.terms_of_service }}") ("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])")
("class" "button")
(icon (text "heart-handshake")) (icon (text "heart-handshake"))
(text "Terms of service")) (text "Terms of service"))
(a (button
("href" "{{ config.policies.privacy }}") ("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])")
("class" "button")
(icon (text "cookie")) (icon (text "cookie"))
(text "Privacy policy")) (text "Privacy policy"))
(b ("class" "title") (str (text "general:label.account"))) (b ("class" "title") (str (text "general:label.account")))
@ -1211,6 +1274,7 @@
("class" "camo small square") ("class" "camo small square")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("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 (div
("class" "card w-full supporter_ad") ("class" "card w-full supporter_ad")
("ui_ident" "supporter_ad") ("ui_ident" "supporter_ad")
@ -1414,8 +1480,9 @@
(text "{{ icon \"heart\" }}") (text "{{ icon \"heart\" }}")
(span (span
(text "{{ text \"general:action.become_supporter\" }}"))))) (text "{{ text \"general:action.become_supporter\" }}")))))
(text "{%- endif %} {%- endmacro %}")
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}") (text "{% macro create_post_options() -%}")
(div (div
("class" "flex gap-2 flex-wrap") ("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 %}") (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") ("title" "More options")
("onclick" "document.getElementById('post_options_dialog').showModal()") ("onclick" "document.getElementById('post_options_dialog').showModal()")
("type" "button") ("type" "button")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(label (label
@ -1474,6 +1542,7 @@
is_nsfw: false, is_nsfw: false,
content_warning: \"\", content_warning: \"\",
tags: [], tags: [],
full_unlist: false,
}; };
window.BLANK_INITIAL_SETTINGS = JSON.stringify( window.BLANK_INITIAL_SETTINGS = JSON.stringify(
@ -1510,6 +1579,11 @@
// window.POST_INITIAL_SETTINGS.is_nsfw.toString(), // window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
// \"checkbox\", // \"checkbox\",
// ], // ],
[
[\"full_unlist\", \"Unlist from timelines\"],
window.POST_INITIAL_SETTINGS.full_unlist.toString(),
\"checkbox\",
],
[ [
[\"content_warning\", \"Content warning\"], [\"content_warning\", \"Content warning\"],
window.POST_INITIAL_SETTINGS.content_warning, window.POST_INITIAL_SETTINGS.content_warning,
@ -1726,8 +1800,8 @@
(span ("class" "notification chip") (text "{{ total }} votes")) (span ("class" "notification chip") (text "{{ total }} votes"))
(text "{% if not poll[2] -%}") (text "{% if not poll[2] -%}")
(span (span
("class" "notification chip") ("class" "notification chip flex items-center gap-1")
(text "Expires in ") (text "Expires in")
(span (span
("class" "poll_date") ("class" "poll_date")
("data-created" "{{ poll[0].created }}") ("data-created" "{{ poll[0].created }}")
@ -1803,7 +1877,6 @@
("id" "join_or_leave") ("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}") (text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button (button
("class" "primary")
("onclick" "join_community()") ("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}") (text "{{ icon \"circle-plus\" }}")
(span (span
@ -2017,6 +2090,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("style" "width: 32px") ("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -2043,6 +2117,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("style" "width: 32px") ("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -2134,6 +2209,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("style" "width: 32px") ("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -2213,6 +2289,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
("style" "width: 32px") ("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}")) (text "{{ icon \"ellipsis\" }}"))
(div (div
("class" "inner") ("class" "inner")
@ -2256,3 +2333,121 @@
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}")) (text "{%- endif %} {% endfor %}"))
(text "{%- endmacro %}") (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 %}")

View file

@ -10,11 +10,27 @@
(div (div
("id" "manage_fields") ("id" "manage_fields")
("class" "card lowered flex flex-col gap-2") ("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 -%}") (text "{% if is_helper -%}")
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "infinity"))
(b (str (text "developer:label.change_quota_status")))) (b (str (text "developer:label.change_quota_status"))))
(div (div
("class" "card") ("class" "card")
@ -28,11 +44,34 @@
("value" "Unlimited") ("value" "Unlimited")
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}") ("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
(text "Unlimited"))))) (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 %}") (text "{%- endif %}")
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "pencil"))
(b (str (text "developer:label.change_title")))) (b (str (text "developer:label.change_title"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -50,14 +89,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "house"))
(b (str (text "developer:label.change_homepage")))) (b (str (text "developer:label.change_homepage"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -75,14 +114,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "goal"))
(b (str (text "developer:label.change_redirect")))) (b (str (text "developer:label.change_redirect"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -100,14 +139,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "telescope"))
(b (str (text "developer:label.manage_scopes")))) (b (str (text "developer:label.manage_scopes"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -140,10 +179,22 @@
(icon (text "external-link")) (text "Docs")))) (icon (text "external-link")) (text "Docs"))))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (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 (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(ul (ul
@ -151,7 +202,8 @@
(li (b (text "Redirect URL: ")) (text "{{ app.redirect }}")) (li (b (text "Redirect URL: ")) (text "{{ app.redirect }}"))
(li (b (text "Quota status: ")) (text "{{ app.quota_status }}")) (li (b (text "Quota status: ")) (text "{{ app.quota_status }}"))
(li (b (text "User grants: ")) (text "{{ app.grants }}")) (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 (a
("class" "button") ("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) => { globalThis.change_title = async (e) => {
e.preventDefault(); 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 () => { globalThis.delete_app = async () => {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [

View file

@ -41,23 +41,19 @@
("id" "homepage") ("id" "homepage")
("placeholder" "homepage") ("placeholder" "homepage")
("required" "") ("required" "")
("minlength" "2") ("minlength" "2")))
("maxlength" "32")))
(div (div
("class" "flex flex-col gap-1") ("class" "flex flex-col gap-1")
(label (label
("for" "title") ("for" "title")
(text "{{ text \"developer:label.redirect\" }}")) (text "{{ text \"developer:label.redirect\" }} (optional)"))
(input (input
("type" "url") ("type" "url")
("name" "redirect") ("name" "redirect")
("id" "redirect") ("id" "redirect")
("placeholder" "redirect URL") ("placeholder" "redirect URL")
("required" "") ("minlength" "2")))
("minlength" "2")
("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
; app listing ; app listing
@ -126,7 +122,7 @@
body: JSON.stringify({ body: JSON.stringify({
title: e.target.title.value, title: e.target.title.value,
homepage: e.target.homepage.value, homepage: e.target.homepage.value,
redirect: e.target.redirect.value, redirect: e.target.redirect.value || \"\",
}), }),
}) })
.then((res) => res.json()) .then((res) => res.json())

View file

@ -39,6 +39,13 @@
(str (text "dialog:action.cancel"))))))) (str (text "dialog:action.cancel")))))))
(script (script
(text "setTimeout(() => { (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) => { globalThis.authorize = async (event) => {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [
@ -76,6 +83,7 @@
const search = new URLSearchParams(window.location.search); const search = new URLSearchParams(window.location.search);
search.append(\"verifier\", verifier); search.append(\"verifier\", verifier);
search.append(\"token\", res.payload); search.append(\"token\", res.payload);
search.append(\"uid\", \"{{ user.id }}\");
window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`; window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`;
} }

View file

@ -6,7 +6,7 @@
(main (main
("class" "flex flex-col gap-2") ("class" "flex flex-col gap-2")
; create new ; create new
(text "{% if user.permissions|has_supporter -%}") (text "{% if user.secondary_permissions|has_dev_pass -%}")
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
@ -30,10 +30,9 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{% else %}") (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 %}") (text "{%- endif %}")
; forge listing ; forge listing

View file

@ -253,7 +253,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))))) (text "{{ text \"general:action.save\" }}")))))))
@ -379,7 +378,6 @@
("name" "tags") ("name" "tags")
("id" "tags") ("id" "tags")
("placeholder" "tags") ("placeholder" "tags")
("required" "")
("minlength" "2") ("minlength" "2")
("maxlength" "128") ("maxlength" "128")
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))

View 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 %}")

View 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 %}")

View 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 %}")

View 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(\"&lt;/script&gt;\", \"</script\" + \">\"),
language: MIME_MODES[\"{{ file.mime }}\"],
theme: \"vs-dark\",
suggest: {
snippetsPreventQuickSuggestions: false,
},
});
});"))
(text "{%- endif %}")
(text "{% endblock %}")

View 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 %}")

View file

@ -39,12 +39,6 @@
("title" "Create post") ("title" "Create post")
(icon (text "square-pen"))) (icon (text "square-pen")))
(a
("href" "/chats/0/0")
("class" "button {% if selected == 'chats' -%}active{%- endif %}")
("title" "Chats")
(icon (text "message-circle")))
(a (a
("href" "/requests") ("href" "/requests")
("class" "button {% if selected == 'requests' -%}active{%- endif %}") ("class" "button {% if selected == 'requests' -%}active{%- endif %}")
@ -65,16 +59,54 @@
("id" "notifications_span") ("id" "notifications_span")
(text "{{ user.notification_count }}"))) (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 -%}") (text "{% if not hide_user_menu -%}")
(div (div
("class" "dropdown") ("class" "dropdown")
(button (button
("class" "flex-row title") ("class" "flex-row title")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exlude" "dropdown") ("exclude" "dropdown")
("style" "gap: var(--pad-1) !important") ("style" "gap: var(--pad-1) !important")
("title" "Account options")
(text "{{ components::avatar(username=user.username, size=\"24px\") }}") (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 "{{ components::user_menu() }}"))
(text "{%- endif %} {% else %}") (text "{%- endif %} {% else %}")
@ -84,7 +116,7 @@
("class" "title") ("class" "title")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown") ("exclude" "dropdown")
(icon_class (text "chevron-down") (text "dropdown-arrow"))) (icon_class (text "chevron-down") (text "dropdown_arrow")))
(div (div
("class" "inner") ("class" "inner")
@ -252,10 +284,17 @@
("class" "pillmenu") ("class" "pillmenu")
(text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}") (text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}")
(a (a
("href" "/@{{ profile.username }}") ("href" "/@{{ profile.username }}?f=true")
("class" "{% if selected == 'posts' -%}active{%- endif %}") ("class" "{% if selected == 'posts' -%}active{%- endif %}")
(str (text "auth:label.posts"))) (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 (a
("href" "/@{{ profile.username }}/replies") ("href" "/@{{ profile.username }}/replies")
("class" "{% if selected == 'replies' -%}active{%- endif %}") ("class" "{% if selected == 'replies' -%}active{%- endif %}")
@ -311,8 +350,9 @@
(span (span
(text "{{ text \"settings:tab.theme\" }}"))) (text "{{ text \"settings:tab.theme\" }}")))
(a (a
("href" "#")
("data-tab-button" "sessions") ("data-tab-button" "sessions")
("href" "#/sessions") ("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])")
(text "{{ icon \"cookie\" }}") (text "{{ icon \"cookie\" }}")
(span (span
(text "{{ text \"settings:tab.sessions\" }}"))) (text "{{ text \"settings:tab.sessions\" }}")))
@ -323,3 +363,17 @@
(span (span
(text "{{ text \"settings:tab.connections\" }}"))) (text "{{ text \"settings:tab.connections\" }}")))
(text "{%- endmacro %}") (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 %}")

View 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 %}")

View file

@ -12,9 +12,12 @@
(icon (text "coffee")) (icon (text "coffee"))
(span (text "Welcome to {{ config.name }}!"))) (span (text "Welcome to {{ config.name }}!")))
(div (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 "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 (div
("class" "card-nest") ("class" "card-nest")

View file

@ -62,12 +62,15 @@
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small flex items-center gap-2") ("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 (span
(text "{{ text \"requests:label.user_follow_request\" }}"))) (text "{{ text \"requests:label.user_follow_request\" }}")))
(div (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(span (span
("class" "flex items-center gap-2")
(text "{{ text \"requests:label.user_follow_request_message\" }}")) (text "{{ text \"requests:label.user_follow_request_message\" }}"))
(div (div
("class" "card flex flex-wrap w-full secondary gap-2") ("class" "card flex flex-wrap w-full secondary gap-2")
@ -92,7 +95,7 @@
(text "{%- endif %} {% endfor %} {% for question in questions %}") (text "{%- endif %} {% endfor %} {% for question in questions %}")
(div (div
("class" "card-nest") ("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 (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')") ("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')")
@ -129,7 +132,6 @@
(text "{{ text \"auth:action.ip_block\" }}"))) (text "{{ text \"auth:action.ip_block\" }}")))
(button (button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))))) (text "{{ text \"requests:label.answer\" }}")))))
(text "{% endfor %}"))) (text "{% endfor %}")))

View file

@ -28,7 +28,6 @@
("required" "") ("required" "")
("minlength" "16"))) ("minlength" "16")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))) (text "{{ text \"communities:action.create\" }}")))))
(script (script

View file

@ -50,6 +50,18 @@
(span (span
("class" "notification") ("class" "notification")
(text "{{ profile.request_count }}"))) (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 (button
("class" "red lowered") ("class" "red lowered")
("onclick" "delete_account(event)") ("onclick" "delete_account(event)")
@ -72,7 +84,7 @@
const ui = await ns(\"ui\"); const ui = await ns(\"ui\");
const element = document.getElementById(\"mod_options\"); 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 (do_confirm) {
if ( if (
!(await trigger(\"atto::confirm\", [ !(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\"]); ui.refresh_container(element, [\"actions\"]);
setTimeout(() => { setTimeout(() => {
@ -168,11 +207,26 @@
\"{{ profile.is_verified }}\", \"{{ profile.is_verified }}\",
\"checkbox\", \"checkbox\",
], ],
[
[\"awaiting_purchase\", \"Awaiting purchase\"],
\"{{ profile.awaiting_purchase }}\",
\"checkbox\",
],
[
[\"is_deactivated\", \"Is deactivated\"],
\"{{ profile.is_deactivated }}\",
\"checkbox\",
],
[ [
[\"role\", \"Permission level\"], [\"role\", \"Permission level\"],
\"{{ profile.permissions }}\", \"{{ profile.permissions }}\",
\"input\", \"input\",
], ],
[
[\"secondary_role\", \"Secondary permission level\"],
\"{{ profile.secondary_permissions }}\",
\"input\",
],
], ],
null, null,
{ {
@ -181,9 +235,22 @@
is_verified: value, 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) => { role: (new_role) => {
return update_user_role(new_role); return update_user_role(new_role);
}, },
secondary_role: (new_role) => {
return update_user_secondary_role(new_role);
},
}, },
); );
}, 100); }, 100);
@ -216,6 +283,32 @@
("class" "card lowered flex flex-wrap gap-2") ("class" "card lowered flex flex-wrap gap-2")
(text "{{ components::user_plate(user=invite[0], show_menu=false) }}"))) (text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
(text "{%- endif %}") (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 (div
("class" "card-nest w-full") ("class" "card-nest w-full")
(div (div
@ -234,6 +327,24 @@
(div (div
("class" "card lowered flex flex-col gap-2") ("class" "card lowered flex flex-col gap-2")
("id" "permission_builder"))) ("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 (script
(text "setTimeout(async () => { (text "setTimeout(async () => {
const get_permissions_html = await trigger( const get_permissions_html = await trigger(
@ -281,6 +392,33 @@
Number.parseInt(\"{{ profile.permissions }}\"), Number.parseInt(\"{{ profile.permissions }}\"),
\"permission_builder\", \"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);"))) }, 250);")))
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -37,7 +37,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "4096"))) ("maxlength" "4096")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")

View file

@ -71,7 +71,6 @@
("name" "content") ("name" "content")
("id" "content") ("id" "content")
("placeholder" "content") ("placeholder" "content")
("required" "")
("minlength" "2") ("minlength" "2")
("maxlength" "4096"))) ("maxlength" "4096")))
(div (div
@ -81,7 +80,6 @@
("class" "flex gap-2") ("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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))) (text "{{ text \"communities:action.create\" }}")))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div
@ -125,7 +123,6 @@
(text "{{ icon \"settings\" }}") (text "{{ icon \"settings\" }}")
(span (span
(text "{{ text \"communities:action.configure\" }}")))) (text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div (div
("class" "flex flex-col gap-2 hidden") ("class" "flex flex-col gap-2 hidden")
("data-tab" "configure") ("data-tab" "configure")
@ -201,7 +198,7 @@
\"checkbox\", \"checkbox\",
], ],
[ [
[\"is_nsfw\", \"Hide from public timelines\"], [\"is_nsfw\", \"Mark as NSFW\"],
\"{{ community.context.is_nsfw }}\", \"{{ community.context.is_nsfw }}\",
\"checkbox\", \"checkbox\",
], ],
@ -210,6 +207,11 @@
settings.content_warning, settings.content_warning,
\"textarea\", \"textarea\",
], ],
[
[\"full_unlist\", \"Unlist from timelines\"],
\"{{ user.settings.auto_full_unlist }}\",
\"checkbox\",
],
[ [
[\"tags\", \"Tags\"], [\"tags\", \"Tags\"],
settings.tags.join(\", \"), settings.tags.join(\", \"),
@ -245,6 +247,7 @@
}, },
}); });
}, 250);"))) }, 250);")))
(text "{%- endif %}")
(text "{% if user and user.id == post.owner -%}") (text "{% if user and user.id == post.owner -%}")
(div (div
("class" "card-nest w-full hidden") ("class" "card-nest w-full hidden")
@ -275,7 +278,6 @@
("class" "flex gap-2") ("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
(button (button
("class" "primary")
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(script (script
(text "async function edit_post_from_form(e) { (text "async function edit_post_from_form(e) {

View file

@ -72,19 +72,25 @@
("style" "color: var(--color-primary);") ("style" "color: var(--color-primary);")
("class" "flex items-center") ("class" "flex items-center")
(text "{{ icon \"badge-check\" }}")) (text "{{ icon \"badge-check\" }}"))
(text "{%- endif %} {% if profile.permissions|has_supporter -%}") (text "{%- endif %} {% if profile.permissions|has_supporter -%}")
(span (span
("title" "Supporter") ("title" "Supporter")
("style" "color: var(--color-primary);") ("style" "color: var(--color-primary);")
("class" "flex items-center") ("class" "flex items-center")
(text "{{ icon \"star\" }}")) (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 (span
("title" "Staff") ("title" "Staff")
("style" "color: var(--color-primary);") ("style" "color: var(--color-primary);")
("class" "flex items-center") ("class" "flex items-center")
(text "{{ icon \"shield-user\" }}")) (text "{{ icon \"shield-user\" }}"))
(text "{%- endif %} {% if profile.permissions|has_banned -%}") (text "{%- endif %} {% if profile.permissions|has_banned -%}")
(span (span
("title" "Banned") ("title" "Banned")
("style" "color: var(--color-primary);") ("style" "color: var(--color-primary);")
@ -101,6 +107,7 @@
(p (p
(text "{{ profile.settings.status }}")) (text "{{ profile.settings.status }}"))
(text "{%- endif %}") (text "{%- endif %}")
(text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}")
(div (div
("class" "w-full flex") ("class" "w-full flex")
(a (a
@ -117,6 +124,7 @@
(text "{{ profile.following_count }}")) (text "{{ profile.following_count }}"))
(span (span
(text "{{ text \"auth:label.following\" }}")))) (text "{{ text \"auth:label.following\" }}"))))
(text "{%- endif %}")
(text "{% if is_following_you -%}") (text "{% if is_following_you -%}")
(b (b
("class" "notification chip w-content flex items-center gap-2") ("class" "notification chip w-content flex items-center gap-2")
@ -219,12 +227,24 @@
(text "{{ icon \"user-minus\" }}") (text "{{ icon \"user-minus\" }}")
(span (span
(text "{{ text \"auth:action.unfollow\" }}"))) (text "{{ text \"auth:action.unfollow\" }}")))
(button (div
("onclick" "toggle_block_user()") ("class" "dropdown")
("class" "lowered red") (button
(text "{{ icon \"shield\" }}") ("onclick" "trigger('atto::hooks::dropdown', [event])")
(span ("exclude" "dropdown")
(text "{{ text \"auth:action.block\" }}"))) ("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 %}") (text "{% else %}")
(button (button
("onclick" "toggle_block_user()") ("onclick" "toggle_block_user()")
@ -278,7 +298,7 @@
]); ]);
fetch( fetch(
\"/api/v1/auth/user/{{ profile.id }}/follow\", \"/api/v1/auth/user/{{ profile.id }}/follow/toggle\",
{ {
method: \"POST\", method: \"POST\",
}, },
@ -342,6 +362,30 @@
res.message, 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 %}") (text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}")
(div (div

View file

@ -24,12 +24,24 @@
(div (div
("class" "card w-full secondary flex gap-2") ("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_blocking -%}") (text "{% if user -%} {% if not is_blocking -%}")
(button (div
("onclick" "toggle_block_user()") ("class" "dropdown")
("class" "lowered red") (button
(text "{{ icon \"shield\" }}") ("onclick" "trigger('atto::hooks::dropdown', [event])")
(span ("exclude" "dropdown")
(text "{{ text \"auth:action.block\" }}"))) ("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 %}") (text "{% else %}")
(button (button
("onclick" "toggle_block_user()") ("onclick" "toggle_block_user()")
@ -58,6 +70,30 @@
res.message, 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 %}") (text "{%- endif %}")
(a (a

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("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\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
(div (div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("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\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
(div (div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("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 -%}") (text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div (div

View file

@ -20,7 +20,11 @@
(div (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(span (span
("class" "fade")
(text "{{ text \"auth:label.private_profile_message\" }}")) (text "{{ text \"auth:label.private_profile_message\" }}"))
(span
("class" "no_p_margin")
(text "{{ profile.settings.private_biography|markdown|safe }}"))
(div (div
("class" "card w-full secondary flex gap-2") ("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_following -%}") (text "{% if user -%} {% if not is_following -%}")
@ -31,6 +35,7 @@
(text "{{ icon \"user-plus\" }}") (text "{{ icon \"user-plus\" }}")
(span (span
(text "{{ text \"auth:action.request_to_follow\" }}"))) (text "{{ text \"auth:action.request_to_follow\" }}")))
(text "{% if follow_requested -%}")
(button (button
("onclick" "cancel_follow_user(event)") ("onclick" "cancel_follow_user(event)")
("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}") ("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}")
@ -38,7 +43,7 @@
(text "{{ icon \"user-minus\" }}") (text "{{ icon \"user-minus\" }}")
(span (span
(text "{{ text \"auth:action.cancel_follow_request\" }}"))) (text "{{ text \"auth:action.cancel_follow_request\" }}")))
(text "{% else %}") (text "{%- endif %} {% else %}")
(button (button
("onclick" "toggle_follow_user(event)") ("onclick" "toggle_follow_user(event)")
("class" "lowered red") ("class" "lowered red")
@ -47,12 +52,24 @@
(span (span
(text "{{ text \"auth:action.unfollow\" }}"))) (text "{{ text \"auth:action.unfollow\" }}")))
(text "{%- endif %} {% if not is_blocking -%}") (text "{%- endif %} {% if not is_blocking -%}")
(button (div
("onclick" "toggle_block_user()") ("class" "dropdown")
("class" "lowered red") (button
(text "{{ icon \"shield\" }}") ("onclick" "trigger('atto::hooks::dropdown', [event])")
(span ("exclude" "dropdown")
(text "{{ text \"auth:action.block\" }}"))) ("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 %}") (text "{% else %}")
(button (button
("onclick" "toggle_block_user()") ("onclick" "toggle_block_user()")
@ -64,7 +81,7 @@
(script (script
(text "globalThis.toggle_follow_user = async (e) => { (text "globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [\"users::follow\"]); 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\", method: \"POST\",
}) })
.then((res) => res.json()) .then((res) => res.json())
@ -151,6 +168,30 @@
res.message, 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 %}") (text "{%- endif %}")
(a (a

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("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\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div (div

View 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 %}")

View file

@ -35,6 +35,87 @@
(text "{{ macros::profile_settings_nav_options() }}")) (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 (div
("class" "w-full flex flex-col gap-2") ("class" "w-full flex flex-col gap-2")
("data-tab" "account") ("data-tab" "account")
@ -56,6 +137,12 @@
(text "{{ icon \"rss\" }}") (text "{{ icon \"rss\" }}")
(span (span
(text "{{ text \"auth:label.following\" }}"))) (text "{{ text \"auth:label.following\" }}")))
(a
("data-tab-button" "account/followers")
("href" "#/account/followers")
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"auth:label.followers\" }}")))
(a (a
("data-tab-button" "account/blocks") ("data-tab-button" "account/blocks")
("href" "#/account/blocks") ("href" "#/account/blocks")
@ -134,15 +221,16 @@
("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}")
(text "All (questions)")) (text "All (questions)"))
(text "{% for stack in stacks %}") (text "{% for stack in stacks %}")
(option (text "<option
("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}") value='{\"Stack\":\"{{ stack.id }}\"}'
("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}") selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
(text "{{ stack.name }} (stack)")) >
{{ stack.name }} (stack)
</option>")
(text "{% endfor %}")) (text "{% endfor %}"))
(span (span
("class" "fade") ("class" "fade")
(text "This represents the timeline the home button takes you (text "This represents the timeline the home button takes you to."))))
to."))))
(div (div
("class" "card-nest desktop") ("class" "card-nest desktop")
("ui_ident" "notifications") ("ui_ident" "notifications")
@ -188,7 +276,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))
@ -197,30 +284,50 @@
("ui_ident" "delete_account") ("ui_ident" "delete_account")
(div (div
("class" "card small flex items-center gap-2 red") ("class" "card small flex items-center gap-2 red")
(text "{{ icon \"skull\" }}") (icon (text "skull"))
(b (b (str (text "communities:label.danger_zone"))))
(text "{{ text \"settings:label.delete_account\" }}"))) (div
(form ("class" "card lowered flex flex-col gap-2")
("class" "card flex flex-col gap-2") (details
("onsubmit" "delete_account(event)") ("class" "accordion")
(div (summary
("class" "flex flex-col gap-1") ("class" "flex items-center gap-2")
(label (icon_class (text "chevron-down") (text "dropdown_arrow"))
("for" "current_password") (str (text "settings:label.deactivate_account")))
(text "{{ text \"settings:label.current_password\" }}")) (div
(input ("class" "inner flex flex-col gap-2")
("type" "password") (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."))
("name" "current_password") (button
("id" "current_password") ("onclick" "deactivate_account()")
("placeholder" "current_password") (icon (text "lock"))
("required" "") (span
("minlength" "6") (str (text "settings:label.deactivate"))))))
("autocomplete" "off"))) (details
(button ("class" "accordion")
("class" "primary") (summary
(text "{{ icon \"trash\" }}") ("class" "flex items-center gap-2")
(span (icon_class (text "chevron-down") (text "dropdown_arrow"))
(text "{{ text \"general:action.delete\" }}"))))) (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 (button
("onclick" "save_settings()") ("onclick" "save_settings()")
("id" "save_button") ("id" "save_button")
@ -331,7 +438,6 @@
("minlength" "6") ("minlength" "6")
("autocomplete" "off"))) ("autocomplete" "off")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))))))) (text "{{ text \"general:action.save\" }}")))))))))
@ -375,7 +481,7 @@
(text "{{ icon \"external-link\" }}") (text "{{ icon \"external-link\" }}")
(span (span
(text "{{ text \"requests:action.view_profile\" }}"))))) (text "{{ text \"requests:action.view_profile\" }}")))))
(text "{% endfor %}")))) (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}"))))
(script (script
(text "globalThis.toggle_follow_user = async (uid) => { (text "globalThis.toggle_follow_user = async (uid) => {
await trigger(\"atto::debounce\", [\"users::follow\"]); 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 (div
("class" "w-full flex flex-col gap-2 hidden") ("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/blocks") ("data-tab" "account/blocks")
@ -446,6 +608,30 @@
("class" "button lowered small") ("class" "button lowered small")
(icon (text "external-link")) (icon (text "external-link"))
(span (str (text "requests:action.view_profile")))))) (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 %}"))))) (text "{% endfor %}")))))
(div (div
("class" "w-full flex flex-col gap-2 hidden") ("class" "w-full flex flex-col gap-2 hidden")
@ -468,32 +654,51 @@
(div (div
("class" "card flex flex-col gap-2 secondary") ("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 %}") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}")
(div (details
("class" "card flex flex-wrap gap-2 items-center justify-between") ("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 (div
("class" "flex gap-2 items-center") ("class" "inner flex flex-col gap-2")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") (form
("style" "cursor: pointer") ("class" "card lowered flex flex-col gap-2")
(text "{{ icon \"file-image\" }}") ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')")
(b (div
(span ("class" "flex flex-col gap-1")
("class" "date") (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text"))))
(text "{{ upload.created }}")) (textarea
(text "({{ upload.what }})"))) ("id" "alt_{{ upload.id }}")
(div ("name" "alt")
("class" "flex gap-2") ("class" "w-full")
(button ("placeholder" "Alternative text")
("class" "lowered small") (text "{{ upload.alt|safe }}")))
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
(text "{{ icon \"view\" }}") (button
(span (icon (text "check"))
(text "{{ text \"general:action.view\" }}"))) (str (text "general:action.save"))))))
(button
("class" "lowered small red")
("onclick" "remove_upload('{{ upload.id }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}")))))
(text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}")
(script (script
(text "globalThis.remove_upload = async (id) => { (text "globalThis.remove_upload = async (id) => {
@ -515,6 +720,26 @@
res.message, 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 -%}") (text "{% if config.security.enable_invite_codes -%}")
@ -619,6 +844,29 @@
(div (div
("class" "card flex flex-col gap-2 secondary") ("class" "card flex flex-col gap-2 secondary")
(text "{% if config.stripe -%}") (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 (div
("class" "card-nest") ("class" "card-nest")
("ui_ident" "supporter_card") ("ui_ident" "supporter_card")
@ -628,92 +876,55 @@
(b (b
(text "Supporter status"))) (text "Supporter status")))
(div (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2 no_p_margin")
(text "{% if is_supporter -%}") (text "{% if is_supporter -%}")
(p (p
(text "You ") (text "You ")
(b (b (text "are "))
(text "are ")) (text "a supporter! Thank you for all that you do."))
(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"))
(text "{% else %}") (text "{% else %}")
(p (text "{{ components::become_supporter_button() }}")
(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 "{%- endif %}"))) (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 %}"))))) (text "{%- endif %}")))))
(div (div
("class" "w-full hidden flex flex-col gap-2") ("class" "w-full hidden flex flex-col gap-2")
@ -743,7 +954,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("class" "fade")
@ -771,11 +981,48 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("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 (button
("onclick" "save_settings()") ("onclick" "save_settings()")
("id" "save_button") ("id" "save_button")
@ -851,7 +1098,6 @@
("class" "card w-full flex flex-wrap gap-2") ("class" "card w-full flex flex-wrap gap-2")
("ui_ident" "import_export") ("ui_ident" "import_export")
(button (button
("class" "primary")
("onclick" "import_theme_settings()") ("onclick" "import_theme_settings()")
(text "{{ icon \"upload\" }}") (text "{{ icon \"upload\" }}")
(span (span
@ -1174,6 +1420,11 @@
globalThis.delete_account = async (e) => { globalThis.delete_account = async (e) => {
e.preventDefault(); 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 ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\", \"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 = const account_settings =
document.getElementById(\"account_settings\"); document.getElementById(\"account_settings\");
const profile_settings = const profile_settings =
@ -1375,6 +1732,8 @@
\"supporter_ad\", \"supporter_ad\",
\"change_avatar\", \"change_avatar\",
\"change_banner\", \"change_banner\",
\"default_profile_page\",
\"show_presets\",
]); ]);
ui.refresh_container(theme_settings, [ ui.refresh_container(theme_settings, [
\"supporter_ad\", \"supporter_ad\",
@ -1397,6 +1756,15 @@
settings.biography, settings.biography,
\"textarea\", \"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\"], [[\"status\", \"Status\"], settings.status, \"textarea\"],
[ [
[\"warning\", \"Profile warning\"], [\"warning\", \"Profile warning\"],
@ -1490,11 +1858,45 @@
\"{{ profile.settings.auto_unlist }}\", \"{{ profile.settings.auto_unlist }}\",
\"checkbox\", \"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'], [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
\"{{ profile.settings.all_timeline_hide_answers }}\", \"{{ profile.settings.all_timeline_hide_answers }}\",
\"checkbox\", \"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\"], [[], \"Questions\", \"title\"],
[ [
[ [
@ -1553,6 +1955,11 @@
\"{{ profile.settings.disable_gpa_fun }}\", \"{{ profile.settings.disable_gpa_fun }}\",
\"checkbox\", \"checkbox\",
], ],
[
[\"disable_achievements\", \"Disable achievements\"],
\"{{ profile.settings.disable_achievements }}\",
\"checkbox\",
],
], ],
settings, settings,
); );
@ -1729,6 +2136,35 @@
description: \"Hover state for secondary buttons.\", 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) { if (can_use_custom_css) {

View file

@ -35,10 +35,12 @@
globalThis.no_policy = false; globalThis.no_policy = false;
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\";
</script>") </script>")
(script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
(script ("src" "/js/atto.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" "theme-color") ("content" "{{ config.color }}"))
(meta ("name" "description") ("content" "{{ config.description }}")) (meta ("name" "description") ("content" "{{ config.description }}"))
@ -68,11 +70,130 @@
(str (text "general:label.account_banned"))) (str (text "general:label.account_banned")))
(div (div
("class" "card") ("class" "card flex flex-col gap-2 no_p_margin")
(str (text "general:label.account_banned_body")))))) (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 ; 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 "<!-- html_footer_goes_here -->"))
(text "{% include \"body.html\" %}"))) (text "{% include \"body.html\" %}")))

View file

@ -29,7 +29,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div

View file

@ -114,7 +114,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))

View file

@ -36,7 +36,7 @@
(text "{% set paged = user and user.settings.paged_timelines %}") (text "{% set paged = user and user.settings.paged_timelines %}")
(script (script
(text "setTimeout(() => { (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 %}") (text "{% endblock %}")

View file

@ -24,6 +24,18 @@
(a (a
("href" "/communities/search") ("href" "/communities/search")
(text "searching for a community to join!"))))) (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 %}") (text "{% else %}")
(div (div
("class" "card w-full flex flex-col gap-2") ("class" "card w-full flex flex-col gap-2")

View file

@ -1,17 +1,15 @@
(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") (text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}")
(text "{% for post in list %} (text "{% for post in list %}
{% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%}
{% 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) }}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %}
{% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }}
{%- endif %}
{%- endif %} {%- endif %}
{% endfor %}") {% endfor %}")
(datalist (datalist
("ui_ident" "list_posts_{{ page }}") ("ui_ident" "list_posts_{{ page }}")
(text "{% for post in list -%}") (text "{% for post in list -%}")
(option ("value" "{{ post[0].id }}")) (option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}"))
(text "{%- endfor %}")) (text "{%- endfor %}"))
(text "{% if list|length == 0 -%}") (text "{% if list|length == 0 -%}")
(div (div

View file

@ -6,4 +6,11 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<rect width="460" height="460" fill="#E793B9" /> <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> </svg>

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View 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

View 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,
});
}

View file

@ -156,14 +156,12 @@ media_theme_pref();
.replaceAll(" year ago", "y"); .replaceAll(" year ago", "y");
} }
element.innerText = element.innerText = !pretty ? then.toLocaleDateString() : pretty;
pretty === undefined ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block"; element.style.display = "inline-block";
} }
}); });
self.define("clean_poll_date_codes", ({ $ }) => { self.define("clean_poll_date_codes", async ({ $ }) => {
for (const element of Array.from( for (const element of Array.from(
document.querySelectorAll(".poll_date"), document.querySelectorAll(".poll_date"),
)) { )) {
@ -183,7 +181,7 @@ media_theme_pref();
element.setAttribute("title", then.toLocaleString()); element.setAttribute("title", then.toLocaleString());
const pretty = const pretty =
$.rel_date(then) (await $.rel_date(then))
.replaceAll(" minutes ago", "m") .replaceAll(" minutes ago", "m")
.replaceAll(" minute ago", "m") .replaceAll(" minute ago", "m")
.replaceAll(" hours ago", "h") .replaceAll(" hours ago", "h")
@ -198,9 +196,7 @@ media_theme_pref();
.replaceAll(" year ago", "y") .replaceAll(" year ago", "y")
.replaceAll("Yesterday", "1d") || ""; .replaceAll("Yesterday", "1d") || "";
element.innerText = element.innerText = !pretty ? then.toLocaleDateString() : pretty;
pretty === undefined ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block"; 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.classList.remove("hook:long.hidden_text");
element.innerHTML = full_text; element.innerHTML = full_text;
$.clean_date_codes();
$.clean_poll_date_codes();
$.link_filter();
}); });
self.define("hooks::long_text.init", (_) => { self.define("hooks::long_text.init", (_) => {
for (const element of Array.from( setTimeout(() => {
document.querySelectorAll("[hook=long]") || [], for (const element of Array.from(
)) { document.querySelectorAll("[hook=long]") || [],
const is_long = element.innerText.length >= 64 * 8; )) {
const is_long = element.innerText.length >= 64 * 8;
if (!is_long) { if (!is_long) {
continue; 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);
} }
}, 150);
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);
}
}); });
self.define("hooks::alt", (_) => { self.define("hooks::alt", (_) => {
@ -505,7 +507,7 @@ media_theme_pref();
return now - last_seen <= maximum_time_to_be_considered_idle; 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( for (const element of Array.from(
document.querySelectorAll("[hook=online_indicator]") || [], document.querySelectorAll("[hook=online_indicator]") || [],
)) { )) {
@ -513,8 +515,8 @@ media_theme_pref();
element.getAttribute("hook-arg:last_seen"), element.getAttribute("hook-arg:last_seen"),
); );
const is_online = $.last_seen_just_now(last_seen); const is_online = await $.last_seen_just_now(last_seen);
const is_idle = $.last_seen_recently(last_seen); const is_idle = await $.last_seen_recently(last_seen);
const offline = element.querySelector("[hook_ui_ident=offline]"); const offline = element.querySelector("[hook_ui_ident=offline]");
const online = element.querySelector("[hook_ui_ident=online]"); const online = element.querySelector("[hook_ui_ident=online]");
@ -687,7 +689,7 @@ media_theme_pref();
}); });
self.define("hooks::check_message_reactions", async ({ $ }) => { 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) => { async (element) => {
const reactions = await ( const reactions = await (
await fetch( await fetch(
@ -851,7 +853,8 @@ media_theme_pref();
anchor.href.startsWith("https://tetratto.com") || anchor.href.startsWith("https://tetratto.com") ||
anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://buy.stripe.com") ||
anchor.href.startsWith("https://billing.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; continue;
} }
@ -917,18 +920,18 @@ media_theme_pref();
if (option.input_element_type === "checkbox") { if (option.input_element_type === "checkbox") {
into_element.innerHTML += `<div class="card flex items-center gap-2"> into_element.innerHTML += `<div class="card flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)" onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
placeholder="${option.key}" placeholder="${option.key}"
name="${option.key}" name="${option.key}"
id="${option.key}" id="${option.key}"
${option.value === "true" ? "checked" : ""} ${option.value === "true" ? "checked" : ""}
class="w-content" class="w-content"
/> />
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label> <label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
</div>`; </div>`;
return; return;
} }
@ -1064,7 +1067,13 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
// permissions ui // permissions ui
self.define( self.define(
"generate_permissions_ui", "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) { function all_matching_permissions(role) {
const matching = []; const matching = [];
const not_matching = []; const not_matching = [];
@ -1094,7 +1103,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
function get_permissions_html(role, id) { function get_permissions_html(role, id) {
const [matching, not_matching] = all_matching_permissions(role); const [matching, not_matching] = all_matching_permissions(role);
globalThis.remove_permission_from_role = (permission) => { globalThis[remove_name] = (permission) => {
matching.splice(matching.indexOf(permission), 1); matching.splice(matching.indexOf(permission), 1);
not_matching.push(permission); not_matching.push(permission);
@ -1102,7 +1111,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
get_permissions_html(rebuild_role(matching), id); 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); not_matching.splice(not_matching.indexOf(permission), 1);
matching.push(permission); matching.push(permission);
@ -1115,14 +1124,14 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
for (const match of matching) { for (const match of matching) {
permissions_html += `<div class="card w-full secondary flex justify-between gap-2"> permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
<span>${match} <code>${permissions[match]}</code></span> <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>`; </div>`;
} }
for (const match of not_matching) { for (const match of not_matching) {
permissions_html += `<div class="card w-full secondary flex justify-between gap-2"> permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
<span>${match} <code>${permissions[match]}</code></span> <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>`; </div>`;
} }
@ -1134,8 +1143,15 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
); );
// lightbox // lightbox
self.define("lightbox_open", (_, src) => { self.define("lightbox_open", async (_, src) => {
document.getElementById("lightbox_img").src = 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_img_a").href = src;
document.getElementById("lightbox").classList.remove("hidden"); 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_HAS_LOADED_AT_LEAST_ONCE = false;
self.IO_DATA_DISCONNECTED = false; self.IO_DATA_DISCONNECTED = false;
self.IO_DATA_DISABLE_RELOAD = false; self.IO_DATA_DISABLE_RELOAD = false;
self.IO_DATA_LOAD_BEFORE = 0;
if (!paginated_mode) { if (!paginated_mode) {
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
@ -1252,7 +1269,9 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
// ... // ...
const text = await ( 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(); ).text();
self.IO_DATA_WAITING = false; self.IO_DATA_WAITING = false;
@ -1270,11 +1289,22 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
} }
if ( 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"); console.log("io_data_end; disconnect");
self.IO_DATA_OBSERVER.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; self.IO_DATA_DISCONNECTED = true;
return; return;
} }
@ -1287,30 +1317,6 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
self.IO_DATA_ELEMENT.children.length - 1 self.IO_DATA_ELEMENT.children.length - 1
].after(self.IO_DATA_MARKER); ].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 // push ids
for (const opt of Array.from( for (const opt of Array.from(
document.querySelectorAll( document.querySelectorAll(
@ -1322,6 +1328,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
if (!self.IO_DATA_SEEN_IDS[v]) { if (!self.IO_DATA_SEEN_IDS[v]) {
self.IO_DATA_SEEN_IDS.push(v); self.IO_DATA_SEEN_IDS.push(v);
} }
self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created");
} }
}, 150); }, 150);
@ -1337,6 +1345,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
atto["hooks::online_indicator"](); atto["hooks::online_indicator"]();
atto["hooks::verify_emoji"](); atto["hooks::verify_emoji"]();
atto["hooks::check_reactions"](); atto["hooks::check_reactions"]();
fix_atto_links();
}); });
})(); })();
@ -1378,7 +1388,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
JSON.stringify(accepted_warnings), JSON.stringify(accepted_warnings),
); );
setTimeout(() => { setTimeout(async () => {
await trigger("me::achievement", ["AcceptProfileWarning"]);
window.history.back(); window.history.back();
}, 100); }, 100);
}); });

View file

@ -193,9 +193,13 @@
like.classList.add("green"); like.classList.add("green");
like.querySelector("svg").classList.add("filled"); like.querySelector("svg").classList.add("filled");
dislike.classList.remove("red"); if (dislike) {
dislike.classList.remove("red");
}
} else { } else {
dislike.classList.add("red"); if (dislike) {
dislike.classList.add("red");
}
like.classList.remove("green"); like.classList.remove("green");
like.querySelector("svg").classList.remove("filled"); 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) => { self.define("report", (_, asset, asset_type) => {
window.open( window.open(
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`, `/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 }) => { self.define("notifications_stream", ({ _, streams }) => {
const element = document.getElementById("notifications_span"); const element = document.getElementById("notifications_span");
let current = Number.parseInt(element.innerText || "0");
streams.subscribe("notifs"); streams.subscribe("notifs");
streams.event("notifs", "message", (data) => { streams.event("notifs", "message", (data) => {
@ -414,13 +470,12 @@
const inner_data = JSON.parse(data.data); const inner_data = JSON.parse(data.data);
if (data.method.Packet.Crud === "Create") { if (data.method.Packet.Crud === "Create") {
const current = Number.parseInt(element.innerText || "0");
if (current <= 0) { if (current <= 0) {
element.classList.remove("hidden"); element.classList.remove("hidden");
} }
element.innerText = current + 1; current += 1;
element.innerText = current;
// check if we're already connected // check if we're already connected
const connected = const connected =
@ -456,16 +511,19 @@
console.info("notification created"); console.info("notification created");
} }
} else if (data.method.Packet.Crud === "Delete") { } else if (data.method.Packet.Crud === "Delete") {
const current = Number.parseInt(element.innerText || "0");
if (current - 1 <= 0) { if (current - 1 <= 0) {
element.classList.add("hidden"); element.classList.add("hidden");
} }
element.innerText = current - 1; current -= 1;
element.innerText = current;
} else { } else {
console.warn("correct packet type but with wrong data"); console.warn("correct packet type but with wrong data");
} }
if (element.innerText !== current) {
element.innerText = current;
}
}); });
}); });
@ -979,7 +1037,13 @@
self.define( self.define(
"timestamp", "timestamp",
({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => { async (
{ $ },
updated_,
progress_ms_,
duration_ms_,
display = "full",
) => {
if (duration_ms_ === "0") { if (duration_ms_ === "0") {
return; return;
} }
@ -1003,7 +1067,7 @@
} }
if (display === "full") { 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") { 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;
}
});
})();

View 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();

View file

@ -43,6 +43,12 @@
}; };
socket.addEventListener("message", async (event) => { socket.addEventListener("message", async (event) => {
const sock = await $.sock(stream);
if (!sock) {
return;
}
if (event.data === "Ping") { if (event.data === "Ping") {
return socket.send("Pong"); return socket.send("Pong");
} }
@ -54,7 +60,7 @@
return console.info(`${stream} ${data.data}`); return console.info(`${stream} ${data.data}`);
} }
return (await $.sock(stream)).events.message(data); return sock.events.message(data);
}); });
return $.STREAMS[stream]; return $.STREAMS[stream];

View 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()),
}
}

View file

@ -7,7 +7,7 @@ use crate::{
State, State,
}; };
use axum::{Extension, Json, extract::Path, response::IntoResponse}; use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
apps::{AppQuota, ThirdPartyApp}, apps::{AppQuota, ThirdPartyApp},
oauth::{AuthGrant, PkceChallengeMethod}, oauth::{AuthGrant, PkceChallengeMethod},
@ -15,7 +15,7 @@ use tetratto_core::model::{
ApiReturn, Error, ApiReturn, Error,
}; };
use tetratto_shared::{hash::random_id, unix_epoch_timestamp}; use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
use super::CreateApp; use super::{CreateApp, UpdateAppStorageCapacity};
pub async fn create_request( pub async fn create_request(
jar: CookieJar, 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( pub async fn update_scopes_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -239,3 +268,34 @@ pub async fn grant_request(
Err(e) => Json(e.into()), 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()),
}
}

View file

@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::{ use tetratto_core::{
database::connections::last_fm::LastFmConnection, database::connections::last_fm::LastFmConnection,
model::{ model::{

View file

@ -5,7 +5,7 @@ pub mod stripe;
use std::collections::HashMap; use std::collections::HashMap;
use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::{ use tetratto_core::model::{
auth::{ConnectionService, ExternalConnectionData}, auth::{ConnectionService, ExternalConnectionData},

View file

@ -1,5 +1,5 @@
use axum::{response::IntoResponse, Extension, Json}; use axum::{response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::{ use tetratto_core::{
database::connections::spotify::SpotifyConnection, database::connections::spotify::SpotifyConnection,
model::{ model::{

View file

@ -1,14 +1,15 @@
use std::time::Duration; use std::{str::FromStr, time::Duration};
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
auth::{User, Notification}, auth::{Notification, User},
moderation::AuditLogEntry, moderation::AuditLogEntry,
permissions::FinePermission, permissions::{FinePermission, SecondaryPermission},
ApiReturn, Error, ApiReturn, Error,
}; };
use stripe::{EventObject, EventType}; use stripe::{EventObject, EventType};
use crate::State; use crate::{get_user_from_token, State};
pub async fn stripe_webhook( pub async fn stripe_webhook(
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -17,9 +18,10 @@ pub async fn stripe_webhook(
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
if data.0.0.stripe.is_none() { let stripe_cnf = match data.0.0.stripe {
return Json(Error::MiscError("Disabled".to_string()).into()); Some(ref c) => c,
} None => return Json(Error::MiscError("Disabled".to_string()).into()),
};
let sig = match headers.get("Stripe-Signature") { let sig = match headers.get("Stripe-Signature") {
Some(s) => s, Some(s) => s,
@ -56,7 +58,7 @@ pub async fn stripe_webhook(
Err(e) => return Json(e.into()), 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 if let Err(e) = data
.update_user_stripe_id(user.id, customer_id.as_str()) .update_user_stripe_id(user.id, customer_id.as_str())
.await .await
@ -74,6 +76,48 @@ pub async fn stripe_webhook(
}; };
let customer_id = invoice.customer.unwrap().id(); 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 // pull user and update role
let mut retries: usize = 0; let mut retries: usize = 0;
@ -118,36 +162,91 @@ pub async fn stripe_webhook(
} }
let user = user.unwrap(); let user = user.unwrap();
tracing::info!("found subscription user in {retries} tries");
if user.permissions.check(FinePermission::SUPPORTER) { if product_id == stripe_cnf.product_ids.supporter {
return Json(ApiReturn { // supporter
ok: true, tracing::info!("found subscription user in {retries} tries");
message: "Already applied".to_string(),
payload: (),
});
}
tracing::info!("invoice {} (stripe: {})", user.id, customer_id); if user.permissions.check(FinePermission::SUPPORTER) {
let new_user_permissions = user.permissions | FinePermission::SUPPORTER; return Json(ApiReturn {
ok: true,
message: "Already applied".to_string(),
payload: (),
});
}
if let Err(e) = data tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
.update_user_role(user.id, new_user_permissions, user.clone(), true) let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
.await
{
return Json(e.into());
}
if let Err(e) = data if let Err(e) = data
.create_notification(Notification::new( .update_user_role(user.id, new_user_permissions, user.clone(), true)
"Welcome new supporter!".to_string(), .await
"Thank you for your support! Your account has been updated with your new role." {
.to_string(), return Json(e.into());
user.id, }
))
.await if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
{ if let Err(e) = data
return Json(e.into()); .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 => { EventType::CustomerSubscriptionDeleted => {
@ -158,22 +257,72 @@ pub async fn stripe_webhook(
}; };
let customer_id = subscription.customer.id(); 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 { let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua, Ok(ua) => ua,
Err(e) => return Json(e.into()), Err(e) => return Json(e.into()),
}; };
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); // handle each subscription item
let new_user_permissions = user.permissions - FinePermission::SUPPORTER; 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 if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true) .update_user_role(user.id, new_user_permissions, user.clone(), true)
.await .await
{ {
return Json(e.into()); 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 if let Err(e) = data
.create_notification(Notification::new( .create_notification(Notification::new(
"Sorry to see you go... :(".to_string(), "Sorry to see you go... :(".to_string(),
@ -186,6 +335,133 @@ pub async fn stripe_webhook(
return Json(e.into()); 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()), _ => return Json(Error::Unknown.into()),
} }
@ -195,3 +471,145 @@ pub async fn stripe_webhook(
payload: (), 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()),
}
}

View file

@ -4,7 +4,7 @@ use axum::{
extract::{Path, Query}, extract::{Path, Query},
response::IntoResponse, response::IntoResponse,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use pathbufd::{PathBufD, pathd}; use pathbufd::{PathBufD, pathd};
use serde::Deserialize; use serde::Deserialize;
use std::{ use std::{

View file

@ -1,12 +1,34 @@
use crate::{ use crate::{
State, get_user_from_token, get_app_from_key, get_user_from_token,
model::{ApiReturn, Error}, model::{ApiReturn, Error},
routes::api::v1::CreateIpBan, routes::api::v1::CreateIpBan,
State,
}; };
use axum::{Extension, Json, extract::Path, response::IntoResponse}; use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission}; 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. /// Create a new IP ban.
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,

View file

@ -16,7 +16,7 @@ use axum::{
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::addr::RemoteAddr; use tetratto_core::model::addr::RemoteAddr;
use tetratto_shared::hash::hash; use tetratto_shared::hash::hash;
@ -54,7 +54,7 @@ pub async fn register_request(
// check for ip ban // check for ip ban
if data if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await .await
.is_ok() .is_ok()
{ {
@ -88,41 +88,46 @@ pub async fn register_request(
// check invite code // check invite code
if data.0.0.security.enable_invite_codes { if data.0.0.security.enable_invite_codes {
if props.invite_code.is_empty() { if !props.purchase {
return ( if props.invite_code.is_empty() {
None, return (
Json(Error::MiscError("Missing invite code".to_string()).into()), 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 // push initial token
@ -133,7 +138,7 @@ pub async fn register_request(
match data.create_user(user).await { match data.create_user(user).await {
Ok(_) => { Ok(_) => {
// mark invite as used // 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 { let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
Ok(c) => c, Ok(c) => c,
Err(e) => return (None, Json(e.into())), Err(e) => return (None, Json(e.into())),
@ -189,7 +194,7 @@ pub async fn login_request(
// check for ip ban // check for ip ban
if data if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await .await
.is_ok() .is_ok()
{ {

View file

@ -1,10 +1,12 @@
use std::time::Duration; use std::{str::FromStr, time::Duration};
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
model::{ApiReturn, Error}, model::{ApiReturn, Error},
routes::api::v1::{ routes::api::v1::{
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason,
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword,
UpdateUserRole, UpdateUserUsername,
}, },
State, State,
}; };
@ -16,12 +18,12 @@ use axum::{
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use futures_util::{sink::SinkExt, stream::StreamExt}; use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{ use tetratto_core::{
cache::Cache, cache::Cache,
model::{ model::{
auth::{AchievementName, InviteCode, Token, UserSettings}, auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
moderation::AuditLogEntry, moderation::AuditLogEntry,
oauth, oauth,
permissions::FinePermission, permissions::FinePermission,
@ -153,7 +155,7 @@ pub async fn update_user_settings_request(
// award achievement // award achievement
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::EditSettings.into()) .add_achievement(&mut user, AchievementName::EditSettings.into(), true)
.await .await
{ {
return Json(e.into()); 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. /// Update the role of the given user.
/// ///
/// Does not support third-party grants. /// 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. /// Update the current user's last seen value.
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
@ -422,8 +509,8 @@ pub async fn delete_user_request(
Extension(data): Extension<State>, Extension(data): Extension<State>,
Json(req): Json<DeleteUser>, Json(req): Json<DeleteUser>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await);
let user = match get_user_from_token!(jar, data) { let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
@ -432,6 +519,7 @@ pub async fn delete_user_request(
return Json(Error::NotAllowed.into()); return Json(Error::NotAllowed.into());
} else if user.permissions.check(FinePermission::MANAGE_USERS) { } else if user.permissions.check(FinePermission::MANAGE_USERS) {
if let Err(e) = data if let Err(e) = data
.0
.create_audit_log_entry(AuditLogEntry::new( .create_audit_log_entry(AuditLogEntry::new(
user.id, user.id,
format!("invoked `delete_user` with x value `{id}`"), format!("invoked `delete_user` with x value `{id}`"),
@ -443,14 +531,32 @@ pub async fn delete_user_request(
} }
match data match data
.0
.delete_user(id, &req.password, user.permissions.check_manager()) .delete_user(id, &req.password, user.permissions.check_manager())
.await .await
{ {
Ok(_) => Json(ApiReturn { Ok(ua) => {
ok: true, // delete stripe user
message: "User deleted".to_string(), if let Some(stripe_id) = ua.seller_data.account_id
payload: (), && 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()), Err(e) => Json(e.into()),
} }
} }
@ -464,11 +570,20 @@ pub async fn enable_totp_request(
Extension(data): Extension<State>, Extension(data): Extension<State>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; 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, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), 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 { match data.enable_totp(id, user).await {
Ok(x) => Json(ApiReturn { Ok(x) => Json(ApiReturn {
ok: true, ok: true,
@ -911,3 +1026,83 @@ pub async fn generate_invite_codes_request(
payload: Some((out_string, errors_string)), 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()),
}
}

View file

@ -9,14 +9,15 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
addr::RemoteAddr,
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
oauth, oauth,
}; };
/// Toggle following on the given user. /// Toggle following on the given user.
pub async fn follow_request( pub async fn toggle_follow_request(
jar: CookieJar, jar: CookieJar,
Path(id): Path<usize>, Path(id): Path<usize>,
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -61,7 +62,7 @@ pub async fn follow_request(
// award achievement // award achievement
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::FollowUser.into()) .add_achievement(&mut user, AchievementName::FollowUser.into(), true)
.await .await
{ {
return Json(e.into()); 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. /// Toggle blocking on the given user.
pub async fn block_request( pub async fn block_request(
jar: CookieJar, jar: CookieJar,
@ -228,7 +319,10 @@ pub async fn ip_block_request(
None => return Json(Error::NotAllowed.into()), 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 // delete
match data.delete_ipblock(ipblock.id, user).await { match data.delete_ipblock(ipblock.id, user).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
@ -274,7 +368,10 @@ pub async fn followers_request(
Ok(f) => Json(ApiReturn { Ok(f) => Json(ApiReturn {
ok: true, ok: true,
message: "Success".to_string(), 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)), Ok(f) => Some(data.userfollows_user_filter(&f)),
Err(e) => return Json(e.into()), Err(e) => return Json(e.into()),
}, },
@ -306,7 +403,10 @@ pub async fn following_request(
Ok(f) => Json(ApiReturn { Ok(f) => Json(ApiReturn {
ok: true, ok: true,
message: "Success".to_string(), 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)), Ok(f) => Some(data.userfollows_user_filter(&f)),
Err(e) => return Json(e.into()), Err(e) => return Json(e.into()),
}, },
@ -314,3 +414,64 @@ pub async fn following_request(
Err(e) => Json(e.into()), 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()),
}
}

View file

@ -9,7 +9,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission}; use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission};
/// Create a new user warning. /// Create a new user warning.

View file

@ -1,5 +1,5 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse}; 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 tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error};
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
@ -293,3 +293,62 @@ pub async fn get_request(
Err(e) => Json(e.into()), 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()),
}
}

View file

@ -1,6 +1,6 @@
use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State}; use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
use axum::{Extension, Json, extract::Path, response::IntoResponse}; 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}; use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
pub async fn get_request( pub async fn get_request(

View file

@ -7,7 +7,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::{ use tetratto_core::{
cache::{Cache, redis::Commands}, cache::{Cache, redis::Commands},
model::{ model::{

View file

@ -3,7 +3,7 @@ use axum::{
extract::Path, extract::Path,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
auth::Notification, auth::Notification,
communities::{Community, CommunityMembership}, communities::{Community, CommunityMembership},
@ -292,11 +292,10 @@ pub async fn create_membership(
}; };
match data match data
.create_membership(CommunityMembership::new( .create_membership(
user.id, CommunityMembership::new(user.id, id, CommunityPermission::default()),
id, &user,
CommunityPermission::default(), )
))
.await .await
{ {
Ok(m) => Json(ApiReturn { Ok(m) => Json(ApiReturn {

View file

@ -3,8 +3,8 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error}; use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error};
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
routes::{ routes::{
@ -20,11 +20,20 @@ pub async fn create_request(
Json(req): Json<CreatePostDraft>, Json(req): Json<CreatePostDraft>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; 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, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), 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 match data
.create_draft(PostDraft::new(req.content, user.id)) .create_draft(PostDraft::new(req.content, user.id))
.await .await

View file

@ -7,7 +7,7 @@ use crate::{
State, State,
}; };
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
oauth, oauth,
uploads::{CustomEmoji, MediaType, MediaUpload}, uploads::{CustomEmoji, MediaType, MediaUpload},
@ -17,6 +17,8 @@ use tetratto_core::model::{
/// Expand a unicode emoji into its Gemoji shortcode. /// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
match emoji.as_str() { match emoji.as_str() {
// matches `CustomEmoji::replace`
"💯" => "100".to_string(),
"👍" => "thumbs_up".to_string(), "👍" => "thumbs_up".to_string(),
"👎" => "thumbs_down".to_string(), "👎" => "thumbs_down".to_string(),
_ => match emojis::get(&emoji) { _ => match emojis::get(&emoji) {

View file

@ -1,5 +1,5 @@
use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use pathbufd::{PathBufD, pathd}; use pathbufd::{PathBufD, pathd};
use std::fs::exists; use std::fs::exists;
use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth};

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
addr::RemoteAddr, addr::RemoteAddr,
auth::AchievementName, auth::AchievementName,
@ -67,7 +67,7 @@ pub async fn create_request(
// check for ip ban // check for ip ban
if data if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await .await
.is_ok() .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) => { Ok(id) => {
// write to uploads // 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) { let image = match images.get(i) {
Some(img) => img, Some(img) => img,
None => { None => {
@ -181,7 +182,7 @@ pub async fn create_request(
// achievements // achievements
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreatePost.into()) .add_achievement(&mut user, AchievementName::CreatePost.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -189,7 +190,7 @@ pub async fn create_request(
if user.post_count >= 49 { if user.post_count >= 49 {
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create50Posts.into()) .add_achievement(&mut user, AchievementName::Create50Posts.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -198,7 +199,7 @@ pub async fn create_request(
if user.post_count >= 99 { if user.post_count >= 99 {
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create100Posts.into()) .add_achievement(&mut user, AchievementName::Create100Posts.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -207,7 +208,7 @@ pub async fn create_request(
if user.post_count >= 999 { if user.post_count >= 999 {
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create1000Posts.into()) .add_achievement(&mut user, AchievementName::Create1000Posts.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -341,11 +342,20 @@ pub async fn update_content_request(
Json(req): Json<UpdatePostContent>, Json(req): Json<UpdatePostContent>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; 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, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), 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 { match data.update_post_content(id, user, req.content).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
ok: true, ok: true,
@ -714,7 +724,7 @@ pub async fn from_communities_request(
}; };
match data match data
.get_posts_from_user_communities(user.id, 12, props.page) .get_posts_from_user_communities(user.id, 12, props.page, &user)
.await .await
{ {
Ok(posts) => { Ok(posts) => {
@ -829,7 +839,7 @@ pub async fn all_request(
}; };
match data match data
.get_latest_posts(12, props.page, &Some(user.clone())) .get_latest_posts(12, props.page, &Some(user.clone()), props.before)
.await .await
{ {
Ok(posts) => { Ok(posts) => {

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
addr::RemoteAddr, addr::RemoteAddr,
auth::{AchievementName, IpBlock}, auth::{AchievementName, IpBlock},
@ -43,7 +43,7 @@ pub async fn create_request(
// check for ip ban // check for ip ban
if data if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await .await
.is_ok() .is_ok()
{ {
@ -55,7 +55,7 @@ pub async fn create_request(
let mut user = user.clone(); let mut user = user.clone();
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateQuestion.into()) .add_achievement(&mut user, AchievementName::CreateQuestion.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());
@ -63,7 +63,7 @@ pub async fn create_request(
if drawings.len() > 0 { if drawings.len() > 0 {
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateDrawing.into()) .add_achievement(&mut user, AchievementName::CreateDrawing.into(), true)
.await .await
{ {
return Json(e.into()); 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 match data
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) .create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
.await .await
@ -145,7 +156,7 @@ pub async fn ip_block_request(
// check for an existing ip block // check for an existing ip block
if data 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 .await
.is_ok() .is_ok()
{ {

View 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(),
));
}
}
}

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Json, Path}, extract::{Json, Path},
Extension, Extension,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_shared::snow::Snowflake; use tetratto_shared::snow::Snowflake;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
@ -110,7 +110,7 @@ pub async fn create_request(
Ok(x) => { Ok(x) => {
// award achievement // award achievement
if let Err(e) = data if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateJournal.into()) .add_achievement(&mut user, AchievementName::CreateJournal.into(), true)
.await .await
{ {
return Json(e.into()); return Json(e.into());

View file

@ -1,13 +1,17 @@
pub mod app_data;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod channels; pub mod channels;
pub mod communities; pub mod communities;
pub mod domains;
pub mod journals; pub mod journals;
pub mod notes; pub mod notes;
pub mod notifications; pub mod notifications;
pub mod products;
pub mod reactions; pub mod reactions;
pub mod reports; pub mod reports;
pub mod requests; pub mod requests;
pub mod services;
pub mod stacks; pub mod stacks;
pub mod uploads; pub mod uploads;
pub mod util; pub mod util;
@ -18,15 +22,18 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::{ use tetratto_core::model::{
apps::AppQuota, apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
communities::{ communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext, PollOption, PostContext,
}, },
communities_permissions::CommunityPermission, communities_permissions::CommunityPermission,
journals::JournalPrivacyPermission, journals::JournalPrivacyPermission,
littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope, oauth::AppScope,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
products::{ProductPrice, ProductType},
reactions::AssetType, reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort}, 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}/avatar", get(auth::images::avatar_request))
.route("/auth/user/{id}/banner", get(auth::images::banner_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", post(auth::social::follow_request))
.route(
"/auth/user/{id}/follow/toggle",
post(auth::social::toggle_follow_request),
)
.route( .route(
"/auth/user/{id}/follow/cancel", "/auth/user/{id}/follow/cancel",
post(auth::social::cancel_follow_request), post(auth::social::cancel_follow_request),
@ -287,7 +298,19 @@ pub fn routes() -> Router {
"/auth/user/{id}/follow/accept", "/auth/user/{id}/follow/accept",
post(auth::social::accept_follow_request), 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", 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( .route(
"/auth/user/{id}/settings", "/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request), post(auth::profile::update_user_settings_request),
@ -300,6 +323,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/role/2", "/auth/user/{id}/role/2",
post(auth::profile::update_user_secondary_role_request), post(auth::profile::update_user_secondary_role_request),
) )
.route(
"/auth/user/{id}/ban_reason",
post(auth::profile::update_user_ban_reason_request),
)
.route( .route(
"/auth/user/{id}", "/auth/user/{id}",
delete(auth::profile::delete_user_request), delete(auth::profile::delete_user_request),
@ -320,6 +347,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/verified", "/auth/user/{id}/verified",
post(auth::profile::update_user_is_verified_request), 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( .route(
"/auth/user/{id}/totp", "/auth/user/{id}/totp",
post(auth::profile::enable_totp_request), post(auth::profile::enable_totp_request),
@ -379,8 +414,17 @@ pub fn routes() -> Router {
"/auth/user/{id}/grants/{app}/refresh", "/auth/user/{id}/grants/{app}/refresh",
post(auth::profile::refresh_grant_request), 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 // apps
.route("/apps", post(apps::create_request)) .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}/title", post(apps::update_title_request))
.route("/apps/{id}/homepage", post(apps::update_homepage_request)) .route("/apps/{id}/homepage", post(apps::update_homepage_request))
.route("/apps/{id}/redirect", post(apps::update_redirect_request)) .route("/apps/{id}/redirect", post(apps::update_redirect_request))
@ -388,9 +432,21 @@ pub fn routes() -> Router {
"/apps/{id}/quota_status", "/apps/{id}/quota_status",
post(apps::update_quota_status_request), 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}/scopes", post(apps::update_scopes_request))
.route("/apps/{id}", delete(apps::delete_request))
.route("/apps/{id}/grant", post(apps::grant_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 // warnings
.route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", get(auth::user_warnings::get_request))
.route("/warnings/{id}", post(auth::user_warnings::create_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request))
@ -439,6 +495,7 @@ pub fn routes() -> Router {
post(communities::communities::update_membership_role), post(communities::communities::update_membership_role),
) )
// ipbans // ipbans
.route("/bans/{ip}", get(auth::ipbans::check_request))
.route("/bans/{ip}", post(auth::ipbans::create_request)) .route("/bans/{ip}", post(auth::ipbans::create_request))
.route("/bans/{ip}", delete(auth::ipbans::delete_request)) .route("/bans/{ip}", delete(auth::ipbans::delete_request))
// reports // reports
@ -488,6 +545,18 @@ pub fn routes() -> Router {
"/service_hooks/stripe", "/service_hooks/stripe",
post(auth::connections::stripe::stripe_webhook), 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 // channels
.route("/channels", post(channels::channels::create_request)) .route("/channels", post(channels::channels::create_request))
.route( .route(
@ -511,6 +580,14 @@ pub fn routes() -> Router {
"/channels/{id}/kick", "/channels/{id}/kick",
post(channels::channels::kick_member_request), 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/{id}", get(channels::channels::get_request))
.route( .route(
"/channels/community/{id}", "/channels/community/{id}",
@ -599,6 +676,40 @@ pub fn routes() -> Router {
// uploads // uploads
.route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_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)] #[derive(Deserialize)]
@ -617,6 +728,12 @@ pub struct RegisterProps {
pub captcha_response: String, pub captcha_response: String,
#[serde(default)] #[serde(default)]
pub invite_code: String, 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)] #[derive(Deserialize)]
@ -724,6 +841,16 @@ pub struct UpdateUserIsVerified {
pub is_verified: bool, pub is_verified: bool,
} }
#[derive(Deserialize)]
pub struct UpdateUserAwaitingPurchase {
pub awaiting_purchase: bool,
}
#[derive(Deserialize)]
pub struct UpdateUserIsDeactivated {
pub is_deactivated: bool,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateNotificationRead { pub struct UpdateNotificationRead {
pub read: bool, pub read: bool,
@ -749,6 +876,16 @@ pub struct UpdateSecondaryUserRole {
pub role: SecondaryPermission, pub role: SecondaryPermission,
} }
#[derive(Deserialize)]
pub struct UpdateUserBanReason {
pub reason: String,
}
#[derive(Deserialize)]
pub struct UpdateUserInviteCode {
pub invite_code: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeleteUser { pub struct DeleteUser {
pub password: String, pub password: String,
@ -777,6 +914,10 @@ pub struct CreateQuestion {
pub receiver: String, pub receiver: String,
#[serde(default)] #[serde(default)]
pub community: String, pub community: String,
#[serde(default)]
pub mask_owner: bool,
#[serde(default)]
pub asking_about: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -871,6 +1012,7 @@ pub struct UpdatePostIsOpen {
pub struct CreateApp { pub struct CreateApp {
pub title: String, pub title: String,
pub homepage: String, pub homepage: String,
#[serde(default)]
pub redirect: String, pub redirect: String,
} }
@ -894,6 +1036,11 @@ pub struct UpdateAppQuotaStatus {
pub quota_status: AppQuota, pub quota_status: AppQuota,
} }
#[derive(Deserialize)]
pub struct UpdateAppStorageCapacity {
pub storage_capacity: DeveloperPassStorageQuota,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAppScopes { pub struct UpdateAppScopes {
pub scopes: Vec<AppScope>, pub scopes: Vec<AppScope>,
@ -968,7 +1115,96 @@ pub struct AddJournalDir {
pub struct RemoveJournalDir { pub struct RemoveJournalDir {
pub dir: String, pub dir: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateNoteTags { pub struct UpdateNoteTags {
pub tags: Vec<String>, 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,
}

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Json, Path}, extract::{Json, Path},
Extension, Extension,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_shared::unix_epoch_timestamp; use tetratto_shared::unix_epoch_timestamp;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
@ -16,6 +16,7 @@ use crate::{
use tetratto_core::{ use tetratto_core::{
database::NAME_REGEX, database::NAME_REGEX,
model::{ model::{
auth::AchievementName,
journals::{JournalPrivacyPermission, Note}, journals::{JournalPrivacyPermission, Note},
oauth, oauth,
permissions::FinePermission, permissions::FinePermission,
@ -190,11 +191,20 @@ pub async fn update_content_request(
Json(props): Json<UpdateNoteContent>, Json(props): Json<UpdateNoteContent>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; 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, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), 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 { match data.update_note_content(id, &user, &props.content).await {
Ok(_) => { Ok(_) => {
if let Err(e) = data 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 { 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("\\@", "@")
.replace("%5C@", "@") .replace("%5C@", "@")
} }

View file

@ -5,7 +5,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{oauth, ApiReturn, Error}; use tetratto_core::model::{oauth, ApiReturn, Error};
pub async fn delete_request( pub async fn delete_request(

View 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()),
}
}

View file

@ -1,7 +1,12 @@
use crate::{State, get_user_from_token, routes::api::v1::CreateReaction}; use crate::{State, get_user_from_token, routes::api::v1::CreateReaction};
use axum::{Extension, Json, extract::Path, response::IntoResponse}; use axum::{
use axum_extra::extract::CookieJar; extract::Path,
use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction}; 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( pub async fn get_request(
jar: CookieJar, jar: CookieJar,
@ -26,6 +31,7 @@ pub async fn get_request(
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,
headers: HeaderMap,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Json(req): Json<CreateReaction>, Json(req): Json<CreateReaction>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -40,6 +46,20 @@ pub async fn create_request(
Err(e) => return Json(Error::MiscError(e.to_string()).into()), 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 // check for existing reaction
if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await { if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await {
match data.delete_reaction(r.id, &user).await { match data.delete_reaction(r.id, &user).await {
@ -63,6 +83,7 @@ pub async fn create_request(
.create_reaction( .create_reaction(
Reaction::new(user.id, asset_id, req.asset_type, req.is_like), Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
&user, &user,
&addr,
) )
.await .await
{ {

View file

@ -1,7 +1,7 @@
use super::CreateReport; use super::CreateReport;
use crate::{State, get_user_from_token}; use crate::{State, get_user_from_token};
use axum::{Extension, Json, extract::Path, response::IntoResponse}; 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}; use tetratto_core::model::{ApiReturn, Error, moderation::Report};
pub async fn create_request( pub async fn create_request(

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::model::{oauth, ApiReturn, Error}; use tetratto_core::model::{oauth, ApiReturn, Error};
pub async fn delete_request( pub async fn delete_request(

View 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()),
}
}

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use tetratto_core::{ use tetratto_core::{
model::{ model::{
oauth, oauth,

View file

@ -1,8 +1,8 @@
use std::fs::exists; use std::fs::exists;
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use pathbufd::PathBufD; 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 super::auth::images::read_image;
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; 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))) 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( pub async fn delete_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -72,3 +90,25 @@ pub async fn delete_request(
Err(e) => Json(e.into()), 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()),
}
}

View file

@ -7,7 +7,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Extension,
}; };
use axum_extra::extract::CookieJar; use crate::cookie::CookieJar;
use pathbufd::PathBufD; use pathbufd::PathBufD;
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::permissions::FinePermission; use tetratto_core::model::permissions::FinePermission;

View file

@ -19,3 +19,5 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript"));
serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_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"));

View file

@ -20,6 +20,8 @@ pub fn routes(config: &Config) -> Router {
.route("/js/me.js", get(assets::me_js_request)) .route("/js/me.js", get(assets::me_js_request))
.route("/js/streams.js", get(assets::streams_js_request)) .route("/js/streams.js", get(assets::streams_js_request))
.route("/js/carp.js", get(assets::carp_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( .nest_service(
"/public", "/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
@ -42,3 +44,14 @@ pub fn routes(config: &Config) -> Router {
// pages // pages
.merge(pages::routes()) .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