Compare commits

..

74 commits

Author SHA1 Message Date
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
57a69eea50 add: increase note character limit (16384 (*16)-> 262144) 2025-06-19 15:52:46 -04:00
c1568ad866 add: journals + notes 2025-06-19 15:48:04 -04:00
c08a26ae8d fix: color picker setting mirror 2025-06-19 00:20:04 -04:00
1aab2f1b97 add: make hide_dislikes disable post dislikes entirely 2025-06-18 21:32:05 -04:00
42421bd906 add: full journals api
add: full notes api
2025-06-18 21:00:07 -04:00
102ea0ee35 add: journals/notes database interfaces 2025-06-18 19:21:01 -04:00
0f48a46c40 fix: infinite scrolling likes 2025-06-17 16:37:47 -04:00
3027b679db add: expand infinite scrolling to stacks and profiles 2025-06-17 14:28:18 -04:00
2b253c811c add: infinitely scrolling timelines 2025-06-17 01:52:17 -04:00
822aaed0c8 add: increase image proxy limit for supporters 2025-06-16 19:50:10 -04:00
c55d8bd38b fix: post page reposting 2025-06-16 19:08:40 -04:00
a6aa2488c4 add: hide simple reposts you cannot view
quotes still show "Could not find original post..." when you cannot view the post that was quoted
2025-06-16 18:32:22 -04:00
844e60df30 add: serve csp through header 2025-06-15 23:52:33 -04:00
dd8e6561e6 fix: disable setreponseheaderlayer
there appears to be a bug in it possibly
2025-06-15 23:40:36 -04:00
83c6df6f6e fix: use image/avif as default avatar mime
fix: disable cross-origin iframes
2025-06-15 23:35:19 -04:00
a43e586e4c fix: don't send comment notif if our profile is private and we aren't following post owner 2025-06-15 19:26:52 -04:00
b7b84d15b7 add: style blockquotes 2025-06-15 19:19:41 -04:00
8c5d8bf0ba fix: circle stack users ui 2025-06-15 19:04:56 -04:00
9443bfb58d add: order dms by last message time 2025-06-15 18:55:19 -04:00
0af95e517d fix: chat stream links 2025-06-15 18:38:37 -04:00
a7c0046762 fix: upload only post likes ui 2025-06-15 18:22:29 -04:00
131 changed files with 8201 additions and 1066 deletions

8
Cargo.lock generated
View file

@ -3231,7 +3231,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "8.0.0"
version = "10.0.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3262,7 +3262,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "8.0.0"
version = "10.0.0"
dependencies = [
"async-recursion",
"base16ct",
@ -3284,7 +3284,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
version = "8.0.0"
version = "10.0.0"
dependencies = [
"pathbufd",
"serde",
@ -3293,7 +3293,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
version = "8.0.0"
version = "10.0.0"
dependencies = [
"ammonia",
"chrono",

View file

@ -33,6 +33,8 @@ 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.
## 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 = "8.0.0"
version = "10.0.0"
edition = "2024"
[dependencies]
@ -9,7 +9,7 @@ serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic"] }
tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] }
axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }

View file

@ -32,12 +32,14 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
pub const ROOT_CSS: &str = include_str!("./public/css/root.css");
pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css");
pub const CHATS_CSS: &str = include_str!("./public/css/chats.css");
// js
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");
// html
pub const BODY: &str = include_str!("./public/html/body.lisp");
@ -49,6 +51,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");
@ -97,6 +100,7 @@ pub const TIMELINES_FOLLOWING_QUESTIONS: &str =
pub const TIMELINES_ALL_QUESTIONS: &str =
include_str!("./public/html/timelines/all_questions.lisp");
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp");
pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp");
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp");
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp");
@ -114,6 +118,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");
@ -124,6 +129,8 @@ pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp
pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp");
pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp");
pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -149,7 +156,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());
@ -174,7 +181,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();
}};
}
@ -342,6 +350,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);
@ -385,6 +394,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins);
write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins);
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins);
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins);
@ -402,6 +412,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);
@ -412,6 +423,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins);
write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins);
write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins);
html_path
}
@ -487,6 +500,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

@ -16,6 +16,8 @@ version = "1.0.0"
"general:link.ip_bans" = "IP bans"
"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"
@ -38,6 +40,9 @@ version = "1.0.0"
"general:label.account_banned" = "Account banned"
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
"general:label.better_with_account" = "It's better with an account! Login or sign up to explore more."
"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.supporter_motivation" = "Become a supporter!"
"general:action.become_supporter" = "Become supporter"
@ -131,6 +136,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"
@ -162,10 +169,13 @@ version = "1.0.0"
"settings:label.export" = "Export"
"settings:label.manage_blocks" = "Manage blocks"
"settings:label.users" = "Users"
"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"
@ -173,6 +183,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"
@ -228,3 +241,22 @@ version = "1.0.0"
"developer:label.guides_and_help" = "Guides & help"
"developer:action.delete" = "Delete app"
"developer:action.authorize" = "Authorize"
"journals:label.my_journals" = "My journals"
"journals:action.create_journal" = "Create journal"
"journals:action.create_note" = "Create note"
"journals:label.welcome" = "Welcome to Journals!"
"journals:label.select_a_journal" = "Select or create a journal to get started."
"journals:label.select_a_note" = "Select or create a note in this journal to get started."
"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"

View file

@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private {
// check if other user is banned
if $other_user.permissions.check_banned() {
if let Some(ref ua) = $user {
if !ua.permissions.check(FinePermission::MANAGE_USERS) {
if !ua
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
{
$crate::user_banned!($user, $other_user, $data, $jar);
}
} else {
@ -213,7 +216,9 @@ macro_rules! check_user_blocked_or_private {
.get_user_stack_blocked_users($other_user.id)
.await
.contains(&ua.id))
&& !ua.permissions.check(FinePermission::MANAGE_USERS)
&& !ua
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
{
let lang = get_lang!($jar, $data.0);
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
@ -238,7 +243,9 @@ macro_rules! check_user_blocked_or_private {
if $other_user.settings.private_profile {
if let Some(ref ua) = $user {
if (ua.id != $other_user.id)
&& !ua.permissions.check(FinePermission::MANAGE_USERS)
&& !ua
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
&& $data
.0
.get_userfollow_by_initiator_receiver($other_user.id, ua.id)

View file

@ -11,12 +11,16 @@ use assets::{init_dirs, write_assets};
use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji};
pub use tetratto_core::*;
use axum::{Extension, Router};
use axum::{
http::{HeaderName, HeaderValue},
Extension, Router,
};
use reqwest::Client;
use tera::{Tera, Value};
use tower_http::{
trace::{self, TraceLayer},
catch_panic::CatchPanicLayer,
set_header::SetResponseHeaderLayer,
trace::{self, TraceLayer},
};
use tracing::{Level, info};
@ -34,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())
}
@ -74,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;
@ -98,6 +110,7 @@ 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();
@ -115,6 +128,10 @@ 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))

View file

@ -0,0 +1,232 @@
:root {
--list-bar-width: 64px;
--channels-bar-width: 256px;
--sidebar-height: calc(100dvh - 42px);
--channel-header-height: 48px;
}
html,
body {
overflow: hidden;
}
.name.shortest {
max-width: 165px;
overflow-wrap: normal;
}
.send_button {
width: 48px;
height: 48px;
}
.send_button .icon {
width: 2em;
height: 2em;
}
a.channel_icon {
width: 48px;
height: 48px;
min-height: 48px;
}
a.channel_icon .icon {
min-width: 24px;
height: 24px;
}
a.channel_icon.small {
width: 24px;
height: 24px;
min-height: 24px;
}
a.channel_icon.small .icon {
min-width: 12px;
height: 12px;
}
a.channel_icon:has(img) {
padding: 0;
}
a.channel_icon img {
min-width: 48px;
min-height: 48px;
}
a.channel_icon img,
a.channel_icon:has(.icon) {
transition:
outline 0.25s,
background 0.15s !important;
}
a.channel_icon:not(.selected):hover img,
a.channel_icon:not(.selected):hover:has(.icon) {
outline: solid 1px var(--color-text);
}
a.channel_icon.selected img,
a.channel_icon.selected:has(.icon) {
outline: solid 2px var(--color-text);
}
nav {
background: var(--color-raised);
color: var(--color-text-raised) !important;
height: 42px;
position: sticky !important;
}
nav::after {
display: block;
position: absolute;
background: var(--color-super-lowered);
height: 1px;
width: calc(100% - var(--list-bar-width));
bottom: 0;
left: var(--list-bar-width);
content: "";
}
nav .content_container {
max-width: 100% !important;
width: 100%;
}
.chats_nav {
display: none;
padding: 0;
}
.chats_nav button {
justify-content: flex-start;
width: 100% !important;
flex-direction: row !important;
font-size: 16px !important;
margin-top: -4px;
}
.chats_nav button svg {
margin-right: var(--pad-4);
}
.sidebar {
background: var(--color-raised);
color: var(--color-text-raised);
border-right: solid 1px var(--color-super-lowered);
padding: 0.4rem;
width: max-content;
height: var(--sidebar-height);
overflow: auto;
transition: left 0.15s;
z-index: 2;
}
.sidebar .title:not(.dropdown *) {
padding: var(--pad-4);
border-bottom: solid 1px var(--color-super-lowered);
}
.sidebar#channels_list {
width: var(--channels-bar-width);
background: var(--color-surface);
color: var(--color-text);
}
.sidebar#notes_list {
width: calc(var(--channels-bar-width) + var(--list-bar-width));
flex: 1 0 auto;
}
#stream {
width: calc(
100dvw - var(--list-bar-width) - var(--channels-bar-width)
) !important;
height: var(--sidebar-height);
}
.message {
transition: background 0.15s;
box-shadow: none;
position: relative;
}
.message:hover {
background: var(--color-raised);
}
.message:hover .hidden,
.message:focus .hidden,
.message:active .hidden {
display: flex !important;
}
.message.grouped {
padding: var(--pad-1) var(--pad-4) var(--pad-1)
calc(var(--pad-4) + var(--pad-2) + 42px);
}
turbo-frame {
display: contents;
}
.channel_header {
height: var(--channel-header-height);
}
.members_list_half {
padding-top: var(--pad-4);
border-top: solid 1px var(--color-super-lowered);
}
.channels_list_half:not(.no_members),
.members_list_half {
overflow: auto;
height: calc(
(var(--sidebar-height) - var(--channel-header-height) - 8rem) / 2
);
}
@media screen and (max-width: 900px) {
:root {
--sidebar-height: calc(100dvh - 42px * 2);
}
.message.grouped {
padding: var(--pad-1) var(--pad-4) var(--pad-1)
calc(var(--pad-4) + var(--pad-2) + 31px);
}
body:not(.sidebars_shown) .sidebar {
position: absolute;
left: -200%;
}
body.sidebars_shown .sidebar {
position: absolute;
}
#stream {
width: 100dvw !important;
height: var(--sidebar-height);
}
nav::after {
width: 100dvw;
left: 0;
}
.chats_nav {
display: flex;
}
nav:has(+ .chats_nav) .dropdown .inner {
top: calc(100% + 44px);
}
.padded_section {
padding: 0 !important;
}
}

View file

@ -116,7 +116,7 @@ article {
padding: 0;
}
body .card:not(.card *):not(#stream *):not(.user_plate),
body .card:not(.card *):not(.user_plate),
body .pillmenu:not(.card *) > a,
body .card-nest:not(.card *) > .card,
body .banner {
@ -213,6 +213,14 @@ ol {
margin-left: var(--pad-4);
}
pre {
padding: var(--pad-4);
}
code {
padding: 0;
}
pre,
code {
font-family: "Jetbrains Mono", "Fire Code", monospace;
@ -221,18 +229,12 @@ code {
overflow: auto;
background: var(--color-lowered);
border-radius: var(--radius);
padding: var(--pad-1);
font-size: 0.8rem;
}
pre {
padding: var(--pad-4);
}
svg.icon {
stroke: currentColor;
width: 18px;
width: 1em;
height: 1em;
}
@ -263,7 +265,6 @@ code {
overflow-wrap: normal;
text-wrap: pretty;
word-wrap: break-word;
overflow-wrap: anywhere;
}
h1,
@ -275,7 +276,6 @@ h6 {
margin: 0;
font-weight: 700;
width: -moz-max-content;
width: max-content;
position: relative;
max-width: 100%;
}
@ -344,3 +344,56 @@ img.emoji {
height: 1em;
aspect-ratio: 1 / 1;
}
blockquote {
padding-left: 1rem;
border-left: solid 5px var(--color-super-lowered);
font-style: italic;
}
.skel {
display: block;
border-radius: var(--radius);
background: var(--color-raised);
animation: skel ease-in-out infinite 2s forwards running;
transition: opacity 0.15s;
}
@keyframes skel {
from {
background: var(--color-raised);
}
50% {
background: var(--color-lowered);
}
to {
background: var(--color-raised);
}
}
.loader {
animation: spin linear infinite 2s forwards running;
display: flex;
justify-content: center;
align-items: center;
}
@keyframes spin {
from {
transform: rotateZ(0deg);
}
to {
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

@ -273,6 +273,12 @@ button,
font-weight: 600;
}
button:disabled,
.button:disabled {
cursor: not-allowed;
opacity: 50%;
}
button.small,
.button.small {
/* min-height: max-content; */
@ -407,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;
@ -565,11 +609,9 @@ select:focus {
nav {
background: var(--color-primary);
color: var(--color-text-primary) !important;
color: inherit;
width: 100%;
display: flex;
justify-content: space-between;
color: var(--color-text);
position: sticky;
top: 0;
z-index: 6374;
@ -716,19 +758,25 @@ nav .button:not(.title):not(.active):hover {
border-bottom-right-radius: var(--radius) !important;
}
@media screen and (min-width: 900px) {
.mobile_nav:not(.mobile) {
border-radius: var(--radius);
border: solid 1px var(--color-super-lowered);
}
}
/* dialog */
dialog {
padding: 0;
position: fixed;
bottom: 0;
top: 0;
display: flex;
display: none;
background: var(--color-surface);
border: solid 1px var(--color-super-lowered) !important;
border-radius: var(--radius);
max-width: 100%;
border-style: none;
display: none;
margin: auto;
color: var(--color-text);
animation: popin ease-in-out 1 0.1s forwards running;
@ -1055,14 +1103,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;
@ -1075,7 +1123,7 @@ details[open] summary::after {
animation: fadein ease-in-out 1 0.1s forwards running;
}
details .card {
details > .card {
background: var(--color-super-raised);
}
@ -1116,3 +1164,156 @@ details.accordion .inner {
border: solid 1px var(--color-super-lowered);
border-top: none;
}
/* codemirror */
.CodeMirror {
color: var(--color-text) !important;
}
.CodeMirror {
background: transparent !important;
font-family: inherit !important;
height: 10rem !important;
min-height: 100%;
max-height: 100%;
cursor: text;
}
.CodeMirror-cursor {
border-color: rgb(0, 0, 0) !important;
}
.CodeMirror-cursor:is(.dark *) {
border-color: rgb(255, 255, 255) !important;
}
.CodeMirror-cursor {
height: 22px !important;
}
[role="presentation"]::-moz-selection,
[role="presentation"] *::-moz-selection {
background-color: rgb(191, 219, 254) !important;
}
[role="presentation"]::selection,
[role="presentation"] *::selection,
.CodeMirror-selected {
background-color: rgb(191, 219, 254) !important;
}
[role="presentation"]:is(.dark *)::-moz-selection,
[role="presentation"] *:is(.dark *)::-moz-selection {
background-color: rgb(64, 64, 64) !important;
}
[role="presentation"]:is(.dark *)::selection,
[role="presentation"] *:is(.dark *)::selection,
.CodeMirror-selected:is(.dark *) {
background-color: rgb(64, 64, 64) !important;
}
.cm-header {
color: inherit !important;
}
.cm-variable-2,
.cm-quote,
.cm-keyword,
.cm-string,
.cm-atom {
color: rgb(63, 98, 18) !important;
}
.cm-variable-2:is(.dark *),
.cm-quote:is(.dark *),
.cm-keyword:is(.dark *),
.cm-string:is(.dark *),
.cm-atom:is(.dark *) {
color: rgb(217, 249, 157) !important;
}
.cm-comment {
color: rgb(153 27 27) !important;
}
.cm-comment:is(.dark *) {
color: rgb(254, 202, 202) !important;
}
.cm-comment {
font-family: ui-monospace, monospace;
}
.cm-link {
color: var(--color-link) !important;
}
.cm-url,
.cm-property,
.cm-qualifier {
color: rgb(29, 78, 216) !important;
}
.cm-url:is(.dark *),
.cm-property:is(.dark *),
.cm-qualifier:is(.dark *) {
color: rgb(191, 219, 254) !important;
}
.cm-variable-3,
.cm-tag,
.cm-def,
.cm-attribute,
.cm-number {
color: rgb(91, 33, 182) !important;
}
.cm-variable-3:is(.dark *),
.cm-tag:is(.dark *),
.cm-def:is(.dark *),
.cm-attribute:is(.dark *),
.cm-number:is(.dark *) {
color: rgb(221, 214, 254) !important;
}
.CodeMirror {
height: auto !important;
}
.CodeMirror-line {
padding-left: 0 !important;
}
.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

@ -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

@ -90,7 +90,7 @@
}),
})
.then((res) => res.json())
.then((res) => {
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
@ -98,7 +98,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,20 @@
("required" "")
("name" "password")
("id" "password")))
(text "{% if config.security.enable_invite_codes -%}")
(div
("class" "flex flex-col gap-1")
(label
("for" "invite_code")
(b
(text "Invite code")))
(input
("type" "text")
("placeholder" "invite code")
("required" "")
("name" "invite_code")
("id" "invite_code")))
(text "{%- endif %}")
(hr)
(div
("class" "card-nest w-full")
@ -56,7 +70,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")
@ -89,10 +103,11 @@
captcha_response: e.target.querySelector(
\"[name=cf-turnstile-response]\",
).value,
invite_code: (e.target.invite_code || { value: \"\" }).value,
}),
})
.then((res) => res.json())
.then((res) => {
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
@ -100,7 +115,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,9 +1,75 @@
(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")
(div
("class" "flex flex-col gap-2")
("ui_ident" "loading_skel")
(div
("class" "card lowered green flex items-center gap-2")
(div ("class" "loader") (icon (text "loader-circle")))
(span (str (text "general:label.loading"))))
(div
("class" "card secondary flex gap-2")
(div ("class" "skel avatar"))
(div
("class" "flex flex-col gap-2 w-full")
(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 +119,10 @@
console.log(\"socket disconnect\");
}
}
if (window.location.pathname.startsWith(\"/reference\")) {
window.location.reload();
}
});
</script>
{%- endif %}")
@ -228,6 +298,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 }}\";
@ -243,21 +324,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

@ -1,7 +1,7 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Chats - {{ config.name }}"))
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}")
(nav
("class" "chats_nav")
@ -16,7 +16,6 @@
(b
(text "{{ text \"chats:label.my_chats\" }}"))
(text "{%- endif %}")))
(div
("class" "flex")
(div
@ -87,7 +86,7 @@
(text "{{ components::user_plate(user=user, show_menu=true) }}"))
(text "{% if channel -%}")
(div
("class" "w-full flex flex-col gap-2")
("class" "w-full flex flex-col gap-2 padded_section")
("id" "stream")
("style" "padding: var(--pad-4)")
(turbo-frame
@ -110,225 +109,23 @@
("title" "Send")
(text "{{ icon \"send-horizontal\" }}"))))
(text "{%- endif %}")
(style
(text ":root {
--list-bar-width: 64px;
--channels-bar-width: 256px;
--sidebar-height: calc(100dvh - 42px);
--channel-header-height: 48px;
; 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;
}
html,
body {
overflow: hidden;
}
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]);
});"))
.name.shortest {
max-width: 165px;
overflow-wrap: normal;
}
.send_button {
width: 48px;
height: 48px;
}
.send_button .icon {
width: 2em;
height: 2em;
}
a.channel_icon {
width: 48px;
height: 48px;
min-height: 48px;
}
a.channel_icon .icon {
min-width: 24px;
height: 24px;
}
a.channel_icon.small {
width: 24px;
height: 24px;
min-height: 24px;
}
a.channel_icon.small .icon {
min-width: 12px;
height: 12px;
}
a.channel_icon:has(img) {
padding: 0;
}
a.channel_icon img {
min-width: 48px;
min-height: 48px;
}
a.channel_icon img,
a.channel_icon:has(.icon) {
transition:
outline 0.25s,
background 0.15s !important;
}
a.channel_icon:not(.selected):hover img,
a.channel_icon:not(.selected):hover:has(.icon) {
outline: solid 1px var(--color-text);
}
a.channel_icon.selected img,
a.channel_icon.selected:has(.icon) {
outline: solid 2px var(--color-text);
}
nav {
background: var(--color-raised);
color: var(--color-text-raised) !important;
height: 42px;
position: sticky !important;
}
nav::after {
display: block;
position: absolute;
background: var(--color-super-lowered);
height: 1px;
width: calc(100% - var(--list-bar-width));
bottom: 0;
left: var(--list-bar-width);
content: \"\";
}
nav .content_container {
max-width: 100% !important;
width: 100%;
}
.chats_nav {
display: none;
padding: 0;
}
.chats_nav button {
justify-content: flex-start;
width: 100% !important;
flex-direction: row !important;
font-size: 16px !important;
margin-top: -4px;
}
.chats_nav button svg {
margin-right: var(--pad-4);
}
.sidebar {
background: var(--color-raised);
color: var(--color-text-raised);
border-right: solid 1px var(--color-super-lowered);
padding: 0.4rem;
width: max-content;
height: var(--sidebar-height);
overflow: auto;
transition: left 0.15s;
z-index: 1;
}
.sidebar .title:not(.dropdown *) {
padding: var(--pad-4);
border-bottom: solid 1px var(--color-super-lowered);
}
.sidebar#channels_list {
width: var(--channels-bar-width);
background: var(--color-surface);
color: var(--color-text);
}
#stream {
width: calc(
100dvw - var(--list-bar-width) - var(--channels-bar-width)
) !important;
height: var(--sidebar-height);
}
.message {
transition: background 0.15s;
box-shadow: none;
position: relative;
}
.message:hover {
background: var(--color-raised);
}
.message:hover .hidden,
.message:focus .hidden,
.message:active .hidden {
display: flex !important;
}
.message.grouped {
padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px);
}
turbo-frame {
display: contents;
}
.channel_header {
height: var(--channel-header-height);
}
.members_list_half {
padding-top: var(--pad-4);
border-top: solid 1px var(--color-super-lowered);
}
.channels_list_half:not(.no_members),
.members_list_half {
overflow: auto;
height: calc(
(var(--sidebar-height) - var(--channel-header-height) - 8rem) /
2
);
}
@media screen and (max-width: 900px) {
:root {
--sidebar-height: calc(100dvh - 42px * 2);
}
.message.grouped {
padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px);
}
body:not(.sidebars_shown) .sidebar {
position: absolute;
left: -200%;
}
body.sidebars_shown .sidebar {
position: absolute;
}
#stream {
width: 100dvw !important;
height: var(--sidebar-height);
}
nav::after {
width: 100dvw;
left: 0;
}
.chats_nav {
display: flex;
}
}"))
; ...
(script
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
@ -654,6 +451,7 @@
const clean_text = () => {
trigger(\"atto::clean_date_codes\");
trigger(\"atto::hooks::online_indicator\");
trigger(\"atto::hooks::check_message_reactions\");
};
document.addEventListener(
@ -684,5 +482,4 @@
}
}, 100);"))
(text "{%- endif %}"))
(text "{% endblock %}")

View file

@ -10,7 +10,7 @@
(b
(text "{{ text \"chats:label.viewing_old_messages\" }}"))
(a
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}")
("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page - 1}}")
("class" "button small")
("onclick" "window.CURRENT_PAGE -= 1")
(text "{{ text \"chats:label.go_back\" }}")))
@ -20,7 +20,7 @@
(b
(text "{{ text \"chats:label.viewing_single_message\" }}"))
(a
("href" "/chats/{{ community }}/{{ channel }}?page={{ page }}")
("href" "/chats/{{ community }}/{{ channel.id }}?page={{ page }}")
("class" "button small")
("onclick" "window.VIEWING_SINGLE = false")
("target" "_top")
@ -30,7 +30,7 @@
("class" "flex gap-2 w-full justify-center")
(a
("class" "button")
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}")
("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page + 1 }}")
("onclick" "window.CURRENT_PAGE += 1")
(text "{{ icon \"clock\" }}")
(span

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

@ -72,7 +72,7 @@
("style" "display: contents")
(text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}"))
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false) -%}")
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}")
(button
("title" "Like")
("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small")
@ -83,7 +83,7 @@
(text "{{ likes }}"))
(text "{%- endif %}"))
(text "{% if not user or not user.settings.hide_dislikes -%}")
(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}")
(button
("title" "Dislike")
("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small")
@ -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")
("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,10 +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 {% if secondary -%}secondary{%- endif %}")
("id" "post:{{ post.id }}")
("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")
@ -174,10 +174,11 @@
("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %} {% if post.stack -%}")
(span
(a
("title" "Posted to a stack you're in")
("class" "flex items-center")
("class" "flex items-center flush")
("style" "color: var(--color-primary)")
("href" "/stacks/{{ post.stack }}")
(text "{{ icon \"layers\" }}"))
(text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}")
(span
@ -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,12 +235,13 @@
(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\" }}")
(span
(text "Could not find original post...")))
(str (text "general:label.could_not_find_post"))))
(text "{%- endif %} {%- endif %}"))
(text "{{ self::post_media(upload_ids=post.uploads) }} {% else %}")
(details
@ -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\" }}")
@ -289,7 +290,7 @@
("class" "flex gap-1 reactions_box")
("hook" "check_reactions")
("hook-arg:id" "{{ post.id }}")
(text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}")
(text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes, disable_dislikes=owner.settings.hide_dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}")
(a
("href" "/post/{{ post.context.repost.reposting }}")
("class" "button small camo")
@ -329,7 +330,7 @@
("class" "title")
(text "{{ text \"general:label.share\" }}"))
(button
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}'])")
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.repost\" }}")))
@ -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 %}")
@ -612,7 +625,7 @@
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
(div
("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2")
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
(text "{% if owner.id == 0 -%}")
(span
(text "{% if profile and profile.settings.anonymous_avatar_url -%}")
@ -676,7 +689,10 @@
(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 %}")
@ -693,7 +709,7 @@
(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) -%}")
(div
("class" "card-nest")
(div
@ -707,6 +723,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 +740,83 @@
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
("class" "flex gap-2")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(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 %}"))))
(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\",
}),
);
// ...
fetch(\"/api/v1/questions\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
@ -747,6 +827,10 @@
if (res.ok) {
e.target.reset();
if (globalThis.gerald) {
globalThis.gerald.clear();
}
}
});
}"))
@ -956,9 +1040,29 @@
(text "{%- endif %}")
(div
("class" "flex w-full gap-2 justify-between")
(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")
@ -976,6 +1080,16 @@
(text "{{ icon \"circle-user-round\" }}")
(span
(text "{{ text \"auth:link.my_profile\" }}")))
(a
("href" "/journals/0/0")
(icon (text "notebook"))
(str (text "general:link.journals")))
(text "{% if not user.settings.disable_achievements -%}")
(a
("href" "/achievements")
(icon (text "award"))
(str (text "general:link.achievements")))
(text "{%- endif %}")
(a
("href" "/settings")
(text "{{ icon \"settings\" }}")
@ -1019,6 +1133,18 @@
("data-turbo" "false")
(icon (text "rabbit"))
(str (text "general:link.reference")))
(a
("href" "{{ config.policies.terms_of_service }}")
("class" "button")
(icon (text "heart-handshake"))
(text "Terms of service"))
(a
("href" "{{ config.policies.privacy }}")
("class" "button")
(icon (text "cookie"))
(text "Privacy policy"))
(b ("class" "title") (str (text "general:label.account")))
(button
("onclick" "trigger('me::switch_account')")
@ -1099,13 +1225,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
@ -1151,19 +1279,40 @@
}
if (event.detail.unicode) {
if (window.EMOJI_PICKER_MODE === \"replace\") {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${await (
).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 {
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();
});"))
@ -1271,7 +1420,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
@ -1286,7 +1435,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")
@ -1346,11 +1508,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,
@ -1839,3 +2001,261 @@
(text "{{ stack.created }}"))
(text "; {{ stack.privacy }}; {{ stack.users|length }} users")))
(text "{%- endmacro %}")
(text "{% macro journal_listing(journal, notes, selected_note, selected_journal, view_mode=false, owner=false) -%}")
(text "{% if selected_journal != journal.id -%}")
; not selected
(div
("class" "flex flex-row gap-1")
(a
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
("class" "button justify-start lowered w-full")
(icon (text "notebook"))
(text "{{ journal.title }}"))
(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" "delete_journal('{{ journal.id }}')")
("class" "red")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{% else %}")
; selected
(div
("class" "flex flex-row gap-1")
(button
("class" "justify-start lowered w-full")
(icon (text "arrow-down"))
(text "{{ journal.title }}"))
(text "{% if user and user.id == journal.owner -%}")
(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")
(a
("class" "button")
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
(icon (text "house"))
(str (text "general:link.home")))
(button
("onclick" "delete_journal('{{ journal.id }}')")
("class" "red")
(icon (text "trash"))
(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)")
; create note
(text "{% if user and user.id == journal.owner -%}")
(button
("class" "lowered justify-start w-full")
("onclick" "create_note()")
(icon (text "plus"))
(str (text "journals:action.create_note")))
(text "{%- endif %}")
; note listings
(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 %}")

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

@ -0,0 +1,967 @@
(text "{% extends \"root.html\" %} {% block head %}")
(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 }}"))
(text "{% else %}")
; redirect to journal homepage
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
(text "{%- endif %} {%- endif %}")
(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")
(button
("class" "flex gap-2 items-center active")
("onclick" "toggle_sidebars(event)")
(text "{{ icon \"panel-left\" }} {% if community -%}")
(b
("class" "name shorter")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
(text "{% else %}")
(b
(text "{{ text \"journals:label.my_journals\" }}"))
(text "{%- endif %}")))
(text "{%- endif %}")
(div
("class" "flex")
; journals/notes listing
(text "{% if not view_mode -%}")
; this isn't shown if we're in view mode
(div
("class" "sidebar flex flex-col gap-2 justify-between")
("id" "notes_list")
(div
("class" "flex flex-col gap-2 w-full")
(button
("class" "lowered justify-start w-full")
("onclick" "create_journal()")
(icon (text "plus"))
(str (text "journals:action.create_journal")))
(text "{% for journal in journals %}")
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}")
(text "{% endfor %}")))
(text "{%- endif %}")
; editor
(div
("class" "w-full padded_section")
("id" "editor")
("style" "padding: var(--pad-4)")
(main
("class" "flex flex-col gap-2")
; the journal/note header is always shown
(text "{% if journal and not global_mode -%}")
(div
("class" "mobile_nav w-full flex items-center justify-between gap-2")
(div
("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\") }}"))
(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 user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
(div
("class" "pillmenu")
(a
("class" "{% if not view_mode -%}active{%- endif %}")
("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}")
("data-turbo" "false")
(icon (text "pencil")))
(a
("class" "{% if view_mode -%}active{%- endif %}")
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
(icon (text "eye"))))
(text "{%- endif %}"))
(text "{%- endif %}")
; we're going to put some help panes in here if something is 0
; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar
(text "{% if selected_journal == 0 -%}")
; no journal selected
(div
("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"))
(str (text "journals:action.create_journal"))))
(text "{% elif selected_note == 0 -%}")
; journal selected, but no note is selected
(text "{% if not view_mode -%}")
; we're the journal owner and we're not in view mode
(div
("class" "card w-full flex flex-col gap-2")
(h3 (text "{{ journal.title }}"))
(span (str (text "journals:label.select_a_note")))
(button
("onclick" "create_note()")
(icon (text "plus"))
(str (text "journals:action.create_note"))))
; we'll also let users edit the journal's settings here i guess
(details
("class" "w-full")
(summary
("class" "button lowered w-full justify-start")
(icon (text "settings"))
(str (text "general:action.manage")))
(div
("class" "card flex flex-col gap-2 lowered")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "Privacy")))
(div
("class" "card")
(select
("onchange" "change_journal_privacy(event)")
(option
("value" "Private")
("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}")
(text "Private"))
(option
("value" "Public")
("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}")
(text "Public")))))
(div
("class" "card-nest")
(div
("class" "card small")
(label
("for" "title")
(b (str (text "communities:label.title")))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_journal_title(event)")
(div
("class" "flex flex-col gap-1")
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(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 %}")
; journal AND note selected
(text "{% if not view_mode -%}")
; not view mode; show editor
; 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"))
(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
("href" "#/editor")
("data-tab-button" "editor")
("data-turbo" "false")
("class" "active")
(str (text "journals:label.editor")))
(a
("href" "#/preview")
("data-tab-button" "preview")
("data-turbo" "false")
(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")
(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")
("class" "flex flex-col gap-2 card hidden")
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
("id" "preview_tab"))
(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(async () => {
document.getElementById(\"editor_tab\").innerHTML = \"\";
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML,
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,
inputStyle: \"contenteditable\",
highlightFormatting: false,
fencedCodeBlockHighlighting: false,
xml: false,
smartIndent: true,
indentUnit: 4,
placeholder: `# {{ note.title }}`,
extraKeys: {
Home: \"goLineLeft\",
End: \"goLineRight\",
Enter: (cm) => {
cm.replaceSelection(\"\\n\");
},
},
});
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\"]);
});
document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => {
e.preventDefault();
const res = await (
await fetch(\"/api/v1/notes/preview\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: globalThis.editor.getValue(),
}),
})
).text();
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);"))
(text "{% else %}")
; we're just viewing this note
(div
("class" "flex flex-col gap-2 card")
(text "{{ note.content|markdown|safe }}"))
(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
(text "nav::after {
width: 100%;
left: 0;
}"))
(script
(text "window.JOURNAL_PROPS = {
selected_journal: \"{{ selected_journal }}\",
selected_note: \"{{ selected_note }}\",
};
// journals/notes
globalThis.create_journal = async () => {
const title = await trigger(\"atto::prompt\", [\"Journal title:\"]);
if (!title) {
return;
}
fetch(\"/api/v1/journals\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/journals/${res.payload}/0`;
}, 100);
}
});
}
globalThis.create_note = async () => {
const title = await trigger(\"atto::prompt\", [\"Note title:\"]);
if (!title) {
return;
}
fetch(\"/api/v1/notes\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title,
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
journal: \"{{ selected_journal }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/journals/{{ selected_journal }}/${res.payload}`;
}, 100);
}
});
}
globalThis.delete_journal = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/journals/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = \"/journals\";
}, 100);
}
});
}
globalThis.delete_note = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/notes/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = \"/journals/{{ selected_journal }}/0\";
}, 100);
}
});
}
globalThis.change_journal_title = async (e) => {
e.preventDefault();
fetch(\"/api/v1/journals/{{ selected_journal }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.reset();
}
});
}
globalThis.change_journal_privacy = async (e) => {
e.preventDefault();
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\",
},
body: JSON.stringify({
privacy: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
globalThis.change_note_title = async (id) => {
const title = await trigger(\"atto::prompt\", [\"New note title:\"]);
if (!title) {
return;
}
fetch(`/api/v1/notes/${id}/title`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.reset();
}
});
}
globalThis.change_note_content = async (id) => {
fetch(`/api/v1/notes/${id}/content`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: globalThis.editor.getValue(),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
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\") {
window.SIDEBARS_OPEN = true;
}
if (
window.SIDEBARS_OPEN &&
!document.body.classList.contains(\"sidebars_shown\")
) {
toggle_sidebars();
window.SIDEBARS_OPEN = true;
}
for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) {
anchor.href += `?nav=${window.SIDEBARS_OPEN}`;
}
function toggle_sidebars() {
window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN;
for (const anchor of document.querySelectorAll(
\"[data-turbo=false]\",
)) {
anchor.href = anchor.href.replace(
`?nav=${!window.SIDEBARS_OPEN}`,
`?nav=${window.SIDEBARS_OPEN}`,
);
}
const notes_list = document.getElementById(\"notes_list\");
if (document.body.classList.contains(\"sidebars_shown\")) {
// hide
document.body.classList.remove(\"sidebars_shown\");
notes_list.style.left = \"-200%\";
} else {
// show
document.body.classList.add(\"sidebars_shown\");
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

@ -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 %}")
@ -112,7 +112,19 @@
("class" "button")
("data-turbo" "false")
(icon (text "rabbit"))
(str (text "general:link.reference"))))))
(str (text "general:link.reference")))
(a
("href" "{{ config.policies.terms_of_service }}")
("class" "button")
(icon (text "heart-handshake"))
(text "Terms of service"))
(a
("href" "{{ config.policies.privacy }}")
("class" "button")
(icon (text "cookie"))
(text "Privacy policy")))))
(text "{%- endif %}")))
(text "{%- endmacro %}")
@ -124,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"))

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

@ -112,12 +112,11 @@
(div
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("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
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))
(button
("type" "button")
("class" "red lowered")
@ -127,7 +126,11 @@
("type" "button")
("class" "red lowered")
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
(text "{{ text \"auth:action.ip_block\" }}")))))
(text "{{ text \"auth:action.ip_block\" }}")))
(button
("class" "primary")
(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) {
@ -202,6 +202,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 +235,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 +270,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

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) }}"))
(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) }}"))
(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) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div
@ -27,7 +27,7 @@
(text "{{ text \"auth:label.recent_posts\" }}"))
(text "{% else %} {{ icon \"tag\" }}")
(span
(text "{{ text \"auth:label.recent_with_tag\" }}:")
(text "{{ text \"auth:label.recent_with_tag\" }}: ")
(b
(text "{{ tag }}")))
(text "{%- endif %}"))
@ -40,7 +40,16 @@
(text "{{ text \"general:link.search\" }}")))
(text "{%- endif %}"))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in posts %} {% 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 %} {{ components::pagination(page=page, items=posts|length, key=\"&tag=\", value=tag) }}")))
("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 }}&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

@ -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) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div

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,19 @@
(div
("class" "flex gap-2")
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
(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")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}"))))
(icon (text "external-link"))
(span (str (text "requests:action.view_profile"))))))
(text "{% endfor %}")))))
(div
("class" "w-full flex flex-col gap-2 hidden")
@ -495,6 +516,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")
@ -564,14 +667,14 @@
(li
(text "Use custom CSS on your profile"))
(li
(text "Ability to use community emojis outside of
(text "Use community emojis outside of
their community"))
(li
(text "Ability to upload and use gif emojis"))
(text "Upload and use gif emojis"))
(li
(text "Create infinite stack timelines"))
(li
(text "Ability to upload images to posts"))
(text "Upload images to posts"))
(li
(text "Save infinite post drafts"))
(li
@ -579,11 +682,24 @@
(li
(text "Ability to create forges"))
(li
(text "Ability to create more than 1 app"))
(text "Create more than 1 app"))
(li
(text "Create up to 10 stack blocks"))
(li
(text "Add unlimited users to stacks")))
(text "Add unlimited users to stacks"))
(li
(text "Increased proxied image size"))
(li
(text "Create infinite journals"))
(li
(text "Create infinite notes in each journal"))
(li
(text "Publish up to 50 notes"))
(text "{% if config.security.enable_invite_codes -%}")
(li
(text "Create up to 48 invite codes"))
(text "{%- endif %}"))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
@ -921,8 +1037,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,
);
@ -1291,6 +1407,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,
{
@ -1353,6 +1485,16 @@
\"{{ 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\",
],
[[], \"Questions\", \"title\"],
[
[
@ -1370,6 +1512,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,
@ -1394,7 +1544,7 @@
],
[
[],
\"Hides dislikes on all posts. Users can still dislike your posts, you just won't be able to see it.\",
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
\"text\",
],
[[], \"Fun\", \"title\"],
@ -1403,6 +1553,11 @@
\"{{ profile.settings.disable_gpa_fun }}\",
\"checkbox\",
],
[
[\"disable_achievements\", \"Disable achievements\"],
\"{{ profile.settings.disable_achievements }}\",
\"checkbox\",
],
],
settings,
);

View file

@ -7,10 +7,9 @@
(meta ("charset" "UTF-8"))
(meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0"))
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))
(meta ("http-equiv" "content-security-policy") ("content" "default-src 'self' blob: *.spotify.com musicbrainz.org; 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: *"))
(link ("rel" "icon") ("href" "/public/favicon.svg"))
(link ("rel" "stylesheet") ("href" "/css/style.css"))
(link ("rel" "stylesheet") ("href" "/css/style.css?v=tetratto-{{ random_cache_breaker }}"))
(text "{% if user -%}
<script>
@ -22,11 +21,10 @@
{%- endif %}")
(text "<script>
globalThis.ns_verbose = false;
globalThis.ns_config = {
root: \"/js/\",
verbose: globalThis.ns_verbose,
version: \"cache-breaker-{{ random_cache_breaker }}\",
verbose: false,
version: \"tetratto-{{ random_cache_breaker }}\",
};
globalThis._app_base = {
@ -36,10 +34,11 @@
};
globalThis.no_policy = false;
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
</script>")
(script ("src" "/js/loader.js" ))
(script ("src" "/js/atto.js" ))
(script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
(script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
(meta ("name" "theme-color") ("content" "{{ config.color }}"))
(meta ("name" "description") ("content" "{{ config.description }}"))

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

@ -40,9 +40,9 @@
(text "{%- endif %}")))
(div
("class" "card w-full flex flex-col gap-2")
(text "{% if list|length == 0 -%}")
(text "{% if stack.users|length == 0 -%}")
(p
(text "No items yet! Maybe ")
(text "No users included yet! Maybe ")
(a
("href" "/stacks/{{ stack.id }}/manage#/users")
(text "add a user to this stack"))
@ -63,21 +63,32 @@
(div
("class" "flex gap-2 flex-wrap w-full")
(text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}"))
(text "{{ components::pagination(page=page, items=list|length) }}")
(text "{% else %}")
; user icons for circle stack
(text "{% if stack.mode == 'Circle' -%} {% for user in stack.users %}")
(text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}")
(text "{% endfor %} {%- endif %}")
(text "{% if stack.mode == 'Circle' -%}")
(div
("class" "flex w-full gap-2 flex-wrap")
(text "{% for user in stack.users %}")
(a
("href" "/api/v1/auth/user/find/{{ user }}")
("class" "flush")
(text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}"))
(text "{% endfor %}"))
(text "{%- endif %}")
; posts for all stacks except blocklist
(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 %}")
(text "{%- endif %} {{ components::pagination(page=page, items=list|length) }}"))))
(div
("class" "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(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{%- endif %}"))))
(script
(text "async function block_all(block = true) {

View file

@ -104,7 +104,7 @@
(div
("class" "flex flex-col gap-1")
(label
("for" "new_title")
("for" "name")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
@ -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

@ -30,6 +30,13 @@
(text "{%- endif %}")
(div
("class" "card w-full flex flex-col gap-2")
(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 %} {{ components::pagination(page=page, items=list|length) }}")))
("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, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -8,6 +8,13 @@
(text "{{ macros::timelines_nav(selected=\"following\", posts=\"/following\", questions=\"/following/questions\") }}")
(div
("class" "card w-full flex flex-col gap-2")
(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 %} {{ components::pagination(page=page, items=list|length) }}")))
("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, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -27,7 +27,14 @@
(text "{% else %}")
(div
("class" "card w-full flex flex-col gap-2")
(text "{% for post in list %} {% 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 %} {{ components::pagination(page=page, items=list|length) }}"))
("ui_ident" "io_data_load")
(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, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -8,6 +8,13 @@
(text "{{ macros::timelines_nav(selected=\"popular\", posts=\"/popular\", questions=\"/popular/questions\") }}")
(div
("class" "card w-full flex flex-col gap-2")
(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 %} {{ components::pagination(page=page, items=list|length) }}")))
("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, \"{{ 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

@ -0,0 +1,36 @@
(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 %}
{%- endif %}
{% endfor %}")
(datalist
("ui_ident" "list_posts_{{ page }}")
(text "{% for post in list -%}")
(option ("value" "{{ post[0].id }}"))
(text "{%- endfor %}"))
(text "{% if list|length == 0 -%}")
(div
("class" "card lowered green flex justify-between items-center gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"shell\" }}")
(span
(str (text "general:label.timeline_end"))
(text "<!-- observer_disconnect_{{ random_cache_breaker }} -->")))
(text "{% if page > 0 -%}")
(a
("class" "button")
("href" "?page=0")
(icon (text "arrow-up"))
(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

@ -25,10 +25,10 @@ function media_theme_pref() {
}
}
function set_theme(theme) {
window.set_theme = (theme) => {
window.localStorage.setItem("tetratto:theme", theme);
document.documentElement.className = theme;
}
};
media_theme_pref();
@ -39,6 +39,7 @@ media_theme_pref();
// init
use("me", () => {});
use("streams", () => {});
use("carp", () => {});
// env
self.DEBOUNCE = [];
@ -91,7 +92,7 @@ media_theme_pref();
self.define("rel_date", (_, date) => {
// stolen and slightly modified because js dates suck
const diff = Math.abs((new Date().getTime() - date.getTime()) / 1000);
const diff = Math.abs((Date.now() - date.getTime()) / 1000);
const day_diff = Math.floor(diff / 86400);
if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
@ -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) |
@ -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) {
@ -396,7 +397,7 @@ media_theme_pref();
counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`;
});
self.define("hooks::character_counter.init", (_, event) => {
self.define("hooks::character_counter.init", (_) => {
for (const element of Array.from(
document.querySelectorAll("[hook=counter]") || [],
)) {
@ -413,7 +414,7 @@ media_theme_pref();
element.innerHTML = full_text;
});
self.define("hooks::long_text.init", (_, event) => {
self.define("hooks::long_text.init", (_) => {
for (const element of Array.from(
document.querySelectorAll("[hook=long]") || [],
)) {
@ -453,18 +454,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 +473,7 @@ media_theme_pref();
]);
}
setInterval(() => {
setInterval(async () => {
element.setAttribute(
"hook-arg:updated",
Number.parseInt(element.getAttribute("hook-arg:updated")) +
@ -485,21 +486,21 @@ media_theme_pref();
1000,
);
render();
await render();
}, 1000);
render();
await render();
}
});
self.define("last_seen_just_now", (_, last_seen) => {
const now = new Date().getTime();
const now = Date.now();
const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes
return now - last_seen <= maximum_time_to_be_considered_online;
});
self.define("last_seen_recently", (_, last_seen) => {
const now = new Date().getTime();
const now = Date.now();
const maximum_time_to_be_considered_idle = 60000 * 5; // 5 minutes
return now - last_seen <= maximum_time_to_be_considered_idle;
});
@ -585,8 +586,8 @@ media_theme_pref();
self.define(
"hooks::attach_to_partial",
({ $ }, partial, full, attach, wrapper, page, run_on_load) => {
return new Promise((resolve, reject) => {
({ $ }, partial, full, attach, wrapper, page) => {
return new Promise((resolve, _) => {
async function load_partial() {
const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`;
history.replaceState(
@ -618,7 +619,6 @@ media_theme_pref();
.catch(() => {
// done scrolling, no more pages (http error)
wrapper.removeEventListener("scroll", event);
resolve();
});
}
@ -635,7 +635,6 @@ media_theme_pref();
return;
}
// biome-ignore lint/style/noParameterAssign: no it isn't
page += 1;
await load_partial();
})
@ -651,7 +650,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,7 +686,39 @@ 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];
// tab
for (const element of Array.from(
document.querySelectorAll("[data-tab]"),
@ -805,7 +836,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);
@ -848,7 +878,7 @@ media_theme_pref();
})();
// ui ns
(() => {
(async () => {
const self = reg_ns("ui");
window.SETTING_SET_FUNCTIONS = [];
@ -886,7 +916,7 @@ media_theme_pref();
}
if (option.input_element_type === "checkbox") {
into_element.innerHTML += `<div class="card flex gap-2">
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)"
@ -1019,7 +1049,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
window.update_field_with_color = (key, value) => {
console.log("sync_color_text", key);
document.getElementById(key).value = value;
set_setting_field(key, value);
window.SETTING_SET_FUNCTIONS[0](key, value);
preview_color(key, value);
};
@ -1119,6 +1149,199 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
document.getElementById("lightbox").classList.add("hidden");
}, 250);
});
// intersection observer infinite scrolling
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;
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;
}
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_load_data", async () => {
if (self.IO_DATA_WAITING) {
return;
}
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);
// show loading component
const loading = self.IO_HTML_TMPL.content.cloneNode(true);
self.IO_DATA_ELEMENT.appendChild(loading);
// ...
const text = await (
await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
).text();
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} -->`)
) {
console.log("io_data_end; disconnect");
self.IO_DATA_OBSERVER.disconnect();
self.IO_DATA_ELEMENT.innerHTML += text;
self.IO_DATA_DISCONNECTED = true;
return;
}
self.IO_DATA_ELEMENT.innerHTML += text;
setTimeout(() => {
// move marker to bottom of dom hierarchy
self.IO_DATA_ELEMENT.children[
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 (element.getAttribute("is_repost") === true) {
continue;
}
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(
`[ui_ident=list_posts_${self.IO_DATA_PAGE}] option`,
),
)) {
const v = opt.getAttribute("value");
if (!self.IO_DATA_SEEN_IDS[v]) {
self.IO_DATA_SEEN_IDS.push(v);
}
}
}, 150);
// run hooks
const atto = await ns("atto");
atto.clean_date_codes();
atto.clean_poll_date_codes();
atto.link_filter();
atto["hooks::long_text.init"]();
atto["hooks::alt"]();
atto["hooks::online_indicator"]();
atto["hooks::verify_emoji"]();
atto["hooks::check_reactions"]();
});
})();
(() => {
@ -1130,7 +1353,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
self.define(
"open",
async ({ $ }, warning_id, warning_hash, warning_page = "") => {
async (_, warning_id, warning_hash, warning_page = "") => {
// check localStorage for this warning_id
if (accepted_warnings[warning_id] !== undefined) {
// check hash
@ -1151,7 +1374,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
},
);
self.define("accept", ({ _ }, warning_id, warning_hash) => {
self.define("accept", (_, warning_id, warning_hash) => {
accepted_warnings[warning_id] = warning_hash;
window.localStorage.setItem(

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

@ -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) {
while (!res) {
if (tries >= 5) {
return console.error(
"namespace does not exist, please use one of the following:",
`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",
@ -489,6 +531,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 +878,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 +979,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 +1009,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 +1140,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,7 @@
},
};
socket.addEventListener("message", (event) => {
socket.addEventListener("message", async (event) => {
if (event.data === "Ping") {
return socket.send("Pong");
}
@ -54,14 +54,14 @@
return console.info(`${stream} ${data.data}`);
}
return $.sock(stream).events.message(data);
return (await $.sock(stream)).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 +72,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 +84,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 +97,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;
}
}

View file

@ -82,14 +82,16 @@ pub async fn avatar_request(
}
};
let mime = if user.settings.avatar_mime.is_empty() {
"image/avif"
} else {
&user.settings.avatar_mime
};
let path = PathBufD::current().extend(&[
data.0.0.dirs.media.as_str(),
"avatars",
&format!(
"{}.{}",
&(user.id as i64),
user.settings.avatar_mime.replace("image/", "")
),
&format!("{}.{}", &(user.id as i64), mime.replace("image/", "")),
]);
if !exists(&path).unwrap() {
@ -104,10 +106,7 @@ pub async fn avatar_request(
}
Ok((
[(
"Content-Type".to_string(),
user.settings.avatar_mime.clone(),
)],
[("Content-Type".to_string(), mime.to_owned())],
Body::from(read_image(path)),
))
}
@ -134,14 +133,16 @@ pub async fn banner_request(
}
};
let mime = if user.settings.banner_mime.is_empty() {
"image/avif"
} else {
&user.settings.banner_mime
};
let path = PathBufD::current().extend(&[
data.0.0.dirs.media.as_str(),
"banners",
&format!(
"{}.{}",
&(user.id as i64),
user.settings.banner_mime.replace("image/", "")
),
&format!("{}.{}", &(user.id as i64), mime.replace("image/", "")),
]);
if !exists(&path).unwrap() {
@ -156,10 +157,7 @@ pub async fn banner_request(
}
Ok((
[(
"Content-Type".to_string(),
user.settings.banner_mime.clone(),
)],
[("Content-Type".to_string(), mime.to_owned())],
Body::from(read_image(path)),
))
}
@ -211,8 +209,17 @@ pub async fn upload_avatar_request(
mime.replace("image/", "")
);
// upload image (gif)
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
// update user settings
auth_user.settings.avatar_mime = mime.to_string();
auth_user.settings.avatar_mime = "image/gif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
@ -220,14 +227,7 @@ pub async fn upload_avatar_request(
return Json(e.into());
}
// upload image (gif)
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());
}
std::fs::write(&path, img.0).unwrap();
// ...
return Json(ApiReturn {
ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(),
@ -237,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
@ -247,15 +256,7 @@ 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
},
) {
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(),
@ -309,8 +310,17 @@ pub async fn upload_banner_request(
mime.replace("image/", "")
);
// upload image (gif)
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
// update user settings
auth_user.settings.banner_mime = mime.to_string();
auth_user.settings.banner_mime = "image/gif".to_string();
if let Err(e) = data
.update_user_settings(auth_user.id, auth_user.settings)
.await
@ -318,14 +328,7 @@ pub async fn upload_banner_request(
return Json(e.into());
}
// upload image (gif)
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());
}
std::fs::write(&path, img.0).unwrap();
// ...
return Json(ApiReturn {
ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(),
@ -335,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
@ -345,15 +357,7 @@ 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
},
) {
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(),

View file

@ -86,12 +86,66 @@ 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.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;
}
// push initial token
let (initial_token, t) = User::create_token(&real_ip);
user.tokens.push(t);
// return
match data.create_user(user).await {
Ok(_) => (
Ok(_) => {
// mark invite as used
if data.0.0.security.enable_invite_codes {
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!(
@ -105,7 +159,8 @@ pub async fn register_request(
message: initial_token,
payload: (),
}),
),
)
}
Err(e) => (None, Json(e.into())),
}
}

View file

@ -3,8 +3,8 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole,
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
},
State,
};
@ -21,7 +21,8 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{
cache::Cache,
model::{
auth::{Token, UserSettings},
auth::{AchievementName, InviteCode, Token, UserSettings},
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
socket::{PacketType, SocketMessage, SocketMethod},
@ -30,7 +31,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 +116,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 +151,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())
.await
{
return Json(e.into());
}
// ...
match data.update_user_settings(id, req).await {
Ok(_) => Json(ApiReturn {
@ -185,7 +194,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,
@ -358,6 +367,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 +430,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
@ -556,7 +603,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 +716,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 +864,50 @@ 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)),
})
}

View file

@ -11,7 +11,7 @@ use axum::{
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow},
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
oauth,
};
@ -22,7 +22,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 +40,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 +59,15 @@ pub async fn follow_request(
return Json(e.into());
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::FollowUser.into())
.await
{
return Json(e.into());
}
// ...
Json(ApiReturn {
ok: true,
message: "User followed".to_string(),
@ -116,7 +125,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(_) => {

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

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()),
};
@ -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())
.await
{
return Json(e.into());
}
if user.post_count >= 49 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create50Posts.into())
.await
{
return Json(e.into());
}
}
if user.post_count >= 99 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create100Posts.into())
.await
{
return Json(e.into());
}
}
if user.post_count >= 999 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::Create1000Posts.into())
.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())
.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()))
.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);
@ -49,6 +50,27 @@ pub async fn create_request(
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())
.await
{
return Json(e.into());
}
if drawings.len() > 0 {
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateDrawing.into())
.await
{
return Json(e.into());
}
}
}
// ...
let mut props = Question::new(
if let Some(ref ua) = user { ua.id } else { 0 },
@ -70,7 +92,10 @@ pub async fn create_request(
}
}
match data.create_question(props).await {
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(),

View file

@ -0,0 +1,298 @@
use axum::{
response::IntoResponse,
extract::{Json, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_shared::snow::Snowflake;
use crate::{
get_user_from_token,
routes::api::v1::{
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
},
State,
};
use tetratto_core::{
database::NAME_REGEX,
model::{
auth::AchievementName,
journals::{Journal, JournalPrivacyPermission},
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::UserReadJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if journal.privacy == JournalPrivacyPermission::Private
&& user.id != journal.owner
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
{
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(journal),
})
}
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) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_journals_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(props): Json<CreateJournal>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_journal(Journal::new(user.id, props.title))
.await
{
Ok(x) => {
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateJournal.into())
.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()),
}
}
pub async fn update_title_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(mut props): Json<UpdateJournalTitle>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
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
.get_journal_by_owner_title(user.id, &props.title)
.await
.is_ok()
{
return Json(Error::TitleInUse.into());
}
// ...
match data.update_journal_title(id, &user, &props.title).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_privacy_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateJournalPrivacy>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_journal_privacy(id, &user, props.privacy).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_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::UserManageJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_journal(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Journal deleted".to_string(),
payload: (),
}),
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

@ -2,6 +2,8 @@ pub mod apps;
pub mod auth;
pub mod channels;
pub mod communities;
pub mod journals;
pub mod notes;
pub mod notifications;
pub mod reactions;
pub mod reports;
@ -22,8 +24,9 @@ use tetratto_core::model::{
PollOption, PostContext,
},
communities_permissions::CommunityPermission,
journals::JournalPrivacyPermission,
oauth::AppScope,
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort},
};
@ -34,10 +37,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}",
@ -276,6 +296,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),
@ -530,7 +554,9 @@ pub fn routes() -> Router {
delete(communities::emojis::delete_request),
)
// stacks
.route("/stacks", get(stacks::list_request))
.route("/stacks", post(stacks::create_request))
.route("/stacks/{id}", get(stacks::get_request))
.route("/stacks/{id}/name", post(stacks::update_name_request))
.route("/stacks/{id}/privacy", post(stacks::update_privacy_request))
.route("/stacks/{id}/mode", post(stacks::update_mode_request))
@ -541,6 +567,35 @@ pub fn routes() -> Router {
.route("/stacks/{id}/block", post(stacks::block_request))
.route("/stacks/{id}/block", delete(stacks::unblock_request))
.route("/stacks/{id}", delete(stacks::delete_request))
// journals
.route("/journals", get(journals::list_request))
.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))
@ -560,6 +615,8 @@ pub struct RegisterProps {
pub password: String,
pub policy_consent: bool,
pub captcha_response: String,
#[serde(default)]
pub invite_code: String,
}
#[derive(Deserialize)]
@ -687,6 +744,11 @@ pub struct UpdateUserRole {
pub role: FinePermission,
}
#[derive(Deserialize)]
pub struct UpdateSecondaryUserRole {
pub role: SecondaryPermission,
}
#[derive(Deserialize)]
pub struct DeleteUser {
pub password: String,
@ -846,3 +908,67 @@ pub struct CreateGrant {
pub struct RefreshGrantToken {
pub verifier: String,
}
#[derive(Deserialize)]
pub struct CreateJournal {
pub title: String,
}
#[derive(Deserialize)]
pub struct CreateNote {
pub title: String,
pub content: String,
pub journal: String,
}
#[derive(Deserialize)]
pub struct UpdateJournalTitle {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateJournalPrivacy {
pub privacy: JournalPrivacyPermission,
}
#[derive(Deserialize)]
pub struct UpdateNoteTitle {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateNoteContent {
pub content: String,
}
#[derive(Deserialize)]
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>,
}

View file

@ -0,0 +1,419 @@
use axum::{
response::IntoResponse,
extract::{Json, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_shared::unix_epoch_timestamp;
use crate::{
get_user_from_token,
routes::api::v1::{
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
UpdateNoteTitle,
},
State,
};
use tetratto_core::{
database::NAME_REGEX,
model::{
journals::{JournalPrivacyPermission, Note},
oauth,
permissions::FinePermission,
uploads::CustomEmoji,
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::UserReadJournals) {
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.id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if journal.privacy == JournalPrivacyPermission::Private
&& user.id != journal.owner
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
{
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(note),
})
}
pub async fn list_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::UserReadJournals) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let journal = match data.get_journal_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if journal.privacy == JournalPrivacyPermission::Private
&& user.id != journal.owner
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
{
return Json(Error::NotAllowed.into());
}
match data.get_notes_by_journal(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(props): Json<CreateNote>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_note(Note::new(
user.id,
props.title,
match props.journal.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
props.content,
))
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Note created".to_string(),
payload: Some(x.id.to_string()),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_title_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(mut props): Json<UpdateNoteTitle>,
) -> 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()),
};
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
.get_note_by_journal_title(note.journal, &props.title)
.await
.is_ok()
{
return Json(Error::TitleInUse.into());
}
// ...
match data.update_note_title(id, &user, &props.title).await {
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()),
}
}
pub async fn update_content_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateNoteContent>,
) -> 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_content(id, &user, &props.content).await {
Ok(_) => {
if let Err(e) = data
.update_note_edited(id, unix_epoch_timestamp() as i64)
.await
{
return Json(e.into());
}
Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}
pub async fn delete_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()),
};
match data.delete_note(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
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

@ -19,6 +19,54 @@ use super::{
UpdateStackSort,
};
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 stack = match data.get_stack_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if stack.privacy == StackPrivacy::Private
&& user.id != stack.owner
&& ((stack.mode != StackMode::Circle) | stack.users.contains(&user.id))
&& !user.permissions.check(FinePermission::MANAGE_STACKS)
{
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(stack),
})
}
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::UserReadStacks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_stacks_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>,
@ -163,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>,
@ -12,7 +12,20 @@ pub async fn get_request(
) -> impl IntoResponse {
let data = &(data.read().await).0;
let upload = data.get_upload_by_id(id).await.unwrap();
let upload = match data.get_upload_by_id(id).await {
Ok(u) => u,
Err(_) => {
return Err((
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
));
}
};
let path = upload.path(&data.0.0);
if !exists(&path).unwrap() {
@ -26,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

@ -1,5 +1,5 @@
use super::auth::images::read_image;
use crate::State;
use crate::{get_user_from_token, State};
use axum::{
body::Body,
extract::Query,
@ -7,10 +7,13 @@ use axum::{
response::IntoResponse,
Extension,
};
use axum_extra::extract::CookieJar;
use pathbufd::PathBufD;
use serde::Deserialize;
use tetratto_core::model::permissions::FinePermission;
pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4194304; // 4 MiB
pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4_194_304; // 4 MiB
pub const MAXIMUM_SUPPORTER_PROXY_FILE_SIZE: u64 = 10_485_760; // 4 MiB
#[derive(Deserialize)]
pub struct ProxyQuery {
@ -19,10 +22,22 @@ pub struct ProxyQuery {
/// Proxy an external url
pub async fn proxy_request(
jar: CookieJar,
Query(props): Query<ProxyQuery>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await);
let user = get_user_from_token!(jar, data.0);
let maximum_size = if let Some(ref ua) = user {
if ua.permissions.check(FinePermission::SUPPORTER) {
MAXIMUM_SUPPORTER_PROXY_FILE_SIZE
} else {
MAXIMUM_PROXY_FILE_SIZE
}
} else {
MAXIMUM_PROXY_FILE_SIZE
};
let http = &data.2;
let data = &data.0.0;
@ -60,7 +75,7 @@ pub async fn proxy_request(
match http.get(image_url).send().await {
Ok(stream) => {
let size = stream.content_length();
if size.unwrap_or_default() > MAXIMUM_PROXY_FILE_SIZE {
if size.unwrap_or_default() > maximum_size {
// return defualt image (content too big)
return (
[("Content-Type", "image/svg+xml")],

View file

@ -12,8 +12,10 @@ serve_asset!(favicon_request: FAVICON("image/svg+xml"));
serve_asset!(style_css_request: STYLE_CSS("text/css"));
serve_asset!(root_css_request: ROOT_CSS("text/css"));
serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
serve_asset!(chats_css_request: CHATS_CSS("text/css"));
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"));

View file

@ -14,14 +14,20 @@ pub fn routes(config: &Config) -> Router {
.route("/css/style.css", get(assets::style_css_request))
.route("/css/root.css", get(assets::root_css_request))
.route("/css/utility.css", get(assets::utility_css_request))
.route("/css/chats.css", get(assets::chats_css_request))
.route("/js/loader.js", get(assets::loader_js_request))
.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))
.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)),

View file

@ -417,7 +417,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 {
@ -752,7 +752,7 @@ pub async fn post_request(
}
// check repost
let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await;
let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await;
// check question
let question = match data.0.get_post_question(&post, &ignore_users).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

@ -0,0 +1,366 @@
use axum::{
extract::{Path, Query},
response::{Html, IntoResponse, Redirect},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::{
assets::initial_context,
check_user_blocked_or_private, get_lang, get_user_from_token,
routes::pages::{render_error, JournalsAppQuery},
State,
};
use tetratto_core::model::{journals::JournalPrivacyPermission, Error};
pub async fn redirect_request() -> impl IntoResponse {
Redirect::to("/journals/0/0")
}
/// `/journals/{journal}/{note}`
pub async fn app_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((selected_journal, selected_note)): Path<(usize, usize)>,
Query(props): Query<JournalsAppQuery>,
) -> 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 journals = match data.0.get_journals_by_user(user.id).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(
render_error(e, &jar, &data, &Some(user.to_owned())).await,
));
}
};
let notes = match data.0.get_notes_by_journal(selected_journal).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
}
};
// get journal and check privacy settings
let journal = if selected_journal != 0 {
match data.0.get_journal_by_id(selected_journal).await {
Ok(p) => Some(p),
Err(e) => {
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
}
}
} else {
None
};
if let Some(ref j) = journal {
// if we're not the owner, we shouldn't be viewing this journal from this endpoint
if user.id != j.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
));
}
}
// ...
let note = if selected_note != 0 {
match data.0.get_note_by_id(selected_note).await {
Ok(p) => Some(p),
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.clone())).await;
context.insert("selected_journal", &selected_journal);
context.insert("selected_note", &selected_note);
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &user);
context.insert("journals", &journals);
context.insert("notes", &notes);
context.insert("view_mode", &props.view);
context.insert("is_editor", &true);
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
/// `/@{owner}/{journal}/{note}`
pub async fn view_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((owner, selected_journal, mut selected_note)): Path<(String, String, 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_journal.is_empty() | (selected_note == "journal.css") {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
// 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 note = if !selected_note.is_empty() {
match data
.0
.get_note_by_journal_title(journal.id, &selected_note)
.await
{
Ok(p) => Some(p),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
} else {
None
};
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);
}
if selected_note.is_empty() {
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::<[i8; 0], &str>("notes", &[]);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
// 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(Html(data.1.render("journals/app.html", &context).unwrap()))
}

View file

@ -9,7 +9,12 @@ use axum::{
};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{permissions::FinePermission, requests::ActionType, Error};
use tetratto_core::model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
permissions::FinePermission,
requests::ActionType,
Error,
};
use std::fs::read_to_string;
use pathbufd::PathBufD;
@ -40,22 +45,9 @@ pub async fn index_request(
return {
// all timeline for unauthenticated users
// i'm only changing this for stripe
let list = match data.0.get_latest_posts(12, req.page).await {
Ok(l) => match data
.0
.fill_posts_with_community(l, 0, &Vec::new(), &None)
.await
{
Ok(l) => l,
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
},
Err(e) => return 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, &None).await;
context.insert("list", &list);
context.insert("page", &req.page);
Html(data.1.render("timelines/all.html", &context).unwrap())
};
@ -99,36 +91,9 @@ pub async fn popular_request(
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let ignore_users = crate::ignore_users_gen!(user, data);
let list = match data.0.get_popular_posts(12, req.page, 604_800_000).await {
Ok(l) => match data
.0
.fill_posts_with_community(
l,
if let Some(ref ua) = user { ua.id } else { 0 },
&ignore_users,
&user,
)
.await
{
Ok(l) => data.0.posts_muted_phrase_filter(
&l,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
),
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
},
Err(e) => return 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;
context.insert("list", &list);
context.insert("page", &req.page);
Html(data.1.render("timelines/popular.html", &context).unwrap())
}
@ -149,30 +114,9 @@ pub async fn following_request(
}
};
let ignore_users = crate::ignore_users_gen!(user!, data);
let list = match data
.0
.get_posts_from_user_following(user.id, 12, req.page)
.await
{
Ok(l) => match data
.0
.fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone()))
.await
{
Ok(l) => data
.0
.posts_muted_phrase_filter(&l, Some(&user.settings.muted)),
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)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("page", &req.page);
Ok(Html(
data.1.render("timelines/following.html", &context).unwrap(),
@ -188,36 +132,9 @@ pub async fn all_request(
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let ignore_users = crate::ignore_users_gen!(user, data);
let list = match data.0.get_latest_posts(12, req.page).await {
Ok(l) => match data
.0
.fill_posts_with_community(
l,
if let Some(ref ua) = user { ua.id } else { 0 },
&ignore_users,
&user,
)
.await
{
Ok(l) => data.0.posts_muted_phrase_filter(
&l,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
),
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
},
Err(e) => return 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;
context.insert("list", &list);
context.insert("page", &req.page);
Html(data.1.render("timelines/all.html", &context).unwrap())
}
@ -417,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;
@ -519,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())
.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,
@ -649,3 +616,146 @@ pub async fn search_request(
data.1.render("timelines/search.html", &context).unwrap(),
))
}
#[derive(Deserialize)]
pub struct TimelineQuery {
#[serde(default)]
pub tl: DefaultTimelineChoice,
#[serde(default)]
pub page: usize,
#[serde(default)]
pub stack_id: usize,
#[serde(default)]
pub user_id: usize,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub paginated: bool,
}
/// `/_swiss_army_timeline`
pub async fn swiss_army_timeline_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<TimelineQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let ignore_users = crate::ignore_users_gen!(user, data);
let list = if req.stack_id != 0 {
// stacks
if let Some(ref ua) = user {
match data
.0
.get_stack_posts(
ua.id,
req.stack_id,
12,
req.page,
&ignore_users,
&Some(ua.to_owned()),
)
.await
{
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
} else {
match if req.user_id != 0 {
// users
let other_user = match data.0.get_user_by_id(req.user_id).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
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).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, &user).await
}
DefaultTimelineChoice::PopularPosts => {
data.0.get_popular_posts(12, req.page, 604_800_000).await
}
DefaultTimelineChoice::FollowingPosts => {
if let Some(ref ua) = user {
data.0
.get_posts_from_user_following(ua.id, 12, req.page)
.await
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
DefaultTimelineChoice::MyCommunities => {
if let Some(ref ua) = user {
data.0
.get_posts_from_user_communities(ua.id, 12, req.page)
.await
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// questions bad
_ => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
} {
Ok(l) => match data
.0
.fill_posts_with_community(
l,
if let Some(ref ua) = user { ua.id } else { 0 },
&ignore_users,
&user,
)
.await
{
Ok(l) => data.0.posts_muted_phrase_filter(
&l,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
),
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Ok(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;
context.insert("list", &list);
context.insert("page", &req.page);
context.insert("paginated", &req.paginated);
Ok(Html(
data.1
.render("timelines/swiss_army.html", &context)
.unwrap(),
))
}

View file

@ -3,6 +3,7 @@ pub mod chats;
pub mod communities;
pub mod developer;
pub mod forge;
pub mod journals;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@ -29,6 +30,10 @@ pub fn routes() -> Router {
.route("/following", get(misc::following_request))
.route("/all", get(misc::all_request))
.route("/search", get(misc::search_request))
.route(
"/_swiss_army_timeline",
get(misc::swiss_army_timeline_request),
)
// question timelines
.route("/questions", get(misc::index_questions_request))
.route("/popular/questions", get(misc::popular_questions_request))
@ -40,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
@ -126,6 +132,13 @@ 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::index_view_request))
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
.route("/x/{note}", get(journals::global_view_request))
}
pub async fn render_error(
@ -181,3 +194,11 @@ pub struct RepostsQuery {
#[serde(default)]
pub page: usize,
}
#[derive(Deserialize)]
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

@ -1,6 +1,7 @@
use super::{render_error, PaginatedQuery, ProfileQuery};
use crate::{
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
ignore_users_gen, State,
};
use axum::{
Extension,
@ -100,6 +101,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);
@ -112,6 +129,7 @@ pub async fn settings_request(
context.insert("following", &following);
context.insert("blocks", &blocks);
context.insert("stackblocks", &stackblocks);
context.insert("invites", &invites);
context.insert(
"user_tokens_serde",
&serde_json::to_string(&tokens)
@ -241,67 +259,8 @@ pub async fn posts_request(
));
}
// fetch data
let ignore_users = crate::ignore_users_gen!(user, data);
let posts = if props.tag.is_empty() {
match data
.0
.get_posts_by_user(other_user.id, 12, props.page, &user)
.await
{
Ok(p) => match data
.0
.fill_posts_with_community(
p,
if let Some(ref ua) = user { ua.id } else { 0 },
&ignore_users,
&user,
)
.await
{
Ok(p) => data.0.posts_muted_phrase_filter(
&p,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
} else {
match data
.0
.get_posts_by_user_tag(other_user.id, &props.tag, 12, props.page, &user)
.await
{
Ok(p) => match data
.0
.fill_posts_with_community(
p,
if let Some(ref ua) = user { ua.id } else { 0 },
&ignore_users,
&user,
)
.await
{
Ok(p) => data.0.posts_muted_phrase_filter(
&p,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
};
// fetch pinned
let ignore_users = ignore_users_gen!(user, data);
let pinned = if props.tag.is_empty() {
match data.0.get_pinned_posts_by_user(other_user.id).await {
Ok(p) => match data
@ -375,7 +334,6 @@ pub async fn posts_request(
false
};
context.insert("posts", &posts);
context.insert("pinned", &pinned);
context.insert("page", &props.page);
context.insert("tag", &props.tag);

View file

@ -92,25 +92,6 @@ pub async fn feed_request(
.await
.is_ok(),
);
} else {
let ignore_users = crate::ignore_users_gen!(user!, data);
let list = match data
.0
.get_stack_posts(
user.id,
stack.id,
12,
req.page,
&ignore_users,
&Some(user.clone()),
)
.await
{
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
context.insert("list", &list);
}
// return
@ -176,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 = "8.0.0"
version = "10.0.0"
edition = "2024"
[dependencies]

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(),
}
}
}
@ -341,6 +349,8 @@ fn default_banned_usernames() -> Vec<String> {
"stacks".to_string(),
"stack".to_string(),
"search".to_string(),
"journals".to_string(),
"links".to_string(),
]
}

View file

@ -1,8 +1,9 @@
use super::common::NAME_REGEX;
use oiseau::cache::Cache;
use crate::model::auth::UserConnections;
use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections};
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 +16,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 +109,9 @@ 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(),
}
}
@ -200,7 +267,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)",
params![
&(data.id as i64),
&(data.created as i64),
@ -216,13 +283,16 @@ impl DataManager {
&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(),
]
);
@ -389,6 +459,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 +558,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,67 +688,6 @@ impl DataManager {
Ok(())
}
pub async fn update_user_role(
&self,
id: usize,
role: FinePermission,
user: User,
force: bool,
) -> Result<()> {
let other_user = self.get_user_by_id(id).await?;
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,
"UPDATE users SET permissions = $1 WHERE id = $2",
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 `update_user_role` with x value `{}` and y value `{}`",
other_user.id,
role.bits()
),
))
.await?;
// ...
Ok(())
}
pub async fn seen_user(&self, user: &User) -> Result<()> {
let conn = match self.0.connect().await {
Ok(c) => c,
@ -639,6 +709,69 @@ impl DataManager {
Ok(())
}
/// Add an achievement to a user.
///
/// Still returns `Ok` if the user already has the achievement.
pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> 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?;
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 +910,19 @@ 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!(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 +943,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

@ -21,6 +21,7 @@ impl DataManager {
position: get!(x->6(i32)) as usize,
members: serde_json::from_str(&get!(x->7(String))).unwrap(),
title: get!(x->8(String)),
last_message: get!(x->9(i64)) as usize,
}
}
@ -81,7 +82,7 @@ impl DataManager {
let res = query_rows!(
&conn,
"SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC",
"SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY last_message DESC",
params![&(user as i64), &format!("%{user}%")],
|x| { Self::get_channel_from_row(x) }
);
@ -162,7 +163,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
params![
&(data.id as i64),
&(data.community as i64),
@ -172,7 +173,8 @@ impl DataManager {
&(data.minimum_role_write as i32),
&(data.position as i32),
&serde_json::to_string(&data.members).unwrap(),
&data.title
&data.title,
&(data.last_message as i64)
]
);
@ -320,4 +322,5 @@ impl DataManager {
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_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_\-\.,!]+";
@ -36,6 +36,10 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_APPS).unwrap();
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();
self.0
.1
@ -48,6 +52,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]
@ -112,7 +136,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()));
@ -164,7 +189,15 @@ macro_rules! auto_method {
.get(format!($cache_key_tmpl, selector.to_string()))
.await
{
return Ok(serde_json::from_str(&cached).unwrap());
match serde_json::from_str(&cached) {
Ok(x) => return Ok(x),
Err(_) => {
self.0
.1
.remove(format!($cache_key_tmpl, selector.to_string()))
.await
}
};
}
let conn = match self.0.connect().await {

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

View file

@ -23,3 +23,7 @@ pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql");
pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql");
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");

View file

@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS channels (
minimum_role_write INT NOT NULL,
position INT NOT NULL,
members TEXT NOT NULL,
title TEXT NOT NULL
title TEXT NOT NULL,
last_message BIGINT 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

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS journals (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
title TEXT NOT NULL,
privacy TEXT NOT NULL,
dirs 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
)

View file

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS notes (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
title TEXT NOT NULL,
journal BIGINT NOT NULL,
content TEXT NOT NULL,
edited BIGINT NOT NULL,
dir BIGINT NOT NULL,
tags TEXT NOT NULL,
is_global INT NOT NULL
)

View file

@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions (
dislikes INT NOT NULL,
-- ...
context TEXT NOT NULL,
ip TEXT NOT NULL
ip TEXT NOT NULL,
drawings TEXT NOT NULL
)

View file

@ -19,5 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
connections TEXT NOT NULL,
stripe_id TEXT NOT NULL,
grants TEXT NOT NULL,
associated TEXT NOT NULL
associated TEXT NOT NULL,
secondary_permissions INT NOT NULL,
achievements TEXT NOT NULL
)

View file

@ -0,0 +1,206 @@
use oiseau::{cache::Cache, query_row, query_rows};
use tetratto_shared::unix_epoch_timestamp;
use crate::model::{
Error, Result,
auth::{User, InviteCode},
permissions::FinePermission,
};
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, params};
impl DataManager {
/// Get a [`InviteCode`] from an SQL row.
pub(crate) fn get_invite_code_from_row(x: &PostgresRow) -> InviteCode {
InviteCode {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
code: get!(x->3(String)),
is_used: get!(x->4(i32)) as i8 == 1,
}
}
auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}");
auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode);
/// Get invite_codes by `owner`.
pub async fn get_invite_codes_by_owner(
&self,
owner: usize,
batch: usize,
page: usize,
) -> Result<Vec<InviteCode>> {
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 invite_codes WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(owner as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_invite_code_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("invite_code".to_string()));
}
Ok(res.unwrap())
}
/// Get invite_codes by `owner`.
pub async fn get_invite_codes_by_owner_count(&self, owner: usize) -> 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,
"SELECT COUNT(*)::int FROM invite_codes WHERE owner = $1",
&[&(owner as i64)],
|x| Ok(x.get::<usize, i32>(0))
);
if res.is_err() {
return Err(Error::GeneralNotFound("invite_code".to_string()));
}
Ok(res.unwrap())
}
/// Fill a vector of invite codes with the user that used them.
pub async fn fill_invite_codes(
&self,
codes: Vec<InviteCode>,
) -> Result<Vec<(Option<User>, InviteCode)>> {
let mut out = Vec::new();
for code in codes {
if code.is_used {
out.push((
match self.get_user_by_invite_code(code.id as i64).await {
Ok(u) => Some(u),
Err(_) => None,
},
code,
))
} else {
out.push((None, code))
}
}
Ok(out)
}
const MAXIMUM_FREE_INVITE_CODES: usize = 4;
const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48;
const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo
/// Create a new invite_code in the database.
///
/// # Arguments
/// * `data` - a mock [`InviteCode`] object to insert
pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result<InviteCode> {
if !user.permissions.check(FinePermission::SUPPORTER) {
// check account creation date
if unix_epoch_timestamp() - user.created
< Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES
{
return Err(Error::MiscError(
"Your account is too young to do this".to_string(),
));
}
// our account is old enough, but we need to make sure we don't already have
// 2 invite codes
if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
>= Self::MAXIMUM_FREE_INVITE_CODES
{
return Err(Error::MiscError(
"You already have the maximum number of invite codes you can create"
.to_string(),
));
}
} else if !user.permissions.check(FinePermission::MANAGE_USERS) {
// check count since we're also not a moderator with MANAGE_USERS
if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
>= Self::MAXIMUM_SUPPORTER_INVITE_CODES
{
return Err(Error::MiscError(
"You already have the maximum number of invite codes you can create"
.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 invite_codes VALUES ($1, $2, $3, $4, $5)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.code,
&{ if data.is_used { 1 } else { 0 } }
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> {
if !user.permissions.check(FinePermission::MANAGE_USERS) {
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 invite_codes WHERE id = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.0.1.remove(format!("atto.invite_code:{}", id)).await;
Ok(())
}
pub async fn update_invite_code_is_used(&self, id: usize, new_is_used: bool) -> Result<()> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"UPDATE invite_codes SET is_used = $1 WHERE id = $2",
params![&{ if new_is_used { 1 } else { 0 } }, &(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.0.1.remove(format!("atto.invite_code:{}", id)).await;
Ok(())
}
}

View file

@ -0,0 +1,189 @@
use oiseau::{cache::Cache, query_row};
use crate::{
database::common::NAME_REGEX,
model::{
auth::User,
journals::{Journal, JournalPrivacyPermission},
permissions::FinePermission,
Error, Result,
},
};
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_rows, params};
impl DataManager {
/// Get a [`Journal`] from an SQL row.
pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal {
Journal {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
title: get!(x->3(String)),
privacy: serde_json::from_str(&get!(x->4(String))).unwrap(),
dirs: serde_json::from_str(&get!(x->5(String))).unwrap(),
}
}
auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}");
/// Get a journal by `owner` and `title`.
pub async fn get_journal_by_owner_title(&self, owner: usize, title: &str) -> Result<Journal> {
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 journals WHERE owner = $1 AND title = $2",
params![&(owner as i64), &title],
|x| { Ok(Self::get_journal_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("journal".to_string()));
}
Ok(res.unwrap())
}
/// Get all journals by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch journals for
pub async fn get_journals_by_user(&self, id: usize) -> Result<Vec<Journal>> {
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 journals WHERE owner = $1 ORDER BY title ASC",
&[&(id as i64)],
|x| { Self::get_journal_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("journal".to_string()));
}
Ok(res.unwrap())
}
const MAXIMUM_FREE_JOURNALS: usize = 5;
/// Create a new journal in the database.
///
/// # Arguments
/// * `data` - a mock [`Journal`] object to insert
pub async fn create_journal(&self, mut data: Journal) -> Result<Journal> {
// check values
if data.title.len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 32 {
return Err(Error::DataTooLong("title".to_string()));
}
data.title = data.title.replace(" ", "_").to_lowercase();
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&data.title).is_some() {
return Err(Error::MiscError(
"This title contains invalid characters".to_string(),
));
}
// make sure this title isn't already in use
if self
.get_journal_by_owner_title(data.owner, &data.title)
.await
.is_ok()
{
return Err(Error::TitleInUse);
}
// check number of journals
let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) {
let journals = self.get_journals_by_user(data.owner).await?;
if journals.len() >= Self::MAXIMUM_FREE_JOURNALS {
return Err(Error::MiscError(
"You already have the maximum number of journals you can have".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 journals VALUES ($1, $2, $3, $4, $5, $6)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.title,
&serde_json::to_string(&data.privacy).unwrap(),
&serde_json::to_string(&data.dirs).unwrap(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> {
let journal = self.get_journal_by_id(id).await?;
// check user permission
if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) {
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 journals WHERE id = $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 journal = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.0.1.remove(format!("atto.journal:{}", id)).await;
Ok(())
}
auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}");
auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
}

View file

@ -1,4 +1,5 @@
use oiseau::cache::Cache;
use crate::model::auth::AchievementName;
use crate::model::communities::Community;
use crate::model::requests::{ActionRequest, ActionType};
use crate::model::{
@ -169,7 +170,11 @@ impl DataManager {
/// # Arguments
/// * `data` - a mock [`CommunityMembership`] object to insert
#[async_recursion::async_recursion]
pub async fn create_membership(&self, data: CommunityMembership) -> Result<String> {
pub async fn create_membership(
&self,
data: CommunityMembership,
user: &User,
) -> Result<String> {
// make sure membership doesn't already exist
if self
.get_membership_by_owner_community_no_void(data.owner, data.community)
@ -199,7 +204,7 @@ impl DataManager {
.await?;
// ...
return self.create_membership(data).await;
return self.create_membership(data, user).await;
}
}
_ => (),
@ -237,6 +242,9 @@ impl DataManager {
Ok(if data.role.check(CommunityPermission::REQUESTED) {
"Join request sent".to_string()
} else {
self.add_achievement(&mut user.clone(), AchievementName::JoinCommunity.into())
.await?;
"Community joined".to_string()
})
}

View file

@ -0,0 +1,183 @@
use oiseau::{cache::Cache, query_rows};
use crate::model::{
Error, Result,
auth::{Notification, User},
permissions::FinePermission,
channels::MessageReaction,
};
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_row, params};
impl DataManager {
/// Get a [`MessageReaction`] from an SQL row.
pub(crate) fn get_message_reaction_from_row(x: &PostgresRow) -> MessageReaction {
MessageReaction {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
message: get!(x->3(i64)) as usize,
emoji: get!(x->4(String)),
}
}
auto_method!(get_message_reaction_by_id()@get_message_reaction_from_row -> "SELECT * FROM message_reactions WHERE id = $1" --name="message_reaction" --returns=MessageReaction --cache-key-tmpl="atto.message_reaction:{}");
/// Get message_reactions by `owner` and `message`.
pub async fn get_message_reactions_by_owner_message(
&self,
owner: usize,
message: usize,
) -> Result<Vec<MessageReaction>> {
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 message_reactions WHERE owner = $1 AND message = $2",
&[&(owner as i64), &(message as i64)],
|x| { Self::get_message_reaction_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("message_reaction".to_string()));
}
Ok(res.unwrap())
}
/// Get a message_reaction by `owner`, `message`, and `emoji`.
pub async fn get_message_reaction_by_owner_message_emoji(
&self,
owner: usize,
message: usize,
emoji: &str,
) -> Result<MessageReaction> {
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 message_reactions WHERE owner = $1 AND message = $2 AND emoji = $3",
params![&(owner as i64), &(message as i64), &emoji],
|x| { Ok(Self::get_message_reaction_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("message_reaction".to_string()));
}
Ok(res.unwrap())
}
/// Create a new message_reaction in the database.
///
/// # Arguments
/// * `data` - a mock [`MessageReaction`] object to insert
pub async fn create_message_reaction(&self, data: MessageReaction, user: &User) -> Result<()> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let mut message = self.get_message_by_id(data.message).await?;
let channel = self.get_channel_by_id(message.channel).await?;
// ...
let res = execute!(
&conn,
"INSERT INTO message_reactions VALUES ($1, $2, $3, $4, $5)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&(data.message as i64),
&data.emoji
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// incr corresponding
if let Some(x) = message.reactions.get(&data.emoji) {
message.reactions.insert(data.emoji.clone(), x + 1);
} else {
message.reactions.insert(data.emoji.clone(), 1);
}
self.update_message_reactions(message.id, message.reactions)
.await?;
// send notif
if message.owner != user.id {
self
.create_notification(Notification::new(
"Your message has received a reaction!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has reacted \"{}\" to your [message](/chats/{}/{}?message={})!",
user.username, user.id, data.emoji, channel.community, channel.id, message.id
),
message.owner,
))
.await?;
}
// return
Ok(())
}
pub async fn delete_message_reaction(&self, id: usize, user: &User) -> Result<()> {
let message_reaction = self.get_message_reaction_by_id(id).await?;
if user.id != message_reaction.owner
&& !user.permissions.check(FinePermission::MANAGE_REACTIONS)
{
return Err(Error::NotAllowed);
}
let mut message = self.get_message_by_id(message_reaction.message).await?;
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 message_reactions WHERE id = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.0
.1
.remove(format!("atto.message_reaction:{}", id))
.await;
// decr message reaction count
if let Some(x) = message.reactions.get(&message_reaction.emoji) {
if *x == 1 {
// there are no 0 of this reaction
message.reactions.remove(&message_reaction.emoji);
} else {
// decr 1
message.reactions.insert(message_reaction.emoji, x - 1);
}
}
self.update_message_reactions(message.id, message.reactions)
.await?;
// return
Ok(())
}
}

View file

@ -31,6 +31,7 @@ impl DataManager {
edited: get!(x->4(i64)) as usize,
content: get!(x->5(String)),
context: serde_json::from_str(&get!(x->6(String))).unwrap(),
reactions: serde_json::from_str(&get!(x->7(String))).unwrap(),
}
}
@ -218,7 +219,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
params![
&(data.id as i64),
&(data.channel as i64),
@ -226,7 +227,8 @@ impl DataManager {
&(data.created as i64),
&(data.edited as i64),
&data.content,
&serde_json::to_string(&data.context).unwrap()
&serde_json::to_string(&data.context).unwrap(),
&serde_json::to_string(&data.reactions).unwrap(),
]
);
@ -254,6 +256,10 @@ impl DataManager {
return Err(Error::MiscError(e.to_string()));
}
// update channel position
self.update_channel_last_message(channel.id, unix_epoch_timestamp() as i64)
.await?;
// ...
Ok(())
}
@ -353,4 +359,6 @@ impl DataManager {
// return
Ok(())
}
auto_method!(update_message_reactions(HashMap<String, usize>) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}");
}

View file

@ -8,10 +8,14 @@ pub mod connections;
mod drafts;
mod drivers;
mod emojis;
mod invite_codes;
mod ipbans;
mod ipblocks;
mod journals;
mod memberships;
mod message_reactions;
mod messages;
mod notes;
mod notifications;
mod polls;
mod pollvotes;
@ -28,3 +32,4 @@ mod userblocks;
mod userfollows;
pub use drivers::DataManager;
pub use common::NAME_REGEX;

View file

@ -0,0 +1,298 @@
use oiseau::cache::Cache;
use crate::database::common::NAME_REGEX;
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
use crate::{auto_method, DataManager};
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
impl DataManager {
/// Get a [`Note`] from an SQL row.
pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note {
Note {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
title: get!(x->3(String)),
journal: get!(x->4(i64)) as usize,
content: get!(x->5(String)),
edited: get!(x->6(i64)) as usize,
dir: get!(x->7(i64)) as usize,
tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
is_global: get!(x->9(i32)) as i8 == 1,
}
}
auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
/// Get the number of global notes a user has.
pub async fn get_user_global_notes_count(&self, owner: usize) -> 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,
"SELECT COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1",
&[&(owner as i64)],
|x| Ok(x.get::<usize, i32>(0))
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
/// Get a note by `journal` and `title`.
pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result<Note> {
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 notes WHERE journal = $1 AND title = $2",
params![&(journal as i64), &title],
|x| { Ok(Self::get_note_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("note".to_string()));
}
Ok(res.unwrap())
}
/// Get all notes by journal.
///
/// # Arguments
/// * `id` - the ID of the journal to fetch notes for
pub async fn get_notes_by_journal(&self, id: usize) -> Result<Vec<Note>> {
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 notes WHERE journal = $1 ORDER BY edited DESC",
&[&(id as i64)],
|x| { Self::get_note_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("note".to_string()));
}
Ok(res.unwrap())
}
/// Get all notes by journal with the given tag.
///
/// # Arguments
/// * `id` - the ID of the journal to fetch notes for
/// * `tag`
pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result<Vec<Note>> {
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 notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC",
params![&(id as i64), tag],
|x| { Self::get_note_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("note".to_string()));
}
Ok(res.unwrap())
}
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10;
pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50;
/// Create a new note in the database.
///
/// # Arguments
/// * `data` - a mock [`Note`] object to insert
pub async fn create_note(&self, mut data: Note) -> Result<Note> {
// check values
if data.title.len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 64 {
return Err(Error::DataTooLong("title".to_string()));
}
if data.content.len() < 2 {
return Err(Error::DataTooShort("content".to_string()));
} else if data.content.len() > 262144 {
return Err(Error::DataTooLong("content".to_string()));
}
data.title = data.title.replace(" ", "_").to_lowercase();
// check number of notes
let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) {
let journals = self.get_notes_by_journal(data.owner).await?;
if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
return Err(Error::MiscError(
"You already have the maximum number of notes you can have in this journal"
.to_string(),
));
}
}
// check name
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&data.title).is_some() {
return Err(Error::MiscError(
"This title contains invalid characters".to_string(),
));
}
// make sure this title isn't already in use
if self
.get_note_by_journal_title(data.journal, &data.title)
.await
.is_ok()
{
return Err(Error::TitleInUse);
}
// check permission
let journal = self.get_journal_by_id(data.journal).await?;
if data.owner != journal.owner {
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,
"INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.title,
&(data.journal as i64),
&data.content,
&(data.edited as i64),
&(data.dir as i64),
&serde_json::to_string(&data.tags).unwrap(),
&if data.is_global { 1 } else { 0 }
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> {
let note = self.get_note_by_id(id).await?;
// check user permission
if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) {
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 notes WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.cache_clear_note(&note).await;
Ok(())
}
/// Delete all notes by dir ID.
///
/// # Arguments
/// * `journal`
/// * `dir`
pub async fn delete_notes_by_journal_dir(
&self,
journal: usize,
dir: usize,
user: &User,
) -> Result<()> {
let journal = self.get_journal_by_id(journal).await?;
if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) {
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 notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC",
&[&(dir as i64), &(journal.id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
/// Incremenet note views. Views are only stored in the cache.
///
/// This should only be done for global notes.
pub async fn incr_note_views(&self, id: usize) {
self.0.1.incr(format!("atto.note:{id}/views")).await;
}
pub async fn get_note_views(&self, id: usize) -> Option<String> {
self.0.1.get(format!("atto.note:{id}/views")).await
}
pub async fn cache_clear_note(&self, x: &Note) {
self.0.1.remove(format!("atto.note:{}", x.id)).await;
self.0.1.remove(format!("atto.note:{}", x.title)).await;
}
auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note);
auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
}

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