From 97b7e873eddafe1938c72d71d8ccf4e67eb70b7b Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 16:19:57 -0400 Subject: [PATCH 01/74] fix: journal privacy --- crates/app/src/public/html/journals/app.lisp | 33 ++++++++++++++++++-- crates/app/src/routes/api/v1/journals.rs | 23 +++++++++++--- crates/app/src/routes/api/v1/notes.rs | 25 +++++++++++---- crates/core/src/database/journals.rs | 15 ++++++++- crates/core/src/database/mod.rs | 1 + crates/core/src/database/notes.rs | 13 ++++++++ 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 267541a..4a3ccbd 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -272,7 +272,34 @@ ("class" "flex flex-col gap-2 card") (text "{{ note.content|markdown|safe }}")) - (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (div + ("class" "flex w-full justify-between gap-2") + (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (text "{% if user and user.id == owner.id -%}") + (button + ("class" "small") + ("onclick" "{% 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 %}") + (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 @@ -431,8 +458,8 @@ globalThis.change_journal_privacy = async (e) => { e.preventDefault(); - const selected = event.target.selectedOptions[0]; - fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", { + const selected = e.target.selectedOptions[0]; + fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 97f8c9b..0cf3617 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -9,11 +9,14 @@ use crate::{ routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle}, State, }; -use tetratto_core::model::{ - journals::{Journal, JournalPrivacyPermission}, - oauth, - permissions::FinePermission, - ApiReturn, Error, +use tetratto_core::{ + database::NAME_REGEX, + model::{ + journals::{Journal, JournalPrivacyPermission}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, }; pub async fn get_request( @@ -101,6 +104,16 @@ pub async fn update_title_request( props.title = props.title.replace(" ", "_"); + // 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) diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 45c4a74..41ab1f9 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -10,12 +10,15 @@ use crate::{ routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle}, State, }; -use tetratto_core::model::{ - journals::{JournalPrivacyPermission, Note}, - oauth, - permissions::FinePermission, - uploads::CustomEmoji, - ApiReturn, Error, +use tetratto_core::{ + database::NAME_REGEX, + model::{ + journals::{JournalPrivacyPermission, Note}, + oauth, + permissions::FinePermission, + uploads::CustomEmoji, + ApiReturn, Error, + }, }; pub async fn get_request( @@ -137,6 +140,16 @@ pub async fn update_title_request( props.title = props.title.replace(" ", "_"); + // 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) diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 4602b6b..5979347 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -1,9 +1,10 @@ use oiseau::{cache::Cache, query_row}; use crate::{ + database::common::NAME_REGEX, model::{ auth::User, - permissions::FinePermission, journals::{Journal, JournalPrivacyPermission}, + permissions::FinePermission, Error, Result, }, }; @@ -85,6 +86,18 @@ impl DataManager { data.title = data.title.replace(" ", "_"); + // 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) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index e56bc93..a00fde9 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -30,3 +30,4 @@ mod userblocks; mod userfollows; pub use drivers::DataManager; +pub use common::NAME_REGEX; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index d46394b..377fa6e 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -1,4 +1,5 @@ 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}; @@ -84,6 +85,18 @@ impl DataManager { data.title = data.title.replace(" ", "_"); + // 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) From 1b1c1c0beaad68d4090daa3868a907641084cf76 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 16:23:33 -0400 Subject: [PATCH 02/74] fix: make forward slash escape mentions parser --- crates/core/src/model/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c12f76..a5714c3 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -332,7 +332,7 @@ impl User { // parse for char in input.chars() { - if (char == '\\') && !escape { + if ((char == '\\') | (char == '/')) && !escape { escape = true; continue; } From eb5a0d146f5f55145a49098fb274e6d4aa571328 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 16:34:08 -0400 Subject: [PATCH 03/74] fix: make journal and note titles lowercase add: remove journal index route --- crates/app/src/public/html/journals/app.lisp | 4 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/pages/journals.rs | 78 ++++++++++++++++++++ crates/app/src/routes/pages/mod.rs | 2 +- crates/core/src/database/journals.rs | 2 +- crates/core/src/database/notes.rs | 2 +- 7 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 4a3ccbd..6fbcea2 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -65,7 +65,7 @@ (a ("class" "flush") - ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") + ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") (b (text "{{ journal.title }}"))) (text "{% if note -%}") @@ -83,7 +83,7 @@ (icon (text "pencil"))) (a ("class" "{% if view_mode -%}active{%- endif %}") - ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}") + ("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}") (icon (text "eye")))) (text "{%- endif %}")) (text "{%- endif %}") diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 0cf3617..d501f08 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -102,7 +102,7 @@ pub async fn update_title_request( None => return Json(Error::NotAllowed.into()), }; - props.title = props.title.replace(" ", "_"); + props.title = props.title.replace(" ", "_").to_lowercase(); // check name let regex = regex::RegexBuilder::new(NAME_REGEX) diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 41ab1f9..faf1bec 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -138,7 +138,7 @@ pub async fn update_title_request( Err(e) => return Json(e.into()), }; - props.title = props.title.replace(" ", "_"); + props.title = props.title.replace(" ", "_").to_lowercase(); // check name let regex = regex::RegexBuilder::new(NAME_REGEX) diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index f631826..397b4cd 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -207,3 +207,81 @@ pub async fn view_request( // return Ok(Html(data.1.render("journals/app.html", &context).unwrap())) } + +/// `/@{owner}/{journal}` +pub async fn index_view_request( + jar: CookieJar, + Extension(data): Extension, + Path((owner, selected_journal)): Path<(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, + }; + + // 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 = match data.0.get_notes_by_journal(journal.id).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + let 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", ¬es); + + context.insert("view_mode", &true); + context.insert("is_editor", &false); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 2177d94..2eaeca2 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -134,7 +134,7 @@ pub fn routes() -> Router { // journals .route("/journals", get(journals::redirect_request)) .route("/journals/{journal}/{note}", get(journals::app_request)) - .route("/@{owner}/{journal}", get(journals::view_request)) + .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) } diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 5979347..8c2a637 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -84,7 +84,7 @@ impl DataManager { return Err(Error::DataTooLong("title".to_string())); } - data.title = data.title.replace(" ", "_"); + data.title = data.title.replace(" ", "_").to_lowercase(); // check name let regex = regex::RegexBuilder::new(NAME_REGEX) diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index 377fa6e..e3fcdab 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -83,7 +83,7 @@ impl DataManager { return Err(Error::DataTooLong("content".to_string())); } - data.title = data.title.replace(" ", "_"); + data.title = data.title.replace(" ", "_").to_lowercase(); // check name let regex = regex::RegexBuilder::new(NAME_REGEX) From f0d1a1e8e4790a474cf9ce657242ea8255d49236 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 16:37:11 -0400 Subject: [PATCH 04/74] add: show mobile help text on journals homepage --- crates/app/src/public/html/journals/app.lisp | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 6fbcea2..d148a0f 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -96,6 +96,7 @@ ("class" "card w-full flex flex-col gap-2") (h3 (str (text "journals:label.welcome"))) (span (str (text "journals:label.select_a_journal"))) + (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals"))) (button ("onclick" "create_journal()") (icon (text "plus")) From dc50f3a8afcdb8ee4a72820b224541fa91260750 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 19:13:07 -0400 Subject: [PATCH 05/74] add: journal.css special note --- crates/app/src/public/css/style.css | 29 +++++++++ crates/app/src/public/html/journals/app.lisp | 62 +++++++++++++++++-- .../app/src/public/html/profile/settings.lisp | 12 ++-- crates/app/src/routes/api/v1/journals.rs | 14 +++++ crates/app/src/routes/api/v1/mod.rs | 1 + crates/app/src/routes/pages/journals.rs | 2 +- crates/core/src/database/journals.rs | 2 +- crates/core/src/database/notes.rs | 16 +++++ 8 files changed, 125 insertions(+), 13 deletions(-) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f592c77..0603ee1 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1250,3 +1250,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/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index d148a0f..d65163d 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -9,6 +9,11 @@ ; 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 %} {{ macros::nav(selected=\"journals\") }}") (text "{% if not view_mode -%}") (nav @@ -73,7 +78,7 @@ (b (text "{{ note.title }}")) (text "{%- endif %}")) - (text "{% if user and user.id == journal.owner -%}") + (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}") (div ("class" "pillmenu") (a @@ -181,10 +186,36 @@ ; import codemirror (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true")) (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true")) - (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true")) + (text "{% if note.title == \"journal.css\" -%}") + ; css editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true")) + + (style + (text ".CodeMirror { + font-family: monospace !important; + font-size: 16px; + border: solid 1px var(--color-super-lowered); + border-radius: var(--radius); + } + + .CodeMirror-line { + padding-left: 5px !important; + }")) + (text "{% else %}") + ; markdown editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) + (text "{%- endif %}") + ; tab bar + (text "{% if note.title != \"journal.css\" -%}") (div ("class" "pillmenu") (a @@ -199,6 +230,7 @@ ("data-tab-button" "preview") ("data-turbo" "false") (str (text "journals:label.preview_pane")))) + (text "{%- endif %}") ; tabs (div @@ -222,10 +254,15 @@ (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) (script (text "setTimeout(() => { + if (!document.getElementById(\"preview_tab\").shadowRoot) { + document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" }); + } + globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { value: document.getElementById(\"editor_content\").innerHTML, - mode: \"markdown\", + mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\", lineWrapping: true, + lineNumbers: \"{{ note.title }}\" === \"journal.css\", autoCloseBrackets: true, autofocus: true, viewportMargin: Number.POSITIVE_INFINITY, @@ -233,7 +270,8 @@ highlightFormatting: false, fencedCodeBlockHighlighting: false, xml: false, - smartIndent: false, + smartIndent: true, + indentUnit: 4, placeholder: `# {{ note.title }}`, extraKeys: { Home: \"goLineLeft\", @@ -244,6 +282,15 @@ }, }); + editor.on(\"keydown\", (cm, e) => { + if (e.key.length > 1) { + // ignore all keys that aren't a letter + return; + } + + CodeMirror.showHint(cm, CodeMirror.hint.css); + }); + document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => { e.preventDefault(); trigger(\"atto::hooks::tabs:switch\", [\"editor\"]); @@ -263,7 +310,10 @@ }) ).text(); - document.getElementById(\"preview_tab\").innerHTML = res; + const preview_token = window.crypto.randomUUID(); + document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}`; trigger(\"atto::hooks::tabs:switch\", [\"preview\"]); }); }, 150);")) @@ -360,7 +410,7 @@ }, body: JSON.stringify({ title, - content: `# ${title}`, + content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`, journal: \"{{ selected_journal }}\", }), }) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8be4836..88c6d59 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -564,14 +564,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,7 +579,7 @@ (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 @@ -587,7 +587,9 @@ (li (text "Increased proxied image size")) (li - (text "Create infinite journals"))) + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index d501f08..de6d501 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -49,6 +49,20 @@ pub async fn get_request( }) } +pub async fn get_css_request( + Path(id): Path, + Extension(data): Extension, +) -> 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/plain")], format!("/* {e} */")), + }; + + ([("Content-Type", "text/css")], note.content) +} + pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f16b1ed..9217437 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -551,6 +551,7 @@ pub fn routes() -> Router { .route("/journals", post(journals::create_request)) .route("/journals/{id}", get(journals::get_request)) .route("/journals/{id}", delete(journals::delete_request)) + .route("/journals/{id}/journal.css", get(journals::get_css_request)) .route("/journals/{id}/title", post(journals::update_title_request)) .route( "/journals/{id}/privacy", diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index 397b4cd..1f03dd7 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -116,7 +116,7 @@ pub async fn view_request( } // if we don't have a selected journal, we shouldn't be here probably - if selected_journal.is_empty() { + if selected_journal.is_empty() | (selected_note == "journal.css") { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &user).await, )); diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 8c2a637..a4a0d00 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -70,7 +70,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_JOURNALS: usize = 15; + const MAXIMUM_FREE_JOURNALS: usize = 5; /// Create a new journal in the database. /// diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index e3fcdab..ea7da45 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -65,6 +65,8 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; + /// Create a new note in the database. /// /// # Arguments @@ -85,6 +87,20 @@ impl DataManager { 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) From fa72d6a59dbc7d77ea5d1d27484b1b3db79a1200 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 19:27:42 -0400 Subject: [PATCH 06/74] fix: journals ui panic --- crates/app/src/public/html/components.lisp | 4 ++-- crates/app/src/public/html/journals/app.lisp | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 75a24ef..2726b26 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1928,7 +1928,7 @@ (text "{%- endif %}") ; note listings - (text "{% for note in notes %}") + (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}") (div ("class" "flex flex-row gap-1") (a @@ -1958,6 +1958,6 @@ (icon (text "trash")) (str (text "general:action.delete"))))) (text "{%- endif %}")) - (text "{% endfor %}")) + (text "{%- endif %} {% endfor %}")) (text "{%- endif %}") (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index d65163d..a5136ff 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -68,10 +68,12 @@ ("href" "/api/v1/auth/user/find/{{ journal.owner }}") (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", 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 "/")) From ffdf320c14ca5873b376def07435c6323986397d Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 22:10:17 -0400 Subject: [PATCH 07/74] add: ability to enable pages instead of infinite scrolling --- crates/app/src/public/html/profile/posts.lisp | 3 ++- crates/app/src/public/html/profile/settings.lisp | 5 +++++ crates/app/src/public/html/stacks/feed.lisp | 3 ++- crates/app/src/public/html/timelines/all.lisp | 3 ++- crates/app/src/public/html/timelines/following.lisp | 3 ++- crates/app/src/public/html/timelines/home.lisp | 3 ++- crates/app/src/public/html/timelines/popular.lisp | 3 ++- .../app/src/public/html/timelines/swiss_army.lisp | 4 ++++ crates/app/src/public/js/atto.js | 13 +++++++++++-- crates/app/src/routes/api/v1/auth/images.rs | 8 ++++---- crates/app/src/routes/api/v1/communities/images.rs | 4 ++-- crates/app/src/routes/api/v1/communities/posts.rs | 2 +- crates/app/src/routes/pages/misc.rs | 3 +++ crates/core/src/model/auth.rs | 3 +++ crates/core/src/model/mod.rs | 2 ++ 15 files changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 0c9d79a..06aca2f 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -44,9 +44,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 88c6d59..268dfef 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1403,6 +1403,11 @@ \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\", \"text\", ], + [ + [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], + \"{{ profile.settings.paged_timelines }}\", + \"checkbox\", + ], [[], \"Fun\", \"title\"], [ [\"disable_gpa_fun\", \"Disable GPA\"], diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index 5698065..5002856 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -83,9 +83,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker"))) + (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{%- endif %}")))) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index c38dd88..7cced78 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -33,9 +33,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp index b1759e4..ef23a55 100644 --- a/crates/app/src/public/html/timelines/following.lisp +++ b/crates/app/src/public/html/timelines/following.lisp @@ -11,9 +11,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index e398615..5a5658b 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -31,9 +31,10 @@ (div ("ui_ident" "io_data_marker"))) (text "{%- endif %}")) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp index 6d26f3d..d0223df 100644 --- a/crates/app/src/public/html/timelines/popular.lisp +++ b/crates/app/src/public/html/timelines/popular.lisp @@ -11,9 +11,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index eb722c9..23243ce 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -30,3 +30,7 @@ (str (text "chats:label.go_back"))) (text "{%- endif %}")) (text "{%- endif %}") + +(text "{% if paginated -%}") +(text "{{ components::pagination(page=page, items=list|length) }}") +(text "{%- endif %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 6c30428..835f76e 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1141,7 +1141,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} }, ); - self.define("io_data_load", (_, tmpl, page) => { + self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => { self.IO_DATA_MARKER = document.querySelector( "[ui_ident=io_data_marker]", ); @@ -1164,7 +1164,16 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_DATA_PAGE = page; self.IO_DATA_SEEN_IDS = []; - self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); + 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(); + } + + self.IO_PAGINATED = paginated_mode; }); self.define("io_load_data", async () => { diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index e062be1..e177db7 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -213,7 +213,7 @@ pub async fn upload_avatar_request( if mime == "image/gif" { // gif image, don't encode if img.0.len() > MAXIMUM_GIF_FILE_SIZE { - return Json(Error::DataTooLong("gif".to_string()).into()); + return Json(Error::FileTooLarge.into()); } std::fs::write(&path, img.0).unwrap(); @@ -226,7 +226,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 @@ -314,7 +314,7 @@ pub async fn upload_banner_request( if mime == "image/gif" { // gif image, don't encode if img.0.len() > MAXIMUM_GIF_FILE_SIZE { - return Json(Error::DataTooLong("gif".to_string()).into()); + return Json(Error::FileTooLarge.into()); } std::fs::write(&path, img.0).unwrap(); @@ -327,7 +327,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 diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 464dede..3ddee00 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -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 diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 81a1fae..7bf4bf3 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -133,7 +133,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()); } } diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 3ff3f0d..8b76292 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -576,6 +576,8 @@ pub struct TimelineQuery { pub user_id: usize, #[serde(default)] pub tag: String, + #[serde(default)] + pub paginated: bool, } /// `/_swiss_army_timeline` @@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request( context.insert("list", &list); context.insert("page", &req.page); + context.insert("paginated", &req.paginated); Ok(Html( data.1 .render("timelines/swiss_army.html", &context) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index a5714c3..bc8b13f 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -231,6 +231,9 @@ pub struct UserSettings { /// A list of strings the user has muted. #[serde(default)] pub muted: Vec, + /// If timelines are paged instead of infinitely scrolled. + #[serde(default)] + pub paged_timelines: bool, } fn mime_avif() -> String { diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index c50ea7c..62f26a3 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -41,6 +41,7 @@ pub enum Error { AlreadyAuthenticated, DataTooLong(String), DataTooShort(String), + FileTooLarge, UsernameInUse, TitleInUse, QuestionsDisabled, @@ -62,6 +63,7 @@ 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::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(), From 6be729de50c3f5c74965fbcfa077cf86a2c6ebc3 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 22:37:49 -0400 Subject: [PATCH 08/74] fix: journals scrolling --- crates/app/src/public/html/journals/app.lisp | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index a5136ff..012909d 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -2,6 +2,27 @@ (text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (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 }}")) From 16843a6ab8fd6620c04320d2a71ecdb4171396bf Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 20 Jun 2025 17:40:55 -0400 Subject: [PATCH 09/74] add: drawings in questions --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/css/root.css | 8 + crates/app/src/public/html/body.lisp | 36 + crates/app/src/public/html/components.lisp | 94 ++- crates/app/src/public/html/journals/app.lisp | 3 +- crates/app/src/public/html/profile/media.lisp | 2 +- .../app/src/public/html/profile/outbox.lisp | 2 +- crates/app/src/public/html/profile/posts.lisp | 2 +- .../app/src/public/html/profile/replies.lisp | 2 +- .../app/src/public/html/profile/settings.lisp | 8 + crates/app/src/public/js/atto.js | 1 + crates/app/src/public/js/carp.js | 624 ++++++++++++++++++ crates/app/src/public/js/loader.js | 2 +- .../routes/api/v1/communities/questions.rs | 8 +- crates/app/src/routes/api/v1/journals.rs | 12 +- crates/app/src/routes/api/v1/uploads.rs | 17 +- crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + crates/core/src/database/auth.rs | 2 +- crates/core/src/database/posts.rs | 18 + crates/core/src/database/questions.rs | 65 +- crates/core/src/model/auth.rs | 3 + crates/core/src/model/carp.rs | 285 ++++++++ crates/core/src/model/communities.rs | 4 + crates/core/src/model/mod.rs | 3 + crates/core/src/model/uploads.rs | 5 +- sql_changes/questions_drawings.sql | 2 + 28 files changed, 1181 insertions(+), 32 deletions(-) create mode 100644 crates/app/src/public/js/carp.js create mode 100644 crates/core/src/model/carp.rs create mode 100644 sql_changes/questions_drawings.sql diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index a3bb588..5533a77 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -39,6 +39,7 @@ 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"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index b725251..11491fc 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -135,6 +135,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" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index fbb1d4d..3d7dd62 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -389,3 +389,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/html/body.lisp b/crates/app/src/public/html/body.lisp index 16a47d8..a8e398b 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -18,6 +18,42 @@ (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/mod.rs b/crates/core/src/model/mod.rs index 839310f..3ff8379 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; +pub mod layouts; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql new file mode 100644 index 0000000..0d8e489 --- /dev/null +++ b/sql_changes/users_layouts.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN layouts TEXT NOT NULL DEFAULT '{}'; From b493b2ade8e4c957ab6835b7435470fef9694dd9 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 20:14:04 -0400 Subject: [PATCH 65/74] add: layouts api --- crates/app/src/routes/api/v1/layouts.rs | 175 ++++++++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 34 +++++ crates/core/src/database/auth.rs | 2 - crates/core/src/database/common.rs | 5 +- crates/core/src/database/layouts.rs | 117 ++++++++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/model/auth.rs | 8 -- crates/core/src/model/layouts.rs | 19 ++- crates/core/src/model/oauth.rs | 12 +- sql_changes/users_layouts.sql | 2 +- 10 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 crates/app/src/routes/api/v1/layouts.rs create mode 100644 crates/core/src/database/layouts.rs diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs new file mode 100644 index 0000000..b86bfd2 --- /dev/null +++ b/crates/app/src/routes/api/v1/layouts.rs @@ -0,0 +1,175 @@ +use crate::{ + get_user_from_token, + routes::{ + api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + model::{ + layouts::{Layout, LayoutPrivacy}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let layout = match data.get_layout_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if layout.privacy == LayoutPrivacy::Public + && user.id != layout.owner + && !user.permissions.check(FinePermission::MANAGE_USERS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(layout), + }) +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_layouts_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_layout(Layout::new(req.name, user.id, req.replaces)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Layout created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_title(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_privacy(id, &user, req.privacy).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_pages_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_pages(id, &user, req.pages).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_layout(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index c88b003..f207f1c 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod journals; +pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -26,6 +27,7 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, + layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -612,6 +614,17 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + // layouts + .route("/layouts", get(layouts::list_request)) + .route("/layouts", post(layouts::create_request)) + .route("/layouts/{id}", get(layouts::get_request)) + .route("/layouts/{id}", delete(layouts::delete_request)) + .route("/layouts/{id}/title", post(layouts::update_name_request)) + .route( + "/layouts/{id}/privacy", + post(layouts::update_privacy_request), + ) + .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } #[derive(Deserialize)] @@ -993,3 +1006,24 @@ pub struct UpdateNoteTags { pub struct AwardAchievement { pub name: AchievementName, } + +#[derive(Deserialize)] +pub struct CreateLayout { + pub name: String, + pub replaces: CustomizablePage, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPrivacy { + pub privacy: LayoutPrivacy, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPages { + pub pages: Vec, +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4038fb9..f6fb848 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,7 +112,6 @@ impl DataManager { 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(), - layouts: serde_json::from_str(&get!(x->24(String)).to_string()).unwrap(), } } @@ -294,7 +293,6 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), - &serde_json::to_string(&data.layouts).unwrap(), ] ); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 45111db..6a22ba9 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -397,10 +397,7 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!( - "invoked `{}` with x value `{id}` and y value `{x:?}`", - stringify!($name) - ), + format!("invoked `{}` with x value `{id}`", stringify!($name)), )) .await? } diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs new file mode 100644 index 0000000..6ab1f48 --- /dev/null +++ b/crates/core/src/database/layouts.rs @@ -0,0 +1,117 @@ +use crate::model::{ + auth::User, + layouts::{Layout, LayoutPage, LayoutPrivacy}, + permissions::FinePermission, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; + +impl DataManager { + /// Get a [`Layout`] from an SQL row. + pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { + Layout { + 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(), + pages: serde_json::from_str(&get!(x->5(String))).unwrap(), + replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); + + /// Get all layouts by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch layouts for + pub async fn get_layouts_by_user(&self, id: usize) -> Result> { + 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 layouts WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_layout_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("layout".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new layout in the database. + /// + /// # Arguments + /// * `data` - a mock [`Layout`] object to insert + pub async fn create_layout(&self, data: Layout) -> Result { + // 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())); + } + + // ... + 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 layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", + 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.pages).unwrap(), + &serde_json::to_string(&data.replaces).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { + let layout = self.get_layout_by_id(id).await?; + + // check user permission + if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { + 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 layouts WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.layout:{}", id)).await; + Ok(()) + } + + auto_method!(update_layout_title(&str)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_pages(Vec)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5f81259..6877100 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -12,6 +12,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod layouts; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c0e761..bac4ae6 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use crate::model::layouts::CustomizablePage; - use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -63,11 +61,6 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, - /// The ID of each layout the user is using. - /// - /// Only applies if the user is a supporter. - #[serde(default)] - pub layouts: HashMap, } pub type UserConnections = @@ -326,7 +319,6 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), - layouts: HashMap::new(), } } diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs index 7254d0a..a9d60a4 100644 --- a/crates/core/src/model/layouts.rs +++ b/crates/core/src/model/layouts.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt::Display}; use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::auth::DefaultTimelineChoice; /// Each different page which can be customized. @@ -20,10 +21,26 @@ pub struct Layout { pub title: String, pub privacy: LayoutPrivacy, pub pages: Vec, + pub replaces: CustomizablePage, +} + +impl Layout { + /// Create a new [`Layout`]. + pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + privacy: LayoutPrivacy::Public, + pages: Vec::new(), + replaces, + } + } } /// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum LayoutPrivacy { Public, Private, diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index e783a1e..7d5ebb6 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,8 +68,8 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, - /// Read the user's links. - UserReadLinks, + /// Read the user's layouts. + UserReadLayouts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -88,8 +88,8 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, - /// Create links on behalf of the user. - UserCreateLinks, + /// Create layouts on behalf of the user. + UserCreateLayouts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -124,8 +124,8 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, - /// Manage the user's links. - UserManageLinks, + /// Manage the user's layouts. + UserManageLayouts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql index 0d8e489..d80e60b 100644 --- a/sql_changes/users_layouts.sql +++ b/sql_changes/users_layouts.sql @@ -1,2 +1,2 @@ ALTER TABLE users -ADD COLUMN layouts TEXT NOT NULL DEFAULT '{}'; +DROP COLUMN layouts; From ee2f7c7cbb3ae5b96fb955ca3bccf8d5c1528628 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 22:41:10 -0400 Subject: [PATCH 66/74] fix: render dates in quotes with long text --- crates/app/src/public/js/atto.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 30d29bd..6503548 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -163,7 +163,7 @@ media_theme_pref(); } }); - self.define("clean_poll_date_codes", ({ $ }) => { + self.define("clean_poll_date_codes", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll(".poll_date"), )) { @@ -183,7 +183,7 @@ media_theme_pref(); element.setAttribute("title", then.toLocaleString()); const pretty = - $.rel_date(then) + (await $.rel_date(then)) .replaceAll(" minutes ago", "m") .replaceAll(" minute ago", "m") .replaceAll(" hours ago", "h") @@ -409,9 +409,13 @@ media_theme_pref(); } }); - self.define("hooks::long", (_, element, full_text) => { + self.define("hooks::long", ({ $ }, element, full_text) => { element.classList.remove("hook:long.hidden_text"); element.innerHTML = full_text; + + $.clean_date_codes(); + $.clean_poll_date_codes(); + $.link_filter(); }); self.define("hooks::long_text.init", (_) => { From 0aa2ea362fea1b26b3d2e24374103aec0d12edba Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 23:10:58 -0400 Subject: [PATCH 67/74] chore: refactor auto_method macro for SecondaryPermission --- crates/core/src/database/apps.rs | 8 +++--- crates/core/src/database/channels.rs | 10 +++---- crates/core/src/database/common.rs | 36 ++++++++++++------------- crates/core/src/database/communities.rs | 8 +++--- crates/core/src/database/emojis.rs | 2 +- crates/core/src/database/journals.rs | 6 ++--- crates/core/src/database/layouts.rs | 6 ++--- crates/core/src/database/notes.rs | 8 +++--- crates/core/src/database/stacks.rs | 10 +++---- 9 files changed, 47 insertions(+), 47 deletions(-) diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 9faf6d4..f24b427 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -136,11 +136,11 @@ impl DataManager { Ok(()) } - auto_method!(update_app_title(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_scopes(Vec)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index b3dc31b..ee42d4b 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -317,10 +317,10 @@ impl DataManager { Ok(()) } - auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_members(Vec)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_members(Vec)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 6a22ba9..aabb0d3 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -226,12 +226,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -256,12 +256,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -288,12 +288,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -319,12 +319,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -352,12 +352,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -387,12 +387,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -567,12 +567,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -600,12 +600,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -679,12 +679,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 8237b7e..fa7c234 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -521,10 +521,10 @@ impl DataManager { Ok(()) } - auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); diff --git a/crates/core/src/database/emojis.rs b/crates/core/src/database/emojis.rs index c61fed6..4f09af7 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -201,5 +201,5 @@ impl DataManager { Ok(()) } - auto_method!(update_emoji_name(&str)@get_emoji_by_id:MANAGE_EMOJIS -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); + auto_method!(update_emoji_name(&str)@get_emoji_by_id:FinePermission::MANAGE_EMOJIS; -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); } diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 2ad4078..4855f9d 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -183,7 +183,7 @@ impl DataManager { 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:{}"); + auto_method!(update_journal_title(&str)@get_journal_by_id:FinePermission::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:FinePermission::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:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs index 6ab1f48..052a733 100644 --- a/crates/core/src/database/layouts.rs +++ b/crates/core/src/database/layouts.rs @@ -111,7 +111,7 @@ impl DataManager { Ok(()) } - auto_method!(update_layout_title(&str)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_pages(Vec)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_title(&str)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_pages(Vec)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); } diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index 2754baf..9769159 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -289,10 +289,10 @@ impl DataManager { 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)@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_title(&str)@get_note_by_id:FinePermission::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:FinePermission::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:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_tags(Vec)@get_note_by_id:FinePermission::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); } diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 46a3e30..c4fa5df 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -245,10 +245,10 @@ impl DataManager { Ok(()) } - auto_method!(update_stack_name(&str)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_users(Vec)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_name(&str)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_users(Vec)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_mode(StackMode)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_sort(StackSort)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_mode(StackMode)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_sort(StackSort)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); } From 2ec8d86edf080e527ceebe88cd7d0aa64015942d Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 3 Jul 2025 21:56:21 -0400 Subject: [PATCH 68/74] add: purchased accounts --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 4 + crates/app/src/public/html/auth/base.lisp | 2 +- crates/app/src/public/html/auth/login.lisp | 3 +- crates/app/src/public/html/auth/register.lisp | 47 +- crates/app/src/public/html/components.lisp | 77 ++ crates/app/src/public/html/mod/profile.lisp | 10 + .../app/src/public/html/profile/settings.lisp | 120 ++- crates/app/src/public/html/root.lisp | 68 +- crates/app/src/public/js/layout_editor.js | 762 ++++++++++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 21 + crates/app/src/routes/api/v1/auth/mod.rs | 75 +- crates/app/src/routes/api/v1/auth/profile.rs | 84 +- crates/app/src/routes/api/v1/mod.rs | 24 + crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 4 + crates/core/src/config.rs | 2 + crates/core/src/database/auth.rs | 55 +- .../src/database/drivers/sql/create_users.sql | 4 +- crates/core/src/database/invite_codes.rs | 23 +- crates/core/src/model/auth.rs | 11 + sql_changes/users_awaiting_purchase.sql | 5 + 22 files changed, 1279 insertions(+), 124 deletions(-) create mode 100644 crates/app/src/public/js/layout_editor.js create mode 100644 sql_changes/users_awaiting_purchase.sql diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3958f09..89af907 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -40,6 +40,7 @@ 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"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f854057..ea87729 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -44,6 +44,7 @@ version = "1.0.0" "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" @@ -88,6 +89,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" 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/login.lisp b/crates/app/src/public/html/auth/login.lisp index cb8bfff..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; diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 9e6c22b..aa94c3d 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -37,16 +37,31 @@ (text "{% if config.security.enable_invite_codes -%}") (div ("class" "flex flex-col gap-1") + ("oninput" "check_should_show_purchase(event)") (label ("for" "invite_code") (b - (text "Invite code"))) + (text "Invite code (optional)"))) (input ("type" "text") ("placeholder" "invite code") - ("required" "") ("name" "invite_code") ("id" "invite_code"))) + + (script + (text "function check_should_show_purchase(e) { + if (e.target.value.length > 0) { + document.querySelector('[ui_ident=purchase_account]').classList.add('hidden'); + document.querySelector('[ui_ident=create_account]').classList.remove('hidden'); + globalThis.DO_PURCHASE = false; + } else { + document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden'); + document.querySelector('[ui_ident=create_account]').classList.add('hidden'); + globalThis.DO_PURCHASE = true; + } + } + + globalThis.DO_PURCHASE = true;")) (text "{%- endif %}") (hr) (div @@ -84,8 +99,33 @@ ("class" "cf-turnstile") ("data-sitekey" "{{ config.turnstile.site_key }}")) (hr) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex gap-2 justify-between") + ("ui_ident" "purchase_account") + + (button + (icon (text "credit-card")) + (str (text "auth:action.purchase_account"))) + + (button + ("class" "small square lowered") + ("type" "button") + ("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "hidden lowered card w-full no_p_margin") + ("ui_ident" "purchase_help") + (b (text "What does \"Purchase account\" mean?")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.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) { @@ -104,6 +144,7 @@ \"[name=cf-turnstile-response]\", ).value, invite_code: (e.target.invite_code || { value: \"\" }).value, + purchase: globalThis.DO_PURCHASE, }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d9001c9..a15fe19 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2285,3 +2285,80 @@ (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") (text "{%- endif %} {% endfor %}")) (text "{%- endmacro %}") + +(text "{% macro become_supporter_button() -%}") +(p + (text "You're ") + (b + (text "not ")) + (text "currently a supporter! No + pressure, but it helps us do some pretty cool + things! As a supporter, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Vanity badge on profile")) + (li + (text "No more supporter ads (duh)")) + (li + (text "Ability to upload gif avatars/banners")) + (li + (text "Be an admin/owner of up to 10 communities")) + (li + (text "Use custom CSS on your profile")) + (li + (text "Use community emojis outside of + their community")) + (li + (text "Upload and use gif emojis")) + (li + (text "Create infinite stack timelines")) + (li + (text "Upload images to posts")) + (li + (text "Save infinite post drafts")) + (li + (text "Ability to search through all posts")) + (li + (text "Ability to create forges")) + (li + (text "Create more than 1 app")) + (li + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks")) + (li + (text "Increased proxied image size")) + (li + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal")) + (li + (text "Publish up to 50 notes")) + + (text "{% if config.security.enable_invite_codes -%}") + (li + (text "Create up to 48 invite codes") + (sup (a ("href" "#footnote-1") (text "1")))) + (text "{%- endif %}")) +(a + ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when + completing payment. It is required to manage + your billing settings.")) + +(text "{% if config.security.enable_invite_codes -%}") +(span + ("class" "fade") + ("id" "footnote-1") + (b (text "1: ")) (text "After your account is at least 1 month old")) +(text "{%- endif %}") +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index e64ec63..9fb5ebf 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -168,6 +168,11 @@ \"{{ profile.is_verified }}\", \"checkbox\", ], + [ + [\"awaiting_purchase\", \"Awaiting purchase\"], + \"{{ profile.awaiting_purchase }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -181,6 +186,11 @@ is_verified: value, }); }, + awaiting_purchase: (value) => { + profile_request(false, \"awaiting_purchase\", { + awaiting_purchase: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index dce30d2..8acd9c3 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -671,73 +671,31 @@ ("target" "_blank") (text "Manage billing")) (text "{% else %}") - (p - (text "You're ") - (b - (text "not ")) - (text "currently a supporter! No - pressure, but it helps us do some pretty cool - things! As a supporter, you'll get:")) - (ul - ("style" "margin-bottom: var(--pad-4)") - (li - (text "Vanity badge on profile")) - (li - (text "No more supporter ads (duh)")) - (li - (text "Ability to upload gif avatars/banners")) - (li - (text "Be an admin/owner of up to 10 communities")) - (li - (text "Use custom CSS on your profile")) - (li - (text "Use community emojis outside of - their community")) - (li - (text "Upload and use gif emojis")) - (li - (text "Create infinite stack timelines")) - (li - (text "Upload images to posts")) - (li - (text "Save infinite post drafts")) - (li - (text "Ability to search through all posts")) - (li - (text "Ability to create forges")) - (li - (text "Create more than 1 app")) - (li - (text "Create up to 10 stack blocks")) - (li - (text "Add unlimited users to stacks")) - (li - (text "Increased proxied image size")) - (li - (text "Create infinite journals")) - (li - (text "Create infinite notes in each journal")) - (li - (text "Publish up to 50 notes")) - - (text "{% if config.security.enable_invite_codes -%}") - (li - (text "Create up to 48 invite codes")) - (text "{%- endif %}")) - (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") - ("class" "button") - ("target" "_blank") - (text "Become a supporter")) - (span - ("class" "fade") - (text "Please use your") - (b - (text "real email")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + + (text "{% if user.was_purchased and user.invite_code == 0 -%}") + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling.")) + + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + (text "{%- endif %}") (text "{%- endif %}"))))) (div ("class" "w-full hidden flex flex-col gap-2") @@ -1198,6 +1156,11 @@ globalThis.delete_account = async (e) => { e.preventDefault(); + // {% if user.permissions|has_supporter %} + alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\"); + return; + // {% endif %} + if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this?\", @@ -1381,6 +1344,31 @@ }); }; + globalThis.update_invite_code = async (e) => { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + const account_settings = document.getElementById(\"account_settings\"); const profile_settings = diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index a4288b3..93312bb 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -72,7 +72,73 @@ (str (text "general:label.account_banned_body")))))) ; if we aren't banned, just show the page body - (text "{% else %} {% block body %}{% endblock %} {%- endif %}") + (text "{% elif user and user.awaiting_purchase %}") + ; account waiting for payment message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "general:label.must_activate_account"))) + + (div + ("class" "card no_p_margin flex flex-col gap-2") + (p (text "Since you didn't provide an invite code, you'll need to activate your account to use it.")) + (p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code.")) + (div + ("class" "card w-full lowered flex flex-col gap-2") + (text "{{ components::become_supporter_button() }}")) + (p (text "Alternatively, you can provide an invite code to activate your account.")) + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + + (script + (text "async function update_invite_code(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")))))) + (text "{% else %}") + ; page body + (text "{% block body %}{% endblock %}") + (text "{%- endif %}") (text "")) (text "{% include \"body.html\" %}"))) diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js new file mode 100644 index 0000000..13d3d8b --- /dev/null +++ b/crates/app/src/public/js/layout_editor.js @@ -0,0 +1,762 @@ +/// Copy all the fields from one object to another. +function copy_fields(from, to) { + for (const field of Object.entries(from)) { + to[field[0]] = field[1]; + } + + return to; +} + +/// Simple template components. +const COMPONENT_TEMPLATES = { + EMPTY_COMPONENT: { component: "empty", options: {}, children: [] }, + FLEX_DEFAULT: { + component: "flex", + options: { + direction: "row", + gap: "2", + }, + children: [], + }, + FLEX_SIMPLE_ROW: { + component: "flex", + options: { + direction: "row", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_SIMPLE_COL: { + component: "flex", + options: { + direction: "col", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_MOBILE_COL: { + component: "flex", + options: { + collapse: "yes", + gap: "2", + width: "full", + }, + children: [], + }, + MARKDOWN_DEFAULT: { + component: "markdown", + options: { + text: "Hello, world!", + }, + }, + MARKDOWN_CARD: { + component: "markdown", + options: { + class: "card w-full", + text: "Hello, world!", + }, + }, +}; + +/// All available components with their label and JSON representation. +const COMPONENTS = [ + [ + "Markdown block", + COMPONENT_TEMPLATES.MARKDOWN_DEFAULT, + [["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]], + ], + [ + "Flex container", + COMPONENT_TEMPLATES.FLEX_DEFAULT, + [ + ["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW], + ["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL], + ["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL], + ], + ], + [ + "Profile tabs", + { + component: "tabs", + }, + ], + [ + "Profile feeds", + { + component: "feed", + }, + ], + [ + "Profile banner", + { + component: "banner", + }, + ], + [ + "Question box", + { + component: "ask", + }, + ], + [ + "Name & avatar", + { + component: "name", + }, + ], + [ + "About section", + { + component: "about", + }, + ], + [ + "Action buttons", + { + component: "actions", + }, + ], + [ + "CSS stylesheet", + { + component: "style", + options: { + data: "", + }, + }, + ], +]; + +// preload icons +trigger("app::icon", ["shapes"]); +trigger("app::icon", ["type"]); +trigger("app::icon", ["plus"]); +trigger("app::icon", ["move-up"]); +trigger("app::icon", ["move-down"]); +trigger("app::icon", ["trash"]); +trigger("app::icon", ["arrow-left"]); +trigger("app::icon", ["x"]); + +/// The location of an element as represented by array indexes. +class ElementPointer { + position = []; + + constructor(element) { + if (element) { + const pos = []; + + let target = element; + while (target.parentElement) { + const parent = target.parentElement; + + // push index + pos.push(Array.from(parent.children).indexOf(target) || 0); + + // update target + if (parent.id === "editor") { + break; + } + + target = parent; + } + + this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse + } else { + this.position = []; + } + } + + get() { + return this.position; + } + + resolve(json, minus = 0) { + let out = json; + + if (this.position.length === 1) { + // this is the first element (this.position === [0]) + return out; + } + + const pos = this.position.slice(1, this.position.length); // the first one refers to the root element + + for (let i = 0; i < minus; i++) { + pos.pop(); + } + + for (const idx of pos) { + const child = ((out || { children: [] }).children || [])[idx]; + + if (!child) { + break; + } + + out = child; + } + + return out; + } +} + +/// The layout editor controller. +class LayoutEditor { + element; + json; + tree = ""; + current = { component: "empty" }; + pointer = new ElementPointer(); + + /// Create a new [`LayoutEditor`]. + constructor(element, json) { + this.element = element; + this.json = json; + + if (this.json.json) { + delete this.json.json; + } + + element.addEventListener("click", (e) => this.click(e, this)); + element.addEventListener("mouseover", (e) => { + e.stopImmediatePropagation(); + const ptr = new ElementPointer(e.target); + + if (document.getElementById("position")) { + document.getElementById( + "position", + ).parentElement.style.display = "flex"; + + document.getElementById("position").innerText = ptr + .get() + .join("."); + } + }); + + this.render(); + } + + /// Render layout. + render() { + fetch("/api/v0/auth/render_layout", { + method: "POST", + body: JSON.stringify({ + layout: this.json, + }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((r) => r.json()) + .then((r) => { + this.element.innerHTML = r.block; + this.tree = r.tree; + + if (this.json.component !== "empty") { + // remove all "empty" components (if the root component isn't an empty) + for (const element of document.querySelectorAll( + '[data-component-name="empty"]', + )) { + element.remove(); + } + } + }); + } + + /// Editor clicked. + click(e, self) { + e.stopImmediatePropagation(); + trigger("app::hooks::dropdown.close"); + + const ptr = new ElementPointer(e.target); + self.current = ptr.resolve(self.json); + self.pointer = ptr; + + if (document.getElementById("current_position")) { + document.getElementById( + "current_position", + ).parentElement.style.display = "flex"; + + document.getElementById("current_position").innerText = ptr + .get() + .join("."); + } + + for (const element of document.querySelectorAll( + ".layout_editor_block.active", + )) { + element.classList.remove("active"); + } + + e.target.classList.add("active"); + self.screen("element"); + } + + /// Open sidebar. + open() { + document.getElementById("editor_sidebar").classList.add("open"); + document.getElementById("editor").style.transform = "scale(0.8)"; + } + + /// Close sidebar. + close() { + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards to_left"; + + setTimeout(() => { + document.getElementById("editor_sidebar").classList.remove("open"); + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards from_right"; + }, 250); + + document.getElementById("editor").style.transform = "scale(1)"; + } + + /// Render editor dialog. + screen(page = "element", data = {}) { + this.current.component = this.current.component.toLowerCase(); + + const sidebar = document.getElementById("editor_sidebar"); + sidebar.innerHTML = ""; + + // render page + if ( + page === "add" || + (page === "element" && this.current.component === "empty") + ) { + // add element + sidebar.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = data.add_title || "Add component"; + return heading; + })(), + ); + + sidebar.appendChild(document.createElement("hr")); + + const container = document.createElement("div"); + container.className = "flex w-full gap-2 flex-wrap"; + + for (const component of data.components || COMPONENTS) { + container.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("secondary"); + + trigger("app::icon", [ + data.icon || "shapes", + "icon", + ]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`; + return span; + })(), + ); + + button.addEventListener("click", () => { + if (component[2]) { + // render presets + return this.screen(page, { + back: ["add", {}], + add_title: "Select preset", + components: [ + ["Default", component[1]], + ...component[2], + ], + icon: "type", + }); + } + + // no presets + if ( + page === "element" && + this.current.component === "empty" + ) { + // replace with component + copy_fields(component[1], this.current); + } else { + // add component to children + this.current.children.push( + structuredClone(component[1]), + ); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + } + + sidebar.appendChild(container); + } else if (page === "element") { + // edit element + const name = document.createElement("div"); + name.className = "flex flex-col gap-2"; + + name.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = `Edit ${this.current.component}`; + return heading; + })(), + ); + + name.appendChild( + (() => { + const pos = document.createElement("div"); + pos.className = "notification w-content"; + pos.innerText = this.pointer.get().join("."); + return pos; + })(), + ); + + sidebar.appendChild(name); + sidebar.appendChild(document.createElement("hr")); + + // options + const options = document.createElement("div"); + options.className = "card flex flex-col gap-2 w-full"; + + const add_option = ( + label_text, + name, + valid = [], + input_element = "input", + ) => { + const card = document.createElement("details"); + card.className = "w-full"; + + const summary = document.createElement("summary"); + summary.className = "w-full"; + + const label = document.createElement("label"); + label.setAttribute("for", name); + label.className = "w-full"; + label.innerText = label_text; + label.style.cursor = "pointer"; + + label.addEventListener("click", () => { + // bubble to summary click + summary.click(); + }); + + const input_box = document.createElement("div"); + input_box.style.paddingLeft = "1rem"; + input_box.style.borderLeft = + "solid 2px var(--color-super-lowered)"; + + const input = document.createElement(input_element); + input.id = name; + input.setAttribute("name", name); + input.setAttribute("type", "text"); + + if (input_element === "input") { + input.setAttribute( + "value", + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + (this.current.options || {})[name] || "", + ); + } else { + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + input.innerHTML = (this.current.options || {})[name] || ""; + } + + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + if ((this.current.options || {})[name]) { + // open details if a value is set + card.setAttribute("open", ""); + } + + input.addEventListener("change", (e) => { + if ( + valid.length > 0 && + !valid.includes(e.target.value) && + e.target.value.length > 0 // anything can be set to empty + ) { + alert(`Must be one of: ${JSON.stringify(valid)}`); + return; + } + + if (!this.current.options) { + this.current.options = {}; + } + + this.current.options[name] = + e.target.value === "no" ? "" : e.target.value; + }); + + summary.appendChild(label); + card.appendChild(summary); + input_box.appendChild(input); + card.appendChild(input_box); + options.appendChild(card); + }; + + sidebar.appendChild(options); + + if (this.current.component === "flex") { + add_option("Gap", "gap", ["1", "2", "3", "4"]); + add_option("Direction", "direction", ["row", "col"]); + add_option("Do collapse", "collapse", ["yes", "no"]); + add_option("Width", "width", ["full", "content"]); + add_option("Class name", "class"); + add_option("Unique ID", "id"); + add_option("Style", "style", [], "textarea"); + } else if (this.current.component === "markdown") { + add_option("Content", "text", [], "textarea"); + add_option("Class name", "class"); + } else if (this.current.component === "divider") { + add_option("Class name", "class"); + } else if (this.current.component === "style") { + add_option("Style data", "data", [], "textarea"); + } else { + options.remove(); + } + + // action buttons + const buttons = document.createElement("div"); + buttons.className = "card w-full flex flex-wrap gap-2"; + + if (this.current.component === "flex") { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["plus", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Add child"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen("add"); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-up", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move up"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx - 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx - 1]), + ); + + copy_fields(clone, parent_ref[idx - 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-down", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move down"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx + 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx + 1]), + ); + + copy_fields(clone, parent_ref[idx + 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("red"); + + trigger("app::icon", ["trash", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Delete"; + return span; + })(), + ); + + button.addEventListener("click", async () => { + if ( + !(await trigger("app::confirm", [ + "Are you sure you would like to do this?", + ])) + ) { + return; + } + + if (this.json === this.current) { + // this is the root element; replace with empty + copy_fields( + COMPONENT_TEMPLATES.EMPTY_COMPONENT, + this.current, + ); + } else { + // get parent + const idx = this.pointer.get().pop(); + const ref = this.pointer.resolve(this.json); + // remove element + ref.children.splice(idx, 1); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + } else if (page === "tree") { + sidebar.innerHTML = this.tree; + } + + sidebar.appendChild(document.createElement("hr")); + + const buttons = document.createElement("div"); + buttons.className = "flex gap-2 flex-wrap"; + + if (data.back) { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "secondary"; + + trigger("app::icon", ["arrow-left", "icon"]).then( + (icon) => { + button.prepend(icon); + }, + ); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Back"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen(...data.back); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "red secondary"; + + trigger("app::icon", ["x", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Close"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + + // ... + this.open(); + } +} + +define("ElementPointer", ElementPointer); +define("LayoutEditor", LayoutEditor); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 6ef6fcd..b5f746c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -138,6 +138,15 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Welcome new supporter!".to_string(), @@ -174,6 +183,18 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 934f5fc..085844a 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -88,41 +88,46 @@ pub async fn register_request( // check invite code if data.0.0.security.enable_invite_codes { - if props.invite_code.is_empty() { - return ( - None, - Json(Error::MiscError("Missing invite code".to_string()).into()), - ); + if !props.purchase { + if props.invite_code.is_empty() { + return ( + None, + Json(Error::MiscError("Missing invite code".to_string()).into()), + ); + } + + let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { + Ok(c) => c, + Err(e) => return (None, Json(e.into())), + }; + + if invite_code.is_used { + return ( + None, + Json(Error::MiscError("This code has already been used".to_string()).into()), + ); + } + + // let owner = match data.get_user_by_id(invite_code.owner).await { + // Ok(u) => u, + // Err(e) => return (None, Json(e.into())), + // }; + + // if !owner.permissions.check(FinePermission::SUPPORTER) { + // return ( + // None, + // Json( + // Error::MiscError("Invite code owner must be an active supporter".to_string()) + // .into(), + // ), + // ); + // } + + user.invite_code = invite_code.id; + } else { + // this account is being purchased + user.awaiting_purchase = true; } - - let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { - Ok(c) => c, - Err(e) => return (None, Json(e.into())), - }; - - if invite_code.is_used { - return ( - None, - Json(Error::MiscError("This code has already been used".to_string()).into()), - ); - } - - // let owner = match data.get_user_by_id(invite_code.owner).await { - // Ok(u) => u, - // Err(e) => return (None, Json(e.into())), - // }; - - // if !owner.permissions.check(FinePermission::SUPPORTER) { - // return ( - // None, - // Json( - // Error::MiscError("Invite code owner must be an active supporter".to_string()) - // .into(), - // ), - // ); - // } - - user.invite_code = invite_code.id; } // push initial token @@ -133,7 +138,7 @@ pub async fn register_request( match data.create_user(user).await { Ok(_) => { // mark invite as used - if data.0.0.security.enable_invite_codes { + if data.0.0.security.enable_invite_codes && !props.purchase { let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { Ok(c) => c, Err(e) => return (None, Json(e.into())), diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index f66e9bf..75247f1 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,8 +4,8 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, + UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -343,6 +343,34 @@ pub async fn update_user_is_verified_request( } } +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_awaiting_purchase_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_user_awaiting_purchased_status(id, req.awaiting_purchase, user, true) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Awaiting purchase status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. @@ -949,3 +977,55 @@ pub async fn self_serve_achievement_request( Err(e) => Json(e.into()), } } + +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_invite_code_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if req.invite_code.is_empty() { + return Json(Error::MiscError("Missing invite code".to_string()).into()); + } + + let invite_code = match data.get_invite_code_by_code(&req.invite_code).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if invite_code.is_used { + return Json(Error::MiscError("This code has already been used".to_string()).into()); + } + + if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return Json(e.into()); + } + + match data + .update_user_invite_code(user.id, invite_code.id as i64) + .await + { + Ok(_) => { + match data + .update_user_awaiting_purchased_status(user.id, false, user, false) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Invite code updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f207f1c..19a17ca 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -331,6 +331,10 @@ pub fn routes() -> Router { "/auth/user/{id}/verified", post(auth::profile::update_user_is_verified_request), ) + .route( + "/auth/user/{id}/awaiting_purchase", + post(auth::profile::update_user_awaiting_purchase_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -394,6 +398,10 @@ pub fn routes() -> Router { "/auth/user/me/achievement", post(auth::profile::self_serve_achievement_request), ) + .route( + "/auth/user/me/invite_code", + post(auth::profile::update_user_invite_code_request), + ) // apps .route("/apps", post(apps::create_request)) .route("/apps/{id}/title", post(apps::update_title_request)) @@ -643,6 +651,12 @@ pub struct RegisterProps { pub captcha_response: String, #[serde(default)] pub invite_code: String, + /// If this is true, invite_code should be empty. + /// + /// If invite codes are enabled, but purchase is false, the invite_code MUST + /// be checked and MUST be valid. + #[serde(default)] + pub purchase: bool, } #[derive(Deserialize)] @@ -750,6 +764,11 @@ pub struct UpdateUserIsVerified { pub is_verified: bool, } +#[derive(Deserialize)] +pub struct UpdateUserAwaitingPurchase { + pub awaiting_purchase: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, @@ -775,6 +794,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserInviteCode { + pub invite_code: String, +} + #[derive(Deserialize)] pub struct DeleteUser { pub password: String, diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 4a450c5..2e66c19 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,3 +19,4 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); +serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index d67dc0c..80f5cbe 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,6 +20,10 @@ pub fn routes(config: &Config) -> Router { .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) + .route( + "/js/layout_editor.js", + get(assets::layout_editor_js_request), + ) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7de4cfb..3a3e7d6 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -192,6 +192,8 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, + /// The text representation of the price of supporter. (like `$4 USD`) + pub supporter_price_text: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index f6fb848..b6a820f 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,6 +112,8 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), + awaiting_purchase: get!(x->24(i32)) as i8 == 1, + was_purchased: get!(x->25(i32)) as i8 == 1, } } @@ -267,7 +269,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, $22, $23, $24)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", params![ &(data.id as i64), &(data.created as i64), @@ -277,7 +279,7 @@ impl DataManager { &serde_json::to_string(&data.settings).unwrap(), &serde_json::to_string(&data.tokens).unwrap(), &(FinePermission::DEFAULT.bits() as i32), - &(if data.is_verified { 1_i32 } else { 0_i32 }), + &if data.is_verified { 1_i32 } else { 0_i32 }, &0_i32, &0_i32, &0_i32, @@ -293,6 +295,8 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), + &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, + &if data.was_purchased { 1_i32 } else { 0_i32 }, ] ); @@ -688,6 +692,52 @@ impl DataManager { Ok(()) } + pub async fn update_user_awaiting_purchased_status( + &self, + id: usize, + x: bool, + user: User, + require_permission: bool, + ) -> Result<()> { + if (user.id != id) | require_permission { + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET awaiting_purchase = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(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_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn seen_user(&self, user: &User) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, @@ -923,6 +973,7 @@ impl DataManager { 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)@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)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 9cb0851..3257a2d 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -21,5 +21,7 @@ CREATE TABLE IF NOT EXISTS users ( grants TEXT NOT NULL, associated TEXT NOT NULL, secondary_permissions INT NOT NULL, - achievements TEXT NOT NULL + achievements TEXT NOT NULL, + awaiting_purchase INT NOT NULL, + was_purchased INT NOT NULL ) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index 2c6d950..ba31155 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -20,8 +20,8 @@ impl DataManager { } } - 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); + 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( @@ -96,23 +96,22 @@ impl DataManager { 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 + const MINIMUM_ACCOUNT_AGE_FOR_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 { - 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(), - )); - } + // check account creation date + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } + // ... + if !user.permissions.check(FinePermission::SUPPORTER) { // 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) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index bac4ae6..91b67d9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -61,6 +61,15 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, + /// If the account was registered as a "bought" account, the user should not + /// be allowed to actually use the account if they haven't paid for supporter yet. + #[serde(default)] + pub awaiting_purchase: bool, + /// This value cannot be changed after account creation. This value is used to + /// lock the user's account again if the subscription is cancelled and they haven't + /// used an invite code. + #[serde(default)] + pub was_purchased: bool, } pub type UserConnections = @@ -319,6 +328,8 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), + awaiting_purchase: false, + was_purchased: false, } } 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; From 1dc06112980f2b3010d7d1fc6b0772c30c3b7cf4 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 3 Jul 2025 23:58:42 -0400 Subject: [PATCH 69/74] add: allow published notes to be shown through iframe --- crates/app/src/macros.rs | 8 ++++---- crates/app/src/main.rs | 2 +- crates/app/src/public/css/root.css | 2 +- crates/app/src/routes/pages/journals.rs | 8 +++++++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2787330..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(), )); } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 52b35be..1236d53 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -130,7 +130,7 @@ async fn main() { ) .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: *"), + 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'; frame-ancestors 'self'"), )) .layer(CatchPanicLayer::new()); diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 34281e6..e1c196b 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -267,7 +267,7 @@ span, code { max-width: 100%; overflow-wrap: normal; - text-wrap: pretty; + text-wrap: stable; word-wrap: break-word; } diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index 0c35e04..ff6a738 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -362,5 +362,11 @@ pub async fn global_view_request( context.insert("global_mode", &true); // return - Ok(Html(data.1.render("journals/app.html", &context).unwrap())) + Ok(( + [( + "content-security-policy", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors *", + )], + Html(data.1.render("journals/app.html", &context).unwrap()), + )) } From e5b6b5a4d4253e4e6f6693e3fe6d7bd4f64cd3bd Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 4 Jul 2025 17:41:58 -0400 Subject: [PATCH 70/74] fix: duplicated posts in all timeline --- crates/app/src/public/html/timelines/all.lisp | 2 +- .../src/public/html/timelines/swiss_army.lisp | 2 +- crates/app/src/public/js/atto.js | 35 ++++--------------- .../src/routes/api/v1/communities/posts.rs | 2 +- crates/app/src/routes/pages/misc.rs | 6 +++- crates/app/src/routes/pages/mod.rs | 2 ++ crates/core/src/database/auth.rs | 18 +++++----- crates/core/src/database/posts.rs | 8 ++++- crates/core/src/database/stacks.rs | 2 +- 9 files changed, 34 insertions(+), 43 deletions(-) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 7cced78..d739b2e 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -36,7 +36,7 @@ (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index 535dbe9..02edda2 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -9,7 +9,7 @@ (datalist ("ui_ident" "list_posts_{{ page }}") (text "{% for post in list -%}") - (option ("value" "{{ post[0].id }}")) + (option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}")) (text "{%- endfor %}")) (text "{% if list|length == 0 -%}") (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 6503548..d3b4bbb 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1212,6 +1212,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_HAS_LOADED_AT_LEAST_ONCE = false; self.IO_DATA_DISCONNECTED = false; self.IO_DATA_DISABLE_RELOAD = false; + self.IO_DATA_LOAD_BEFORE = 0; if (!paginated_mode) { self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); @@ -1256,7 +1257,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // ... const text = await ( - await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + await fetch( + `${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`, + ) ).text(); self.IO_DATA_WAITING = false; @@ -1291,34 +1294,6 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} 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( @@ -1330,6 +1305,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} if (!self.IO_DATA_SEEN_IDS[v]) { self.IO_DATA_SEEN_IDS.push(v); } + + self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created"); } }, 150); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index e2e608e..7ea70e5 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -838,7 +838,7 @@ pub async fn all_request( }; match data - .get_latest_posts(12, props.page, &Some(user.clone())) + .get_latest_posts(12, props.page, &Some(user.clone()), props.before) .await { Ok(posts) => { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 1abc14b..141ec25 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -631,6 +631,8 @@ pub struct TimelineQuery { pub tag: String, #[serde(default)] pub paginated: bool, + #[serde(default)] + pub before: usize, } /// `/_swiss_army_timeline` @@ -688,7 +690,9 @@ pub async fn swiss_army_timeline_request( // everything else match req.tl { DefaultTimelineChoice::AllPosts => { - data.0.get_latest_posts(12, req.page, &user).await + data.0 + .get_latest_posts(12, req.page, &user, req.before) + .await } DefaultTimelineChoice::PopularPosts => { data.0.get_popular_posts(12, req.page, 604_800_000).await diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 909fa2d..abc0b32 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -157,6 +157,8 @@ pub async fn render_error( pub struct PaginatedQuery { #[serde(default)] pub page: usize, + #[serde(default)] + pub before: usize, } #[derive(Deserialize)] diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index b6a820f..3cadb2e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -725,14 +725,16 @@ impl DataManager { self.cache_clear_user(&other_user).await; // create audit log entry - self.create_audit_log_entry(AuditLogEntry::new( - user.id, - format!( - "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", - other_user.id, x - ), - )) - .await?; + if user.id != other_user.id { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + } // ... Ok(()) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 2c5aed8..cc864cd 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1374,6 +1374,7 @@ impl DataManager { batch: usize, page: usize, as_user: &Option, + before_time: usize, ) -> Result> { let hide_answers: bool = if let Some(user) = as_user { user.settings.all_timeline_hide_answers @@ -1389,7 +1390,12 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{} AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + if before_time > 0 { + format!(" AND created < {before_time}") + } else { + String::new() + }, if hide_answers { " AND context::jsonb->>'answering' = '0'" } else { diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index c4fa5df..6a64b53 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -56,7 +56,7 @@ impl DataManager { match stack.sort { StackSort::Created => { self.fill_posts_with_community( - self.get_latest_posts(batch, page, &user).await?, + self.get_latest_posts(batch, page, &user, 0).await?, as_user_id, &ignore_users, user, From 9ba6320d467046a4147208477796cb70db2777a3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 5 Jul 2025 11:58:51 -0400 Subject: [PATCH 71/74] fix: register page captcha --- crates/app/src/main.rs | 2 +- crates/app/src/routes/pages/journals.rs | 2 +- crates/core/src/database/invite_codes.rs | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1236d53..75a9c02 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -130,7 +130,7 @@ async fn main() { ) .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'; frame-ancestors 'self'"), + 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'"), )) .layer(CatchPanicLayer::new()); diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index ff6a738..ca48e78 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index ba31155..084cfb3 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -103,11 +103,13 @@ impl DataManager { /// # Arguments /// * `data` - a mock [`InviteCode`] object to insert pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { - // check account creation date - if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { - return Err(Error::MiscError( - "Your account is too young to do this".to_string(), - )); + // check account creation date (if we aren't a supporter OR this is a purchased account) + if !user.permissions.check(FinePermission::SUPPORTER) | user.was_purchased { + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } } // ... From 07a23f505b11d5ed5b310288cd86f40bf5c275b2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 6 Jul 2025 13:34:20 -0400 Subject: [PATCH 72/74] add: dedicated responses tab for profiles --- crates/app/src/assets.rs | 2 + crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 8 +- crates/app/src/public/html/macros.lisp | 12 ++- .../src/public/html/profile/responses.lisp | 55 +++++++++++++ .../app/src/public/html/profile/settings.lisp | 25 +++++- crates/app/src/public/js/atto.js | 3 +- crates/app/src/public/js/streams.js | 8 +- crates/app/src/routes/api/v1/auth/profile.rs | 6 +- crates/app/src/routes/api/v1/auth/social.rs | 2 +- .../src/routes/api/v1/communities/drafts.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 10 +-- .../routes/api/v1/communities/questions.rs | 4 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/pages/misc.rs | 24 ++++-- crates/app/src/routes/pages/mod.rs | 4 + crates/app/src/routes/pages/profile.rs | 19 ++++- crates/core/src/database/auth.rs | 21 ++++- crates/core/src/database/memberships.rs | 8 +- crates/core/src/database/posts.rs | 80 ++++++++++++++++++- crates/core/src/database/reactions.rs | 10 +-- crates/core/src/database/userfollows.rs | 35 +++++--- crates/core/src/model/auth.rs | 44 +++++++++- 24 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 crates/app/src/public/html/profile/responses.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 89af907..1bc09ad 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -71,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"); @@ -370,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); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index ea87729..0842e62 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -76,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" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a15fe19..626efc0 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -800,7 +800,7 @@ }")) (text "{%- endif %}")) - (text "{% if not is_global and allow_anonymous -%}") + (text "{% if not is_global and allow_anonymous and not user -%}") (div ("class" "flex gap-2 items-center") (input @@ -1155,10 +1155,8 @@ (icon (text "code")) (str (text "general:link.source_code"))) - (a - ("href" "/reference/tetratto/index.html") - ("class" "button") - ("data-turbo" "false") + (button + ("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])") (icon (text "rabbit")) (str (text "general:link.reference"))) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index b2e8863..a554351 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -252,10 +252,17 @@ ("class" "pillmenu") (text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}") (a - ("href" "/@{{ profile.username }}") + ("href" "/@{{ profile.username }}?f=true") ("class" "{% if selected == 'posts' -%}active{%- endif %}") (str (text "auth:label.posts"))) + (text "{% if profile.settings.enable_questions -%}") + (a + ("href" "/@{{ profile.username }}?r=true") + ("class" "{% if selected == 'responses' -%}active{%- endif %}") + (str (text "auth:label.responses"))) + (text "{%- endif %}") + (a ("href" "/@{{ profile.username }}/replies") ("class" "{% if selected == 'replies' -%}active{%- endif %}") @@ -311,8 +318,9 @@ (span (text "{{ text \"settings:tab.theme\" }}"))) (a + ("href" "#") ("data-tab-button" "sessions") - ("href" "#/sessions") + ("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])") (text "{{ icon \"cookie\" }}") (span (text "{{ text \"settings:tab.sessions\" }}"))) diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp new file mode 100644 index 0000000..868f959 --- /dev/null +++ b/crates/app/src/public/html/profile/responses.lisp @@ -0,0 +1,55 @@ +(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") +(div + ("style" "display: contents") + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) + +(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 items-center") + (text "{{ icon \"pin\" }}") + (span + (text "{{ text \"communities:label.pinned\" }}"))) + (div + ("class" "card flex flex-col gap-4") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) + +(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 justify-between items-center") + (div + ("class" "flex gap-2 items-center") + (text "{% if not tag -%} {{ icon \"clock\" }}") + (span + (text "{{ text \"auth:label.recent_posts\" }}")) + (text "{% else %} {{ icon \"tag\" }}") + (span + (text "{{ text \"auth:label.recent_with_tag\" }}: ") + (b + (text "{{ tag }}"))) + (text "{%- endif %}")) + (text "{% if user -%}") + (a + ("href" "/search?profile={{ profile.id }}") + ("class" "button lowered small") + (text "{{ icon \"search\" }}") + (span + (text "{{ text \"general:link.search\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(async () => { + await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; + console.log(\"created profile timeline\"); + }, 1000);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8acd9c3..b7f0947 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -757,7 +757,29 @@ (text "{{ icon \"check\" }}"))) (span ("class" "fade") - (text "Use an image of 1100x350px for the best results."))))) + (text "Use an image of 1100x350px for the best results.")))) + (div + ("class" "card-nest") + ("ui_ident" "default_profile_page") + (div + ("class" "card small") + (b + (text "Default profile tab"))) + (div + ("class" "card") + (select + ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)") + (option + ("value" "Posts") + ("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}") + (text "Posts")) + (option + ("value" "Responses") + ("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}") + (text "Responses"))) + (span + ("class" "fade") + (text "This represents the timeline that is shown on your profile by default."))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1387,6 +1409,7 @@ \"supporter_ad\", \"change_avatar\", \"change_banner\", + \"default_profile_page\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index d3b4bbb..be40e7f 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1363,7 +1363,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} JSON.stringify(accepted_warnings), ); - setTimeout(() => { + setTimeout(async () => { + await trigger("me::achievement", ["AcceptProfileWarning"]); window.history.back(); }, 100); }); diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 8b9954d..7c5adf7 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -43,6 +43,12 @@ }; socket.addEventListener("message", async (event) => { + const sock = await $.sock(stream); + + if (!sock) { + return; + } + if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,7 +60,7 @@ return console.info(`${stream} ${data.data}`); } - return (await $.sock(stream)).events.message(data); + return sock.events.message(data); }); return $.STREAMS[stream]; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 75247f1..8104c71 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -154,7 +154,7 @@ pub async fn update_user_settings_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditSettings.into()) + .add_achievement(&mut user, AchievementName::EditSettings.into(), true) .await { return Json(e.into()); @@ -500,7 +500,7 @@ pub async fn enable_totp_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::Enable2fa.into()) + .add_achievement(&mut user, AchievementName::Enable2fa.into(), true) .await { return Json(e.into()); @@ -968,7 +968,7 @@ pub async fn self_serve_achievement_request( } // award achievement - match data.add_achievement(&mut user, req.name.into()).await { + match data.add_achievement(&mut user, req.name.into(), true).await { Ok(_) => Json(ApiReturn { ok: true, message: "Achievement granted".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 730746a..b80bd14 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -62,7 +62,7 @@ pub async fn follow_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::FollowUser.into()) + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 346a253..75f0948 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -27,7 +27,7 @@ pub async fn create_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDraft.into()) + .add_achievement(&mut user, AchievementName::CreateDraft.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 7ea70e5..d6554ff 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -181,7 +181,7 @@ pub async fn create_request( // achievements if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreatePost.into()) + .add_achievement(&mut user, AchievementName::CreatePost.into(), true) .await { return Json(e.into()); @@ -189,7 +189,7 @@ pub async fn create_request( if user.post_count >= 49 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create50Posts.into()) + .add_achievement(&mut user, AchievementName::Create50Posts.into(), true) .await { return Json(e.into()); @@ -198,7 +198,7 @@ pub async fn create_request( if user.post_count >= 99 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create100Posts.into()) + .add_achievement(&mut user, AchievementName::Create100Posts.into(), true) .await { return Json(e.into()); @@ -207,7 +207,7 @@ pub async fn create_request( if user.post_count >= 999 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create1000Posts.into()) + .add_achievement(&mut user, AchievementName::Create1000Posts.into(), true) .await { return Json(e.into()); @@ -348,7 +348,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditPost.into()) + .add_achievement(&mut user, AchievementName::EditPost.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 631e5c0..1d1a7ba 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -55,7 +55,7 @@ pub async fn create_request( let mut user = user.clone(); if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateQuestion.into()) + .add_achievement(&mut user, AchievementName::CreateQuestion.into(), true) .await { return Json(e.into()); @@ -63,7 +63,7 @@ pub async fn create_request( if drawings.len() > 0 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDrawing.into()) + .add_achievement(&mut user, AchievementName::CreateDrawing.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 95ae3da..0b1b394 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -110,7 +110,7 @@ pub async fn create_request( Ok(x) => { // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateJournal.into()) + .add_achievement(&mut user, AchievementName::CreateJournal.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index ae67c4d..6b274ff 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -198,7 +198,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditNote.into()) + .add_achievement(&mut user, AchievementName::EditNote.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 141ec25..e65f4b5 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -464,7 +464,7 @@ pub async fn achievements_request( // award achievement if let Err(e) = data .0 - .add_achievement(&mut user, AchievementName::OpenAchievements.into()) + .add_achievement(&mut user, AchievementName::OpenAchievements.into(), true) .await { return Err(Html(render_error(e, &jar, &data, &None).await)); @@ -633,6 +633,8 @@ pub struct TimelineQuery { pub paginated: bool, #[serde(default)] pub before: usize, + #[serde(default)] + pub responses_only: bool, } /// `/_swiss_army_timeline` @@ -680,11 +682,23 @@ pub async fn swiss_army_timeline_request( check_user_blocked_or_private!(user, other_user, data, jar); if req.tag.is_empty() { - data.0.get_posts_by_user(req.user_id, 12, req.page).await + if req.responses_only { + data.0 + .get_responses_by_user(req.user_id, 12, req.page) + .await + } else { + data.0.get_posts_by_user(req.user_id, 12, req.page).await + } } else { - data.0 - .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) - .await + if req.responses_only { + data.0 + .get_responses_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } else { + data.0 + .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } } } else { // everything else diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index abc0b32..6cf5431 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -179,6 +179,10 @@ pub struct ProfileQuery { pub warning: bool, #[serde(default)] pub tag: String, + #[serde(default, alias = "r")] + pub responses_only: bool, + #[serde(default, alias = "f")] + pub force: bool, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index ed7adcd..186d291 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -11,7 +11,12 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; -use tetratto_core::model::{auth::User, communities::Community, permissions::FinePermission, Error}; +use tetratto_core::model::{ + auth::{DefaultProfileTabChoice, User}, + communities::Community, + permissions::FinePermission, + Error, +}; use tetratto_shared::hash::hash; use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD}; @@ -252,6 +257,10 @@ pub async fn posts_request( check_user_blocked_or_private!(user, other_user, data, jar); + let responses_only = props.responses_only + | (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses + && !props.force); + // check for warning if props.warning { let lang = get_lang!(jar, data.0); @@ -356,7 +365,13 @@ pub async fn posts_request( ); // return - Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + if responses_only { + Ok(Html( + data.1.render("profile/responses.html", &context).unwrap(), + )) + } else { + Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + } } /// `/@{username}/replies` diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3cadb2e..fbf229b 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,6 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; +use crate::model::auth::{ + Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, +}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; use crate::model::permissions::SecondaryPermission; @@ -764,7 +766,13 @@ impl DataManager { /// 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<()> { + #[async_recursion::async_recursion] + pub async fn add_achievement( + &self, + user: &mut User, + achievement: Achievement, + check_for_final: bool, + ) -> Result<()> { if user.settings.disable_achievements { return Ok(()); } @@ -794,6 +802,15 @@ impl DataManager { self.update_user_achievements(user.id, user.achievements.to_owned()) .await?; + // check for final + if check_for_final { + if user.achievements.len() + 1 == ACHIEVEMENTS { + self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false) + .await?; + } + } + + // ... Ok(()) } diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 01f286b..610d0a0 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -242,8 +242,12 @@ 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?; + self.add_achievement( + &mut user.clone(), + AchievementName::JoinCommunity.into(), + true, + ) + .await?; "Community joined".to_string() }) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index cc864cd..becb780 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -758,6 +758,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + 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 posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Calculate the GPA (great post average) of a given user. /// /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes)) @@ -1066,6 +1097,45 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user + /// with the given tag (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `tag` - the tag to filter by + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user_tag( + &self, + id: usize, + tag: &str, + batch: usize, + page: usize, + ) -> Result> { + 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 posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $3 OFFSET $4", + params![ + &(id as i64), + &format!("%\"{tag}\"%"), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given community (from most recent). /// /// # Arguments @@ -1661,8 +1731,12 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreatePostWithTitle.into()) - .await?; + self.add_achievement( + &mut owner, + AchievementName::CreatePostWithTitle.into(), + true, + ) + .await?; } } @@ -1803,7 +1877,7 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreateRepost.into()) + self.add_achievement(&mut owner, AchievementName::CreateRepost.into(), true) .await?; } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 0a61261..c26c3dc 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -162,26 +162,26 @@ impl DataManager { // achievements if user.id != post.owner { let mut owner = self.get_user_by_id(post.owner).await?; - self.add_achievement(&mut owner, AchievementName::Get1Like.into()) + self.add_achievement(&mut owner, AchievementName::Get1Like.into(), true) .await?; if post.likes >= 9 { - self.add_achievement(&mut owner, AchievementName::Get10Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get10Likes.into(), true) .await?; } if post.likes >= 49 { - self.add_achievement(&mut owner, AchievementName::Get50Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get50Likes.into(), true) .await?; } if post.likes >= 99 { - self.add_achievement(&mut owner, AchievementName::Get100Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get100Likes.into(), true) .await?; } if post.dislikes >= 24 { - self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into()) + self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into(), true) .await?; } } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index ffcd891..5428f67 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -262,33 +262,50 @@ impl DataManager { // check if we're staff if initiator.permissions.check(FinePermission::STAFF_BADGE) { - self.add_achievement(&mut other_user, AchievementName::FollowedByStaff.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::FollowedByStaff.into(), + true, + ) + .await?; } // other achivements - self.add_achievement(&mut other_user, AchievementName::Get1Follower.into()) + self.add_achievement(&mut other_user, AchievementName::Get1Follower.into(), true) .await?; if other_user.follower_count >= 9 { - self.add_achievement(&mut other_user, AchievementName::Get10Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get10Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 49 { - self.add_achievement(&mut other_user, AchievementName::Get50Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get50Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 99 { - self.add_achievement(&mut other_user, AchievementName::Get100Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get100Followers.into(), + true, + ) + .await?; } if initiator.following_count >= 9 { self.add_achievement( &mut initiator.clone(), AchievementName::Follow10Users.into(), + true, ) .await?; } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 91b67d9..2b47562 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -124,6 +124,20 @@ impl DefaultTimelineChoice { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum DefaultProfileTabChoice { + /// General posts (in any community) from the user. + Posts, + /// Responses to questions. + Responses, +} + +impl Default for DefaultProfileTabChoice { + fn default() -> Self { + Self::Posts + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct UserSettings { #[serde(default)] @@ -285,6 +299,9 @@ pub struct UserSettings { /// Automatically hide users that you've blocked on your other accounts from your timelines. #[serde(default)] pub hide_associated_blocked_users: bool, + /// Which tab is shown by default on the user's profile. + #[serde(default)] + pub default_profile_tab: DefaultProfileTabChoice, } fn mime_avif() -> String { @@ -504,10 +521,15 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 30; +pub const ACHIEVEMENTS: usize = 34; /// "self-serve" achievements can be granted by the user through the API. -pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = - &[AchievementName::OpenTos, AchievementName::OpenPrivacyPolicy]; +pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ + AchievementName::OpenReference, + AchievementName::OpenTos, + AchievementName::OpenPrivacyPolicy, + AchievementName::AcceptProfileWarning, + AchievementName::OpenSessionSettings, +]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -541,6 +563,10 @@ pub enum AchievementName { CreateRepost, OpenTos, OpenPrivacyPolicy, + OpenReference, + GetAllOtherAchievements, + AcceptProfileWarning, + OpenSessionSettings, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -583,6 +609,10 @@ impl AchievementName { Self::CreateRepost => "More than a like or comment...", Self::OpenTos => "Well informed!", Self::OpenPrivacyPolicy => "Privacy conscious", + Self::OpenReference => "What does this do?", + Self::GetAllOtherAchievements => "The final performance", + Self::AcceptProfileWarning => "I accept the risks!", + Self::OpenSessionSettings => "Am I alone in here?", } } @@ -618,6 +648,10 @@ impl AchievementName { Self::CreateRepost => "Create a repost or quote.", Self::OpenTos => "Open the terms of service.", Self::OpenPrivacyPolicy => "Open the privacy policy.", + Self::OpenReference => "Open the source code reference documentation.", + Self::GetAllOtherAchievements => "Get every other achievement.", + Self::AcceptProfileWarning => "Accept a profile warning.", + Self::OpenSessionSettings => "Open your session settings.", } } @@ -655,6 +689,10 @@ impl AchievementName { Self::CreateRepost => Common, Self::OpenTos => Uncommon, Self::OpenPrivacyPolicy => Uncommon, + Self::OpenReference => Uncommon, + Self::GetAllOtherAchievements => Rare, + Self::AcceptProfileWarning => Common, + Self::OpenSessionSettings => Common, } } } From c4de17058b6b5a81b1fff7a620048abd44ed63d8 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 7 Jul 2025 14:45:30 -0400 Subject: [PATCH 73/74] add: littleweb base --- README.md | 2 + crates/app/src/main.rs | 22 ++- crates/app/src/public/js/atto.js | 15 +- .../routes/api/v1/auth/connections/stripe.rs | 52 ++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/mod.rs | 11 ++ crates/app/src/routes/pages/mod.rs | 4 + crates/core/src/config.rs | 9 + crates/core/src/database/common.rs | 3 + crates/core/src/database/domains.rs | 153 +++++++++++++++++ crates/core/src/database/drivers/common.rs | 3 + .../database/drivers/sql/create_domains.sql | 8 + .../database/drivers/sql/create_layouts.sql | 9 + .../database/drivers/sql/create_services.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/littleweb.rs | 154 ++++++++++++++++++ crates/core/src/model/mod.rs | 1 + crates/core/src/model/permissions.rs | 2 + example/tetratto.toml | 1 + justfile | 4 + 20 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 crates/core/src/database/domains.rs create mode 100644 crates/core/src/database/drivers/sql/create_domains.sql create mode 100644 crates/core/src/database/drivers/sql/create_layouts.sql create mode 100644 crates/core/src/database/drivers/sql/create_services.sql create mode 100644 crates/core/src/model/littleweb.rs diff --git a/README.md b/README.md index e1ac999..d1a3d80 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ A `docs` directory will be generated in the same directory that you ran the `tet You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries. +You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects. + ## Usage (as a user) Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out! diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 75a9c02..baad195 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -113,9 +113,22 @@ async fn main() { 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") @@ -128,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' *.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'"), - )) .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/js/atto.js b/crates/app/src/public/js/atto.js index be40e7f..43a46b8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1277,11 +1277,22 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} } if ( - text.includes(`!`) + text.includes( + `!`, + ) || + document.documentElement.innerHTML.includes("observer_disconnect") ) { console.log("io_data_end; disconnect"); self.IO_DATA_OBSERVER.disconnect(); - self.IO_DATA_ELEMENT.innerHTML += text; + + if ( + !document.documentElement.innerHTML.includes( + "observer_disconnect", + ) + ) { + self.IO_DATA_ELEMENT.innerHTML += text; + } + self.IO_DATA_DISCONNECTED = true; return; } diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index b5f746c..e62a0e8 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -207,6 +207,58 @@ pub async fn stripe_webhook( return Json(e.into()); } } + EventType::InvoicePaymentFailed => { + // payment failed + let subscription = match req.data.object { + EventObject::Subscription(c) => c, + _ => unreachable!("cannot be this"), + }; + + let customer_id = subscription.customer.id(); + + let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + + if let Err(e) = data + .update_user_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "It seems your recent payment has failed :(".to_string(), + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } _ => return Json(Error::Unknown.into()), } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 19a17ca..42bcd97 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -635,6 +635,10 @@ pub fn routes() -> Router { .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + #[derive(Deserialize)] pub struct LoginProps { pub username: String, diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 80f5cbe..81746d9 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -46,3 +46,14 @@ pub fn routes(config: &Config) -> Router { // pages .merge(pages::routes()) } + +/// These routes are only used when you provide the `LITTLEWEB` environment variable. +/// +/// These routes are NOT for editing. These routes are only for viewing littleweb sites. +pub fn lw_routes() -> Router { + Router::new() + // api + .nest("/api/v1", api::v1::lw_routes()) + // pages + .merge(pages::lw_routes()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6cf5431..07bd5a7 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -141,6 +141,10 @@ pub fn routes() -> Router { .route("/x/{note}", get(journals::global_view_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + pub async fn render_error( e: Error, jar: &CookieJar, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 3a3e7d6..85ff839 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -252,6 +252,10 @@ pub struct Config { /// so this host should be included in there as well. #[serde(default = "default_host")] pub host: String, + /// The main public host of the littleweb server. **Not** used to check against banned hosts, + /// so this host should be included in there as well. + #[serde(default = "default_lw_host")] + pub lw_host: String, /// Database security. #[serde(default = "default_security")] pub security: SecurityConfig, @@ -319,6 +323,10 @@ fn default_host() -> String { String::new() } +fn default_lw_host() -> String { + String::new() +} + fn default_security() -> SecurityConfig { SecurityConfig::default() } @@ -385,6 +393,7 @@ impl Default for Config { port: default_port(), banned_hosts: default_banned_hosts(), host: default_host(), + lw_host: default_lw_host(), database: default_database(), security: default_security(), dirs: default_dirs(), diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index aabb0d3..969b014 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,6 +40,9 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); + execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); + execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); self.0 .1 diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs new file mode 100644 index 0000000..0249f6f --- /dev/null +++ b/crates/core/src/database/domains.rs @@ -0,0 +1,153 @@ +use crate::model::{ + auth::User, + littleweb::{Domain, DomainData, DomainTld}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Domain`] from an SQL row. + pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain { + Domain { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + tld: (get!(x->4(String)).as_str()).into(), + data: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}"); + + /// Get a domain given its name and TLD. + /// + /// # Arguments + /// * `name` + /// * `tld` + pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM domains WHERE name = $1 AND tld = $2", + &[&name, &tld.to_string()], + |x| { Ok(Self::get_domain_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all domains by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch domains for + pub async fn get_domains_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM domains WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_domain_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new domain in the database. + /// + /// # Arguments + /// * `data` - a mock [`Domain`] object to insert + pub async fn create_domain(&self, data: Domain) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check for existing + if self + .get_domain_by_name_tld(&data.name, &data.tld) + .await + .is_ok() + { + return Err(Error::MiscError( + "Domain + TLD already in use. Maybe try another TLD!".to_string(), + )); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO domains VALUES ($1, $2, $3, $4, $5, $6)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.tld.to_string(), + &serde_json::to_string(&data.data).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> { + let domain = self.get_domain_by_id(id).await?; + + // check user permission + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM domains WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.domain:{}", id)).await; + Ok(()) + } + + auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}"); +} diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e1cfad7..efa3eae 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,3 +27,6 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); +pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql"); +pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); +pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); diff --git a/crates/core/src/database/drivers/sql/create_domains.sql b/crates/core/src/database/drivers/sql/create_domains.sql new file mode 100644 index 0000000..fc0f190 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_domains.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS domains ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + tld TEXT NOT NULL, + data TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql new file mode 100644 index 0000000..3f28c0a --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_layouts.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS layouts ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + privacy TEXT NOT NULL, + pages TEXT NOT NULL, + replaces TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql new file mode 100644 index 0000000..78277b5 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS services ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + files TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 6877100..5e3cd5b 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -5,6 +5,7 @@ mod channels; mod common; mod communities; pub mod connections; +mod domains; mod drafts; mod drivers; mod emojis; diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs new file mode 100644 index 0000000..474bec4 --- /dev/null +++ b/crates/core/src/model/littleweb.rs @@ -0,0 +1,154 @@ +use std::fmt::Display; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub files: Vec, +} + +/// 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 { + /// 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) -> (&str, &str, DomainTld, Vec) { + // 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> = value.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 no_protocol = value.replace("atto://", ""); + 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, + domain, + tld, + path.split("/").map(|x| x.to_owned()).collect(), + ) + } + + /// 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/over_http?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 3ff8379..e825340 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -7,6 +7,7 @@ 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; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 1584083..55cf9cc 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -174,6 +174,8 @@ bitflags! { pub struct SecondaryPermission: u32 { const DEFAULT = 1 << 0; const ADMINISTRATOR = 1 << 1; + const MANAGE_DOMAINS = 1 << 2; + const MANAGE_SERVICES = 1 << 3; const _ = !0; } diff --git a/example/tetratto.toml b/example/tetratto.toml index 0f36100..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", 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 From 3fc0872867b668ece6705c5cf05d892904ee754d Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 7 Jul 2025 16:32:18 -0400 Subject: [PATCH 74/74] add: littleweb api + scopes --- crates/app/src/langs/en-US.toml | 3 + .../src/public/html/littleweb/services.lisp | 92 ++++++++++ crates/app/src/routes/api/v1/domains.rs | 164 ++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 38 +++- crates/app/src/routes/api/v1/services.rs | 104 +++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/database/services.rs | 130 ++++++++++++++ crates/core/src/model/littleweb.rs | 65 +++++-- crates/core/src/model/oauth.rs | 12 ++ 9 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 crates/app/src/public/html/littleweb/services.lisp create mode 100644 crates/app/src/routes/api/v1/domains.rs create mode 100644 crates/app/src/routes/api/v1/services.rs create mode 100644 crates/core/src/database/services.rs diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0842e62..cfae86e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -267,3 +267,6 @@ version = "1.0.0" "journals:action.publish" = "Publish" "journals:action.unpublish" = "Unpublish" "journals:action.view" = "View" + +"littleweb:label.create_new" = "Create new site" +"littleweb:label.my_services" = "My services" diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp new file mode 100644 index 0000000..e4525ca --- /dev/null +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -0,0 +1,92 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My stacks - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_service_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.my_services"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/services/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.files|length }} files"))) + (text "{% endfor %}")))) + +(script + (text "async function create_service_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"services::create\"]); + + fetch(\"/api/v1/services\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/services/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs new file mode 100644 index 0000000..aec1a01 --- /dev/null +++ b/crates/app/src/routes/api/v1/domains.rs @@ -0,0 +1,164 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{CreateDomain, UpdateDomainData}, + State, +}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + http::StatusCode, + Extension, Json, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error}; +use serde::Deserialize; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_domain_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_domains_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_domain(Domain::new(req.name, req.tld, user.id)) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Domain created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_data_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_domain_data(id, &user, req.data).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_domain(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +#[derive(Deserialize)] +pub struct GetFileQuery { + pub addr: String, +} + +pub async fn get_file_request( + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let (subdomain, domain, tld, path) = Domain::from_str(&props.addr); + + // resolve domain + let domain = match data.get_domain_by_name_tld(&domain, &tld).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }; + + // resolve service + let service = match domain.service(&subdomain) { + Some(id) => match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }, + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("service".to_string()).to_string(), + )); + } + }; + + // resolve file + match service.file(&path) { + Some(f) => Ok(( + [("Content-Type".to_string(), f.mime.to_string())], + f.content, + )), + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("file".to_string()).to_string(), + )); + } + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 42bcd97..59f4353 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod domains; pub mod journals; pub mod layouts; pub mod notes; @@ -9,6 +10,7 @@ pub mod notifications; pub mod reactions; pub mod reports; pub mod requests; +pub mod services; pub mod stacks; pub mod uploads; pub mod util; @@ -28,6 +30,7 @@ use tetratto_core::model::{ communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, + littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -633,10 +636,22 @@ pub fn routes() -> Router { post(layouts::update_privacy_request), ) .route("/layouts/{id}/pages", post(layouts::update_pages_request)) + // services + .route("/services", get(services::list_request)) + .route("/services", post(services::create_request)) + .route("/services/{id}", get(services::get_request)) + .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/files", post(services::update_files_request)) + // domains + .route("/domains", get(domains::list_request)) + .route("/domains", post(domains::create_request)) + .route("/domains/{id}", get(domains::get_request)) + .route("/domains/{id}", delete(domains::delete_request)) + .route("/domains/{id}/data", post(domains::update_data_request)) } pub fn lw_routes() -> Router { - Router::new() + Router::new().route("/file", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -1055,3 +1070,24 @@ pub struct UpdateLayoutPrivacy { pub struct UpdateLayoutPages { pub pages: Vec, } + +#[derive(Deserialize)] +pub struct CreateService { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFiles { + pub files: Vec, +} + +#[derive(Deserialize)] +pub struct CreateDomain { + pub name: String, + pub tld: DomainTld, +} + +#[derive(Deserialize)] +pub struct UpdateDomainData { + pub data: Vec<(String, DomainData)>, +} diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs new file mode 100644 index 0000000..36895d6 --- /dev/null +++ b/crates/app/src/routes/api/v1/services.rs @@ -0,0 +1,104 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateServiceFiles, CreateService}, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_service_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_services_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_service(Service::new(req.name, user.id)).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Service created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_files_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_files(id, &user, req.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_service(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5e3cd5b..1009797 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -26,6 +26,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod services; mod stackblocks; mod stacks; mod uploads; diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs new file mode 100644 index 0000000..de67f74 --- /dev/null +++ b/crates/core/src/database/services.rs @@ -0,0 +1,130 @@ +use crate::model::{ + auth::User, + littleweb::{Service, ServiceFsEntry}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Service`] from an SQL row. + pub(crate) fn get_service_from_row(x: &PostgresRow) -> Service { + Service { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + files: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_service_by_id(usize as i64)@get_service_from_row -> "SELECT * FROM services WHERE id = $1" --name="service" --returns=Service --cache-key-tmpl="atto.service:{}"); + + /// Get all services by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch services for + pub async fn get_services_by_user(&self, id: usize) -> Result> { + 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 services WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_service_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("service".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_SERVICES: usize = 5; + + /// Create a new service in the database. + /// + /// # Arguments + /// * `data` - a mock [`Service`] object to insert + pub async fn create_service(&self, data: Service) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check number of services + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let services = self.get_services_by_user(data.owner).await?; + + if services.len() >= Self::MAXIMUM_FREE_SERVICES { + return Err(Error::MiscError( + "You already have the maximum number of services 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 services VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &serde_json::to_string(&data.files).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_service(&self, id: usize, user: &User) -> Result<()> { + let service = self.get_service_by_id(id).await?; + + // check user permission + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + 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 services WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.service:{}", id)).await; + Ok(()) + } + + auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); +} diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 474bec4..79cffb1 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -1,5 +1,6 @@ use std::fmt::Display; use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Service { @@ -10,6 +11,42 @@ pub struct Service { 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 { @@ -84,14 +121,28 @@ pub struct Domain { } 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) -> (&str, &str, DomainTld, Vec) { + 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> = value.split(".").collect(); + let mut s: Vec<&str> = no_protocol.split(".").collect(); s.reverse(); let mut s = s.into_iter(); @@ -100,7 +151,6 @@ impl Domain { let subdomain = s.next().unwrap_or("@"); // get path - let no_protocol = value.replace("atto://", ""); let mut chars = no_protocol.chars(); let mut char = '.'; @@ -113,12 +163,7 @@ impl Domain { let path: String = chars.collect(); // return - ( - subdomain, - domain, - tld, - path.split("/").map(|x| x.to_owned()).collect(), - ) + (subdomain.to_owned(), domain.to_owned(), tld, path) } /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. @@ -131,7 +176,7 @@ impl Domain { // 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/over_http?addr=atto://") + input.replace("\"atto://", "/api/v1/file?addr=atto://") } /// Get the domain's service ID. diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 7d5ebb6..07a23c3 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -70,6 +70,10 @@ pub enum AppScope { 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. @@ -90,6 +94,10 @@ pub enum AppScope { 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. @@ -126,6 +134,10 @@ pub enum AppScope { 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.