From 4ebd7e6c2b7aaed92360e4117f09b6c7892ecef1 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 14:36:14 -0400 Subject: [PATCH 01/69] fix: "ask anonymously" checkbox --- crates/app/src/public/html/components.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 156875e..d8c192f 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 and not user -%}") + (text "{% if not is_global and allow_anonymous and user -%}") (div ("class" "flex gap-2 items-center") (input From 78c9b3349d3ab43298ab790002bee5f495b91caa Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 15:21:57 -0400 Subject: [PATCH 02/69] add: better domain editor ui --- .../src/public/html/littleweb/browser.lisp | 3 +- .../app/src/public/html/littleweb/domain.lisp | 73 +++++------ .../src/public/html/littleweb/service.lisp | 115 +++++++++++------- crates/core/src/model/littleweb.rs | 3 +- 4 files changed, 112 insertions(+), 82 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 76ac82b..6da038f 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -91,6 +91,7 @@ position: fixed; width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; left: var(--pad-2); + z-index: 2; } } @@ -101,7 +102,7 @@ height: var(--h); min-height: var(--h); max-height: var(--h); - font-size: 14px; + font-size: 16px; } #panel button:not(.inner *), diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp index d8b01f1..96d2da7 100644 --- a/crates/app/src/public/html/littleweb/domain.lisp +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -72,7 +72,7 @@ ("class" "card hidden w-full lowered flex flex-col gap-2") ("onsubmit" "add_data_from_form(event)") (div - ("class" "flex gap-2") + ("class" "flex gap-2 flex-collapse") (div ("class" "flex w-full flex-col gap-1") (label @@ -119,44 +119,47 @@ (icon (text "check")) (str (text "general:action.save"))))) ; data - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Value")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto") + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Value")) + (th (text "Actions")))) - (tbody - (text "{% for item in domain.data -%}") - (tr - (td (text "{{ item[0] }}")) - (text "{% for k,v in item[1] -%}") - (td (text "{{ k }}")) - (td (text "{{ v }}")) - (text "{%- endfor %}") - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (tbody + (text "{% for item in domain.data -%}") + (tr + (td (text "{{ item[0] }}")) + (text "{% for k,v in item[1] -%}") + (td (text "{{ k }}")) + (td (text "{{ v }}")) + (text "{%- endfor %}") + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_data('{{ item[0] }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_data('{{ item[0] }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_data('{{ item[0] }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{%- endfor %}")))))) + (button + ("class" "red") + ("onclick" "remove_data('{{ item[0] }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{%- endfor %}"))))))) (script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) (script diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp index b0b7ac9..8ef12e1 100644 --- a/crates/app/src/public/html/littleweb/service.lisp +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -14,9 +14,24 @@ (div ("class" "card-nest") (div - ("class" "card small") - (b - (text "{{ service.name }}"))) + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex w-full gap-2 justify-between") + (b + (text "{{ service.name }}")) + + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('site_help').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "site_help") + (p (text "Your site should include an \"index.html\" file in order to show content on its homepage.")) + (p (text "In the HTML editor, you can type `!` and use the provided suggestion to get an HTML boilerplate.")) + (p (text "After you've created a page, you can click \"Copy ID\" and go to manage a domain you own. On the domain management page, click \"Add\" and paste the ID you copied into the value field.")))) (div ("class" "flex gap-2 flex-wrap card") @@ -72,53 +87,56 @@ ("class" "card flex flex-col gap-2") (text "{% if not file or file.children|length > 0 -%}") ; directory browser - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Children")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto") + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Children")) + (th (text "Actions")))) - (tbody - (text "{% for item in files %}") - (tr - (td - ("class" "flex gap-2 items-center") - (text "{% if item.children|length > 0 -%}") - (icon (text "folder")) - (text "{% else %}") - (icon (text "file")) - (text "{%- endif %}") + (tbody + (text "{% for item in files %}") + (tr + (td + ("class" "flex gap-2 items-center") + (text "{% if item.children|length > 0 -%}") + (icon (text "folder")) + (text "{% else %}") + (icon (text "file")) + (text "{%- endif %}") - (a - ("href" "?path={{ path }}/{{ item.name }}") - ("data-turbo" "false") - (text "{{ item.name }}"))) - (td (text "{{ item.mime }}")) - (td (text "{{ item.children|length }}")) - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (a + ("href" "?path={{ path }}/{{ item.name }}") + ("data-turbo" "false") + (text "{{ item.name }}"))) + (td (text "{{ item.mime }}")) + (td (text "{{ item.children|length }}")) + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_file('{{ item.id }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_file('{{ item.id }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_file('{{ item.id }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{% endfor %}"))) + (button + ("class" "red") + ("onclick" "remove_file('{{ item.id }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{% endfor %}")))) (text "{% else %}") ; file editor (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) @@ -319,6 +337,7 @@ (text "{% if file and file.mime != 'Plain' -%}") (script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js")) +(script ("src" "https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js")) (script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}")) (script (text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } }); @@ -337,10 +356,16 @@ style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";'; shadow.appendChild(style); + emmetMonaco.emmetHTML(); + emmetMonaco.emmetCSS(); + globalThis.editor = monaco.editor.create(inner, { value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"\"), language: MIME_MODES[\"{{ file.mime }}\"], theme: \"vs-dark\", + suggest: { + snippetsPreventQuickSuggestions: false, + }, }); });")) (text "{%- endif %}") diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 479a444..77b8dc6 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -193,7 +193,8 @@ macro_rules! define_domain_tlds { define_domain_tlds!( Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, - Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site + Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All, + Me, Bug, Slop, Retro, Eye, Neo, Spring ); #[derive(Debug, Clone, Serialize, Deserialize)] From e7febc7c7e9ce606cece0cab5ca8b16e3463cdc9 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 15:33:51 -0400 Subject: [PATCH 03/69] add: allow direct "atto://" links to work for script tags --- crates/app/src/public/html/littleweb/domain.lisp | 3 ++- crates/app/src/public/html/littleweb/service.lisp | 3 ++- crates/app/src/routes/api/v1/domains.rs | 3 ++- crates/core/src/database/domains.rs | 2 +- crates/core/src/database/services.rs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp index 96d2da7..d4bc359 100644 --- a/crates/app/src/public/html/littleweb/domain.lisp +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -121,8 +121,9 @@ ; data (div ("class" "w-full") - ("style" "max-width: 100%; overflow: auto") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") (table + ("class" "w-full") (thead (tr (th (text "Name")) diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp index 8ef12e1..7cd9597 100644 --- a/crates/app/src/public/html/littleweb/service.lisp +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -89,8 +89,9 @@ ; directory browser (div ("class" "w-full") - ("style" "max-width: 100%; overflow: auto") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") (table + ("class" "w-full") (thead (tr (th (text "Name")) diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 8cfd9dc..f9949db 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -154,7 +154,8 @@ pub async fn get_file_request( ) } else { f.content - }, + } + .replace("atto://", "/api/v1/net/atto://"), )), None => { return Err(( diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 672de1c..737bd5f 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -74,7 +74,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_DOMAINS: usize = 5; + const MAXIMUM_FREE_DOMAINS: usize = 10; /// Create a new domain in the database. /// diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index adadf7e..f28460d 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -45,7 +45,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_SERVICES: usize = 5; + const MAXIMUM_FREE_SERVICES: usize = 10; /// Create a new service in the database. /// From 388ccbf58cae140b176eeeb5d286fad246852833 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:38:24 -0400 Subject: [PATCH 04/69] add: small littleweb browser changes --- .../src/public/html/littleweb/browser.lisp | 12 ++- .../src/public/html/littleweb/domains.lisp | 11 +++ .../src/public/html/littleweb/services.lisp | 11 +++ crates/app/src/public/html/mod/profile.lisp | 89 +++++++++++++++++++ crates/app/src/public/js/atto.js | 16 ++-- crates/app/src/public/js/proto_links.js | 2 +- crates/app/src/routes/api/v1/domains.rs | 7 +- crates/app/src/routes/pages/littleweb.rs | 65 ++++++++++++-- crates/core/src/model/littleweb.rs | 2 +- 9 files changed, 194 insertions(+), 21 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 6da038f..e3e201d 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -32,7 +32,7 @@ (input ("type" "uri") ("class" "w-full") - ("true_value" "{{ path }}") + ("true_value" "") ("name" "uri") ("id" "uri")) @@ -121,10 +121,6 @@ uri = `${uri}/index.html`; } - if (!uri.startsWith(\"atto://\")) { - uri = `atto://${uri}`; - } - // ... console.log(\"navigate\", uri); document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; @@ -152,7 +148,8 @@ if (data.event === \"change_url\") { const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); - document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + document.getElementById(\"uri\").value = uri.split(\"/\")[0]; } }); @@ -207,6 +204,7 @@ is_focused = false; }); - document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]")) + // navigate + littleweb_navigate(\"{{ path|safe }}\");")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index 1a9b649..e3a6c10 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's domains warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's domains! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index cca5af7..83a6179 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's services warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's sites! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 9fb5ebf..529228a 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -50,6 +50,18 @@ (span ("class" "notification") (text "{{ profile.request_count }}"))) + (a + ("href" "/services?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Sites"))) + (a + ("href" "/domains?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Domains"))) (button ("class" "red lowered") ("onclick" "delete_account(event)") @@ -155,6 +167,33 @@ }); }; + globalThis.update_user_secondary_role = async (new_role) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/user/{{ profile.id }}/role/2`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + role: Number.parseInt(new_role), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + ui.refresh_container(element, [\"actions\"]); setTimeout(() => { @@ -178,6 +217,11 @@ \"{{ profile.permissions }}\", \"input\", ], + [ + [\"secondary_role\", \"Secondary permission level\"], + \"{{ profile.secondary_permissions }}\", + \"input\", + ], ], null, { @@ -194,6 +238,9 @@ role: (new_role) => { return update_user_role(new_role); }, + secondary_role: (new_role) => { + return update_user_secondary_role(new_role); + }, }, ); }, 100); @@ -244,6 +291,24 @@ (div ("class" "card lowered flex flex-col gap-2") ("id" "permission_builder"))) + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"blocks\" }}") + (span + (text "{{ text \"mod_panel:label.permissions_level_builder\" }}"))) + (button + ("class" "small lowered") + ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('role').value))") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}")))) + (div + ("class" "card lowered flex flex-col gap-2") + ("id" "secondary_permission_builder"))) (script (text "setTimeout(async () => { const get_permissions_html = await trigger( @@ -291,6 +356,30 @@ Number.parseInt(\"{{ profile.permissions }}\"), \"permission_builder\", ); + }, 250); + + setTimeout(async () => { + const get_permissions_html = await trigger( + \"ui::generate_permissions_ui\", + [ + { + // https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.SecondaryPermission.html + DEFAULT: 1 << 0, + ADMINISTRATOR: 1 << 1, + MANAGE_DOMAINS: 1 << 2, + MANAGE_SECONDARY: 1 << 3, + }, + \"secondary_role\", + \"add_permission_to_secondary_role\", + \"remove_permission_to_secondary_role\", + ], + ); + + document.getElementById(\"secondary_permission_builder\").innerHTML = + get_permissions_html( + Number.parseInt(\"{{ profile.secondary_permissions }}\"), + \"secondary_permission_builder\", + ); }, 250);"))) (text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1b2a4db..f67cd2c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1069,7 +1069,13 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // permissions ui self.define( "generate_permissions_ui", - (_, permissions, field_id = "role") => { + ( + _, + permissions, + field_id = "role", + add_name = "add_permission_to_role", + remove_name = "remove_permission_from_role", + ) => { function all_matching_permissions(role) { const matching = []; const not_matching = []; @@ -1099,7 +1105,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} function get_permissions_html(role, id) { const [matching, not_matching] = all_matching_permissions(role); - globalThis.remove_permission_from_role = (permission) => { + globalThis[remove_name] = (permission) => { matching.splice(matching.indexOf(permission), 1); not_matching.push(permission); @@ -1107,7 +1113,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} get_permissions_html(rebuild_role(matching), id); }; - globalThis.add_permission_to_role = (permission) => { + globalThis[add_name] = (permission) => { not_matching.splice(not_matching.indexOf(permission), 1); matching.push(permission); @@ -1120,14 +1126,14 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} for (const match of matching) { permissions_html += `
${match} ${permissions[match]} - +
`; } for (const match of not_matching) { permissions_html += `
${match} ${permissions[match]} - +
`; } diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index ab5d938..a2f3ffc 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -54,7 +54,7 @@ function fix_atto_links() { `atto://${path.replace("atto://", "").split("/")[0]}${x}`; } else { y[property] = - `/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`; + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}`; } } } diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index f9949db..51056a3 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -110,9 +110,14 @@ pub async fn delete_request( } pub async fn get_file_request( - Path(addr): Path, + Path(mut addr): Path, Extension(data): Extension, ) -> impl IntoResponse { + if !addr.starts_with("atto://") { + addr = format!("atto://{addr}"); + } + + // ... let data = &(data.read().await).0; let (subdomain, domain, tld, path) = Domain::from_str(&addr); diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 9dc5907..d37a45a 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -1,18 +1,22 @@ use super::render_error; -use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, + routes::pages::misc::NotificationsProps, State, +}; use axum::{ response::{Html, IntoResponse}, extract::{Query, Path}, Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::TLDS_VEC, Error}; +use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; /// `/services` pub async fn services_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -24,7 +28,26 @@ pub async fn services_request( } }; - let list = match data.0.get_services_by_user(user.id).await { + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let list = match data.0.get_services_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -32,6 +55,7 @@ pub async fn services_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("list", &list); + context.insert("profile", &profile); // return Ok(Html( @@ -43,6 +67,7 @@ pub async fn services_request( pub async fn domains_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -54,6 +79,25 @@ pub async fn domains_request( } }; + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + let list = match data.0.get_domains_by_user(user.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), @@ -64,6 +108,7 @@ pub async fn domains_request( context.insert("list", &list); context.insert("tlds", &*TLDS_VEC); + context.insert("profile", &profile); // return Ok(Html( @@ -99,7 +144,11 @@ pub async fn service_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != service.owner { + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -153,7 +202,11 @@ pub async fn domain_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != domain.owner { + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -204,7 +257,7 @@ pub async fn browser_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("path", &uri); + context.insert("path", &uri.replace("atto://", "")); // return Html(data.1.render("littleweb/browser.html", &context).unwrap()) diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 77b8dc6..4c85024 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -194,7 +194,7 @@ macro_rules! define_domain_tlds { define_domain_tlds!( Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All, - Me, Bug, Slop, Retro, Eye, Neo, Spring + Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony ); #[derive(Debug, Clone, Serialize, Deserialize)] From 65e5d5f4e958fdc72c6fc33076507490d0170629 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:44:49 -0400 Subject: [PATCH 05/69] fix: user domains view for staff --- crates/app/src/public/html/mod/profile.lisp | 2 +- crates/app/src/routes/pages/littleweb.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 529228a..5121fb9 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -367,7 +367,7 @@ DEFAULT: 1 << 0, ADMINISTRATOR: 1 << 1, MANAGE_DOMAINS: 1 << 2, - MANAGE_SECONDARY: 1 << 3, + MANAGE_SERVICES: 1 << 3, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index d37a45a..61560d7 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -98,7 +98,7 @@ pub async fn domains_request( )); } - let list = match data.0.get_domains_by_user(user.id).await { + let list = match data.0.get_domains_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; From e72ccf913963cdf1d47fa81cd185611720295621 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:52:39 -0400 Subject: [PATCH 06/69] fix: mod panel secondary role builder --- crates/app/src/public/html/mod/profile.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 5121fb9..2121f1e 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -302,7 +302,7 @@ (text "{{ text \"mod_panel:label.permissions_level_builder\" }}"))) (button ("class" "small lowered") - ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('role').value))") + ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))) From 22a2545aa0efedd839493c72aa3d811b1e9dc0a1 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 18:25:47 -0400 Subject: [PATCH 07/69] fix: littleweb browser page url bar --- crates/app/src/public/html/littleweb/browser.lisp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index e3e201d..8e298e2 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -148,7 +148,13 @@ if (data.event === \"change_url\") { const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); - document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + document.getElementById(\"uri\").value = uri.split(\"/\")[0]; } }); @@ -205,6 +211,8 @@ }); // navigate - littleweb_navigate(\"{{ path|safe }}\");")) + if ({{ path|length }} > 0) { + littleweb_navigate(\"{{ path|safe }}\"); + }")) (text "{% endblock %}") From 7ead0ce7757c5af9aed1ea17a832d10b9893a7a6 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 18:29:59 -0400 Subject: [PATCH 08/69] fix: don't change link hrefs in littleweb browser --- crates/app/src/public/js/proto_links.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index a2f3ffc..87986b3 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -60,8 +60,7 @@ function fix_atto_links() { } fix_element("a", "href", true); - fix_element("link", "href", false); - fix_element("script", "src", false); + fix_element("img", "src", false); // send message window.top.postMessage( From 69067145cedca4ab1988c04ca8d4560fc01d654b Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 21:44:49 -0400 Subject: [PATCH 09/69] fix: home timeline setting --- crates/app/src/public/html/profile/settings.lisp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b7f0947..5fe4010 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -134,10 +134,12 @@ ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") (text "All (questions)")) (text "{% for stack in stacks %}") - (option - ("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}") - ("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}") - (text "{{ stack.name }} (stack)")) + (text "") (text "{% endfor %}")) (span ("class" "fade") From 7960f1ed419386190fb6c4e7ea0592152f7e4ecc Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 22:29:54 -0400 Subject: [PATCH 10/69] fix: nsfw posts in all/communities timelines --- .../src/routes/api/v1/communities/posts.rs | 2 +- crates/app/src/routes/pages/misc.rs | 4 +-- crates/core/src/database/posts.rs | 28 +++++++++++++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index d6554ff..b4b3896 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -723,7 +723,7 @@ pub async fn from_communities_request( }; match data - .get_posts_from_user_communities(user.id, 12, props.page) + .get_posts_from_user_communities(user.id, 12, props.page, &user) .await { Ok(posts) => { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index e65f4b5..5d017ef 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -58,7 +58,7 @@ pub async fn index_request( let list = match data .0 - .get_posts_from_user_communities(user.id, 12, req.page) + .get_posts_from_user_communities(user.id, 12, req.page, &user) .await { Ok(l) => match data @@ -725,7 +725,7 @@ pub async fn swiss_army_timeline_request( DefaultTimelineChoice::MyCommunities => { if let Some(ref ua) = user { data.0 - .get_posts_from_user_communities(ua.id, 12, req.page) + .get_posts_from_user_communities(ua.id, 12, req.page, ua) .await } else { return Err(Html( diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index becb780..dda4ae6 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1452,6 +1452,14 @@ impl DataManager { false }; + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = as_user { + hide_nsfw = !ua.settings.show_nsfw; + } + + // ... let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -1460,12 +1468,17 @@ 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{}{}{} ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { String::new() }, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, if hide_answers { " AND context::jsonb->>'answering' = '0'" } else { @@ -1494,6 +1507,7 @@ impl DataManager { id: usize, batch: usize, page: usize, + user: &User, ) -> Result> { let memberships = self.get_memberships_by_owner(id).await?; let mut memberships = memberships.iter(); @@ -1508,6 +1522,9 @@ impl DataManager { query_string.push_str(&format!(" OR community = {}", membership.community)); } + // check if we should hide nsfw posts + let hide_nsfw: bool = !user.settings.show_nsfw; + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -1517,8 +1534,13 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE (community = {} {query_string}) AND NOT context LIKE '%\"is_nsfw\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", - first.community + "SELECT * FROM posts WHERE (community = {} {query_string}){} AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + first.community, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, ), &[&(batch as i64), &((page * batch) as i64)], |x| { Self::get_post_from_row(x) } From 4e152b07be722e157d1e1d4366b1899727c06d82 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 22:59:28 -0400 Subject: [PATCH 11/69] add: littleweb (common) achievements --- crates/app/src/routes/api/v1/domains.rs | 12 +++++++++++- crates/app/src/routes/api/v1/services.rs | 13 +++++++++++-- crates/core/src/model/auth.rs | 8 ++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 51056a3..40ff713 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -6,6 +6,7 @@ use crate::{ use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ + auth::AchievementName, littleweb::{Domain, ServiceFsMime}, oauth, ApiReturn, Error, }; @@ -48,11 +49,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateDomain.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data .create_domain(Domain::new(req.name, req.tld, user.id)) .await diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index 252fe5a..d1ffbf0 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -7,7 +7,7 @@ use crate::{ }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; pub async fn get_request( Path(id): Path, @@ -47,11 +47,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateSite.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.create_service(Service::new(req.name, user.id)).await { Ok(x) => Json(ApiReturn { ok: true, diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 2b47562..4303640 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -567,6 +567,8 @@ pub enum AchievementName { GetAllOtherAchievements, AcceptProfileWarning, OpenSessionSettings, + CreateSite, + CreateDomain, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -613,6 +615,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "The final performance", Self::AcceptProfileWarning => "I accept the risks!", Self::OpenSessionSettings => "Am I alone in here?", + Self::CreateSite => "Littlewebmaster", + Self::CreateDomain => "LittleDNS", } } @@ -652,6 +656,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "Get every other achievement.", Self::AcceptProfileWarning => "Accept a profile warning.", Self::OpenSessionSettings => "Open your session settings.", + Self::CreateSite => "Create a site.", + Self::CreateDomain => "Create a domain.", } } @@ -693,6 +699,8 @@ impl AchievementName { Self::GetAllOtherAchievements => Rare, Self::AcceptProfileWarning => Common, Self::OpenSessionSettings => Common, + Self::CreateSite => Common, + Self::CreateDomain => Common, } } } From bdd8f9a869191b80840767c034dab9b592350645 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 10 Jul 2025 13:32:43 -0400 Subject: [PATCH 12/69] add: hide_from_social_lists user setting --- .../app/src/public/html/profile/settings.lisp | 11 +++- crates/app/src/routes/api/v1/auth/social.rs | 10 +++- crates/app/src/routes/pages/profile.rs | 6 ++- crates/core/src/database/userfollows.rs | 50 +++++++++++++------ crates/core/src/model/auth.rs | 8 ++- 5 files changed, 64 insertions(+), 21 deletions(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 5fe4010..6d08053 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -143,8 +143,7 @@ (text "{% endfor %}")) (span ("class" "fade") - (text "This represents the timeline the home button takes you - to.")))) + (text "This represents the timeline the home button takes you to.")))) (div ("class" "card-nest desktop") ("ui_ident" "notifications") @@ -1540,6 +1539,14 @@ \"{{ profile.settings.hide_associated_blocked_users }}\", \"checkbox\", ], + [ + [ + \"hide_from_social_lists\", + \"Hide my profile from social lists (followers/following)\", + ], + \"{{ profile.settings.hide_from_social_lists }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index b80bd14..88a78b5 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -278,7 +278,10 @@ pub async fn followers_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_initiator(f).await { + payload: match data + .fill_userfollows_with_initiator(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, @@ -310,7 +313,10 @@ pub async fn following_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_receiver(f).await { + payload: match data + .fill_userfollows_with_receiver(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 186d291..4d12556 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -70,6 +70,8 @@ pub async fn settings_request( .get_userfollows_by_initiator_all(profile.id) .await .unwrap_or(Vec::new()), + &None, + false, ) .await { @@ -718,7 +720,7 @@ pub async fn following_request( .get_userfollows_by_initiator(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_receiver(l).await { + Ok(l) => match data.0.fill_userfollows_with_receiver(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, @@ -813,7 +815,7 @@ pub async fn followers_request( .get_userfollows_by_receiver(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_initiator(l).await { + Ok(l) => match data.0.fill_userfollows_with_initiator(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 5428f67..63fcfbe 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -195,18 +195,29 @@ impl DataManager { pub async fn fill_userfollows_with_receiver( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let receiver = userfollow.receiver; - out.push(( - userfollow, - match self.get_user_by_id(receiver).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(receiver).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) @@ -216,18 +227,29 @@ impl DataManager { pub async fn fill_userfollows_with_initiator( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let initiator = userfollow.initiator; - out.push(( - userfollow, - match self.get_user_by_id(initiator).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(initiator).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 4303640..3b14d41 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -302,6 +302,12 @@ pub struct UserSettings { /// Which tab is shown by default on the user's profile. #[serde(default)] pub default_profile_tab: DefaultProfileTabChoice, + /// If the user is hidden from followers/following tabs. + /// + /// The user will still impact the followers/following numbers, but will not + /// be shown in the UI (or API). + #[serde(default)] + pub hide_from_social_lists: bool, } fn mime_avif() -> String { @@ -521,7 +527,7 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 34; +pub const ACHIEVEMENTS: usize = 36; /// "self-serve" achievements can be granted by the user through the API. pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ AchievementName::OpenReference, From 14f3bf849eabb07f1abbf0e6d6aa831b0915f9ae Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 10 Jul 2025 18:43:54 -0400 Subject: [PATCH 13/69] add: post full unlist option --- crates/app/src/public/html/components.lisp | 12 ++++++++++++ crates/app/src/public/html/post/post.lisp | 7 ++++++- crates/app/src/public/html/profile/settings.lisp | 10 ++++++++++ crates/core/src/database/posts.rs | 12 ++++++++++-- crates/core/src/model/auth.rs | 4 ++++ crates/core/src/model/communities.rs | 3 +++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d8c192f..77b28c8 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -173,6 +173,12 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) + (text "{%- endif %} {% if post.context.full_unlist -%}") + (span + ("title" "Unlisted") + ("class" "flex items-center") + ("style" "color: var(--color-primary)") + (icon (text "eye-off"))) (text "{%- endif %} {% if post.stack -%}") (a ("title" "Posted to a stack you're in") @@ -1507,6 +1513,7 @@ is_nsfw: false, content_warning: \"\", tags: [], + full_unlist: false, }; window.BLANK_INITIAL_SETTINGS = JSON.stringify( @@ -1543,6 +1550,11 @@ // window.POST_INITIAL_SETTINGS.is_nsfw.toString(), // \"checkbox\", // ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + window.POST_INITIAL_SETTINGS.full_unlist.toString(), + \"checkbox\", + ], [ [\"content_warning\", \"Content warning\"], window.POST_INITIAL_SETTINGS.content_warning, diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 11a5156..062db3d 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -201,7 +201,7 @@ \"checkbox\", ], [ - [\"is_nsfw\", \"Hide from public timelines\"], + [\"is_nsfw\", \"Mark as NSFW\"], \"{{ community.context.is_nsfw }}\", \"checkbox\", ], @@ -210,6 +210,11 @@ settings.content_warning, \"textarea\", ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + \"{{ user.settings.auto_full_unlist }}\", + \"checkbox\", + ], [ [\"tags\", \"Tags\"], settings.tags.join(\", \"), diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 6d08053..4397155 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1526,6 +1526,16 @@ \"{{ profile.settings.auto_unlist }}\", \"checkbox\", ], + [ + [\"auto_full_unlist\", \"Only publish my posts to my profile\"], + \"{{ profile.settings.auto_unlist }}\", + \"checkbox\", + ], + [ + [], + \"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\", + \"text\", + ], [ [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], \"{{ profile.settings.all_timeline_hide_answers }}\", diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index dda4ae6..f17bbea 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1468,7 +1468,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0{}{}{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { @@ -1534,7 +1534,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE (community = {} {query_string}){} AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE (community = {} {query_string}){} AND NOT context LIKE '%\"full_unlist\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", first.community, if hide_nsfw { " AND NOT context LIKE '%\"is_nsfw\":true%'" @@ -1979,6 +1979,10 @@ impl DataManager { data.context.is_nsfw = true; } + if owner.settings.auto_full_unlist { + data.context.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -2379,6 +2383,10 @@ impl DataManager { x.is_nsfw = true; } + if user.settings.auto_full_unlist { + x.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 3b14d41..c3d7de9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -308,6 +308,10 @@ pub struct UserSettings { /// be shown in the UI (or API). #[serde(default)] pub hide_from_social_lists: bool, + /// Automatically hide your posts from all timelines except your profile + /// and the following timeline. + #[serde(default)] + pub auto_full_unlist: bool, } fn mime_avif() -> String { diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 4df9795..8a4ab9a 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -190,6 +190,8 @@ pub struct PostContext { pub content_warning: String, #[serde(default)] pub tags: Vec, + #[serde(default)] + pub full_unlist: bool, } fn default_comments_enabled() -> bool { @@ -218,6 +220,7 @@ impl Default for PostContext { reactions_enabled: default_reactions_enabled(), content_warning: String::new(), tags: Vec::new(), + full_unlist: false, } } } From 9aee80493f6e7eb3fac8b73ddfb89b7e284e9f31 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 12:35:47 -0400 Subject: [PATCH 14/69] fix: anonymous post page panic --- crates/app/src/public/html/post/post.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 062db3d..b43fc82 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -125,7 +125,6 @@ (text "{{ icon \"settings\" }}") (span (text "{{ text \"communities:action.configure\" }}")))) - (text "{%- endif %}") (div ("class" "flex flex-col gap-2 hidden") ("data-tab" "configure") @@ -250,6 +249,7 @@ }, }); }, 250);"))) + (text "{%- endif %}") (text "{% if user and user.id == post.owner -%}") (div ("class" "card-nest w-full hidden") From cfcc2358f40f0a426bf4bc9c9f155f9db2f5390b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 18:56:49 -0400 Subject: [PATCH 15/69] add: service edit date + browser session ids --- crates/app/src/macros.rs | 14 +++++ crates/app/src/main.rs | 2 +- .../src/public/html/littleweb/browser.lisp | 7 ++- .../src/public/html/littleweb/services.lisp | 5 +- crates/app/src/public/js/proto_links.js | 4 +- crates/app/src/routes/api/v1/domains.rs | 59 +++++++++++++++---- crates/app/src/routes/api/v1/services.rs | 17 ++++-- crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 33 ++++++++++- crates/core/src/database/auth.rs | 6 +- .../database/drivers/sql/create_services.sql | 3 +- .../src/database/drivers/sql/create_users.sql | 3 +- crates/core/src/database/services.rs | 5 +- crates/core/src/model/auth.rs | 11 ++++ crates/core/src/model/littleweb.rs | 2 + sql_changes/browser_session.sql | 2 + sql_changes/services_revision.sql | 2 + 17 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 sql_changes/browser_session.sql create mode 100644 sql_changes/services_revision.sql diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2c3c03c..2f5433d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -140,6 +140,20 @@ macro_rules! get_user_from_token { None } }}; + + (--browser_session=$browser_session:expr, $db:expr) => {{ + // browser session id + match $db.get_user_by_browser_session(&$browser_session).await { + Ok(ua) => { + if ua.permissions.check_banned() { + None + } else { + Some(ua) + } + } + Err(_) => None, + } + }}; } #[macro_export] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 00ad85f..b4ffbe6 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -123,7 +123,7 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; 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' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors 'self'"), )); } diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 8e298e2..67c64ab 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -55,7 +55,7 @@ (iframe ("id" "browser_iframe") ("frameborder" "0") - ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}")) (style ("data-turbo-temporary" "true") @@ -116,14 +116,15 @@ }")) (script - (text "function littleweb_navigate(uri) { + (text "globalThis.SECRET_SESSION = \"{{ session }}\"; + function littleweb_navigate(uri) { if (!uri.includes(\".html\")) { uri = `${uri}/index.html`; } // ... console.log(\"navigate\", uri); - document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; + document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`; } document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 83a6179..3399685 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -73,7 +73,10 @@ (span ("class" "date") (text "{{ item.created }}")) - (text "; {{ item.files|length }} files"))) + (text "; Updated ") + (span + ("class" "date") + (text "{{ item.revision }}")))) (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index 87986b3..9cf4940 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -54,7 +54,7 @@ function fix_atto_links() { `atto://${path.replace("atto://", "").split("/")[0]}${x}`; } else { y[property] = - `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}`; + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; } } } @@ -112,7 +112,7 @@ function fix_atto_links() { if (TETRATTO_LINK_HANDLER_CTX === "net") { window.location.href = `/net/${href.replace("atto://", "")}`; } else { - window.location.href = `/api/v1/net/${href}`; + window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`; } }); diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 40ff713..f1af2e6 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,13 +3,21 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, State, }; -use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; +use axum::{ + extract::{Path, Query}, + http::StatusCode, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ auth::AchievementName, littleweb::{Domain, ServiceFsMime}, - oauth, ApiReturn, Error, + oauth, + permissions::FinePermission, + ApiReturn, Error, }; +use serde::Deserialize; pub async fn get_request( Path(id): Path, @@ -119,9 +127,16 @@ pub async fn delete_request( } } +#[derive(Deserialize)] +pub struct GetFileQuery { + #[serde(default, alias = "s")] + pub session: String, +} + pub async fn get_file_request( Path(mut addr): Path, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { if !addr.starts_with("atto://") { addr = format!("atto://{addr}"); @@ -129,8 +144,21 @@ pub async fn get_file_request( // ... let data = &(data.read().await).0; + let user = get_user_from_token!(--browser_session = props.session, data); let (subdomain, domain, tld, path) = Domain::from_str(&addr); + if path.starts_with("$") && user.is_none() { + return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string())); + } else if let Some(ref ua) = user + && path.starts_with("$paid") + && !ua.permissions.check(FinePermission::SUPPORTER) + { + return Err(( + StatusCode::BAD_REQUEST, + Error::RequiresSupporter.to_string(), + )); + } + // resolve domain let domain = match data.get_domain_by_name_tld(&domain, &tld).await { Ok(x) => x, @@ -160,17 +188,28 @@ pub async fn get_file_request( Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], if f.mime == ServiceFsMime::Html { - f.content.replace( - "", - &format!( - "", - data.0.0.host - ), - ) + f.content + .replace( + "", + &format!( + "", + data.0.0.host, props.session + ), + ) + .replace( + ".js\"", + &format!(".js?r={}&s={}\"", service.revision, props.session), + ) + .replace( + ".css\"", + &format!(".css?r={}&s={}\"", service.revision, props.session), + ) } else { f.content } - .replace("atto://", "/api/v1/net/atto://"), + .replace("atto://", "/api/v1/net/"), )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index d1ffbf0..a847338 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -8,6 +8,7 @@ use crate::{ use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_shared::unix_epoch_timestamp; pub async fn get_request( Path(id): Path, @@ -156,11 +157,17 @@ pub async fn update_content_request( // ... match data.update_service_files(id, &user, service.files).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Service updated".to_string(), - payload: (), - }), + Ok(_) => match data + .update_service_revision(id, unix_epoch_timestamp() as i64) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index cdfba32..db76e93 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' *; worker-src * blob:; 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' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 61560d7..9e347e1 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -11,6 +11,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; +use tetratto_shared::hash::salt; /// `/services` pub async fn services_request( @@ -230,12 +231,26 @@ pub async fn browser_home_request( let data = data.read().await; let user = get_user_from_token!(jar, data.0); + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + context.insert("session", &session); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } /// `/net/{uri}` @@ -255,10 +270,24 @@ pub async fn browser_request( uri = format!("atto://{uri}"); } + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("session", &session); context.insert("path", &uri.replace("atto://", "")); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index fbf229b..88ef32e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -116,12 +116,14 @@ impl DataManager { 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, + browser_session: get!(x->26(String)), } } auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username_no_cache(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User); + auto_method!(get_user_by_browser_session(&str)@get_user_from_row -> "SELECT * FROM users WHERE browser_session = $1" --name="user" --returns=User); /// Get a user given just their ID. Returns the void user if the user doesn't exist. /// @@ -271,7 +273,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, $25, $26)", + "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, $27)", params![ &(data.id as i64), &(data.created as i64), @@ -299,6 +301,7 @@ impl DataManager { &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 }, + &data.browser_session, ] ); @@ -993,6 +996,7 @@ impl DataManager { 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!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $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_services.sql b/crates/core/src/database/drivers/sql/create_services.sql index 78277b5..ecb04d6 100644 --- a/crates/core/src/database/drivers/sql/create_services.sql +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS services ( created BIGINT NOT NULL, owner BIGINT NOT NULL, name TEXT NOT NULL, - files TEXT NOT NULL + files TEXT NOT NULL, + revision BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 3257a2d..0e24753 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -23,5 +23,6 @@ CREATE TABLE IF NOT EXISTS users ( secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, - was_purchased INT NOT NULL + was_purchased INT NOT NULL, + browser_session TEXT NOT NULL ) diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index f28460d..adc9bc6 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -16,6 +16,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, name: get!(x->3(String)), files: serde_json::from_str(&get!(x->4(String))).unwrap(), + revision: get!(x->5(i64)) as usize, } } @@ -80,13 +81,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO services VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO services VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &data.name, &serde_json::to_string(&data.files).unwrap(), + &(data.created as i64) ] ); @@ -128,4 +130,5 @@ impl DataManager { auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_revision(i64) -> "UPDATE services SET revision = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c3d7de9..efea59a 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -70,6 +70,16 @@ pub struct User { /// used an invite code. #[serde(default)] pub was_purchased: bool, + /// This value is updated for every **new** littleweb browser session. + /// + /// This means the user can only have one of these sessions open at once + /// (unless this token is stored somewhere with a way to say we already have one, + /// but this does not happen yet). + /// + /// Without this token, the user can still use the browser, they just cannot + /// view pages which require authentication (all `$` routes). + #[serde(default)] + pub browser_session: String, } pub type UserConnections = @@ -357,6 +367,7 @@ impl User { achievements: Vec::new(), awaiting_purchase: false, was_purchased: false, + browser_session: String::new(), } } diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 4c85024..f06154d 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -11,6 +11,7 @@ pub struct Service { pub owner: usize, pub name: String, pub files: Vec, + pub revision: usize, } impl Service { @@ -22,6 +23,7 @@ impl Service { owner, name, files: Vec::new(), + revision: unix_epoch_timestamp(), } } diff --git a/sql_changes/browser_session.sql b/sql_changes/browser_session.sql new file mode 100644 index 0000000..07570cd --- /dev/null +++ b/sql_changes/browser_session.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN browser_session TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/services_revision.sql b/sql_changes/services_revision.sql new file mode 100644 index 0000000..93150f0 --- /dev/null +++ b/sql_changes/services_revision.sql @@ -0,0 +1,2 @@ +ALTER TABLE services +ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; From 4d49fc3cdf3c65971c1142925706b9d30ac92593 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 19:39:46 -0400 Subject: [PATCH 16/69] fix: littleweb browser url --- crates/app/src/public/html/littleweb/browser.lisp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 67c64ab..9379357 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -125,6 +125,14 @@ // ... console.log(\"navigate\", uri); document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`; + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { @@ -156,7 +164,7 @@ document.getElementById(\"uri\").setAttribute(\"true_value\", uri); } - document.getElementById(\"uri\").value = uri.split(\"/\")[0]; + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } }); From 6af56ed2b29b73ff90dfdacace7fcd1e5577f324 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 00:07:37 -0400 Subject: [PATCH 17/69] fix: atto links (relative) --- crates/app/src/public/js/proto_links.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index 9cf4940..9c8d9fd 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -31,7 +31,9 @@ function fix_atto_links() { if (TETRATTO_LINK_HANDLER_CTX === "embed") { // relative links for embeds - const path = window.location.pathname.slice("/api/v1/net/".length); + const path = window.location.pathname + .replace("atto://", "") + .slice("/api/v1/net/".length); function fix_element( selector = "a", @@ -43,15 +45,19 @@ function fix_atto_links() { continue; } - let x = new URL(y[property]).pathname; + const p = new URL(y[property]).pathname.replace("atto://", ""); + let x = p.startsWith("/api/v1/net/") + ? p.replace("/api/v1/net/", "") + : p.startsWith("/") + ? `${path.split("/")[0]}${p}` + : p; if (!x.includes(".html")) { x = `${x}/index.html`; } if (relative) { - y[property] = - `atto://${path.replace("atto://", "").split("/")[0]}${x}`; + y[property] = `atto://${x}`; } else { y[property] = `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; @@ -107,7 +113,6 @@ function fix_atto_links() { } const href = structuredClone(anchor.href); - anchor.addEventListener("click", () => { if (TETRATTO_LINK_HANDLER_CTX === "net") { window.location.href = `/net/${href.replace("atto://", "")}`; From 227cd3d2ac2355ad40f1f12b03ebd51d24782c4d Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 14:44:50 -0400 Subject: [PATCH 18/69] fix: user follows panic --- crates/app/src/public/html/journals/app.lisp | 1 - crates/core/src/database/userfollows.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 255b2ec..c6ed985 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -379,7 +379,6 @@ ("name" "tags") ("id" "tags") ("placeholder" "tags") - ("required" "") ("minlength" "2") ("maxlength" "128") (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 63fcfbe..4b22835 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -400,9 +400,13 @@ impl DataManager { // decr counts (if we aren't deleting the user OR the user id isn't the deleted user id) if !is_deleting_user | (follow.initiator != user.id) { - self.decr_user_following_count(follow.initiator) + if self + .decr_user_following_count(follow.initiator) .await - .unwrap(); + .is_err() + { + println!("ERR_TETRATTO_DECR_FOLLOWS: could not decr initiator follow count") + } } if !is_deleting_user | (follow.receiver != user.id) { From fdaa81422abd502491772ca0f64a742fdb4442b1 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 16:30:57 -0400 Subject: [PATCH 19/69] add: better stripe endpoint --- crates/app/src/public/html/components.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 4 + .../app/src/public/html/profile/settings.lisp | 9 + .../routes/api/v1/auth/connections/stripe.rs | 118 +++-- crates/app/src/routes/api/v1/layouts.rs | 175 -------- crates/app/src/routes/api/v1/mod.rs | 34 -- crates/core/src/config.rs | 18 +- crates/core/src/database/common.rs | 1 - crates/core/src/database/drivers/common.rs | 1 - .../database/drivers/sql/create_layouts.sql | 9 - crates/core/src/database/layouts.rs | 117 ----- crates/core/src/database/mod.rs | 1 - crates/core/src/model/auth.rs | 3 + crates/core/src/model/layouts.rs | 403 ------------------ crates/core/src/model/mod.rs | 1 - 15 files changed, 118 insertions(+), 778 deletions(-) delete mode 100644 crates/app/src/routes/api/v1/layouts.rs delete mode 100644 crates/core/src/database/drivers/sql/create_layouts.sql delete mode 100644 crates/core/src/database/layouts.rs delete mode 100644 crates/core/src/model/layouts.rs diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 77b28c8..bd03879 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2362,7 +2362,7 @@ (sup (a ("href" "#footnote-1") (text "1")))) (text "{%- endif %}")) (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index c5acd7d..83d533f 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -20,7 +20,11 @@ (div ("class" "card flex flex-col gap-2") (span + ("class" "fade") (text "{{ text \"auth:label.private_profile_message\" }}")) + (span + ("class" "no_p_margin") + (text "{{ profile.settings.private_biography|markdown|safe }}")) (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_following -%}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4397155..c5566c7 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1433,6 +1433,15 @@ settings.biography, \"textarea\", ], + [ + [\"private_biography\", \"Private biography\"], + settings.private_biography, + \"textarea\", + { + embed_html: + 'This biography is only shown to users you are not following while your account is private.', + }, + ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], 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 e62a0e8..3a4619e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -17,9 +17,10 @@ pub async fn stripe_webhook( ) -> impl IntoResponse { let data = &(data.read().await).0; - if data.0.0.stripe.is_none() { - return Json(Error::MiscError("Disabled".to_string()).into()); - } + let stripe_cnf = match data.0.0.stripe { + Some(ref c) => c, + None => return Json(Error::MiscError("Disabled".to_string()).into()), + }; let sig = match headers.get("Stripe-Signature") { Some(s) => s, @@ -56,7 +57,7 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; - tracing::info!("subscribe {} (stripe: {})", user.id, customer_id); + tracing::info!("payment {} (stripe: {})", user.id, customer_id); if let Err(e) = data .update_user_stripe_id(user.id, customer_id.as_str()) .await @@ -74,6 +75,48 @@ pub async fn stripe_webhook( }; let customer_id = invoice.customer.unwrap().id(); + let lines = invoice.lines.unwrap(); + + if lines.total_count.unwrap() > 1 { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too many invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too many line items".to_string()).into()); + } + + let item = match lines.data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); // pull user and update role let mut retries: usize = 0; @@ -118,45 +161,54 @@ pub async fn stripe_webhook( } let user = user.unwrap(); - tracing::info!("found subscription user in {retries} tries"); - if user.permissions.check(FinePermission::SUPPORTER) { - return Json(ApiReturn { - ok: true, - message: "Already applied".to_string(), - payload: (), - }); - } + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("found subscription user in {retries} tries"); - tracing::info!("invoice {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions | FinePermission::SUPPORTER; + if user.permissions.check(FinePermission::SUPPORTER) { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions | FinePermission::SUPPORTER; - 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) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } - } - if let Err(e) = data - .create_notification(Notification::new( - "Welcome new supporter!".to_string(), - "Thank you for your support! Your account has been updated with your new role." - .to_string(), - user.id, - )) - .await - { - return Json(e.into()); + 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(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } } EventType::CustomerSubscriptionDeleted => { diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs deleted file mode 100644 index b86bfd2..0000000 --- a/crates/app/src/routes/api/v1/layouts.rs +++ /dev/null @@ -1,175 +0,0 @@ -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 506e74f..b3496db 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,7 +4,6 @@ pub mod channels; pub mod communities; pub mod domains; pub mod journals; -pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -29,7 +28,6 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, - layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, @@ -625,17 +623,6 @@ 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)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1055,27 +1042,6 @@ 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, -} - #[derive(Deserialize)] pub struct CreateService { pub name: String, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 309c851..44d1257 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,13 +173,13 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { - /// Payment link from the Stripe dashboard. + /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership /// 2. Set the product price to a recurring subscription /// 3. Create a payment link for the new product /// 4. The payment link pasted into this config field should NOT include a query string - pub payment_link: String, + pub payment_links: StripePaymentLinks, /// To apply benefits to user accounts, you should then go into the Stripe developer /// "workbench" and create a new webhook. The webhook needs the scopes: /// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`. @@ -194,6 +194,20 @@ pub struct StripeConfig { pub billing_portal_url: String, /// The text representation of the price of supporter. (like `$4 USD`) pub supporter_price_text: String, + /// Product IDs from the Stripe dashboard. + /// + /// These are checked when we receive a webhook to ensure we provide the correct product. + pub product_ids: StripeProductIds, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePaymentLinks { + pub supporter: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripeProductIds { + pub supporter: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 969b014..f3d2668 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,7 +40,6 @@ 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(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index efa3eae..6a562e7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,6 +27,5 @@ 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_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql deleted file mode 100644 index 3f28c0a..0000000 --- a/crates/core/src/database/drivers/sql/create_layouts.sql +++ /dev/null @@ -1,9 +0,0 @@ -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/layouts.rs b/crates/core/src/database/layouts.rs deleted file mode 100644 index 052a733..0000000 --- a/crates/core/src/database/layouts.rs +++ /dev/null @@ -1,117 +0,0 @@ -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: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/mod.rs b/crates/core/src/database/mod.rs index 1009797..57873f9 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -13,7 +13,6 @@ 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 efea59a..a97b1fd 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -322,6 +322,9 @@ pub struct UserSettings { /// and the following timeline. #[serde(default)] pub auto_full_unlist: bool, + /// Biography shown on `profile/private.lisp` page. + #[serde(default)] + pub private_biography: String, } fn mime_avif() -> String { diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs deleted file mode 100644 index a9d60a4..0000000 --- a/crates/core/src/model/layouts.rs +++ /dev/null @@ -1,403 +0,0 @@ -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. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum CustomizablePage { - Home, - All, - Popular, -} - -/// Layouts allow you to customize almost every page in the Tetratto UI through -/// simple blocks. -#[derive(Serialize, Deserialize)] -pub struct Layout { - pub id: usize, - pub created: usize, - pub owner: usize, - 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, PartialEq, Eq)] -pub enum LayoutPrivacy { - Public, - Private, -} - -impl Display for Layout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - for (i, page) in self.pages.iter().enumerate() { - let mut x = page.to_string(); - - if i == 0 { - x = x.replace("%?%", ""); - } else { - x = x.replace("%?%", "hidden"); - } - - out.push_str(&x); - } - - f.write_str(&out) - } -} - -/// Layouts are able to contain subpages within them. -/// -/// Each layout is only allowed 2 subpages pages, meaning one main page and one extra. -#[derive(Serialize, Deserialize)] -pub struct LayoutPage { - pub name: String, - pub blocks: Vec, - pub css: String, - pub js: String, -} - -impl Display for LayoutPage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "
{}
", - { - 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 e825340..2cd4955 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,7 +6,6 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; -pub mod layouts; pub mod littleweb; pub mod moderation; pub mod oauth; From e4468e476842f563e72df24a26d6cb157753e08e Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 18:06:36 -0400 Subject: [PATCH 20/69] add: user seller_data --- .gitignore | 1 + crates/app/Cargo.toml | 1 + crates/app/src/assets.rs | 1 - crates/app/src/main.rs | 18 +- crates/app/src/public/js/layout_editor.js | 762 ------------------ .../routes/api/v1/auth/connections/stripe.rs | 104 ++- crates/app/src/routes/api/v1/mod.rs | 8 + crates/app/src/routes/assets.rs | 1 - crates/app/src/routes/mod.rs | 4 - crates/app/src/routes/pages/mod.rs | 5 +- crates/core/src/config.rs | 2 + crates/core/src/database/auth.rs | 8 +- crates/core/src/model/auth.rs | 10 + sql_changes/users_seller_data.sql | 2 + 14 files changed, 150 insertions(+), 777 deletions(-) delete mode 100644 crates/app/src/public/js/layout_editor.js create mode 100644 sql_changes/users_seller_data.sql diff --git a/.gitignore b/.gitignore index f5f83f6..7bc86aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target debug/ +.dev diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8a7eb6e..a54b7a8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -37,6 +37,7 @@ async-stripe = { version = "0.41.0", features = [ "webhook-events", "billing", "runtime-tokio-hyper", + "connect", ] } emojis = "0.7.0" webp = "0.3.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 81671fe..e4088a1 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -40,7 +40,6 @@ 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"); pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); // html diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b4ffbe6..f7f7c06 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -8,6 +8,7 @@ mod routes; mod sanitize; use assets::{init_dirs, write_assets}; +use stripe::Client as StripeClient; use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; pub use tetratto_core::*; @@ -27,7 +28,8 @@ use tracing::{Level, info}; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tokio::sync::RwLock; -pub(crate) type State = Arc>; +pub(crate) type InnerState = (DataManager, Tera, Client, Option); +pub(crate) type State = Arc>; fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { Ok( @@ -115,6 +117,13 @@ async fn main() { let client = Client::new(); let mut app = Router::new(); + // cretae stripe client + let stripe_client = if let Some(ref stripe) = config.stripe { + Some(StripeClient::new(stripe.secret.clone())) + } else { + None + }; + // add correct routes if var("LITTLEWEB").is_ok() { app = app.merge(routes::lw_routes()); @@ -129,7 +138,12 @@ async fn main() { // add junk app = app - .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) + .layer(Extension(Arc::new(RwLock::new(( + database, + tera, + client, + stripe_client, + ))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") .unwrap_or("8388608".to_string()) diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js deleted file mode 100644 index 13d3d8b..0000000 --- a/crates/app/src/public/js/layout_editor.js +++ /dev/null @@ -1,762 +0,0 @@ -/// 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 3a4619e..8343b1b 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,6 +1,7 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; use tetratto_core::model::{ auth::{User, Notification}, moderation::AuditLogEntry, @@ -8,7 +9,7 @@ use tetratto_core::model::{ ApiReturn, Error, }; use stripe::{EventObject, EventType}; -use crate::State; +use crate::{get_user_from_token, State}; pub async fn stripe_webhook( Extension(data): Extension, @@ -320,3 +321,102 @@ pub async fn stripe_webhook( payload: (), }) } + +pub async fn onboarding_account_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::AccountLink::create( + &client, + stripe::CreateAccountLink { + account: match user.seller_data.account_id { + Some(id) => stripe::AccountId::from_str(&id).unwrap(), + None => return Json(Error::NotAllowed.into()), + }, + type_: stripe::AccountLinkType::AccountOnboarding, + collect: None, + expand: &[], + refresh_url: Some(&format!( + "{}/auth/connections_link/seller/refresh", + data.0.0.0.host + )), + return_url: Some(&format!( + "{}/auth/connections_link/seller/return", + data.0.0.0.host + )), + collection_options: None, + }, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} + +pub async fn create_seller_account_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + let account = match stripe::Account::create( + &client, + stripe::CreateAccount { + type_: Some(stripe::AccountType::Express), + capabilities: Some(stripe::CreateAccountCapabilities { + card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments { + requested: Some(true), + }), + transfers: Some(stripe::CreateAccountCapabilitiesTransfers { + requested: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + { + Ok(a) => a, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + user.seller_data.account_id = Some(account.id.to_string()); + match data + .0 + .update_user_seller_data(user.id, user.seller_data) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index b3496db..164b17f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -512,6 +512,14 @@ pub fn routes() -> Router { "/service_hooks/stripe", post(auth::connections::stripe::stripe_webhook), ) + .route( + "/service_hooks/stripe/seller/register", + post(auth::connections::stripe::create_seller_account_request), + ) + .route( + "/service_hooks/stripe/seller/onboarding", + post(auth::connections::stripe::onboarding_account_link_request), + ) // channels .route("/channels", post(channels::channels::create_request)) .route( diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 2aa1bc5..d7843bd 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,5 +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")); serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 0872632..e0fa067 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,10 +20,6 @@ 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), - ) .route("/js/proto_links.js", get(assets::proto_links_request)) .nest_service( "/public", diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6ce6318..ed513f9 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -17,11 +17,10 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tetratto_core::{ - DataManager, model::{Error, auth::User}, }; -use crate::{assets::initial_context, get_lang}; +use crate::{assets::initial_context, get_lang, InnerState}; pub fn routes() -> Router { Router::new() @@ -156,7 +155,7 @@ pub fn lw_routes() -> Router { pub async fn render_error( e: Error, jar: &CookieJar, - data: &(DataManager, tera::Tera, reqwest::Client), + data: &InnerState, user: &Option, ) -> String { let lang = get_lang!(jar, data.0); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 44d1257..d695c39 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,6 +173,8 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { + /// Your Stripe API secret. + pub secret: String, /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 88ef32e..9bfdd36 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,7 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::auth::{ - Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, + Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData, + UserConnections, ACHIEVEMENTS, }; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; @@ -117,6 +118,7 @@ impl DataManager { awaiting_purchase: get!(x->24(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), + seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), } } @@ -273,7 +275,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, $25, $26, $27)", + "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, $27, $28)", params![ &(data.id as i64), &(data.created as i64), @@ -302,6 +304,7 @@ impl DataManager { &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, + &serde_json::to_string(&data.seller_data).unwrap(), ] ); @@ -997,6 +1000,7 @@ impl DataManager { 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!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index a97b1fd..1119d8b 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -80,6 +80,9 @@ pub struct User { /// view pages which require authentication (all `$` routes). #[serde(default)] pub browser_session: String, + /// Stripe connected account information (for Tetratto marketplace). + #[serde(default)] + pub seller_data: StripeSellerData, } pub type UserConnections = @@ -327,6 +330,12 @@ pub struct UserSettings { pub private_biography: String, } +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct StripeSellerData { + #[serde(default)] + pub account_id: Option, +} + fn mime_avif() -> String { "image/avif".to_string() } @@ -371,6 +380,7 @@ impl User { awaiting_purchase: false, was_purchased: false, browser_session: String::new(), + seller_data: StripeSellerData::default(), } } diff --git a/sql_changes/users_seller_data.sql b/sql_changes/users_seller_data.sql new file mode 100644 index 0000000..fa8a1f0 --- /dev/null +++ b/sql_changes/users_seller_data.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN seller_data TEXT NOT NULL DEFAULT '{}'; From aea764948c3e8bd4a9b4fe6b2c724b50dec55897 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 21:05:45 -0400 Subject: [PATCH 21/69] add: ability to create seller account --- crates/app/src/assets.rs | 8 ++ crates/app/src/langs/en-US.toml | 6 ++ crates/app/src/public/css/style.css | 8 ++ .../public/html/auth/seller_connection.lisp | 25 +++++ crates/app/src/public/html/macros.lisp | 14 +++ .../src/public/html/marketplace/seller.lisp | 79 +++++++++++++++ .../app/src/public/images/vendor/stripe.svg | 1 + crates/app/src/public/js/me.js | 57 +++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 43 +++++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/pages/marketplace.rs | 95 +++++++++++++++++++ crates/app/src/routes/pages/mod.rs | 14 +++ crates/core/src/model/auth.rs | 2 + 13 files changed, 356 insertions(+) create mode 100644 crates/app/src/public/html/auth/seller_connection.lisp create mode 100644 crates/app/src/public/html/marketplace/seller.lisp create mode 100644 crates/app/src/public/images/vendor/stripe.svg create mode 100644 crates/app/src/routes/pages/marketplace.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index e4088a1..ad0f49b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -58,6 +58,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp"); pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp"); +pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp"); @@ -139,6 +140,8 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); +pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -146,6 +149,7 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg"); +pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg"); pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp"); @@ -343,6 +347,7 @@ pub(crate) fn lisp_plugins() -> HashMap Elemen pub(crate) async fn write_assets(config: &Config) -> PathBufD { vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons); + vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons); bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets); // ... @@ -364,6 +369,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins); write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins); + write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins); @@ -440,6 +446,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); + write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a852d5a..94fa6f8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -285,3 +285,9 @@ version = "1.0.0" "littleweb:action.edit_site_name" = "Edit site name" "littleweb:action.rename" = "Rename" "littleweb:action.add" = "Add" + +"marketplace:label.products" = "Products" +"marketplace:label.status" = "Status" +"marketplace:action.get_started" = "Get started" +"marketplace:action.finsh_setting_up_account" = "Finish setting up my account" +"marketplace:action.open_seller_dashboard" = "Open seller dashboard" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 24c41bd..c4c5185 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -583,6 +583,9 @@ input[type="checkbox"]:checked { border-radius: 6px; height: max-content; font-weight: 600; + display: flex; + justify-content: center; + align-items: center; } .notification.tr { @@ -597,6 +600,11 @@ input[type="checkbox"]:checked { padding: 0; } +.notification .icon { + width: 100%; + height: 100%; +} + /* chip */ .chip { background: var(--color-primary); diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp new file mode 100644 index 0000000..43381da --- /dev/null +++ b/crates/app/src/public/html/auth/seller_connection.lisp @@ -0,0 +1,25 @@ +(text "{% extends \"auth/base.html\" %} {% block head %}") +(title + (text "Connection")) + +(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}") +(div + ("class" "w-full flex-col gap-2") + ("id" "status") + (b + (text "Working..."))) + +(text "{% if connection_type == \"refresh\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + trigger(\"seller::onboarding\"); + }, 1000);")) +(text "{% elif connection_type == \"return\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + document.getElementById(\"status\").innerHTML = + `Account updated. You can now close this tab.`; + }, 1000);")) +(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f9d8a1f..980ee7f 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -331,3 +331,17 @@ (span (text "{{ text \"settings:tab.connections\" }}"))) (text "{%- endmacro %}") + +(text "{% macro seller_settings_nav_options() -%}") +(a + ("data-tab-button" "account") + ("class" "active") + ("href" "#/account") + (icon (text "smile")) + (span (str (text "settings:tab.account")))) +(a + ("data-tab-button" "products") + ("href" "#/products") + (icon (text "package")) + (span (str (text "marketplace:label.products")))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp new file mode 100644 index 0000000..0efa4f0 --- /dev/null +++ b/crates/app/src/public/html/marketplace/seller.lisp @@ -0,0 +1,79 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Seller settings - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + + ; nav + (div + ("class" "mobile_nav mobile") + ; primary nav + (div + ("class" "dropdown") + ("style" "width: max-content") + (button + ("class" "raised small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "sliders-horizontal")) + (span ("class" "current_tab_text") (text "account"))) + (div + ("class" "inner left") + (text "{{ macros::seller_settings_nav_options() }}")))) + + ; nav desktop + (div + ("class" "desktop pillmenu") + (text "{{ macros::seller_settings_nav_options() }}")) + + ; ... + (div + ("class" "card w-full lowered flex flex-col gap-2") + ("data-tab" "account") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center gap-2") + (div + ("class" "notification") + ("style" "width: 46px") + (icon (text "stripe"))) + + (b (str (text "marketplace:label.status")))) + + (div + ("class" "card") + (text "{% if user.seller_data.account_id -%}") + (text "{% if user.seller_data.completed_onboarding -%}") + ; completed onboarding + has stripe account linked + (button + ("onclick" "trigger('seller::login')") + (icon (text "arrow-right")) + (str (text "marketplace:action.open_seller_dashboard"))) + (text "{% else %}") + ; not completed onboarding + (p (text "You've not finished setting up your Stripe account.")) + (p (text "Please complete onboarding to accept payments.")) + + (button + ("onclick" "trigger('seller::onboarding')") + (icon (text "arrow-right")) + (str (text "marketplace:action.finsh_setting_up_account"))) + (text "{%- endif %}") + (text "{% else %}") + ; doesn't have a stripe account linked + (button + ("onclick" "trigger('seller::register')") + (icon (text "arrow-right")) + (str (text "marketplace:action.get_started"))) + (text "{%- endif %}")))) + + (div + ("class" "card w-full lowered hidden flex flex-col gap-2") + ("data-tab" "products") + (div + ("class" "card w-full flex flex-wrap gap-2") + ))) + +(text "{% endblock %}") diff --git a/crates/app/src/public/images/vendor/stripe.svg b/crates/app/src/public/images/vendor/stripe.svg new file mode 100644 index 0000000..415271d --- /dev/null +++ b/crates/app/src/public/images/vendor/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 4fd2150..e7fa2d6 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -1201,3 +1201,60 @@ ]); }); })(); + +(() => { + const self = reg_ns("seller"); + + self.define("register", async () => { + await trigger("atto::debounce", ["seller::register"]); + + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/register", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + self.onboarding(); + }); + + self.define("onboarding", async () => { + await trigger("atto::debounce", ["seller::onboarding"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/onboarding", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); + + self.define("login", async () => { + await trigger("atto::debounce", ["seller::login"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/login", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); +})(); 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 8343b1b..33e60b4 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -332,6 +332,10 @@ pub async fn onboarding_account_link_request( None => return Json(Error::NotAllowed.into()), }; + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + let client = match data.3 { Some(ref c) => c, None => return Json(Error::Unknown.into()), @@ -379,6 +383,10 @@ pub async fn create_seller_account_request( None => return Json(Error::NotAllowed.into()), }; + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + let client = match data.3 { Some(ref c) => c, None => return Json(Error::Unknown.into()), @@ -420,3 +428,38 @@ pub async fn create_seller_account_request( Err(e) => return Json(e.into()), } } + +pub async fn login_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding { + return Json(Error::NotAllowed.into()); + } + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::LoginLink::create( + &client, + &stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(), + &data.0.0.0.host, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 164b17f..ccc91c8 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -520,6 +520,10 @@ pub fn routes() -> Router { "/service_hooks/stripe/seller/onboarding", post(auth::connections::stripe::onboarding_account_link_request), ) + .route( + "/service_hooks/stripe/seller/login", + post(auth::connections::stripe::login_link_request), + ) // channels .route("/channels", post(channels::channels::create_request)) .route( diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs new file mode 100644 index 0000000..69a2b3d --- /dev/null +++ b/crates/app/src/routes/pages/marketplace.rs @@ -0,0 +1,95 @@ +use super::render_error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use axum::{ + response::{Html, IntoResponse}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::Error; + +/// `/settings/seller` +pub async fn seller_settings_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let lang = get_lang!(jar, data.0); + let context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + // return + Ok(Html( + data.1.render("marketplace/seller.html", &context).unwrap(), + )) +} + +pub async fn connection_return_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + // update user + user.seller_data.completed_onboarding = true; + if let Err(e) = data + .0 + .update_user_seller_data(user.id, user.seller_data.clone()) + .await + { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "return"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} + +pub async fn connection_reload_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "reload"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index ed513f9..83e29ad 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -5,6 +5,7 @@ pub mod developer; pub mod forge; pub mod journals; pub mod littleweb; +pub mod marketplace; pub mod misc; pub mod mod_panel; pub mod profile; @@ -76,6 +77,14 @@ pub fn routes() -> Router { "/auth/connections_link/app/{id}", get(developer::connection_callback_request), ) + .route( + "/auth/connections_link/seller/reload", + get(marketplace::connection_reload_request), + ) + .route( + "/auth/connections_link/seller/return", + get(marketplace::connection_return_request), + ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) @@ -146,6 +155,11 @@ pub fn routes() -> Router { .route("/domains/{id}", get(littleweb::domain_request)) .route("/net", get(littleweb::browser_home_request)) .route("/net/{*uri}", get(littleweb::browser_request)) + // marketplace + .route( + "/settings/seller", + get(marketplace::seller_settings_request), + ) } pub fn lw_routes() -> Router { diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 1119d8b..4fb1882 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -334,6 +334,8 @@ pub struct UserSettings { pub struct StripeSellerData { #[serde(default)] pub account_id: Option, + #[serde(default)] + pub completed_onboarding: bool, } fn mime_avif() -> String { From 2705608903c3c298007f7b2e01036152589457c8 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 00:05:28 -0400 Subject: [PATCH 22/69] add: product types --- .../app/src/public/html/profile/settings.lisp | 2 +- crates/app/src/routes/api/v1/auth/profile.rs | 35 ++++++++--- crates/core/src/database/auth.rs | 4 +- crates/core/src/model/mod.rs | 1 + crates/core/src/model/products.rs | 58 +++++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 crates/core/src/model/products.rs diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index c5566c7..e3d775b 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1537,7 +1537,7 @@ ], [ [\"auto_full_unlist\", \"Only publish my posts to my profile\"], - \"{{ profile.settings.auto_unlist }}\", + \"{{ profile.settings.auto_full_unlist }}\", \"checkbox\", ], [ diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 8104c71..bb874fe 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use crate::{ get_user_from_token, model::{ApiReturn, Error}, @@ -451,8 +451,8 @@ pub async fn delete_user_request( Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data) { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; @@ -461,6 +461,7 @@ pub async fn delete_user_request( return Json(Error::NotAllowed.into()); } else if user.permissions.check(FinePermission::MANAGE_USERS) { if let Err(e) = data + .0 .create_audit_log_entry(AuditLogEntry::new( user.id, format!("invoked `delete_user` with x value `{id}`"), @@ -472,14 +473,32 @@ pub async fn delete_user_request( } match data + .0 .delete_user(id, &req.password, user.permissions.check_manager()) .await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "User deleted".to_string(), - payload: (), - }), + Ok(ua) => { + // delete stripe user + if let Some(stripe_id) = ua.seller_data.account_id + && let Some(ref client) = data.3 + { + if let Err(e) = stripe::Account::delete( + &client, + &stripe::AccountId::from_str(&stripe_id).unwrap(), + ) + .await + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "User deleted".to_string(), + payload: (), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 9bfdd36..b8651ca 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -321,7 +321,7 @@ impl DataManager { /// * `id` - the ID of the user /// * `password` - the current password of the user /// * `force` - if we should delete even if the given password is incorrect - pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result<()> { + pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result { let user = self.get_user_by_id(id).await?; if (hash_salted(password.to_string(), user.salt.clone()) != user.password) && !force { @@ -581,7 +581,7 @@ impl DataManager { } // ... - Ok(()) + Ok(user) } pub async fn update_user_verified_status(&self, id: usize, x: bool, user: User) -> Result<()> { diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 2cd4955..b86ebfa 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -10,6 +10,7 @@ pub mod littleweb; pub mod moderation; pub mod oauth; pub mod permissions; +pub mod products; pub mod reactions; pub mod requests; pub mod socket; diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs new file mode 100644 index 0000000..e7b5b41 --- /dev/null +++ b/crates/core/src/model/products.rs @@ -0,0 +1,58 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Product { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub description: String, + pub likes: usize, + pub dislikes: usize, + pub r#type: ProductType, + pub stripe_id: String, + pub price: ProductPrice, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ProductType { + /// Text + images. + Message, + /// When a commission product is purchased, the creator will receive a request + /// prompting them to respond with text + images. + /// + /// This is the only product type which does not immediately return data to the + /// customer, as seller input is required. + /// + /// If the request is deleted, the purchase should be immediately refunded. + Commission, +} + +/// Price in USD. `(dollars, cents)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductPrice(u64, u64); + +impl Product { + /// Create a new [`Product`]. + pub fn new( + owner: usize, + title: String, + description: String, + price: ProductPrice, + r#type: ProductType, + ) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + description, + likes: 0, + dislikes: 0, + r#type, + stripe_id: String::new(), + price, + } + } +} From ea135265155c81ed5e2f8a392a289ff5986b6bc6 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 00:50:16 -0400 Subject: [PATCH 23/69] add: product types --- crates/app/src/public/css/style.css | 2 +- crates/app/src/public/html/mod/profile.lisp | 1 + crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_products.sql | 12 ++ crates/core/src/database/mod.rs | 1 + crates/core/src/database/products.rs | 138 ++++++++++++++++++ crates/core/src/model/permissions.rs | 1 + crates/core/src/model/products.rs | 14 +- 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/create_products.sql create mode 100644 crates/core/src/database/products.rs diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index c4c5185..53162c6 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -600,7 +600,7 @@ input[type="checkbox"]:checked { padding: 0; } -.notification .icon { +.notification:not(.chip) .icon { width: 100%; height: 100%; } diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 2121f1e..408b391 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -368,6 +368,7 @@ ADMINISTRATOR: 1 << 1, MANAGE_DOMAINS: 1 << 2, MANAGE_SERVICES: 1 << 3, + MANAGE_PRODUCTS: 1 << 4, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index f3d2668..e7cd0ef 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -42,6 +42,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); + execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 6a562e7..7bee30a 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -29,3 +29,4 @@ pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_mess pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.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"); +pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql new file mode 100644 index 0000000..ff45afc --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS products ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + likes INT NOT NULL, + dislikes INT NOT NULL, + product_type TEXT NOT NULL, + stripe_id TEXT NOT NULL, + price TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 57873f9..730c54a 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -21,6 +21,7 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs new file mode 100644 index 0000000..10eb566 --- /dev/null +++ b/crates/core/src/database/products.rs @@ -0,0 +1,138 @@ +use crate::model::{ + auth::User, + products::Product, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Product`] from an SQL row. + pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { + Product { + 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)), + description: get!(x->4(String)), + likes: get!(x->5(i32)) as isize, + dislikes: get!(x->6(i32)) as isize, + product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), + stripe_id: get!(x->8(String)), + price: serde_json::from_str(&get!(x->9(String))).unwrap(), + } + } + + auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + pub async fn get_products_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 products WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_PRODUCTS: usize = 15; + + /// Create a new product in the database. + /// + /// # Arguments + /// * `data` - a mock [`Product`] object to insert + pub async fn create_product(&self, data: Product) -> 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 products + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let products = self.get_products_by_user(data.owner).await?; + + if products.len() >= Self::MAXIMUM_FREE_PRODUCTS { + return Err(Error::MiscError( + "You already have the maximum number of products 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 products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.description, + &0_i32, + &0_i32, + &serde_json::to_string(&data.product_type).unwrap(), + &data.stripe_id, + &serde_json::to_string(&data.price).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { + let product = self.get_product_by_id(id).await?; + + // check user permission + if user.id != product.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_PRODUCTS) + { + 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 products WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.product:{}", id)).await; + Ok(()) + } +} diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 55cf9cc..bbaca18 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -176,6 +176,7 @@ bitflags! { const ADMINISTRATOR = 1 << 1; const MANAGE_DOMAINS = 1 << 2; const MANAGE_SERVICES = 1 << 3; + const MANAGE_PRODUCTS = 1 << 4; const _ = !0; } diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index e7b5b41..1b54ba3 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -6,11 +6,11 @@ pub struct Product { pub id: usize, pub created: usize, pub owner: usize, - pub title: String, + pub name: String, pub description: String, - pub likes: usize, - pub dislikes: usize, - pub r#type: ProductType, + pub likes: isize, + pub dislikes: isize, + pub product_type: ProductType, pub stripe_id: String, pub price: ProductPrice, } @@ -37,7 +37,7 @@ impl Product { /// Create a new [`Product`]. pub fn new( owner: usize, - title: String, + name: String, description: String, price: ProductPrice, r#type: ProductType, @@ -46,11 +46,11 @@ impl Product { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), owner, - title, + name, description, likes: 0, dislikes: 0, - r#type, + product_type: r#type, stripe_id: String::new(), price, } From 2be2409d661b9f90a2f5ae17dcd292cd002ab0d5 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 12:42:28 -0400 Subject: [PATCH 24/69] fix: InvoicePaymentFailed event --- crates/app/src/routes/api/v1/auth/connections/stripe.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 33e60b4..16db8d6 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -262,12 +262,12 @@ pub async fn stripe_webhook( } EventType::InvoicePaymentFailed => { // payment failed - let subscription = match req.data.object { - EventObject::Subscription(c) => c, + let invoice = match req.data.object { + EventObject::Invoice(i) => i, _ => unreachable!("cannot be this"), }; - let customer_id = subscription.customer.id(); + let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, From cf2af1e1e9e65a57b2f567ce223fd6a82899acd3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 15:28:55 -0400 Subject: [PATCH 25/69] add: products api --- crates/app/src/routes/api/v1/mod.rs | 36 ++++ crates/app/src/routes/api/v1/products.rs | 162 ++++++++++++++++++ .../database/drivers/sql/create_products.sql | 1 - crates/core/src/database/products.rs | 10 +- crates/core/src/model/oauth.rs | 6 + crates/core/src/model/products.rs | 35 +++- 6 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/routes/api/v1/products.rs diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ccc91c8..38f915e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -6,6 +6,7 @@ pub mod domains; pub mod journals; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; @@ -31,6 +32,7 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, + products::{ProductType, ProductPrice}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -652,6 +654,17 @@ pub fn routes() -> Router { .route("/domains/{id}", get(domains::get_request)) .route("/domains/{id}", delete(domains::delete_request)) .route("/domains/{id}/data", post(domains::update_data_request)) + // products + .route("/products", get(products::list_request)) + .route("/products", post(products::create_request)) + .route("/products/{id}", get(products::get_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/name", post(products::update_name_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) } pub fn lw_routes() -> Router { @@ -1086,3 +1099,26 @@ pub struct CreateDomain { pub struct UpdateDomainData { pub data: Vec<(String, DomainData)>, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub name: String, + pub description: String, + pub product_type: ProductType, + pub price: ProductPrice, +} + +#[derive(Deserialize)] +pub struct UpdateProductName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: ProductPrice, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs new file mode 100644 index 0000000..5812127 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,162 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{products::Product, 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_product_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_products_by_user(user.id).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::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_product(Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + 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::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_description_request( + jar: CookieJar, + Extension(data): Extension, + 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::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_product_description(id, &user, &req.description) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_price_request( + jar: CookieJar, + Extension(data): Extension, + 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::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_price(id, &user, req.price).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_product(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index ff45afc..54bec8d 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,6 +7,5 @@ CREATE TABLE IF NOT EXISTS products ( likes INT NOT NULL, dislikes INT NOT NULL, product_type TEXT NOT NULL, - stripe_id TEXT NOT NULL, price TEXT NOT NULL ) diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 10eb566..a9833f0 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -1,7 +1,7 @@ use crate::model::{ auth::User, - products::Product, permissions::{FinePermission, SecondaryPermission}, + products::{Product, ProductPrice}, Error, Result, }; use crate::{auto_method, DataManager}; @@ -19,7 +19,6 @@ impl DataManager { likes: get!(x->5(i32)) as isize, dislikes: get!(x->6(i32)) as isize, product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - stripe_id: get!(x->8(String)), price: serde_json::from_str(&get!(x->9(String))).unwrap(), } } @@ -85,7 +84,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", params![ &(data.id as i64), &(data.created as i64), @@ -95,7 +94,6 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.product_type).unwrap(), - &data.stripe_id, &serde_json::to_string(&data.price).unwrap(), ] ); @@ -135,4 +133,8 @@ impl DataManager { self.0.1.remove(format!("atto.product:{}", id)).await; Ok(()) } + + auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 07a23c3..72884ae 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,6 +74,8 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -98,6 +100,8 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, + /// Create products on behalf of the user. + UserCreateProducts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -138,6 +142,8 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, + /// Manage the user's products. + UserManageProducts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index 1b54ba3..5e28b76 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; @@ -11,14 +13,13 @@ pub struct Product { pub likes: isize, pub dislikes: isize, pub product_type: ProductType, - pub stripe_id: String, pub price: ProductPrice, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProductType { /// Text + images. - Message, + Data, /// When a commission product is purchased, the creator will receive a request /// prompting them to respond with text + images. /// @@ -26,12 +27,39 @@ pub enum ProductType { /// customer, as seller input is required. /// /// If the request is deleted, the purchase should be immediately refunded. + /// + /// Commissions are paid beforehand to prevent theft. This means it is vital + /// that refunds are enforced. Commission, } +/// A currency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Currency { + USD, + EUR, + GBP, +} + +impl Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Currency::USD => "$", + Currency::EUR => "€", + Currency::GBP => "£", + }) + } +} + /// Price in USD. `(dollars, cents)`. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProductPrice(u64, u64); +pub struct ProductPrice(u64, u64, Currency); + +impl Display for ProductPrice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{}{}.{}", self.2, self.0, self.1)) + } +} impl Product { /// Create a new [`Product`]. @@ -51,7 +79,6 @@ impl Product { likes: 0, dislikes: 0, product_type: r#type, - stripe_id: String::new(), price, } } From f94570f74cab431477f2998467c41cd3f2d2130d Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 17:54:12 -0400 Subject: [PATCH 26/69] add: settings presets --- crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/css/style.css | 19 ++- .../src/public/html/littleweb/browser.lisp | 2 +- crates/app/src/public/html/macros.lisp | 4 +- crates/app/src/public/html/profile/base.lisp | 2 +- .../app/src/public/html/profile/blocked.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 2 +- .../app/src/public/html/profile/settings.lisp | 157 +++++++++++++++++- .../app/src/public/html/timelines/home.lisp | 12 ++ 9 files changed, 186 insertions(+), 16 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 94fa6f8..abc24e1 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -32,6 +32,7 @@ version = "1.0.0" "general:action.copy_link" = "Copy link" "general:action.copy_id" = "Copy ID" "general:action.post" = "Post" +"general:action.apply" = "Apply" "general:label.account" = "Account" "general:label.safety" = "Safety" "general:label.share" = "Share" @@ -161,6 +162,7 @@ version = "1.0.0" "settings:tab.sessions" = "Sessions" "settings:tab.connections" = "Connections" "settings:tab.images" = "Images" +"settings:tab.presets" = "Presets" "settings:label.change_password" = "Change password" "settings:label.current_password" = "Current password" "settings:label.delete_account" = "Delete account" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 53162c6..f8a4a8a 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -939,7 +939,7 @@ dialog::backdrop { transition: transform 0.15s; } -.dropdown:has(.inner.open) .dropdown-arrow { +.dropdown:has(.inner.open) .dropdown_arrow { transform: rotateZ(180deg); } @@ -1119,7 +1119,7 @@ details[open] > summary { margin-bottom: var(--pad-1); } -details[open] > summary::after { +details[open]:not(.accordion) > summary::after { top: 0; left: 0; width: 5px; @@ -1142,8 +1142,7 @@ details.accordion { } details.accordion summary { - background: var(--background); - border: solid 1px var(--color-super-lowered); + background: var(--color-lowered); border-radius: var(--radius); padding: var(--pad-3) var(--pad-4); margin: 0; @@ -1151,11 +1150,15 @@ details.accordion summary { user-select: none; } -details.accordion summary .icon { +details.accordion summary:hover { + background: var(--color-super-lowered); +} + +details.accordion summary .icon.dropdown_arrow { transition: transform 0.15s; } -details.accordion[open] summary .icon { +details.accordion[open] summary .icon.dropdown_arrow { transform: rotateZ(180deg); } @@ -1165,13 +1168,11 @@ details.accordion[open] summary { } details.accordion .inner { - background: var(--background); + background: var(--color-raised); padding: var(--pad-3) var(--pad-4); border-radius: var(--radius); border-top-left-radius: 0; border-top-right-radius: 0; - border: solid 1px var(--color-super-lowered); - border-top: none; } /* codemirror */ diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 9379357..95f35d8 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -47,7 +47,7 @@ ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %}")) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 980ee7f..0b0ba4b 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -74,7 +74,7 @@ ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %} {% else %}") @@ -84,7 +84,7 @@ ("class" "title") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (div ("class" "inner") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7962728..c482033 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -225,7 +225,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/blocked.lisp b/crates/app/src/public/html/profile/blocked.lisp index 1a128fa..660be0d 100644 --- a/crates/app/src/public/html/profile/blocked.lisp +++ b/crates/app/src/public/html/profile/blocked.lisp @@ -30,7 +30,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 83d533f..11740c9 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -57,7 +57,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index e3d775b..18fcc99 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -35,6 +35,87 @@ (text "{{ macros::profile_settings_nav_options() }}")) ; ... + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "presets") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (icon (text "arrow-left")) + (span + (str (text "general:action.back")))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (icon (text "cooking-pot")) + (span + (str (text "settings:tab.presets")))) + (div + ("class" "card flex flex-col gap-2 secondary") + (p (text "Not sure where to start? Try some settings presets!")) + (details + ("class" "w-full accordion") + (summary + (icon (text "rss")) + (text "Microblogging")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Focus on yourself and your communities.")) + (ul ("id" "preset_microblogging_ul")) + (button + ("onclick" "apply_preset(PRESET_MICROBLOGGING)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "message-circle-heart")) + (text "Q&A")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Just like Neospring!")) + (ul ("id" "preset_questions_ul")) + (button + ("onclick" "apply_preset(PRESET_QUESTIONS)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "key")) + (text "Private")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) + (ul ("id" "preset_private_ul")) + (button + ("onclick" "apply_preset(PRESET_PRIVATE)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "eye-closed")) + (text "NSFW")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) + (ul ("id" "preset_nsfw_ul")) + (button + ("onclick" "apply_preset(PRESET_NSFW)") + (icon (text "settings")) + (str (text "general:action.apply"))))))))) + (div ("class" "w-full flex flex-col gap-2") ("data-tab" "account") @@ -780,7 +861,23 @@ (text "Responses"))) (span ("class" "fade") - (text "This represents the timeline that is shown on your profile by default."))))) + (text "This represents the timeline that is shown on your profile by default.")))) + (div + ("class" "flex flex-col gap-2") + ("ui_ident" "show_presets") + (hr ("class" "margin")) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Not sure what to do?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1392,6 +1489,63 @@ }); } + // presets + globalThis.apply_preset = async (preset) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will change all listed settings to the listed value.\", + ])) + ) { + return; + } + + for (const x of preset) { + window.SETTING_SET_FUNCTIONS[0](x[0], x[1]) + } + + save_settings(); + } + + globalThis.render_preset_lis = (preset, id) => { + for (const x of preset) { + console.log(id); + document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; + } + } + + globalThis.PRESET_MICROBLOGGING = [ + [\"default_timeline\", \"All\"], + [\"all_timeline_hide_answers\", true], + ]; + + globalThis.PRESET_QUESTIONS = [ + [\"default_timeline\", \"Following\"], + [\"auto_full_unlist\", true], + [\"enable_questions\", true], + [\"allow_anonymous_questions\", true], + [\"enable_drawings\", true], + [\"hide_extra_post_tabs\", true], + ]; + + globalThis.PRESET_PRIVATE = [ + [\"private_profile\", true], + [\"private_last_seen\", true], + [\"private_communities\", true], + [\"private_chats\", true], + [\"require_account\", true], + ]; + + globalThis.PRESET_NSFW = [ + [\"auto_unlist\", true], + [\"show_nsfw\", true], + ]; + + render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\"); + render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\"); + render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\"); + render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\"); + + // ... const account_settings = document.getElementById(\"account_settings\"); const profile_settings = @@ -1411,6 +1565,7 @@ \"change_avatar\", \"change_banner\", \"default_profile_page\", + \"show_presets\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index 5a5658b..65b3a60 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -24,6 +24,18 @@ (a ("href" "/communities/search") (text "searching for a community to join!"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Need help getting started?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))) (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") From 2c83ed3d9d2a794e32fc73ef7161866fad067a29 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 18:42:08 -0400 Subject: [PATCH 27/69] add: "ask about this" from neospring --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 43 ++++++++++++++----- crates/app/src/public/html/misc/requests.lisp | 2 +- .../routes/api/v1/communities/questions.rs | 7 +++ crates/app/src/routes/api/v1/mod.rs | 2 + crates/app/src/routes/pages/communities.rs | 20 +++++++-- crates/core/src/database/posts.rs | 39 +++++++++++++---- crates/core/src/database/questions.rs | 36 +++++++++++++--- crates/core/src/model/communities.rs | 3 ++ 9 files changed, 122 insertions(+), 31 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index abc24e1..788ca48 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -131,6 +131,7 @@ version = "1.0.0" "communities:label.edit_content" = "Edit content" "communities:label.repost" = "Repost" "communities:label.quote_post" = "Quote post" +"communities:label.ask_about_this" = "Ask about this" "communities:label.search_results" = "Search results" "communities:label.query" = "Query" "communities:label.join_new" = "Join new" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index bd03879..0ce8821 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -118,7 +118,7 @@ (div ("class" "card-nest post_outer:{{ post.id }} post_outer") ("is_repost" "{{ is_repost }}") - (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") + (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}") (div ("class" "card small") (a @@ -321,7 +321,6 @@ ("class" "button camo small") ("target" "_blank") (text "{{ icon \"external-link\" }}")) - (text "{% if user -%}") (div ("class" "dropdown") (button @@ -335,6 +334,7 @@ (b ("class" "title") (text "{{ text \"general:label.share\" }}")) + (text "{% if user -%}") (button ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") (text "{{ icon \"repeat-2\" }}") @@ -357,7 +357,14 @@ (span (text "BlueSky"))) (text "{%- endif %}") - (text "{% if user.id != post.owner -%}") + (a + ("class" "button") + ("href" "/@{{ owner.username }}?asking_about={{ post.id }}") + (icon (text "reply")) + (span + (str (text "communities:label.ask_about_this")))) + (text "{%- endif %}") + (text "{% if user and user.id != post.owner -%}") (b ("class" "title") (text "{{ text \"general:label.safety\" }}")) @@ -367,12 +374,12 @@ (text "{{ icon \"flag\" }}") (span (text "{{ text \"general:action.report\" }}"))) - (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}") + (text "{%- endif %} {% if user and (user.id == post.owner) or is_helper or can_manage_post %}") (b ("class" "title") (text "{{ text \"general:action.manage\" }}")) ; forge stuff - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{% if user and community and community.is_forge -%} {% if post.is_open -%}") (button ("class" "green") ("onclick" "trigger('me::update_open', ['{{ post.id }}', false])") @@ -388,7 +395,7 @@ (text "{{ text \"forge:action.reopen\" }}"))) (text "{%- endif %} {%- endif %}") ; owner stuff - (text "{% if user.id == post.owner -%}") + (text "{% if user and user.id == post.owner -%}") (a ("href" "/post/{{ post.id }}#/edit") (text "{{ icon \"pen\" }}") @@ -420,8 +427,7 @@ (text "{{ icon \"undo\" }}") (span (text "{{ text \"general:action.restore\" }}"))) - (text "{%- endif %} {%- endif %}"))) - (text "{%- endif %}")))) + (text "{%- endif %} {%- endif %}")))))) (text "{% if community and show_community and community.id != config.town_square or question %}")) (text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}") @@ -630,7 +636,7 @@ --{{ css }}: {{ color|color }} !important; }")) -(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") +(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") (div ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") (text "{% if owner.id == 0 or question.context.mask_owner -%}") @@ -700,6 +706,10 @@ (text "{{ question.content|markdown|safe }}")) ; question drawings (text "{{ self::post_media(upload_ids=question.drawings) }}") + ; asking about + (text "{% if asking_about -%}") + (text "{{ self::post(post=asking_about[1], owner=asking_about[0], secondary=not secondary, show_community=false) }}") + (text "{%- endif %}") ; anonymous user ip thing ; this is only shown if the post author is anonymous AND we are a helper (text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}") @@ -736,6 +746,7 @@ ("class" "no_p_margin") (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) (form + ("id" "create_question_form") ("class" "card flex flex-col gap-2") ("onsubmit" "create_question_from_form(event)") (div @@ -822,6 +833,15 @@ (script (text "globalThis.gerald = null; + // asking about + globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\"); + + if (asking_about) { + document.getElementById(\"create_question_form\").innerHTML += + `
    Asking about: ${asking_about} (cancel)`; + } + + // ... async function create_question_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"questions::create\"]); @@ -843,7 +863,8 @@ receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", - mask_owner: (e.target.mask_owner || { checked:false }).checked + mask_owner: (e.target.mask_owner || { checked:false }).checked, + asking_about, }), ); @@ -872,7 +893,7 @@ (text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}") (div ("class" "card-nest") - (text "{{ self::question(question=question[0], owner=question[1], show_community=show_community) }}") + (text "{{ self::question(question=question[0], owner=question[1], asking_about=false, show_community=show_community) }}") (div ("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}") (div diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 9ba68d2..b9700f0 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -92,7 +92,7 @@ (text "{%- endif %} {% endfor %} {% for question in questions %}") (div ("class" "card-nest") - (text "{{ components::question(question=question[0], owner=question[1], profile=user) }}") + (text "{{ components::question(question=question[0], owner=question[1], asking_about=question[2], profile=user) }}") (form ("class" "card flex flex-col gap-2") ("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')") diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 1d1a7ba..e67b91b 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -96,6 +96,13 @@ pub async fn create_request( props.context.mask_owner = true; } + if !req.asking_about.is_empty() && !req.is_global { + props.context.asking_about = match req.asking_about.parse::() { + Ok(x) => Some(x), + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + } + } + match data .create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) .await diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 38f915e..d4e19c1 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -865,6 +865,8 @@ pub struct CreateQuestion { pub community: String, #[serde(default)] pub mask_owner: bool, + #[serde(default)] + pub asking_about: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 30d2ce0..59dc982 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::{render_error, PaginatedQuery, RepostsQuery, SearchedQuery}; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State, @@ -798,7 +800,11 @@ pub async fn post_request( let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -918,7 +924,11 @@ pub async fn reposts_request( let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -1069,7 +1079,11 @@ pub async fn likes_request( .await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index f17bbea..701c053 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -22,10 +22,11 @@ pub type FullPost = ( User, Community, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, ); +pub type FullQuestion = (Question, User, Option<(User, Post)>); macro_rules! private_post_replying { ($post:ident, $replying_posts:ident, $ua1:ident, $data:ident) => { @@ -224,8 +225,14 @@ impl DataManager { &self, post: &Post, ignore_users: &[usize], - ) -> Result> { + seen_questions: &mut HashMap, + ) -> Result> { if post.context.answering != 0 { + if let Some(q) = seen_questions.get(&post.context.answering) { + return Ok(Some(q.to_owned())); + } + + // ... let question = self.get_question_by_id(post.context.answering).await?; if ignore_users.contains(&question.owner) { @@ -238,7 +245,11 @@ impl DataManager { self.get_user_by_id_with_void(question.owner).await? }; - Ok(Some((question, user))) + let asking_about = self.get_question_asking_about(&question).await?; + let full_question = (question, user, asking_about); + + seen_questions.insert(post.context.answering, full_question.to_owned()); + Ok(Some(full_question)) } else { Ok(None) } @@ -322,7 +333,7 @@ impl DataManager { Post, User, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, )>, @@ -332,6 +343,7 @@ impl DataManager { let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -373,7 +385,8 @@ impl DataManager { post.clone(), ua.clone(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -454,7 +467,8 @@ impl DataManager { post.clone(), ua, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -477,6 +491,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); let mut memberships: HashMap = HashMap::new(); @@ -544,7 +559,8 @@ impl DataManager { ua.clone(), community.to_owned(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -643,7 +659,8 @@ impl DataManager { ua, community, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -716,8 +733,12 @@ impl DataManager { } // question - if let Some((_, ref mut x)) = post.4 { + if let Some((_, ref mut x, ref mut y)) = post.4 { x.clean(); + + if y.is_some() { + y.as_mut().unwrap().0.clean(); + } } // ... diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 1cee527..900d68c 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; use crate::model::addr::RemoteAddr; +use crate::model::communities::Post; use crate::model::communities_permissions::CommunityPermission; use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ @@ -38,13 +39,26 @@ impl DataManager { auto_method!(get_question_by_id()@get_question_from_row -> "SELECT * FROM questions WHERE id = $1" --name="question" --returns=Question --cache-key-tmpl="atto.question:{}"); + /// Get the post a given question is asking about. + pub async fn get_question_asking_about( + &self, + question: &Question, + ) -> Result> { + Ok(if let Some(id) = question.context.asking_about { + let post = self.get_post_by_id(id).await?; + Some((self.get_user_by_id(post.owner).await?, post)) + } else { + None + }) + } + /// Fill the given vector of questions with their owner as well. pub async fn fill_questions( &self, questions: Vec, ignore_users: &[usize], - ) -> Result> { - let mut out: Vec<(Question, User)> = Vec::new(); + ) -> Result)>> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); let mut seen_users: HashMap = HashMap::new(); for question in questions { @@ -53,7 +67,8 @@ impl DataManager { } if let Some(ua) = seen_users.get(&question.owner) { - out.push((question, ua.to_owned())); + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, ua.to_owned(), asking_about)); } else { let user = if question.owner == 0 { User::anonymous() @@ -62,7 +77,9 @@ impl DataManager { }; seen_users.insert(question.owner, user.clone()); - out.push((question, user)); + + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, user, asking_about)); } } @@ -72,12 +89,17 @@ impl DataManager { /// Filter to update questions to clean their owner for public APIs. pub fn questions_owner_filter( &self, - questions: &Vec<(Question, User)>, - ) -> Vec<(Question, User)> { - let mut out: Vec<(Question, User)> = Vec::new(); + questions: &Vec<(Question, User, Option<(User, Post)>)>, + ) -> Vec<(Question, User, Option<(User, Post)>)> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); for mut question in questions.clone() { question.1.clean(); + + if question.2.is_some() { + question.2.as_mut().unwrap().0.clean(); + } + out.push(question); } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 8a4ab9a..14f640f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -387,6 +387,9 @@ pub struct QuestionContext { /// If the owner is shown as anonymous in the UI. #[serde(default)] pub mask_owner: bool, + /// The POST this question is asking about. + #[serde(default)] + pub asking_about: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] From 73d8e9ab498799ab35d49a4b2f9f9d15acefff2c Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 18:43:36 -0400 Subject: [PATCH 28/69] fix: don't show "ask about this" if owner has questions disabled --- crates/app/src/public/html/components.lisp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 0ce8821..2e09987 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -357,6 +357,7 @@ (span (text "BlueSky"))) (text "{%- endif %}") + (text "{% if owner.settings.enable_questions -%}") (a ("class" "button") ("href" "/@{{ owner.username }}?asking_about={{ post.id }}") @@ -364,6 +365,7 @@ (span (str (text "communities:label.ask_about_this")))) (text "{%- endif %}") + (text "{%- endif %}") (text "{% if user and user.id != post.owner -%}") (b ("class" "title") From 052ddf862f16039fa23f647825a8377d6e338ce2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 19:05:17 -0400 Subject: [PATCH 29/69] fix: check permissions before asking about a post --- crates/core/src/database/questions.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 900d68c..253b6c2 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -427,6 +427,22 @@ impl DataManager { } } + // check asking_about + if let Some(id) = data.context.asking_about { + let post = self.get_post_by_id(id).await?; + let owner = self.get_user_by_id(post.owner).await?; + + if post.stack != 0 { + return Err(Error::MiscError( + "Cannot ask about posts in a circle".to_string(), + )); + } else if owner.settings.private_profile { + return Err(Error::MiscError( + "Cannot ask about posts from a private user".to_string(), + )); + } + } + // create uploads if drawings.len() > 2 { return Err(Error::MiscError( From 292d30230402331005e5a035662539cd99c9368b Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 19:58:59 -0400 Subject: [PATCH 30/69] fix: regular question asking --- crates/app/src/public/html/components.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 2e09987..578345a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -836,7 +836,7 @@ (script (text "globalThis.gerald = null; // asking about - globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\"); + globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\") || \"\"; if (asking_about) { document.getElementById(\"create_question_form\").innerHTML += From 3b5b0ce1a1e18c7745515f151a19f8edae08799e Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 23:15:00 -0400 Subject: [PATCH 31/69] add: product uploads --- crates/app/src/public/html/components.lisp | 11 ++++++++++- crates/app/src/public/html/macros.lisp | 1 + crates/app/src/public/html/profile/settings.lisp | 2 +- .../app/src/routes/api/v1/auth/connections/stripe.rs | 2 +- .../core/src/database/drivers/sql/create_products.sql | 3 ++- crates/core/src/database/products.rs | 6 ++++-- crates/core/src/database/questions.rs | 6 +++++- crates/core/src/model/products.rs | 3 +++ 8 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 578345a..1e3aa17 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -227,7 +227,7 @@ ("hook" "long") (text "{{ post.title }}")) - (button ("class" "small lowered") (icon (text "ellipsis")))) + (button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis")))) (text "{% else %}") (text "{% if not post.context.content_warning -%}") (span @@ -327,6 +327,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -922,6 +923,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1035,6 +1037,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1273,6 +1276,7 @@ ("class" "camo small square") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1494,6 +1498,7 @@ ("title" "More options") ("onclick" "document.getElementById('post_options_dialog').showModal()") ("type" "button") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (label @@ -2085,6 +2090,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2111,6 +2117,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2202,6 +2209,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2281,6 +2289,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 0b0ba4b..969439b 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -73,6 +73,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") + ("title" "Account options") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") (icon_class (text "chevron-down") (text "dropdown_arrow"))) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 18fcc99..da41608 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1493,7 +1493,7 @@ globalThis.apply_preset = async (preset) => { if ( !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this? This will change all listed settings to the listed value.\", + \"Are you sure you would like to do this? This will change all listed settings to their listed values.\", ])) ) { 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 16db8d6..91560b0 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -303,7 +303,7 @@ pub async fn stripe_webhook( 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." + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment.\n\nIf you've cancelled your subscription, you can safely disregard this." .to_string(), user.id, )) diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index 54bec8d..4a972aa 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS products ( likes INT NOT NULL, dislikes INT NOT NULL, product_type TEXT NOT NULL, - price TEXT NOT NULL + price TEXT NOT NULL, + uploads TEXT NOT NULL ) diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index a9833f0..5127f78 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -19,7 +19,8 @@ impl DataManager { likes: get!(x->5(i32)) as isize, dislikes: get!(x->6(i32)) as isize, product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - price: serde_json::from_str(&get!(x->9(String))).unwrap(), + price: serde_json::from_str(&get!(x->8(String))).unwrap(), + uploads: serde_json::from_str(&get!(x->9(String))).unwrap(), } } @@ -84,7 +85,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.created as i64), @@ -95,6 +96,7 @@ impl DataManager { &0_i32, &serde_json::to_string(&data.product_type).unwrap(), &serde_json::to_string(&data.price).unwrap(), + &serde_json::to_string(&data.uploads).unwrap(), ] ); diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 253b6c2..84f9eac 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -45,7 +45,11 @@ impl DataManager { question: &Question, ) -> Result> { Ok(if let Some(id) = question.context.asking_about { - let post = self.get_post_by_id(id).await?; + let post = match self.get_post_by_id(id).await { + Ok(x) => x, + Err(_) => return Ok(None), + }; + Some((self.get_user_by_id(post.owner).await?, post)) } else { None diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index 5e28b76..2b90ca5 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -14,6 +14,8 @@ pub struct Product { pub dislikes: isize, pub product_type: ProductType, pub price: ProductPrice, + /// Optional uploads to accompany the product title and description. Maximum of 4. + pub uploads: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +82,7 @@ impl Product { dislikes: 0, product_type: r#type, price, + uploads: Vec::new(), } } } From e0e38b2b32414b97f61fff15745b826b947fe2d1 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 15:30:17 -0400 Subject: [PATCH 32/69] add: upload alt text --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 1 - .../app/src/public/html/profile/settings.lisp | 90 ++++++++++++----- crates/app/src/public/js/atto.js | 17 ++-- .../src/routes/api/v1/communities/posts.rs | 5 +- crates/app/src/routes/api/v1/mod.rs | 7 ++ crates/app/src/routes/api/v1/products.rs | 97 +++++++++++++++---- crates/app/src/routes/api/v1/uploads.rs | 42 +++++++- crates/app/src/routes/pages/marketplace.rs | 8 +- .../database/drivers/sql/create_uploads.sql | 3 +- crates/core/src/database/uploads.rs | 8 +- crates/core/src/model/uploads.rs | 2 + sql_changes/uploads_alt.sql | 2 + 13 files changed, 224 insertions(+), 59 deletions(-) create mode 100644 sql_changes/uploads_alt.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 788ca48..de5a411 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -183,6 +183,7 @@ version = "1.0.0" "settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" +"settings:label.alt_text" = "Alt text" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1e3aa17..c81ef23 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -442,7 +442,6 @@ ("alt" "Image upload") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) (text "{% endfor %}")) - (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (div ("class" "w-full card-nest") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index da41608..6be8134 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -574,32 +574,51 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}") - (div - ("class" "card flex flex-wrap gap-2 items-center justify-between") + (details + ("class" "accordion w-full") + (summary + ("class" "card flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2 items-center") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (b + (span + ("class" "date") + (text "{{ upload.created }}")) + (text " ({{ upload.what }})"))) + (div + ("class" "flex gap-2") + (button + ("class" "raised small") + ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") + (text "{{ icon \"view\" }}") + (span + (text "{{ text \"general:action.view\" }}"))) + (button + ("class" "raised small red") + ("onclick" "remove_upload('{{ upload.id }}')") + (text "{{ icon \"x\" }}") + (span + (text "{{ text \"stacks:label.remove\" }}"))))) + (div - ("class" "flex gap-2 items-center") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - ("style" "cursor: pointer") - (text "{{ icon \"file-image\" }}") - (b - (span - ("class" "date") - (text "{{ upload.created }}")) - (text "({{ upload.what }})"))) - (div - ("class" "flex gap-2") - (button - ("class" "lowered small") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - (text "{{ icon \"view\" }}") - (span - (text "{{ text \"general:action.view\" }}"))) - (button - ("class" "lowered small red") - ("onclick" "remove_upload('{{ upload.id }}')") - (text "{{ icon \"x\" }}") - (span - (text "{{ text \"stacks:label.remove\" }}"))))) + ("class" "inner flex flex-col gap-2") + (form + ("class" "card lowered flex flex-col gap-2") + ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") + (div + ("class" "flex flex-col gap-1") + (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) + (textarea + ("id" "alt_{{ upload.id }}") + ("name" "alt") + ("class" "w-full") + ("placeholder" "Alternative text") + (text "{{ upload.alt|safe }}"))) + + (button + (icon (text "check")) + (str (text "general:action.save")))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script (text "globalThis.remove_upload = async (id) => { @@ -621,6 +640,26 @@ res.message, ]); }); + }; + + globalThis.update_upload_alt = async (e, id) => { + e.preventDefault(); + fetch(`/api/v1/uploads/${id}/alt`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + alt: e.target.alt.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))))) (text "{% if config.security.enable_invite_codes -%}") @@ -1508,7 +1547,6 @@ globalThis.render_preset_lis = (preset, id) => { for (const x of preset) { - console.log(id); document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; } } diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index f67cd2c..157d6d3 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -156,9 +156,7 @@ media_theme_pref(); .replaceAll(" year ago", "y"); } - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -198,9 +196,7 @@ media_theme_pref(); .replaceAll(" year ago", "y") .replaceAll("Yesterday", "1d") || ""; - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -1145,8 +1141,15 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", (_, src) => { + self.define("lightbox_open", async (_, src) => { document.getElementById("lightbox_img").src = src; + + const data = await (await fetch(`${src}/data`)).json(); + document + .getElementById("lightbox_img") + .setAttribute("alt", data.payload.alt); + document.getElementById("lightbox_img").title = data.payload.alt; + document.getElementById("lightbox_img_a").href = src; document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index b4b3896..d65ce53 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -152,10 +152,11 @@ pub async fn create_request( } // ... - match data.create_post(props.clone()).await { + let uploads = props.uploads.clone(); + match data.create_post(props).await { Ok(id) => { // write to uploads - for (i, upload_id) in props.uploads.iter().enumerate() { + for (i, upload_id) in uploads.iter().enumerate() { let image = match images.get(i) { Some(img) => img, None => { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d4e19c1..517016b 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -637,6 +637,8 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + .route("/uploads/{id}/data", get(uploads::get_json_request)) + .route("/uploads/{id}/alt", post(uploads::update_alt_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1124,3 +1126,8 @@ pub struct UpdateProductDescription { pub struct UpdateProductPrice { pub price: ProductPrice, } + +#[derive(Deserialize)] +pub struct UpdateUploadAlt { + pub alt: String, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 5812127..6a48dd3 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,13 +1,20 @@ use crate::{ get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, routes::api::v1::{ - CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, }, State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{products::Product, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + oauth, + products::Product, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; pub async fn get_request( Path(id): Path, @@ -44,7 +51,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + JsonMultipart(uploads, req): JsonMultipart, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { @@ -52,21 +59,75 @@ pub async fn create_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_product(Product::new( - user.id, - req.name, - req.description, - req.price, - req.product_type, - )) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Product created".to_string(), - payload: x.id.to_string(), - }), + if uploads.len() > 4 { + return Json( + Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(), + ); + } + + let mut product = Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + ); + + // check sizes + for img in &uploads { + if img.len() > MAXIMUM_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + } + + // create uploads + for _ in 0..uploads.len() { + product.uploads.push( + match data + .create_upload(MediaUpload::new(MediaType::Webp, product.owner)) + .await + { + Ok(u) => u.id, + Err(e) => return Json(e.into()), + }, + ); + } + + let product_uploads = product.uploads.clone(); + match data.create_product(product).await { + Ok(x) => { + // store uploads + for (i, upload_id) in product_uploads.iter().enumerate() { + let image = match uploads.get(i) { + Some(img) => img, + None => { + if let Err(e) = data.delete_upload(*upload_id).await { + return Json(e.into()); + } + + continue; + } + }; + + let upload = match data.get_upload_by_id(*upload_id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 0e7d6ab..02673fe 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -2,7 +2,7 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use pathbufd::PathBufD; -use crate::{get_user_from_token, State}; +use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; @@ -52,6 +52,24 @@ pub async fn get_request( Ok(([("Content-Type", upload.what.mime())], Body::from(bytes))) } +pub async fn get_json_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(upload), + }) +} + pub async fn delete_request( jar: CookieJar, Extension(data): Extension, @@ -72,3 +90,25 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn update_alt_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_upload_alt(id, &user, &props.alt).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Upload updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index 69a2b3d..f2b6b11 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -22,8 +22,14 @@ pub async fn seller_settings_request( } }; + let products = match data.0.get_products_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let lang = get_lang!(jar, data.0); - let context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &products); // return Ok(Html( diff --git a/crates/core/src/database/drivers/sql/create_uploads.sql b/crates/core/src/database/drivers/sql/create_uploads.sql index a563080..57d4037 100644 --- a/crates/core/src/database/drivers/sql/create_uploads.sql +++ b/crates/core/src/database/drivers/sql/create_uploads.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS uploads ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, - what TEXT NOT NULL + what TEXT NOT NULL, + alt TEXT NOT NULL ) diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index e3b2cb5..f669c53 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -16,10 +16,11 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, what: serde_json::from_str(&get!(x->3(String))).unwrap(), + alt: get!(x->4(String)), } } - auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.uploads:{}"); + auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.upload:{}"); /// Get all uploads (paginated). /// @@ -113,12 +114,13 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO uploads VALUES ($1, $2, $3, $4)", + "INSERT INTO uploads VALUES ($1, $2, $3, $4, $5)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.what).unwrap().as_str(), + &data.alt, ] ); @@ -187,4 +189,6 @@ impl DataManager { self.0.1.remove(format!("atto.upload:{}", id)).await; Ok(()) } + + auto_method!(update_upload_alt(&str)@get_upload_by_id:FinePermission::MANAGE_UPLOADS; -> "UPDATE uploads SET alt = $1 WHERE id = $2" --cache-key-tmpl="atto.upload:{}"); } diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 35165c6..9ab2d97 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -44,6 +44,7 @@ pub struct MediaUpload { pub created: usize, pub owner: usize, pub what: MediaType, + pub alt: String, } impl MediaUpload { @@ -54,6 +55,7 @@ impl MediaUpload { created: unix_epoch_timestamp(), owner, what, + alt: String::new(), } } diff --git a/sql_changes/uploads_alt.sql b/sql_changes/uploads_alt.sql new file mode 100644 index 0000000..3d6298c --- /dev/null +++ b/sql_changes/uploads_alt.sql @@ -0,0 +1,2 @@ +ALTER TABLE uploads +ADD COLUMN alt TEXT NOT NULL DEFAULT ''; From 8dfd307919ca1fc1c79bfb4dec8d739e05b745e2 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 16:54:55 -0400 Subject: [PATCH 33/69] fix: stripe notification spam --- .../app/src/routes/api/v1/auth/connections/stripe.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 91560b0..b9964f6 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -274,6 +274,15 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; + if !user.permissions.check(FinePermission::SUPPORTER) { + // the user isn't currently a supporter, there's no reason to send this notification + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } + tracing::info!( "unsubscribe (pay fail) {} (stripe: {})", user.id, @@ -303,7 +312,7 @@ pub async fn stripe_webhook( 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.\n\nIf you've cancelled your subscription, you can safely disregard this." + "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, )) From 959a1259928ab3818a5ccea300978c687a18dd15 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 22:05:59 -0400 Subject: [PATCH 34/69] add: change default avatar --- .../app/src/public/images/default-avatar.svg | 7 ++++ crates/app/src/routes/api/v1/products.rs | 23 ++++++++--- crates/app/src/routes/pages/marketplace.rs | 10 ++++- crates/core/src/database/common.rs | 20 ++++++++++ crates/core/src/database/products.rs | 39 +++++++++++++++++-- crates/core/src/database/stacks.rs | 6 ++- 6 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/app/src/public/images/default-avatar.svg b/crates/app/src/public/images/default-avatar.svg index 00fa7ab..2f92a92 100644 --- a/crates/app/src/public/images/default-avatar.svg +++ b/crates/app/src/public/images/default-avatar.svg @@ -6,4 +6,11 @@ xmlns="http://www.w3.org/2000/svg" > + + + diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 6a48dd3..05d8e9c 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,13 +1,20 @@ use crate::{ get_user_from_token, image::{save_webp_buffer, JsonMultipart}, - routes::api::v1::{ - communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, - UpdateProductName, UpdateProductPrice, + routes::{ + api::v1::{ + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, + }, + pages::PaginatedQuery, }, State, }; -use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ oauth, @@ -31,14 +38,18 @@ pub async fn get_request( } } -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { +pub async fn list_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; - match data.get_products_by_user(user.id).await { + match data.get_products_by_user(user.id, 12, props.page).await { Ok(x) => Json(ApiReturn { ok: true, message: "Success".to_string(), diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index f2b6b11..0de9be7 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -1,6 +1,9 @@ use super::render_error; -use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery, +}; use axum::{ + extract::Query, response::{Html, IntoResponse}, Extension, }; @@ -11,6 +14,7 @@ use tetratto_core::model::Error; pub async fn seller_settings_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -22,14 +26,16 @@ pub async fn seller_settings_request( } }; - let products = match data.0.get_products_by_user(user.id).await { + let products = match data.0.get_products_by_user(user.id, 12, props.page).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &products); + context.insert("page", &props.page); // return Ok(Html( diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index e7cd0ef..e61b565 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -75,6 +75,26 @@ impl DataManager { Ok(res.unwrap()) } + + pub async fn get_table_row_count_where(&self, table: &str, r#where: &str) -> 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, + &format!("SELECT COUNT(*)::int FROM {} {}", table, r#where), + params![], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } } #[macro_export] diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 5127f78..0eab9aa 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -30,7 +30,38 @@ impl DataManager { /// /// # Arguments /// * `id` - the ID of the user to fetch products for - pub async fn get_products_by_user(&self, id: usize) -> Result> { + /// * `batch` + /// * `page` + pub async fn get_products_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 products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + pub async fn get_products_by_user_all(&self, id: usize) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -68,9 +99,11 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let products = self.get_products_by_user(data.owner).await?; + let products = self + .get_table_row_count_where("products", &format!("owner = {}", owner.id)) + .await? as usize; - if products.len() >= Self::MAXIMUM_FREE_PRODUCTS { + if products >= Self::MAXIMUM_FREE_PRODUCTS { return Err(Error::MiscError( "You already have the maximum number of products you can have".to_string(), )); diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 6a64b53..cea2be9 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -165,9 +165,11 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_user(data.owner).await?; + let stacks = self + .get_table_row_count_where("stacks", &format!("owner = {}", owner.id)) + .await? as usize; - if stacks.len() >= Self::MAXIMUM_FREE_STACKS { + if stacks >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( "You already have the maximum number of stacks you can have".to_string(), )); From 70ecc6f96e14380ac1de95c6f46864ee87bfa444 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 15 Jul 2025 00:08:49 -0400 Subject: [PATCH 35/69] add: manage followers page --- crates/app/src/public/html/components.lisp | 4 +- crates/app/src/public/html/misc/requests.lisp | 5 +- .../app/src/public/html/profile/private.lisp | 3 +- .../app/src/public/html/profile/settings.lisp | 64 ++++++++++++++++++- crates/app/src/routes/api/v1/auth/social.rs | 25 ++++++++ crates/app/src/routes/api/v1/mod.rs | 4 ++ crates/app/src/routes/pages/profile.rs | 19 +++++- justfile | 1 + 8 files changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index c81ef23..1f62828 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1798,8 +1798,8 @@ (span ("class" "notification chip") (text "{{ total }} votes")) (text "{% if not poll[2] -%}") (span - ("class" "notification chip") - (text "Expires in ") + ("class" "notification chip flex items-center gap-1") + (text "Expires in") (span ("class" "poll_date") ("data-created" "{{ poll[0].created }}") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index b9700f0..8f4bdb6 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -62,12 +62,15 @@ ("class" "card-nest") (div ("class" "card small flex items-center gap-2") - (text "{{ icon \"user-plus\" }}") + (a + ("href" "/api/v1/auth/user/find/{{ request.id }}") + (text "{{ components::avatar(username=request.id, selector_type=\"id\") }}")) (span (text "{{ text \"requests:label.user_follow_request\" }}"))) (div ("class" "card flex flex-col gap-2") (span + ("class" "flex items-center gap-2") (text "{{ text \"requests:label.user_follow_request_message\" }}")) (div ("class" "card flex flex-wrap w-full secondary gap-2") diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 11740c9..8bd94e9 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -35,6 +35,7 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"auth:action.request_to_follow\" }}"))) + (text "{% if follow_requested -%}") (button ("onclick" "cancel_follow_user(event)") ("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}") @@ -42,7 +43,7 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.cancel_follow_request\" }}"))) - (text "{% else %}") + (text "{%- endif %} {% else %}") (button ("onclick" "toggle_follow_user(event)") ("class" "lowered red") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 6be8134..046f425 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -137,6 +137,12 @@ (text "{{ icon \"rss\" }}") (span (text "{{ text \"auth:label.following\" }}"))) + (a + ("data-tab-button" "account/followers") + ("href" "#/account/followers") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) (a ("data-tab-button" "account/blocks") ("href" "#/account/blocks") @@ -457,7 +463,7 @@ (text "{{ icon \"external-link\" }}") (span (text "{{ text \"requests:action.view_profile\" }}"))))) - (text "{% endfor %}")))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}")))) (script (text "globalThis.toggle_follow_user = async (uid) => { await trigger(\"atto::debounce\", [\"users::follow\"]); @@ -473,6 +479,62 @@ ]); }); };"))) + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "account/followers") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") + (span + (text "{{ text \"general:action.back\" }}"))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for userfollow in followers %} {% set user = userfollow[1] %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2") + (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) + (div + ("class" "flex gap-2") + (button + ("class" "lowered red small") + ("onclick" "force_unfollow_me('{{ user.id }}')") + (text "{{ icon \"user-minus\" }}") + (span + (str (text "stacks:label.remove")))) + (a + ("href" "/@{{ user.username }}") + ("class" "button lowered small") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}")))) + (script + (text "globalThis.force_unfollow_me = async (uid) => { + await trigger(\"atto::debounce\", [\"users::follow\"]); + + fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + };"))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/blocks") diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 88a78b5..17ca6cf 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -154,6 +154,31 @@ pub async fn accept_follow_request( } } +pub async fn force_unfollow_me_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::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await { + match data.delete_userfollow(userfollow.id, &user, false).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User is no longer following you".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } else { + return Json(Error::GeneralNotFound("user follow".to_string()).into()); + } +} + /// Toggle blocking on the given user. pub async fn block_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 517016b..0fbfc40 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -293,6 +293,10 @@ pub fn routes() -> Router { "/auth/user/{id}/follow/accept", post(auth::social::accept_follow_request), ) + .route( + "/auth/user/{id}/force_unfollow_me", + post(auth::social::force_unfollow_me_request), + ) .route("/auth/user/{id}/block", post(auth::social::block_request)) .route( "/auth/user/{id}/block_ip", diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 4d12556..11966a6 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -63,11 +63,27 @@ pub async fn settings_request( } }; + let followers = match data + .0 + .fill_userfollows_with_initiator( + data.0 + .get_userfollows_by_receiver(profile.id, 12, req.page) + .await + .unwrap_or(Vec::new()), + &None, + false, + ) + .await + { + Ok(r) => r, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let following = match data .0 .fill_userfollows_with_receiver( data.0 - .get_userfollows_by_initiator_all(profile.id) + .get_userfollows_by_initiator(profile.id, 12, req.page) .await .unwrap_or(Vec::new()), &None, @@ -138,6 +154,7 @@ pub async fn settings_request( context.insert("page", &req.page); context.insert("uploads", &uploads); context.insert("stacks", &stacks); + context.insert("followers", &followers); context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); diff --git a/justfile b/justfile index a83d0c4..56aa26b 100644 --- a/justfile +++ b/justfile @@ -10,5 +10,6 @@ doc: cargo doc --document-private-items --no-deps test: + sudo pkill -e tetratto cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run From 0256f38e5da6fe3107621db4222d55d67a47b491 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 15 Jul 2025 15:59:05 -0400 Subject: [PATCH 36/69] fix: don't toggle follow when following back --- crates/app/src/public/html/post/post.lisp | 1 - crates/app/src/public/html/profile/base.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 2 +- crates/app/src/public/js/me.js | 8 ++- crates/app/src/routes/api/v1/auth/social.rs | 67 ++++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 4 ++ justfile | 1 - 7 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index b43fc82..81a16a9 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -71,7 +71,6 @@ ("name" "content") ("id" "content") ("placeholder" "content") - ("required" "") ("minlength" "2") ("maxlength" "4096"))) (div diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index c482033..51f8489 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -290,7 +290,7 @@ ]); fetch( - \"/api/v1/auth/user/{{ profile.id }}/follow\", + \"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }, diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 8bd94e9..4654298 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -81,7 +81,7 @@ (script (text "globalThis.toggle_follow_user = async (e) => { await trigger(\"atto::debounce\", [\"users::follow\"]); - fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", { + fetch(\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }) .then((res) => res.json()) diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index e7fa2d6..99fda4e 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -193,9 +193,13 @@ like.classList.add("green"); like.querySelector("svg").classList.add("filled"); - dislike.classList.remove("red"); + if (dislike) { + dislike.classList.remove("red"); + } } else { - dislike.classList.add("red"); + if (dislike) { + dislike.classList.add("red"); + } like.classList.remove("green"); like.querySelector("svg").classList.remove("filled"); diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 17ca6cf..86a601d 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -17,7 +17,7 @@ use tetratto_core::model::{ }; /// Toggle following on the given user. -pub async fn follow_request( +pub async fn toggle_follow_request( jar: CookieJar, Path(id): Path, Extension(data): Extension, @@ -154,6 +154,71 @@ pub async fn accept_follow_request( } } +pub async fn follow_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if data + .get_userfollow_by_initiator_receiver(user.id, id) + .await + .is_ok() + { + return Json(Error::MiscError("Already following user".to_string()).into()); + } else { + match data + .create_userfollow(UserFollow::new(user.id, id), &user, false) + .await + { + Ok(r) => { + if r == FollowResult::Followed { + if let Err(e) = data + .create_notification(Notification::new( + "Somebody has followed you!".to_string(), + format!( + "You have been followed by [@{}](/api/v1/auth/user/find/{}).", + user.username, user.id + ), + id, + )) + .await + { + return Json(e.into()); + }; + + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "User followed".to_string(), + payload: (), + }) + } else { + Json(ApiReturn { + ok: true, + message: "Asked to follow user".to_string(), + payload: (), + }) + } + } + Err(e) => Json(e.into()), + } + } +} + pub async fn force_unfollow_me_request( jar: CookieJar, Path(id): Path, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 0fbfc40..d59fe26 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -285,6 +285,10 @@ pub fn routes() -> Router { .route("/auth/user/{id}/avatar", get(auth::images::avatar_request)) .route("/auth/user/{id}/banner", get(auth::images::banner_request)) .route("/auth/user/{id}/follow", post(auth::social::follow_request)) + .route( + "/auth/user/{id}/follow/toggle", + post(auth::social::toggle_follow_request), + ) .route( "/auth/user/{id}/follow/cancel", post(auth::social::cancel_follow_request), diff --git a/justfile b/justfile index 56aa26b..a83d0c4 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,5 @@ doc: cargo doc --document-private-items --no-deps test: - sudo pkill -e tetratto cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run From b25bda29b8da3bacc9556d464bcfc3061be53d68 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 16 Jul 2025 18:36:56 -0400 Subject: [PATCH 37/69] fix: can_manage_posts permission --- crates/app/src/routes/pages/communities.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 59dc982..901ec75 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -124,12 +124,20 @@ macro_rules! community_context_bools { ) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_POSTS) + } else { + false }; let can_manage_community = if let Some(ref membership) = membership { membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_COMMUNITY) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_COMMUNITIES) + } else { + false }; let can_manage_roles = if let Some(ref membership) = membership { From d1c3643574d846ba2d63994cdef4d3642730c8b8 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 16 Jul 2025 20:18:39 -0400 Subject: [PATCH 38/69] add: user ban_reason --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/macros.rs | 5 ++- crates/app/src/public/html/components.lisp | 6 +++- crates/app/src/public/html/mod/profile.lisp | 29 +++++++++++++++- crates/app/src/public/html/root.lisp | 9 +++-- crates/app/src/routes/api/v1/auth/profile.rs | 34 +++++++++++++++++-- crates/app/src/routes/api/v1/mod.rs | 9 +++++ crates/core/src/database/auth.rs | 5 ++- .../src/database/drivers/sql/create_users.sql | 5 ++- crates/core/src/model/auth.rs | 4 +++ sql_changes/users_ban_reason.sql | 2 ++ 11 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 sql_changes/users_ban_reason.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index de5a411..226b35c 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -198,6 +198,7 @@ version = "1.0.0" "mod_panel:label.associations" = "Associations" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" +"mod_panel:label.ban_reason" = "Ban reason" "mod_panel:action.send" = "Send" "requests:label.requests" = "Requests" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2f5433d..b9faeb6 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,7 +87,10 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + let mut banned_user = tetratto_core::model::auth::User::banned(); + banned_user.ban_reason = ua.ban_reason; + + Some(banned_user) } else { Some(ua) } diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1f62828..53ef6d3 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -102,7 +102,11 @@ ("class" "flush") ("style" "font-weight: 600") ("target" "_top") - (text "{{ self::username(user=user) }}")) + (text "{% if user.permissions|has_banned -%}") + (del ("class" "fade") (text "{{ self::username(user=user) }}")) + (text "{% else %}") + (text "{{ self::username(user=user) }}") + (text "{%- endif %}")) (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}") (span ("title" "Verified") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 408b391..1d1410a 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -84,7 +84,7 @@ const ui = await ns(\"ui\"); const element = document.getElementById(\"mod_options\"); - async function profile_request(do_confirm, path, body) { + globalThis.profile_request = async (do_confirm, path, body) => { if (do_confirm) { if ( !(await trigger(\"atto::confirm\", [ @@ -273,6 +273,33 @@ ("class" "card lowered flex flex-wrap gap-2") (text "{{ components::user_plate(user=invite[0], show_menu=false) }}"))) (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "scale")) + (span + (str (text "mod_panel:label.ban_reason"))))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "event.preventDefault(); profile_request(false, 'ban_reason', { reason: event.target.reason.value || '' })") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (str (text "mod_panel:label.ban_reason"))) + (textarea + ("type" "text") + ("name" "reason") + ("id" "reason") + ("placeholder" "ban reason") + ("minlength" "2") + (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) + (button + ("class" "primary") + (str (text "general:action.save"))))) (div ("class" "card-nest w-full") (div diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 6730dd8..3dd15a0 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -70,8 +70,13 @@ (str (text "general:label.account_banned"))) (div - ("class" "card") - (str (text "general:label.account_banned_body")))))) + ("class" "card flex flex-col gap-2 no_p_margin") + (str (text "general:label.account_banned_body")) + (hr) + (span ("class" "fade") (text "The following reason was provided by a moderator:")) + (div + ("class" "card lowered w-full") + (text "{{ user.ban_reason|markdown|safe }}")))))) ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index bb874fe..5119e0d 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,8 +4,9 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, - UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, + UpdateUserInviteCode, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, + UpdateUserUsername, }, State, }; @@ -424,6 +425,35 @@ pub async fn update_user_secondary_role_request( } } +/// Update the ban reason of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_ban_reason_request( + jar: CookieJar, + Path(id): Path, + 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 !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + match data.update_user_ban_reason(id, &req.reason).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the current user's last seen value. pub async fn seen_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let data = &(data.read().await).0; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d59fe26..588a08e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -322,6 +322,10 @@ pub fn routes() -> Router { "/auth/user/{id}/role/2", post(auth::profile::update_user_secondary_role_request), ) + .route( + "/auth/user/{id}/ban_reason", + post(auth::profile::update_user_ban_reason_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -840,6 +844,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserBanReason { + pub reason: String, +} + #[derive(Deserialize)] pub struct UpdateUserInviteCode { pub invite_code: String, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index b8651ca..65c5307 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -119,6 +119,7 @@ impl DataManager { was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), + ban_reason: get!(x->28(String)), } } @@ -275,7 +276,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, $25, $26, $27, $28)", + "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, $27, $28, $29)", params![ &(data.id as i64), &(data.created as i64), @@ -305,6 +306,7 @@ impl DataManager { &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, &serde_json::to_string(&data.seller_data).unwrap(), + &data.ban_reason ] ); @@ -1001,6 +1003,7 @@ impl DataManager { 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!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $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 0e24753..57b2078 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -20,9 +20,12 @@ CREATE TABLE IF NOT EXISTS users ( stripe_id TEXT NOT NULL, grants TEXT NOT NULL, associated TEXT NOT NULL, + invite_code TEXT NOT NULL, secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, was_purchased INT NOT NULL, - browser_session TEXT NOT NULL + browser_session TEXT NOT NULL, + seller_data TEXT NOT NULL, + ban_reason TEXT NOT NULL ) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 4fb1882..a9dadf1 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -83,6 +83,9 @@ pub struct User { /// Stripe connected account information (for Tetratto marketplace). #[serde(default)] pub seller_data: StripeSellerData, + /// The reason the user was banned. + #[serde(default)] + pub ban_reason: String, } pub type UserConnections = @@ -383,6 +386,7 @@ impl User { was_purchased: false, browser_session: String::new(), seller_data: StripeSellerData::default(), + ban_reason: String::new(), } } diff --git a/sql_changes/users_ban_reason.sql b/sql_changes/users_ban_reason.sql new file mode 100644 index 0000000..15f7b6f --- /dev/null +++ b/sql_changes/users_ban_reason.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN ban_reason TEXT NOT NULL DEFAULT ''; From f802a1c8abb0a150e8e4b2ed90cf33a6b92e5f00 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 00:44:05 -0400 Subject: [PATCH 39/69] chore: bump deps --- Cargo.lock | 115 ++++++++++++++++++++++++++++----------- crates/app/Cargo.toml | 8 +-- crates/app/src/assets.rs | 4 +- crates/core/Cargo.toml | 4 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 6 files changed, 94 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c90535f..8ea8eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" [[package]] name = "ammonia" -version = "4.1.0" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" +checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f" dependencies = [ "cssparser", "html5ever", @@ -337,12 +337,6 @@ dependencies = [ "tokio-postgres", ] -[[package]] -name = "bberry" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458" - [[package]] name = "bit_field" version = "0.10.2" @@ -1124,12 +1118,11 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "mac", "markup5ever", "match_token", ] @@ -1576,6 +1569,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1750,9 +1754,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.16.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", "tendril", @@ -1761,9 +1765,9 @@ dependencies = [ [[package]] name = "match_token" -version = "0.1.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", @@ -1871,6 +1875,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "nanoneo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892" + [[package]] name = "native-tls" version = "0.2.14" @@ -2639,9 +2649,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64 0.22.1", "bytes", @@ -2925,6 +2935,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3191,7 +3210,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] @@ -3255,13 +3274,13 @@ dependencies = [ "async-stripe", "axum", "axum-extra", - "bberry", "cf-turnstile", "contrasted", "emojis", "futures-util", "image", "mime_guess", + "nanoneo", "pathbufd", "regex", "reqwest", @@ -3297,7 +3316,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", - "toml", + "toml 0.9.2", "totp-rs", ] @@ -3307,7 +3326,7 @@ version = "11.0.0" dependencies = [ "pathbufd", "serde", - "toml", + "toml 0.9.2", ] [[package]] @@ -3444,16 +3463,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", + "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -3548,11 +3569,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3562,6 +3598,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3570,17 +3615,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", - "toml_write", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_parser" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "totp-rs" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a54b7a8..f9eb7d4 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -16,15 +16,15 @@ tower-http = { version = "0.6.6", features = [ "set-header", ] } axum = { version = "0.8.4", features = ["macros", "ws"] } -tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } -ammonia = "4.1.0" +ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" -reqwest = { version = "0.12.20", features = ["json", "stream"] } +reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" mime_guess = "2.0.5" @@ -41,4 +41,4 @@ async-stripe = { version = "0.41.0", features = [ ] } emojis = "0.7.0" webp = "0.3.0" -bberry = "0.2.0" +nanoneo = "0.2.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index ad0f49b..aba7de9 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -1,4 +1,4 @@ -use bberry::{ +use nanoneo::{ core::element::{Element, Render}, text, read_param, }; @@ -240,7 +240,7 @@ pub(crate) async fn replace_in_html( input.to_string() } else { let start = SystemTime::now(); - let parsed = bberry::parse(input); + let parsed = nanoneo::parse(input); println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros()); if let Some(plugins) = plugins { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ffbd2c2..72d6481 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,12 +6,12 @@ edition = "2024" [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" +toml = "0.9.2" tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.20", features = ["json"] } +reqwest = { version = "0.12.22", features = ["json"] } bitflags = "2.9.1" async-recursion = "1.1.1" md-5 = "0.10.6" diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 9544981..d7661c2 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -9,4 +9,4 @@ license.workspace = true [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" +toml = "0.9.2" diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 633984b..f21f611 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -7,7 +7,7 @@ repository.workspace = true license.workspace = true [dependencies] -ammonia = "4.1.0" +ammonia = "4.1.1" chrono = "0.4.41" markdown = "1.0.0" hex_fmt = "0.3.0" From 5c520f4308ae00daecaea5151f6673be7dfdef61 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 01:30:27 -0400 Subject: [PATCH 40/69] add: app_data table --- crates/core/src/database/app_data.rs | 163 ++++++++++++++++++ crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_app_data.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/apps.rs | 76 ++++++++ 6 files changed, 249 insertions(+) create mode 100644 crates/core/src/database/app_data.rs create mode 100644 crates/core/src/database/drivers/sql/create_app_data.sql diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs new file mode 100644 index 0000000..b614e85 --- /dev/null +++ b/crates/core/src/database/app_data.rs @@ -0,0 +1,163 @@ +use oiseau::cache::Cache; +use crate::model::{apps::AppData, auth::User, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; + +use oiseau::PostgresRow; + +use oiseau::{execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`AppData`] from an SQL row. + pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { + AppData { + id: get!(x->0(i64)) as usize, + owner: get!(x->1(i64)) as usize, + app: get!(x->2(i64)) as usize, + key: get!(x->3(String)), + value: get!(x->4(String)), + } + } + + auto_method!(get_app_data_by_id(usize as i64)@get_app_data_from_row -> "SELECT * FROM app_data WHERE id = $1" --name="app_data" --returns=AppData --cache-key-tmpl="atto.app_data:{}"); + + /// Get all app_data by app. + /// + /// # Arguments + /// * `id` - the ID of the app to fetch app_data for + pub async fn get_app_data_by_app(&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 app_data WHERE app = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all app_data by owner. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch app_data for + pub async fn get_app_data_by_owner(&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 app_data WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_APP_DATA: usize = 5; + const MAXIMUM_DATA_SIZE: usize = 205_000; + + /// Create a new app_data in the database. + /// + /// # Arguments + /// * `data` - a mock [`AppData`] object to insert + pub async fn create_app_data(&self, data: AppData) -> Result { + let app = self.get_app_by_id(data.app).await?; + + // check values + if data.key.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.key.len() > 32 { + return Err(Error::DataTooLong("key".to_string())); + } + + if data.value.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { + return Err(Error::DataTooLong("key".to_string())); + } + + // check number of app_data + let owner = self.get_user_by_id(app.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let app_data = self + .get_table_row_count_where("app_data", &format!("app = {}", data.app)) + .await? as usize; + + if app_data >= Self::MAXIMUM_FREE_APP_DATA { + return Err(Error::MiscError( + "You already have the maximum number of app_data 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 app_data VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.owner as i64), + &(data.app as i64), + &data.key, + &data.value + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_app_data(&self, id: usize, user: &User) -> Result<()> { + let app_data = self.get_app_data_by_id(id).await?; + let app = self.get_app_by_id(app_data.app).await?; + + // check user permission + if ((user.id != app.owner) | (user.id != app_data.owner)) + && !user.permissions.check(FinePermission::MANAGE_APPS) + { + 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 app_data WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.app_data:{}", id)).await; + Ok(()) + } + + auto_method!(update_app_data_key(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_value(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index e61b565..075d0f7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -43,6 +43,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 7bee30a..2535f43 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -30,3 +30,4 @@ pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_co pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); +pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); diff --git a/crates/core/src/database/drivers/sql/create_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql new file mode 100644 index 0000000..28a8379 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS app_data ( + id BIGINT NOT NULL PRIMARY KEY, + owner BIGINT NOT NULL, + app BIGINT NOT NULL, + k TEXT NOT NULL, + v TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 730c54a..a4cdb3d 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +mod app_data; mod apps; mod audit_log; mod auth; diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 713df48..8f90899 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::oauth::AppScope; @@ -100,3 +102,77 @@ impl ThirdPartyApp { } } } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppData { + pub id: usize, + pub owner: usize, + pub app: usize, + pub key: String, + pub value: String, +} + +impl AppData { + /// Create a new [`AppData`]. + pub fn new(owner: usize, app: usize, key: String, value: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + owner, + app, + key, + value, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectQuery { + Like(String, String), +} + +impl Display for AppDataSelectQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::Like(k, v) => format!("%\"{k}\":\"{v}\"%"), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectMode { + /// Select a single row. + One, + /// Select multiple rows at once. + /// + /// `(order by top level key, limit, offset)` + Many(String, usize, usize), +} + +impl Display for AppDataSelectMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::One => "LIMIT 1".to_string(), + Self::Many(order_by_top_level_key, limit, offset) => { + format!( + "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {limit} OFFSET {offset}" + ) + } + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppDataQuery { + pub app: usize, + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +impl Display for AppDataQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "SELECT * FROM app_data WHERE app = {} AND v LIKE $1 {}", + self.app, self.mode + )) + } +} From f423daf2fc35f5fed20a79e7b04b9ac7f9fae354 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:34:10 -0400 Subject: [PATCH 41/69] add: app_data api --- crates/app/src/langs/en-US.toml | 3 + crates/app/src/macros.rs | 17 +++ crates/app/src/public/css/style.css | 18 ++- .../public/html/communities/create_post.lisp | 1 - .../app/src/public/html/communities/list.lisp | 1 - .../src/public/html/communities/question.lisp | 1 - .../src/public/html/communities/search.lisp | 1 - .../src/public/html/communities/settings.lisp | 5 - crates/app/src/public/html/components.lisp | 2 - crates/app/src/public/html/developer/app.lisp | 74 ++++++++-- .../app/src/public/html/developer/home.lisp | 1 - crates/app/src/public/html/forge/home.lisp | 1 - crates/app/src/public/html/journals/app.lisp | 1 - .../src/public/html/littleweb/domains.lisp | 1 - .../src/public/html/littleweb/services.lisp | 1 - .../src/public/html/misc/achievements.lisp | 2 +- crates/app/src/public/html/misc/requests.lisp | 1 - .../app/src/public/html/mod/file_report.lisp | 1 - crates/app/src/public/html/mod/profile.lisp | 2 +- crates/app/src/public/html/mod/warnings.lisp | 1 - crates/app/src/public/html/post/post.lisp | 2 - .../app/src/public/html/profile/settings.lisp | 6 - crates/app/src/public/html/stacks/list.lisp | 1 - crates/app/src/public/html/stacks/manage.lisp | 1 - crates/app/src/routes/api/v1/app_data.rs | 136 ++++++++++++++++++ crates/app/src/routes/api/v1/apps.rs | 31 ++++ crates/app/src/routes/api/v1/mod.rs | 32 ++++- crates/app/src/routes/pages/developer.rs | 6 +- crates/core/src/database/app_data.rs | 65 +++++---- crates/core/src/database/apps.rs | 26 +++- crates/core/src/database/channels.rs | 1 - crates/core/src/database/mod.rs | 2 +- crates/core/src/model/apps.rs | 38 ++++- crates/core/src/model/mod.rs | 2 + crates/core/src/model/permissions.rs | 1 + crates/shared/src/hash.rs | 12 ++ sql_changes/apps_api_key.sql | 2 + sql_changes/apps_data_used.sql | 2 + 38 files changed, 410 insertions(+), 91 deletions(-) create mode 100644 crates/app/src/routes/api/v1/app_data.rs create mode 100644 sql_changes/apps_api_key.sql create mode 100644 sql_changes/apps_data_used.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 226b35c..243b70f 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -253,6 +253,9 @@ version = "1.0.0" "developer:label.manage_scopes" = "Manage scopes" "developer:label.scopes" = "Scopes" "developer:label.guides_and_help" = "Guides & help" +"developer:label.secret_key" = "Secret key" +"developer:label.roll_key" = "Roll key" +"developer:label.data_usage" = "Data usage" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index b9faeb6..0edafbb 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -419,3 +419,20 @@ macro_rules! ignore_users_gen { .concat() }; } + +#[macro_export] +macro_rules! get_app_from_key { + ($db:ident, $jar:ident) => { + if let Some(token) = $jar.get("Atto-Secret-Key") { + match $db + .get_app_by_api_key(&token.to_string().replace("Atto-Secret-Key=", "")) + .await + { + Ok(x) => Some(x), + Err(_) => None, + } + } else { + None + } + }; +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f8a4a8a..ab6a09d 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -404,7 +404,7 @@ select:focus { .poll_bar { background-color: var(--color-primary); border-radius: var(--radius); - height: 25px; + height: 24px; } .poll_option { @@ -413,6 +413,22 @@ select:focus { overflow-wrap: anywhere; } +.progress_bar { + background: var(--color-super-lowered); + border-radius: var(--circle); + position: relative; + overflow: hidden; + height: 14px; +} + +.progress_bar .poll_bar { + border-radius: var(--circle); + height: 14px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + position: absolute; +} + input[type="checkbox"] { --color: #c9b1bc; appearance: none; diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 0b7cf19..1c8ce2c 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -159,7 +159,6 @@ (text "{{ icon \"notepad-text-dashed\" }}")) (text "{%- endif %} {%- endif %}") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 186d4f9..cf1cb48 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}") (div diff --git a/crates/app/src/public/html/communities/question.lisp b/crates/app/src/public/html/communities/question.lisp index 975e055..4468d25 100644 --- a/crates/app/src/public/html/communities/question.lisp +++ b/crates/app/src/public/html/communities/question.lisp @@ -39,7 +39,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/communities/search.lisp b/crates/app/src/public/html/communities/search.lisp index 642d214..a985e91 100644 --- a/crates/app/src/public/html/communities/search.lisp +++ b/crates/app/src/public/html/communities/search.lisp @@ -28,7 +28,6 @@ ("maxlength" "32") ("value" "{{ text }}"))) (button - ("class" "primary") (text "{{ text \"dialog:action.continue\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 4213cb9..fa5ddcf 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -135,7 +135,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -190,7 +189,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}")))) (div ("class" "card-nest") @@ -213,7 +211,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -245,7 +242,6 @@ ("required" "") ("minlength" "18"))) (button - ("class" "primary") (text "{{ text \"communities:action.select\" }}"))))) (div ("class" "card flex flex-col gap-2 w-full") @@ -296,7 +292,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% for channel in channels %}") (div diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 53ef6d3..a83ad44 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -779,7 +779,6 @@ (div ("class" "flex gap-2") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (text "{% if drawing_enabled -%}") @@ -1879,7 +1878,6 @@ ("id" "join_or_leave") (text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}") (button - ("class" "primary") ("onclick" "join_community()") (text "{{ icon \"circle-plus\" }}") (span diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 2850ef5..d01e9de 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -10,11 +10,27 @@ (div ("id" "manage_fields") ("class" "card lowered flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "database")) + (b (str (text "developer:label.data_usage")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit.")) + (text "{% set percentage = (data_limit / app.data_used) * 100 %}") + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))) + (div + ("class" "w-full flex justify-between items-center") + (span (text "{{ app.data_used|filesizeformat }}")) + (span (text "{{ data_limit|filesizeformat }}"))))) (text "{% if is_helper -%}") (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "infinity")) (b (str (text "developer:label.change_quota_status")))) (div ("class" "card") @@ -32,7 +48,8 @@ (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "pencil")) (b (str (text "developer:label.change_title")))) (form ("class" "card flex flex-col gap-2") @@ -50,14 +67,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "house")) (b (str (text "developer:label.change_homepage")))) (form ("class" "card flex flex-col gap-2") @@ -75,14 +92,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "goal")) (b (str (text "developer:label.change_redirect")))) (form ("class" "card flex flex-col gap-2") @@ -100,14 +117,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "telescope")) (b (str (text "developer:label.manage_scopes")))) (form ("class" "card flex flex-col gap-2") @@ -140,10 +157,22 @@ (icon (text "external-link")) (text "Docs")))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span - (text "{{ text \"general:action.save\" }}")))))) + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "rotate-ccw-key")) + (b (str (text "developer:label.secret_key")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "Your app's API key can only be seen once, so don't lose it. Rolling the key will invalidate the old one.")) + (pre (code ("id" "new_key"))) + (button + ("onclick" "roll_key()") + (str (text "developer:label.roll_key")))))) (div ("class" "card flex flex-col gap-2") (ul @@ -323,6 +352,31 @@ }); }; + globalThis.roll_key = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/roll\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + document.getElementById(\"new_key\").innerText = res.payload; + } + }); + }; + globalThis.delete_app = async () => { if ( !(await trigger(\"atto::confirm\", [ diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index aefd55d..fb00c7e 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -57,7 +57,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) ; app listing diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index a83c545..c295066 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -30,7 +30,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% else %}") (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index c6ed985..71fbd4d 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -253,7 +253,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))) diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index e3a6c10..c79ab3e 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -59,7 +59,6 @@ (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) (text "{%- endfor %}"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (details diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 3399685..261b006 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -45,7 +45,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp index 429c924..93f895e 100644 --- a/crates/app/src/public/html/misc/achievements.lisp +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -17,7 +17,7 @@ (p (text "You'll find out what each achievement is when you get it, so look around!")) (hr) (span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%")) - (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))) + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 8f4bdb6..f49b6f4 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -132,7 +132,6 @@ (text "{{ text \"auth:action.ip_block\" }}"))) (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{% endfor %}"))) diff --git a/crates/app/src/public/html/mod/file_report.lisp b/crates/app/src/public/html/mod/file_report.lisp index 39891a7..39f7669 100644 --- a/crates/app/src/public/html/mod/file_report.lisp +++ b/crates/app/src/public/html/mod/file_report.lisp @@ -28,7 +28,6 @@ ("required" "") ("minlength" "16"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (script diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 1d1410a..6f07c93 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -298,7 +298,6 @@ ("minlength" "2") (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (button - ("class" "primary") (str (text "general:action.save"))))) (div ("class" "card-nest w-full") @@ -396,6 +395,7 @@ MANAGE_DOMAINS: 1 << 2, MANAGE_SERVICES: 1 << 3, MANAGE_PRODUCTS: 1 << 4, + DEVELOPER_PASS: 1 << 5, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/app/src/public/html/mod/warnings.lisp b/crates/app/src/public/html/mod/warnings.lisp index 35c384e..203fa3e 100644 --- a/crates/app/src/public/html/mod/warnings.lisp +++ b/crates/app/src/public/html/mod/warnings.lisp @@ -37,7 +37,6 @@ ("minlength" "2") ("maxlength" "4096"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 81a16a9..8013461 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -80,7 +80,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (text "{%- endif %}") (div @@ -279,7 +278,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (button - ("class" "primary") (text "{{ text \"general:action.save\" }}"))))) (script (text "async function edit_post_from_form(e) { diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 046f425..c4169a6 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -276,7 +276,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -305,7 +304,6 @@ ("minlength" "6") ("autocomplete" "off"))) (button - ("class" "primary") (text "{{ icon \"trash\" }}") (span (text "{{ text \"general:action.delete\" }}"))))) @@ -419,7 +417,6 @@ ("minlength" "6") ("autocomplete" "off"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))))) @@ -908,7 +905,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -936,7 +932,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -1054,7 +1049,6 @@ ("class" "card w-full flex flex-wrap gap-2") ("ui_ident" "import_export") (button - ("class" "primary") ("onclick" "import_theme_settings()") (text "{{ icon \"upload\" }}") (span diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 50246ef..6381881 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index 450c027..ecd892c 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -114,7 +114,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs new file mode 100644 index 0000000..c074c8f --- /dev/null +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -0,0 +1,136 @@ +use crate::{ + get_app_from_key, + routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, + State, +}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ + apps::{AppData, AppDataQuery}, + ApiReturn, Error, +}; + +pub async fn query_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // check size + let new_size = app.data_used + req.value.len(); + if new_size > AppData::user_limit(&owner) { + return Json(Error::AppHitStorageLimit.into()); + } + + // ... + match data + .create_app_data(AppData::new(app.id, req.key, req.value)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "App created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_value_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // check size + let size_without = app.data_used - app_data.value.len(); + let new_size = size_without + req.value.len(); + + if new_size > AppData::user_limit(&owner) { + return Json(Error::AppHitStorageLimit.into()); + } + + // ... + match data.update_app_data_value(id, &req.value).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_app_from_key!(data, jar).is_none() { + return Json(Error::NotAllowed.into()); + } + + match data.delete_app_data(id).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index c4e2809..2b1a314 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -239,3 +239,34 @@ pub async fn grant_request( Err(e) => Json(e.into()), } } + +pub async fn roll_api_key_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let app = match data.get_app_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != app.owner { + return Json(Error::NotAllowed.into()); + } + + let new_key = tetratto_shared::hash::random_id_salted_len(32); + match data.update_app_api_key(id, &new_key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: Some(new_key), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 588a08e..8b276dd 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,3 +1,4 @@ +pub mod app_data; pub mod apps; pub mod auth; pub mod channels; @@ -19,9 +20,9 @@ use axum::{ routing::{any, delete, get, post, put}, Router, }; -use serde::Deserialize; +use serde::{Deserialize}; use tetratto_core::model::{ - apps::AppQuota, + apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota}, auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, @@ -32,7 +33,7 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, - products::{ProductType, ProductPrice}, + products::{ProductPrice, ProductType}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -419,6 +420,7 @@ pub fn routes() -> Router { ) // apps .route("/apps", post(apps::create_request)) + .route("/apps/{id}", delete(apps::delete_request)) .route("/apps/{id}/title", post(apps::update_title_request)) .route("/apps/{id}/homepage", post(apps::update_homepage_request)) .route("/apps/{id}/redirect", post(apps::update_redirect_request)) @@ -427,8 +429,13 @@ pub fn routes() -> Router { post(apps::update_quota_status_request), ) .route("/apps/{id}/scopes", post(apps::update_scopes_request)) - .route("/apps/{id}", delete(apps::delete_request)) .route("/apps/{id}/grant", post(apps::grant_request)) + .route("/apps/{id}/roll", post(apps::roll_api_key_request)) + // app data + .route("/app_data", post(app_data::create_request)) + .route("/app_data/query", post(app_data::query_request)) + .route("/app_data/{id}", delete(app_data::delete_request)) + .route("/app_data/{id}/value", post(app_data::update_value_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -1148,3 +1155,20 @@ pub struct UpdateProductPrice { pub struct UpdateUploadAlt { pub alt: String, } + +#[derive(Deserialize)] +pub struct UpdateAppDataValue { + pub value: String, +} + +#[derive(Deserialize)] +pub struct InsertAppData { + pub key: String, + pub value: String, +} + +#[derive(Deserialize)] +pub struct QueryAppData { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index 76d94fe..de4c4e1 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -6,7 +6,7 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, Error}; +use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error}; /// `/developer` pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -62,9 +62,13 @@ pub async fn app_request( )); } + let data_limit = AppData::user_limit(&user); + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("app", &app); + context.insert("data_limit", &data_limit); // return Ok(Html(data.1.render("developer/app.html", &context).unwrap())) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index b614e85..6ea4f63 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -1,17 +1,17 @@ use oiseau::cache::Cache; -use crate::model::{apps::AppData, auth::User, permissions::FinePermission, Error, Result}; +use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery}; +use crate::model::{apps::AppData, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +pub const FREE_DATA_LIMIT: usize = 512_000; +pub const PASS_DATA_LIMIT: usize = 5_242_880; impl DataManager { /// Get a [`AppData`] from an SQL row. pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { AppData { id: get!(x->0(i64)) as usize, - owner: get!(x->1(i64)) as usize, app: get!(x->2(i64)) as usize, key: get!(x->3(String)), value: get!(x->4(String)), @@ -48,24 +48,39 @@ impl DataManager { /// /// # Arguments /// * `id` - the ID of the user to fetch app_data for - pub async fn get_app_data_by_owner(&self, id: usize) -> Result> { + pub async fn query_app_data(&self, query: AppDataQuery) -> 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 app_data WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_app_data_from_row(x) } + let query_str = query.to_string().replace( + "%q%", + &match query.query { + AppDataSelectQuery::Like(_, _) => format!("v LIKE $1"), + }, ); - if res.is_err() { - return Err(Error::GeneralNotFound("app_data".to_string())); - } + let res = match query.mode { + AppDataSelectMode::One => AppDataQueryResult::One( + match query_row!(&conn, &query_str, params![&query.query.to_string()], |x| { + Ok(Self::get_app_data_from_row(x)) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::Many(_, _, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + }; - Ok(res.unwrap()) + Ok(res) } const MAXIMUM_FREE_APP_DATA: usize = 5; @@ -114,10 +129,9 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO app_data VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO app_data VALUES ($1, $2, $3, $4)", params![ &(data.id as i64), - &(data.owner as i64), &(data.app as i64), &data.key, &data.value @@ -131,18 +145,7 @@ impl DataManager { Ok(data) } - pub async fn delete_app_data(&self, id: usize, user: &User) -> Result<()> { - let app_data = self.get_app_data_by_id(id).await?; - let app = self.get_app_by_id(app_data.app).await?; - - // check user permission - if ((user.id != app.owner) | (user.id != app_data.owner)) - && !user.permissions.check(FinePermission::MANAGE_APPS) - { - return Err(Error::NotAllowed); - } - - // ... + pub async fn delete_app_data(&self, id: usize) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -158,6 +161,6 @@ impl DataManager { Ok(()) } - auto_method!(update_app_data_key(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); - auto_method!(update_app_data_value(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_key(&str) -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_value(&str) -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index f24b427..c6a4f42 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -7,10 +7,7 @@ use crate::model::{ Error, Result, }; use crate::{auto_method, DataManager}; - -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`ThirdPartyApp`] from an SQL row. @@ -26,10 +23,13 @@ impl DataManager { banned: get!(x->7(i32)) as i8 == 1, grants: get!(x->8(i32)) as usize, scopes: serde_json::from_str(&get!(x->9(String))).unwrap(), + api_key: get!(x->10(String)), + data_used: get!(x->11(i32)) as usize, } } auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); /// Get all apps by user. /// @@ -90,7 +90,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", params![ &(data.id as i64), &(data.created as i64), @@ -102,6 +102,8 @@ impl DataManager { &{ if data.banned { 1 } else { 0 } }, &(data.grants as i32), &serde_json::to_string(&data.scopes).unwrap(), + &data.api_key, + &(data.data_used as i32) ] ); @@ -133,6 +135,19 @@ impl DataManager { } self.0.1.remove(format!("atto.app:{}", id)).await; + + // remove data + let res = execute!( + &conn, + "DELETE FROM app_data WHERE app = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... Ok(()) } @@ -141,6 +156,7 @@ impl DataManager { 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:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --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 ee42d4b..c1e9938 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -5,7 +5,6 @@ use crate::model::{ communities_permissions::CommunityPermission, channels::Channel, }; use crate::{auto_method, DataManager}; - use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index a4cdb3d..80b77a1 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,4 +1,4 @@ -mod app_data; +pub mod app_data; mod apps; mod audit_log; mod auth; diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 8f90899..b48b0ed 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -2,7 +2,10 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -use crate::model::oauth::AppScope; +use crate::{ + database::app_data::{FREE_DATA_LIMIT, PASS_DATA_LIMIT}, + model::{auth::User, oauth::AppScope, permissions::SecondaryPermission}, +}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum AppQuota { @@ -83,6 +86,10 @@ pub struct ThirdPartyApp { /// /// Your app should handle informing users when scopes change. pub scopes: Vec, + /// The app's secret API key (for app_data access). + pub api_key: String, + /// The number of bytes the app's app_data rows are using. + pub data_used: usize, } impl ThirdPartyApp { @@ -99,6 +106,8 @@ impl ThirdPartyApp { banned: false, grants: 0, scopes: Vec::new(), + api_key: String::new(), + data_used: 0, } } } @@ -106,7 +115,6 @@ impl ThirdPartyApp { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppData { pub id: usize, - pub owner: usize, pub app: usize, pub key: String, pub value: String, @@ -114,15 +122,26 @@ pub struct AppData { impl AppData { /// Create a new [`AppData`]. - pub fn new(owner: usize, app: usize, key: String, value: String) -> Self { + pub fn new(app: usize, key: String, value: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - owner, app, key, value, } } + + /// Get the data limit of a given user. + pub fn user_limit(user: &User) -> usize { + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + PASS_DATA_LIMIT + } else { + FREE_DATA_LIMIT + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -154,7 +173,8 @@ impl Display for AppDataSelectMode { Self::One => "LIMIT 1".to_string(), Self::Many(order_by_top_level_key, limit, offset) => { format!( - "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {limit} OFFSET {offset}" + "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {} OFFSET {offset}", + if *limit > 1024 { 1024 } else { *limit } ) } }) @@ -171,8 +191,14 @@ pub struct AppDataQuery { impl Display for AppDataQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&format!( - "SELECT * FROM app_data WHERE app = {} AND v LIKE $1 {}", + "SELECT * FROM app_data WHERE app = {} AND %q% {}", self.app, self.mode )) } } + +#[derive(Serialize, Deserialize)] +pub enum AppDataQueryResult { + One(AppData), + Many(Vec), +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index b86ebfa..7d7f19e 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -51,6 +51,7 @@ pub enum Error { QuestionsDisabled, RequiresSupporter, DrawingsDisabled, + AppHitStorageLimit, Unknown, } @@ -75,6 +76,7 @@ impl Display for Error { Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), + Self::AppHitStorageLimit => "This app has already hit its storage limit, or will do so if this data is processed.".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index bbaca18..796b9f1 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -177,6 +177,7 @@ bitflags! { const MANAGE_DOMAINS = 1 << 2; const MANAGE_SERVICES = 1 << 3; const MANAGE_PRODUCTS = 1 << 4; + const DEVELOPER_PASS = 1 << 5; const _ = !0; } diff --git a/crates/shared/src/hash.rs b/crates/shared/src/hash.rs index f346861..a267bc4 100644 --- a/crates/shared/src/hash.rs +++ b/crates/shared/src/hash.rs @@ -33,6 +33,18 @@ pub fn salt() -> String { .collect() } +pub fn salt_len(len: usize) -> String { + rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + pub fn random_id() -> String { hash(uuid()) } + +pub fn random_id_salted_len(len: usize) -> String { + hash(uuid() + &salt_len(len)) +} diff --git a/sql_changes/apps_api_key.sql b/sql_changes/apps_api_key.sql new file mode 100644 index 0000000..a5c35ad --- /dev/null +++ b/sql_changes/apps_api_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps +ADD COLUMN api_key TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/apps_data_used.sql b/sql_changes/apps_data_used.sql new file mode 100644 index 0000000..77202a0 --- /dev/null +++ b/sql_changes/apps_data_used.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps +ADD COLUMN data_used INT NOT NULL DEFAULT 0; From 440ca81c25b4c2e9fd9c8efcc2045ae8ab05b290 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:46:20 -0400 Subject: [PATCH 42/69] fix: properly update app usage --- crates/app/src/routes/api/v1/app_data.rs | 26 ++++++++++++++++++++++-- crates/core/src/database/apps.rs | 1 + 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index c074c8f..b4c0d03 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -61,6 +61,10 @@ pub async fn create_request( } // ... + if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + return Json(e.into()); + } + match data .create_app_data(AppData::new(app.id, req.key, req.value)) .await @@ -105,6 +109,10 @@ pub async fn update_value_request( } // ... + if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + return Json(e.into()); + } + match data.update_app_data_value(id, &req.value).await { Ok(_) => Json(ApiReturn { ok: true, @@ -121,8 +129,22 @@ pub async fn delete_request( Path(id): Path, ) -> impl IntoResponse { let data = &(data.read().await).0; - if get_app_from_key!(data, jar).is_none() { - return Json(Error::NotAllowed.into()); + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // ... + if let Err(e) = data + .update_app_data_used(app.id, (app.data_used - app_data.value.len()) as i32) + .await + { + return Json(e.into()); } match data.delete_app_data(id).await { diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index c6a4f42..4915907 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -157,6 +157,7 @@ impl DataManager { 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:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_data_used(i32) -> "UPDATE apps SET data_used = $1 WHERE id = $2" --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); From 9f61d9ce6abb9c3dcf41578e9f3322c79d126ce2 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:51:56 -0400 Subject: [PATCH 43/69] fix: post creation form --- crates/app/src/public/html/communities/create_post.lisp | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 1c8ce2c..0b7cf19 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -159,6 +159,7 @@ (text "{{ icon \"notepad-text-dashed\" }}")) (text "{%- endif %} {%- endif %}") (button + ("class" "primary") (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script From 22aea48cc596a4b5e9ca78f131e660d033694204 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 00:14:52 -0400 Subject: [PATCH 44/69] add: better app data queries --- crates/app/src/macros.rs | 9 +- crates/app/src/public/html/developer/app.lisp | 2 +- .../app/src/public/html/developer/home.lisp | 5 +- .../app/src/public/html/developer/link.lisp | 7 ++ crates/app/src/routes/api/v1/app_data.rs | 86 +++++++++++++++---- crates/app/src/routes/api/v1/mod.rs | 4 +- crates/core/src/database/app_data.rs | 59 ++++++++++--- crates/core/src/database/apps.rs | 29 ++++--- .../database/drivers/sql/create_app_data.sql | 1 - .../src/database/drivers/sql/create_apps.sql | 3 +- crates/core/src/model/apps.rs | 28 ++++-- sql_changes/apps_data_used.sql | 2 +- 12 files changed, 175 insertions(+), 60 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 0edafbb..fd141ea 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -422,12 +422,9 @@ macro_rules! ignore_users_gen { #[macro_export] macro_rules! get_app_from_key { - ($db:ident, $jar:ident) => { - if let Some(token) = $jar.get("Atto-Secret-Key") { - match $db - .get_app_by_api_key(&token.to_string().replace("Atto-Secret-Key=", "")) - .await - { + ($db:ident, $headers:ident) => { + if let Some(token) = $headers.get("Atto-Secret-Key") { + match $db.get_app_by_api_key(token.to_str().unwrap()).await { Ok(x) => Some(x), Err(_) => None, } diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index d01e9de..6795001 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -19,7 +19,7 @@ (div ("class" "card flex flex-col gap-2") (p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit.")) - (text "{% set percentage = (data_limit / app.data_used) * 100 %}") + (text "{% set percentage = (app.data_used / data_limit) * 100 %}") (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))) (div ("class" "w-full flex justify-between items-center") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index fb00c7e..d96be6f 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -47,13 +47,12 @@ ("class" "flex flex-col gap-1") (label ("for" "title") - (text "{{ text \"developer:label.redirect\" }}")) + (text "{{ text \"developer:label.redirect\" }} (optional)")) (input ("type" "url") ("name" "redirect") ("id" "redirect") ("placeholder" "redirect URL") - ("required" "") ("minlength" "2") ("maxlength" "32"))) (button @@ -125,7 +124,7 @@ body: JSON.stringify({ title: e.target.title.value, homepage: e.target.homepage.value, - redirect: e.target.redirect.value, + redirect: e.target.redirect.value || \"\", }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp index 5d46c87..2c94309 100644 --- a/crates/app/src/public/html/developer/link.lisp +++ b/crates/app/src/public/html/developer/link.lisp @@ -39,6 +39,13 @@ (str (text "dialog:action.cancel"))))))) (script (text "setTimeout(() => { + // {% if app.redirect|length == 0 %} + alert(\"App has an invalid redirect. Please contact the owner for help.\"); + window.close(); + return; + // {% endif %} + + // ... globalThis.authorize = async (event) => { if ( !(await trigger(\"atto::confirm\", [ diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index b4c0d03..c2983f1 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -3,20 +3,19 @@ use crate::{ routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, State, }; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; use tetratto_core::model::{ - apps::{AppData, AppDataQuery}, + apps::{AppData, AppDataQuery, AppDataQueryResult}, ApiReturn, Error, }; pub async fn query_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -39,12 +38,12 @@ pub async fn query_request( } pub async fn create_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -61,7 +60,7 @@ pub async fn create_request( } // ... - if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { return Json(e.into()); } @@ -71,7 +70,7 @@ pub async fn create_request( { Ok(s) => Json(ApiReturn { ok: true, - message: "App created".to_string(), + message: "Data inserted".to_string(), payload: s.id.to_string(), }), Err(e) => Json(e.into()), @@ -79,13 +78,13 @@ pub async fn create_request( } pub async fn update_value_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -109,7 +108,7 @@ pub async fn update_value_request( } // ... - if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { return Json(e.into()); } @@ -124,12 +123,12 @@ pub async fn update_value_request( } pub async fn delete_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Path(id): Path, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -141,7 +140,7 @@ pub async fn delete_request( // ... if let Err(e) = data - .update_app_data_used(app.id, (app.data_used - app_data.value.len()) as i32) + .add_app_data_used(app.id, -(app_data.value.len() as i32)) .await { return Json(e.into()); @@ -156,3 +155,60 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn delete_query_request( + headers: HeaderMap, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + // ... + let rows = match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query.clone(), + mode: req.mode.clone(), + }) + .await + { + Ok(x) => match x { + AppDataQueryResult::One(x) => vec![x], + AppDataQueryResult::Many(x) => x, + }, + Err(e) => return Json(e.into()), + }; + + let mut subtract_amount: usize = 0; + for row in &rows { + subtract_amount += row.value.len(); + } + drop(rows); + + if let Err(e) = data + .add_app_data_used(app.id, -(subtract_amount as i32)) + .await + { + return Json(e.into()); + } + + match data + .query_delete_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 8b276dd..9e48f8d 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -433,9 +433,10 @@ pub fn routes() -> Router { .route("/apps/{id}/roll", post(apps::roll_api_key_request)) // app data .route("/app_data", post(app_data::create_request)) - .route("/app_data/query", post(app_data::query_request)) .route("/app_data/{id}", delete(app_data::delete_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) + .route("/app_data/query", post(app_data::query_request)) + .route("/app_data/query", delete(app_data::delete_query_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -987,6 +988,7 @@ pub struct UpdatePostIsOpen { pub struct CreateApp { pub title: String, pub homepage: String, + #[serde(default)] pub redirect: String, } diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 6ea4f63..7ddece5 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -12,9 +12,9 @@ impl DataManager { pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { AppData { id: get!(x->0(i64)) as usize, - app: get!(x->2(i64)) as usize, - key: get!(x->3(String)), - value: get!(x->4(String)), + app: get!(x->1(i64)) as usize, + key: get!(x->2(String)), + value: get!(x->3(String)), } } @@ -44,10 +44,7 @@ impl DataManager { Ok(res.unwrap()) } - /// Get all app_data by owner. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch app_data for + /// Get all app_data by the given query. pub async fn query_app_data(&self, query: AppDataQuery) -> Result { let conn = match self.0.connect().await { Ok(c) => c, @@ -57,12 +54,13 @@ impl DataManager { let query_str = query.to_string().replace( "%q%", &match query.query { - AppDataSelectQuery::Like(_, _) => format!("v LIKE $1"), + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), }, ); let res = match query.mode { - AppDataSelectMode::One => AppDataQueryResult::One( + AppDataSelectMode::One(_) => AppDataQueryResult::One( match query_row!(&conn, &query_str, params![&query.query.to_string()], |x| { Ok(Self::get_app_data_from_row(x)) }) { @@ -70,7 +68,15 @@ impl DataManager { Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), }, ), - AppDataSelectMode::Many(_, _, _) => AppDataQueryResult::Many( + AppDataSelectMode::Many(_, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::ManyJson(_, _, _) => AppDataQueryResult::Many( match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { Self::get_app_data_from_row(x) }) { @@ -83,6 +89,35 @@ impl DataManager { Ok(res) } + /// Delete all app_data matched by the given query. + pub async fn query_delete_app_data(&self, query: AppDataQuery) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let query_str = query + .to_string() + .replace( + "%q%", + &match query.query { + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), + }, + ) + .replace("SELECT * FROM", "SELECT id FROM"); + + if let Err(e) = execute!( + &conn, + &format!("DELETE FROM app_data WHERE id IN ({query_str})"), + params![&query.query.to_string()] + ) { + return Err(Error::MiscError(e.to_string())); + } + + Ok(()) + } + const MAXIMUM_FREE_APP_DATA: usize = 5; const MAXIMUM_DATA_SIZE: usize = 205_000; @@ -101,9 +136,9 @@ impl DataManager { } if data.value.len() < 2 { - return Err(Error::DataTooShort("key".to_string())); + return Err(Error::DataTooShort("value".to_string())); } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { - return Err(Error::DataTooLong("key".to_string())); + return Err(Error::DataTooLong("value".to_string())); } // check number of app_data diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 4915907..1fa5f31 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -29,7 +29,7 @@ impl DataManager { } auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); - auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app_k:{}"); /// Get all apps by user. /// @@ -134,7 +134,7 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } - self.0.1.remove(format!("atto.app:{}", id)).await; + self.cache_clear_app(&app).await; // remove data let res = execute!( @@ -151,14 +151,21 @@ impl DataManager { Ok(()) } - 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:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_data_used(i32) -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + pub async fn cache_clear_app(&self, app: &ThirdPartyApp) { + self.0.1.remove(format!("atto.app:{}", app.id)).await; + self.0.1.remove(format!("atto.app_k:{}", app.api_key)).await; + } - 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); + auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_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=cache_clear_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=cache_clear_app); + auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_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=cache_clear_app); + auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + + auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + + auto_method!(incr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --incr); + auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --decr=grants); } diff --git a/crates/core/src/database/drivers/sql/create_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql index 28a8379..64cdd3f 100644 --- a/crates/core/src/database/drivers/sql/create_app_data.sql +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -1,6 +1,5 @@ CREATE TABLE IF NOT EXISTS app_data ( id BIGINT NOT NULL PRIMARY KEY, - owner BIGINT NOT NULL, app BIGINT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql index 575ce5c..d01ed41 100644 --- a/crates/core/src/database/drivers/sql/create_apps.sql +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -8,5 +8,6 @@ CREATE TABLE IF NOT EXISTS apps ( quota_status TEXT NOT NULL, banned INT NOT NULL, grants INT NOT NULL, - scopes TEXT NOT NULL + scopes TEXT NOT NULL, + data_used INT NOT NULL CHECK (data_used >= 0) ) diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index b48b0ed..bca5c81 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -146,34 +146,46 @@ impl AppData { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectQuery { - Like(String, String), + KeyIs(String), + LikeJson(String, String), } impl Display for AppDataSelectQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { - Self::Like(k, v) => format!("%\"{k}\":\"{v}\"%"), + Self::KeyIs(k) => k.to_owned(), + Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"), }) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectMode { - /// Select a single row. - One, + /// Select a single row (with offset). + One(usize), + /// Select multiple rows at once. + /// + /// `(limit, offset)` + Many(usize, usize), /// Select multiple rows at once. /// /// `(order by top level key, limit, offset)` - Many(String, usize, usize), + ManyJson(String, usize, usize), } impl Display for AppDataSelectMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { - Self::One => "LIMIT 1".to_string(), - Self::Many(order_by_top_level_key, limit, offset) => { + Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"), + Self::Many(limit, offset) => { format!( - "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {} OFFSET {offset}", + "LIMIT {} OFFSET {offset}", + if *limit > 1024 { 1024 } else { *limit } + ) + } + Self::ManyJson(order_by_top_level_key, limit, offset) => { + format!( + "ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}", if *limit > 1024 { 1024 } else { *limit } ) } diff --git a/sql_changes/apps_data_used.sql b/sql_changes/apps_data_used.sql index 77202a0..a86da50 100644 --- a/sql_changes/apps_data_used.sql +++ b/sql_changes/apps_data_used.sql @@ -1,2 +1,2 @@ ALTER TABLE apps -ADD COLUMN data_used INT NOT NULL DEFAULT 0; +ADD COLUMN data_used INT NOT NULL DEFAULT 0 CHECK (data_used >= 0); From e393221b4fbe00a8bf83a843c6a6ef0f7c8f2ae3 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 12:22:50 -0400 Subject: [PATCH 45/69] fix: check muted phrases while creating questions --- crates/core/src/database/questions.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 84f9eac..7722250 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -387,23 +387,8 @@ impl DataManager { // inherit nsfw status data.context.is_nsfw = community.context.is_nsfw; } else { - let receiver = self.get_user_by_id(data.receiver).await?; - - if !receiver.settings.enable_questions { - return Err(Error::QuestionsDisabled); - } - - // check for ip block - if self - .get_ipblock_by_initiator_receiver( - receiver.id, - &RemoteAddr::from(data.ip.as_str()), - ) - .await - .is_ok() - { - return Err(Error::NotAllowed); - } + // this should be unreachable + return Err(Error::Unknown); } } else { // single @@ -421,6 +406,14 @@ impl DataManager { return Err(Error::DrawingsDisabled); } + // check muted phrases + for phrase in receiver.settings.muted { + if data.content.contains(&phrase) { + // act like the question was created so theyre less likely to try and send it again or bypass + return Ok(0); + } + } + // check for ip block if self .get_ipblock_by_initiator_receiver(receiver.id, &RemoteAddr::from(data.ip.as_str())) From 636ecce9f45b0336e5f9fd8727560541267f2412 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 13:22:25 -0400 Subject: [PATCH 46/69] add: apps js sdk --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/html/components.lisp | 10 -- crates/app/src/public/html/macros.lisp | 43 ++++++-- crates/app/src/public/js/app_sdk.js | 108 +++++++++++++++++++++ crates/app/src/public/js/atto.js | 52 +++++----- crates/app/src/routes/api/v1/app_data.rs | 17 ++++ crates/app/src/routes/api/v1/mod.rs | 1 + crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + example/app_sdk_test.js | 28 ++++++ 11 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 crates/app/src/public/js/app_sdk.js create mode 100644 example/app_sdk_test.js diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index aba7de9..50b256b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -41,6 +41,7 @@ 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 PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); +pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js"); // html 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 243b70f..c77bfd5 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -234,6 +234,7 @@ version = "1.0.0" "stacks:label.block_all" = "Block all" "stacks:label.unblock_all" = "Unblock all" +"forge:label.forges" = "Forges" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" "forge:tab.info" = "Info" @@ -242,6 +243,7 @@ version = "1.0.0" "forge:action.close" = "Close" "developer:label.for_developers" = "for Developers" +"developer:label.apps" = "Apps" "developer:label.my_apps" = "My apps" "developer:label.create_new" = "Create new app" "developer:label.homepage" = "Homepage" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a83ad44..516fe2a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1142,16 +1142,6 @@ (text "{{ icon \"circle-user-round\" }}") (span (text "{{ text \"auth:link.my_profile\" }}"))) - (a - ("href" "/journals/0/0") - (icon (text "notebook")) - (str (text "general:link.journals"))) - (text "{% if config.lw_host -%}") - (button - ("onclick" "document.getElementById('littleweb').showModal()") - (icon (text "globe")) - (str (text "general:link.little_web"))) - (text "{%- endif %}") (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 969439b..fff3188 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -39,12 +39,6 @@ ("title" "Create post") (icon (text "square-pen"))) - (a - ("href" "/chats/0/0") - ("class" "button {% if selected == 'chats' -%}active{%- endif %}") - ("title" "Chats") - (icon (text "message-circle"))) - (a ("href" "/requests") ("class" "button {% if selected == 'requests' -%}active{%- endif %}") @@ -65,6 +59,43 @@ ("id" "notifications_span") (text "{{ user.notification_count }}"))) + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row {% if selected == 'chats' or selected == 'journals' -%}active{%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("title" "More services") + (icon (text "grip"))) + + (div + ("class" "inner") + (a + ("href" "/chats/0/0") + ("title" "Chats") + (icon (text "message-circle")) + (str (text "communities:label.chats"))) + (a + ("href" "/journals/0/0") + (icon (text "notebook")) + (str (text "general:link.journals"))) + (a + ("href" "/forges") + (icon (text "anvil")) + (str (text "forge:label.forges"))) + (a + ("href" "/developer") + (icon (text "code")) + (str (text "developer:label.apps"))) + (text "{% if config.lw_host -%}") + (button + ("onclick" "document.getElementById('littleweb').showModal()") + (icon (text "globe")) + (str (text "general:link.little_web"))) + (text "{%- endif %}"))) + (text "{%- endif %}") + (text "{% if not hide_user_menu -%}") (div ("class" "dropdown") diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js new file mode 100644 index 0000000..10de985 --- /dev/null +++ b/crates/app/src/public/js/app_sdk.js @@ -0,0 +1,108 @@ +import { + JSONParse as json_parse, + JSONStringify as json_stringify, +} from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js"; + +export default function tetratto(tetratto_host, api_key) { + function api_promise(res) { + return new Promise((resolve, reject) => { + if (res.ok) { + resolve(res.payload); + } else { + reject(res.message); + } + }); + } + + async function app() { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/app`, { + method: "GET", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + async function query(body) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + async function insert(key, value) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + value, + }), + }) + ).text(), + ), + ); + } + + async function remove(id) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/${id}`, { + method: "DELETE", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + async function remove_query(body) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/query`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + return { + app, + query, + insert, + remove, + remove_query, + }; +} diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 157d6d3..d8ffb86 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -415,33 +415,35 @@ media_theme_pref(); }); self.define("hooks::long_text.init", (_) => { - for (const element of Array.from( - document.querySelectorAll("[hook=long]") || [], - )) { - const is_long = element.innerText.length >= 64 * 8; + setTimeout(() => { + for (const element of Array.from( + document.querySelectorAll("[hook=long]") || [], + )) { + const is_long = element.innerText.length >= 64 * 8; - if (!is_long) { - continue; + if (!is_long) { + continue; + } + + element.classList.add("hook:long.hidden_text"); + + if (element.getAttribute("hook-arg") === "lowered") { + element.classList.add("hook:long.hidden_text+lowered"); + } + + const html = element.innerHTML; + const short = html.slice(0, 64 * 8); + element.innerHTML = `${short}...`; + + // event + const listener = () => { + self["hooks::long"](element, html); + element.removeEventListener("click", listener); + }; + + element.addEventListener("click", listener); } - - element.classList.add("hook:long.hidden_text"); - - if (element.getAttribute("hook-arg") === "lowered") { - element.classList.add("hook:long.hidden_text+lowered"); - } - - const html = element.innerHTML; - const short = html.slice(0, 64 * 8); - element.innerHTML = `${short}...`; - - // event - const listener = () => { - self["hooks::long"](element, html); - element.removeEventListener("click", listener); - }; - - element.addEventListener("click", listener); - } + }, 150); }); self.define("hooks::alt", (_) => { diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index c2983f1..f9da1c8 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -9,6 +9,23 @@ use tetratto_core::model::{ ApiReturn, Error, }; +pub async fn get_app_request( + headers: HeaderMap, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(app), + }) +} + pub async fn query_request( headers: HeaderMap, Extension(data): Extension, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9e48f8d..3b6e61e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -433,6 +433,7 @@ pub fn routes() -> Router { .route("/apps/{id}/roll", post(apps::roll_api_key_request)) // app data .route("/app_data", post(app_data::create_request)) + .route("/app_data/app", get(app_data::get_app_request)) .route("/app_data/{id}", delete(app_data::delete_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) .route("/app_data/query", post(app_data::query_request)) diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index d7843bd..f18ede0 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -20,3 +20,4 @@ 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!(proto_links_request: PROTO_LINKS_JS("text/javascript")); +serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index e0fa067..cde54f5 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -21,6 +21,7 @@ pub fn routes(config: &Config) -> Router { .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) .route("/js/proto_links.js", get(assets::proto_links_request)) + .route("/js/app_sdk.js", get(assets::app_sdk_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js new file mode 100644 index 0000000..000eb8d --- /dev/null +++ b/example/app_sdk_test.js @@ -0,0 +1,28 @@ +// @ts-nocheck +// APP_API_KEY=... deno run --allow-net --allow-import --allow-env -r app_sdk_test.js +const deno = Deno; +const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default( + "http://localhost:4118", + deno.env.get("APP_API_KEY"), +); + +// check data used +console.log("data used:", (await sdk.app()).data_used); + +// record insert +await sdk.insert("deno_test", "Hello, Deno!"); +console.log("record created"); +console.log("data used:", (await sdk.app()).data_used); + +// testing record query then delete +const record = ( + await sdk.query({ + query: { KeyIs: "deno_test" }, + mode: { One: 0 }, + }) +).One; + +console.log(record); +await sdk.remove(record.id); +console.log("record deleted"); +console.log("data used:", (await sdk.app()).data_used); From 02f3d089260a55d98dddbd66368d30c83378105b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 14:52:00 -0400 Subject: [PATCH 47/69] add: developer pass --- crates/app/src/assets.rs | 7 +- crates/app/src/main.rs | 15 +- crates/app/src/public/html/auth/register.lisp | 2 +- crates/app/src/public/html/components.lisp | 60 ++++- crates/app/src/public/html/forge/home.lisp | 4 +- crates/app/src/public/html/profile/base.lisp | 12 +- .../app/src/public/html/profile/settings.lisp | 58 +++-- .../routes/api/v1/auth/connections/stripe.rs | 225 ++++++++++++++---- crates/core/src/config.rs | 12 +- crates/core/src/database/app_data.rs | 18 +- crates/core/src/database/apps.rs | 13 +- crates/core/src/database/common.rs | 2 +- crates/core/src/database/communities.rs | 7 +- crates/core/src/model/apps.rs | 21 +- 14 files changed, 355 insertions(+), 101 deletions(-) diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 50b256b..1504ba5 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -15,7 +15,7 @@ use tetratto_core::{ config::Config, model::{ auth::{DefaultTimelineChoice, User}, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, }, }; use tetratto_l10n::LangFile; @@ -516,6 +516,11 @@ pub(crate) async fn initial_context( "is_supporter", &ua.permissions.check(FinePermission::SUPPORTER), ); + ctx.insert( + "has_developer_pass", + &ua.secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS), + ); ctx.insert("home", &ua.settings.default_timeline.relative_url()); } else { ctx.insert("is_helper", &false); diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index f7f7c06..bf74220 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -9,7 +9,10 @@ mod sanitize; use assets::{init_dirs, write_assets}; use stripe::Client as StripeClient; -use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; +use tetratto_core::model::{ + permissions::{FinePermission, SecondaryPermission}, + uploads::CustomEmoji, +}; pub use tetratto_core::*; use axum::{ @@ -55,6 +58,15 @@ fn check_supporter(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok( + SecondaryPermission::from_bits(value.as_u64().unwrap() as u32) + .unwrap() + .check(SecondaryPermission::DEVELOPER_PASS) + .into(), + ) +} + fn check_staff_badge(value: &Value, _: &HashMap) -> tera::Result { Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32) .unwrap() @@ -109,6 +121,7 @@ async fn main() { tera.register_filter("markdown", render_markdown); tera.register_filter("color", color_escape); tera.register_filter("has_supporter", check_supporter); + tera.register_filter("has_dev_pass", check_dev_pass); tera.register_filter("has_staff_badge", check_staff_badge); tera.register_filter("has_banned", check_banned); tera.register_filter("remove_script_tags", remove_script_tags); diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index aa94c3d..05b3d71 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -118,7 +118,7 @@ ("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 "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}.")) (p (text "Alternatively, you can provide an invite code to create your account for free."))) (text "{%- endif %}") (button diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 516fe2a..982c099 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1452,7 +1452,9 @@ }); })();")) -(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") +(text "{%- endmacro %}") + +(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") (div ("class" "card w-full supporter_ad") ("ui_ident" "supporter_ad") @@ -1472,8 +1474,9 @@ (text "{{ icon \"heart\" }}") (span (text "{{ text \"general:action.become_supporter\" }}"))))) +(text "{%- endif %} {%- endmacro %}") -(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}") +(text "{% macro create_post_options() -%}") (div ("class" "flex gap-2 flex-wrap") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}") @@ -2358,10 +2361,6 @@ (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 @@ -2388,15 +2387,13 @@ ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") - (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) + (text "Become a supporter ({{ config.stripe.price_texts.supporter }})")) (span ("class" "fade") (text "Please use your") (b (text " real email ")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "when completing payment. It is required to manage your billing settings.")) (text "{% if config.security.enable_invite_codes -%}") (span @@ -2405,3 +2402,46 @@ (b (text "1: ")) (text "After your account is at least 1 month old")) (text "{%- endif %}") (text "{%- endmacro %}") + +(text "{% macro get_developer_pass_button() -%}") +(p + (text "You currently do not hold a developer pass. With a developer pass, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Increased app storage limit (500 KB->5 MB)")) + (li + (text "Ability to create forges")) + (li + (text "Ability to create more than 1 app")) + (li + (text "Developer pass profile badge"))) +(a + ("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Continue ({{ config.stripe.price_texts.dev_pass }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there.")) +(text "{%- endmacro %}") + +(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}") +(div + ("class" "card w-full supporter_ad") + ("ui_ident" "supporter_ad") + ("onclick" "window.location.href = '/settings#/account/billing'") + (div + ("class" "card w-full flex flex-wrap items-center gap-2 justify-between") + (b + (text "{{ body }}")) + (a + ("href" "/settings#/account/billing") + ("class" "button small") + (icon (text "arrow-right")) + (span + (str (text "dialog:action.continue")))))) +(text "{%- endif %} {%- endmacro %}") diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index c295066..3208a63 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -6,7 +6,7 @@ (main ("class" "flex flex-col gap-2") ; create new - (text "{% if user.permissions|has_supporter -%}") + (text "{% if user.secondary_permissions|has_dev_pass -%}") (div ("class" "card-nest") (div @@ -32,7 +32,7 @@ (button (text "{{ text \"communities:action.create\" }}")))) (text "{% else %}") - (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") + (text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}") (text "{%- endif %}") ; forge listing diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 51f8489..7e4d6fb 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -72,19 +72,25 @@ ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) - (text "{%- endif %} {% if profile.permissions|has_supporter -%}") + (text "{%- endif %} {% if profile.permissions|has_supporter -%}") (span ("title" "Supporter") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"star\" }}")) - (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") + (text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}") + (span + ("title" "Developer pass") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"id-card-lanyard\" }}")) + (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (span ("title" "Staff") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"shield-user\" }}")) - (text "{%- endif %} {% if profile.permissions|has_banned -%}") + (text "{%- endif %} {% if profile.permissions|has_banned -%}") (span ("title" "Banned") ("style" "color: var(--color-primary);") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index c4169a6..b8e251c 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -823,6 +823,29 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{% if config.stripe -%}") + (text "{% if has_developer_pass or is_supporter -%}") + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "credit-card")) + (b + (text "Manage billing"))) + (div + ("class" "card flex flex-col gap-2") + (p + (text "You currently have a subscription! You can manage your billing information below. ") + (b + (text "Please use your email address you supplied when paying to log into the billing portal.")) + (text " You can manage all of your active subscriptions through this page.")) + (a + ("href" "{{ config.stripe.billing_portal_url }}") + ("class" "button lowered") + ("target" "_blank") + (text "Manage billing")))) + (text "{%- endif %}") + (div ("class" "card-nest") ("ui_ident" "supporter_card") @@ -832,28 +855,33 @@ (b (text "Supporter status"))) (div - ("class" "card flex flex-col gap-2") + ("class" "card flex flex-col gap-2 no_p_margin") (text "{% if is_supporter -%}") (p (text "You ") - (b - (text "are ")) - (text "a supporter! Thank you for all - that you do. You can manage your billing - information below.") - (b - (text "Please use your email address you supplied - when paying to login to the billing - portal."))) - (a - ("href" "{{ config.stripe.billing_portal_url }}") - ("class" "button lowered") - ("target" "_blank") - (text "Manage billing")) + (b (text "are ")) + (text "a supporter! Thank you for all that you do.")) (text "{% else %}") (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "id-card-lanyard")) + (b + (text "Developer pass status"))) + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (text "{% if has_developer_pass -%}") + (p + (text "You currently have a developer pass!")) + (text "{% else %}") + (text "{{ components::get_developer_pass_button() }}") + (text "{%- endif %}"))) + (text "{% if user.was_purchased and user.invite_code == 0 -%}") (form ("class" "card w-full lowered flex flex-col gap-2") 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 b9964f6..1d5be0d 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -3,9 +3,9 @@ use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ - auth::{User, Notification}, + auth::{Notification, User}, moderation::AuditLogEntry, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, ApiReturn, Error, }; use stripe::{EventObject, EventType}; @@ -205,6 +205,43 @@ pub async fn stripe_webhook( { return Json(e.into()); } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("found subscription user in {retries} tries"); + + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } + + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new developer!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } } else { tracing::error!( "received an invalid stripe product id, please check config.stripe.product_ids" @@ -220,34 +257,72 @@ pub async fn stripe_webhook( }; let customer_id = subscription.customer.id(); + let product_id = subscription + .items + .data + .get(0) + .as_ref() + .expect("cancelled nothing?") + .plan + .as_ref() + .expect("no subscription plan?") + .product + .as_ref() + .expect("plan with no product?") + .id() + .to_string(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; - if let Err(e) = data - .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) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".to_string(), @@ -269,46 +344,112 @@ pub async fn stripe_webhook( let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id(); + let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); + let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - if !user.permissions.check(FinePermission::SUPPORTER) { - // the user isn't currently a supporter, there's no reason to send this notification - return Json(ApiReturn { - ok: true, - message: "Acceptable".to_string(), - payload: (), - }); - } + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + if !user.permissions.check(FinePermission::SUPPORTER) { + // the user isn't currently a supporter, there's no reason to send this notification + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } - tracing::info!( - "unsubscribe (pay fail) {} (stripe: {})", - user.id, - customer_id - ); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + 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) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + if !user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "It seems your recent payment has failed :(".to_string(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d695c39..e1637b1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -194,22 +194,30 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, - /// The text representation of the price of supporter. (like `$4 USD`) - pub supporter_price_text: String, + /// The text representation of prices. (like `$4 USD`) + pub price_texts: StripePriceTexts, /// Product IDs from the Stripe dashboard. /// /// These are checked when we receive a webhook to ensure we provide the correct product. pub product_ids: StripeProductIds, } +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePriceTexts { + pub supporter: String, + pub dev_pass: String, +} + #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripePaymentLinks { pub supporter: String, + pub dev_pass: String, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeProductIds { pub supporter: String, + pub dev_pass: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 7ddece5..d6225fc 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -1,5 +1,5 @@ use oiseau::cache::Cache; -use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery}; +use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode}; use crate::model::{apps::AppData, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; @@ -51,13 +51,7 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let query_str = query.to_string().replace( - "%q%", - &match query.query { - AppDataSelectQuery::KeyIs(_) => format!("k = $1"), - AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), - }, - ); + let query_str = query.to_string().replace("%q%", &query.query.selector()); let res = match query.mode { AppDataSelectMode::One(_) => AppDataQueryResult::One( @@ -98,13 +92,7 @@ impl DataManager { let query_str = query .to_string() - .replace( - "%q%", - &match query.query { - AppDataSelectQuery::KeyIs(_) => format!("k = $1"), - AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), - }, - ) + .replace("%q%", &query.query.selector()) .replace("SELECT * FROM", "SELECT id FROM"); if let Err(e) = execute!( diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 1fa5f31..b605cb6 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -3,7 +3,7 @@ use crate::model::{ apps::{AppQuota, ThirdPartyApp}, auth::User, oauth::AppScope, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, Error, Result, }; use crate::{auto_method, DataManager}; @@ -72,10 +72,15 @@ impl DataManager { // check number of apps let owner = self.get_user_by_id(data.owner).await?; - if !owner.permissions.check(FinePermission::SUPPORTER) { - let apps = self.get_apps_by_owner(data.owner).await?; + if !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + let apps = self + .get_table_row_count_where("apps", &format!("owner = {}", owner.id)) + .await? as usize; - if apps.len() >= Self::MAXIMUM_FREE_APPS { + if apps >= Self::MAXIMUM_FREE_APPS { return Err(Error::MiscError( "You already have the maximum number of apps you can have".to_string(), )); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 075d0f7..c0b1b59 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -85,7 +85,7 @@ impl DataManager { let res = query_row!( &conn, - &format!("SELECT COUNT(*)::int FROM {} {}", table, r#where), + &format!("SELECT COUNT(*)::int FROM {} WHERE {}", table, r#where), params![], |x| Ok(x.get::(0)) ); diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index fa7c234..df107e9 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -3,6 +3,7 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; use crate::model::communities_permissions::CommunityPermission; +use crate::model::permissions::SecondaryPermission; use crate::model::{ Error, Result, auth::User, @@ -255,7 +256,11 @@ impl DataManager { // check is_forge // only supporters can CREATE forge communities... anybody can contribute to them - if data.is_forge && !owner.permissions.check(FinePermission::SUPPORTER) { + if data.is_forge + && !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { return Err(Error::RequiresSupporter); } diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index bca5c81..2482c5f 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -147,6 +147,8 @@ impl AppData { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectQuery { KeyIs(String), + KeyLike(String), + ValueLike(String), LikeJson(String, String), } @@ -154,11 +156,24 @@ impl Display for AppDataSelectQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { Self::KeyIs(k) => k.to_owned(), + Self::KeyLike(k) => k.to_owned(), + Self::ValueLike(v) => v.to_owned(), Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"), }) } } +impl AppDataSelectQuery { + pub fn selector(&self) -> String { + match self { + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::KeyLike(_) => format!("k LIKE $1"), + AppDataSelectQuery::ValueLike(_) => format!("v LIKE $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectMode { /// Select a single row (with offset). @@ -179,14 +194,14 @@ impl Display for AppDataSelectMode { Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"), Self::Many(limit, offset) => { format!( - "LIMIT {} OFFSET {offset}", - if *limit > 1024 { 1024 } else { *limit } + "ORDER BY k DESC LIMIT {} OFFSET {offset}", + if *limit > 24 { 24 } else { *limit } ) } Self::ManyJson(order_by_top_level_key, limit, offset) => { format!( "ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}", - if *limit > 1024 { 1024 } else { *limit } + if *limit > 24 { 24 } else { *limit } ) } }) From 884a89904ebdb92e93c06888651bbd9970781c25 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 20:04:26 -0400 Subject: [PATCH 48/69] add: channel mutes --- crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/html/chats/app.lisp | 24 ++++++++ .../app/src/public/html/chats/channels.lisp | 16 +++++ crates/app/src/public/html/components.lisp | 6 ++ crates/app/src/public/js/atto.js | 2 +- .../src/routes/api/v1/channels/channels.rs | 59 +++++++++++++++++++ .../src/routes/api/v1/communities/emojis.rs | 2 + crates/app/src/routes/api/v1/mod.rs | 8 +++ crates/core/src/database/auth.rs | 7 ++- crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 3 + crates/core/src/database/messages.rs | 5 ++ crates/core/src/model/auth.rs | 4 ++ crates/core/src/model/oauth.rs | 2 + crates/core/src/model/uploads.rs | 11 +++- 17 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/version_migrations.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index c77bfd5..5a2b0e1 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -222,6 +222,8 @@ version = "1.0.0" "chats:action.add_someone" = "Add someone" "chats:action.kick_member" = "Kick member" "chats:action.mention_user" = "Mention user" +"chats:action.mute" = "Mute" +"chats:action.unmute" = "Unmute" "stacks:link.stacks" = "Stacks" "stacks:label.my_stacks" = "My stacks" diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index 0dc16c3..a5bf139 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -210,6 +210,30 @@ }); }; + globalThis.mute_channel = async (id, mute = true) => { + await trigger(\"atto::debounce\", [\"channels::mute\"]); + fetch(`/api/v1/channels/${id}/mute`, { + method: mute ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + if (mute) { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\"); + } else { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\"); + } + } + }); + }; + globalThis.update_channel_title = async (id) => { await trigger(\"atto::debounce\", [\"channels::update_title\"]); const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp index a87dbeb..f789d32 100644 --- a/crates/app/src/public/html/chats/channels.lisp +++ b/crates/app/src/public/html/chats/channels.lisp @@ -31,6 +31,22 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"chats:action.add_someone\" }}"))) + ; mute/unmute + (button + ("class" "lowered small {% if channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.mute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}')") + (icon (text "bell-off")) + (span + (str (text "chats:action.mute")))) + (button + ("class" "lowered small {% if not channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.unmute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}', false)") + (icon (text "bell-ring")) + (span + (str (text "chats:action.unmute")))) + ; ... (text "{%- endif %}") (button ("class" "lowered small") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 982c099..d3c1a7f 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -113,6 +113,12 @@ ("style" "color: var(--color-primary)") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) + (text "{%- endif %} {% if user.permissions|has_staff_badge -%}") + (span + ("title" "Staff") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"shield-user\" }}")) (text "{%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index d8ffb86..9c556cf 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -689,7 +689,7 @@ media_theme_pref(); }); self.define("hooks::check_message_reactions", async ({ $ }) => { - const observer = $.offload_work_to_client_when_in_view( + const observer = await $.offload_work_to_client_when_in_view( async (element) => { const reactions = await ( await fetch( diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs index e3ead5a..0251e18 100644 --- a/crates/app/src/routes/api/v1/channels/channels.rs +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -293,3 +293,62 @@ pub async fn get_request( Err(e) => Json(e.into()), } } + +pub async fn mute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.channel_mutes.contains(&id) { + return Json(Error::MiscError("Channel already muted".to_string()).into()); + } + + user.channel_mutes.push(id); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unmute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let pos = match user.channel_mutes.iter().position(|x| *x == id) { + Some(x) => x, + None => return Json(Error::MiscError("Channel not muted".to_string()).into()), + }; + + user.channel_mutes.remove(pos); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 1db4c0c..1d2400f 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -17,6 +17,8 @@ use tetratto_core::model::{ /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { match emoji.as_str() { + // matches `CustomEmoji::replace` + "💯" => "100".to_string(), "👍" => "thumbs_up".to_string(), "👎" => "thumbs_down".to_string(), _ => match emojis::get(&emoji) { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3b6e61e..2420007 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -570,6 +570,14 @@ pub fn routes() -> Router { "/channels/{id}/kick", post(channels::channels::kick_member_request), ) + .route( + "/channels/{id}/mute", + post(channels::channels::mute_channel_request), + ) + .route( + "/channels/{id}/mute", + delete(channels::channels::unmute_channel_request), + ) .route("/channels/{id}", get(channels::channels::get_request)) .route( "/channels/community/{id}", diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 65c5307..64530b5 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -120,6 +120,7 @@ impl DataManager { browser_session: get!(x->26(String)), seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), ban_reason: get!(x->28(String)), + channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), } } @@ -276,7 +277,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, $25, $26, $27, $28, $29)", + "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, $27, $28, $29, $30)", params![ &(data.id as i64), &(data.created as i64), @@ -306,7 +307,8 @@ impl DataManager { &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, &serde_json::to_string(&data.seller_data).unwrap(), - &data.ban_reason + &data.ban_reason, + &serde_json::to_string(&data.channel_mutes).unwrap(), ] ); @@ -1004,6 +1006,7 @@ impl DataManager { auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index c0b1b59..1bf00cf 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); + execute!(&conn, common::VERSION_MIGRATIONS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 2535f43..d2239a6 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -1,3 +1,4 @@ +pub const VERSION_MIGRATIONS: &str = include_str!("./sql/version_migrations.sql"); pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql"); pub const CREATE_TABLE_COMMUNITIES: &str = include_str!("./sql/create_communities.sql"); pub const CREATE_TABLE_POSTS: &str = include_str!("./sql/create_posts.sql"); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 57b2078..1cbbbc8 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -27,5 +27,6 @@ CREATE TABLE IF NOT EXISTS users ( was_purchased INT NOT NULL, browser_session TEXT NOT NULL, seller_data TEXT NOT NULL, - ban_reason TEXT NOT NULL + ban_reason TEXT NOT NULL, + channel_mutes TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql new file mode 100644 index 0000000..0f5682b --- /dev/null +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -0,0 +1,3 @@ +-- users channel_mutes +ALTER TABLE users +ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 64157f0..3acb2ee 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -190,6 +190,11 @@ impl DataManager { continue; } + let user = self.get_user_by_id(member).await?; + if user.channel_mutes.contains(&channel.id) { + continue; + } + let mut notif = Notification::new( "You've received a new message!".to_string(), format!( diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index a9dadf1..ffcb264 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -86,6 +86,9 @@ pub struct User { /// The reason the user was banned. #[serde(default)] pub ban_reason: String, + /// IDs of channels the user has muted. + #[serde(default)] + pub channel_mutes: Vec, } pub type UserConnections = @@ -387,6 +390,7 @@ impl User { browser_session: String::new(), seller_data: StripeSellerData::default(), ban_reason: String::new(), + channel_mutes: Vec::new(), } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 72884ae..aa0e00a 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -144,6 +144,8 @@ pub enum AppScope { UserManageServices, /// Manage the user's products. UserManageProducts, + /// Manage the user's channel mutes. + UserManageChannelMutes, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 9ab2d97..bed6dad 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -131,9 +131,14 @@ impl CustomEmoji { if emoji.1 == 0 { out = out.replace( &emoji.0, - match emojis::get_by_shortcode(&emoji.2) { - Some(e) => e.as_str(), - None => &emoji.0, + match emoji.2.as_str() { + "100" => "💯", + "thumbs_up" => "👍", + "thumbs_down" => "👎", + _ => match emojis::get_by_shortcode(&emoji.2) { + Some(e) => e.as_str(), + None => &emoji.0, + }, }, ); } else { From 0138bf4cd45db76adde975de4e9646e181d7804c Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 00:44:12 -0400 Subject: [PATCH 49/69] add: user requests in js app sdk --- Cargo.lock | 1 + crates/app/Cargo.toml | 2 +- crates/app/src/cookie.rs | 68 +++++++ crates/app/src/main.rs | 1 + .../app/src/public/html/developer/link.lisp | 1 + crates/app/src/public/js/app_sdk.js | 170 +++++++++++++++++- crates/app/src/routes/api/v1/apps.rs | 2 +- .../routes/api/v1/auth/connections/last_fm.rs | 2 +- .../src/routes/api/v1/auth/connections/mod.rs | 2 +- .../routes/api/v1/auth/connections/spotify.rs | 2 +- .../routes/api/v1/auth/connections/stripe.rs | 2 +- crates/app/src/routes/api/v1/auth/images.rs | 2 +- crates/app/src/routes/api/v1/auth/ipbans.rs | 2 +- crates/app/src/routes/api/v1/auth/mod.rs | 2 +- crates/app/src/routes/api/v1/auth/profile.rs | 2 +- crates/app/src/routes/api/v1/auth/social.rs | 2 +- .../src/routes/api/v1/auth/user_warnings.rs | 2 +- .../src/routes/api/v1/channels/channels.rs | 2 +- .../api/v1/channels/message_reactions.rs | 2 +- .../src/routes/api/v1/channels/messages.rs | 2 +- .../routes/api/v1/communities/communities.rs | 2 +- .../src/routes/api/v1/communities/drafts.rs | 2 +- .../src/routes/api/v1/communities/emojis.rs | 2 +- .../src/routes/api/v1/communities/images.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 2 +- .../routes/api/v1/communities/questions.rs | 2 +- crates/app/src/routes/api/v1/domains.rs | 2 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/api/v1/notifications.rs | 2 +- crates/app/src/routes/api/v1/products.rs | 2 +- crates/app/src/routes/api/v1/reactions.rs | 2 +- crates/app/src/routes/api/v1/reports.rs | 2 +- crates/app/src/routes/api/v1/requests.rs | 2 +- crates/app/src/routes/api/v1/services.rs | 2 +- crates/app/src/routes/api/v1/stacks.rs | 2 +- crates/app/src/routes/api/v1/uploads.rs | 2 +- crates/app/src/routes/api/v1/util.rs | 2 +- crates/app/src/routes/pages/auth.rs | 2 +- crates/app/src/routes/pages/chats.rs | 2 +- crates/app/src/routes/pages/communities.rs | 2 +- crates/app/src/routes/pages/developer.rs | 2 +- crates/app/src/routes/pages/forge.rs | 2 +- crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 2 +- crates/app/src/routes/pages/marketplace.rs | 2 +- crates/app/src/routes/pages/misc.rs | 2 +- crates/app/src/routes/pages/mod.rs | 2 +- crates/app/src/routes/pages/mod_panel.rs | 2 +- crates/app/src/routes/pages/profile.rs | 2 +- crates/app/src/routes/pages/stacks.rs | 2 +- crates/core/src/model/apps.rs | 2 +- 52 files changed, 282 insertions(+), 53 deletions(-) create mode 100644 crates/app/src/cookie.rs diff --git a/Cargo.lock b/Cargo.lock index 8ea8eb1..614d3bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3276,6 +3276,7 @@ dependencies = [ "axum-extra", "cf-turnstile", "contrasted", + "cookie", "emojis", "futures-util", "image", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index f9eb7d4..170d252 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -22,7 +22,6 @@ ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } - image = "0.25.6" reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" @@ -42,3 +41,4 @@ async-stripe = { version = "0.41.0", features = [ emojis = "0.7.0" webp = "0.3.0" nanoneo = "0.2.0" +cookie = "0.18.1" diff --git a/crates/app/src/cookie.rs b/crates/app/src/cookie.rs new file mode 100644 index 0000000..45fd9a4 --- /dev/null +++ b/crates/app/src/cookie.rs @@ -0,0 +1,68 @@ +use std::convert::Infallible; +use axum::{ + extract::FromRequestParts, + http::{request::Parts, HeaderMap}, +}; +use cookie::{Cookie, CookieJar as CookieCookieJar}; + +/// This is required because "Cookie" his a forbidden header for some fucking reason. +/// Stupidest thing I've ever encountered in JavaScript, absolute fucking insanity. +/// +/// Anyway, most of this shit is just from the original source for axum_extra::extract::CookieJar, +/// just edited to use X-Cookie instead. +/// +/// Stuff from axum_extra will have links to the original provided. +pub struct CookieJar { + jar: CookieCookieJar, +} + +/// +impl FromRequestParts for CookieJar +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Self::from_headers(&parts.headers)) + } +} + +fn cookies_from_request( + header: String, + headers: &HeaderMap, +) -> impl Iterator> + '_ { + headers + .get_all(header) + .into_iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|cookie| Cookie::parse_encoded(cookie.to_owned()).ok()) +} + +impl CookieJar { + /// + /// + /// Modified only to prefer "X-Cookie" header. + pub fn from_headers(headers: &HeaderMap) -> Self { + let mut jar = CookieCookieJar::new(); + + for cookie in cookies_from_request( + if headers.contains_key("X-Cookie") { + "X-Cookie".to_string() + } else { + "Cookie".to_string() + }, + headers, + ) { + jar.add_original(cookie.clone()); + } + + Self { jar } + } + + /// + pub fn get(&self, name: &str) -> Option<&Cookie<'static>> { + self.jar.get(name) + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bf74220..8347c23 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,6 +2,7 @@ #![doc(html_favicon_url = "/public/favicon.svg")] #![doc(html_logo_url = "/public/tetratto_bunny.webp")] mod assets; +mod cookie; mod image; mod macros; mod routes; diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp index 2c94309..f50ad18 100644 --- a/crates/app/src/public/html/developer/link.lisp +++ b/crates/app/src/public/html/developer/link.lisp @@ -83,6 +83,7 @@ const search = new URLSearchParams(window.location.search); search.append(\"verifier\", verifier); search.append(\"token\", res.payload); + search.append(\"uid\", \"{{ user.id }}\"); window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`; } diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 10de985..9d18e9b 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -3,7 +3,45 @@ import { JSONStringify as json_stringify, } from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js"; -export default function tetratto(tetratto_host, api_key) { +/// PKCE key generation. +export const PKCE = { + /// Create a verifier for [`PKCE::challenge`]. + verifier: async (length) => { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < length; i++) { + text += possible.charAt( + Math.floor(Math.random() * possible.length), + ); + } + + return text; + }, + /// Create the challenge needed to request a user token. + challenge: async (verifier) => { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return btoa( + String.fromCharCode.apply(null, [...new Uint8Array(digest)]), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + }, +}; + +export default function tetratto({ + host = "https://tetratto.com", + api_key = null, + app_id = 0n, + user_token = null, + user_verifier = null, + user_id = 0n, +}) { + const GRANT_URL = `${host}/auth/connections_link/app/${app_id}`; + function api_promise(res) { return new Promise((resolve, reject) => { if (res.ok) { @@ -14,11 +52,16 @@ export default function tetratto(tetratto_host, api_key) { }); } + // app data async function app() { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/app`, { + await fetch(`${host}/api/v1/app_data/app`, { method: "GET", headers: { "Atto-Secret-Key": api_key, @@ -30,10 +73,14 @@ export default function tetratto(tetratto_host, api_key) { } async function query(body) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/query`, { + await fetch(`${host}/api/v1/app_data/query`, { method: "POST", headers: { "Content-Type": "application/json", @@ -47,10 +94,14 @@ export default function tetratto(tetratto_host, api_key) { } async function insert(key, value) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data`, { + await fetch(`${host}/api/v1/app_data`, { method: "POST", headers: { "Content-Type": "application/json", @@ -67,10 +118,14 @@ export default function tetratto(tetratto_host, api_key) { } async function remove(id) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/${id}`, { + await fetch(`${host}/api/v1/app_data/${id}`, { method: "DELETE", headers: { "Atto-Secret-Key": api_key, @@ -82,10 +137,14 @@ export default function tetratto(tetratto_host, api_key) { } async function remove_query(body) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/query`, { + await fetch(`${host}/api/v1/app_data/query`, { method: "DELETE", headers: { "Content-Type": "application/json", @@ -98,11 +157,110 @@ export default function tetratto(tetratto_host, api_key) { ); } + // user connection + /// Extract the verifier, token, and user ID from the URL. + function extract_verifier_token_uid() { + const search = new URLSearchParams(window.location.search); + return [ + search.get("verifier"), + search.get("token"), + BigInt(search.get("uid")), + ]; + } + + /// Accept a connection grant and store it in localStorage. + function localstorage_accept_connection() { + const [verifier, token, uid] = extract_verifier_token_uid(); + window.localStorage.setItem("atto:grant.verifier", verifier); + window.localStorage.setItem("atto:grant.token", token); + window.localStorage.setItem("atto:grant.user_id", uid); + } + + async function refresh_token(verifier) { + if (!user_token) { + throw Error("No user token provided."); + } + + return api_promise( + json_parse( + await ( + await fetch( + `${host}/api/v1/auth/user/${user_id}/grants/${app_id}/refresh`, + { + method, + headers: { + "Content-Type": "application/json", + "X-Cookie": `__Secure-atto-token=${user_token}`, + }, + body: json_stringify({ + verifier, + }), + }, + ) + ).text(), + ), + ); + } + + async function request({ + api_path, + method = "POST", + content_type = "application/json", + body = "{}", + }) { + if (!user_token) { + throw Error("No user token provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/${api_path}`, { + method, + headers: { + "Content-Type": content_type, + "X-Cookie": `__Secure-atto-token=${user_token}`, + }, + body: + content_type === "application/json" + ? json_stringify(body) + : body, + }) + ).text(), + ), + ); + } + + // ... return { + // app data app, query, insert, remove, remove_query, + // user connection + GRANT_URL, + extract_verifier_token_uid, + refresh_token, + localstorage_accept_connection, + request, }; } + +export function from_localstorage({ + host = "https://tetratto.com", + app_id = 0n, +}) { + const user_verifier = window.localStorage.getItem("atto:grant.verifier"); + const user_token = window.localStorage.getItem("atto:grant.token"); + const user_id = window.localStorage.getItem("atto:grant.user_id"); + + return tetratto({ + host, + app_id, + user_verifier, + user_id, + user_token, + }); +} diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index 2b1a314..3b5cd60 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ apps::{AppQuota, ThirdPartyApp}, oauth::{AuthGrant, PkceChallengeMethod}, diff --git a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs index 9740b5a..be4176c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs +++ b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::last_fm::LastFmConnection, model::{ diff --git a/crates/app/src/routes/api/v1/auth/connections/mod.rs b/crates/app/src/routes/api/v1/auth/connections/mod.rs index 5cd9813..8a98355 100644 --- a/crates/app/src/routes/api/v1/auth/connections/mod.rs +++ b/crates/app/src/routes/api/v1/auth/connections/mod.rs @@ -5,7 +5,7 @@ pub mod stripe; use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{ConnectionService, ExternalConnectionData}, diff --git a/crates/app/src/routes/api/v1/auth/connections/spotify.rs b/crates/app/src/routes/api/v1/auth/connections/spotify.rs index 8d0db30..d83057e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/spotify.rs +++ b/crates/app/src/routes/api/v1/auth/connections/spotify.rs @@ -1,5 +1,5 @@ use axum::{response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::spotify::SpotifyConnection, model::{ 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 1d5be0d..2110924 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,7 +1,7 @@ use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::{Notification, User}, moderation::AuditLogEntry, diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 4619a80..cbaf344 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, Query}, response::IntoResponse, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use serde::Deserialize; use std::{ diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs index 8a71d25..7163091 100644 --- a/crates/app/src/routes/api/v1/auth/ipbans.rs +++ b/crates/app/src/routes/api/v1/auth/ipbans.rs @@ -4,7 +4,7 @@ use crate::{ routes::api::v1::CreateIpBan, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission}; /// Create a new IP ban. diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 085844a..dff259e 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -16,7 +16,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::addr::RemoteAddr; use tetratto_shared::hash::hash; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 5119e0d..fdb71cf 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -18,7 +18,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 86a601d..84e20c8 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, diff --git a/crates/app/src/routes/api/v1/auth/user_warnings.rs b/crates/app/src/routes/api/v1/auth/user_warnings.rs index 321ab78..3020ec6 100644 --- a/crates/app/src/routes/api/v1/auth/user_warnings.rs +++ b/crates/app/src/routes/api/v1/auth/user_warnings.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission}; /// Create a new user warning. diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs index 0251e18..2059a0f 100644 --- a/crates/app/src/routes/api/v1/channels/channels.rs +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error}; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs index b9ccb53..5f5c79c 100644 --- a/crates/app/src/routes/api/v1/channels/message_reactions.rs +++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs @@ -1,6 +1,6 @@ use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/channels/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs index e88138e..92a5c48 100644 --- a/crates/app/src/routes/api/v1/channels/messages.rs +++ b/crates/app/src/routes/api/v1/channels/messages.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ cache::{Cache, redis::Commands}, model::{ diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 539cc08..a0793b1 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -3,7 +3,7 @@ use axum::{ extract::Path, response::{IntoResponse, Redirect}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::Notification, communities::{Community, CommunityMembership}, diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 75f0948..559e4b3 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -3,7 +3,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error}; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 1d2400f..84fadc0 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ oauth, uploads::{CustomEmoji, MediaType, MediaUpload}, diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 3ddee00..9f32ef3 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use std::fs::exists; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index d65ce53..13729b3 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::AchievementName, diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index e67b91b..de6cbb2 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, IpBlock}, diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index f1af2e6..1e57049 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::AchievementName, littleweb::{Domain, ServiceFsMime}, diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 0b1b394..d018903 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::snow::Snowflake; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 6b274ff..bba335e 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::unix_epoch_timestamp; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/notifications.rs b/crates/app/src/routes/api/v1/notifications.rs index 06b2397..de683ae 100644 --- a/crates/app/src/routes/api/v1/notifications.rs +++ b/crates/app/src/routes/api/v1/notifications.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 05d8e9c..4d53814 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -15,7 +15,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ oauth, products::Product, diff --git a/crates/app/src/routes/api/v1/reactions.rs b/crates/app/src/routes/api/v1/reactions.rs index 261a48d..b8589e4 100644 --- a/crates/app/src/routes/api/v1/reactions.rs +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/reports.rs b/crates/app/src/routes/api/v1/reports.rs index 459b8a9..8509896 100644 --- a/crates/app/src/routes/api/v1/reports.rs +++ b/crates/app/src/routes/api/v1/reports.rs @@ -1,7 +1,7 @@ use super::CreateReport; use crate::{State, get_user_from_token}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ApiReturn, Error, moderation::Report}; pub async fn create_request( diff --git a/crates/app/src/routes/api/v1/requests.rs b/crates/app/src/routes/api/v1/requests.rs index 0169b72..90236cc 100644 --- a/crates/app/src/routes/api/v1/requests.rs +++ b/crates/app/src/routes/api/v1/requests.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index a847338..556924a 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -6,7 +6,7 @@ use crate::{ State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; use tetratto_shared::unix_epoch_timestamp; diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 1fe5c87..e46cfdc 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ model::{ oauth, diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 02673fe..a1d11f8 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -1,6 +1,6 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs index 8714968..501f0d9 100644 --- a/crates/app/src/routes/api/v1/util.rs +++ b/crates/app/src/routes/api/v1/util.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; use tetratto_core::model::permissions::FinePermission; diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs index e9f1699..a675e6a 100644 --- a/crates/app/src/routes/pages/auth.rs +++ b/crates/app/src/routes/pages/auth.rs @@ -4,7 +4,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{Error, auth::ConnectionService}; use super::render_error; diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs index e6ef791..65ff437 100644 --- a/crates/app/src/routes/pages/chats.rs +++ b/crates/app/src/routes/pages/chats.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission, Error, diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 901ec75..6f5524f 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -10,7 +10,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index de4c4e1..0d421f7 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -5,7 +5,7 @@ use axum::{ extract::Path, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error}; /// `/developer` diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs index be1769c..c09c04f 100644 --- a/crates/app/src/routes/pages/forge.rs +++ b/crates/app/src/routes/pages/forge.rs @@ -8,7 +8,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{communities::Community, Error}; /// `/forges` diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index db76e93..8ac0a05 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 9e347e1..18233ff 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Query, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; use tetratto_shared::hash::salt; diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index 0de9be7..8d5a3be 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::Error; /// `/settings/seller` diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 5d017ef..7ee2f72 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS}, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 83e29ad..2f3c9d5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -15,7 +15,7 @@ use axum::{ routing::{get, post}, Router, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ model::{Error, auth::User}, diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index 7a9b6f7..2b82cf1 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ cache::Cache, diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 11966a6..15a3ee8 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Path, Query}, response::{Html, IntoResponse}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index e8285e9..f4ee986 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::User, permissions::FinePermission, diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 2482c5f..309d850 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -67,7 +67,7 @@ pub struct ThirdPartyApp { /// if the verifier doesn't match, it won't pass the challenge. /// /// Requests to API endpoints using your grant token should be sent with a - /// cookie (in the `Cookie` header) named `Atto-Grant`. This cookie should + /// cookie (in the `Cookie` or `X-Cookie` header) named `Atto-Grant`. This cookie should /// contain the token you received from either the initial connection, /// or a token refresh. pub redirect: String, From 9ccbc69405542277b75a0ca573c5f68fa5f952c6 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 02:00:04 -0400 Subject: [PATCH 50/69] add: app sdk client auth flow example --- crates/app/src/macros.rs | 2 +- crates/app/src/public/html/developer/app.lisp | 3 +- .../app/src/public/html/developer/home.lisp | 6 +-- crates/app/src/public/js/app_sdk.js | 34 +++++++++------- crates/core/src/database/auth.rs | 4 +- example/.gitignore | 1 + example/app_sdk_test.js | 8 ++-- .../examples/auth_flow_example/index.html | 39 +++++++++++++++++++ .../examples/auth_flow_example/redirect.html | 25 ++++++++++++ 9 files changed, 95 insertions(+), 27 deletions(-) create mode 100644 example/public/examples/auth_flow_example/index.html create mode 100644 example/public/examples/auth_flow_example/redirect.html diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index fd141ea..44669a4 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -112,7 +112,7 @@ macro_rules! get_user_from_token { Ok((grant, ua)) => { if grant.scopes.contains(&$grant_scope) { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + None } else { Some(ua) } diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 6795001..b1661e8 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -180,7 +180,8 @@ (li (b (text "Redirect URL: ")) (text "{{ app.redirect }}")) (li (b (text "Quota status: ")) (text "{{ app.quota_status }}")) (li (b (text "User grants: ")) (text "{{ app.grants }}")) - (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}"))) + (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")) + (li (b (text "App ID (for SDK): ")) (text "{{ app.id }}"))) (a ("class" "button") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index d96be6f..160181b 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -41,8 +41,7 @@ ("id" "homepage") ("placeholder" "homepage") ("required" "") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (div ("class" "flex flex-col gap-1") (label @@ -53,8 +52,7 @@ ("name" "redirect") ("id" "redirect") ("placeholder" "redirect URL") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (button (text "{{ text \"communities:action.create\" }}")))) diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 9d18e9b..4b5599b 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -176,11 +176,7 @@ export default function tetratto({ window.localStorage.setItem("atto:grant.user_id", uid); } - async function refresh_token(verifier) { - if (!user_token) { - throw Error("No user token provided."); - } - + async function refresh_token() { return api_promise( json_parse( await ( @@ -190,10 +186,10 @@ export default function tetratto({ method, headers: { "Content-Type": "application/json", - "X-Cookie": `__Secure-atto-token=${user_token}`, + "X-Cookie": `Atto-Grant=${user_token}`, }, body: json_stringify({ - verifier, + verifier: user_verifier, }), }, ) @@ -203,10 +199,10 @@ export default function tetratto({ } async function request({ - api_path, + route, method = "POST", content_type = "application/json", - body = "{}", + body = {}, }) { if (!user_token) { throw Error("No user token provided."); @@ -215,16 +211,19 @@ export default function tetratto({ return api_promise( json_parse( await ( - await fetch(`${host}/api/v1/${api_path}`, { + await fetch(`${host}/api/v1/${route}`, { method, headers: { - "Content-Type": content_type, - "X-Cookie": `__Secure-atto-token=${user_token}`, + "Content-Type": + method === "GET" ? null : content_type, + "X-Cookie": `Atto-Grant=${user_token}`, }, body: - content_type === "application/json" - ? json_stringify(body) - : body, + method === "GET" + ? null + : content_type === "application/json" + ? json_stringify(body) + : body, }) ).text(), ), @@ -233,6 +232,11 @@ export default function tetratto({ // ... return { + user_id, + user_token, + user_verifier, + app_id, + api_key, // app data app, query, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 64530b5..c520794 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -196,8 +196,8 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)", - &[&token], + "SELECT * FROM users WHERE grants LIKE $1", + &[&format!("%\"token\":\"{token}\"%")], |x| Ok(Self::get_user_from_row(x)) ); diff --git a/example/.gitignore b/example/.gitignore index 004f366..b5ba3f2 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -4,6 +4,7 @@ html/* public/* !public/footer.html !public/robots.txt +!public/examples media/* icons/* langs/* diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js index 000eb8d..bd69519 100644 --- a/example/app_sdk_test.js +++ b/example/app_sdk_test.js @@ -1,10 +1,10 @@ // @ts-nocheck // APP_API_KEY=... deno run --allow-net --allow-import --allow-env -r app_sdk_test.js const deno = Deno; -const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default( - "http://localhost:4118", - deno.env.get("APP_API_KEY"), -); +const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default({ + host: "http://localhost:4118", + api_key: deno.env.get("APP_API_KEY"), +}); // check data used console.log("data used:", (await sdk.app()).data_used); diff --git a/example/public/examples/auth_flow_example/index.html b/example/public/examples/auth_flow_example/index.html new file mode 100644 index 0000000..5a4c023 --- /dev/null +++ b/example/public/examples/auth_flow_example/index.html @@ -0,0 +1,39 @@ + + + + + + Auth flow example + + + + +

    + + + + diff --git a/example/public/examples/auth_flow_example/redirect.html b/example/public/examples/auth_flow_example/redirect.html new file mode 100644 index 0000000..532eb22 --- /dev/null +++ b/example/public/examples/auth_flow_example/redirect.html @@ -0,0 +1,25 @@ + + + + + + Auth flow example redirect + + +

    Waiting...

    + + + + From 63d3c2350d90658ac148c033a671f6a588cf9d15 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 03:17:21 -0400 Subject: [PATCH 51/69] add: user is_deactivated --- crates/app/src/langs/en-US.toml | 4 + crates/app/src/macros.rs | 13 +++ crates/app/src/public/html/mod/profile.lisp | 10 ++ .../app/src/public/html/profile/settings.lisp | 92 ++++++++++++++----- crates/app/src/public/html/root.lisp | 50 +++++++++- crates/app/src/routes/api/v1/auth/profile.rs | 32 ++++++- crates/app/src/routes/api/v1/mod.rs | 9 ++ crates/core/src/database/auth.rs | 42 ++++++++- crates/core/src/database/common.rs | 5 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/posts.rs | 4 +- crates/core/src/model/auth.rs | 5 + 13 files changed, 243 insertions(+), 30 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 5a2b0e1..3246755 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -184,6 +184,10 @@ version = "1.0.0" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:label.alt_text" = "Alt text" +"settings:label.deactivate_account" = "Deactivate account" +"settings:label.activate_account" = "Activate account" +"settings:label.deactivate" = "Deactivate" +"settings:label.account_deactivated" = "Account deactivated" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 44669a4..1aa9a2d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -192,6 +192,19 @@ macro_rules! user_banned { #[macro_export] macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { + // check is_deactivated + if $other_user.is_deactivated { + return Err(Html( + render_error( + Error::GeneralNotFound("user".to_string()), + &$jar, + &$data, + &$user, + ) + .await, + )); + } + // check require_account if $user.is_none() && $other_user.settings.require_account { return Err(Html( diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 6f07c93..2b68c90 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -212,6 +212,11 @@ \"{{ profile.awaiting_purchase }}\", \"checkbox\", ], + [ + [\"is_deactivated\", \"Is deactivated\"], + \"{{ profile.is_deactivated }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -235,6 +240,11 @@ awaiting_purchase: value, }); }, + is_deactivated: (value) => { + profile_request(false, \"deactivated\", { + is_deactivated: 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 b8e251c..59b64d0 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -284,29 +284,50 @@ ("ui_ident" "delete_account") (div ("class" "card small flex items-center gap-2 red") - (text "{{ icon \"skull\" }}") - (b - (text "{{ text \"settings:label.delete_account\" }}"))) - (form - ("class" "card flex flex-col gap-2") - ("onsubmit" "delete_account(event)") - (div - ("class" "flex flex-col gap-1") - (label - ("for" "current_password") - (text "{{ text \"settings:label.current_password\" }}")) - (input - ("type" "password") - ("name" "current_password") - ("id" "current_password") - ("placeholder" "current_password") - ("required" "") - ("minlength" "6") - ("autocomplete" "off"))) - (button - (text "{{ icon \"trash\" }}") - (span - (text "{{ text \"general:action.delete\" }}"))))) + (icon (text "skull")) + (b (str (text "communities:label.danger_zone")))) + (div + ("class" "card lowered flex flex-col gap-2") + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.deactivate_account"))) + (div + ("class" "inner flex flex-col gap-2") + (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) + (button + ("onclick" "deactivate_account()") + (icon (text "lock")) + (span + (str (text "settings:label.deactivate")))))) + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.delete_account"))) + (form + ("class" "inner flex flex-col gap-2") + ("onsubmit" "delete_account(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "current_password") + (text "{{ text \"settings:label.current_password\" }}")) + (input + ("type" "password") + ("name" "current_password") + ("id" "current_password") + ("placeholder" "current_password") + ("required" "") + ("minlength" "6") + ("autocomplete" "off"))) + (button + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1612,6 +1633,31 @@ }); } + globalThis.deactivate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: true }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + // presets globalThis.apply_preset = async (preset) => { if ( diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 3dd15a0..5cf7da9 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -77,7 +77,6 @@ (div ("class" "card lowered w-full") (text "{{ user.ban_reason|markdown|safe }}")))))) - ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message @@ -142,6 +141,55 @@ } }); }")))))) + (text "{% elif user.is_deactivated -%}") + ; account deactivated message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "settings:label.account_deactivated"))) + + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (p (text "You have deactivated your account. You can undo this with the button below if you'd like.")) + (hr) + (button + ("onclick" "activate_account()") + (icon (text "lock-open")) + (str (text "settings:label.activate_account"))))))) + + (script + (text "globalThis.activate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: false }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + };")) (text "{% else %}") ; page body (text "{% block body %}{% endblock %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index fdb71cf..aec31ef 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -5,8 +5,8 @@ use crate::{ routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, - UpdateUserInviteCode, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -372,6 +372,34 @@ pub async fn update_user_awaiting_purchase_request( } } +/// Update the deactivated status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_is_deactivated_request( + jar: CookieJar, + Path(id): Path, + 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_is_deactivated(id, req.is_deactivated, user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Deactivated status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2420007..60bbf20 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -351,6 +351,10 @@ pub fn routes() -> Router { "/auth/user/{id}/awaiting_purchase", post(auth::profile::update_user_awaiting_purchase_request), ) + .route( + "/auth/user/{id}/deactivate", + post(auth::profile::update_user_is_deactivated_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -836,6 +840,11 @@ pub struct UpdateUserAwaitingPurchase { pub awaiting_purchase: bool, } +#[derive(Deserialize)] +pub struct UpdateUserIsDeactivated { + pub is_deactivated: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index c520794..513be6d 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -121,6 +121,7 @@ impl DataManager { seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), ban_reason: get!(x->28(String)), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), + is_deactivated: get!(x->30(i32)) as i8 == 1, } } @@ -277,7 +278,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, $25, $26, $27, $28, $29, $30)", + "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, $27, $28, $29, $30, $31)", params![ &(data.id as i64), &(data.created as i64), @@ -309,6 +310,7 @@ impl DataManager { &serde_json::to_string(&data.seller_data).unwrap(), &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), + &if data.is_deactivated { 1_i32 } else { 0_i32 }, ] ); @@ -626,6 +628,44 @@ impl DataManager { Ok(()) } + pub async fn update_user_is_deactivated(&self, id: usize, x: bool, user: User) -> Result<()> { + if id != user.id && !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 is_deactivated = $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_is_deactivated` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn update_user_password( &self, id: usize, diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 1bf00cf..d37c330 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,7 +44,10 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); - execute!(&conn, common::VERSION_MIGRATIONS).unwrap(); + + for x in common::VERSION_MIGRATIONS.split(";") { + execute!(&conn, x).unwrap(); + } self.0 .1 diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 1cbbbc8..6a939e5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -28,5 +28,6 @@ CREATE TABLE IF NOT EXISTS users ( browser_session TEXT NOT NULL, seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, - channel_mutes TEXT NOT NULL + channel_mutes TEXT NOT NULL, + is_deactivated INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 0f5682b..c0c863a 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -1,3 +1,7 @@ -- users channel_mutes ALTER TABLE users ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; + +-- users is_deactivated +ALTER TABLE users +ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 701c053..0891fed 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -397,7 +397,9 @@ impl DataManager { continue; } - if ua.permissions.check_banned() | ignore_users.contains(&owner) + if (ua.permissions.check_banned() + | ignore_users.contains(&owner) + | ua.is_deactivated) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { continue; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index ffcb264..37d2bf9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -89,6 +89,10 @@ pub struct User { /// IDs of channels the user has muted. #[serde(default)] pub channel_mutes: Vec, + /// If the user is deactivated. Deactivated users act almost like deleted + /// users, but their data is not wiped. + #[serde(default)] + pub is_deactivated: bool, } pub type UserConnections = @@ -391,6 +395,7 @@ impl User { seller_data: StripeSellerData::default(), ban_reason: String::new(), channel_mutes: Vec::new(), + is_deactivated: false, } } From f05074ffc5ff89f6ed57b9f63088fb3e1c5efdf3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 03:20:13 -0400 Subject: [PATCH 52/69] fix: delete apps and app_data when deleting user --- crates/core/src/database/auth.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 513be6d..3bd8678 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -537,6 +537,11 @@ impl DataManager { self.delete_userfollow(follow.id, &user, true).await?; } + // delete apps + for app in self.get_apps_by_owner(id).await? { + self.delete_app(app.id, &user).await?; + } + // remove images let avatar = PathBufD::current().extend(&[ self.0.0.dirs.media.as_str(), From fe1e53c47ac27236c4d1483835772020bb6d9bab Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 15:31:06 -0400 Subject: [PATCH 53/69] add: apps rust sdk --- Cargo.lock | 2 + Cargo.toml | 1 + crates/app/Cargo.toml | 4 + crates/app/src/macros.rs | 8 +- crates/app/src/public/js/app_sdk.js | 24 +++ crates/core/Cargo.toml | 31 ++- crates/core/examples/sdk_db.rs | 65 ++++++ crates/core/src/lib.rs | 7 + crates/core/src/sdk.rs | 313 ++++++++++++++++++++++++++++ crates/l10n/Cargo.toml | 1 + example/app_sdk_test.js | 16 +- 11 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 crates/core/examples/sdk_db.rs create mode 100644 crates/core/src/sdk.rs diff --git a/Cargo.lock b/Cargo.lock index 614d3bb..1a9e39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,6 +2669,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -3317,6 +3318,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", + "tokio", "toml 0.9.2", "totp-rs", ] diff --git a/Cargo.toml b/Cargo.toml index e8d6326..b5beca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"] package.authors = ["trisuaso"] package.repository = "https://trisua.com/t/tetratto" package.license = "AGPL-3.0-or-later" +package.homepage = "https://tetratto.com" [profile.dev] incremental = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 170d252..5c60129 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -2,6 +2,10 @@ name = "tetratto" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 1aa9a2d..13333da 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,13 @@ macro_rules! user_banned { macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { // check is_deactivated - if $other_user.is_deactivated { + if ($user.is_none() && $other_user.is_deactivated) + | !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { return Err(Html( render_error( Error::GeneralNotFound("user".to_string()), diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 4b5599b..2ca1f53 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -117,6 +117,29 @@ export default function tetratto({ ); } + async function update(id, value) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/value`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + value, + }), + }) + ).text(), + ), + ); + } + async function remove(id) { if (!api_key) { throw Error("No API key provided."); @@ -241,6 +264,7 @@ export default function tetratto({ app, query, insert, + update, remove, remove_query, // user connection diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 72d6481..526b195 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -2,6 +2,16 @@ name = "tetratto-core" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true + +[features] +database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"] +types = ["dep:totp-rs", "dep:paste", "dep:bitflags"] +sdk = ["types", "dep:reqwest"] +default = ["database", "types", "sdk"] [dependencies] pathbufd = "0.1.4" @@ -10,17 +20,20 @@ toml = "0.9.2" tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" -totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.22", features = ["json"] } -bitflags = "2.9.1" -async-recursion = "1.1.1" -md-5 = "0.10.6" -base16ct = { version = "0.2.0", features = ["alloc"] } -base64 = "0.22.1" +totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } +reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } +bitflags = { version = "2.9.1", optional = true } +async-recursion = { version = "1.1.1", optional = true } +md-5 = { version = "0.10.6", optional = true } +base16ct = { version = "0.2.0", features = ["alloc"], optional = true } +base64 = { version = "0.22.1", optional = true } emojis = "0.7.0" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = [ "postgres", "redis", -] } -paste = "1.0.15" +], optional = true } +paste = { version = "1.0.15", optional = true } + +[dev-dependencies] +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/examples/sdk_db.rs b/crates/core/examples/sdk_db.rs new file mode 100644 index 0000000..becdca1 --- /dev/null +++ b/crates/core/examples/sdk_db.rs @@ -0,0 +1,65 @@ +extern crate tetratto_core; +use tetratto_core::{ + model::apps::{AppDataSelectMode, AppDataSelectQuery, AppDataQueryResult}, + sdk::{DataClient, SimplifiedQuery}, +}; +use std::env::var; + +// mirror of https://trisua.com/t/tetratto/src/branch/master/example/app_sdk_test.js ... but in rust +#[tokio::main] +pub async fn main() { + let client = DataClient::new( + Some("http://localhost:4118".to_string()), + var("APP_API_KEY").unwrap(), + ); + + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // record insert + client + .insert("rust_test".to_string(), "Hello, world!".to_string()) + .await + .unwrap(); + println!("record created"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // testing record query then delete + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client + .update(record.id, "Hello, world! 1".to_string()) + .await + .unwrap(); + println!("record updated"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client.remove(record.id).await.unwrap(); + println!("record deleted"); + println!("data used: {}", client.get_app().await.unwrap().data_used); +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index aa61770..b785f89 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,13 @@ +#[cfg(feature = "types")] pub mod config; +#[cfg(feature = "database")] pub mod database; +#[cfg(feature = "types")] pub mod model; +#[cfg(feature = "sdk")] +pub mod sdk; +#[cfg(feature = "database")] pub use database::DataManager; +#[cfg(feature = "database")] pub use oiseau::cache; diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs new file mode 100644 index 0000000..72b82e8 --- /dev/null +++ b/crates/core/src/sdk.rs @@ -0,0 +1,313 @@ +use crate::model::{ + apps::{ + AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery, ThirdPartyApp, + }, + ApiReturn, Error, Result, +}; +use reqwest::{ + multipart::{Form, Part}, + Client as HttpClient, +}; +pub use reqwest::Method; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +macro_rules! api_return_ok { + ($ret:ty, $res:ident) => { + match $res.json::>().await { + Ok(x) => { + if x.ok { + Ok(x.payload) + } else { + Err(Error::MiscError(x.message)) + } + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + }; +} + +/// A simplified app data query which matches what the API endpoint actually requires. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimplifiedQuery { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +/// The data client is used to access an app's data storage capabilities. +#[derive(Debug, Clone)] +pub struct DataClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The app's API key. You can retrieve this from the web dashboard. + pub api_key: String, + /// The origin of the Tetratto server. When creating with [`DataClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl DataClient { + /// Create a new [`DataClient`]. + pub fn new(host: Option, api_key: String) -> Self { + Self { + http: HttpClient::new(), + api_key, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Get the current app using the provided API key. + /// + /// # Usage + /// ```rust + /// let client = DataClient::new("https://tetratto.com".to_string(), "...".to_string()); + /// let app = client.get_app().await.expect("failed to get app"); + /// ``` + pub async fn get_app(&self) -> Result { + match self + .http + .get(format!("{}/api/v1/app_data/app", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!(ThirdPartyApp, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Query the app's data. + pub async fn query(&self, query: &SimplifiedQuery) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!(AppDataQueryResult, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Insert a key, value pair into the app's data. + pub async fn insert(&self, key: String, value: String) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!(String, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Update a record's value given its ID and the new value. + pub async fn update(&self, id: usize, value: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/value", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete a row from the app's data by its `id`. + pub async fn remove(&self, id: usize) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/{id}", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete row(s) from the app's data by a query. + pub async fn remove_query(&self, query: &AppDataQuery) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} + +/// The state of the [`ApiClient`]. +#[derive(Debug, Clone, Default)] +pub struct ApiClientState { + /// The token you received from an app grant request. + pub user_token: String, + /// The verifier you received from an app grant request. + pub user_verifier: String, + /// The ID of the user this client is connecting to. + pub user_id: usize, + /// The ID of the app that is being used for user grants. + /// + /// You can get this from the web dashboard. + pub app_id: usize, +} + +/// The API client is used to manage authentication flow and send requests on behalf of a user. +/// +/// This client assumes you already have the required information for the given user. +/// If you don't, try using the JS SDK to extract this information. +#[derive(Debug, Clone)] +pub struct ApiClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The general state of the client. Will be updated whenever you refresh the user's token. + pub state: ApiClientState, + /// The origin of the Tetratto server. When creating with [`ApiClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl ApiClient { + /// Create a new [`ApiClient`]. + pub fn new(host: Option, state: ApiClientState) -> Self { + Self { + http: HttpClient::new(), + state, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Refresh the client's user_token. + pub async fn refresh_token(&mut self) -> Result { + match self + .http + .post(format!( + "{}/api/v1/auth/user/{}/grants/{}/refresh", + self.host, self.state.user_id, self.state.app_id + )) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert( + "verifier".to_string(), + serde_json::Value::String(self.state.user_verifier.to_owned()), + ); + map + })) + .send() + .await + { + Ok(x) => { + let ret = api_return_ok!(String, x)?; + self.state.user_token = ret.clone(); + Ok(ret) + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Send a simple JSON request to the given endpoint. + pub async fn request( + &self, + route: String, + method: Method, + body: Option<&B>, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + if let Some(body) = body { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } else { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + } + + /// Send a JSON request with attachments to the given endpoint. + /// + /// This type of request is only required for routes which use JsonMultipart, + /// such as `POST /api/v1/posts` (`create_post`). + /// + /// Method is locked to `POST` for this type of request. + pub async fn request_attachments( + &self, + route: String, + attachments: Vec>, + body: &B, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + let mut multipart_body = Form::new(); + + // add attachments + for v in attachments.clone() { + // the file name doesn't matter + multipart_body = multipart_body.part(String::new(), Part::bytes(v)); + } + + drop(attachments); + + // add json + multipart_body = multipart_body.part( + String::new(), + Part::text(serde_json::to_string(body).unwrap()), + ); + + // ... + match self + .http + .post(format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .multipart(multipart_body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index d7661c2..bfccdbf 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" authors.workspace = true repository.workspace = true license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js index bd69519..37f2063 100644 --- a/example/app_sdk_test.js +++ b/example/app_sdk_test.js @@ -15,7 +15,7 @@ console.log("record created"); console.log("data used:", (await sdk.app()).data_used); // testing record query then delete -const record = ( +let record = ( await sdk.query({ query: { KeyIs: "deno_test" }, mode: { One: 0 }, @@ -23,6 +23,20 @@ const record = ( ).One; console.log(record); + +await sdk.update("deno_test", "Hello, Deno! 1"); +console.log("record updated"); +console.log("data used:", (await sdk.app()).data_used); + +record = ( + await sdk.query({ + query: { KeyIs: "deno_test" }, + mode: { One: 0 }, + }) +).One; + +console.log(record); + await sdk.remove(record.id); console.log("record deleted"); console.log("data used:", (await sdk.app()).data_used); From 7d30d65a3b9bb83ab2a0e2499e86d1fddb7a3366 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 15:38:58 -0400 Subject: [PATCH 54/69] fix: profile panic --- crates/app/src/macros.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 13333da..69730e0 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -194,11 +194,13 @@ macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { // check is_deactivated if ($user.is_none() && $other_user.is_deactivated) - | !$user - .as_ref() - .unwrap() - .permissions - .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + | ($user.is_some() + && !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + && $other_user.is_deactivated) { return Err(Html( render_error( From 35b66c94d08f85ccf3ecf826ae0d9cc31cfce79f Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 21:30:41 -0400 Subject: [PATCH 55/69] chore: publish l10n, shared, and core --- Cargo.lock | 4 ++-- crates/app/Cargo.toml | 2 +- crates/core/Cargo.toml | 7 ++++--- crates/l10n/Cargo.toml | 1 + crates/shared/Cargo.toml | 3 ++- justfile | 14 ++++++++++++++ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a9e39a..a3a09d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2885,9 +2885,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5c60129..09efda7 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -29,7 +29,7 @@ tetratto-l10n = { path = "../l10n" } image = "0.25.6" reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" -serde_json = "1.0.140" +serde_json = "1.0.141" mime_guess = "2.0.5" cf-turnstile = "0.2.0" contrasted = "0.1.3" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 526b195..2dd5dfa 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-core" +description = "The core behind Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true @@ -17,9 +18,9 @@ default = ["database", "types", "sdk"] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.2" -tetratto-shared = { path = "../shared" } -tetratto-l10n = { path = "../l10n" } -serde_json = "1.0.140" +tetratto-shared = { version = "11.0.0", path = "../shared" } +tetratto-l10n = { version = "11.0.0", path = "../l10n" } +serde_json = "1.0.141" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } bitflags = { version = "2.9.1", optional = true } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index bfccdbf..6b8cb59 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-l10n" +description = "Localization for Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index f21f611..38fd768 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-shared" +description = "Shared stuff for Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true @@ -12,7 +13,7 @@ chrono = "0.4.41" markdown = "1.0.0" hex_fmt = "0.3.0" rand = "0.9.1" -serde = "1.0.219" +serde = { version = "1.0.219", features = ["derive"] } sha2 = "0.10.9" snowflaked = "1.0.3" uuid = { version = "1.17.0", features = ["v4"] } diff --git a/justfile b/justfile index a83d0c4..b106417 100644 --- a/justfile +++ b/justfile @@ -12,3 +12,17 @@ doc: test: cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run + +publish-shared: + cargo publish --allow-dirty --package tetratto-shared + +publish-l10n: + cargo publish --allow-dirty --package tetratto-l10n + +publish-core: + cargo publish --allow-dirty --package tetratto-core + +publish: + just publish-shared + just publish-l10n + just publish-core From 6f2d556c65940a360e574d844383d1bd4626008e Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 23:21:01 -0400 Subject: [PATCH 56/69] add: app data rename method --- Cargo.lock | 8 ++--- crates/app/Cargo.toml | 2 +- crates/app/src/main.rs | 2 +- crates/app/src/public/js/app_sdk.js | 24 ++++++++++++++ crates/app/src/routes/api/v1/app_data.rs | 41 +++++++++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 6 ++++ crates/core/Cargo.toml | 6 ++-- crates/core/src/sdk.rs | 19 +++++++++++ crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 10 files changed, 100 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3a09d1..b03e9ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "11.0.0" +version = "12.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3301,7 +3301,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "11.0.0" +version = "12.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "11.0.0" +version = "12.0.0" dependencies = [ "pathbufd", "serde", @@ -3334,7 +3334,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "11.0.0" +version = "12.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 09efda7..e5ca15f 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8347c23..bfac36f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -131,7 +131,7 @@ async fn main() { let client = Client::new(); let mut app = Router::new(); - // cretae stripe client + // create stripe client let stripe_client = if let Some(ref stripe) = config.stripe { Some(StripeClient::new(stripe.secret.clone())) } else { diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 2ca1f53..cd21e6a 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -140,6 +140,29 @@ export default function tetratto({ ); } + async function rename(id, key) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + }), + }) + ).text(), + ), + ); + } + async function remove(id) { if (!api_key) { throw Error("No API key provided."); @@ -265,6 +288,7 @@ export default function tetratto({ query, insert, update, + rename, remove, remove_query, // user connection diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index f9da1c8..d5e8c3f 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -1,6 +1,6 @@ use crate::{ get_app_from_key, - routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, + routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue, UpdateAppDataKey}, State, }; use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; @@ -94,6 +94,37 @@ pub async fn create_request( } } +pub async fn update_key_request( + headers: HeaderMap, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + + match data.update_app_data_key(id, &req.key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_value_request( headers: HeaderMap, Extension(data): Extension, @@ -116,6 +147,10 @@ pub async fn update_value_request( Err(e) => return Json(e.into()), }; + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + // check size let size_without = app.data_used - app_data.value.len(); let new_size = size_without + req.value.len(); @@ -155,6 +190,10 @@ pub async fn delete_request( Err(e) => return Json(e.into()), }; + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + // ... if let Err(e) = data .add_app_data_used(app.id, -(app_data.value.len() as i32)) diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 60bbf20..6b56e9b 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -439,6 +439,7 @@ pub fn routes() -> Router { .route("/app_data", post(app_data::create_request)) .route("/app_data/app", get(app_data::get_app_request)) .route("/app_data/{id}", delete(app_data::delete_request)) + .route("/app_data/{id}/key", post(app_data::update_key_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) .route("/app_data/query", post(app_data::query_request)) .route("/app_data/query", delete(app_data::delete_query_request)) @@ -1176,6 +1177,11 @@ pub struct UpdateUploadAlt { pub alt: String, } +#[derive(Deserialize)] +pub struct UpdateAppDataKey { + pub key: String, +} + #[derive(Deserialize)] pub struct UpdateAppDataValue { pub value: String, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2dd5dfa..98a0947 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-core" description = "The core behind Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true @@ -18,8 +18,8 @@ default = ["database", "types", "sdk"] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.2" -tetratto-shared = { version = "11.0.0", path = "../shared" } -tetratto-l10n = { version = "11.0.0", path = "../l10n" } +tetratto-shared = { version = "12.0.0", path = "../shared" } +tetratto-l10n = { version = "12.0.0", path = "../l10n" } serde_json = "1.0.141" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs index 72b82e8..71ba502 100644 --- a/crates/core/src/sdk.rs +++ b/crates/core/src/sdk.rs @@ -129,6 +129,25 @@ impl DataClient { } } + /// Update a record's key given its ID and the new key. + pub async fn rename(&self, id: usize, key: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/key", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + /// Delete a row from the app's data by its `id`. pub async fn remove(&self, id: usize) -> Result<()> { match self diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 6b8cb59..5993ffc 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-l10n" description = "Localization for Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 38fd768..02b14fc 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-shared" description = "Shared stuff for Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true From 270d7550d63d594bc11c7b97629829cc54e054bb Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 03:12:27 -0400 Subject: [PATCH 57/69] fix: app data limits --- crates/core/src/database/app_data.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index d6225fc..2b8520c 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -117,13 +117,13 @@ impl DataManager { let app = self.get_app_by_id(data.app).await?; // check values - if data.key.len() < 2 { + if data.key.len() < 1 { return Err(Error::DataTooShort("key".to_string())); - } else if data.key.len() > 32 { + } else if data.key.len() > 128 { return Err(Error::DataTooLong("key".to_string())); } - if data.value.len() < 2 { + if data.value.len() < 1 { return Err(Error::DataTooShort("value".to_string())); } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { return Err(Error::DataTooLong("value".to_string())); From d58e47cbbefe8c63d532ffc8e9871a17d02ed248 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 03:33:03 -0400 Subject: [PATCH 58/69] fix: only add delta bytes when changing app data value --- crates/app/src/routes/api/v1/app_data.rs | 9 ++++++++- crates/core/src/database/app_data.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index d5e8c3f..5e0182e 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -160,7 +160,14 @@ pub async fn update_value_request( } // ... - if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { + // we only need to add the delta size (the next size - the old size) + if let Err(e) = data + .add_app_data_used( + app.id, + (req.value.len() as i32) - (app_data.value.len() as i32), + ) + .await + { return Json(e.into()); } diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 2b8520c..9aeafc1 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -5,7 +5,7 @@ use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; pub const FREE_DATA_LIMIT: usize = 512_000; -pub const PASS_DATA_LIMIT: usize = 5_242_880; +pub const PASS_DATA_LIMIT: usize = 26_214_400; impl DataManager { /// Get a [`AppData`] from an SQL row. From 55460fc60ac66c5e098d9ff79f3de0479a262ef0 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 15:04:16 -0400 Subject: [PATCH 59/69] add: actually parse arrow alignment for markdown --- crates/app/src/public/html/components.lisp | 2 +- crates/shared/src/markdown.rs | 126 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d3c1a7f..8475223 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2415,7 +2415,7 @@ (ul ("style" "margin-bottom: var(--pad-4)") (li - (text "Increased app storage limit (500 KB->5 MB)")) + (text "Increased app storage limit (500 KB->25 MB)")) (li (text "Ability to create forges")) (li diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index 82d6b79..540ca97 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; /// Render markdown input into HTML pub fn render_markdown(input: &str) -> String { + let input = &parse_alignment(input); let options = Options { compile: CompileOptions { allow_any_img_src: false, @@ -45,7 +46,7 @@ pub fn render_markdown(input: &str) -> String { Builder::default() .generic_attributes(allowed_attributes) .add_tags(&[ - "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", + "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", "align", ]) .rm_tags(&["script", "style", "link", "canvas"]) .add_tag_attributes("a", &["href", "target"]) @@ -57,7 +58,124 @@ pub fn render_markdown(input: &str) -> String { "loading=\"lazy\" src=\"/api/v1/util/proxy?url=http", ) .replace("