diff --git a/Cargo.lock b/Cargo.lock index a11c634..f3f387d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,7 +488,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -498,7 +498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -648,7 +648,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", "smallvec", ] @@ -728,11 +728,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emojis" -version = "0.6.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df" dependencies = [ - "phf", + "phf 0.12.1", ] [[package]] @@ -2164,7 +2164,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -2174,7 +2183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -2183,7 +2192,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -2194,7 +2203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.101", @@ -2209,6 +2218,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3067,7 +3085,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -3079,7 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -3231,7 +3249,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "9.0.0" +version = "10.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3262,7 +3280,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "9.0.0" +version = "10.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3284,7 +3302,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "9.0.0" +version = "10.0.0" dependencies = [ "pathbufd", "serde", @@ -3293,7 +3311,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "9.0.0" +version = "10.0.0" dependencies = [ "ammonia", "chrono", @@ -3476,7 +3494,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.11.3", "pin-project-lite", "postgres-protocol", "postgres-types", @@ -4066,7 +4084,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/README.md b/README.md index 050f8ce..d1a3d80 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`. +You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries. + +You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects. + ## Usage (as a user) Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out! diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index e29dcb9..8a775e8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "9.0.0" +version = "10.0.0" edition = "2024" [dependencies] @@ -33,6 +33,6 @@ async-stripe = { version = "0.41.0", features = [ "billing", "runtime-tokio-hyper", ] } -emojis = "0.6.4" +emojis = "0.7.0" webp = "0.3.0" bberry = "0.2.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index a3bb588..1bc09ad 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -39,6 +39,8 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); +pub const CARP_JS: &str = include_str!("./public/js/carp.js"); +pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -50,6 +52,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp"); pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp"); pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp"); pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp"); +pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp"); pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); @@ -68,6 +71,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp"); pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); +pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -116,6 +120,7 @@ pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp"); pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp"); +pub const STACKS_ADD_USER: &str = include_str!("./public/html/stacks/add_user.lisp"); pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp"); pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp"); @@ -153,7 +158,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) { "https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg" ); - let file_path = PathBufD::current().extend(&[icons_dir, icon]); + let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]); if exists(&file_path).unwrap() { writer.insert(icon.to_string(), read_to_string(&file_path).unwrap()); @@ -178,7 +183,8 @@ macro_rules! vendor_icon { let writer = &mut ICONS.write().await; writer.insert($name.to_string(), $icon.to_string()); - let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]); + let file_path = + PathBufD::current().extend(&[$icons_dir.clone(), format!("{}.svg", $name.to_string())]); std::fs::write(file_path, $icon).unwrap(); }}; } @@ -346,6 +352,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins); write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins); write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins); + write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins); write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins); write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); @@ -364,6 +371,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins); write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); + write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); @@ -407,6 +415,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins); write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins); + write_template!(html_path->"stacks/add_user.html"(crate::assets::STACKS_ADD_USER) --config=config --lisp plugins); write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins); write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins); @@ -494,6 +503,13 @@ pub(crate) async fn initial_context( } ctx.insert("lang", &lang.data); - ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone()); + ctx.insert( + "random_cache_breaker", + &if let Ok(c) = std::env::var("CACHE_BREAKER") { + c + } else { + CACHE_BREAKER.clone() + }, + ); ctx } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index b725251..cfae86e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -17,6 +17,7 @@ version = "1.0.0" "general:link.stats" = "Stats" "general:link.search" = "Search" "general:link.journals" = "Journals" +"general:link.achievements" = "Achievements" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -42,6 +43,8 @@ version = "1.0.0" "general:label.could_not_find_post" = "Could not find original post..." "general:label.timeline_end" = "That's a wrap!" "general:label.loading" = "Working on it!" +"general:label.send_anonymously" = "Send anonymously" +"general:label.must_activate_account" = "You need to activate your account!" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -73,6 +76,7 @@ version = "1.0.0" "auth:label.recent_replies" = "Recent replies" "auth:label.recent_posts_with_media" = "Recent posts (with media)" "auth:label.posts" = "Posts" +"auth:label.responses" = "Answers" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" @@ -86,6 +90,9 @@ version = "1.0.0" "auth:action.message" = "Message" "auth:label.banned" = "Banned" "auth:label.banned_message" = "This user has been banned for breaking the site's rules." +"auth:action.create_account" = "Create account" +"auth:action.purchase_account" = "Purchase account" +"auth:action.continue" = "Continue" "communities:action.create" = "Create" "communities:action.select" = "Select" @@ -135,6 +142,8 @@ version = "1.0.0" "communities:label.file" = "File" "communities:label.drafts" = "Drafts" "communities:label.load" = "Load" +"communities:action.draw" = "Draw" +"communities:action.remove_drawing" = "Remove drawing" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" @@ -166,10 +175,14 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"settings:label.ips" = "IPs" +"settings:label.generate_invites" = "Generate invites" +"settings:label.add_to_stack" = "Add to stack" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" "settings:tab.uploads" = "Uploads" +"settings:tab.invites" = "Invites" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" @@ -177,6 +190,9 @@ version = "1.0.0" "mod_panel:label.warnings" = "Warnings" "mod_panel:label.create_warning" = "Create warning" "mod_panel:label.associations" = "Associations" +"mod_panel:label.invited_by" = "Invited by" +"mod_panel:label.send_debug_payload" = "Send debug payload" +"mod_panel:action.send" = "Send" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" @@ -242,3 +258,15 @@ version = "1.0.0" "journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." "journals:label.editor" = "Editor" "journals:label.preview_pane" = "Preview" +"journals:action.edit_tags" = "Edit tags" +"journals:action.tags" = "Tags" +"journals:label.directories" = "Directories" +"journals:action.create_subdir" = "Create subdirectory" +"journals:action.create_root_dir" = "Create root directory" +"journals:action.move" = "Move" +"journals:action.publish" = "Publish" +"journals:action.unpublish" = "Unpublish" +"journals:action.view" = "View" + +"littleweb:label.create_new" = "Create new site" +"littleweb:label.my_services" = "My services" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 01406bb..2c3c03c 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -166,7 +166,7 @@ macro_rules! user_banned { let mut context = initial_context(&$data.0.0.0, lang, &$user).await; context.insert("profile", &$other_user); - return Ok(Html( + return Err(Html( $data.1.render("profile/banned.html", &context).unwrap(), )); }; @@ -233,7 +233,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/blocked.html", &context).unwrap(), )); } @@ -281,7 +281,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } @@ -293,7 +293,7 @@ macro_rules! check_user_blocked_or_private { context.insert("follow_requested", &false); context.insert("is_following", &false); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } @@ -352,7 +352,14 @@ macro_rules! ignore_users_gen { ($user:ident, $data:ident) => { if let Some(ref ua) = $user { [ - $data.0.get_userblocks_receivers(ua.id).await, + $data + .0 + .get_userblocks_receivers( + ua.id, + &ua.associated, + ua.settings.hide_associated_blocked_users, + ) + .await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, $data.0.get_user_stack_blocked_users(ua.id).await, ] @@ -364,7 +371,14 @@ macro_rules! ignore_users_gen { ($user:ident!, $data:ident) => {{ [ - $data.0.get_userblocks_receivers($user.id).await, + $data + .0 + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) + .await, $data .0 .get_userblocks_initiator_by_receivers($user.id) @@ -376,7 +390,13 @@ macro_rules! ignore_users_gen { ($user:ident!, #$data:ident) => { [ - $data.get_userblocks_receivers($user.id).await, + $data + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) + .await, $data.get_userblocks_initiator_by_receivers($user.id).await, ] .concat() diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 152cde1..baad195 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -38,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok(CustomEmoji::replace(value.as_str().unwrap()).into()) +} + fn color_escape(value: &Value, _: &HashMap) -> tera::Result { Ok(sanitize::color_escape(value.as_str().unwrap()).into()) } @@ -78,7 +82,11 @@ async fn main() { .compact() .init(); - let config = config::Config::get_config(); + let mut config = config::Config::get_config(); + if let Ok(port) = var("PORT") { + let port = port.parse::().expect("port should be a u16"); + config.port = port; + } // init init_dirs(&config).await; @@ -102,11 +110,25 @@ async fn main() { tera.register_filter("has_staff_badge", check_staff_badge); tera.register_filter("has_banned", check_banned); tera.register_filter("remove_script_tags", remove_script_tags); + tera.register_filter("emojis", render_emojis); let client = Client::new(); + let mut app = Router::new(); - let app = Router::new() - .merge(routes::routes(&config)) + // add correct routes + if var("LITTLEWEB").is_ok() { + app = app.merge(routes::lw_routes()); + } else { + app = app + .merge(routes::routes(&config)) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), + )); + } + + // add junk + app = app .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") @@ -119,12 +141,9 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"), - )) .layer(CatchPanicLayer::new()); + // ... let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) .await .unwrap(); diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index fbb1d4d..e1c196b 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -38,6 +38,10 @@ --pad-2: 0.5rem; --pad-3: 0.75rem; --pad-4: 1rem; + + --online: var(--color-green); + --idle: var(--color-yellow); + --offline: hsl(0, 0%, 50%); } .dark, @@ -218,7 +222,7 @@ pre { } code { - padding: var(--pad-1); + padding: 0; } pre, @@ -263,7 +267,7 @@ span, code { max-width: 100%; overflow-wrap: normal; - text-wrap: pretty; + text-wrap: stable; word-wrap: break-word; } @@ -389,3 +393,11 @@ blockquote { transform: rotateZ(360deg); } } + +canvas { + border-radius: var(--radius); + border: solid 5px var(--color-primary); + background: white; + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f592c77..24c41bd 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -413,6 +413,44 @@ select:focus { overflow-wrap: anywhere; } +input[type="checkbox"] { + --color: #c9b1bc; + appearance: none; + border-radius: var(--radius); + transition: + box-shadow 0.15s, + background 0.15s; + background-color: var(--color-super-raised); + background-position: center; + background-origin: padding-box; + background-size: 14px; + background-repeat: no-repeat; + width: 1em !important; + height: 1em; + min-width: 1em; + outline: none; + border: solid 1px var(--color-super-lowered); + padding: 0; + cursor: pointer; + color: var(--color-text-primary); +} + +input[type="checkbox"]:hover { + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); +} + +input[type="checkbox"]:focus { + outline: solid 2px var(--color); + outline-offset: 2px; +} + +input[type="checkbox"]:checked { + border-color: var(--color); + background-color: var(--color); + background-image: url("/icons/check.svg"); +} + /* pillmenu */ .pillmenu { display: flex; @@ -544,6 +582,7 @@ select:focus { font-size: 12px; border-radius: 6px; height: max-content; + font-weight: 600; } .notification.tr { @@ -632,7 +671,7 @@ nav .button:not(.title):not(.active):hover { margin-bottom: 0; backdrop-filter: none; bottom: 0; - position: absolute; + position: fixed; height: max-content; top: unset; } @@ -1065,14 +1104,14 @@ details summary::-webkit-details-marker { display: none; } -details[open] summary { +details[open] > summary { position: relative; - color: var(--color-primary); - background: var(--color-super-lowered); + color: var(--color-text-lowered) !important; + background: var(--color-super-lowered) !important; margin-bottom: var(--pad-1); } -details[open] summary::after { +details[open] > summary::after { top: 0; left: 0; width: 5px; @@ -1250,3 +1289,32 @@ details.accordion .inner { .CodeMirror-focused .CodeMirror-placeholder { opacity: 50%; } + +.CodeMirror-gutters { + border-color: var(--color-super-lowered) !important; + background-color: var(--color-lowered) !important; +} + +.CodeMirror-hints { + background: var(--color-raised) !important; + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); + border-radius: var(--radius) !important; + padding: var(--pad-1) !important; + border-color: var(--color-super-lowered) !important; +} + +.CodeMirror-hints li { + color: var(--color-text-raised) !important; + border-radius: var(--radius) !important; + transition: + background 0.15s, + color 0.15s; + font-size: 10px; + padding: calc(var(--pad-1) / 2) var(--pad-2); +} + +.CodeMirror-hints li.CodeMirror-hint-active { + background-color: var(--color-primary) !important; + color: var(--color-text-primary) !important; +} diff --git a/crates/app/src/public/html/auth/base.lisp b/crates/app/src/public/html/auth/base.lisp index c13c336..3ed7b5a 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block body %}") (main ("class" "flex flex-col gap-2") - ("style" "max-width: 25rem") + ("style" "max-width: 48ch") (h2 ("class" "w-full text-center") ; block for title diff --git a/crates/app/src/public/html/auth/connection.lisp b/crates/app/src/public/html/auth/connection.lisp index 905b215..8c4fcef 100644 --- a/crates/app/src/public/html/auth/connection.lisp +++ b/crates/app/src/public/html/auth/connection.lisp @@ -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 }}\"; diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index 82ce3b4..e887b2b 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -48,7 +48,8 @@ ("name" "totp") ("id" "totp")))) (button - (text "Submit"))) + (icon (text "arrow-right")) + (str (text "auth:action.continue")))) (script (text "let flow_page = 1; @@ -90,7 +91,7 @@ }), }) .then((res) => res.json()) - .then((res) => { + .then(async (res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, @@ -98,7 +99,7 @@ if (res.ok) { // update tokens - const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS; + const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS; new_tokens[e.target.username.value] = res.message; trigger(\"me::set_login_account_tokens\", [new_tokens]); diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 116cdcf..aa94c3d 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -25,7 +25,7 @@ (div ("class" "flex flex-col gap-1") (label - ("for" "username") + ("for" "password") (b (text "Password"))) (input @@ -34,6 +34,35 @@ ("required" "") ("name" "password") ("id" "password"))) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "flex flex-col gap-1") + ("oninput" "check_should_show_purchase(event)") + (label + ("for" "invite_code") + (b + (text "Invite code (optional)"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("id" "invite_code"))) + + (script + (text "function check_should_show_purchase(e) { + if (e.target.value.length > 0) { + document.querySelector('[ui_ident=purchase_account]').classList.add('hidden'); + document.querySelector('[ui_ident=create_account]').classList.remove('hidden'); + globalThis.DO_PURCHASE = false; + } else { + document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden'); + document.querySelector('[ui_ident=create_account]').classList.add('hidden'); + globalThis.DO_PURCHASE = true; + } + } + + globalThis.DO_PURCHASE = true;")) + (text "{%- endif %}") (hr) (div ("class" "card-nest w-full") @@ -56,7 +85,7 @@ ("href" "{{ config.policies.privacy }}") (text "Privacy policy")))) (div - ("class" "flex gap-2") + ("class" "flex items-center gap-2") (input ("type" "checkbox") ("name" "policy_consent") @@ -70,8 +99,33 @@ ("class" "cf-turnstile") ("data-sitekey" "{{ config.turnstile.site_key }}")) (hr) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex gap-2 justify-between") + ("ui_ident" "purchase_account") + + (button + (icon (text "credit-card")) + (str (text "auth:action.purchase_account"))) + + (button + ("class" "small square lowered") + ("type" "button") + ("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "hidden lowered card w-full no_p_margin") + ("ui_ident" "purchase_help") + (b (text "What does \"Purchase account\" mean?")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}.")) + (p (text "Alternatively, you can provide an invite code to create your account for free."))) + (text "{%- endif %}") (button - (text "Submit"))) + ("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}") + ("ui_ident" "create_account") + (icon (text "plus")) + (str (text "auth:action.create_account")))) (script (text "async function register(e) { @@ -89,10 +143,12 @@ captcha_response: e.target.querySelector( \"[name=cf-turnstile-response]\", ).value, + invite_code: (e.target.invite_code || { value: \"\" }).value, + purchase: globalThis.DO_PURCHASE, }), }) .then((res) => res.json()) - .then((res) => { + .then(async (res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, @@ -100,7 +156,7 @@ if (res.ok) { // update tokens - const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS; + const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS; new_tokens[e.target.username.value] = res.message; trigger(\"me::set_login_account_tokens\", [new_tokens]); diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 16a47d8..afc41b4 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -1,5 +1,17 @@ (div ("id" "toast_zone")) +; large text +(text "{% if user and user.settings.large_text -%}") +(style + (text "button, a, p, span, b, strone, em, i, pre, code { + font-size: 18px !important; + } + + nav .icon { + font-size: 15px !important; + }")) +(text "{%- endif %}") + ; templates (template ("id" "loading_skeleton") @@ -18,10 +30,46 @@ (div ("class" "skel") ("style" "width: 25%; height: 25px;")) (div ("class" "skel") ("style" "width: 100%; height: 150px")))))) +(template + ("id" "carp_canvas") + (div + ("class" "flex flex-col gap-2") + (div ("ui_ident" "canvas_loc")) + (div + ("class" "flex justify-between gap-2") + (div + ("class" "flex gap-2") + (input + ("type" "color") + ("style" "width: 5rem") + ("ui_ident" "color_picker")) + + (input + ("type" "range") + ("min" "1") + ("max" "25") + ("step" "1") + ("value" "2") + ("ui_ident" "stroke_range"))) + + (div + ("class" "flex gap-2") + (button + ("title" "Undo") + ("ui_ident" "undo") + ("type" "button") + (icon (text "undo"))) + + (button + ("title" "Redo") + ("ui_ident" "redo") + ("type" "button") + (icon (text "redo"))))))) + ; random js (text "", + { + let mut out = String::new(); + + for block in &self.blocks { + out.push_str(&block.to_string()); + } + + out + }, + self.css, + self.js + )) + } +} + +/// Blocks are the basis of each layout page. They are simple and composable. +#[derive(Serialize, Deserialize)] +pub struct LayoutBlock { + pub r#type: BlockType, + pub children: Vec, +} + +impl LayoutBlock { + pub fn render_children(&self) -> String { + let mut out = String::new(); + + for child in &self.children { + out.push_str(&child.to_string()); + } + + out + } +} + +impl Display for LayoutBlock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut out = String::new(); + + // head + out.push_str(&match self.r#type { + BlockType::Block(ref x) => format!("<{} {}>", x.element, x), + BlockType::Flexible(ref x) => format!("<{} {}>", x.element, x), + BlockType::Markdown(ref x) => format!("<{} {}>", x.element, x), + BlockType::Timeline(ref x) => format!("<{} {}>", x.element, x), + }); + + // body + out.push_str(&match self.r#type { + BlockType::Block(_) => self.render_children(), + BlockType::Flexible(_) => self.render_children(), + BlockType::Markdown(ref x) => x.sub_options.content.to_string(), + BlockType::Timeline(ref x) => { + format!( + "
", + x.sub_options.timeline + ) + } + }); + + // tail + out.push_str(&self.r#type.unwrap_cloned().element.tail()); + + // ... + f.write_str(&out) + } +} + +/// Each different type of block has different attributes associated with it. +#[derive(Serialize, Deserialize)] +pub enum BlockType { + Block(GeneralBlockOptions), + Flexible(GeneralBlockOptions), + Markdown(GeneralBlockOptions), + Timeline(GeneralBlockOptions), +} + +impl BlockType { + pub fn unwrap(self) -> GeneralBlockOptions> { + match self { + Self::Block(x) => x.boxed(), + Self::Flexible(x) => x.boxed(), + Self::Markdown(x) => x.boxed(), + Self::Timeline(x) => x.boxed(), + } + } + + pub fn unwrap_cloned(&self) -> GeneralBlockOptions> { + match self { + Self::Block(x) => x.boxed_cloned::(), + Self::Flexible(x) => x.boxed_cloned::(), + Self::Markdown(x) => x.boxed_cloned::(), + Self::Timeline(x) => x.boxed_cloned::(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HtmlElement { + Div, + Span, + Italics, + Bold, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Image, +} + +impl HtmlElement { + pub fn tail(&self) -> String { + match self { + Self::Image => String::new(), + _ => format!(""), + } + } +} + +impl Display for HtmlElement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Div => "div", + Self::Span => "span", + Self::Italics => "i", + Self::Bold => "b", + Self::Heading1 => "h1", + Self::Heading2 => "h2", + Self::Heading3 => "h3", + Self::Heading4 => "h4", + Self::Heading5 => "h5", + Self::Heading6 => "h6", + Self::Image => "img", + }) + } +} + +/// This trait is used to provide cloning capabilities to structs which DO implement +/// clone, but we aren't allowed to tell the compiler that they implement clone +/// (through a trait bound), as Clone is not dyn compatible. +/// +/// Implementations for this trait should really just take reference to another +/// value (T), then just run `.to_owned()` on it. This means T and F (Self) MUST +/// be the same type. +pub trait RefFrom { + fn ref_from(value: &T) -> Self; +} + +#[derive(Serialize, Deserialize)] +pub struct GeneralBlockOptions +where + T: Display, +{ + pub element: HtmlElement, + pub class_list: String, + pub id: String, + pub attributes: HashMap, + pub sub_options: T, +} + +impl GeneralBlockOptions { + pub fn boxed(self) -> GeneralBlockOptions> { + GeneralBlockOptions { + element: self.element, + class_list: self.class_list, + id: self.id, + attributes: self.attributes, + sub_options: Box::new(self.sub_options), + } + } + + pub fn boxed_cloned + 'static>( + &self, + ) -> GeneralBlockOptions> { + let x: F = F::ref_from(&self.sub_options); + GeneralBlockOptions { + element: self.element.clone(), + class_list: self.class_list.clone(), + id: self.id.clone(), + attributes: self.attributes.clone(), + sub_options: Box::new(x), + } + } +} + +impl Display for GeneralBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "class=\"{} {}\" {} id={} {}", + self.class_list, + self.sub_options.to_string(), + { + let mut attrs = String::new(); + + for (k, v) in &self.attributes { + attrs.push_str(&format!("{k}=\"{v}\"")); + } + + attrs + }, + self.id, + if self.element == HtmlElement::Image { + "/" + } else { + "" + } + )) + } +} +#[derive(Clone, Serialize, Deserialize)] +pub struct EmptyBlockOptions; + +impl Display for EmptyBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +impl RefFrom for EmptyBlockOptions { + fn ref_from(value: &EmptyBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FlexibleBlockOptions { + pub gap: FlexibleBlockGap, + pub direction: FlexibleBlockDirection, + pub wrap: bool, + pub collapse: bool, +} + +impl Display for FlexibleBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "flex {} {} {} {}", + self.gap, + self.direction, + if self.wrap { "flex-wrap" } else { "" }, + if self.collapse { "flex-collapse" } else { "" } + )) + } +} + +impl RefFrom for FlexibleBlockOptions { + fn ref_from(value: &FlexibleBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum FlexibleBlockGap { + Tight, + Comfortable, + Spacious, + Large, +} + +impl Display for FlexibleBlockGap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Tight => "gap-1", + Self::Comfortable => "gap-2", + Self::Spacious => "gap-3", + Self::Large => "gap-4", + }) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum FlexibleBlockDirection { + Row, + Column, +} + +impl Display for FlexibleBlockDirection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Row => "flex-row", + Self::Column => "flex-col", + }) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct MarkdownBlockOptions { + pub content: String, +} + +impl Display for MarkdownBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +impl RefFrom for MarkdownBlockOptions { + fn ref_from(value: &MarkdownBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TimelineBlockOptions { + pub timeline: DefaultTimelineChoice, +} + +impl Display for TimelineBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("w-full flex flex-col gap-2\" ui_ident=\"io_data_load") + } +} + +impl RefFrom for TimelineBlockOptions { + fn ref_from(value: &TimelineBlockOptions) -> Self { + value.to_owned() + } +} diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs new file mode 100644 index 0000000..79cffb1 --- /dev/null +++ b/crates/core/src/model/littleweb.rs @@ -0,0 +1,199 @@ +use std::fmt::Display; +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub files: Vec, +} + +impl Service { + /// Create a new [`Service`]. + pub fn new(name: String, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + files: Vec::new(), + } + } + + /// Resolve a file from the virtual file system. + pub fn file(&self, path: &str) -> Option { + let segments = path.chars().filter(|x| x == &'/').count(); + + let mut path = path.split("/"); + let mut path_segment = path.next().unwrap(); + let mut i = 0; + + let mut f = &self.files; + + while let Some(nf) = f.iter().find(|x| x.name == path_segment) { + if i == segments - 1 { + return Some(nf.to_owned()); + } + + f = &nf.children; + path_segment = path.next().unwrap(); + i += 1; + } + + None + } +} + +/// A file type for [`ServiceFsEntry`] structs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ServiceFsMime { + #[serde(alias = "text/html")] + Html, + #[serde(alias = "text/css")] + Css, + #[serde(alias = "text/javascript")] + Js, + #[serde(alias = "application/json")] + Json, + #[serde(alias = "text/plain")] + Plain, +} + +impl Display for ServiceFsMime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Html => "text/html", + Self::Css => "text/css", + Self::Js => "text/javascript", + Self::Json => "application/json", + Self::Plain => "text/plain", + }) + } +} + +/// A single entry in the file system of [`Service`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceFsEntry { + pub name: String, + pub mime: ServiceFsMime, + pub children: Vec, + pub content: String, + /// SHA-256 checksum of the entry's content. + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainTld { + Bunny, +} + +impl Display for DomainTld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Bunny => "bunny", + }) + } +} + +impl From<&str> for DomainTld { + fn from(value: &str) -> Self { + match value { + "bunny" => Self::Bunny, + _ => Self::Bunny, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Domain { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub tld: DomainTld, + /// Data about the domain. This can only be configured by the domain's owner. + /// + /// Maximum of 4 entries. Stored in a structure of `(subdomain string, data)`. + pub data: Vec<(String, DomainData)>, +} + +impl Domain { + /// Create a new [`Domain`]. + pub fn new(name: String, tld: DomainTld, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + tld, + data: Vec::new(), + } + } + + /// Get the domain's subdomain, name, TLD, and path segments from a string. + /// + /// If no subdomain is provided, the subdomain will be "@". This means that + /// domain data entries should use "@" as the root service. + pub fn from_str(value: &str) -> (String, String, DomainTld, String) { + let no_protocol = value.replace("atto://", ""); + + // we're reversing this so it's predictable, as there might not always be a subdomain + // (we shouldn't have the variable entry be first, there is always going to be a tld) + let mut s: Vec<&str> = no_protocol.split(".").collect(); + s.reverse(); + let mut s = s.into_iter(); + + let tld = DomainTld::from(s.next().unwrap()); + let domain = s.next().unwrap(); + let subdomain = s.next().unwrap_or("@"); + + // get path + let mut chars = no_protocol.chars(); + let mut char = '.'; + + while char != '/' { + // we need to keep eating characters until we reach the first / + // (marking the start of the path) + char = chars.next().unwrap(); + } + + let path: String = chars.collect(); + + // return + (subdomain.to_owned(), domain.to_owned(), tld, path) + } + + /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. + /// + /// This would not be needed if the JS custom protocol API wasn't awful. + pub fn http_assets(input: String) -> String { + // this is served over the littleweb api NOT the main api! + // + // littleweb requests MUST be on another subdomain so cookies are + // not shared with custom user HTML (since users can embed JS which can make POST requests) + // + // the littleweb routes are used by providing the "LITTLEWEB" env var + input.replace("\"atto://", "/api/v1/file?addr=atto://") + } + + /// Get the domain's service ID. + pub fn service(&self, subdomain: &str) -> Option { + let s = self.data.iter().find(|x| x.0 == subdomain)?; + match s.1 { + DomainData::Service(id) => Some(id), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainData { + /// The ID of the service this domain points to. The first service found will + /// always be used. This means having multiple service entires will be useless. + Service(usize), + /// A text entry with a maximum of 512 characters. + Text(String), +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index c50ea7c..e825340 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,10 +1,13 @@ pub mod addr; pub mod apps; pub mod auth; +pub mod carp; pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; +pub mod layouts; +pub mod littleweb; pub mod moderation; pub mod oauth; pub mod permissions; @@ -41,10 +44,13 @@ pub enum Error { AlreadyAuthenticated, DataTooLong(String), DataTooShort(String), + FileTooLarge, + FileTooSmall, UsernameInUse, TitleInUse, QuestionsDisabled, RequiresSupporter, + DrawingsDisabled, Unknown, } @@ -62,10 +68,13 @@ impl Display for Error { Self::AlreadyAuthenticated => "Already authenticated".to_string(), Self::DataTooLong(name) => format!("Given {name} is too long!"), Self::DataTooShort(name) => format!("Given {name} is too short!"), + Self::FileTooLarge => "Given file is too large".to_string(), + Self::FileTooSmall => "Given file is too small".to_string(), Self::UsernameInUse => "Username in use".to_string(), Self::TitleInUse => "Title in use".to_string(), Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(), + Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index df34f3d..07a23c3 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,6 +68,12 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, + /// Read the user's layouts. + UserReadLayouts, + /// Read the user's domains. + UserReadDomains, + /// Read the user's services. + UserReadServices, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -86,6 +92,12 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, + /// Create layouts on behalf of the user. + UserCreateLayouts, + /// Create domains on behalf of the user. + UserCreateDomains, + /// Create services on behalf of the user. + UserCreateServices, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -120,6 +132,12 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, + /// Manage the user's layouts. + UserManageLayouts, + /// Manage the user's domains. + UserManageDomains, + /// Manage the user's services. + UserManageServices, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 9cd6dcb..55cf9cc 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -44,86 +44,110 @@ bitflags! { } } -impl Serialize for FinePermission { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_u32(self.bits()) - } +macro_rules! user_permission { + ($struct:ident, $visitor:ident, $banned_check:ident) => { + impl Serialize for $struct { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u32(self.bits()) + } + } + + struct $visitor; + impl Visitor<'_> for $visitor { + type Value = $struct; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("u32") + } + + fn visit_u32(self, value: u32) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value)) + } + } + + fn visit_i32(self, value: i32) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value as u32) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value as u32)) + } + } + + fn visit_u64(self, value: u64) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value as u32) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value as u32)) + } + } + } + + impl<'de> Deserialize<'de> for $struct { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any($visitor) + } + } + + impl $struct { + /// Join two permissions into a single `u32`. + pub fn join(lhs: $struct, rhs: $struct) -> Self { + lhs | rhs + } + + /// Check if the given `input` contains the given permission. + pub fn check(self, permission: $struct) -> bool { + if (self & $struct::ADMINISTRATOR) == $struct::ADMINISTRATOR { + // has administrator permission, meaning everything else is automatically true + return true; + } else if self.$banned_check() { + // has banned permission, meaning everything else is automatically false + return false; + } + + (self & permission) == permission + } + + /// Sink for checking if the permission is banned. + pub fn sink(&self) -> bool { + false + } + } + + impl Default for $struct { + fn default() -> Self { + Self::DEFAULT + } + } + }; } -struct FinePermissionVisitor; -impl Visitor<'_> for FinePermissionVisitor { - type Value = FinePermission; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("u32") - } - - fn visit_u32(self, value: u32) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value)) - } - } - - fn visit_i32(self, value: i32) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value as u32) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value as u32)) - } - } - - fn visit_u64(self, value: u64) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value as u32) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value as u32)) - } - } -} - -impl<'de> Deserialize<'de> for FinePermission { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_any(FinePermissionVisitor) - } -} +user_permission!(FinePermission, FinePermissionVisitor, check_banned); impl FinePermission { - /// Join two [`FinePermission`]s into a single `u32`. - pub fn join(lhs: FinePermission, rhs: FinePermission) -> FinePermission { - lhs | rhs + /// Check if the given permission qualifies as "Banned" status. + pub fn check_banned(self) -> bool { + (self & FinePermission::BANNED) == FinePermission::BANNED } - /// Check if the given `input` contains the given [`FinePermission`]. - pub fn check(self, permission: FinePermission) -> bool { - if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR { - // has administrator permission, meaning everything else is automatically true - return true; - } else if self.check_banned() { - // has banned permission, meaning everything else is automatically false - return false; - } - - (self & permission) == permission - } - - /// Check if the given [`FinePermission`] qualifies as "Helper" status. + /// Check if the given permission qualifies as "Helper" status. pub fn check_helper(self) -> bool { self.check(FinePermission::MANAGE_COMMUNITIES) && self.check(FinePermission::MANAGE_POSTS) @@ -133,24 +157,28 @@ impl FinePermission { && self.check(FinePermission::VIEW_AUDIT_LOG) } - /// Check if the given [`FinePermission`] qualifies as "Manager" status. + /// Check if the given permission qualifies as "Manager" status. pub fn check_manager(self) -> bool { self.check_helper() && self.check(FinePermission::MANAGE_USERS) } - /// Check if the given [`FinePermission`] qualifies as "Administrator" status. + /// Check if the given permission qualifies as "Administrator" status. pub fn check_admin(self) -> bool { self.check_manager() && self.check(FinePermission::ADMINISTRATOR) } +} - /// Check if the given [`FinePermission`] qualifies as "Banned" status. - pub fn check_banned(self) -> bool { - (self & FinePermission::BANNED) == FinePermission::BANNED +bitflags! { + /// Fine-grained permissions built using bitwise operations. Second permission value. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct SecondaryPermission: u32 { + const DEFAULT = 1 << 0; + const ADMINISTRATOR = 1 << 1; + const MANAGE_DOMAINS = 1 << 2; + const MANAGE_SERVICES = 1 << 3; + + const _ = !0; } } -impl Default for FinePermission { - fn default() -> Self { - Self::DEFAULT - } -} +user_permission!(SecondaryPermission, SecondaryPermissionVisitor, sink); diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index d502697..35165c6 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -5,7 +5,7 @@ use crate::config::Config; use std::fs::{write, exists, remove_file}; use super::{Error, Result}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum MediaType { #[serde(alias = "image/webp")] Webp, @@ -17,6 +17,8 @@ pub enum MediaType { Jpg, #[serde(alias = "image/gif")] Gif, + #[serde(alias = "image/carpgraph")] + Carpgraph, } impl MediaType { @@ -27,6 +29,7 @@ impl MediaType { Self::Png => "png", Self::Jpg => "jpg", Self::Gif => "gif", + Self::Carpgraph => "carpgraph", } } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index c50b714..1b3e5e1 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "9.0.0" +version = "10.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 8d48901..5fd4230 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "9.0.0" +version = "10.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index c4b51da..1ae665b 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -14,8 +14,9 @@ pub fn render_markdown(input: &str) -> String { }, parse: ParseOptions { constructs: Constructs { - gfm_autolink_literal: true, - ..Default::default() + math_flow: true, + math_text: true, + ..Constructs::gfm() }, gfm_strikethrough_single_tilde: false, math_text_single_dollar: false, diff --git a/example/nginx/sites-enabled/tetratto.conf b/example/nginx/sites-enabled/tetratto.conf new file mode 100644 index 0000000..52aa6a4 --- /dev/null +++ b/example/nginx/sites-enabled/tetratto.conf @@ -0,0 +1,28 @@ +# servers can be uncommented to add load balancing +upstream tetratto { + least_conn; + server localhost:4118; + # server localhost:5118; +} + +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name tetratto; + + # main service stuff + location / { + proxy_pass http://tetratto; + proxy_pass_header CF-Connecting-IP; + proxy_pass_request_headers on; + } + + # websocket forwarding stuff + location ~ /_connect/ { + proxy_pass http://tetratto; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/example/tetratto.toml b/example/tetratto.toml index 37119a4..bc7ff59 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -4,6 +4,7 @@ color = "#c9b1bc" port = 4118 banned_hosts = [] host = "http://localhost:4118" +lw_host = "http://localhost:4119" no_track = [] banned_usernames = [ "admin", @@ -23,6 +24,7 @@ html_footer_path = "public/footer.html" [security] registration_enabled = true real_ip_header = "CF-Connecting-IP" +enable_invite_codes = false [dirs] templates = "html" @@ -37,8 +39,8 @@ user = "user" password = "postgres" [policies] -terms_of_service = "/public/tos.html" -privacy = "/public/privacy.html" +terms_of_service = "/doc/tos.md" +privacy = "/doc/privacy.md" [turnstile] site_key = "1x00000000000000000000AA" diff --git a/justfile b/justfile index ad945c9..a83d0c4 100644 --- a/justfile +++ b/justfile @@ -8,3 +8,7 @@ fix: doc: cargo doc --document-private-items --no-deps + +test: + cd example && LITTLEWEB=true PORT=4119 cargo run & + cd example && cargo run diff --git a/sql_changes/journals_dirs.sql b/sql_changes/journals_dirs.sql new file mode 100644 index 0000000..72d1aaf --- /dev/null +++ b/sql_changes/journals_dirs.sql @@ -0,0 +1,2 @@ +ALTER TABLE journals +ADD COLUMN dirs TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/messages_reactions.sql b/sql_changes/messages_reactions.sql new file mode 100644 index 0000000..684e890 --- /dev/null +++ b/sql_changes/messages_reactions.sql @@ -0,0 +1,2 @@ +ALTER TABLE messages +ADD COLUMN reactions TEXT NOT NULL DEFAULT '{}'; diff --git a/sql_changes/notes_dir_tags.sql b/sql_changes/notes_dir_tags.sql new file mode 100644 index 0000000..0bf24d1 --- /dev/null +++ b/sql_changes/notes_dir_tags.sql @@ -0,0 +1,5 @@ +ALTER TABLE notes +ADD COLUMN dir BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE notes +ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/notes_is_global.sql b/sql_changes/notes_is_global.sql new file mode 100644 index 0000000..eeea878 --- /dev/null +++ b/sql_changes/notes_is_global.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes +ADD COLUMN is_global INT NOT NULL DEFAULT 0; diff --git a/sql_changes/questions_drawings.sql b/sql_changes/questions_drawings.sql new file mode 100644 index 0000000..f45e50b --- /dev/null +++ b/sql_changes/questions_drawings.sql @@ -0,0 +1,2 @@ +ALTER TABLE questions +ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/users_achievements.sql b/sql_changes/users_achievements.sql new file mode 100644 index 0000000..2eadcf4 --- /dev/null +++ b/sql_changes/users_achievements.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN achievements TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/users_awaiting_purchase.sql b/sql_changes/users_awaiting_purchase.sql new file mode 100644 index 0000000..5d2d565 --- /dev/null +++ b/sql_changes/users_awaiting_purchase.sql @@ -0,0 +1,5 @@ +ALTER TABLE users +ADD COLUMN awaiting_purchase INT NOT NULL DEFAULT 0; + +ALTER TABLE users +ADD COLUMN was_purchased INT NOT NULL DEFAULT 0; diff --git a/sql_changes/users_invite_code.sql b/sql_changes/users_invite_code.sql new file mode 100644 index 0000000..2e97a8d --- /dev/null +++ b/sql_changes/users_invite_code.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN invite_code BIGINT NOT NULL DEFAULT 0; diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql new file mode 100644 index 0000000..d80e60b --- /dev/null +++ b/sql_changes/users_layouts.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP COLUMN layouts; diff --git a/sql_changes/users_secondary_permissions.sql b/sql_changes/users_secondary_permissions.sql new file mode 100644 index 0000000..7172889 --- /dev/null +++ b/sql_changes/users_secondary_permissions.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN secondary_permissions INT NOT NULL DEFAULT 1;