From f423daf2fc35f5fed20a79e7b04b9ac7f9fae354 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:34:10 -0400 Subject: [PATCH] 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;