Compare commits

...

74 commits

Author SHA1 Message Date
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
5dd9fa01cb add: 8 achievements add: larger text setting fix: small infinite
timeline bugs
2025-06-27 13:10:04 -04:00
b860f74124 add: user achievements 2025-06-27 03:45:50 -04:00
e7c4cf14aa add: option to clear all notifications when you open the page 2025-06-27 01:38:35 -04:00
45ea91a768 fix: profile infinite reload 2025-06-26 14:30:15 -04:00
4b7808e70b fix: client timeline load disconnect issue 2025-06-26 13:58:10 -04:00
904944f5d3 fix: client share intents 2025-06-26 13:53:19 -04:00
5bfbd4e110 fix: timeline infinite reload 2025-06-26 13:49:21 -04:00
f622fb1125 fix: user connection song duration ui 2025-06-26 13:46:37 -04:00
87b61d7717 fix: various infinite timeline issues 2025-06-26 13:41:08 -04:00
aeaa230162 fix: make sure timeline loads data 2025-06-26 03:07:27 -04:00
2cd04b0db0 add: global notes 2025-06-26 02:56:22 -04:00
59581f69c9 remove: PacketType::Javascript 2025-06-25 23:40:12 -04:00
6e0f2985b9 fix: user avatar mime change from gif to avif 2025-06-25 23:15:24 -04:00
ffdb767518 add: full links api 2025-06-24 16:34:55 -04:00
c2dbe2f114 fix: gif image uploading 2025-06-24 14:18:19 -04:00
2676340fba add: more mod panel stats add: show user invite in mod panel add:
ability to share to twitter/bluesky
2025-06-24 13:18:52 -04:00
66beef6b1d fix: invite codes fix: missing icons 2025-06-23 22:31:14 -04:00
5fbf454b52 add: better checkboxes 2025-06-23 21:20:12 -04:00
0ae64de989 add: update user secondary role api 2025-06-23 19:49:52 -04:00
9528d71b2a add: user secondary permission 2025-06-23 19:42:02 -04:00
339aa59434 fix: invite code snowflake id collisions 2025-06-23 14:17:01 -04:00
253f11b00c add: send invite code generation errors to client 2025-06-23 14:07:15 -04:00
4843688fcf add: ability to generate invite codes in bulk add: better mark as nsfw
ui
2025-06-23 13:48:16 -04:00
2a77c61bf2 add: ability to add user to stack through block list ui 2025-06-22 21:07:35 -04:00
8c969cd56f fix: user delete audit log 2025-06-22 19:21:30 -04:00
aceb51c21c add: CACHE_BREAKER env var 2025-06-22 18:53:02 -04:00
69fc3ca490 fix: remove MANAGE_INVITES (overflow) 2025-06-22 15:15:39 -04:00
dc74c5d63c add: increase invite code limits 2025-06-22 15:06:21 -04:00
38ddf6cde1 fix: spotify state push 2025-06-22 14:21:38 -04:00
efd4ac8104 fix: spotify connection 2025-06-22 14:11:15 -04:00
2f83497f98 add: allow free users to create 2 invites 2025-06-22 13:50:12 -04:00
626c6711ef add: invite codes 2025-06-22 13:03:02 -04:00
d1a074eaeb add: increase IPV6_PREFIX_BYTES (8 -> 16) 2025-06-22 03:02:44 -04:00
958979cfa1 fix: check user show_nsfw in community timeline 2025-06-22 02:25:41 -04:00
612fbf5eb4 add: option to hide posts answering questions from "All" timeline 2025-06-22 00:45:05 -04:00
5961999ce4 add: PORT env var 2025-06-22 00:04:32 -04:00
52c8983634 add: utility classes for posts and questions 2025-06-21 22:22:20 -04:00
d67bf26955 fix: user notification count when clearing notifications 2025-06-21 21:40:41 -04:00
0c509b7001 add: open graph tags for posts and notes 2025-06-21 21:32:51 -04:00
af6fbdf04e add: journal note tags and directories 2025-06-21 19:44:28 -04:00
a37312fecf add: chat message reactions 2025-06-21 03:11:29 -04:00
a4298f95f6 fix: don't allow empty drawings to be uploaded 2025-06-20 19:27:12 -04:00
16843a6ab8 add: drawings in questions 2025-06-20 17:40:55 -04:00
6be729de50 fix: journals scrolling 2025-06-19 22:37:49 -04:00
ffdf320c14 add: ability to enable pages instead of infinite scrolling 2025-06-19 22:10:17 -04:00
fa72d6a59d fix: journals ui panic 2025-06-19 19:27:42 -04:00
dc50f3a8af add: journal.css special note 2025-06-19 19:13:07 -04:00
f0d1a1e8e4 add: show mobile help text on journals homepage 2025-06-19 16:37:11 -04:00
eb5a0d146f fix: make journal and note titles lowercase add: remove journal index
route
2025-06-19 16:34:08 -04:00
1b1c1c0bea fix: make forward slash escape mentions parser 2025-06-19 16:23:33 -04:00
97b7e873ed fix: journal privacy 2025-06-19 16:19:57 -04:00
151 changed files with 9590 additions and 974 deletions

54
Cargo.lock generated
View file

@ -488,7 +488,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
"phf 0.11.3",
]
[[package]]
@ -498,7 +498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf 0.11.3",
"phf_codegen",
]
@ -648,7 +648,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"phf 0.11.3",
"smallvec",
]
@ -728,11 +728,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "emojis"
version = "0.6.4"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4"
checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
dependencies = [
"phf",
"phf 0.12.1",
]
[[package]]
@ -2164,7 +2164,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
@ -2174,7 +2183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
@ -2183,7 +2192,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"phf_shared 0.11.3",
"rand 0.8.5",
]
@ -2194,7 +2203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.101",
@ -2209,6 +2218,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -3067,7 +3085,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
@ -3079,7 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
@ -3231,7 +3249,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "9.0.0"
version = "10.0.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3262,7 +3280,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "9.0.0"
version = "10.0.0"
dependencies = [
"async-recursion",
"base16ct",
@ -3284,7 +3302,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
version = "9.0.0"
version = "10.0.0"
dependencies = [
"pathbufd",
"serde",
@ -3293,7 +3311,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
version = "9.0.0"
version = "10.0.0"
dependencies = [
"ammonia",
"chrono",
@ -3476,7 +3494,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
"phf",
"phf 0.11.3",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
@ -4066,7 +4084,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b"
dependencies = [
"phf",
"phf 0.11.3",
"phf_codegen",
"string_cache",
"string_cache_codegen",

View file

@ -33,6 +33,10 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l
A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`.
You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries.
You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects.
## Usage (as a user)
Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "9.0.0"
version = "10.0.0"
edition = "2024"
[dependencies]
@ -33,6 +33,6 @@ async-stripe = { version = "0.41.0", features = [
"billing",
"runtime-tokio-hyper",
] }
emojis = "0.6.4"
emojis = "0.7.0"
webp = "0.3.0"
bberry = "0.2.0"

View file

@ -39,6 +39,8 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const ME_JS: &str = include_str!("./public/js/me.js");
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js");
// html
pub const BODY: &str = include_str!("./public/html/body.lisp");
@ -50,6 +52,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp");
pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp");
pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp");
pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp");
pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp");
pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
@ -68,6 +71,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp
pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp");
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
@ -116,6 +120,7 @@ pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp
pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp");
pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp");
pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
pub const STACKS_ADD_USER: &str = include_str!("./public/html/stacks/add_user.lisp");
pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp");
pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp");
@ -153,7 +158,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
);
let file_path = PathBufD::current().extend(&[icons_dir, icon]);
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());
@ -178,7 +183,8 @@ macro_rules! vendor_icon {
let writer = &mut ICONS.write().await;
writer.insert($name.to_string(), $icon.to_string());
let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]);
let file_path =
PathBufD::current().extend(&[$icons_dir.clone(), format!("{}.svg", $name.to_string())]);
std::fs::write(file_path, $icon).unwrap();
}};
}
@ -346,6 +352,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins);
write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins);
write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins);
write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins);
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins);
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
@ -364,6 +371,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins);
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
@ -407,6 +415,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins);
write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins);
write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);
write_template!(html_path->"stacks/add_user.html"(crate::assets::STACKS_ADD_USER) --config=config --lisp plugins);
write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins);
write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins);
@ -494,6 +503,13 @@ pub(crate) async fn initial_context(
}
ctx.insert("lang", &lang.data);
ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone());
ctx.insert(
"random_cache_breaker",
&if let Ok(c) = std::env::var("CACHE_BREAKER") {
c
} else {
CACHE_BREAKER.clone()
},
);
ctx
}

View file

@ -17,6 +17,7 @@ version = "1.0.0"
"general:link.stats" = "Stats"
"general:link.search" = "Search"
"general:link.journals" = "Journals"
"general:link.achievements" = "Achievements"
"general:action.save" = "Save"
"general:action.delete" = "Delete"
"general:action.purge" = "Purge"
@ -42,6 +43,8 @@ version = "1.0.0"
"general:label.could_not_find_post" = "Could not find original post..."
"general:label.timeline_end" = "That's a wrap!"
"general:label.loading" = "Working on it!"
"general:label.send_anonymously" = "Send anonymously"
"general:label.must_activate_account" = "You need to activate your account!"
"general:label.supporter_motivation" = "Become a supporter!"
"general:action.become_supporter" = "Become supporter"
@ -73,6 +76,7 @@ version = "1.0.0"
"auth:label.recent_replies" = "Recent replies"
"auth:label.recent_posts_with_media" = "Recent posts (with media)"
"auth:label.posts" = "Posts"
"auth:label.responses" = "Answers"
"auth:label.replies" = "Replies"
"auth:label.media" = "Media"
"auth:label.outbox" = "Outbox"
@ -86,6 +90,9 @@ version = "1.0.0"
"auth:action.message" = "Message"
"auth:label.banned" = "Banned"
"auth:label.banned_message" = "This user has been banned for breaking the site's rules."
"auth:action.create_account" = "Create account"
"auth:action.purchase_account" = "Purchase account"
"auth:action.continue" = "Continue"
"communities:action.create" = "Create"
"communities:action.select" = "Select"
@ -135,6 +142,8 @@ version = "1.0.0"
"communities:label.file" = "File"
"communities:label.drafts" = "Drafts"
"communities:label.load" = "Load"
"communities:action.draw" = "Draw"
"communities:action.remove_drawing" = "Remove drawing"
"notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread"
@ -166,10 +175,14 @@ version = "1.0.0"
"settings:label.export" = "Export"
"settings:label.manage_blocks" = "Manage blocks"
"settings:label.users" = "Users"
"settings:label.ips" = "IPs"
"settings:label.generate_invites" = "Generate invites"
"settings:label.add_to_stack" = "Add to stack"
"settings:tab.security" = "Security"
"settings:tab.blocks" = "Blocks"
"settings:tab.billing" = "Billing"
"settings:tab.uploads" = "Uploads"
"settings:tab.invites" = "Invites"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"
@ -177,6 +190,9 @@ version = "1.0.0"
"mod_panel:label.warnings" = "Warnings"
"mod_panel:label.create_warning" = "Create warning"
"mod_panel:label.associations" = "Associations"
"mod_panel:label.invited_by" = "Invited by"
"mod_panel:label.send_debug_payload" = "Send debug payload"
"mod_panel:action.send" = "Send"
"requests:label.requests" = "Requests"
"requests:label.community_join_request" = "Community join request"
@ -242,3 +258,15 @@ version = "1.0.0"
"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar."
"journals:label.editor" = "Editor"
"journals:label.preview_pane" = "Preview"
"journals:action.edit_tags" = "Edit tags"
"journals:action.tags" = "Tags"
"journals:label.directories" = "Directories"
"journals:action.create_subdir" = "Create subdirectory"
"journals:action.create_root_dir" = "Create root directory"
"journals:action.move" = "Move"
"journals:action.publish" = "Publish"
"journals:action.unpublish" = "Unpublish"
"journals:action.view" = "View"
"littleweb:label.create_new" = "Create new site"
"littleweb:label.my_services" = "My services"

View file

@ -166,7 +166,7 @@ macro_rules! user_banned {
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
context.insert("profile", &$other_user);
return Ok(Html(
return Err(Html(
$data.1.render("profile/banned.html", &context).unwrap(),
));
};
@ -233,7 +233,7 @@ macro_rules! check_user_blocked_or_private {
.is_ok(),
);
return Ok(Html(
return Err(Html(
$data.1.render("profile/blocked.html", &context).unwrap(),
));
}
@ -281,7 +281,7 @@ macro_rules! check_user_blocked_or_private {
.is_ok(),
);
return Ok(Html(
return Err(Html(
$data.1.render("profile/private.html", &context).unwrap(),
));
}
@ -293,7 +293,7 @@ macro_rules! check_user_blocked_or_private {
context.insert("follow_requested", &false);
context.insert("is_following", &false);
return Ok(Html(
return Err(Html(
$data.1.render("profile/private.html", &context).unwrap(),
));
}
@ -352,7 +352,14 @@ macro_rules! ignore_users_gen {
($user:ident, $data:ident) => {
if let Some(ref ua) = $user {
[
$data.0.get_userblocks_receivers(ua.id).await,
$data
.0
.get_userblocks_receivers(
ua.id,
&ua.associated,
ua.settings.hide_associated_blocked_users,
)
.await,
$data.0.get_userblocks_initiator_by_receivers(ua.id).await,
$data.0.get_user_stack_blocked_users(ua.id).await,
]
@ -364,7 +371,14 @@ macro_rules! ignore_users_gen {
($user:ident!, $data:ident) => {{
[
$data.0.get_userblocks_receivers($user.id).await,
$data
.0
.get_userblocks_receivers(
$user.id,
&$user.associated,
$user.settings.hide_associated_blocked_users,
)
.await,
$data
.0
.get_userblocks_initiator_by_receivers($user.id)
@ -376,7 +390,13 @@ macro_rules! ignore_users_gen {
($user:ident!, #$data:ident) => {
[
$data.get_userblocks_receivers($user.id).await,
$data
.get_userblocks_receivers(
$user.id,
&$user.associated,
$user.settings.hide_associated_blocked_users,
)
.await,
$data.get_userblocks_initiator_by_receivers($user.id).await,
]
.concat()

View file

@ -38,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
)
}
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(CustomEmoji::replace(value.as_str().unwrap()).into())
}
fn color_escape(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(sanitize::color_escape(value.as_str().unwrap()).into())
}
@ -78,7 +82,11 @@ async fn main() {
.compact()
.init();
let config = config::Config::get_config();
let mut config = config::Config::get_config();
if let Ok(port) = var("PORT") {
let port = port.parse::<u16>().expect("port should be a u16");
config.port = port;
}
// init
init_dirs(&config).await;
@ -102,11 +110,25 @@ async fn main() {
tera.register_filter("has_staff_badge", check_staff_badge);
tera.register_filter("has_banned", check_banned);
tera.register_filter("remove_script_tags", remove_script_tags);
tera.register_filter("emojis", render_emojis);
let client = Client::new();
let mut app = Router::new();
let app = Router::new()
.merge(routes::routes(&config))
// 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' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"),
));
}
// add junk
app = app
.layer(Extension(Arc::new(RwLock::new((database, tera, client)))))
.layer(axum::extract::DefaultBodyLimit::max(
var("BODY_LIMIT")
@ -119,12 +141,9 @@ async fn main() {
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
)
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"),
))
.layer(CatchPanicLayer::new());
// ...
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
.await
.unwrap();

View file

@ -38,6 +38,10 @@
--pad-2: 0.5rem;
--pad-3: 0.75rem;
--pad-4: 1rem;
--online: var(--color-green);
--idle: var(--color-yellow);
--offline: hsl(0, 0%, 50%);
}
.dark,
@ -218,7 +222,7 @@ pre {
}
code {
padding: var(--pad-1);
padding: 0;
}
pre,
@ -263,7 +267,7 @@ span,
code {
max-width: 100%;
overflow-wrap: normal;
text-wrap: pretty;
text-wrap: stable;
word-wrap: break-word;
}
@ -389,3 +393,11 @@ blockquote {
transform: rotateZ(360deg);
}
}
canvas {
border-radius: var(--radius);
border: solid 5px var(--color-primary);
background: white;
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
}

View file

@ -413,6 +413,44 @@ select:focus {
overflow-wrap: anywhere;
}
input[type="checkbox"] {
--color: #c9b1bc;
appearance: none;
border-radius: var(--radius);
transition:
box-shadow 0.15s,
background 0.15s;
background-color: var(--color-super-raised);
background-position: center;
background-origin: padding-box;
background-size: 14px;
background-repeat: no-repeat;
width: 1em !important;
height: 1em;
min-width: 1em;
outline: none;
border: solid 1px var(--color-super-lowered);
padding: 0;
cursor: pointer;
color: var(--color-text-primary);
}
input[type="checkbox"]:hover {
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
}
input[type="checkbox"]:focus {
outline: solid 2px var(--color);
outline-offset: 2px;
}
input[type="checkbox"]:checked {
border-color: var(--color);
background-color: var(--color);
background-image: url("/icons/check.svg");
}
/* pillmenu */
.pillmenu {
display: flex;
@ -544,6 +582,7 @@ select:focus {
font-size: 12px;
border-radius: 6px;
height: max-content;
font-weight: 600;
}
.notification.tr {
@ -632,7 +671,7 @@ nav .button:not(.title):not(.active):hover {
margin-bottom: 0;
backdrop-filter: none;
bottom: 0;
position: absolute;
position: fixed;
height: max-content;
top: unset;
}
@ -1065,14 +1104,14 @@ details summary::-webkit-details-marker {
display: none;
}
details[open] summary {
details[open] > summary {
position: relative;
color: var(--color-primary);
background: var(--color-super-lowered);
color: var(--color-text-lowered) !important;
background: var(--color-super-lowered) !important;
margin-bottom: var(--pad-1);
}
details[open] summary::after {
details[open] > summary::after {
top: 0;
left: 0;
width: 5px;
@ -1250,3 +1289,32 @@ details.accordion .inner {
.CodeMirror-focused .CodeMirror-placeholder {
opacity: 50%;
}
.CodeMirror-gutters {
border-color: var(--color-super-lowered) !important;
background-color: var(--color-lowered) !important;
}
.CodeMirror-hints {
background: var(--color-raised) !important;
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
border-radius: var(--radius) !important;
padding: var(--pad-1) !important;
border-color: var(--color-super-lowered) !important;
}
.CodeMirror-hints li {
color: var(--color-text-raised) !important;
border-radius: var(--radius) !important;
transition:
background 0.15s,
color 0.15s;
font-size: 10px;
padding: calc(var(--pad-1) / 2) var(--pad-2);
}
.CodeMirror-hints li.CodeMirror-hint-active {
background-color: var(--color-primary) !important;
color: var(--color-text-primary) !important;
}

View file

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

View file

@ -12,6 +12,7 @@
(text "{% if connection_type == \"Spotify\" and user and user.connections.Spotify and config.connections.spotify_client_id %}")
(script
("defer" "true")
(text "setTimeout(async () => {
const code = new URLSearchParams(window.location.search).get(\"code\");
const client_id = \"{{ config.connections.spotify_client_id }}\";
@ -46,10 +47,11 @@
setTimeout(() => {
window.location.href = \"/settings#/connections\";
}, 500);
}, 150);"))
}, 1000);"))
(text "{% elif connection_type == \"LastFm\" and user and user.connections.LastFm and config.connections.last_fm_key %}")
(script
("defer" "true")
(text "setTimeout(async () => {
const token = new URLSearchParams(window.location.search).get(\"token\");
const api_key = \"{{ config.connections.last_fm_key }}\";

View file

@ -48,7 +48,8 @@
("name" "totp")
("id" "totp"))))
(button
(text "Submit")))
(icon (text "arrow-right"))
(str (text "auth:action.continue"))))
(script
(text "let flow_page = 1;
@ -90,7 +91,7 @@
}),
})
.then((res) => res.json())
.then((res) => {
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
@ -98,7 +99,7 @@
if (res.ok) {
// update tokens
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger(\"me::set_login_account_tokens\", [new_tokens]);

View file

@ -25,7 +25,7 @@
(div
("class" "flex flex-col gap-1")
(label
("for" "username")
("for" "password")
(b
(text "Password")))
(input
@ -34,6 +34,35 @@
("required" "")
("name" "password")
("id" "password")))
(text "{% if config.security.enable_invite_codes -%}")
(div
("class" "flex flex-col gap-1")
("oninput" "check_should_show_purchase(event)")
(label
("for" "invite_code")
(b
(text "Invite code (optional)")))
(input
("type" "text")
("placeholder" "invite code")
("name" "invite_code")
("id" "invite_code")))
(script
(text "function check_should_show_purchase(e) {
if (e.target.value.length > 0) {
document.querySelector('[ui_ident=purchase_account]').classList.add('hidden');
document.querySelector('[ui_ident=create_account]').classList.remove('hidden');
globalThis.DO_PURCHASE = false;
} else {
document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden');
document.querySelector('[ui_ident=create_account]').classList.add('hidden');
globalThis.DO_PURCHASE = true;
}
}
globalThis.DO_PURCHASE = true;"))
(text "{%- endif %}")
(hr)
(div
("class" "card-nest w-full")
@ -56,7 +85,7 @@
("href" "{{ config.policies.privacy }}")
(text "Privacy policy"))))
(div
("class" "flex gap-2")
("class" "flex items-center gap-2")
(input
("type" "checkbox")
("name" "policy_consent")
@ -70,8 +99,33 @@
("class" "cf-turnstile")
("data-sitekey" "{{ config.turnstile.site_key }}"))
(hr)
(text "{% if config.security.enable_invite_codes -%}")
(div
("class" "w-full flex gap-2 justify-between")
("ui_ident" "purchase_account")
(button
(icon (text "credit-card"))
(str (text "auth:action.purchase_account")))
(button
("class" "small square lowered")
("type" "button")
("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')")
(icon (text "circle-question-mark"))))
(div
("class" "hidden lowered card w-full no_p_margin")
("ui_ident" "purchase_help")
(b (text "What does \"Purchase account\" mean?"))
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}."))
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
(text "{%- endif %}")
(button
(text "Submit")))
("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}")
("ui_ident" "create_account")
(icon (text "plus"))
(str (text "auth:action.create_account"))))
(script
(text "async function register(e) {
@ -89,10 +143,12 @@
captcha_response: e.target.querySelector(
\"[name=cf-turnstile-response]\",
).value,
invite_code: (e.target.invite_code || { value: \"\" }).value,
purchase: globalThis.DO_PURCHASE,
}),
})
.then((res) => res.json())
.then((res) => {
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
@ -100,7 +156,7 @@
if (res.ok) {
// update tokens
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger(\"me::set_login_account_tokens\", [new_tokens]);

View file

@ -1,5 +1,17 @@
(div ("id" "toast_zone"))
; large text
(text "{% if user and user.settings.large_text -%}")
(style
(text "button, a, p, span, b, strone, em, i, pre, code {
font-size: 18px !important;
}
nav .icon {
font-size: 15px !important;
}"))
(text "{%- endif %}")
; templates
(template
("id" "loading_skeleton")
@ -18,10 +30,46 @@
(div ("class" "skel") ("style" "width: 25%; height: 25px;"))
(div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
(template
("id" "carp_canvas")
(div
("class" "flex flex-col gap-2")
(div ("ui_ident" "canvas_loc"))
(div
("class" "flex justify-between gap-2")
(div
("class" "flex gap-2")
(input
("type" "color")
("style" "width: 5rem")
("ui_ident" "color_picker"))
(input
("type" "range")
("min" "1")
("max" "25")
("step" "1")
("value" "2")
("ui_ident" "stroke_range")))
(div
("class" "flex gap-2")
(button
("title" "Undo")
("ui_ident" "undo")
("type" "button")
(icon (text "undo")))
(button
("title" "Redo")
("ui_ident" "redo")
("type" "button")
(icon (text "redo")))))))
; random js
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {
const atto = ns(\"atto\");
document.documentElement.addEventListener(\"turbo:load\", async () => {
const atto = await ns(\"atto\");
if (!atto) {
window.location.reload();
@ -53,6 +101,11 @@
}
setTimeout(() => {
if (globalThis.notifs_stream_init) {
return;
}
globalThis.notifs_stream_init = true;
trigger(\"me::notifications_stream\");
}, 250);
});
@ -250,6 +303,17 @@
return;
}
while (!ns(\"spotify\")) {
console.log(\"still no spotify\");
await (() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
})();
}
window.spotify_init = true;
const client_id = \"{{ config.connections.spotify_client_id }}\";
let token = \"{{ user.connections.Spotify[0].data.token }}\";
@ -265,21 +329,11 @@
if (playing.error) {
// refresh token
const [new_token, new_refresh_token, expires_in] =
await trigger(\"spotify::refresh_token\", [
client_id,
const [new_token, new_refresh_token] =
await trigger(\"spotify::refresh\", [
refresh_token,
]);
await trigger(\"connections::push_con_data\", [
\"Spotify\",
{
token: new_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
token = new_token;
refresh_token = new_refresh_token;
return;

View file

@ -109,6 +109,23 @@
("title" "Send")
(text "{{ icon \"send-horizontal\" }}"))))
(text "{%- endif %}")
; emoji picker
(text "{{ components::emoji_picker(element_id=\"\", render_dialog=true, render_button=false) }}")
(input ("id" "react_emoji_picker_field") ("class" "hidden") ("type" "hidden"))
(script
(text "window.EMOJI_PICKER_MODE = \"replace\";
document.getElementById(\"react_emoji_picker_field\").addEventListener(\"change\", (e) => {
if (!EMOJI_PICKER_REACTION_MESSAGE_ID) {
return;
}
const emoji = e.target.value === \"::\" ? \":heart:\" : e.target.value;
trigger(\"me::message_react\", [document.getElementById(`message-${EMOJI_PICKER_REACTION_MESSAGE_ID}`), EMOJI_PICKER_REACTION_MESSAGE_ID, emoji]);
});"))
; ...
(script
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
@ -434,6 +451,7 @@
const clean_text = () => {
trigger(\"atto::clean_date_codes\");
trigger(\"atto::hooks::online_indicator\");
trigger(\"atto::hooks::check_message_reactions\");
};
document.addEventListener(

View file

@ -139,7 +139,7 @@
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex justify-between gap-2")
("class" "flex justify-between flex-collapse gap-2")
(text "{{ components::create_post_options() }}")
(div
("class" "flex gap-2")

View file

@ -572,9 +572,9 @@
(text "{%- endif %}"))
(script
(text "setTimeout(() => {
(text "setTimeout(async () => {
const element = document.getElementById(\"membership_info\");
const ui = ns(\"ui\");
const ui = await ns(\"ui\");
const uid = new URLSearchParams(window.location.search).get(\"uid\");
if (uid) {
@ -665,7 +665,7 @@
`/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`,
)
.then((res) => res.json())
.then((res) => {
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
@ -676,7 +676,7 @@
}
// permissions manager
const get_permissions_html = trigger(
const get_permissions_html = await trigger(
\"ui::generate_permissions_ui\",
[
{
@ -750,8 +750,8 @@
(text "{{ community.context|json_encode()|safe }}"))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
(text "setTimeout(async () => {
const ui = await ns(\"ui\");
const settings = JSON.parse(
document.getElementById(\"settings_json\").innerHTML,
);

View file

@ -94,7 +94,7 @@
(text "{{ dislikes }}"))
(text "{%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%}")
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
(div
("class" "flex items-center")
(a
@ -110,14 +110,14 @@
("class" "flex items-center")
(text "{{ icon \"badge-check\" }}"))
(text "{%- endif %}"))
(text "{%- 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
("style" "display: contents")
(text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}"))
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false, is_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
(div
("class" "card-nest post_outer:{{ post.id }}")
("class" "card-nest post_outer:{{ post.id }} post_outer")
("is_repost" "{{ is_repost }}")
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")
(div
("class" "card small")
@ -130,9 +130,10 @@
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")))
(text "{%- endif %} {%- endif %}")
(div
("class" "card flex flex-col gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
("class" "card flex flex-col post gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
("data-community" "{{ post.community }}")
("data-ownsup" "{{ owner.permissions|has_supporter }}")
("data-id" "{{ post.id }}")
("hook" "verify_emojis")
(div
("class" "w-full flex gap-2")
@ -215,7 +216,7 @@
("class" "flush")
("href" "/post/{{ post.id }}")
(h2
("id" "post-content:{{ post.id }}")
("id" "post_content:{{ post.id }}")
("class" "no_p_margin post_content")
("hook" "long")
(text "{{ post.title }}"))
@ -224,7 +225,6 @@
(text "{% else %}")
(text "{% if not post.context.content_warning -%}")
(span
("id" "post-content:{{ post.id }}")
("class" "no_p_margin post_content")
("hook" "long")
@ -235,7 +235,8 @@
(text "{%- endif %}")
; content
(text "{{ post.content|markdown|safe }} {% 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 %}")
(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, is_repost=true) }} {% else %}")
(div
("class" "card lowered red flex items-center gap-2")
(text "{{ icon \"frown\" }}")
@ -252,7 +253,6 @@
(div
("class" "flex flex-col gap-2")
(span
("id" "post-content:{{ post.id }}")
("class" "no_p_margin post_content")
("hook" "long")
@ -262,7 +262,8 @@
(text "{% endif %}")
; content
(text "{{ post.content|markdown|safe }} {% 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 %}")
(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 %}")
(div
("class" "card lowered red flex items-center gap-2")
(text "{{ icon \"frown\" }}")
@ -339,7 +340,32 @@
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quote_post\" }}")))
(button
("onclick" "trigger('me::intent_twitter', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
(icon (text "bird"))
(span
(text "Twitter")))
(button
("onclick" "trigger('me::intent_bluesky', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
(icon (text "cloud"))
(span
(text "BlueSky")))
(text "{%- endif %}")
(text "{% if user.id != post.owner -%}")
(b
("class" "title")
(text "{{ text \"general:label.safety\" }}"))
(button
("class" "red")
("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:action.report\" }}")))
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
(b
("class" "title")
(text "{{ text \"general:action.manage\" }}"))
; forge stuff
(text "{% if community and community.is_forge -%} {% if post.is_open -%}")
(button
("class" "green")
@ -355,20 +381,7 @@
(span
(text "{{ text \"forge:action.reopen\" }}")))
(text "{%- endif %} {%- endif %}")
(text "{% if user.id != post.owner -%}")
(b
("class" "title")
(text "{{ text \"general:label.safety\" }}"))
(button
("class" "red")
("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:action.report\" }}")))
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
(b
("class" "title")
(text "{{ text \"general:action.manage\" }}"))
; owner stuff
(text "{% if user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
@ -405,7 +418,7 @@
(text "{%- endif %}"))))
(text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
(div
("class" "media_gallery gap-2")
(text "{% for upload in upload_ids %}")
@ -515,7 +528,7 @@
("width" "24")
("height" "24")
("viewBox" "0 0 24 24")
("style" "fill: var(--color-green)")
("style" "fill: var(--online)")
(circle
("cx" "12")
("cy" "12")
@ -528,7 +541,7 @@
("width" "24")
("height" "24")
("viewBox" "0 0 24 24")
("style" "fill: var(--color-yellow)")
("style" "fill: var(--idle)")
(circle
("cx" "12")
("cy" "12")
@ -541,7 +554,7 @@
("width" "24")
("height" "24")
("viewBox" "0 0 24 24")
("style" "fill: hsl(0, 0%, 50%)")
("style" "fill: var(--offline)")
(circle
("cx" "12")
("cy" "12")
@ -598,7 +611,8 @@
(text "{%- endif %}")
(div
("style" "display: none;")
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}")
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }}
{{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}")
(style
(text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}"))
(text "{%- endif %}"))
@ -612,8 +626,8 @@
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
(div
("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2")
(text "{% if owner.id == 0 -%}")
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
(text "{% if owner.id == 0 or question.context.mask_owner -%}")
(span
(text "{% if profile and profile.settings.anonymous_avatar_url -%}")
(img
@ -622,7 +636,7 @@
("class" "avatar shadow")
("loading" "lazy")
("style" "--size: 52px"))
(text "{% else %} {{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
(text "{% else %} {{ self::avatar(username=\"anonymous\", selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
(text "{% else %}")
(a
("href" "/@{{ owner.username }}")
@ -634,7 +648,7 @@
("class" "flex items-center gap-2 flex-wrap")
(span
("class" "name")
(text "{% if owner.id == 0 -%} {% if profile and profile.settings.anonymous_username -%}")
(text "{% if owner.id == 0 or question.context.mask_owner -%} {% if profile and profile.settings.anonymous_username -%}")
(span
("class" "flex items-center gap-2")
(b
@ -676,10 +690,13 @@
(span
("class" "no_p_margin")
("style" "font-weight: 500")
("id" "question_content:{{ question.id }}")
(text "{{ question.content|markdown|safe }}"))
; question drawings
(text "{{ self::post_media(upload_ids=question.drawings) }}")
; anonymous user ip thing
; this is only shown if the post author is anonymous AND we are a helper
(text "{% if is_helper and owner.id == 0 %}")
(text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}")
(details
("class" "card tiny lowered w-full")
(summary
@ -688,12 +705,22 @@
(span (text "View IP")))
(pre (code (text "{{ question.ip }}"))))
(text "{% endif %}")
(text "{% if question.context.mask_owner -%}")
(details
("class" "card tiny lowered w-full")
(summary
("class" "w-full flex gap-2 flex-wrap items-center")
(icon (text "venetian-mask"))
(span (text "Unmask")))
(text "{{ self::full_username(user=owner) }}"))
(text "{%- endif %} {%- endif %}")
; ...
(div
("class" "flex gap-2 items-center justify-between"))))
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false, allow_anonymous=false) -%}")
(div
("class" "card-nest")
(div
@ -707,6 +734,12 @@
("onsubmit" "create_question_from_form(event)")
(div
("class" "flex flex-col gap-1")
; carp canvas
(text "{% if drawing_enabled -%}")
(div ("ui_ident" "carp_canvas_field"))
(text "{%- endif %}")
; form
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
@ -718,25 +751,100 @@
("required" "")
("minlength" "2")
("maxlength" "4096")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(div
("class" "flex w-full justify-between gap-2 flex-collapse")
(div
("class" "flex gap-2")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}")
(button
("class" "lowered")
("ui_ident" "add_drawing")
("onclick" "attach_drawing()")
("type" "button")
(text "{{ text \"communities:action.draw\" }}"))
(button
("class" "lowered red hidden")
("ui_ident" "remove_drawing")
("onclick" "remove_drawing()")
("type" "button")
(text "{{ text \"communities:action.remove_drawing\" }}"))
(script
(text "globalThis.attach_drawing = async () => {
globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
globalThis.gerald.create_canvas();
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
}
globalThis.remove_drawing = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
globalThis.gerald = null;
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
}"))
(text "{%- endif %}"))
(text "{% if not is_global and allow_anonymous and not user -%}")
(div
("class" "flex gap-2 items-center")
(input
("type" "checkbox")
("name" "mask_owner")
("id" "mask_owner")
("class" "w-content"))
(label
("for" "mask_owner")
(b (str (text "general:label.send_anonymously")))))
(text "{%- endif %}"))))
(script
(text "async function create_question_from_form(e) {
(text "globalThis.gerald = null;
async function create_question_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"questions::create\"]);
fetch(\"/api/v1/questions\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
// create body
const body = new FormData();
if (globalThis.gerald) {
body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
type: \"application/octet-stream\"
}));
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
receiver: \"{{ receiver }}\",
community: \"{{ community }}\",
is_global: \"{{ is_global }}\" == \"true\",
mask_owner: (e.target.mask_owner || { checked:false }).checked
}),
);
// ...
fetch(\"/api/v1/questions\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
@ -747,6 +855,10 @@
if (res.ok) {
e.target.reset();
if (globalThis.gerald) {
globalThis.gerald.clear();
}
}
});
}"))
@ -956,9 +1068,29 @@
(text "{%- endif %}")
(div
("class" "flex w-full gap-2 justify-between")
(span
("class" "no_p_margin")
(text "{{ message.content|markdown|safe }}"))
(div
("class" "flex flex-col gap-2")
(span
("class" "no_p_margin")
(text "{{ message.content|markdown|safe }}"))
(div
("class" "flex w-full gap-1 flex-wrap")
("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'")
("hook" "check_message_reactions")
("hook-arg:id" "{{ message.id }}")
(text "{% for emoji,num in message.reactions -%}")
(button
("class" "small lowered")
("ui_ident" "emoji_{{ emoji }}")
("onclick" "trigger('me::message_react', [event.target.parentElement.parentElement, '{{ message.id }}', '{{ emoji }}'])")
(span (text "{{ emoji|emojis|safe }} {{ num }}")))
(text "{%- endfor %}")
(div
("class" "hidden")
(text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}"))))
(text "{% if grouped -%}")
(div
("class" "hidden")
@ -980,6 +1112,12 @@
("href" "/journals/0/0")
(icon (text "notebook"))
(str (text "general:link.journals")))
(text "{% if not user.settings.disable_achievements -%}")
(a
("href" "/achievements")
(icon (text "award"))
(str (text "general:link.achievements")))
(text "{%- endif %}")
(a
("href" "/settings")
(text "{{ icon \"settings\" }}")
@ -1017,22 +1155,18 @@
(icon (text "code"))
(str (text "general:link.source_code")))
(a
("href" "/reference/tetratto/index.html")
("class" "button")
("data-turbo" "false")
(button
("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])")
(icon (text "rabbit"))
(str (text "general:link.reference")))
(a
("href" "{{ config.policies.terms_of_service }}")
("class" "button")
(button
("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])")
(icon (text "heart-handshake"))
(text "Terms of service"))
(a
("href" "{{ config.policies.privacy }}")
("class" "button")
(button
("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])")
(icon (text "cookie"))
(text "Privacy policy"))
(b ("class" "title") (str (text "general:label.account")))
@ -1115,13 +1249,15 @@
(text "{{ text \"chats:action.kick_member\" }}")))))
(text "{%- endif %}"))
(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false) -%}")
(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false, render_button=true, small=false) -%}")
(text "{% if render_button -%}")
(button
("class" "button small square lowered")
("class" "button small {% if not small -%} square {%- endif %} lowered")
("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()")
("title" "Emojis")
("type" "button")
(text "{{ icon \"smile-plus\" }}"))
(text "{%- endif %}")
(text "{% if render_dialog -%}")
(dialog
@ -1167,20 +1303,41 @@
}
if (event.detail.unicode) {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${await (
await fetch(\"/api/v1/lookup_emoji\", {
method: \"POST\",
body: event.detail.unicode,
})
).text()}:`;
if (window.EMOJI_PICKER_MODE === \"replace\") {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value = `:${await (
await fetch(\"/api/v1/lookup_emoji\", {
method: \"POST\",
body: event.detail.unicode,
})
).text()}:`;
} else {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${await (
await fetch(\"/api/v1/lookup_emoji\", {
method: \"POST\",
body: event.detail.unicode,
})
).text()}:`;
}
} else {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${event.detail.emoji.shortcodes[0]}:`;
if (window.EMOJI_PICKER_MODE === \"replace\") {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value = `:${event.detail.emoji.shortcodes[0]}:`;
} else {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${event.detail.emoji.shortcodes[0]}:`;
}
}
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).dispatchEvent(new Event(\"change\"));
document.getElementById(\"emoji_dialog\").close();
});"))
(div
@ -1287,7 +1444,7 @@
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
(div
("class" "flex gap-2")
("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 %}")
(button
@ -1302,7 +1459,20 @@
("title" "More options")
("onclick" "document.getElementById('post_options_dialog').showModal()")
("type" "button")
(text "{{ icon \"ellipsis\" }}")))
(text "{{ icon \"ellipsis\" }}"))
(label
("class" "flex items-center gap-1 button lowered")
("title" "Mark as NSFW/hide from public timelines")
("for" "is_nsfw")
(input
("type" "checkbox")
("name" "is_nsfw")
("id" "is_nsfw")
("checked" "{{ user.settings.auto_unlist }}")
("onchange" "POST_INITIAL_SETTINGS['is_nsfw'] = event.target.checked"))
(span (icon (text "eye-closed")))))
(dialog
("id" "post_options_dialog")
@ -1362,11 +1532,11 @@
window.POST_INITIAL_SETTINGS.reactions_enabled.toString(),
\"checkbox\",
],
[
[\"is_nsfw\", \"Hide from public timelines\"],
window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
\"checkbox\",
],
// [
// [\"is_nsfw\", \"Hide from public timelines\"],
// window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
// \"checkbox\",
// ],
[
[\"content_warning\", \"Content warning\"],
window.POST_INITIAL_SETTINGS.content_warning,
@ -1915,6 +2085,22 @@
(str (text "general:action.delete")))))
(text "{%- endif %}"))
(text "{% if selected_note -%}")
; open all details elements above the selected note
(script
("defer" "true")
(text "setTimeout(() => {
let cursor = document.querySelector(\"[ui_ident=active_note]\");
while (cursor) {
if (cursor.nodeName === \"DETAILS\") {
cursor.setAttribute(\"open\", \"true\");
}
cursor = cursor.parentElement;
}
}, 150);"))
(text "{%- endif %}")
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
@ -1928,36 +2114,249 @@
(text "{%- endif %}")
; note listings
(text "{% for note in notes %}")
(div
("class" "flex flex-row gap-1")
(a
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
(icon (text "file-text"))
(text "{{ note.title }}"))
(text "{% if user and user.id == journal.owner -%}")
(div
("class" "dropdown")
(button
("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("onclick" "change_note_title('{{ note.id }}')")
(icon (text "pencil"))
(str (text "chats:action.rename")))
(button
("onclick" "delete_note('{{ note.id }}')")
("class" "red")
(icon (text "trash"))
(str (text "general:action.delete")))))
(text "{%- endif %}"))
(text "{% endfor %}"))
(text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
(details
(summary
("class" "button w-full justify-start raised w-full")
(icon (text "folder"))
(text "{{ dir[2] }}"))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
(text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")))
(text "{%- endmacro %}")
(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}")
; child dirs
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}")
(text "{%- endif %} {% endfor %}")
; child notes
(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}")
(text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}")
(text "{%- endif %} {%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro notes_list_note_listing(owner, journal, note) -%}")
(div
("class" "flex flex-row gap-1")
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
(a
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
(icon (text "file-text"))
(text "{{ note.title }}"))
(text "{% if user and user.id == journal.owner -%}")
(div
("class" "dropdown")
(button
("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("onclick" "change_note_title('{{ note.id }}')")
(icon (text "pencil"))
(str (text "chats:action.rename")))
(a
("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
(icon (text "tag"))
(str (text "journals:action.edit_tags")))
(button
("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
(icon (text "brush-cleaning"))
(str (text "journals:action.move")))
(text "{% if note.is_global -%}")
(a
("class" "button")
("href" "/x/{{ note.title }}")
(icon (text "eye"))
(str (text "journals:action.view")))
(button
("class" "purple")
("onclick" "unpublish_note('{{ note.id }}')")
(icon (text "globe-lock"))
(str (text "journals:action.unpublish")))
(text "{% elif note.title != 'journal.css' %}")
(button
("class" "green")
("onclick" "publish_note('{{ note.id }}')")
(icon (text "globe"))
(str (text "journals:action.publish")))
(text "{%- endif %}")
(button
("onclick" "delete_note('{{ note.id }}')")
("class" "red")
(icon (text "trash"))
(str (text "general:action.delete")))))
(text "{%- endif %}"))
(text "{%- endmacro %}")
(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}")
(div
("class" "flex gap-1 flex-wrap")
(text "{% for tag in note.tags %}")
(a
("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}")
("class" "notification chip")
(span (text "{{ tag }}")))
(text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %}")
(text "{% macro directories_editor(dirs) -%}")
(button
("onclick" "create_directory('0')")
(icon (text "plus"))
(str (text "journals:action.create_root_dir")))
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
(text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro directories_editor_listing(dir, dirs) -%}")
(div
("class" "flex flex-row gap-1")
(button
("class" "justify-start lowered w-full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
(div
("class" "dropdown")
(button
("class" "big_icon lowered")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("onclick" "create_directory('{{ dir[0] }}')")
(icon (text "plus"))
(str (text "journals:action.create_subdir")))
(button
("onclick" "delete_directory('{{ dir[0] }}')")
("class" "red")
(icon (text "trash"))
(str (text "general:action.delete"))))))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
; subdir listings
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}"))
(text "{%- endmacro %}")
(text "{% macro note_mover_dirs(dirs) -%}")
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
(text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}")
(text "{%- endmacro %}")
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
(button
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
("class" "justify-start lowered w-full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
(div
("class" "flex flex-col gap-2")
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
; subdir listings
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
(text "{%- endif %} {% endfor %}"))
(text "{%- endmacro %}")
(text "{% macro become_supporter_button() -%}")
(p
(text "You're ")
(b
(text "not "))
(text "currently a supporter! No
pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:"))
(ul
("style" "margin-bottom: var(--pad-4)")
(li
(text "Vanity badge on profile"))
(li
(text "No more supporter ads (duh)"))
(li
(text "Ability to upload gif avatars/banners"))
(li
(text "Be an admin/owner of up to 10 communities"))
(li
(text "Use custom CSS on your profile"))
(li
(text "Use community emojis outside of
their community"))
(li
(text "Upload and use gif emojis"))
(li
(text "Create infinite stack timelines"))
(li
(text "Upload images to posts"))
(li
(text "Save infinite post drafts"))
(li
(text "Ability to search through all posts"))
(li
(text "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")
(sup (a ("href" "#footnote-1") (text "1"))))
(text "{%- endif %}"))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
("target" "_blank")
(text "Become a supporter ({{ config.stripe.supporter_price_text }})"))
(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 %}")

View file

@ -129,7 +129,7 @@
(pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap")))
(details
(summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help"))
(summary ("class" "button lowered small") (icon (text "circle-question-mark")) (text "Help"))
(div
("class" "card flex flex-col gap-1")
(span ("class" "fade") (text "Scopes should be separated by a single space."))

View file

@ -92,7 +92,7 @@
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "circle-help"))
(icon (text "circle-question-mark"))
(str (text "developer:label.guides_and_help")))
(div

View file

@ -1,7 +1,87 @@
(text "{% extends \"root.html\" %} {% block head %}")
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
(text "{% if journal -%} {% if note -%}")
(title (text "{{ note.title }}"))
(text "{% else %}")
(title (text "{{ journal.title }}"))
(text "{%- endif %} {% else %}")
(title (text "Journals - {{ config.name }}"))
(text "{%- endif %}")
(text "{% if note and journal and owner -%}")
(meta
("name" "og:title")
("content" "{{ note.title }}"))
(text "{% if not global_mode -%}")
(meta
("name" "description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(meta
("name" "twitter:description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(text "{% else %}")
(meta
("name" "description")
("content" "View this note on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View this note on {{ config.name }}!"))
(meta
("name" "twitter:description")
("content" "View this note on {{ config.name }}!"))
(text "{%- endif %}")
(meta
("property" "og:type")
("content" "website"))
(meta
("name" "og:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
(meta
("name" "twitter:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
(meta
("name" "twitter:card")
("content" "summary"))
(meta
("name" "twitter:title")
("content" "{{ note.title }}"))
(text "{%- endif %}")
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
(style
(text "html, body {
overflow: hidden auto !important;
}
.sidebar {
position: sticky;
top: 42px;
}
@media screen and (max-width: 900px) {
.sidebar {
position: absolute;
top: unset;
}
body.sidebars_shown {
overflow: hidden !important;
}
}"))
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
; redirect to note
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
@ -9,7 +89,12 @@
; redirect to journal homepage
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
(text "{%- endif %} {%- endif %}")
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
(text "{% if view_mode and journal -%}")
; add journal css
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
(text "{%- endif %}")
(text "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}")
(text "{% if not view_mode -%}")
(nav
("class" "chats_nav")
@ -53,27 +138,31 @@
(main
("class" "flex flex-col gap-2")
; the journal/note header is always shown
(text "{% if journal -%}")
(text "{% if journal and not global_mode -%}")
(div
("class" "mobile_nav w-full flex items-center justify-between gap-2")
(div
("class" "flex gap-2 items-center")
(a
("class" "flex items-center")
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
("class" "flex flex-col gap-2")
(div
("class" "flex gap-2 items-center")
(a
("class" "flex items-center")
("href" "/@{{ owner.username }}")
(text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}"))
(a
("class" "flush")
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
(b (text "{{ journal.title }}")))
(text "{% if (view_mode and owner) or not view_mode -%}")
(a
("class" "flush")
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
(b (text "{{ journal.title }}")))
(text "{%- endif %}")
(text "{% if note -%}")
(span (text "/"))
(b (text "{{ note.title }}"))
(text "{%- endif %}"))
(text "{% if note -%}")
(span (text "/"))
(b (text "{{ note.title }}"))
(text "{%- endif %}")))
(text "{% if user and user.id == journal.owner -%}")
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
(div
("class" "pillmenu")
(a
@ -83,7 +172,7 @@
(icon (text "pencil")))
(a
("class" "{% if view_mode -%}active{%- endif %}")
("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
(icon (text "eye"))))
(text "{%- endif %}"))
(text "{%- endif %}")
@ -96,6 +185,7 @@
("class" "card w-full flex flex-col gap-2")
(h3 (str (text "journals:label.welcome")))
(span (str (text "journals:label.select_a_journal")))
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
(button
("onclick" "create_journal()")
(icon (text "plus"))
@ -167,10 +257,29 @@
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))))
; users should also be able to manage the journal's sub directories here
(details
("class" "w-full")
(summary
("class" "button lowered w-full justify-start")
(icon (text "folders"))
(str (text "journals:label.directories")))
(div
("class" "card flex flex-col gap-2 lowered")
(text "{{ components::directories_editor(dirs=journal.dirs) }}")))
(text "{% else %}")
; we're in view mode; just show journal listing and notes as journal homepage
(div
("class" "card flex flex-col gap-2")
(text "{% if tag|length > 0 -%}")
(a
("href" "?")
("class" "notification chip w-content")
(text "{{ tag }}"))
(text "{%- endif %}")
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}"))
(text "{%- endif %}")
(text "{% else %}")
@ -180,10 +289,36 @@
; import codemirror
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
(text "{% if note.title == \"journal.css\" -%}")
; css editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
(style
(text ".CodeMirror {
font-family: monospace !important;
font-size: 16px;
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius);
}
.CodeMirror-line {
padding-left: 5px !important;
}"))
(text "{% else %}")
; markdown editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(text "{%- endif %}")
; tab bar
(text "{% if note.title != \"journal.css\" -%}")
(div
("class" "pillmenu")
(a
@ -197,14 +332,31 @@
("href" "#/preview")
("data-tab-button" "preview")
("data-turbo" "false")
(str (text "journals:label.preview_pane"))))
(str (text "journals:label.preview_pane")))
(a
("href" "#/tags")
("data-tab-button" "tags")
("data-turbo" "false")
("class" "hidden")
(str (text "journals:action.edit_tags"))))
(text "{%- endif %}")
; tabs
(text "{{ components::note_tags(note=note) }}")
(div
("data-tab" "editor")
("class" "flex flex-col gap-2 card")
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "editor_tab"))
("class" "flex flex-col gap-2")
(div
("class" "flex flex-col gap-2 card")
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "editor_tab"))
(button
("onclick" "change_note_content('{{ note.id }}')")
(icon (text "check"))
(str (text "general:action.save"))))
(div
("data-tab" "preview")
@ -212,19 +364,62 @@
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "preview_tab"))
(button
("onclick" "change_note_content('{{ note.id }}')")
(icon (text "check"))
(str (text "general:action.save")))
(div
("data-tab" "tags")
("class" "flex flex-col gap-2 card hidden")
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
(form
("onsubmit" "save_tags(event)")
("class" "flex flex-col gap-1")
(label
("for" "tags")
(str (text "journals:action.tags"))
(textarea
("type" "text")
("name" "tags")
("id" "tags")
("placeholder" "tags")
("required" "")
("minlength" "2")
("maxlength" "128")
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
(span ("class" "fade") (text "Tags should be separated by a comma.")))
(button
(icon (text "check"))
(str (text "general:action.save"))))
(script
(text "globalThis.save_tags = (e) => {
event.preventDefault();
fetch(\"/api/v1/notes/{{ selected_note }}/tags\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}")))
; init codemirror
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
(script
(text "setTimeout(() => {
(text "setTimeout(async () => {
document.getElementById(\"editor_tab\").innerHTML = \"\";
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML,
mode: \"markdown\",
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
lineWrapping: true,
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
autoCloseBrackets: true,
autofocus: true,
viewportMargin: Number.POSITIVE_INFINITY,
@ -232,7 +427,8 @@
highlightFormatting: false,
fencedCodeBlockHighlighting: false,
xml: false,
smartIndent: false,
smartIndent: true,
indentUnit: 4,
placeholder: `# {{ note.title }}`,
extraKeys: {
Home: \"goLineLeft\",
@ -243,6 +439,15 @@
},
});
editor.on(\"keydown\", (cm, e) => {
if (e.key.length > 1) {
// ignore all keys that aren't a letter
return;
}
CodeMirror.showHint(cm, CodeMirror.hint.css);
});
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
e.preventDefault();
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
@ -262,7 +467,10 @@
})
).text();
document.getElementById(\"preview_tab\").innerHTML = res;
const preview_token = window.crypto.randomUUID();
document.getElementById(\"preview_tab\").innerHTML = `${res}<style>
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
</style>`;
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
});
}, 150);"))
@ -272,7 +480,54 @@
("class" "flex flex-col gap-2 card")
(text "{{ note.content|markdown|safe }}"))
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(div
("class" "flex w-full justify-between gap-2")
(div
("class" "flex flex-col gap-2 fade")
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(text "{% if global_mode -%}")
(span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}"))
(span (text "Views: {{ redis_views }}"))
(text "{% elif note.is_global -%}")
; globsl note, but we aren't viewing globally...
(a
("href" "/x/{{ note.title }}")
("class" "button lowered small green")
(icon (text "globe"))
(text "View as global"))
(text "{%- endif %}")
(text "{{ components::note_tags(note=note) }}"))
(text "{% if user and user.id == owner.id -%}")
(button
("class" "small")
("onclick" "{% if note.is_global -%}
trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}'])
{%- else -%}
{% if journal.privacy == \"Public\" -%}
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- else -%}
prompt_make_public();
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- endif -%} {%- endif %}")
(icon (text "share"))
(str (text "general:label.share")))
(script
(text "globalThis.prompt_make_public = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Would you like to make this journal public? This is required for others to view this note.\",
]))
) {
return;
}
change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
}"))
(text "{%- endif %}"))
(text "{%- endif %}")
(text "{%- endif %}")))
(style
@ -332,7 +587,7 @@
},
body: JSON.stringify({
title,
content: `# ${title}`,
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
journal: \"{{ selected_journal }}\",
}),
})
@ -431,8 +686,8 @@
globalThis.change_journal_privacy = async (e) => {
e.preventDefault();
const selected = event.target.selectedOptions[0];
fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
const selected = e.target.selectedOptions[0];
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
@ -498,6 +753,155 @@
});
}
globalThis.create_directory = async (parent) => {
const name = await trigger(\"atto::prompt\", [\"Directory name:\"]);
if (!name) {
return;
}
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
name,
parent,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
globalThis.delete_directory = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This will delete all notes within this directory.\",
]))
) {
return;
}
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
id,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
});
}
globalThis.move_note_dir = async (id, dir) => {
fetch(`/api/v1/notes/${id}/dir`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
dir,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
globalThis.publish_note = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
`Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private.
Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`,
]))
) {
return;
}
fetch(`/api/v1/notes/${id}/global`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
globalThis.unpublish_note = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This global note name will be made available.\",
]))
) {
return;
}
fetch(`/api/v1/notes/${id}/global`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
// sidebars
window.SIDEBARS_OPEN = false;
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
@ -540,4 +944,24 @@
notes_list.style.left = \"0\";
}
}")))
(text "{% if journal -%}")
; note mover
(dialog
("id" "note_mover_dialog")
(div
("class" "inner flex flex-col gap-2")
(p (text "Select a directory to move this note into:"))
(text "{{ components::note_mover_dirs(dirs=journal.dirs) }}")
(div
("class" "flex justify-between")
(div)
(div
("class" "flex gap-2")
(button
("class" "bold red lowered")
("onclick" "document.getElementById('note_mover_dialog').close()")
("type" "button")
(text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}"))))))
(text "{%- endif %}")
(text "{% endblock %}")

View file

@ -0,0 +1,92 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "My stacks - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}")
(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
("class" "primary")
(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 "; {{ item.files|length }} files")))
(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

@ -48,7 +48,7 @@
(a
("href" "/requests")
("class" "button {% if selected == 'requests' -%}active{%- endif %}")
("title" "Chats")
("title" "Requests")
(icon (text "inbox"))
(span
("class" "notification tr {% if user.request_count <= 0 -%}hidden{%- endif %}")
@ -58,7 +58,7 @@
(a
("href" "/notifs")
("class" "button {% if selected == 'notifications' -%}active{%- endif %}")
("title" "Chats")
("title" "Notifications")
(icon (text "bell"))
(span
("class" "notification tr {% if user.notification_count <= 0 -%}hidden{%- endif %}")
@ -136,7 +136,7 @@
("class" "dropdown")
("style" "width: max-content")
(button
("class" "camo raised small")
("class" "raised small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon (text "sliders-horizontal"))
@ -252,10 +252,17 @@
("class" "pillmenu")
(text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}")
(a
("href" "/@{{ profile.username }}")
("href" "/@{{ profile.username }}?f=true")
("class" "{% if selected == 'posts' -%}active{%- endif %}")
(str (text "auth:label.posts")))
(text "{% if profile.settings.enable_questions -%}")
(a
("href" "/@{{ profile.username }}?r=true")
("class" "{% if selected == 'responses' -%}active{%- endif %}")
(str (text "auth:label.responses")))
(text "{%- endif %}")
(a
("href" "/@{{ profile.username }}/replies")
("class" "{% if selected == 'replies' -%}active{%- endif %}")
@ -311,8 +318,9 @@
(span
(text "{{ text \"settings:tab.theme\" }}")))
(a
("href" "#")
("data-tab-button" "sessions")
("href" "#/sessions")
("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])")
(text "{{ icon \"cookie\" }}")
(span
(text "{{ text \"settings:tab.sessions\" }}")))

View file

@ -0,0 +1,45 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Achievements - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"achievements\") }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "coffee"))
(span (text "Welcome to {{ config.name }}!")))
(div
("class" "card no_p_margin flex flex-col gap-2")
(p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!"))
(p (text "You'll find out what each achievement is when you get it, so look around!"))
(hr)
(span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%"))
(div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(icon (text "award"))
(span (str (text "general:link.achievements")))))
(div
("class" "card lowered flex flex-col gap-4")
(text "{% for achievement in achievements %}")
(div
("class" "w-full card-nest")
(div
("class" "card small flex items-center gap-2 {% if achievement[2] == 'Uncommon' -%} green {%- elif achievement[2] == 'Rare' -%} purple {%- endif %}")
(icon (text "award"))
(text "{{ achievement[0] }}"))
(div
("class" "card flex flex-col gap-2")
(span ("class" "no_p_margin") (text "{{ achievement[1]|markdown|safe }}"))
(hr)
(span ("class" "fade") (text "Unlocked: ") (span ("class" "date") (text "{{ achievement[3].unlocked }}")))))
(text "{% endfor %}"))))
(text "{% endblock %}")

View file

@ -113,21 +113,24 @@
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex flex-wrap w-full gap-2")
(text "{{ components::create_post_options() }}")
("class" "flex w-full justify-between flex-collapse gap-2")
(div
("class" "flex flex-wrap w-full gap-2")
(text "{{ components::create_post_options() }}")
(button
("type" "button")
("class" "red lowered")
("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
(text "{{ text \"general:action.delete\" }}"))
(button
("type" "button")
("class" "red lowered")
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
(text "{{ text \"auth:action.ip_block\" }}")))
(button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))
(button
("type" "button")
("class" "red lowered")
("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
(text "{{ text \"general:action.delete\" }}"))
(button
("type" "button")
("class" "red lowered")
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
(text "{{ text \"auth:action.ip_block\" }}")))))
(text "{{ text \"requests:label.answer\" }}")))))
(text "{% endfor %}")))
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))

View file

@ -68,8 +68,8 @@
(text "Unban"))
(text "{%- endif %}")))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
(text "setTimeout(async () => {
const ui = await ns(\"ui\");
const element = document.getElementById(\"mod_options\");
async function profile_request(do_confirm, path, body) {
@ -168,6 +168,11 @@
\"{{ profile.is_verified }}\",
\"checkbox\",
],
[
[\"awaiting_purchase\", \"Awaiting purchase\"],
\"{{ profile.awaiting_purchase }}\",
\"checkbox\",
],
[
[\"role\", \"Permission level\"],
\"{{ profile.permissions }}\",
@ -181,6 +186,11 @@
is_verified: value,
});
},
awaiting_purchase: (value) => {
profile_request(false, \"awaiting_purchase\", {
awaiting_purchase: value,
});
},
role: (new_role) => {
return update_user_role(new_role);
},
@ -202,6 +212,20 @@
(text "{% for user in associations -%}")
(text "{{ components::user_plate(user=user, show_menu=false) }}")
(text "{%- endfor %}")))
(text "{% if invite -%}")
(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 \"ticket\" }}")
(span
(text "{{ text \"mod_panel:label.invited_by\" }}"))))
(div
("class" "card lowered flex flex-wrap gap-2")
(text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
(div
@ -221,8 +245,8 @@
("class" "card lowered flex flex-col gap-2")
("id" "permission_builder")))
(script
(text "setTimeout(() => {
const get_permissions_html = trigger(
(text "setTimeout(async () => {
const get_permissions_html = await trigger(
\"ui::generate_permissions_ui\",
[
{
@ -256,6 +280,8 @@
MANAGE_STACKS: 1 << 26,
STAFF_BADGE: 1 << 27,
MANAGE_APPS: 1 << 28,
MANAGE_JOURNALS: 1 << 29,
MANAGE_NOTES: 1 << 30,
},
],
);

View file

@ -29,6 +29,15 @@
(b
(text "Socket tasks: "))
(span
(text "{{ (active_users_chats + active_users) * 3 }}")))))))
(text "{{ (active_users_chats + active_users) * 3 }}"))))
(hr)
(ul
(li (b (text "Users: ")) (span (text "{{ table_users }}")))
(li (b (text "IP bans: ")) (span (text "{{ table_ipbans }}")))
(li (b (text "Invite codes: ")) (span (text "{{ table_invite_codes }}")))
(li (b (text "Posts: ")) (span (text "{{ table_posts }}")))
(li (b (text "Uploads: ")) (span (text "{{ table_uploads }}")))
(li (b (text "Communities: ")) (span (text "{{ table_communities }}")))))))
(text "{% endblock %}")

View file

@ -2,6 +2,41 @@
(title
(text "Post - {{ config.name }}"))
(meta
("name" "og:title")
("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
(meta
("name" "description")
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
(meta
("property" "og:type")
("content" "website"))
(meta
("name" "og:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
(meta
("name" "twitter:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
(meta
("name" "twitter:card")
("content" "summary"))
(meta
("name" "twitter:title")
("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
(meta
("name" "twitter:description")
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
@ -110,8 +145,8 @@
(span
(text "{{ text \"general:action.save\" }}")))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
(text "setTimeout(async () => {
const ui = await ns(\"ui\");
const element = document.getElementById(\"post_context\");
const settings = JSON.parse(\"{{ post_context_serde|safe }}\");

View file

@ -132,9 +132,11 @@
(text "{{ profile.settings.biography|markdown|safe }}"))
(div
("class" "card flex flex-col gap-2")
(text "{% if user -%}")
(div
("style" "display: contents;")
(text "{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name -%} {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} {% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name %} {{ components::last_fm_playing(state=profile.connections.LastFm[1]) }} {%- endif %}"))
(text "{%- endif %}")
(div
("class" "w-full flex justify-between items-center")
(span
@ -217,12 +219,24 @@
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.unfollow\" }}")))
(button
("onclick" "toggle_block_user()")
("class" "lowered red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(div
("class" "dropdown")
(button
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")
(button
("onclick" "toggle_block_user()")
(icon (text "shield"))
(str (text "auth:action.block")))
(button
("onclick" "ip_block_user()")
(icon (text "wifi"))
(str (text "auth:action.ip_block")))))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
@ -340,6 +354,30 @@
res.message,
]);
});
};
globalThis.ip_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))))
(text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}")
(div

View file

@ -24,12 +24,24 @@
(div
("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_blocking -%}")
(button
("onclick" "toggle_block_user()")
("class" "lowered red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(div
("class" "dropdown")
(button
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")
(button
("onclick" "toggle_block_user()")
(icon (text "shield"))
(str (text "auth:action.block")))
(button
("onclick" "ip_block_user()")
(icon (text "wifi"))
(str (text "auth:action.ip_block")))))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
@ -58,6 +70,30 @@
res.message,
]);
});
};
globalThis.ip_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{%- endif %}")
(a

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) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
(div

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) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
(div

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) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(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
@ -44,9 +44,12 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
});"))
(text "setTimeout(async () => {
await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&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

@ -47,12 +47,24 @@
(span
(text "{{ text \"auth:action.unfollow\" }}")))
(text "{%- endif %} {% if not is_blocking -%}")
(button
("onclick" "toggle_block_user()")
("class" "lowered red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(div
("class" "dropdown")
(button
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")
(button
("onclick" "toggle_block_user()")
(icon (text "shield"))
(str (text "auth:action.block")))
(button
("onclick" "ip_block_user()")
(icon (text "wifi"))
(str (text "auth:action.ip_block")))))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
@ -151,6 +163,30 @@
res.message,
]);
});
};
globalThis.ip_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{%- endif %}")
(a

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) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div

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

@ -20,7 +20,7 @@
("class" "dropdown")
("style" "width: max-content")
(button
("class" "camo raised small")
("class" "raised small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon (text "sliders-horizontal"))
@ -61,21 +61,35 @@
("href" "#/account/blocks")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"settings:tab.blocks\" }}")))
(text "{{ text \"settings:tab.blocks\" }}"))))
(text "{% if config.stripe -%}")
; stripe menu
(div
("class" "pillmenu")
("ui_ident" "account_settings_tabs")
(a
("data-tab-button" "account/uploads")
("href" "?page=0#/account/uploads")
(text "{{ icon \"image-up\" }}")
(span
(text "{{ text \"settings:tab.uploads\" }}")))
(text "{% if config.stripe -%}")
(text "{% if config.security.enable_invite_codes -%}")
(a
("data-tab-button" "account/invites")
("href" "?page=0#/account/invites")
(text "{{ icon \"ticket\" }}")
(span
(text "{{ text \"settings:tab.invites\" }}")))
(text "{%- endif %}")
(a
("data-tab-button" "account/billing")
("href" "#/account/billing")
(text "{{ icon \"credit-card\" }}")
(span
(text "{{ text \"settings:tab.billing\" }}")))
(text "{%- endif %}"))
(text "{{ text \"settings:tab.billing\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest")
("ui_ident" "home_timeline")
@ -419,12 +433,43 @@
(div
("class" "flex gap-2")
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
(a
("href" "/@{{ user.username }}")
("class" "button lowered small")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}"))))
(div
("class" "flex gap-2")
(a
("href" "/stacks/add_user/{{ user.id }}")
("target" "_blank")
("class" "button lowered small")
(icon (text "plus"))
(span (str (text "settings:label.add_to_stack"))))
(a
("href" "/@{{ user.username }}")
("class" "button lowered small")
(icon (text "external-link"))
(span (str (text "requests:action.view_profile"))))))
(text "{% endfor %}")))
; ip blocks
(div
("class" "card-nest")
(div
("class" "card flex items-center gap-2 small")
(text "{{ icon \"wifi\" }}")
(span
(text "{{ text \"settings:label.ips\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{% for ip in ipblocks %}")
(div
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
(span
(text "Block from: ") (span ("class" "date") (text "{{ ip.created }}")))
(div
("class" "flex gap-2")
(button
("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])")
("class" "lowered small red")
(icon (text "x"))
(span (str (text "auth:action.unblock"))))))
(text "{% endfor %}")))))
(div
("class" "w-full flex flex-col gap-2 hidden")
@ -495,6 +540,88 @@
]);
});
};"))))))
(text "{% if config.security.enable_invite_codes -%}")
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/invites")
(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 \"ticket\" }}")
(span
(text "{{ text \"settings:tab.invites\" }}")))
(div
("class" "card flex flex-col gap-2 secondary")
(pre ("id" "invite_codes_output") ("class" "hidden") (code))
(pre ("id" "invite_codes_error_output") ("class" "hidden") (code ("class" "red")))
(button
("onclick" "generate_invite_codes()")
(icon (text "plus"))
(str (text "settings:label.generate_invites")))
(text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
(div
("class" "card flex flex-col gap-2")
(text "{% if code[1].is_used -%}")
; used
(b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}")))
(text "{{ components::full_username(user=code[0]) }}")
(text "{% else %}")
; unused
(b (text "{{ code[1].code }}"))
(text "{%- endif %}"))
(text "{% endfor %}")
(text "{{ components::pagination(page=page, items=invites|length, key=\"#/account/invites\") }}")
(script
(text "globalThis.generate_invite_codes = async () => {
await trigger(\"atto::debounce\", [\"invites::create\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\",
]))
) {
return;
}
const count = Number.parseInt(await trigger(\"atto::prompt\", [\"Count (1-48):\"]));
if (!count) {
return;
}
document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\");
document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working... expect to wait 50ms per invite code\";
fetch(`/api/v1/invites/${count}`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload[0];
document.getElementById(\"invite_codes_error_output\").children[0].innerText = res.payload[1];
}
});
};"))))))
(text "{%- endif %}")
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/billing")
@ -544,64 +671,31 @@
("target" "_blank")
(text "Manage billing"))
(text "{% else %}")
(p
(text "You're ")
(b
(text "not "))
(text "currently a supporter! No
pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:"))
(ul
("style" "margin-bottom: var(--pad-4)")
(li
(text "Vanity badge on profile"))
(li
(text "No more supporter ads (duh)"))
(li
(text "Ability to upload gif avatars/banners"))
(li
(text "Be an admin/owner of up to 10 communities"))
(li
(text "Use custom CSS on your profile"))
(li
(text "Ability to use community emojis outside of
their community"))
(li
(text "Ability to upload and use gif emojis"))
(li
(text "Create infinite stack timelines"))
(li
(text "Ability to 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 "Ability to 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")))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
("target" "_blank")
(text "Become a supporter"))
(span
("class" "fade")
(text "Please use your")
(b
(text "real email"))
(text "when
completing payment. It is required to manage
your billing settings."))
(text "{{ components::become_supporter_button() }}")
(text "{%- endif %}")))
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
(form
("class" "card w-full lowered flex flex-col gap-2")
("onsubmit" "update_invite_code(event)")
(p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling."))
(div
("class" "flex flex-col gap-1")
(label
("for" "invite_code")
(b
(text "Invite code")))
(input
("type" "text")
("placeholder" "invite code")
("name" "invite_code")
("required" "")
("id" "invite_code")))
(button
(text "Submit")))
(text "{%- endif %}")
(text "{%- endif %}")))))
(div
("class" "w-full hidden flex flex-col gap-2")
@ -663,7 +757,29 @@
(text "{{ icon \"check\" }}")))
(span
("class" "fade")
(text "Use an image of 1100x350px for the best results.")))))
(text "Use an image of 1100x350px for the best results."))))
(div
("class" "card-nest")
("ui_ident" "default_profile_page")
(div
("class" "card small")
(b
(text "Default profile tab")))
(div
("class" "card")
(select
("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)")
(option
("value" "Posts")
("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}")
(text "Posts"))
(option
("value" "Responses")
("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}")
(text "Responses")))
(span
("class" "fade")
(text "This represents the timeline that is shown on your profile by default.")))))
(button
("onclick" "save_settings()")
("id" "save_button")
@ -925,8 +1041,8 @@
("id" "settings_json")
(text "{{ profile.settings|json_encode()|remove_script_tags|safe }}"))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
(text "setTimeout(async () => {
const ui = await ns(\"ui\");
const settings = JSON.parse(
document.getElementById(\"settings_json\").innerHTML,
);
@ -1062,6 +1178,11 @@
globalThis.delete_account = async (e) => {
e.preventDefault();
// {% if user.permissions|has_supporter %}
alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\");
return;
// {% endif %}
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
@ -1245,6 +1366,31 @@
});
};
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();
}
});
}
const account_settings =
document.getElementById(\"account_settings\");
const profile_settings =
@ -1263,6 +1409,7 @@
\"supporter_ad\",
\"change_avatar\",
\"change_banner\",
\"default_profile_page\",
]);
ui.refresh_container(theme_settings, [
\"supporter_ad\",
@ -1295,6 +1442,22 @@
embed_html:
'<span class=\"fade\">Muted phrases should all be on new lines.</span>',
}],
[[], \"Accessibility\", \"title\"],
[
[\"large_text\", \"Increase UI text size\"],
\"{{ profile.settings.large_text }}\",
\"checkbox\",
],
[
[\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
\"{{ profile.settings.paged_timelines }}\",
\"checkbox\",
],
[
[\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"],
\"{{ profile.settings.auto_clear_notifs }}\",
\"checkbox\",
],
],
settings,
{
@ -1357,6 +1520,24 @@
\"{{ profile.settings.show_nsfw }}\",
\"checkbox\",
],
[
[\"auto_unlist\", \"Automatically mark my posts as NSFW\"],
\"{{ profile.settings.auto_unlist }}\",
\"checkbox\",
],
[
[\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
\"{{ profile.settings.all_timeline_hide_answers }}\",
\"checkbox\",
],
[
[
\"hide_associated_blocked_users\",
\"Hide users that you've blocked on your other accounts from timelines\",
],
\"{{ profile.settings.hide_associated_blocked_users }}\",
\"checkbox\",
],
[[], \"Questions\", \"title\"],
[
[
@ -1374,6 +1555,14 @@
\"{{ profile.settings.allow_anonymous_questions }}\",
\"checkbox\",
],
[
[
\"enable_drawings\",
\"Allow users to create drawings and submit them with questions\",
],
\"{{ profile.settings.enable_drawings }}\",
\"checkbox\",
],
[
[\"motivational_header\", \"Motivational header\"],
settings.motivational_header,
@ -1407,6 +1596,11 @@
\"{{ profile.settings.disable_gpa_fun }}\",
\"checkbox\",
],
[
[\"disable_achievements\", \"Disable achievements\"],
\"{{ profile.settings.disable_achievements }}\",
\"checkbox\",
],
],
settings,
);
@ -1583,6 +1777,35 @@
description: \"Hover state for secondary buttons.\",
},
],
// online indicator
[[], \"\", \"divider\"],
[
[\"theme_color_online\", \"Online indicator (online)\"],
\"{{ profile.settings.theme_color_online }}\",
\"color\",
{
description:
\"The green dot next to the name of online users.\",
},
],
[
[\"theme_color_idle\", \"Online indicator (idle)\"],
\"{{ profile.settings.theme_color_idle }}\",
\"color\",
{
description:
\"The yellow dot next to the name of online users.\",
},
],
[
[\"theme_color_offline\", \"Online indicator (offline)\"],
\"{{ profile.settings.theme_color_offline }}\",
\"color\",
{
description:
\"The grey next to the name of online users.\",
},
],
];
if (can_use_custom_css) {

View file

@ -21,10 +21,9 @@
{%- endif %}")
(text "<script>
globalThis.ns_verbose = false;
globalThis.ns_config = {
root: \"/js/\",
verbose: globalThis.ns_verbose,
verbose: false,
version: \"tetratto-{{ random_cache_breaker }}\",
};
@ -73,7 +72,73 @@
(str (text "general:label.account_banned_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 "{% else %}")
; page body
(text "{% block body %}{% endblock %}")
(text "{%- endif %}")
(text "<!-- html_footer_goes_here -->"))
(text "{% include \"body.html\" %}")))

View file

@ -0,0 +1,49 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Add user to stack - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ components::avatar(username=add_user.username, size=\"24px\") }}")
(text "{{ components::full_username(user=add_user) }}"))
(div
("class" "card flex flex-col gap-2")
(span (text "Select a stack to add this user to:"))
(text "{% for stack in stacks %}")
(button
("class" "justify-start lowered w-full")
("onclick" "choose_stack('{{ stack.id }}')")
(icon (text "layers"))
(text "{{ stack.name }}"))
(text "{% endfor %}"))))
(script
(text "function choose_stack(id) {
fetch(`/api/v1/stacks/${id}/users`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
username: \"{{ add_user.username }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.close();
}
});
}"))
(text "{% endblock %}")

View file

@ -83,9 +83,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker")))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{%- endif %}"))))

View file

@ -173,7 +173,7 @@
return;
}
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
fetch(\"/api/v1/stacks/{{ stack.id }}/users\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",

View file

@ -33,9 +33,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -31,9 +31,10 @@
(div ("ui_ident" "io_data_marker")))
(text "{%- endif %}"))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -53,7 +53,7 @@
("title" "Search help")
("href" "{{ config.manuals.search_help }}")
("target" "_blank")
(text "{{ icon \"circle-help\" }}"))
(text "{{ icon \"circle-question-mark\" }}"))
(text "{%- endif %}"))))
(text "{%- endif %}")
(text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}"))))

View file

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

View file

@ -39,6 +39,7 @@ media_theme_pref();
// init
use("me", () => {});
use("streams", () => {});
use("carp", () => {});
// env
self.DEBOUNCE = [];
@ -116,7 +117,7 @@ media_theme_pref();
);
});
self.define("clean_date_codes", ({ $ }) => {
self.define("clean_date_codes", async ({ $ }) => {
for (const element of Array.from(document.querySelectorAll(".date"))) {
if (element.getAttribute("data-unix")) {
// this allows us to run the function twice on the same page
@ -133,7 +134,7 @@ media_theme_pref();
element.setAttribute("title", then.toLocaleString());
let pretty = $.rel_date(then) || "";
let pretty = (await $.rel_date(then)) || "";
if (
(screen.width < 900 && pretty !== undefined) |
@ -162,7 +163,7 @@ media_theme_pref();
}
});
self.define("clean_poll_date_codes", ({ $ }) => {
self.define("clean_poll_date_codes", async ({ $ }) => {
for (const element of Array.from(
document.querySelectorAll(".poll_date"),
)) {
@ -182,7 +183,7 @@ media_theme_pref();
element.setAttribute("title", then.toLocaleString());
const pretty =
$.rel_date(then)
(await $.rel_date(then))
.replaceAll(" minutes ago", "m")
.replaceAll(" minute ago", "m")
.replaceAll(" hours ago", "h")
@ -287,7 +288,7 @@ media_theme_pref();
const goals = [150, 250, 500, 1000];
track_element.setAttribute("data-scroll", "0");
scroll_element.addEventListener("scroll", (e) => {
scroll_element.addEventListener("scroll", (_) => {
track_element.setAttribute("data-scroll", scroll_element.scrollTop);
for (const goal of goals) {
@ -408,9 +409,13 @@ media_theme_pref();
}
});
self.define("hooks::long", (_, element, full_text) => {
self.define("hooks::long", ({ $ }, element, full_text) => {
element.classList.remove("hook:long.hidden_text");
element.innerHTML = full_text;
$.clean_date_codes();
$.clean_poll_date_codes();
$.link_filter();
});
self.define("hooks::long_text.init", (_) => {
@ -453,18 +458,18 @@ media_theme_pref();
}
});
self.define("hooks::spotify_time_text", (_) => {
self.define("hooks::spotify_time_text", async (_) => {
for (const element of Array.from(
document.querySelectorAll("[hook=spotify_time_text]") || [],
)) {
function render() {
async function render() {
const updated = element.getAttribute("hook-arg:updated");
const progress = element.getAttribute("hook-arg:progress");
const duration = element.getAttribute("hook-arg:duration");
const display =
element.getAttribute("hook-arg:display") || "full";
element.innerHTML = trigger("spotify::timestamp", [
element.innerHTML = await trigger("spotify::timestamp", [
updated,
progress,
duration,
@ -472,7 +477,7 @@ media_theme_pref();
]);
}
setInterval(() => {
setInterval(async () => {
element.setAttribute(
"hook-arg:updated",
Number.parseInt(element.getAttribute("hook-arg:updated")) +
@ -485,10 +490,10 @@ media_theme_pref();
1000,
);
render();
await render();
}, 1000);
render();
await render();
}
});
@ -504,7 +509,7 @@ media_theme_pref();
return now - last_seen <= maximum_time_to_be_considered_idle;
});
self.define("hooks::online_indicator", ({ $ }) => {
self.define("hooks::online_indicator", async ({ $ }) => {
for (const element of Array.from(
document.querySelectorAll("[hook=online_indicator]") || [],
)) {
@ -512,8 +517,8 @@ media_theme_pref();
element.getAttribute("hook-arg:last_seen"),
);
const is_online = $.last_seen_just_now(last_seen);
const is_idle = $.last_seen_recently(last_seen);
const is_online = await $.last_seen_just_now(last_seen);
const is_idle = await $.last_seen_recently(last_seen);
const offline = element.querySelector("[hook_ui_ident=offline]");
const online = element.querySelector("[hook_ui_ident=online]");
@ -618,7 +623,6 @@ media_theme_pref();
.catch(() => {
// done scrolling, no more pages (http error)
wrapper.removeEventListener("scroll", event);
resolve();
});
}
@ -635,7 +639,6 @@ media_theme_pref();
return;
}
// biome-ignore lint/style/noParameterAssign: no it isn't
page += 1;
await load_partial();
})
@ -651,7 +654,7 @@ media_theme_pref();
);
self.define("hooks::check_reactions", async ({ $ }) => {
const observer = $.offload_work_to_client_when_in_view(
const observer = await $.offload_work_to_client_when_in_view(
async (element) => {
const like = element.querySelector(
'[hook_element="reaction.like"]',
@ -687,6 +690,36 @@ media_theme_pref();
$.OBSERVERS.push(observer);
});
self.define("hooks::check_message_reactions", async ({ $ }) => {
const observer = $.offload_work_to_client_when_in_view(
async (element) => {
const reactions = await (
await fetch(
`/api/v1/message_reactions/${element.getAttribute("hook-arg:id")}`,
)
).json();
if (reactions.ok) {
for (const reaction of reactions.payload) {
element
.querySelector(
`[ui_ident=emoji_${reaction.emoji.replaceAll(":", "\\:")}]`,
)
.classList.remove("lowered");
}
}
},
);
for (const element of Array.from(
document.querySelectorAll("[hook=check_message_reactions]") || [],
)) {
observer.observe(element);
}
$.OBSERVERS.push(observer);
});
self.define("hooks::tabs:switch", (_, tab) => {
tab = tab.split("?")[0];
@ -807,7 +840,6 @@ media_theme_pref();
}, time_until_remove * 1000);
const count_interval = setInterval(() => {
// biome-ignore lint/style/noParameterAssign: no it isn't
time_until_remove -= 1;
timer.innerText = time_until_remove;
}, 1000);
@ -850,7 +882,7 @@ media_theme_pref();
})();
// ui ns
(() => {
(async () => {
const self = reg_ns("ui");
window.SETTING_SET_FUNCTIONS = [];
@ -888,19 +920,19 @@ media_theme_pref();
}
if (option.input_element_type === "checkbox") {
into_element.innerHTML += `<div class="card flex gap-2">
<input
type="checkbox"
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
placeholder="${option.key}"
name="${option.key}"
id="${option.key}"
${option.value === "true" ? "checked" : ""}
class="w-content"
/>
into_element.innerHTML += `<div class="card flex items-center gap-2">
<input
type="checkbox"
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
placeholder="${option.key}"
name="${option.key}"
id="${option.key}"
${option.value === "true" ? "checked" : ""}
class="w-content"
/>
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
</div>`;
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
</div>`;
return;
}
@ -1123,51 +1155,99 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
});
// intersection observer infinite scrolling
self.IO_DATA_OBSERVER = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
self.IO_DATA_OBSERVER = null;
self.define(
"io_data_load",
async (_, tmpl, page, paginated_mode = false) => {
// remove old
const obs = self.IO_DATA_OBSERVER;
if (obs) {
console.log("get lost old observer");
obs.disconnect();
self.IO_DATA_OBSERVER = null;
}
self.IO_DATA_OBSERVER = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
await self.io_load_data();
break;
}
},
{
root: document.body,
rootMargin: "0px",
threshold: 1,
},
);
// ...
self.IO_DATA_MARKER = document.querySelector(
"[ui_ident=io_data_marker]",
);
self.IO_DATA_ELEMENT = document.querySelector(
"[ui_ident=io_data_load]",
);
self.IO_HTML_TMPL = document.getElementById("loading_skeleton");
if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) {
console.warn(
"ui::io_data_load called, but required elements don't exist",
);
return;
}
self.IO_DATA_TMPL = tmpl;
self.IO_DATA_PAGE = page;
self.IO_DATA_SEEN_IDS = [];
self.IO_DATA_WAITING = false;
self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
self.IO_DATA_DISCONNECTED = false;
self.IO_DATA_DISABLE_RELOAD = false;
self.IO_DATA_LOAD_BEFORE = 0;
if (!paginated_mode) {
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
} else {
// immediately load first page
self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
self.IO_DATA_TMPL += `&paginated=true&page=`;
self.io_load_data();
}
setTimeout(() => {
if (self.IO_DATA_DISABLE_RELOAD) {
console.log("missing data reload disabled");
return;
}
await self.io_load_data();
break;
}
},
{
root: document.body,
rootMargin: "0px",
threshold: 1,
if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
// reload
self.IO_DATA_OBSERVER.disconnect();
console.log("timeline load fail :(");
window.location.reload();
}
}, 1500);
self.IO_PAGINATED = paginated_mode;
},
);
self.define("io_data_load", (_, tmpl, page) => {
self.IO_DATA_MARKER = document.querySelector(
"[ui_ident=io_data_marker]",
);
self.IO_DATA_ELEMENT = document.querySelector(
"[ui_ident=io_data_load]",
);
self.IO_HTML_TMPL = document.getElementById("loading_skeleton");
if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) {
console.warn(
"ui::io_data_load called, but required elements don't exist",
);
self.define("io_load_data", async () => {
if (self.IO_DATA_WAITING) {
return;
}
self.IO_DATA_TMPL = tmpl;
self.IO_DATA_PAGE = page;
self.IO_DATA_SEEN_IDS = [];
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
});
self.define("io_load_data", async () => {
self.IO_HAS_LOADED_AT_LEAST_ONCE = true;
self.IO_DATA_WAITING = true;
self.IO_DATA_PAGE += 1;
console.log("load page", self.IO_DATA_PAGE);
@ -1177,17 +1257,43 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
// ...
const text = await (
await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
await fetch(
`${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`,
)
).text();
self.IO_DATA_ELEMENT.querySelector("[ui_ident=loading_skel]").remove();
self.IO_DATA_WAITING = false;
const loading_skel = self.IO_DATA_ELEMENT.querySelector(
"[ui_ident=loading_skel]",
);
if (loading_skel) {
loading_skel.remove();
}
if (self.IO_DATA_DISCONNECTED) {
return;
}
if (
text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
text.includes(
`!<!-- observer_disconnect_${window.BUILD_CODE} -->`,
) ||
document.documentElement.innerHTML.includes("observer_disconnect")
) {
console.log("io_data_end; disconnect");
self.IO_DATA_OBSERVER.disconnect();
self.IO_DATA_ELEMENT.innerHTML += text;
if (
!document.documentElement.innerHTML.includes(
"observer_disconnect",
)
) {
self.IO_DATA_ELEMENT.innerHTML += text;
}
self.IO_DATA_DISCONNECTED = true;
return;
}
@ -1199,30 +1305,6 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
self.IO_DATA_ELEMENT.children.length - 1
].after(self.IO_DATA_MARKER);
// remove posts we've already seen
function remove_elements(id, outer = false) {
let idx = 0;
for (const element of Array.from(
document.querySelectorAll(
`.post${outer ? "_outer" : ""}\\:${id}`,
),
)) {
if (idx === 0) {
idx += 1;
continue;
}
// everything that isn't the first element should be removed
element.remove();
console.log("removed duplicate post");
}
}
for (const id of self.IO_DATA_SEEN_IDS) {
remove_elements(id, false);
remove_elements(id, true); // scoop up questions
}
// push ids
for (const opt of Array.from(
document.querySelectorAll(
@ -1234,11 +1316,13 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
if (!self.IO_DATA_SEEN_IDS[v]) {
self.IO_DATA_SEEN_IDS.push(v);
}
self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created");
}
}, 150);
// run hooks
const atto = ns("atto");
const atto = await ns("atto");
atto.clean_date_codes();
atto.clean_poll_date_codes();
@ -1290,7 +1374,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
JSON.stringify(accepted_warnings),
);
setTimeout(() => {
setTimeout(async () => {
await trigger("me::achievement", ["AcceptProfileWarning"]);
window.history.back();
}, 100);
});

View file

@ -0,0 +1,624 @@
(() => {
const self = reg_ns("carp");
const END_OF_HEADER = 0x1a;
const COLOR = 0x1b;
const SIZE = 0x2b;
const LINE = 0x3b;
const POINT = 0x4b;
const EOF = 0x1f;
function enc(s, as = "guess") {
if ((as === "guess" && typeof s === "number") || as === "u32") {
// encode u32
const view = new DataView(new ArrayBuffer(16));
view.setUint32(0, s);
return new Uint8Array(view.buffer).slice(0, 4);
}
if (as === "u16") {
// encode u16
const view = new DataView(new ArrayBuffer(16));
view.setUint16(0, s);
return new Uint8Array(view.buffer).slice(0, 2);
}
// encode string
const encoder = new TextEncoder();
return encoder.encode(s);
}
function dec(as, from) {
if (as === "u32") {
// decode u32
const view = new DataView(new Uint8Array(from).buffer);
return view.getUint32(0);
}
if (as === "u16") {
// decode u16
const view = new DataView(new Uint8Array(from).buffer);
return view.getUint16(0);
}
// decode string
const decoder = new TextDecoder();
return decoder.decode(from);
}
function lpad(size, input) {
if (input.length === size) {
return input;
}
for (let i = 0; i < size - (input.length - 1); i++) {
input = [0, ...input];
}
return input;
}
self.enc = enc;
self.dec = dec;
self.lpad = lpad;
self.CARPS = {};
self.define("new", function ({ $ }, bind_to, read_only = false) {
const canvas = new CarpCanvas(bind_to, read_only);
$.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
return canvas;
});
class CarpCanvas {
#element; // HTMLElement
#ctx; // CanvasRenderingContext2D
#pos = { x: 0, y: 0 }; // Vec2
STROKE_SIZE = 2;
#stroke_size_old = 2;
COLOR = "#000000";
#color_old = "#000000";
COMMANDS = [];
HISTORY = [];
HISTORY_IDX = 0;
#cmd_store = [];
#undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
onedit;
read_only;
/// Create a new [`CarpCanvas`]
constructor(element, read_only) {
this.#element = element;
this.read_only = read_only;
}
/// Push #line_store to LINES
push_state() {
this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
this.#cmd_store = [];
this.HISTORY.push(this.COMMANDS);
this.HISTORY_IDX += 1;
if (this.#undo_clear_future) {
this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
this.#undo_clear_future = false;
}
if (this.onedit) {
this.onedit(this.as_string());
}
}
/// Read current position in history and draw it.
draw_from_history() {
this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
const bytes = this.as_carp2();
this.from_bytes(bytes); // draw
}
/// Undo changes.
undo() {
if (this.HISTORY_IDX === 0) {
// cannot undo
return;
}
this.HISTORY_IDX -= 1;
this.draw_from_history();
this.#undo_clear_future = false;
}
/// Redo changes.
redo() {
if (this.HISTORY_IDX === this.HISTORY.length - 1) {
// cannot redo
return;
}
this.HISTORY_IDX += 1;
this.draw_from_history();
}
/// Create canvas and init context
async create_canvas() {
const canvas = document.createElement("canvas");
canvas.width = "300";
canvas.height = "200";
this.#ctx = canvas.getContext("2d");
if (!this.read_only) {
// desktop
canvas.addEventListener(
"mousemove",
(e) => {
this.draw_event(e);
},
false,
);
canvas.addEventListener(
"mouseup",
(e) => {
this.push_state();
},
false,
);
canvas.addEventListener(
"mousedown",
(e) => {
this.#cmd_store.push({
type: "Line",
data: [],
});
this.move_event(e);
},
false,
);
canvas.addEventListener(
"mouseenter",
(e) => {
this.move_event(e);
},
false,
);
// mobile
canvas.addEventListener(
"touchmove",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.draw_event(e, true);
},
false,
);
canvas.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.#cmd_store.push({
type: "Line",
data: [],
});
this.move_event(e);
},
false,
);
canvas.addEventListener(
"touchend",
(e) => {
e.preventDefault();
e.clientX = e.changedTouches[0].clientX;
e.clientY = e.changedTouches[0].clientY;
this.push_state();
this.move_event(e);
},
false,
);
// add controls
const controls_tmpl = document
.getElementById("carp_canvas")
.content.cloneNode(true);
this.#element.appendChild(controls_tmpl);
const canvas_loc = this.#element.querySelector(
"[ui_ident=canvas_loc]",
);
canvas_loc.appendChild(canvas);
const color_picker = this.#element.querySelector(
"[ui_ident=color_picker]",
);
color_picker.addEventListener("change", (e) => {
this.set_old_color(this.COLOR);
this.COLOR = e.target.value;
});
const stroke_range = this.#element.querySelector(
"[ui_ident=stroke_range]",
);
stroke_range.addEventListener("change", (e) => {
this.set_old_stroke_size(this.STROKE_SIZE);
this.STROKE_SIZE = e.target.value;
});
const undo = this.#element.querySelector("[ui_ident=undo]");
undo.addEventListener("click", () => {
this.undo();
});
const redo = this.#element.querySelector("[ui_ident=redo]");
redo.addEventListener("click", () => {
this.redo();
});
}
}
/// Resize the canvas
resize(size) {
this.#ctx.canvas.width = size.x;
this.#ctx.canvas.height = size.y;
}
/// Clear the canvas
clear() {
const canvas = this.#ctx.canvas;
this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
}
/// Set the old color
set_old_color(value) {
this.#color_old = value;
}
/// Set the old stroke_size
set_old_stroke_size(value) {
this.#stroke_size_old = value;
}
/// Update position (from event)
move_event(e) {
const rect = this.#ctx.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.move({ x, y });
}
/// Update position
move(pos) {
this.#pos.x = pos.x;
this.#pos.y = pos.y;
}
/// Draw on the canvas (from event)
draw_event(e, mobile = false) {
if (e.buttons !== 1 && mobile === false) return;
const rect = this.#ctx.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.draw({ x, y });
}
/// Draw on the canvas
draw(pos, skip_line_store = false) {
this.#ctx.beginPath();
this.#ctx.lineWidth = this.STROKE_SIZE;
this.#ctx.strokeStyle = this.COLOR;
this.#ctx.lineCap = "round";
this.#ctx.moveTo(this.#pos.x, this.#pos.y);
this.move(pos);
this.#ctx.lineTo(this.#pos.x, this.#pos.y);
if (!skip_line_store) {
// yes flooring the values will make the image SLIGHTLY different,
// but it also saves THOUSANDS of characters
const point = [
Math.floor(this.#pos.x),
Math.floor(this.#pos.y),
];
if (this.#color_old !== this.COLOR) {
this.#cmd_store.push({
type: "Color",
data: enc(this.COLOR.replace("#", "")),
});
}
if (this.#stroke_size_old !== this.STROKE_SIZE) {
this.#cmd_store.push({
type: "Size",
data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
});
}
this.#cmd_store.push({
type: "Point",
data: [
// u32
...lpad(4, enc(point[0])),
...lpad(4, enc(point[1])),
],
});
if (this.#color_old !== this.COLOR) {
// we've already seen it once, time to update it
this.set_old_color(this.COLOR);
}
if (this.#stroke_size_old !== this.STROKE_SIZE) {
this.set_old_stroke_size(this.STROKE_SIZE);
}
}
this.#ctx.stroke();
}
/// Create blob and get URL
as_blob() {
const blob = this.#ctx.canvas.toBlob();
return URL.createObjectURL(blob);
}
/// Create Carp2 representation of the graph
as_carp2() {
// most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
const header = [
...enc("CG"),
...enc("02"),
...lpad(4, enc(this.#ctx.canvas.width)),
...lpad(4, enc(this.#ctx.canvas.height)),
END_OF_HEADER,
];
// build commands
const commands = [];
commands.push(COLOR);
commands.push(...enc("000000"));
commands.push(SIZE);
commands.push(...lpad(4, enc(2)).slice(2));
for (const command of this.COMMANDS) {
// this is `impl Into<Vec<u8>> for Command`
switch (command.type) {
case "Point":
commands.push(POINT);
break;
case "Line":
commands.push(LINE);
break;
case "Color":
commands.push(COLOR);
break;
case "Size":
commands.push(SIZE);
break;
}
commands.push(...command.data);
}
// this is so fucking stupid the fact that arraybuffers send as a fucking
// concatenated string of the NUMBERS of the bytes is so stupid this is
// actually crazy what the fuck is this shit
//
// didn't expect i'd have to do this shit myself considering it's done
// for you with File prototypes from a file input
const bin = [...header, ...commands, EOF];
let bin_str = "";
for (const byte of bin) {
bin_str += String.fromCharCode(byte);
}
// return
return bin;
}
/// Export lines as string
as_string() {
return JSON.stringify(this.COMMANDS);
}
/// From an array of bytes
from_bytes(input) {
this.clear();
let idx = -1;
function next() {
idx += 1;
return [idx, input[idx]];
}
function select_bytes(count) {
// select_bytes! macro
const data = [];
let seen_bytes = 0;
let [_, byte] = next();
while (byte !== undefined) {
seen_bytes += 1;
data.push(byte);
if (seen_bytes === count) {
break;
}
[_, byte] = next();
}
return data;
}
// everything past this is just a reverse implementation of carp2.rs in js
const commands = [];
const dimensions = { x: 0, y: 0 };
let in_header = true;
let seen_point = false;
let byte_buffer = [];
let [i, byte] = next();
while (byte !== undefined) {
switch (byte) {
case END_OF_HEADER:
in_header = false;
break;
case COLOR:
{
const data = select_bytes(6);
commands.push({
type: "Color",
data,
});
this.COLOR = `#${dec("string", new Uint8Array(data))}`;
}
break;
case SIZE:
{
const data = select_bytes(2);
commands.push({
type: "Size",
data,
});
this.STROKE_SIZE = dec("u16", data);
}
break;
case POINT:
{
const data = select_bytes(8);
commands.push({
type: "Point",
data,
});
const point = {
x: dec("u32", data.slice(0, 4)),
y: dec("u32", data.slice(4, 8)),
};
if (!seen_point) {
// this is the FIRST POINT that has been seen...
// we need to start drawing from here to avoid a line
// from 0,0 to the point
this.move(point);
seen_point = true;
}
this.draw(point, true);
}
break;
case LINE:
// each line starts at a new place (probably)
seen_point = false;
break;
case EOF:
break;
default:
if (in_header) {
if (0 <= i < 2) {
// tag
} else if (2 <= i < 4) {
//version
} else if (4 <= i < 8) {
// width
byte_buffer.push(byte);
if (i === 7) {
dimensions.x = dec("u32", byte_buffer);
byte_buffer = [];
}
} else if (8 <= i < 12) {
// height
byte_buffer.push(byte);
if (i === 7) {
dimensions.y = dec("u32", byte_buffer);
byte_buffer = [];
this.resize(dimensions); // update canvas
}
}
} else {
// misc byte
console.log(`extraneous byte at ${i}`);
}
break;
}
// ...
[i, byte] = next();
}
return commands;
}
/// Download image as `.carpgraph`
download() {
const blob = new Blob([new Uint8Array(this.as_carp2())], {
type: "image/carpgraph",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.setAttribute("download", "image.carpgraph");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/// Download image as `.carpgraph1`
download_json() {
const string = this.as_string();
const blob = new Blob([string], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.setAttribute("download", "image.carpgraph_json");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
}
})();

View file

@ -0,0 +1,762 @@
/// Copy all the fields from one object to another.
function copy_fields(from, to) {
for (const field of Object.entries(from)) {
to[field[0]] = field[1];
}
return to;
}
/// Simple template components.
const COMPONENT_TEMPLATES = {
EMPTY_COMPONENT: { component: "empty", options: {}, children: [] },
FLEX_DEFAULT: {
component: "flex",
options: {
direction: "row",
gap: "2",
},
children: [],
},
FLEX_SIMPLE_ROW: {
component: "flex",
options: {
direction: "row",
gap: "2",
width: "full",
},
children: [],
},
FLEX_SIMPLE_COL: {
component: "flex",
options: {
direction: "col",
gap: "2",
width: "full",
},
children: [],
},
FLEX_MOBILE_COL: {
component: "flex",
options: {
collapse: "yes",
gap: "2",
width: "full",
},
children: [],
},
MARKDOWN_DEFAULT: {
component: "markdown",
options: {
text: "Hello, world!",
},
},
MARKDOWN_CARD: {
component: "markdown",
options: {
class: "card w-full",
text: "Hello, world!",
},
},
};
/// All available components with their label and JSON representation.
const COMPONENTS = [
[
"Markdown block",
COMPONENT_TEMPLATES.MARKDOWN_DEFAULT,
[["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]],
],
[
"Flex container",
COMPONENT_TEMPLATES.FLEX_DEFAULT,
[
["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW],
["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL],
["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL],
],
],
[
"Profile tabs",
{
component: "tabs",
},
],
[
"Profile feeds",
{
component: "feed",
},
],
[
"Profile banner",
{
component: "banner",
},
],
[
"Question box",
{
component: "ask",
},
],
[
"Name & avatar",
{
component: "name",
},
],
[
"About section",
{
component: "about",
},
],
[
"Action buttons",
{
component: "actions",
},
],
[
"CSS stylesheet",
{
component: "style",
options: {
data: "",
},
},
],
];
// preload icons
trigger("app::icon", ["shapes"]);
trigger("app::icon", ["type"]);
trigger("app::icon", ["plus"]);
trigger("app::icon", ["move-up"]);
trigger("app::icon", ["move-down"]);
trigger("app::icon", ["trash"]);
trigger("app::icon", ["arrow-left"]);
trigger("app::icon", ["x"]);
/// The location of an element as represented by array indexes.
class ElementPointer {
position = [];
constructor(element) {
if (element) {
const pos = [];
let target = element;
while (target.parentElement) {
const parent = target.parentElement;
// push index
pos.push(Array.from(parent.children).indexOf(target) || 0);
// update target
if (parent.id === "editor") {
break;
}
target = parent;
}
this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse
} else {
this.position = [];
}
}
get() {
return this.position;
}
resolve(json, minus = 0) {
let out = json;
if (this.position.length === 1) {
// this is the first element (this.position === [0])
return out;
}
const pos = this.position.slice(1, this.position.length); // the first one refers to the root element
for (let i = 0; i < minus; i++) {
pos.pop();
}
for (const idx of pos) {
const child = ((out || { children: [] }).children || [])[idx];
if (!child) {
break;
}
out = child;
}
return out;
}
}
/// The layout editor controller.
class LayoutEditor {
element;
json;
tree = "";
current = { component: "empty" };
pointer = new ElementPointer();
/// Create a new [`LayoutEditor`].
constructor(element, json) {
this.element = element;
this.json = json;
if (this.json.json) {
delete this.json.json;
}
element.addEventListener("click", (e) => this.click(e, this));
element.addEventListener("mouseover", (e) => {
e.stopImmediatePropagation();
const ptr = new ElementPointer(e.target);
if (document.getElementById("position")) {
document.getElementById(
"position",
).parentElement.style.display = "flex";
document.getElementById("position").innerText = ptr
.get()
.join(".");
}
});
this.render();
}
/// Render layout.
render() {
fetch("/api/v0/auth/render_layout", {
method: "POST",
body: JSON.stringify({
layout: this.json,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((r) => r.json())
.then((r) => {
this.element.innerHTML = r.block;
this.tree = r.tree;
if (this.json.component !== "empty") {
// remove all "empty" components (if the root component isn't an empty)
for (const element of document.querySelectorAll(
'[data-component-name="empty"]',
)) {
element.remove();
}
}
});
}
/// Editor clicked.
click(e, self) {
e.stopImmediatePropagation();
trigger("app::hooks::dropdown.close");
const ptr = new ElementPointer(e.target);
self.current = ptr.resolve(self.json);
self.pointer = ptr;
if (document.getElementById("current_position")) {
document.getElementById(
"current_position",
).parentElement.style.display = "flex";
document.getElementById("current_position").innerText = ptr
.get()
.join(".");
}
for (const element of document.querySelectorAll(
".layout_editor_block.active",
)) {
element.classList.remove("active");
}
e.target.classList.add("active");
self.screen("element");
}
/// Open sidebar.
open() {
document.getElementById("editor_sidebar").classList.add("open");
document.getElementById("editor").style.transform = "scale(0.8)";
}
/// Close sidebar.
close() {
document.getElementById("editor_sidebar").style.animation =
"0.2s ease-in-out forwards to_left";
setTimeout(() => {
document.getElementById("editor_sidebar").classList.remove("open");
document.getElementById("editor_sidebar").style.animation =
"0.2s ease-in-out forwards from_right";
}, 250);
document.getElementById("editor").style.transform = "scale(1)";
}
/// Render editor dialog.
screen(page = "element", data = {}) {
this.current.component = this.current.component.toLowerCase();
const sidebar = document.getElementById("editor_sidebar");
sidebar.innerHTML = "";
// render page
if (
page === "add" ||
(page === "element" && this.current.component === "empty")
) {
// add element
sidebar.appendChild(
(() => {
const heading = document.createElement("h3");
heading.innerText = data.add_title || "Add component";
return heading;
})(),
);
sidebar.appendChild(document.createElement("hr"));
const container = document.createElement("div");
container.className = "flex w-full gap-2 flex-wrap";
for (const component of data.components || COMPONENTS) {
container.appendChild(
(() => {
const button = document.createElement("button");
button.classList.add("secondary");
trigger("app::icon", [
data.icon || "shapes",
"icon",
]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`;
return span;
})(),
);
button.addEventListener("click", () => {
if (component[2]) {
// render presets
return this.screen(page, {
back: ["add", {}],
add_title: "Select preset",
components: [
["Default", component[1]],
...component[2],
],
icon: "type",
});
}
// no presets
if (
page === "element" &&
this.current.component === "empty"
) {
// replace with component
copy_fields(component[1], this.current);
} else {
// add component to children
this.current.children.push(
structuredClone(component[1]),
);
}
this.render();
this.close();
});
return button;
})(),
);
}
sidebar.appendChild(container);
} else if (page === "element") {
// edit element
const name = document.createElement("div");
name.className = "flex flex-col gap-2";
name.appendChild(
(() => {
const heading = document.createElement("h3");
heading.innerText = `Edit ${this.current.component}`;
return heading;
})(),
);
name.appendChild(
(() => {
const pos = document.createElement("div");
pos.className = "notification w-content";
pos.innerText = this.pointer.get().join(".");
return pos;
})(),
);
sidebar.appendChild(name);
sidebar.appendChild(document.createElement("hr"));
// options
const options = document.createElement("div");
options.className = "card flex flex-col gap-2 w-full";
const add_option = (
label_text,
name,
valid = [],
input_element = "input",
) => {
const card = document.createElement("details");
card.className = "w-full";
const summary = document.createElement("summary");
summary.className = "w-full";
const label = document.createElement("label");
label.setAttribute("for", name);
label.className = "w-full";
label.innerText = label_text;
label.style.cursor = "pointer";
label.addEventListener("click", () => {
// bubble to summary click
summary.click();
});
const input_box = document.createElement("div");
input_box.style.paddingLeft = "1rem";
input_box.style.borderLeft =
"solid 2px var(--color-super-lowered)";
const input = document.createElement(input_element);
input.id = name;
input.setAttribute("name", name);
input.setAttribute("type", "text");
if (input_element === "input") {
input.setAttribute(
"value",
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
(this.current.options || {})[name] || "",
);
} else {
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
input.innerHTML = (this.current.options || {})[name] || "";
}
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
if ((this.current.options || {})[name]) {
// open details if a value is set
card.setAttribute("open", "");
}
input.addEventListener("change", (e) => {
if (
valid.length > 0 &&
!valid.includes(e.target.value) &&
e.target.value.length > 0 // anything can be set to empty
) {
alert(`Must be one of: ${JSON.stringify(valid)}`);
return;
}
if (!this.current.options) {
this.current.options = {};
}
this.current.options[name] =
e.target.value === "no" ? "" : e.target.value;
});
summary.appendChild(label);
card.appendChild(summary);
input_box.appendChild(input);
card.appendChild(input_box);
options.appendChild(card);
};
sidebar.appendChild(options);
if (this.current.component === "flex") {
add_option("Gap", "gap", ["1", "2", "3", "4"]);
add_option("Direction", "direction", ["row", "col"]);
add_option("Do collapse", "collapse", ["yes", "no"]);
add_option("Width", "width", ["full", "content"]);
add_option("Class name", "class");
add_option("Unique ID", "id");
add_option("Style", "style", [], "textarea");
} else if (this.current.component === "markdown") {
add_option("Content", "text", [], "textarea");
add_option("Class name", "class");
} else if (this.current.component === "divider") {
add_option("Class name", "class");
} else if (this.current.component === "style") {
add_option("Style data", "data", [], "textarea");
} else {
options.remove();
}
// action buttons
const buttons = document.createElement("div");
buttons.className = "card w-full flex flex-wrap gap-2";
if (this.current.component === "flex") {
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["plus", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Add child";
return span;
})(),
);
button.addEventListener("click", () => {
this.screen("add");
});
return button;
})(),
);
}
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["move-up", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Move up";
return span;
})(),
);
button.addEventListener("click", () => {
const idx = this.pointer.get().pop();
const parent_ref = this.pointer.resolve(
this.json,
).children;
if (parent_ref[idx - 1] === undefined) {
alert("No space to move element.");
return;
}
const clone = JSON.parse(JSON.stringify(this.current));
const other_clone = JSON.parse(
JSON.stringify(parent_ref[idx - 1]),
);
copy_fields(clone, parent_ref[idx - 1]); // move here to here
copy_fields(other_clone, parent_ref[idx]); // move there to here
this.close();
this.render();
});
return button;
})(),
);
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["move-down", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Move down";
return span;
})(),
);
button.addEventListener("click", () => {
const idx = this.pointer.get().pop();
const parent_ref = this.pointer.resolve(
this.json,
).children;
if (parent_ref[idx + 1] === undefined) {
alert("No space to move element.");
return;
}
const clone = JSON.parse(JSON.stringify(this.current));
const other_clone = JSON.parse(
JSON.stringify(parent_ref[idx + 1]),
);
copy_fields(clone, parent_ref[idx + 1]); // move here to here
copy_fields(other_clone, parent_ref[idx]); // move there to here
this.close();
this.render();
});
return button;
})(),
);
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.classList.add("red");
trigger("app::icon", ["trash", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Delete";
return span;
})(),
);
button.addEventListener("click", async () => {
if (
!(await trigger("app::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
if (this.json === this.current) {
// this is the root element; replace with empty
copy_fields(
COMPONENT_TEMPLATES.EMPTY_COMPONENT,
this.current,
);
} else {
// get parent
const idx = this.pointer.get().pop();
const ref = this.pointer.resolve(this.json);
// remove element
ref.children.splice(idx, 1);
}
this.render();
this.close();
});
return button;
})(),
);
sidebar.appendChild(buttons);
} else if (page === "tree") {
sidebar.innerHTML = this.tree;
}
sidebar.appendChild(document.createElement("hr"));
const buttons = document.createElement("div");
buttons.className = "flex gap-2 flex-wrap";
if (data.back) {
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.className = "secondary";
trigger("app::icon", ["arrow-left", "icon"]).then(
(icon) => {
button.prepend(icon);
},
);
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Back";
return span;
})(),
);
button.addEventListener("click", () => {
this.screen(...data.back);
});
return button;
})(),
);
}
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.className = "red secondary";
trigger("app::icon", ["x", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Close";
return span;
})(),
);
button.addEventListener("click", () => {
this.render();
this.close();
});
return button;
})(),
);
sidebar.appendChild(buttons);
// ...
this.open();
}
}
define("ElementPointer", ElementPointer);
define("LayoutEditor", LayoutEditor);

View file

@ -16,19 +16,32 @@ function regns_log(level, ...args) {
}
/// Query an existing namespace
globalThis.ns = (ns) => {
globalThis.ns = async (ns) => {
regns_log("info", "namespace query:", ns);
// get namespace from app base
const res = globalThis._app_base.ns_store[`$${ns}`];
let res = globalThis._app_base.ns_store[`$${ns}`];
let tries = 0;
if (!res) {
return console.error(
"namespace does not exist, please use one of the following:",
Object.keys(globalThis._app_base.ns_store),
);
while (!res) {
if (tries >= 5) {
return console.error(
`namespace "${ns}" does not exist, please use one of the following:`,
Object.keys(globalThis._app_base.ns_store),
);
}
tries += 1;
res = globalThis._app_base.ns_store[`$${ns}`];
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
}
regns_log("info", `found ns "${ns}" after ${tries} tries`);
return res;
};
@ -51,12 +64,12 @@ globalThis.reg_ns = (ns, deps) => {
_ident: ns,
_deps: deps || [],
/// Pull dependencies (other namespaces) as listed in the given `deps` argument
_get_deps: () => {
_get_deps: async () => {
const self = globalThis._app_base.ns_store[`$${ns}`];
const deps = {};
for (const dep of self._deps) {
const res = globalThis.ns(dep);
const res = await globalThis.ns(dep);
if (!res) {
regns_log("warn", "failed to pull dependency:", dep);
@ -72,16 +85,15 @@ globalThis.reg_ns = (ns, deps) => {
/// Store the real versions of functions
_fn_store: {},
/// Call a function in a namespace and load namespace dependencies
define: (name, func, types) => {
const self = globalThis.ns(ns);
define: async (name, func, types) => {
const self = await globalThis.ns(ns);
self._fn_store[name] = func; // store real function
self[name] = function (...args) {
self[name] = async (...args) => {
regns_log("info", "namespace call:", ns, name);
// js doesn't provide type checking, we do
if (types) {
for (const i in args) {
// biome-ignore lint: this is incorrect, you do not need a string literal to use typeof
if (types[i] && typeof args[i] !== types[i]) {
return console.error(
"argument does not pass type check:",
@ -94,7 +106,7 @@ globalThis.reg_ns = (ns, deps) => {
// ...
// we MUST return here, otherwise nothing will work in workers
return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments
return self._fn_store[name](await self._get_deps(), ...args); // call with deps and arguments
};
},
};
@ -104,11 +116,11 @@ globalThis.reg_ns = (ns, deps) => {
};
/// Call a namespace function quickly
globalThis.trigger = (id, args) => {
globalThis.trigger = async (id, args) => {
// get namespace
const s = id.split("::");
const [namespace, func] = [s[0], s.slice(1, s.length).join("::")];
const self = ns(namespace);
const self = await ns(namespace);
if (!self) {
return console.error("namespace does not exist:", namespace);

View file

@ -204,6 +204,47 @@
});
});
self.define("message_react", async (_, element, message, emoji) => {
await trigger("atto::debounce", ["reactions::toggle"]);
fetch("/api/v1/message_reactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
emoji,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
if (res.message.includes("created")) {
const x = element.querySelector(
`[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
);
if (x) {
x.classList.remove("lowered");
}
} else {
const x = element.querySelector(
`[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
);
if (x) {
x.classList.add("lowered");
}
}
}
});
});
self.define("remove_notification", (_, id) => {
fetch(`/api/v1/notifications/${id}`, {
method: "DELETE",
@ -259,7 +300,7 @@
self.define(
"repost",
(
async (
_,
id,
content,
@ -267,6 +308,7 @@
do_not_redirect = false,
is_stack = false,
) => {
await trigger("atto::debounce", ["posts::create"]);
return new Promise((resolve, _) => {
fetch(`/api/v1/posts/${id}/repost`, {
method: "POST",
@ -300,6 +342,36 @@
},
);
self.define("achievement", (_, name) => {
return new Promise((resolve) => {
fetch("/api/v1/auth/user/me/achievement", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
}),
})
.then((res) => res.json())
.then((res) => {
if (!res.ok) {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
}
resolve();
});
});
});
self.define("achievement_link", async (_, name, href) => {
await self.achievement(name);
Turbo.visit(href);
});
self.define("report", (_, asset, asset_type) => {
window.open(
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`,
@ -360,8 +432,30 @@
});
});
self.define("remove_ip_block", async (_, id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch(`/api/v1/auth/ip/${id}/unblock_ip`, {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
});
self.define("notifications_stream", ({ _, streams }) => {
const element = document.getElementById("notifications_span");
let current = Number.parseInt(element.innerText || "0");
streams.subscribe("notifs");
streams.event("notifs", "message", (data) => {
@ -372,13 +466,12 @@
const inner_data = JSON.parse(data.data);
if (data.method.Packet.Crud === "Create") {
const current = Number.parseInt(element.innerText || "0");
if (current <= 0) {
element.classList.remove("hidden");
}
element.innerText = current + 1;
current += 1;
element.innerText = current;
// check if we're already connected
const connected =
@ -414,16 +507,19 @@
console.info("notification created");
}
} else if (data.method.Packet.Crud === "Delete") {
const current = Number.parseInt(element.innerText || "0");
if (current - 1 <= 0) {
element.classList.add("hidden");
}
element.innerText = current - 1;
current -= 1;
element.innerText = current;
} else {
console.warn("correct packet type but with wrong data");
}
if (element.innerText !== current) {
element.innerText = current;
}
});
});
@ -489,6 +585,78 @@
return out;
});
// share intents
self.define(
"gen_share",
(
_,
ids = { q: "0", p: "0" },
target_length = 280,
include_link = true,
) => {
const part_1 = (
document.getElementById(`question_content:${ids.q}`) || {
innerText: "",
}
).innerText;
const part_2 = document.getElementById(
`post_content:${ids.p}`,
).innerText;
// ...
const link =
include_link !== false
? `${window.location.origin}/post/${ids.p}`
: "";
const link_size = link.length;
target_length -= link_size;
let out = "";
const separator = " — ";
const part_2_size = target_length / 2 - 1;
const sep_size = separator.length;
const part_1_size = target_length / 2 - sep_size;
if (part_1 !== "") {
out +=
part_1_size > part_1.length
? part_1
: part_1.substring(0, part_1_size);
out += separator;
}
if (part_2 !== "") {
out +=
part_2_size > part_2.length
? part_2
: part_2.substring(0, part_2_size);
}
out += ` ${link}`;
return out;
},
);
self.define("intent_twitter", async (_, text_promise) => {
window.open(
`https://twitter.com/intent/tweet?text=${encodeURIComponent(await text_promise)}`,
);
trigger("atto::toast", ["success", "Opened intent!"]);
});
self.define("intent_bluesky", async (_, text_promise) => {
window.open(
`https://bsky.app/intent/compose?text=${encodeURIComponent(await text_promise)}`,
);
trigger("atto::toast", ["success", "Opened intent!"]);
});
// token switcher
self.define("append_associations", (_, tokens) => {
fetch("/api/v1/auth/user/me/append_associations", {
@ -764,6 +932,25 @@
return [access_token, refresh_token, expires_in];
});
self.define("refresh", async (_, refresh_token) => {
const [new_token, new_refresh_token, expires_in] = await trigger(
"spotify::refresh_token",
[client_id, refresh_token],
);
await trigger("connections::push_con_data", [
"Spotify",
{
token: new_token,
refresh_token: new_refresh_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
return [new_token, refresh_token];
});
self.define("profile", async (_, token) => {
return await (
await fetch("https://api.spotify.com/v1/me", {
@ -846,12 +1033,18 @@
self.define(
"timestamp",
({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => {
async (
{ $ },
updated_,
progress_ms_,
duration_ms_,
display = "full",
) => {
if (duration_ms_ === "0") {
return;
}
const now = new Date().getTime();
const now = Date.now();
const updated = Number.parseInt(updated_) + 8000;
let elapsed_since_update = now - updated;
@ -870,7 +1063,7 @@
}
if (display === "full") {
return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
return `${await $.ms_time_text(progress_ms)}/${await $.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
}
if (display === "left") {
@ -1001,7 +1194,7 @@
artist: playing.artist.name,
album: playing.album["#text"],
// times
timestamp: new Date().getTime().toString(),
timestamp: Date.now().toString(),
duration_ms: (mb_info.length || 0).toString(),
},
},

View file

@ -42,7 +42,13 @@
},
};
socket.addEventListener("message", (event) => {
socket.addEventListener("message", async (event) => {
const sock = await $.sock(stream);
if (!sock) {
return;
}
if (event.data === "Ping") {
return socket.send("Pong");
}
@ -54,14 +60,14 @@
return console.info(`${stream} ${data.data}`);
}
return $.sock(stream).events.message(data);
return sock.events.message(data);
});
return $.STREAMS[stream];
});
self.define("close", ({ $ }, stream) => {
const socket = $.sock(stream);
self.define("close", async ({ $ }, stream) => {
const socket = await $.sock(stream);
if (!socket) {
console.warn("no such stream to close");
@ -72,8 +78,8 @@
socket.socket.close();
});
self.define("event", ({ $ }, stream, event, handler) => {
const socket = $.sock(stream);
self.define("event", async ({ $ }, stream, event, handler) => {
const socket = await $.sock(stream);
if (!socket) {
console.warn("no such stream to add event to");
@ -84,7 +90,7 @@
});
self.define("send_packet", async ({ $ }, stream, method, data) => {
await (
return await (
await fetch(`/api/v1/auth/user/${$.USER}/_connect/${stream}/send`, {
method: "POST",
headers: {
@ -97,4 +103,19 @@
})
).json();
});
self.define("send_packet_to", async (_, user, stream, method, data) => {
return await (
await fetch(`/api/v1/auth/user/${user}/_connect/${stream}/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
data: JSON.stringify(data),
}),
})
).json();
});
})();

View file

@ -81,7 +81,7 @@ pub async fn stripe_webhook(
loop {
if retries >= 5 {
// we've already tried 5 times (10 seconds of waiting)... it's not
// we've already tried 5 times (25 seconds of waiting)... it's not
// going to happen
//
// we're going to report this error to the audit log so someone can
@ -111,7 +111,7 @@ pub async fn stripe_webhook(
Err(_) => {
tracing::info!("checkout session not stored in db yet");
retries += 1;
tokio::time::sleep(Duration::from_secs(2)).await;
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
}
@ -138,6 +138,15 @@ pub async fn stripe_webhook(
return Json(e.into());
}
if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, false, user.clone(), false)
.await
{
return Json(e.into());
}
}
if let Err(e) = data
.create_notification(Notification::new(
"Welcome new supporter!".to_string(),
@ -174,6 +183,18 @@ pub async fn stripe_webhook(
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());
}
}
if let Err(e) = data
.create_notification(Notification::new(
"Sorry to see you go... :(".to_string(),
@ -186,6 +207,58 @@ pub async fn stripe_webhook(
return Json(e.into());
}
}
EventType::InvoicePaymentFailed => {
// payment failed
let subscription = match req.data.object {
EventObject::Subscription(c) => c,
_ => unreachable!("cannot be this"),
};
let customer_id = subscription.customer.id();
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!(
"unsubscribe (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());
}
}
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()),
}

View file

@ -213,10 +213,21 @@ pub async fn upload_avatar_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::DataTooLong("gif".to_string()).into());
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
// update user settings
auth_user.settings.avatar_mime = "image/gif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// ...
return Json(ApiReturn {
ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(),
@ -226,7 +237,16 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// update user settings
auth_user.settings.avatar_mime = "image/avif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// upload image
@ -236,32 +256,12 @@ pub async fn upload_avatar_request(
bytes.push(byte);
}
match save_buffer(
&path,
bytes,
if mime == "image/gif" {
image::ImageFormat::Gif
} else {
image::ImageFormat::Avif
},
) {
Ok(_) => {
// update user settings
auth_user.settings.avatar_mime = mime.to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// ...
Json(ApiReturn {
ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(),
payload: (),
})
}
match save_buffer(&path, bytes, image::ImageFormat::Avif) {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(),
payload: (),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
}
}
@ -314,10 +314,21 @@ pub async fn upload_banner_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::DataTooLong("gif".to_string()).into());
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
// update user settings
auth_user.settings.banner_mime = "image/gif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// ...
return Json(ApiReturn {
ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(),
@ -327,7 +338,16 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// update user settings
auth_user.settings.avatar_mime = "image/avif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// upload image
@ -337,32 +357,12 @@ pub async fn upload_banner_request(
bytes.push(byte);
}
match save_buffer(
&path,
bytes,
if mime == "image/gif" {
image::ImageFormat::Gif
} else {
image::ImageFormat::Avif
},
) {
Ok(_) => {
// update user settings
auth_user.settings.banner_mime = mime.to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
{
return Json(e.into());
}
// ...
Json(ApiReturn {
ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(),
payload: (),
})
}
match save_buffer(&path, bytes, image::ImageFormat::Avif) {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(),
payload: (),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
}
}

View file

@ -54,7 +54,7 @@ pub async fn register_request(
// check for ip ban
if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await
.is_ok()
{
@ -86,26 +86,86 @@ pub async fn register_request(
let mut user = User::new(props.username.to_lowercase(), props.password);
user.settings.policy_consent = true;
// check invite code
if data.0.0.security.enable_invite_codes {
if !props.purchase {
if props.invite_code.is_empty() {
return (
None,
Json(Error::MiscError("Missing invite code".to_string()).into()),
);
}
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
Ok(c) => c,
Err(e) => return (None, Json(e.into())),
};
if invite_code.is_used {
return (
None,
Json(Error::MiscError("This code has already been used".to_string()).into()),
);
}
// let owner = match data.get_user_by_id(invite_code.owner).await {
// Ok(u) => u,
// Err(e) => return (None, Json(e.into())),
// };
// if !owner.permissions.check(FinePermission::SUPPORTER) {
// return (
// None,
// Json(
// Error::MiscError("Invite code owner must be an active supporter".to_string())
// .into(),
// ),
// );
// }
user.invite_code = invite_code.id;
} else {
// this account is being purchased
user.awaiting_purchase = true;
}
}
// push initial token
let (initial_token, t) = User::create_token(&real_ip);
user.tokens.push(t);
// return
match data.create_user(user).await {
Ok(_) => (
Some([(
"Set-Cookie",
format!(
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
initial_token,
60 * 60 * 24 * 365
),
)]),
Json(ApiReturn {
ok: true,
message: initial_token,
payload: (),
}),
),
Ok(_) => {
// mark invite as used
if data.0.0.security.enable_invite_codes && !props.purchase {
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
Ok(c) => c,
Err(e) => return (None, Json(e.into())),
};
if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
return (None, Json(e.into()));
}
}
// ...
(
Some([(
"Set-Cookie",
format!(
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
initial_token,
60 * 60 * 24 * 365
),
)]),
Json(ApiReturn {
ok: true,
message: initial_token,
payload: (),
}),
)
}
Err(e) => (None, Json(e.into())),
}
}
@ -134,7 +194,7 @@ pub async fn login_request(
// check for ip ban
if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await
.is_ok()
{

View file

@ -3,8 +3,9 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode,
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
},
State,
};
@ -21,7 +22,8 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{
cache::Cache,
model::{
auth::{Token, UserSettings},
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
socket::{PacketType, SocketMessage, SocketMethod},
@ -30,7 +32,7 @@ use tetratto_core::{
};
use tetratto_core::cache::redis::Commands;
use tetratto_shared::{
hash::{self, random_id},
hash::{hash, salt, random_id},
unix_epoch_timestamp,
};
@ -115,7 +117,7 @@ pub async fn update_user_settings_request(
Json(mut req): Json<UserSettings>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
@ -150,6 +152,14 @@ pub async fn update_user_settings_request(
req.theme_lit = format!("{}%", req.theme_lit)
}
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::EditSettings.into(), true)
.await
{
return Json(e.into());
}
// ...
match data.update_user_settings(id, req).await {
Ok(_) => Json(ApiReturn {
@ -185,7 +195,7 @@ pub async fn append_associations_request(
// resolve tokens
for token in req.tokens {
let hashed = hash::hash(token);
let hashed = hash(token);
let user_from_token = match data.get_user_by_token(&hashed).await {
Ok(ua) => ua,
Err(_) => continue,
@ -333,6 +343,34 @@ 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 role of the given user.
///
/// Does not support third-party grants.
@ -358,6 +396,34 @@ pub async fn update_user_role_request(
}
}
/// Update the role of the given user.
///
/// Does not support third-party grants.
pub async fn update_user_secondary_role_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateSecondaryUserRole>,
) -> 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_secondary_role(id, req.role, user, false)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the current user's last seen value.
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
@ -393,6 +459,16 @@ pub async fn delete_user_request(
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
} else if user.permissions.check(FinePermission::MANAGE_USERS) {
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!("invoked `delete_user` with x value `{id}`"),
))
.await
{
return Json(e.into());
}
}
match data
@ -417,11 +493,20 @@ pub async fn enable_totp_request(
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
let mut user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Enable2fa.into(), true)
.await
{
return Json(e.into());
}
// ...
match data.enable_totp(id, user).await {
Ok(x) => Json(ApiReturn {
ok: true,
@ -556,7 +641,7 @@ pub async fn subscription_handler(
pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) {
let (mut sink, mut stream) = socket.split();
let socket_id = tetratto_shared::hash::salt();
let socket_id = salt();
db.0.1
.incr("atto.active_connections:users".to_string())
.await;
@ -669,7 +754,7 @@ pub async fn post_to_socket_request(
None => return Json(Error::NotAllowed.into()),
};
if user.id.to_string() != user_id {
if user.id.to_string() != user_id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
@ -817,3 +902,130 @@ pub async fn refresh_grant_request(
Err(e) => Json(e.into()),
}
}
/// Generate an invite code.
///
/// Does not support third-party grants.
pub async fn generate_invite_codes_request(
jar: CookieJar,
Path(count): Path<usize>,
Extension(data): Extension<State>,
) -> 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 !data.0.0.security.enable_invite_codes {
return Json(Error::NotAllowed.into());
}
if count > 48 {
return Json(Error::DataTooLong("count".to_string()).into());
}
let mut out_string = String::new();
let mut errors_string = String::new();
for _ in 0..count {
// ids will quickly collide, so we need to wait a bit so timestamps are different
tokio::time::sleep(Duration::from_millis(50)).await;
match data
.create_invite_code(InviteCode::new(user.id), &user)
.await
{
Ok(x) => out_string += &(x.code + "\n"),
Err(e) => {
errors_string = e.to_string();
break;
}
}
}
Json(ApiReturn {
ok: true,
message: "Success".to_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

@ -11,7 +11,8 @@ use axum::{
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow},
addr::RemoteAddr,
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
oauth,
};
@ -22,7 +23,7 @@ pub async fn follow_request(
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
@ -40,7 +41,7 @@ pub async fn follow_request(
} else {
// create
match data
.create_userfollow(UserFollow::new(user.id, id), false)
.create_userfollow(UserFollow::new(user.id, id), &user, false)
.await
{
Ok(r) => {
@ -59,6 +60,15 @@ pub async fn follow_request(
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(),
@ -116,7 +126,7 @@ pub async fn accept_follow_request(
// create follow
match data
.create_userfollow(UserFollow::new(id, user.id), true)
.create_userfollow(UserFollow::new(id, user.id), &user, true)
.await
{
Ok(_) => {
@ -219,7 +229,10 @@ pub async fn ip_block_request(
None => return Json(Error::NotAllowed.into()),
};
if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await {
if let Ok(ipblock) = data
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
.await
{
// delete
match data.delete_ipblock(ipblock.id, user).await {
Ok(_) => Json(ApiReturn {
@ -305,3 +318,64 @@ pub async fn following_request(
Err(e) => Json(e.into()),
}
}
pub async fn ip_block_profile_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateIpBlock) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// get other user
let other_user = match data.get_user_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
for (ip, _, _) in other_user.tokens {
// check for an existing ip block
if data
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
.await
.is_ok()
{
continue;
}
// create ip block
if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await {
return Json(e.into());
}
}
Json(ApiReturn {
ok: true,
message: "IP(s) blocked".to_string(),
payload: (),
})
}
pub async fn remove_ip_block_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageBlocks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_ipblock(id, user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "IP unblocked".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -0,0 +1,103 @@
use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
pub async fn get_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::UserReact) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.get_message_reactions_by_owner_message(user.id, id)
.await
{
Ok(r) => Json(ApiReturn {
ok: true,
message: "Reactions exists".to_string(),
payload: Some(r),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateMessageReaction>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let message_id = match req.message.parse::<usize>() {
Ok(n) => n,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// check for existing reaction
if let Ok(r) = data
.get_message_reaction_by_owner_message_emoji(user.id, message_id, &req.emoji)
.await
{
if let Err(e) = data.delete_message_reaction(r.id, &user).await {
return Json(e.into());
} else {
return Json(ApiReturn {
ok: true,
message: "Reaction removed".to_string(),
payload: (),
});
}
}
// create reaction
match data
.create_message_reaction(MessageReaction::new(user.id, message_id, req.emoji), &user)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Reaction created".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((id, emoji)): Path<(usize, String)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let reaction = match data
.get_message_reaction_by_owner_message_emoji(user.id, id, &emoji)
.await
{
Ok(r) => r,
Err(e) => return Json(e.into()),
};
match data.delete_message_reaction(reaction.id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Reaction deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,2 +1,3 @@
pub mod channels;
pub mod message_reactions;
pub mod messages;

View file

@ -292,11 +292,10 @@ pub async fn create_membership(
};
match data
.create_membership(CommunityMembership::new(
user.id,
id,
CommunityPermission::default(),
))
.create_membership(
CommunityMembership::new(user.id, id, CommunityPermission::default()),
&user,
)
.await
{
Ok(m) => Json(ApiReturn {

View file

@ -4,7 +4,7 @@ use axum::{
Extension, Json,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error};
use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error};
use crate::{
get_user_from_token,
routes::{
@ -20,11 +20,20 @@ pub async fn create_request(
Json(req): Json<CreatePostDraft>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateDraft.into(), true)
.await
{
return Json(e.into());
}
// ...
match data
.create_draft(PostDraft::new(req.content, user.id))
.await

View file

@ -16,12 +16,16 @@ use tetratto_core::model::{
/// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
match emojis::get(&emoji) {
Some(e) => match e.shortcode() {
Some(s) => s.to_string(),
None => e.name().replace(" ", "-"),
match emoji.as_str() {
"👍" => "thumbs_up".to_string(),
"👎" => "thumbs_down".to_string(),
_ => match emojis::get(&emoji) {
Some(e) => match e.shortcode() {
Some(s) => s.to_string(),
None => e.name().replace(" ", "-"),
},
None => String::new(),
},
None => String::new(),
}
}

View file

@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image
@ -191,7 +191,7 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
// upload image

View file

@ -7,6 +7,7 @@ use axum::{
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
addr::RemoteAddr,
auth::AchievementName,
communities::{Poll, PollVote, Post},
oauth,
permissions::FinePermission,
@ -36,7 +37,7 @@ pub async fn create_request(
JsonMultipart(images, req): JsonMultipart<CreatePost>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
@ -66,7 +67,7 @@ pub async fn create_request(
// check for ip ban
if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await
.is_ok()
{
@ -133,7 +134,7 @@ pub async fn create_request(
// check sizes
for img in &images {
if img.len() > MAXIMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
return Json(Error::FileTooLarge.into());
}
}
@ -178,6 +179,41 @@ pub async fn create_request(
}
}
// achievements
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreatePost.into(), true)
.await
{
return Json(e.into());
}
if user.post_count >= 49 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create50Posts.into(), true)
.await
{
return Json(e.into());
}
}
if user.post_count >= 99 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create100Posts.into(), true)
.await
{
return Json(e.into());
}
}
if user.post_count >= 999 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create1000Posts.into(), true)
.await
{
return Json(e.into());
}
}
// return
Json(ApiReturn {
ok: true,
@ -305,11 +341,20 @@ pub async fn update_content_request(
Json(req): Json<UpdatePostContent>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::EditPost.into(), true)
.await
{
return Json(e.into());
}
// ...
match data.update_post_content(id, user, req.content).await {
Ok(_) => Json(ApiReturn {
ok: true,
@ -441,10 +486,7 @@ pub async fn posts_request(
};
check_user_blocked_or_private!(Some(&user), other_user, data, @api);
match data
.get_posts_by_user(id, 12, props.page, &Some(user.clone()))
.await
{
match data.get_posts_by_user(id, 12, props.page).await {
Ok(posts) => {
let ignore_users = crate::ignore_users_gen!(user!, #data);
Json(ApiReturn {
@ -478,7 +520,10 @@ pub async fn community_posts_request(
None => return Json(Error::NotAllowed.into()),
};
match data.get_posts_by_community(id, 12, props.page).await {
match data
.get_posts_by_community(id, 12, props.page, &Some(user.clone()))
.await
{
Ok(posts) => {
let ignore_users = crate::ignore_users_gen!(user!, #data);
Json(ApiReturn {
@ -792,7 +837,10 @@ pub async fn all_request(
None => return Json(Error::NotAllowed.into()),
};
match data.get_latest_posts(12, props.page).await {
match data
.get_latest_posts(12, props.page, &Some(user.clone()), props.before)
.await
{
Ok(posts) => {
let ignore_users = crate::ignore_users_gen!(user!, #data);
Json(ApiReturn {

View file

@ -7,7 +7,7 @@ use axum::{
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
addr::RemoteAddr,
auth::IpBlock,
auth::{AchievementName, IpBlock},
communities::{CommunityReadAccess, Question},
oauth,
permissions::FinePermission,
@ -15,6 +15,7 @@ use tetratto_core::model::{
};
use crate::{
get_user_from_token,
image::JsonMultipart,
routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
State,
};
@ -23,7 +24,7 @@ pub async fn create_request(
jar: CookieJar,
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<CreateQuestion>,
JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
@ -42,13 +43,34 @@ pub async fn create_request(
// check for ip ban
if data
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
.await
.is_ok()
{
return Json(Error::NotAllowed.into());
}
// award achievement
if let Some(ref user) = user {
let mut user = user.clone();
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateQuestion.into(), true)
.await
{
return Json(e.into());
}
if drawings.len() > 0 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateDrawing.into(), true)
.await
{
return Json(e.into());
}
}
}
// ...
let mut props = Question::new(
if let Some(ref ua) = user { ua.id } else { 0 },
@ -70,7 +92,14 @@ pub async fn create_request(
}
}
match data.create_question(props).await {
if req.mask_owner && !req.is_global {
props.context.mask_owner = true;
}
match data
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
.await
{
Ok(id) => Json(ApiReturn {
ok: true,
message: "Question created".to_string(),
@ -120,7 +149,7 @@ pub async fn ip_block_request(
// check for an existing ip block
if data
.get_ipblock_by_initiator_receiver(user.id, &question.ip)
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(question.ip.as_str()))
.await
.is_ok()
{

View file

@ -0,0 +1,164 @@
use crate::{
get_user_from_token,
routes::api::v1::{CreateDomain, UpdateDomainData},
State,
};
use axum::{
extract::{Path, Query},
response::IntoResponse,
http::StatusCode,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{littleweb::Domain, oauth, 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 user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.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 {
pub addr: String,
}
pub async fn get_file_request(
Extension(data): Extension<State>,
Query(props): Query<GetFileQuery>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let (subdomain, domain, tld, path) = Domain::from_str(&props.addr);
// 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())],
f.content,
)),
None => {
return Err((
StatusCode::NOT_FOUND,
Error::GeneralNotFound("file".to_string()).to_string(),
));
}
}
}

View file

@ -4,16 +4,23 @@ use axum::{
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_shared::snow::Snowflake;
use crate::{
get_user_from_token,
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
routes::api::v1::{
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
},
State,
};
use tetratto_core::model::{
journals::{Journal, JournalPrivacyPermission},
oauth,
permissions::FinePermission,
ApiReturn, Error,
use tetratto_core::{
database::NAME_REGEX,
model::{
auth::AchievementName,
journals::{Journal, JournalPrivacyPermission},
oauth,
permissions::FinePermission,
ApiReturn, Error,
},
};
pub async fn get_request(
@ -46,6 +53,28 @@ pub async fn get_request(
})
}
pub async fn get_css_request(
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let note = match data.get_note_by_journal_title(id, "journal.css").await {
Ok(x) => x,
Err(e) => {
return (
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
format!("/* {e} */"),
);
}
};
(
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
note.content,
)
}
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::UserReadJournals) {
@ -69,7 +98,7 @@ pub async fn create_request(
Json(props): Json<CreateJournal>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
@ -78,11 +107,22 @@ pub async fn create_request(
.create_journal(Journal::new(user.id, props.title))
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Journal created".to_string(),
payload: Some(x.id.to_string()),
}),
Ok(x) => {
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateJournal.into(), true)
.await
{
return Json(e.into());
}
// ...
Json(ApiReturn {
ok: true,
message: "Journal created".to_string(),
payload: Some(x.id.to_string()),
})
}
Err(e) => Json(e.into()),
}
}
@ -99,7 +139,17 @@ pub async fn update_title_request(
None => return Json(Error::NotAllowed.into()),
};
props.title = props.title.replace(" ", "_");
props.title = props.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&props.title).is_some() {
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
}
// make sure this title isn't already in use
if data
@ -163,3 +213,86 @@ pub async fn delete_request(
Err(e) => Json(e.into()),
}
}
pub async fn add_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<AddJournalDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if props.name.len() > 32 {
return Json(Error::DataTooLong("name".to_string()).into());
}
let mut journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// add dir
journal.dirs.push((
Snowflake::new().to_string().parse::<usize>().unwrap(),
match props.parent.parse() {
Ok(p) => p,
Err(_) => return Json(Error::Unknown.into()),
},
props.name,
));
// ...
match data.update_journal_dirs(id, &user, journal.dirs).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn remove_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<RemoveJournalDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// add dir
let dir_id: usize = match props.dir.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
};
journal
.dirs
.remove(match journal.dirs.iter().position(|x| x.0 == dir_id) {
Some(idx) => idx,
None => return Json(Error::GeneralNotFound("directory".to_string()).into()),
});
// ...
match data.update_journal_dirs(id, &user, journal.dirs).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -0,0 +1,175 @@
use crate::{
get_user_from_token,
routes::{
api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy},
},
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::{
model::{
layouts::{Layout, LayoutPrivacy},
oauth,
permissions::FinePermission,
ApiReturn, Error,
},
};
pub async fn get_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::UserReadStacks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let layout = match data.get_layout_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if layout.privacy == LayoutPrivacy::Public
&& user.id != layout.owner
&& !user.permissions.check(FinePermission::MANAGE_USERS)
{
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(layout),
})
}
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::UserReadLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_layouts_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<CreateLayout>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_layout(Layout::new(req.name, user.id, req.replaces))
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Layout created".to_string(),
payload: s.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<UpdateLayoutName>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_title(id, &user, &req.name).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_privacy_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateLayoutPrivacy>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_privacy(id, &user, req.privacy).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_pages_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateLayoutPages>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_pages(id, &user, req.pages).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout 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::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_layout(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -2,12 +2,15 @@ pub mod apps;
pub mod auth;
pub mod channels;
pub mod communities;
pub mod domains;
pub mod journals;
pub mod layouts;
pub mod notes;
pub mod notifications;
pub mod reactions;
pub mod reports;
pub mod requests;
pub mod services;
pub mod stacks;
pub mod uploads;
pub mod util;
@ -19,14 +22,17 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::AppQuota,
auth::AchievementName,
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
},
communities_permissions::CommunityPermission,
journals::JournalPrivacyPermission,
layouts::{CustomizablePage, LayoutPage, LayoutPrivacy},
littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope,
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort},
};
@ -37,10 +43,27 @@ pub fn routes() -> Router {
.route("/util/proxy", get(util::proxy_request))
.route("/util/lang", get(util::set_langfile_request))
.route("/util/ip", get(util::ip_test_request))
.route(
"/invites/{count}",
post(auth::profile::generate_invite_codes_request),
)
// reactions
.route("/reactions", post(reactions::create_request))
.route("/reactions/{id}", get(reactions::get_request))
.route("/reactions/{id}", delete(reactions::delete_request))
// message reactions
.route(
"/message_reactions",
post(channels::message_reactions::create_request),
)
.route(
"/message_reactions/{id}",
get(channels::message_reactions::get_request),
)
.route(
"/message_reactions/{id}/{emoji}",
delete(channels::message_reactions::delete_request),
)
// communities
.route(
"/communities/find/{id}",
@ -271,6 +294,14 @@ pub fn routes() -> Router {
post(auth::social::accept_follow_request),
)
.route("/auth/user/{id}/block", post(auth::social::block_request))
.route(
"/auth/user/{id}/block_ip",
post(auth::social::ip_block_profile_request),
)
.route(
"/auth/ip/{id}/unblock_ip",
post(auth::social::remove_ip_block_request),
)
.route(
"/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request),
@ -279,6 +310,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/role",
post(auth::profile::update_user_role_request),
)
.route(
"/auth/user/{id}/role/2",
post(auth::profile::update_user_secondary_role_request),
)
.route(
"/auth/user/{id}",
delete(auth::profile::delete_user_request),
@ -299,6 +334,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/verified",
post(auth::profile::update_user_is_verified_request),
)
.route(
"/auth/user/{id}/awaiting_purchase",
post(auth::profile::update_user_awaiting_purchase_request),
)
.route(
"/auth/user/{id}/totp",
post(auth::profile::enable_totp_request),
@ -358,6 +397,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/grants/{app}/refresh",
post(auth::profile::refresh_grant_request),
)
.route(
"/auth/user/me/achievement",
post(auth::profile::self_serve_achievement_request),
)
.route(
"/auth/user/me/invite_code",
post(auth::profile::update_user_invite_code_request),
)
// apps
.route("/apps", post(apps::create_request))
.route("/apps/{id}/title", post(apps::update_title_request))
@ -551,22 +598,60 @@ pub fn routes() -> Router {
.route("/journals", post(journals::create_request))
.route("/journals/{id}", get(journals::get_request))
.route("/journals/{id}", delete(journals::delete_request))
.route("/journals/{id}/journal.css", get(journals::get_css_request))
.route("/journals/{id}/title", post(journals::update_title_request))
.route(
"/journals/{id}/privacy",
post(journals::update_privacy_request),
)
.route("/journals/{id}/dirs", post(journals::add_dir_request))
.route("/journals/{id}/dirs", delete(journals::remove_dir_request))
// notes
.route("/notes", post(notes::create_request))
.route("/notes/{id}", get(notes::get_request))
.route("/notes/{id}", delete(notes::delete_request))
.route("/notes/{id}/title", post(notes::update_title_request))
.route("/notes/{id}/content", post(notes::update_content_request))
.route("/notes/{id}/dir", post(notes::update_dir_request))
.route("/notes/{id}/tags", post(notes::update_tags_request))
.route("/notes/{id}/global", post(notes::publish_request))
.route("/notes/{id}/global", delete(notes::unpublish_request))
.route("/notes/from_journal/{id}", get(notes::list_request))
.route("/notes/preview", post(notes::render_markdown_request))
.route(
"/notes/{journal}/dir/{dir}",
delete(notes::delete_by_dir_request),
)
// uploads
.route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_request))
// layouts
.route("/layouts", get(layouts::list_request))
.route("/layouts", post(layouts::create_request))
.route("/layouts/{id}", get(layouts::get_request))
.route("/layouts/{id}", delete(layouts::delete_request))
.route("/layouts/{id}/title", post(layouts::update_name_request))
.route(
"/layouts/{id}/privacy",
post(layouts::update_privacy_request),
)
.route("/layouts/{id}/pages", post(layouts::update_pages_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}/files", post(services::update_files_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))
}
pub fn lw_routes() -> Router {
Router::new().route("/file", get(domains::get_file_request))
}
#[derive(Deserialize)]
@ -583,6 +668,14 @@ pub struct RegisterProps {
pub password: String,
pub policy_consent: bool,
pub captcha_response: String,
#[serde(default)]
pub invite_code: String,
/// If this is true, invite_code should be empty.
///
/// If invite codes are enabled, but purchase is false, the invite_code MUST
/// be checked and MUST be valid.
#[serde(default)]
pub purchase: bool,
}
#[derive(Deserialize)]
@ -690,6 +783,11 @@ pub struct UpdateUserIsVerified {
pub is_verified: bool,
}
#[derive(Deserialize)]
pub struct UpdateUserAwaitingPurchase {
pub awaiting_purchase: bool,
}
#[derive(Deserialize)]
pub struct UpdateNotificationRead {
pub read: bool,
@ -710,6 +808,16 @@ pub struct UpdateUserRole {
pub role: FinePermission,
}
#[derive(Deserialize)]
pub struct UpdateSecondaryUserRole {
pub role: SecondaryPermission,
}
#[derive(Deserialize)]
pub struct UpdateUserInviteCode {
pub invite_code: String,
}
#[derive(Deserialize)]
pub struct DeleteUser {
pub password: String,
@ -738,6 +846,8 @@ pub struct CreateQuestion {
pub receiver: String,
#[serde(default)]
pub community: String,
#[serde(default)]
pub mask_owner: bool,
}
#[derive(Deserialize)]
@ -906,3 +1016,78 @@ pub struct UpdateNoteContent {
pub struct RenderMarkdown {
pub content: String,
}
#[derive(Deserialize)]
pub struct CreateMessageReaction {
pub message: String,
pub emoji: String,
}
#[derive(Deserialize)]
pub struct UpdateNoteDir {
pub dir: String,
}
#[derive(Deserialize)]
pub struct AddJournalDir {
pub name: String,
#[serde(default)]
pub parent: String,
}
#[derive(Deserialize)]
pub struct RemoveJournalDir {
pub dir: String,
}
#[derive(Deserialize)]
pub struct UpdateNoteTags {
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct AwardAchievement {
pub name: AchievementName,
}
#[derive(Deserialize)]
pub struct CreateLayout {
pub name: String,
pub replaces: CustomizablePage,
}
#[derive(Deserialize)]
pub struct UpdateLayoutName {
pub name: String,
}
#[derive(Deserialize)]
pub struct UpdateLayoutPrivacy {
pub privacy: LayoutPrivacy,
}
#[derive(Deserialize)]
pub struct UpdateLayoutPages {
pub pages: Vec<LayoutPage>,
}
#[derive(Deserialize)]
pub struct CreateService {
pub name: String,
}
#[derive(Deserialize)]
pub struct UpdateServiceFiles {
pub files: Vec<ServiceFsEntry>,
}
#[derive(Deserialize)]
pub struct CreateDomain {
pub name: String,
pub tld: DomainTld,
}
#[derive(Deserialize)]
pub struct UpdateDomainData {
pub data: Vec<(String, DomainData)>,
}

View file

@ -7,15 +7,22 @@ use axum_extra::extract::CookieJar;
use tetratto_shared::unix_epoch_timestamp;
use crate::{
get_user_from_token,
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
routes::api::v1::{
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
UpdateNoteTitle,
},
State,
};
use tetratto_core::model::{
journals::{JournalPrivacyPermission, Note},
oauth,
permissions::FinePermission,
uploads::CustomEmoji,
ApiReturn, Error,
use tetratto_core::{
database::NAME_REGEX,
model::{
auth::AchievementName,
journals::{JournalPrivacyPermission, Note},
oauth,
permissions::FinePermission,
uploads::CustomEmoji,
ApiReturn, Error,
},
};
pub async fn get_request(
@ -135,7 +142,17 @@ pub async fn update_title_request(
Err(e) => return Json(e.into()),
};
props.title = props.title.replace(" ", "_");
props.title = props.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&props.title).is_some() {
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
}
// make sure this title isn't already in use
if data
@ -148,11 +165,21 @@ pub async fn update_title_request(
// ...
match data.update_note_title(id, &user, &props.title).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Ok(_) => {
// update note global status
if note.is_global {
if let Err(e) = data.update_note_is_global(id, 0).await {
return Json(e.into());
}
}
// ...
Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}
@ -164,11 +191,20 @@ pub async fn update_content_request(
Json(props): Json<UpdateNoteContent>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::EditNote.into(), true)
.await
{
return Json(e.into());
}
// ...
match data.update_note_content(id, &user, &props.content).await {
Ok(_) => {
if let Err(e) = data
@ -209,8 +245,185 @@ pub async fn delete_request(
}
}
pub async fn delete_by_dir_request(
jar: CookieJar,
Path((journal, id)): Path<(usize, usize)>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_notes_by_journal_dir(journal, id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Notes deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
.replace("\\@", "@")
.replace("%5C@", "@")
}
pub async fn update_dir_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateNoteDir>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
let journal = match data.get_journal_by_id(note.journal).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// make sure dir exists
let dir = match props.dir.parse::<usize>() {
Ok(d) => d,
Err(_) => return Json(Error::Unknown.into()),
};
if dir != 0 {
if journal.dirs.iter().find(|x| x.0 == dir).is_none() {
return Json(Error::GeneralNotFound("directory".to_string()).into());
}
}
// ...
match data.update_note_dir(id, &user, dir as i64).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_tags_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateNoteTags>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_note_tags(id, &user, props.tags).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn publish_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::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if user.id != note.owner {
return Json(Error::NotAllowed.into());
}
// check count
if data.get_user_global_notes_count(user.id).await.unwrap_or(0)
>= if user.permissions.check(FinePermission::SUPPORTER) {
10
} else {
5
}
{
return Json(
Error::MiscError(
"You already have the maximum number of global notes you can have".to_string(),
)
.into(),
);
}
// make sure note doesn't already exist globally
if data.get_global_note_by_title(&note.title).await.is_ok() {
return Json(
Error::MiscError(
"Note name is already in use globally. Please change the name and try again"
.to_string(),
)
.into(),
);
}
// ...
match data.update_note_is_global(id, 1).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn unpublish_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::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if user.id != note.owner {
return Json(Error::NotAllowed.into());
}
// ...
match data.update_note_is_global(id, 0).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".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 axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum::{
extract::Path,
http::{HeaderMap, HeaderValue},
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction};
use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error};
pub async fn get_request(
jar: CookieJar,
@ -26,6 +31,7 @@ pub async fn get_request(
pub async fn create_request(
jar: CookieJar,
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<CreateReaction>,
) -> impl IntoResponse {
@ -40,6 +46,20 @@ pub async fn create_request(
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// get real ip
let real_ip = headers
.get(data.0.0.security.real_ip_header.to_owned())
.unwrap_or(&HeaderValue::from_static(""))
.to_str()
.unwrap_or("")
.to_string();
// check for ip ban
let addr = RemoteAddr::from(real_ip.as_str());
if data.get_ipban_by_addr(&addr).await.is_ok() {
return Json(Error::NotAllowed.into());
}
// check for existing reaction
if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await {
match data.delete_reaction(r.id, &user).await {
@ -63,6 +83,7 @@ pub async fn create_request(
.create_reaction(
Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
&user,
&addr,
)
.await
{

View file

@ -0,0 +1,104 @@
use crate::{
get_user_from_token,
routes::api::v1::{UpdateServiceFiles, CreateService},
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{littleweb::Service, oauth, 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_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 user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.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_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()),
};
match data.update_service_files(id, &user, req.files).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Service 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::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

@ -211,6 +211,10 @@ pub async fn add_user_request(
Err(e) => return Json(e.into()),
};
if stack.users.contains(&other_user.id) {
return Json(Error::MiscError("This user is already in this stack".to_string()).into());
}
stack.users.push(other_user.id);
// check number of stacks

View file

@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
use pathbufd::PathBufD;
use crate::{get_user_from_token, State};
use super::auth::images::read_image;
use tetratto_core::model::{oauth, ApiReturn, Error};
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
pub async fn get_request(
Path(id): Path<usize>,
@ -39,10 +39,17 @@ pub async fn get_request(
));
}
Ok((
[("Content-Type", upload.what.mime())],
Body::from(read_image(path)),
))
let bytes = read_image(path);
if upload.what == MediaType::Carpgraph {
// conver to svg and return
return Ok((
[("Content-Type", "image/svg+xml".to_string())],
Body::from(CarpGraph::from_bytes(bytes).to_svg()),
));
}
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
}
pub async fn delete_request(

View file

@ -18,3 +18,5 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
serve_asset!(me_js_request: ME_JS("text/javascript"));
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript"));

View file

@ -19,10 +19,19 @@ pub fn routes(config: &Config) -> Router {
.route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request))
.route("/js/streams.js", get(assets::streams_js_request))
.route("/js/carp.js", get(assets::carp_js_request))
.route(
"/js/layout_editor.js",
get(assets::layout_editor_js_request),
)
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
)
.nest_service(
"/icons",
get_service(tower_http::services::ServeDir::new(&config.dirs.icons)),
)
.nest_service(
"/reference",
get_service(tower_http::services::ServeDir::new(&config.dirs.rustdoc)),
@ -37,3 +46,14 @@ pub fn routes(config: &Config) -> Router {
// pages
.merge(pages::routes())
}
/// These routes are only used when you provide the `LITTLEWEB` environment variable.
///
/// These routes are NOT for editing. These routes are only for viewing littleweb sites.
pub fn lw_routes() -> Router {
Router::new()
// api
.nest("/api/v1", api::v1::lw_routes())
// pages
.merge(pages::lw_routes())
}

View file

@ -3,14 +3,16 @@ use crate::{
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
};
use axum::{
Extension,
extract::{Path, Query},
http::{HeaderMap, HeaderValue},
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tera::Context;
use tetratto_core::model::{
addr::RemoteAddr,
auth::User,
communities::Community,
communities_permissions::CommunityPermission,
@ -417,7 +419,7 @@ pub async fn feed_request(
let feed = match data
.0
.get_posts_by_community(community.id, 12, props.page)
.get_posts_by_community(community.id, 12, props.page, &user)
.await
{
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
@ -642,6 +644,7 @@ pub async fn settings_request(
/// `/post/{id}`
pub async fn post_request(
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<usize>,
Query(props): Query<PaginatedQuery>,
Extension(data): Extension<State>,
@ -751,6 +754,46 @@ pub async fn post_request(
check_user_blocked_or_private!(user, owner, data, jar);
}
// get real ip
let real_ip = headers
.get(data.0.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.0.get_ipban_by_addr(&addr).await.is_ok() {
return Err(Html(
render_error(
Error::GeneralNotFound("post".to_string()),
&jar,
&data,
&user,
)
.await,
));
}
// check for ip block
if data
.0
.get_ipblock_by_initiator_receiver(post.owner, &addr)
.await
.is_ok()
{
return Err(Html(
render_error(
Error::GeneralNotFound("post".to_string()),
&jar,
&data,
&user,
)
.await,
));
}
// check repost
let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await;

View file

@ -161,7 +161,7 @@ pub async fn tickets_request(
let feed = match data
.0
.get_posts_by_community(community.id, 12, props.page)
.get_posts_by_community(community.id, 12, props.page, &user)
.await
{
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {

View file

@ -81,7 +81,7 @@ pub async fn app_request(
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
context.insert("selected_journal", &selected_journal);
context.insert("selected_note", &selected_note);
@ -89,6 +89,7 @@ pub async fn app_request(
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &user);
context.insert("journals", &journals);
context.insert("notes", &notes);
@ -116,7 +117,7 @@ pub async fn view_request(
}
// if we don't have a selected journal, we shouldn't be here probably
if selected_journal.is_empty() {
if selected_journal.is_empty() | (selected_note == "journal.css") {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
@ -158,14 +159,6 @@ pub async fn view_request(
}
}
// ...
let notes = match data.0.get_notes_by_journal(journal.id).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
// ...
let note = if !selected_note.is_empty() {
match data
@ -193,13 +186,17 @@ pub async fn view_request(
context.insert("selected_note", &0);
} else {
context.insert("selected_note", &selected_note);
context.insert(
"redis_views",
&data.0.get_note_views(note.as_ref().unwrap().id).await,
);
}
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &owner);
context.insert("notes", &notes);
context.insert::<[i8; 0], &str>("notes", &[]);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
@ -207,3 +204,169 @@ pub async fn view_request(
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
/// `/@{owner}/{journal}`
pub async fn index_view_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((owner, selected_journal)): Path<(String, String)>,
Query(props): Query<JournalsAppQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => Some(ua),
None => None,
};
// get owner
let owner = match data.0.get_user_by_username(&owner).await {
Ok(ua) => ua,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
check_user_blocked_or_private!(user, owner, data, jar);
// get journal and check privacy settings
let journal = match data
.0
.get_journal_by_owner_title(owner.id, &selected_journal)
.await
{
Ok(p) => p,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
if journal.privacy == JournalPrivacyPermission::Private {
if let Some(ref user) = user {
if user.id != journal.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
));
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// ...
let notes = if props.tag.is_empty() {
match data.0.get_notes_by_journal(journal.id).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
}
} else {
match data
.0
.get_notes_by_journal_tag(journal.id, &props.tag)
.await
{
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
}
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
if selected_journal.is_empty() {
context.insert("selected_journal", &0);
} else {
context.insert("selected_journal", &selected_journal);
}
context.insert("selected_note", &0);
context.insert("journal", &journal);
context.insert("owner", &owner);
context.insert("notes", &notes);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
context.insert("tag", &props.tag);
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
/// `/x/{note}`
pub async fn global_view_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(mut selected_note): Path<String>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => Some(ua),
None => None,
};
if selected_note == "index" {
selected_note = String::new();
}
// if we don't have a selected journal, we shouldn't be here probably
if selected_note == "journal.css" {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
// ...
let note = match data.0.get_global_note_by_title(&selected_note).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let journal = match data.0.get_journal_by_id(note.journal).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// get owner
let owner = match data.0.get_user_by_id(note.owner).await {
Ok(ua) => ua,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &user).await));
}
};
check_user_blocked_or_private!(user, owner, data, jar);
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
data.0.incr_note_views(note.id).await;
context.insert("selected_journal", &note.journal);
context.insert("selected_note", &selected_note);
context.insert("redis_views", &data.0.get_note_views(note.id).await);
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &owner);
context.insert::<[i8; 0], &str>("notes", &[]);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
context.insert("global_mode", &true);
// return
Ok((
[(
"content-security-policy",
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *",
)],
Html(data.1.render("journals/app.html", &context).unwrap()),
))
}

View file

@ -10,7 +10,10 @@ use axum::{
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{
auth::DefaultTimelineChoice, permissions::FinePermission, requests::ActionType, Error,
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
permissions::FinePermission,
requests::ActionType,
Error,
};
use std::fs::read_to_string;
use pathbufd::PathBufD;
@ -331,6 +334,14 @@ pub async fn notifications_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
// check and clear
if profile.settings.auto_clear_notifs {
if let Err(e) = data.0.delete_all_notifications(&user).await {
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
}
}
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
@ -433,6 +444,48 @@ pub async fn requests_request(
Ok(Html(data.1.render("misc/requests.html", &context).unwrap()))
}
/// `/achievements`
pub async fn achievements_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 Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let achievements = data.0.fill_achievements(user.achievements.clone());
// award achievement
if let Err(e) = data
.0
.add_achievement(&mut user, AchievementName::OpenAchievements.into(), true)
.await
{
return Err(Html(render_error(e, &jar, &data, &None).await));
}
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert(
"percentage",
&((achievements.len() as f32 / ACHIEVEMENTS as f32) * 100.0),
);
context.insert("achievements", &achievements);
// return
Ok(Html(
data.1.render("misc/achievements.html", &context).unwrap(),
))
}
/// `/doc/{file_name}`
pub async fn markdown_document_request(
jar: CookieJar,
@ -576,6 +629,12 @@ pub struct TimelineQuery {
pub user_id: usize,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub paginated: bool,
#[serde(default)]
pub before: usize,
#[serde(default)]
pub responses_only: bool,
}
/// `/_swiss_army_timeline`
@ -623,18 +682,32 @@ pub async fn swiss_army_timeline_request(
check_user_blocked_or_private!(user, other_user, data, jar);
if req.tag.is_empty() {
data.0
.get_posts_by_user(req.user_id, 12, req.page, &user)
.await
if req.responses_only {
data.0
.get_responses_by_user(req.user_id, 12, req.page)
.await
} else {
data.0.get_posts_by_user(req.user_id, 12, req.page).await
}
} else {
data.0
.get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page, &user)
.await
if req.responses_only {
data.0
.get_responses_by_user_tag(req.user_id, &req.tag, 12, req.page)
.await
} else {
data.0
.get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page)
.await
}
}
} else {
// everything else
match req.tl {
DefaultTimelineChoice::AllPosts => data.0.get_latest_posts(12, req.page).await,
DefaultTimelineChoice::AllPosts => {
data.0
.get_latest_posts(12, req.page, &user, req.before)
.await
}
DefaultTimelineChoice::PopularPosts => {
data.0.get_popular_posts(12, req.page, 604_800_000).await
}
@ -697,6 +770,7 @@ pub async fn swiss_army_timeline_request(
context.insert("list", &list);
context.insert("page", &req.page);
context.insert("paginated", &req.paginated);
Ok(Html(
data.1
.render("timelines/swiss_army.html", &context)

View file

@ -45,6 +45,7 @@ pub fn routes() -> Router {
// misc
.route("/notifs", get(misc::notifications_request))
.route("/requests", get(misc::requests_request))
.route("/achievements", get(misc::achievements_request))
.route("/doc/{*file_name}", get(misc::markdown_document_request))
.fallback_service(get(misc::not_found))
// mod
@ -131,11 +132,17 @@ pub fn routes() -> Router {
.route("/stacks", get(stacks::list_request))
.route("/stacks/{id}", get(stacks::feed_request))
.route("/stacks/{id}/manage", get(stacks::manage_request))
.route("/stacks/add_user/{id}", get(stacks::add_user_request))
// journals
.route("/journals", get(journals::redirect_request))
.route("/journals/{journal}/{note}", get(journals::app_request))
.route("/@{owner}/{journal}", get(journals::view_request))
.route("/@{owner}/{journal}", get(journals::index_view_request))
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
.route("/x/{note}", get(journals::global_view_request))
}
pub fn lw_routes() -> Router {
Router::new()
}
pub async fn render_error(
@ -154,6 +161,8 @@ pub async fn render_error(
pub struct PaginatedQuery {
#[serde(default)]
pub page: usize,
#[serde(default)]
pub before: usize,
}
#[derive(Deserialize)]
@ -174,6 +183,10 @@ pub struct ProfileQuery {
pub warning: bool,
#[serde(default)]
pub tag: String,
#[serde(default, alias = "r")]
pub responses_only: bool,
#[serde(default, alias = "f")]
pub force: bool,
}
#[derive(Deserialize)]
@ -196,4 +209,6 @@ pub struct RepostsQuery {
pub struct JournalsAppQuery {
#[serde(default)]
pub view: bool,
#[serde(default)]
pub tag: String,
}

View file

@ -194,10 +194,23 @@ pub async fn manage_profile_request(
out
};
let invite_code = if profile.invite_code != 0 {
match data.0.get_invite_code_by_id(profile.invite_code).await {
Ok(i) => match data.0.get_user_by_id(i.owner).await {
Ok(u) => Some((u, i)),
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
}
} else {
None
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("profile", &profile);
context.insert("invite", &invite_code);
context.insert("associations", &associations);
// return
@ -298,6 +311,35 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension<State>) ->
.unwrap(),
);
context.insert(
"table_users",
&data.0.get_table_row_count("users").await.unwrap_or(0),
);
context.insert(
"table_posts",
&data.0.get_table_row_count("posts").await.unwrap_or(0),
);
context.insert(
"table_invite_codes",
&data
.0
.get_table_row_count("invite_codes")
.await
.unwrap_or(0),
);
context.insert(
"table_uploads",
&data.0.get_table_row_count("uploads").await.unwrap_or(0),
);
context.insert(
"table_communities",
&data.0.get_table_row_count("communities").await.unwrap_or(0),
);
context.insert(
"table_ipbans",
&data.0.get_table_row_count("ipbans").await.unwrap_or(0),
);
// return
Ok(Html(data.1.render("mod/stats.html", &context).unwrap()))
}

View file

@ -11,7 +11,12 @@ use axum::{
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tera::Context;
use tetratto_core::model::{auth::User, communities::Community, permissions::FinePermission, Error};
use tetratto_core::model::{
auth::{DefaultProfileTabChoice, User},
communities::Community,
permissions::FinePermission,
Error,
};
use tetratto_shared::hash::hash;
use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD};
@ -94,6 +99,11 @@ pub async fn settings_request(
out
};
let ipblocks = match data.0.get_ipblocks_by_initiator(profile.id).await {
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
};
let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await {
Ok(ua) => ua,
Err(e) => {
@ -101,6 +111,22 @@ pub async fn settings_request(
}
};
let invites = match data
.0
.get_invite_codes_by_owner(profile.id, 12, req.page)
.await
{
Ok(l) => match data.0.fill_invite_codes(l).await {
Ok(l) => l,
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &None).await));
}
},
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &None).await));
}
};
let tokens = profile.tokens.clone();
let lang = get_lang!(jar, data.0);
@ -113,6 +139,8 @@ pub async fn settings_request(
context.insert("following", &following);
context.insert("blocks", &blocks);
context.insert("stackblocks", &stackblocks);
context.insert("ipblocks", &ipblocks);
context.insert("invites", &invites);
context.insert(
"user_tokens_serde",
&serde_json::to_string(&tokens)
@ -229,6 +257,10 @@ pub async fn posts_request(
check_user_blocked_or_private!(user, other_user, data, jar);
let responses_only = props.responses_only
| (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses
&& !props.force);
// check for warning
if props.warning {
let lang = get_lang!(jar, data.0);
@ -333,7 +365,13 @@ pub async fn posts_request(
);
// return
Ok(Html(data.1.render("profile/posts.html", &context).unwrap()))
if responses_only {
Ok(Html(
data.1.render("profile/responses.html", &context).unwrap(),
))
} else {
Ok(Html(data.1.render("profile/posts.html", &context).unwrap()))
}
}
/// `/@{username}/replies`

View file

@ -157,3 +157,41 @@ pub async fn manage_request(
// return
Ok(Html(data.1.render("stacks/manage.html", &context).unwrap()))
}
/// `/stacks/add_user`
pub async fn add_user_request(
jar: CookieJar,
Path(id): Path<usize>,
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 Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let add_user = match data.0.get_user_by_id(id).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
};
let stacks = match data.0.get_stacks_by_user(user.id).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("stacks", &stacks);
context.insert("add_user", &add_user);
// return
Ok(Html(
data.1.render("stacks/add_user.html", &context).unwrap(),
))
}

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
version = "9.0.0"
version = "10.0.0"
edition = "2024"
[dependencies]
@ -17,6 +17,6 @@ async-recursion = "1.1.1"
md-5 = "0.10.6"
base16ct = { version = "0.2.0", features = ["alloc"] }
base64 = "0.22.1"
emojis = "0.6.4"
emojis = "0.7.0"
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] }

View file

@ -13,6 +13,9 @@ pub struct SecurityConfig {
/// The name of the header which will contain the real IP of the connecting user.
#[serde(default = "default_real_ip_header")]
pub real_ip_header: String,
/// If users require an invite code to register. Invite codes can be generated by supporters.
#[serde(default = "default_enable_invite_codes")]
pub enable_invite_codes: bool,
}
fn default_security_registration_enabled() -> bool {
@ -23,11 +26,16 @@ fn default_real_ip_header() -> String {
"CF-Connecting-IP".to_string()
}
fn default_enable_invite_codes() -> bool {
false
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
registration_enabled: default_security_registration_enabled(),
real_ip_header: default_real_ip_header(),
enable_invite_codes: default_enable_invite_codes(),
}
}
}
@ -184,6 +192,8 @@ pub struct StripeConfig {
///
/// <https://docs.stripe.com/no-code/customer-portal>
pub billing_portal_url: String,
/// The text representation of the price of supporter. (like `$4 USD`)
pub supporter_price_text: String,
}
/// Manuals config (search help, etc)
@ -242,6 +252,10 @@ pub struct Config {
/// so this host should be included in there as well.
#[serde(default = "default_host")]
pub host: String,
/// The main public host of the littleweb server. **Not** used to check against banned hosts,
/// so this host should be included in there as well.
#[serde(default = "default_lw_host")]
pub lw_host: String,
/// Database security.
#[serde(default = "default_security")]
pub security: SecurityConfig,
@ -309,6 +323,10 @@ fn default_host() -> String {
String::new()
}
fn default_lw_host() -> String {
String::new()
}
fn default_security() -> SecurityConfig {
SecurityConfig::default()
}
@ -341,6 +359,8 @@ fn default_banned_usernames() -> Vec<String> {
"stacks".to_string(),
"stack".to_string(),
"search".to_string(),
"journals".to_string(),
"links".to_string(),
]
}
@ -373,6 +393,7 @@ impl Default for Config {
port: default_port(),
banned_hosts: default_banned_hosts(),
host: default_host(),
lw_host: default_lw_host(),
database: default_database(),
security: default_security(),
dirs: default_dirs(),

View file

@ -136,11 +136,11 @@ impl DataManager {
Ok(())
}
auto_method!(update_app_title(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr);
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants);

View file

@ -1,8 +1,11 @@
use super::common::NAME_REGEX;
use oiseau::cache::Cache;
use crate::model::auth::UserConnections;
use crate::model::auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS,
};
use crate::model::moderation::AuditLogEntry;
use crate::model::oauth::AuthGrant;
use crate::model::permissions::SecondaryPermission;
use crate::model::{
Error, Result,
auth::{Token, User, UserSettings},
@ -15,10 +18,73 @@ use tetratto_shared::{
unix_epoch_timestamp,
};
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_row, params};
use oiseau::PostgresRow;
macro_rules! update_role_fn {
($name:ident, $role_ty:ty, $col:literal) => {
pub async fn $name(
&self,
id: usize,
role: $role_ty,
user: User,
force: bool,
) -> Result<()> {
let other_user = self.get_user_by_id(id).await?;
use oiseau::{execute, get, query_row, params};
if !force {
// check permission
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
return Err(Error::MiscError(
"Cannot manage the role of other managers".to_string(),
));
}
if other_user.permissions == user.permissions {
return Err(Error::MiscError(
"Cannot manage users of equal level to you".to_string(),
));
}
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
&format!("UPDATE users SET {} = $1 WHERE id = $2", $col),
params![&(role.bits() as i32), &(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.cache_clear_user(&other_user).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!(
"invoked `{}` with x value `{}` and y value `{}`",
$col,
other_user.id,
role.bits()
),
))
.await?;
// ...
Ok(())
}
};
}
impl DataManager {
/// Get a [`User`] from an SQL row.
@ -45,6 +111,11 @@ impl DataManager {
stripe_id: get!(x->18(String)),
grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
invite_code: get!(x->21(i64)) as usize,
secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(),
achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(),
awaiting_purchase: get!(x->24(i32)) as i8 == 1,
was_purchased: get!(x->25(i32)) as i8 == 1,
}
}
@ -200,7 +271,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)",
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)",
params![
&(data.id as i64),
&(data.created as i64),
@ -210,19 +281,24 @@ impl DataManager {
&serde_json::to_string(&data.settings).unwrap(),
&serde_json::to_string(&data.tokens).unwrap(),
&(FinePermission::DEFAULT.bits() as i32),
&(if data.is_verified { 1_i32 } else { 0_i32 }),
&if data.is_verified { 1_i32 } else { 0_i32 },
&0_i32,
&0_i32,
&0_i32,
&(data.last_seen as i64),
&String::new(),
&"[]",
"[]",
&0_i32,
&0_i32,
&serde_json::to_string(&data.connections).unwrap(),
&"",
&serde_json::to_string(&data.grants).unwrap(),
&serde_json::to_string(&data.associated).unwrap(),
&(data.invite_code as i64),
&(SecondaryPermission::DEFAULT.bits() as i32),
&serde_json::to_string(&data.achievements).unwrap(),
&if data.awaiting_purchase { 1_i32 } else { 0_i32 },
&if data.was_purchased { 1_i32 } else { 0_i32 },
]
);
@ -389,6 +465,57 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// delete stackblocks
let res = execute!(
&conn,
"DELETE FROM stackblocks WHERE initiator = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete journals
let res = execute!(
&conn,
"DELETE FROM journals WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete notes
let res = execute!(&conn, "DELETE FROM notes WHERE owner = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete invite codes
let res = execute!(
&conn,
"DELETE FROM invite_codes WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete message reactions
let res = execute!(
&conn,
"DELETE FROM message_reactions WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?;
@ -437,6 +564,16 @@ impl DataManager {
self.delete_poll(poll.id, &user).await?;
}
// free up invite code
if self.0.0.security.enable_invite_codes {
if user.invite_code != 0 && self.get_invite_code_by_id(user.invite_code).await.is_ok() {
// we're checking if the code is ok because the owner might've deleted their account,
// deleting all of their invite codes as well
self.update_invite_code_is_used(user.invite_code, false)
.await?;
}
}
// ...
Ok(())
}
@ -557,35 +694,21 @@ impl DataManager {
Ok(())
}
pub async fn update_user_role(
pub async fn update_user_awaiting_purchased_status(
&self,
id: usize,
role: FinePermission,
x: bool,
user: User,
force: bool,
require_permission: bool,
) -> Result<()> {
let other_user = self.get_user_by_id(id).await?;
if !force {
// check permission
if (user.id != id) | require_permission {
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
return Err(Error::MiscError(
"Cannot manage the role of other managers".to_string(),
));
}
if other_user.permissions == user.permissions {
return Err(Error::MiscError(
"Cannot manage users of equal level to you".to_string(),
));
}
}
// ...
let other_user = self.get_user_by_id(id).await?;
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -593,8 +716,8 @@ impl DataManager {
let res = execute!(
&conn,
"UPDATE users SET permissions = $1 WHERE id = $2",
params![&(role.bits() as i32), &(id as i64)]
"UPDATE users SET awaiting_purchase = $1 WHERE id = $2",
params![&{ if x { 1 } else { 0 } }, &(id as i64)]
);
if let Err(e) = res {
@ -604,15 +727,16 @@ impl DataManager {
self.cache_clear_user(&other_user).await;
// create audit log entry
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!(
"invoked `update_user_role` with x value `{}` and y value `{}`",
other_user.id,
role.bits()
),
))
.await?;
if user.id != other_user.id {
self.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!(
"invoked `update_user_purchased_status` with x value `{}` and y value `{}`",
other_user.id, x
),
))
.await?;
}
// ...
Ok(())
@ -639,6 +763,84 @@ impl DataManager {
Ok(())
}
/// Add an achievement to a user.
///
/// Still returns `Ok` if the user already has the achievement.
#[async_recursion::async_recursion]
pub async fn add_achievement(
&self,
user: &mut User,
achievement: Achievement,
check_for_final: bool,
) -> Result<()> {
if user.settings.disable_achievements {
return Ok(());
}
if user
.achievements
.iter()
.find(|x| x.name == achievement.name)
.is_some()
{
return Ok(());
}
// send notif
self.create_notification(Notification::new(
"You've earned a new achievement!".to_string(),
format!(
"You've earned the \"{}\" [achievement](/achievements)!",
achievement.name.title()
),
user.id,
))
.await?;
// add achievement
user.achievements.push(achievement);
self.update_user_achievements(user.id, user.achievements.to_owned())
.await?;
// check for final
if check_for_final {
if user.achievements.len() + 1 == ACHIEVEMENTS {
self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false)
.await?;
}
}
// ...
Ok(())
}
/// Fill achievements with their title and description.
///
/// # Returns
/// `(name, description, rarity, achievement)`
pub fn fill_achievements(
&self,
mut list: Vec<Achievement>,
) -> Vec<(String, String, AchievementRarity, Achievement)> {
let mut out = Vec::new();
// sort by unlocked desc
list.sort_by(|a, b| a.unlocked.cmp(&b.unlocked));
list.reverse();
// ...
for x in list {
out.push((
x.name.title().to_string(),
x.name.description().to_string(),
x.name.rarity(),
x,
))
}
out
}
/// Validate a given TOTP code for the given profile.
pub fn check_totp(&self, ua: &User, code: &str) -> bool {
let totp = ua.totp(Some(
@ -777,11 +979,20 @@ impl DataManager {
.await;
}
update_role_fn!(update_user_role, FinePermission, "permissions");
update_role_fn!(
update_user_secondary_role,
SecondaryPermission,
"secondary_permissions"
);
auto_method!(update_user_tokens(Vec<Token>)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_grants(Vec<AuthGrant>)@get_user_by_id -> "UPDATE users SET grants = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_associated(Vec<usize>)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
@ -802,4 +1013,6 @@ impl DataManager {
auto_method!(update_user_request_count(i32)@get_user_by_id -> "UPDATE users SET request_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=request_count);
auto_method!(get_user_by_invite_code(i64)@get_user_from_row -> "SELECT * FROM users WHERE invite_code = $1" --name="user" --returns=User);
}

View file

@ -317,10 +317,10 @@ impl DataManager {
Ok(())
}
auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_members(Vec<usize>)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_members(Vec<usize>)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}");
auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
}

View file

@ -1,6 +1,6 @@
use crate::model::{Error, Result};
use super::{DataManager, drivers::common};
use oiseau::{cache::Cache, execute};
use oiseau::{cache::Cache, execute, query_row, params};
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
@ -38,6 +38,11 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap();
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
self.0
.1
@ -50,6 +55,26 @@ impl DataManager {
Ok(())
}
pub async fn get_table_row_count(&self, table: &str) -> Result<i32> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
&format!("SELECT COUNT(*)::int FROM {}", table),
params![],
|x| Ok(x.get::<usize, i32>(0))
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
}
#[macro_export]
@ -114,7 +139,8 @@ macro_rules! auto_method {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
let res =
oiseau::query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
if res.is_err() {
return Err(Error::GeneralNotFound($name_.to_string()));
@ -203,12 +229,12 @@ macro_rules! auto_method {
}
};
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => {
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal) => {
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -233,12 +259,12 @@ macro_rules! auto_method {
}
};
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -265,12 +291,12 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -296,12 +322,12 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -329,12 +355,12 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -364,20 +390,17 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
user.id,
format!(
"invoked `{}` with x value `{id}` and y value `{x:?}`",
stringify!($name)
),
format!("invoked `{}` with x value `{id}`", stringify!($name)),
))
.await?
}
@ -547,12 +570,12 @@ macro_rules! auto_method {
}
};
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -580,12 +603,12 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
@ -659,12 +682,12 @@ macro_rules! auto_method {
}
};
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
if user.id != y.owner {
if !user.permissions.check(FinePermission::$permission) {
if !user.permissions.check($permission) {
return Err(Error::NotAllowed);
} else {
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(

View file

@ -299,11 +299,10 @@ impl DataManager {
}
// add community owner as admin
self.create_membership(CommunityMembership::new(
data.owner,
data.id,
CommunityPermission::ADMINISTRATOR,
))
self.create_membership(
CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR),
&owner,
)
.await
.unwrap();
@ -522,10 +521,10 @@ impl DataManager {
Ok(())
}
auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);

View file

@ -0,0 +1,153 @@
use crate::model::{
auth::User,
littleweb::{Domain, DomainData, DomainTld},
permissions::{FinePermission, SecondaryPermission},
Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
impl DataManager {
/// Get a [`Domain`] from an SQL row.
pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain {
Domain {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
name: get!(x->3(String)),
tld: (get!(x->4(String)).as_str()).into(),
data: serde_json::from_str(&get!(x->5(String))).unwrap(),
}
}
auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}");
/// Get a domain given its name and TLD.
///
/// # Arguments
/// * `name`
/// * `tld`
pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> Result<Domain> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM domains WHERE name = $1 AND tld = $2",
&[&name, &tld.to_string()],
|x| { Ok(Self::get_domain_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("domain".to_string()));
}
Ok(res.unwrap())
}
/// Get all domains by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch domains for
pub async fn get_domains_by_user(&self, id: usize) -> Result<Vec<Domain>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM domains WHERE owner = $1 ORDER BY created DESC",
&[&(id as i64)],
|x| { Self::get_domain_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("domain".to_string()));
}
Ok(res.unwrap())
}
/// Create a new domain in the database.
///
/// # Arguments
/// * `data` - a mock [`Domain`] object to insert
pub async fn create_domain(&self, data: Domain) -> Result<Domain> {
// check values
if data.name.len() < 2 {
return Err(Error::DataTooShort("name".to_string()));
} else if data.name.len() > 128 {
return Err(Error::DataTooLong("name".to_string()));
}
// check for existing
if self
.get_domain_by_name_tld(&data.name, &data.tld)
.await
.is_ok()
{
return Err(Error::MiscError(
"Domain + TLD already in use. Maybe try another TLD!".to_string(),
));
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO domains VALUES ($1, $2, $3, $4, $5, $6)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.name,
&data.tld.to_string(),
&serde_json::to_string(&data.data).unwrap(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> {
let domain = self.get_domain_by_id(id).await?;
// check user permission
if user.id != domain.owner
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_DOMAINS)
{
return Err(Error::NotAllowed);
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM domains WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.0.1.remove(format!("atto.domain:{}", id)).await;
Ok(())
}
auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}");
}

View file

@ -25,3 +25,8 @@ pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql");
pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql");
pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql");
pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql");
pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS domains (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
name TEXT NOT NULL,
tld TEXT NOT NULL,
data TEXT NOT NULL
)

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS invite_codes (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
code TEXT NOT NULL,
is_used INT NOT NULL
)

View file

@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals (
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
title TEXT NOT NULL,
privacy TEXT NOT NULL
privacy TEXT NOT NULL,
dirs TEXT NOT NUll
)

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS layouts (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
title TEXT NOT NULL,
privacy TEXT NOT NULL,
pages TEXT NOT NULL,
replaces TEXT NOT NULL
)

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS message_reactions (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
message BIGINT NOT NULL,
emoji TEXT NOT NULL,
UNIQUE (owner, message, emoji)
)

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages (
created BIGINT NOT NULL,
edited BIGINT NOT NULL,
content TEXT NOT NULL,
context TEXT NOT NULL
context TEXT NOT NULL,
reactions TEXT NOT NULL
)

Some files were not shown because too many files have changed in this diff Show more