From 39574df691b6215703fda0bf219c51f41de0be4c Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 14 Jun 2025 20:26:54 -0400 Subject: [PATCH] add: developer panel --- Cargo.lock | 8 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 4 + crates/app/src/langs/en-US.toml | 10 + crates/app/src/macros.rs | 11 +- crates/app/src/public/css/style.css | 1 + crates/app/src/public/html/developer/app.lisp | 348 ++++++++++++++++++ .../app/src/public/html/developer/home.lisp | 28 +- .../app/src/public/html/developer/link.lisp | 86 +++++ crates/app/src/public/html/mod/profile.lisp | 1 + .../app/src/public/html/profile/settings.lisp | 73 +++- crates/app/src/public/js/atto.js | 1 + crates/app/src/routes/api/v1/apps.rs | 82 ++++- crates/app/src/routes/api/v1/auth/profile.rs | 110 +++++- crates/app/src/routes/api/v1/mod.rs | 26 ++ crates/app/src/routes/pages/developer.rs | 62 +++- crates/app/src/routes/pages/mod.rs | 5 + crates/app/src/routes/pages/profile.rs | 16 + crates/core/Cargo.toml | 2 +- crates/core/src/database/apps.rs | 18 +- crates/core/src/database/auth.rs | 35 +- crates/core/src/database/communities.rs | 2 +- .../src/database/drivers/sql/create_apps.sql | 3 +- crates/core/src/database/ipblocks.rs | 4 +- crates/core/src/database/memberships.rs | 2 +- crates/core/src/database/pollvotes.rs | 2 +- crates/core/src/database/posts.rs | 6 +- crates/core/src/model/apps.rs | 44 ++- crates/core/src/model/auth.rs | 18 +- crates/core/src/model/channels.rs | 4 +- crates/core/src/model/communities.rs | 14 +- .../core/src/model/communities_permissions.rs | 2 +- crates/core/src/model/moderation.rs | 4 +- crates/core/src/model/oauth.rs | 4 +- crates/core/src/model/reactions.rs | 2 +- crates/core/src/model/requests.rs | 4 +- crates/core/src/model/stacks.rs | 2 +- crates/core/src/model/uploads.rs | 4 +- crates/l10n/Cargo.toml | 2 +- crates/l10n/src/lib.rs | 2 +- crates/shared/Cargo.toml | 2 +- crates/shared/src/snow.rs | 2 +- crates/shared/src/time.rs | 6 +- sql_changes/apps_scopes.sql | 2 + 44 files changed, 982 insertions(+), 84 deletions(-) create mode 100644 crates/app/src/public/html/developer/app.lisp create mode 100644 crates/app/src/public/html/developer/link.lisp create mode 100644 sql_changes/apps_scopes.sql diff --git a/Cargo.lock b/Cargo.lock index 1b81526..1126bca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "6.0.0" +version = "7.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "6.0.0" +version = "7.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "6.0.0" +version = "7.0.0" dependencies = [ "pathbufd", "serde", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "6.0.0" +version = "7.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 73e8dbd..a575e9d 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "6.0.0" +version = "7.0.0" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3d4752e..45f40f1 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -122,6 +122,8 @@ pub const FORGE_INFO: &str = include_str!("./public/html/forge/info.lisp"); pub const FORGE_TICKETS: &str = include_str!("./public/html/forge/tickets.lisp"); pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp"); +pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp"); +pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -408,6 +410,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"forge/tickets.html"(crate::assets::FORGE_TICKETS) --config=config --lisp plugins); write_template!(html_path->"developer/home.html"(crate::assets::DEVELOPER_HOME) -d "developer" --config=config --lisp plugins); + write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins); + write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins); html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 6e68f4f..d32ef2e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -212,7 +212,17 @@ version = "1.0.0" "forge:action.reopen" = "Reopen" "forge:action.close" = "Close" +"developer:label.for_developers" = "for Developers" "developer:label.my_apps" = "My apps" "developer:label.create_new" = "Create new app" "developer:label.homepage" = "Homepage" "developer:label.redirect" = "Redirect URL" +"developer:label.change_title" = "Change title" +"developer:label.change_homepage" = "Change homepage" +"developer:label.change_redirect" = "Change redirect URL" +"developer:label.change_quota_status" = "Change quota status" +"developer:label.manage_scopes" = "Manage scopes" +"developer:label.scopes" = "Scopes" +"developer:label.guides_and_help" = "Guides & help" +"developer:action.delete" = "Delete app" +"developer:action.authorize" = "Authorize" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 451a348..a990ee1 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -100,19 +100,14 @@ macro_rules! get_user_from_token { }}; ($jar:ident, $db:expr, $grant_scope:expr) => {{ - if let Some(token) = $jar.get("Atto-Grant") - && let Some(verifier) = $jar.get("Atto-Grant-Verifier") - { + if let Some(token) = $jar.get("Atto-Grant") { // grant token - let verifier = verifier.to_string().replace("Atto-Grant-Verifier=", ""); match $db - .get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", "")) + .get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), true) .await { Ok((grant, ua)) => { - if grant.scopes.contains(&$grant_scope) - && grant.check_verifier(&verifier).is_ok() - { + if grant.scopes.contains(&$grant_scope) { if ua.permissions.check_banned() { Some(tetratto_core::model::auth::User::banned()) } else { diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f4aeaad..154f64c 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -191,6 +191,7 @@ table ol { .card.lowered { background: var(--color-lowered); + color: var(--color-text-lowered); } .card-nest { diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp new file mode 100644 index 0000000..b9012ec --- /dev/null +++ b/crates/app/src/public/html/developer/app.lisp @@ -0,0 +1,348 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ app.title }} - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (div + ("class" "w-full flex flex-col gap-2") + (div + ("id" "manage_fields") + ("class" "card lowered flex flex-col gap-2") + (text "{% if is_helper -%}") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b (str (text "developer:label.change_quota_status")))) + (div + ("class" "card") + (select + ("onchange" "save_quota_status(event)") + (option + ("value" "Limited") + ("selected" "{% if app.quota_status == 'Limited' -%}true{% else %}false{%- endif %}") + (text "Limited")) + (option + ("value" "Unlimited") + ("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}") + (text "Unlimited"))))) + (text "{%- endif %}") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b (str (text "developer:label.change_title")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_title(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (text "{{ text \"communities:label.new_title\" }}")) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "new title") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b (str (text "developer:label.change_homepage")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_homepage(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "homepage") + (text "{{ text \"developer:label.homepage\" }}")) + (input + ("type" "url") + ("name" "homepage") + ("id" "homepage") + ("placeholder" "new homepage") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b (str (text "developer:label.change_redirect")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_redirect(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "redirect") + (text "{{ text \"developer:label.redirect\" }}")) + (input + ("type" "url") + ("name" "redirect") + ("id" "redirect") + ("placeholder" "new redirect URL") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b (str (text "developer:label.manage_scopes")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_scopes(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "scopes") + (text "{{ text \"developer:label.scopes\" }}")) + (input + ("type" "text") + ("name" "scopes") + ("id" "scopes") + ("placeholder" "new scopes") + ("required" "") + ("minlength" "2") + ("value" "{% for scope in app.scopes -%} {{ scope }} {% endfor %}"))) + + (pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap"))) + + (details + (summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help")) + (div + ("class" "card flex flex-col gap-1") + (span ("class" "fade") (text "Scopes should be separated by a single space.")) + (a + ("class" "button") + ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variants") + ("target" "_blank") + (icon (text "external-link")) (text "Docs")))) + + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}")))))) + (div + ("class" "card flex flex-col gap-2") + (ul + (li (b (text "Homepage: ")) (text "{{ app.homepage }}")) + (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 }}"))) + + (a + ("class" "button") + ("href" "https://tetratto.com/reference/tetratto/model/apps/struct.ThirdPartyApp.html#structfield.redirect") + ("target" "_blank") + (icon (text "external-link")) (text "Docs"))) + (div + ("class" "card-nest") + (div + ("class" "card small flex gap-1 items-center red") + (text "{{ icon \"skull\" }}") + (b + (text "{{ text \"communities:label.danger_zone\" }}"))) + (div + ("class" "card flex flex-wrap gap-2") + (button + ("class" "red lowered") + ("onclick" "delete_app()") + (text "{{ icon \"trash\" }}") + (span (str (text "developer:action.delete")))))) + (div + ("class" "flex gap-2 flex-wrap") + (a + ("href" "/developer") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") + (span + (text "{{ text \"general:action.back\" }}")))))) +(script + (text "setTimeout(() => { + globalThis.save_quota_status = (event) => { + const selected = event.target.selectedOptions[0]; + fetch(\"/api/v1/apps/{{ app.id }}/quota_status\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + quota_status: selected.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.change_title = async (e) => { + e.preventDefault(); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.change_homepage = async (e) => { + e.preventDefault(); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/homepage\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + homepage: e.target.homepage.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.change_redirect = async (e) => { + e.preventDefault(); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/redirect\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + redirect: e.target.redirect.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.change_scopes = async (e) => { + e.preventDefault(); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will only impact new grants.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/scopes\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + scopes: e.target.scopes.value.trim().split(\" \"), + }), + }) + .then((res) => res.text()) + .then((res) => { + if (res.startsWith(\"{\")) { + const r = JSON.parse(res); + trigger(\"atto::toast\", [r.ok ? \"success\" : \"error\", r.message]); + document.getElementById(\"scope_error_message\").parentElement.classList.add(\"hidden\"); + } else { + document.getElementById(\"scope_error_message\").innerText = res; + document.getElementById(\"scope_error_message\").parentElement.classList.remove(\"hidden\"); + document.getElementById(\"scope_error_message\").parentElement.parentElement.querySelector(\"details\").setAttribute(\"open\", \"\"); + } + }); + }; + + globalThis.delete_app = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This action is permanent.\", + ])) + ) { + return; + } + + fetch(`/api/v1/apps/{{ app.id }}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + }, 250);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index c0a63f2..ffc68b8 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -85,7 +85,33 @@ ("class" "date") (text "{{ item.created }}")) (text "; {{ item.quota_status }} mode; {{ item.grants }} users"))) - (text "{% endfor %}")))) + (text "{% endfor %}"))) + + ; useful links + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "circle-help")) + (str (text "developer:label.guides_and_help"))) + + (div + ("class" "card") + (ul + (li + (a ("href" "https://trisua.com/t/tetratto") (text "Source code"))) + (li + (a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference"))) + (li + (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure"))) + (li + (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes"))) + (li + (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions"))) + (li + (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions"))) + (li + (a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues"))))))) (script (text "async function create_app_from_form(e) { diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp new file mode 100644 index 0000000..5d46c87 --- /dev/null +++ b/crates/app/src/public/html/developer/link.lisp @@ -0,0 +1,86 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ app.title }} - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (div + ("class" "w-full flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card flex flex-col gap-2") + (h4 (text "Would you like to allow \"{{ app.title }}\" to access your account?")) + (p (text "This app is requesting the following permissions on your account:")) + + (div + ("class" "card") + (ul + (text "{% for scope in app.scopes -%}") + (li + (a + ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}") + ("target" "_blank") + (text "{{ scope }}"))) + (text "{%- endfor %}"))) + + (p (text "You can revoke this app's permissions at any time through your connection settings."))) + + (div + ("class" "card flex gap-2") + (button + ("onclick" "authorize()") + (str (text "developer:action.authorize"))) + + (a + ("class" "button secondary") + ("href" "javascript:window.close()") + (str (text "dialog:action.cancel"))))))) +(script + (text "setTimeout(() => { + globalThis.authorize = async (event) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + const verifier = await trigger(\"connections::pkce_verifier\", [ + 128, + ]); + + const challenge = await trigger(\"connections::pkce_challenge\", [ + verifier, + ]); + + fetch(\"/api/v1/apps/{{ app.id }}/grant\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + challenge + }) + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + const search = new URLSearchParams(window.location.search); + search.append(\"verifier\", verifier); + search.append(\"token\", res.payload); + + window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`; + } + }); + }; + }, 250);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 0fc252f..65813b2 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -255,6 +255,7 @@ MANAGE_EMOJIS: 1 << 25, MANAGE_STACKS: 1 << 26, STAFF_BADGE: 1 << 27, + MANAGE_APPS: 1 << 28, }, ], ); diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 7c44c50..8514e34 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -845,7 +845,57 @@ ("class" "w-content")) (span (text "Shown on profile"))))) - (text "{% endfor %}")) + (text "{% endfor %}") + (text "{% for grant in profile_grants %}") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-4") + (div + ("class" "flex items-center gap-2") + (icon (text "bot")) + (a + ("class" "flush") + ("href" "{{ grant[0].homepage }}") + ("target" "_blank") + (b + ("class" "flex items-center gap-2") + (text "{{ grant[0].title }}")))) + + (span + ("class" "fade flex items-center gap-2") + (icon (text "clock")) + (span ("class" "date") (text "{{ grant[1].last_updated }}")))) + (div + ("class" "card flex flex-col gap-2") + (details + (summary (icon (text "scan-eye")) (text "{{ grant[1].scopes|length }} scope{{ grant[1].scopes|length|pluralize }}")) + + (div + ("class" "card lowered w-full") + (ul + (text "{% for scope in grant[1].scopes -%}") + (li + (a + ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}") + ("target" "_blank") + (text "{{ scope }}"))) + (text "{%- endfor %}")))) + + (button + ("class" "lowered red small") + ("onclick" "remove_grant('{{ grant[0].id }}')") + (text "{{ text \"general:action.delete\" }}")))) + (text "{% endfor %}") + + (hr) + (a + ("class" "button") + ("href" "/developer") + (icon (text "code")) + (span + (text "{{ config.name }} ") + (str (text "developer:label.for_developers"))))) (script ("type" "application/json") ("id" "settings_json") @@ -897,6 +947,27 @@ }); }; + globalThis.remove_grant = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/user/{{ profile.id }}/grants/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + globalThis.save_settings = () => { fetch(\"/api/v1/auth/user/{{ profile.id }}/settings\", { method: \"POST\", diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 4750668..1e34ef2 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -818,6 +818,7 @@ media_theme_pref(); anchor.href.length === 0 || anchor.href.startsWith("https://github.com") || anchor.href.startsWith("https://trisua.com") || + anchor.href.startsWith("https://tetratto.com") || anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") || anchor.href.startsWith("https://last.fm") diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index 1a33518..cdcea02 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -1,11 +1,20 @@ use crate::{ get_user_from_token, - routes::api::v1::{UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppTitle}, + routes::api::v1::{ + CreateGrant, UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppScopes, + UpdateAppTitle, + }, State, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{apps::ThirdPartyApp, permissions::FinePermission, ApiReturn, Error}; +use tetratto_core::model::{ + apps::ThirdPartyApp, + oauth::{AuthGrant, PkceChallengeMethod}, + permissions::FinePermission, + ApiReturn, Error, +}; +use tetratto_shared::{hash::random_id, unix_epoch_timestamp}; use super::CreateApp; pub async fn create_request( @@ -129,6 +138,28 @@ pub async fn update_quota_status_request( } } +pub async fn update_scopes_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_app_scopes(id, &user, req.scopes).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn delete_request( jar: CookieJar, Extension(data): Extension, @@ -149,3 +180,50 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn grant_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let app = match data.get_app_by_id(id).await { + Ok(a) => a, + Err(e) => return Json(e.into()), + }; + + if user.get_grant_by_app_id(id).is_some() { + return Json(Error::MiscError("This app already has a grant".to_string()).into()); + } + + let grant = AuthGrant { + app: app.id, + challenge: req.challenge, + method: PkceChallengeMethod::S256, + token: random_id(), + last_updated: unix_epoch_timestamp(), + scopes: app.scopes.clone(), + }; + + user.grants.push(grant.clone()); + match data.update_user_grants(user.id, user.grants).await { + Ok(_) => { + if let Err(e) = data.incr_app_grants(id).await { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: Some(grant.token), + }) + } + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 5bc5254..c98a665 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,8 +3,8 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - AppendAssociations, DeleteUser, DisableTotp, UpdateUserIsVerified, UpdateUserPassword, - UpdateUserRole, UpdateUserUsername, + AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified, + UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -31,7 +31,10 @@ use tetratto_core::{ #[cfg(feature = "redis")] use tetratto_core::cache::redis::Commands; -use tetratto_shared::hash; +use tetratto_shared::{ + hash::{self, random_id}, + unix_epoch_timestamp, +}; pub async fn redirect_from_id( Extension(data): Extension, @@ -717,3 +720,104 @@ pub async fn get_user_gpa_request( payload: Some(gpa), }); } + +/// Remove a grant token. +pub async fn remove_grant_request( + jar: CookieJar, + Path((user_id, app_id)): Path<(usize, usize)>, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + if user.get_grant_by_app_id(app_id).is_none() { + return Json(Error::GeneralNotFound("grant".to_string()).into()); + } + + // remove grant + user.grants + .remove(user.grants.iter().position(|x| x.app == app_id).unwrap()); + + if let Err(e) = data.decr_app_grants(app_id).await { + return Json(e.into()); + } + + // update grants + match data.update_user_grants(user_id, user.grants).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// Refresh a grant token. +pub async fn refresh_grant_request( + jar: CookieJar, + Path((user_id, app_id)): Path<(usize, usize)>, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = if let Some(token) = jar.get("Atto-Grant") { + match data + .get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), false) + .await + { + Ok((grant, ua)) => { + if grant.check_verifier(&req.verifier).is_err() { + return Json(Error::NotAllowed.into()); + } + + if ua.permissions.check_banned() { + tetratto_core::model::auth::User::banned() + } else { + ua + } + } + Err(_) => return Json(Error::NotAllowed.into()), + } + } else { + return Json(Error::NotAllowed.into()); + }; + + if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + let mut grant = match user.get_grant_by_app_id(app_id) { + Some(g) => g.to_owned(), + None => return Json(Error::GeneralNotFound("grant".to_string()).into()), + }; + + // remove grant + user.grants + .remove(user.grants.iter().position(|x| x.app == app_id).unwrap()); + + // refresh token + let token = random_id(); + grant.token = token.clone(); + grant.last_updated = unix_epoch_timestamp(); + + // add grant + user.grants.push(grant); + + // update grants + match data.update_user_grants(user_id, user.grants).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: Some(token), + }), + 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 5bdc72f..ca5f747 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -24,6 +24,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + oauth::AppScope, permissions::FinePermission, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, @@ -348,6 +349,14 @@ pub fn routes() -> Router { "/auth/user/{id}/followers", get(auth::social::followers_request), ) + .route( + "/auth/user/{id}/grants/{app}", + delete(auth::profile::remove_grant_request), + ) + .route( + "/auth/user/{id}/grants/{app}/refresh", + post(auth::profile::refresh_grant_request), + ) // apps .route("/apps", post(apps::create_request)) .route("/apps/{id}/title", post(apps::update_title_request)) @@ -357,7 +366,9 @@ pub fn routes() -> Router { "/apps/{id}/quota_status", 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)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -816,3 +827,18 @@ pub struct UpdateAppRedirect { pub struct UpdateAppQuotaStatus { pub quota_status: AppQuota, } + +#[derive(Deserialize)] +pub struct UpdateAppScopes { + pub scopes: Vec, +} + +#[derive(Deserialize)] +pub struct CreateGrant { + pub challenge: String, +} + +#[derive(Deserialize)] +pub struct RefreshGrantToken { + pub verifier: String, +} diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index 3bf14f7..76d94fe 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -2,10 +2,11 @@ use super::render_error; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use axum::{ response::{Html, IntoResponse}, + extract::Path, Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::Error; +use tetratto_core::model::{permissions::FinePermission, Error}; /// `/developer` pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -33,3 +34,62 @@ pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> data.1.render("developer/home.html", &context).unwrap(), )) } + +/// `/developer/app/{id}` +pub async fn app_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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 app = match data.0.get_app_by_id(id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != app.owner && !user.permissions.check(FinePermission::MANAGE_APPS) { + 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("app", &app); + + // return + Ok(Html(data.1.render("developer/app.html", &context).unwrap())) +} + +/// `/auth/connections_link/app/{id}` +pub async fn connection_callback_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Html(render_error(Error::NotAllowed, &jar, &data, &None).await), + }; + + let app = match data.0.get_app_by_id(id).await { + Ok(p) => p, + Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("app", &app); + + Html(data.1.render("developer/link.html", &context).unwrap()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 1497638..f7d542f 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -68,6 +68,10 @@ pub fn routes() -> Router { "/auth/connections_link/{service}", get(auth::connection_callback_request), ) + .route( + "/auth/connections_link/app/{id}", + get(developer::connection_callback_request), + ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) @@ -119,6 +123,7 @@ pub fn routes() -> Router { .route("/forge/{title}/members", get(communities::members_request)) // developer .route("/developer", get(developer::home_request)) + .route("/developer/app/{id}", get(developer::app_request)) // stacks .route("/stacks", get(stacks::list_request)) .route("/stacks/{id}", get(stacks::posts_request)) diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index ebb9857..f559b3c 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -104,6 +104,22 @@ pub async fn settings_request( .unwrap() .replace("\"", "\\\""), ); + context.insert("profile_grants", &{ + let mut out = Vec::new(); + + for grant in profile.grants { + out.push(( + match data.0.get_app_by_id(grant.app).await { + Ok(a) => a, + // TODO: remove grant from user (app deleted) + Err(_) => continue, + }, + grant, + )); + } + + out + }); // check color contrasts let mut failing_color_keys: Vec<(&str, f64)> = Vec::new(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2cabddb..01e09c0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "6.0.0" +version = "7.0.0" edition = "2024" [features] diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index e6e6f95..9e5e56d 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -1,9 +1,10 @@ use oiseau::cache::Cache; use crate::model::{ - Error, Result, - auth::User, - permissions::FinePermission, apps::{AppQuota, ThirdPartyApp}, + auth::User, + oauth::AppScope, + permissions::FinePermission, + Error, Result, }; use crate::{auto_method, DataManager}; @@ -31,6 +32,7 @@ impl DataManager { quota_status: serde_json::from_str(&get!(x->6(String))).unwrap(), 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(), } } @@ -95,7 +97,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.created as i64), @@ -105,7 +107,8 @@ impl DataManager { &data.redirect, &serde_json::to_string(&data.quota_status).unwrap(), &{ if data.banned { 1 } else { 0 } }, - &(data.grants as i32) + &(data.grants as i32), + &serde_json::to_string(&data.scopes).unwrap(), ] ); @@ -144,7 +147,8 @@ impl DataManager { auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_scopes(Vec)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --incr); - auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --decr=grants); + 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/auth.rs b/crates/core/src/database/auth.rs index 76ed2d3..39626f3 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -114,7 +114,11 @@ impl DataManager { /// /// # Arguments /// * `token` - the token of the user - pub async fn get_user_by_grant_token(&self, token: &str) -> Result<(AuthGrant, User)> { + pub async fn get_user_by_grant_token( + &self, + token: &str, + check_expiration: bool, + ) -> Result<(AuthGrant, User)> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -122,7 +126,7 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM users WHERE grants::jsonb @> ('{\"token\":' || $1 || '}')::jsonb", + "SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)", &[&token], |x| Ok(Self::get_user_from_row(x)) ); @@ -132,14 +136,25 @@ impl DataManager { } let user = res.unwrap(); - Ok(( - user.grants - .iter() - .find(|x| x.token == token) - .unwrap() - .clone(), - user, - )) + let grant = user + .grants + .iter() + .find(|x| x.token == token) + .unwrap() + .clone(); + + // check token expiry + if check_expiration { + let now = unix_epoch_timestamp(); + let delta = now - grant.last_updated; + + if delta > 604_800_000 { + return Err(Error::MiscError("Token expired".to_string())); + } + } + + // ... + Ok((grant, user)) } /// Create a new user in the database. diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index b5db29a..52362f5 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -312,7 +312,7 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } - // add journal page owner as admin + // add community owner as admin self.create_membership(CommunityMembership::new( data.owner, data.id, diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql index f70e399..575ce5c 100644 --- a/crates/core/src/database/drivers/sql/create_apps.sql +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS apps ( redirect TEXT NOT NULL, quota_status TEXT NOT NULL, banned INT NOT NULL, - grants INT NOT NULL + grants INT NOT NULL, + scopes TEXT NOT NULL ) diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index 6386b38..8c2a8f2 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -11,7 +11,7 @@ use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { - /// Get a [`UserBlock`] from an SQL row. + /// Get an [`IpBlock`] from an SQL row. pub(crate) fn get_ipblock_from_row( #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, #[cfg(feature = "postgres")] x: &PostgresRow, @@ -79,7 +79,7 @@ impl DataManager { /// Create a new user block in the database. /// /// # Arguments - /// * `data` - a mock [`UserBlock`] object to insert + /// * `data` - a mock [`IpBlock`] object to insert pub async fn create_ipblock(&self, data: IpBlock) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 85966eb..76fbe2b 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -19,7 +19,7 @@ use oiseau::PostgresRow; use oiseau::{execute, get, query_row, query_rows, params}; impl DataManager { - /// Get a [`JournalEntry`] from an SQL row. + /// Get a [`CommunityMembership`] from an SQL row. pub(crate) fn get_membership_from_row( #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, #[cfg(feature = "postgres")] x: &PostgresRow, diff --git a/crates/core/src/database/pollvotes.rs b/crates/core/src/database/pollvotes.rs index eb5d954..f354b32 100644 --- a/crates/core/src/database/pollvotes.rs +++ b/crates/core/src/database/pollvotes.rs @@ -76,7 +76,7 @@ impl DataManager { // get poll and check permission let poll = self.get_poll_by_id(data.poll_id).await?; - let now = unix_epoch_timestamp() as usize; + let now = unix_epoch_timestamp(); let diff = now - poll.created; if diff > poll.expires { diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 5a6b2f1..ac28744 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -269,7 +269,7 @@ impl DataManager { if post.poll_id != 0 { Ok(Some(match self.get_poll_by_id(post.poll_id).await { Ok(p) => { - let expired = (unix_epoch_timestamp() as usize) - p.created > p.expires; + let expired = unix_epoch_timestamp() - p.created > p.expires; ( p, self.get_pollvote_by_owner_poll(user.id, post.poll_id) @@ -2022,7 +2022,7 @@ impl DataManager { } // update context - y.context.edited = unix_epoch_timestamp() as usize; + y.context.edited = unix_epoch_timestamp(); self.update_post_context(id, user, y.context).await?; // return @@ -2075,7 +2075,7 @@ impl DataManager { } // update context - y.context.edited = unix_epoch_timestamp() as usize; + y.context.edited = unix_epoch_timestamp(); self.update_post_context(id, user, y.context).await?; // return diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index e6fd614..713df48 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +use crate::model::oauth::AppScope; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum AppQuota { @@ -33,6 +34,37 @@ pub struct ThirdPartyApp { /// Upon accepting a grant request, the user will be redirected to this URL /// with a query parameter named `token`, which should be saved by the app /// for future authentication. + /// + /// The developer dashboard lists the URL you should send users to in order to + /// create a grant on their account in the information section under the label + /// "Grant URL". + /// + /// Any search parameters sent with your grant URL (such as an internal user ID) + /// will also be sent back when the user is redirected to your redirect URL. + /// + /// You can use this behaviour to keep track of what user you should save the grant + /// token under. + /// + /// 1. Redirect user to grant URL with their ID: `{grant_url}?my_app_user_id={id}` + /// 2. In your redirect endpoint, read that ID and the added `token` parameter to + /// store the `token` under the given `my_app_user_id` + /// + /// The redirect URL will also have a `verifier` search parameter appended. + /// This verifier is required to refresh the grant's token (which is what is + /// used in the `Atto-Grant` cookie). + /// + /// Tokens only last a week after they were generated (with the verifier), + /// but you can refresh them by sending a request to: + /// `{tetratto}/api/v1/auth/user/{user_id}/grants/{app_id}/refresh`. + /// + /// Tetratto will generate the verifier and challenge for you. The challenge + /// is an SHA-256 hashed + base64 url encoded version of the verifier. This means + /// 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 + /// contain the token you received from either the initial connection, + /// or a token refresh. pub redirect: String, /// The app's quota status, which determines how many grants the app is allowed to maintain. pub quota_status: AppQuota, @@ -40,6 +72,15 @@ pub struct ThirdPartyApp { pub banned: bool, /// The number of accepted grants the app maintains. pub grants: usize, + /// The scopes used for every grant the app maintains. + /// + /// These scopes are only cloned into **new** grants created for the app. + /// An app *cannot* change scopes and have them affect users who already have the + /// app connected. Users must delete the app's grant and authenticate it again + /// to update their scopes. + /// + /// Your app should handle informing users when scopes change. + pub scopes: Vec, } impl ThirdPartyApp { @@ -47,7 +88,7 @@ impl ThirdPartyApp { pub fn new(title: String, owner: usize, homepage: String, redirect: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, title, homepage, @@ -55,6 +96,7 @@ impl ThirdPartyApp { quota_status: AppQuota::Limited, banned: false, grants: 0, + scopes: Vec::new(), } } } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index f02c8ce..8c12f76 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -251,7 +251,7 @@ impl User { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), username, password, salt, @@ -262,7 +262,7 @@ impl User { notification_count: 0, follower_count: 0, following_count: 0, - last_seen: unix_epoch_timestamp() as usize, + last_seen: unix_epoch_timestamp(), totp: String::new(), recovery_codes: Vec::new(), post_count: 0, @@ -312,7 +312,7 @@ impl User { ( ip.to_string(), tetratto_shared::hash::hash(unhashed), - unix_epoch_timestamp() as usize, + unix_epoch_timestamp(), ), ) } @@ -460,7 +460,7 @@ impl Notification { pub fn new(title: String, content: String, owner: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), title, content, owner, @@ -483,7 +483,7 @@ impl UserFollow { pub fn new(initiator: usize, receiver: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), initiator, receiver, } @@ -511,7 +511,7 @@ impl UserBlock { pub fn new(initiator: usize, receiver: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), initiator, receiver, } @@ -531,7 +531,7 @@ impl IpBlock { pub fn new(initiator: usize, receiver: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), initiator, receiver, } @@ -551,7 +551,7 @@ impl IpBan { pub fn new(ip: String, moderator: usize, reason: String) -> Self { Self { ip, - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), reason, moderator, } @@ -572,7 +572,7 @@ impl UserWarning { pub fn new(user: usize, moderator: usize, content: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), receiver: user, moderator, content, diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs index 5bbefdf..5b95d2e 100644 --- a/crates/core/src/model/channels.rs +++ b/crates/core/src/model/channels.rs @@ -33,7 +33,7 @@ impl Channel { id: Snowflake::new().to_string().parse::().unwrap(), community, owner, - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), position, @@ -78,7 +78,7 @@ pub struct Message { impl Message { pub fn new(channel: usize, owner: usize, content: String) -> Self { - let now = unix_epoch_timestamp() as usize; + let now = unix_epoch_timestamp(); Self { id: Snowflake::new().to_string().parse::().unwrap(), diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 2015a4f..ed0d5b9 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -33,7 +33,7 @@ impl Community { pub fn new(title: String, owner: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), title: title.clone(), context: CommunityContext { display_name: title, @@ -157,7 +157,7 @@ impl CommunityMembership { pub fn new(owner: usize, community: usize, role: CommunityPermission) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, community, role, @@ -273,7 +273,7 @@ impl Post { ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), content, owner, community, @@ -353,7 +353,7 @@ impl Question { ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, receiver, content, @@ -387,7 +387,7 @@ impl PostDraft { pub fn new(content: String, owner: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), content, owner, } @@ -426,7 +426,7 @@ impl Poll { Self { id: Snowflake::new().to_string().parse::().unwrap(), owner, - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), expires, // options option_a, @@ -491,7 +491,7 @@ impl PollVote { Self { id: Snowflake::new().to_string().parse::().unwrap(), owner, - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), poll_id, vote, } diff --git a/crates/core/src/model/communities_permissions.rs b/crates/core/src/model/communities_permissions.rs index d8f415e..1d8f0da 100644 --- a/crates/core/src/model/communities_permissions.rs +++ b/crates/core/src/model/communities_permissions.rs @@ -5,7 +5,7 @@ use serde::{ }; bitflags! { - /// Fine-grained journal permissions built using bitwise operations. + /// Fine-grained community permissions built using bitwise operations. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CommunityPermission: u32 { const DEFAULT = 1 << 0; diff --git a/crates/core/src/model/moderation.rs b/crates/core/src/model/moderation.rs index 12b1fbc..3814a38 100644 --- a/crates/core/src/model/moderation.rs +++ b/crates/core/src/model/moderation.rs @@ -16,7 +16,7 @@ impl AuditLogEntry { pub fn new(moderator: usize, content: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), moderator, content, } @@ -38,7 +38,7 @@ impl Report { pub fn new(owner: usize, content: String, asset: usize, asset_type: AssetType) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, content, asset, diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 9985dca..8218b05 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -5,7 +5,6 @@ use super::{Result, Error}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AuthGrant { - pub id: usize, /// The ID of the application associated with this grant. pub app: usize, /// The code challenge for PKCE verifiers associated with this grant. @@ -22,6 +21,9 @@ pub struct AuthGrant { /// The access token associated with the account. This is **not** the same as /// regular account access tokens, as the token can only be used with the requested `scopes`. pub token: String, + /// The time in which the token was last refreshed. Tokens should stop being + /// accepted after a week has passed since this time. + pub last_updated: usize, /// Scopes define what the grant's token is actually allowed to do. /// /// No scope shall ever be allowed to change scopes or manage grants on behalf of the user. diff --git a/crates/core/src/model/reactions.rs b/crates/core/src/model/reactions.rs index 9987e03..827c74e 100644 --- a/crates/core/src/model/reactions.rs +++ b/crates/core/src/model/reactions.rs @@ -29,7 +29,7 @@ impl Reaction { pub fn new(owner: usize, asset: usize, asset_type: AssetType, is_like: bool) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, asset, asset_type, diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index ce11f40..4ffcb78 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -33,7 +33,7 @@ impl ActionRequest { pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, action_type, linked_asset, @@ -44,7 +44,7 @@ impl ActionRequest { pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self { Self { id, - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, action_type, linked_asset, diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index 88dd855..809a0e0 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -60,7 +60,7 @@ impl UserStack { pub fn new(name: String, owner: usize, users: Vec) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, name, users, diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 97b2131..d502697 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -48,7 +48,7 @@ impl MediaUpload { pub fn new(what: MediaType, owner: usize) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, what, } @@ -108,7 +108,7 @@ impl CustomEmoji { ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp() as usize, + created: unix_epoch_timestamp(), owner, community, upload_id, diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index d8d8685..f3f3e62 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "6.0.0" +version = "7.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/l10n/src/lib.rs b/crates/l10n/src/lib.rs index 3840c53..7664b42 100644 --- a/crates/l10n/src/lib.rs +++ b/crates/l10n/src/lib.rs @@ -59,7 +59,7 @@ impl LangFile { } } -/// Read the `langs` directory and return a [`Hashmap`] containing all files +/// Read the `langs` directory and return a Hashmap containing all files pub fn read_langs() -> HashMap { let mut out = HashMap::new(); diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index da11da0..43caae5 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "6.0.0" +version = "7.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/src/snow.rs b/crates/shared/src/snow.rs index e17a6a7..830da95 100644 --- a/crates/shared/src/snow.rs +++ b/crates/shared/src/snow.rs @@ -18,7 +18,7 @@ impl Snowflake { Builder::new().epoch(UNIX_EPOCH + Duration::from_millis(EPOCH_2024)) } - /// Create a new [`AlmostSnowflake`] + /// Create a new [`Snowflake`] pub fn new() -> Self { Self( Self::builder() diff --git a/crates/shared/src/time.rs b/crates/shared/src/time.rs index 970b5ea..206abd1 100644 --- a/crates/shared/src/time.rs +++ b/crates/shared/src/time.rs @@ -1,14 +1,14 @@ use chrono::{TimeZone, Utc}; use std::time::{SystemTime, UNIX_EPOCH}; -/// Get a [`u128`] timestamp -pub fn unix_epoch_timestamp() -> u128 { +/// Get a [`usize`] timestamp +pub fn unix_epoch_timestamp() -> usize { let right_now = SystemTime::now(); let time_since = right_now .duration_since(UNIX_EPOCH) .expect("Time travel is not allowed"); - time_since.as_millis() + time_since.as_millis() as usize } /// Get a [`i64`] timestamp from the given `year` epoch diff --git a/sql_changes/apps_scopes.sql b/sql_changes/apps_scopes.sql new file mode 100644 index 0000000..3fd367f --- /dev/null +++ b/sql_changes/apps_scopes.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps +ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]';