add: app_data api

This commit is contained in:
trisua 2025-07-17 13:34:10 -04:00
parent 5c520f4308
commit f423daf2fc
38 changed files with 410 additions and 91 deletions

View file

@ -253,6 +253,9 @@ version = "1.0.0"
"developer:label.manage_scopes" = "Manage scopes" "developer:label.manage_scopes" = "Manage scopes"
"developer:label.scopes" = "Scopes" "developer:label.scopes" = "Scopes"
"developer:label.guides_and_help" = "Guides & help" "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.delete" = "Delete app"
"developer:action.authorize" = "Authorize" "developer:action.authorize" = "Authorize"

View file

@ -419,3 +419,20 @@ macro_rules! ignore_users_gen {
.concat() .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
}
};
}

View file

@ -404,7 +404,7 @@ select:focus {
.poll_bar { .poll_bar {
background-color: var(--color-primary); background-color: var(--color-primary);
border-radius: var(--radius); border-radius: var(--radius);
height: 25px; height: 24px;
} }
.poll_option { .poll_option {
@ -413,6 +413,22 @@ select:focus {
overflow-wrap: anywhere; 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"] { input[type="checkbox"] {
--color: #c9b1bc; --color: #c9b1bc;
appearance: none; appearance: none;

View file

@ -159,7 +159,6 @@
(text "{{ icon \"notepad-text-dashed\" }}")) (text "{{ icon \"notepad-text-dashed\" }}"))
(text "{%- endif %} {%- endif %}") (text "{%- endif %} {%- endif %}")
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))) (text "{{ text \"communities:action.create\" }}"))))))
(text "{% if not quoting -%}") (text "{% if not quoting -%}")
(script (script

View file

@ -29,7 +29,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (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 %}") (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
(div (div

View file

@ -39,7 +39,6 @@
("class" "flex gap-2") ("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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button (button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))))) (text "{{ text \"requests:label.answer\" }}")))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div

View file

@ -28,7 +28,6 @@
("maxlength" "32") ("maxlength" "32")
("value" "{{ text }}"))) ("value" "{{ text }}")))
(button (button
("class" "primary")
(text "{{ text \"dialog:action.continue\" }}")))) (text "{{ text \"dialog:action.continue\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")

View file

@ -135,7 +135,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))
@ -190,7 +189,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}")))) (text "{{ icon \"check\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")
@ -213,7 +211,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp") ("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("class" "fade")
@ -245,7 +242,6 @@
("required" "") ("required" "")
("minlength" "18"))) ("minlength" "18")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.select\" }}"))))) (text "{{ text \"communities:action.select\" }}")))))
(div (div
("class" "card flex flex-col gap-2 w-full") ("class" "card flex flex-col gap-2 w-full")
@ -296,7 +292,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{% for channel in channels %}") (text "{% for channel in channels %}")
(div (div

View file

@ -779,7 +779,6 @@
(div (div
("class" "flex gap-2") ("class" "flex gap-2")
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")) (text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}") (text "{% if drawing_enabled -%}")
@ -1879,7 +1878,6 @@
("id" "join_or_leave") ("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}") (text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button (button
("class" "primary")
("onclick" "join_community()") ("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}") (text "{{ icon \"circle-plus\" }}")
(span (span

View file

@ -10,11 +10,27 @@
(div (div
("id" "manage_fields") ("id" "manage_fields")
("class" "card lowered flex flex-col gap-2") ("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 -%}") (text "{% if is_helper -%}")
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "infinity"))
(b (str (text "developer:label.change_quota_status")))) (b (str (text "developer:label.change_quota_status"))))
(div (div
("class" "card") ("class" "card")
@ -32,7 +48,8 @@
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "pencil"))
(b (str (text "developer:label.change_title")))) (b (str (text "developer:label.change_title"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -50,14 +67,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "house"))
(b (str (text "developer:label.change_homepage")))) (b (str (text "developer:label.change_homepage"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -75,14 +92,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "goal"))
(b (str (text "developer:label.change_redirect")))) (b (str (text "developer:label.change_redirect"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -100,14 +117,14 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
("class" "card small") ("class" "card small flex items-center gap-2")
(icon (text "telescope"))
(b (str (text "developer:label.manage_scopes")))) (b (str (text "developer:label.manage_scopes"))))
(form (form
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
@ -140,10 +157,22 @@
(icon (text "external-link")) (text "Docs")))) (icon (text "external-link")) (text "Docs"))))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (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 (div
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(ul (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 () => { globalThis.delete_app = async () => {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [

View file

@ -57,7 +57,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
; app listing ; app listing

View file

@ -30,7 +30,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{% else %}") (text "{% else %}")
(text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}")

View file

@ -253,7 +253,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))))) (text "{{ text \"general:action.save\" }}")))))))

View file

@ -59,7 +59,6 @@
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) (option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
(text "{%- endfor %}"))) (text "{%- endfor %}")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")) (text "{{ text \"communities:action.create\" }}"))
(details (details

View file

@ -45,7 +45,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div

View file

@ -17,7 +17,7 @@
(p (text "You'll find out what each achievement is when you get it, so look around!")) (p (text "You'll find out what each achievement is when you get it, so look around!"))
(hr) (hr)
(span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%")) (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 (div
("class" "card-nest") ("class" "card-nest")

View file

@ -132,7 +132,6 @@
(text "{{ text \"auth:action.ip_block\" }}"))) (text "{{ text \"auth:action.ip_block\" }}")))
(button (button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))))) (text "{{ text \"requests:label.answer\" }}")))))
(text "{% endfor %}"))) (text "{% endfor %}")))

View file

@ -28,7 +28,6 @@
("required" "") ("required" "")
("minlength" "16"))) ("minlength" "16")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))) (text "{{ text \"communities:action.create\" }}")))))
(script (script

View file

@ -298,7 +298,6 @@
("minlength" "2") ("minlength" "2")
(text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (text "{{ profile.ban_reason|remove_script_tags|safe }}")))
(button (button
("class" "primary")
(str (text "general:action.save"))))) (str (text "general:action.save")))))
(div (div
("class" "card-nest w-full") ("class" "card-nest w-full")
@ -396,6 +395,7 @@
MANAGE_DOMAINS: 1 << 2, MANAGE_DOMAINS: 1 << 2,
MANAGE_SERVICES: 1 << 3, MANAGE_SERVICES: 1 << 3,
MANAGE_PRODUCTS: 1 << 4, MANAGE_PRODUCTS: 1 << 4,
DEVELOPER_PASS: 1 << 5,
}, },
\"secondary_role\", \"secondary_role\",
\"add_permission_to_secondary_role\", \"add_permission_to_secondary_role\",

View file

@ -37,7 +37,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "4096"))) ("maxlength" "4096")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(div (div
("class" "card-nest") ("class" "card-nest")

View file

@ -80,7 +80,6 @@
("class" "flex gap-2") ("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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))) (text "{{ text \"communities:action.create\" }}")))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div
@ -279,7 +278,6 @@
("class" "flex gap-2") ("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
(button (button
("class" "primary")
(text "{{ text \"general:action.save\" }}"))))) (text "{{ text \"general:action.save\" }}")))))
(script (script
(text "async function edit_post_from_form(e) { (text "async function edit_post_from_form(e) {

View file

@ -276,7 +276,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))
@ -305,7 +304,6 @@
("minlength" "6") ("minlength" "6")
("autocomplete" "off"))) ("autocomplete" "off")))
(button (button
("class" "primary")
(text "{{ icon \"trash\" }}") (text "{{ icon \"trash\" }}")
(span (span
(text "{{ text \"general:action.delete\" }}"))))) (text "{{ text \"general:action.delete\" }}")))))
@ -419,7 +417,6 @@
("minlength" "6") ("minlength" "6")
("autocomplete" "off"))) ("autocomplete" "off")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}"))))))))) (text "{{ text \"general:action.save\" }}")))))))))
@ -908,7 +905,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("class" "fade")
@ -936,7 +932,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content")) ("class" "w-content"))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}"))) (text "{{ icon \"check\" }}")))
(span (span
("class" "fade") ("class" "fade")
@ -1054,7 +1049,6 @@
("class" "card w-full flex flex-wrap gap-2") ("class" "card w-full flex flex-wrap gap-2")
("ui_ident" "import_export") ("ui_ident" "import_export")
(button (button
("class" "primary")
("onclick" "import_theme_settings()") ("onclick" "import_theme_settings()")
(text "{{ icon \"upload\" }}") (text "{{ icon \"upload\" }}")
(span (span

View file

@ -29,7 +29,6 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))) (text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div

View file

@ -114,7 +114,6 @@
("required" "") ("required" "")
("minlength" "2"))) ("minlength" "2")))
(button (button
("class" "primary")
(text "{{ icon \"check\" }}") (text "{{ icon \"check\" }}")
(span (span
(text "{{ text \"general:action.save\" }}")))))) (text "{{ text \"general:action.save\" }}"))))))

View file

@ -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<State>,
Json(req): Json<QueryAppData>,
) -> 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<State>,
Json(req): Json<InsertAppData>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppDataValue>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -239,3 +239,34 @@ pub async fn grant_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn roll_api_key_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -1,3 +1,4 @@
pub mod app_data;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod channels; pub mod channels;
@ -19,9 +20,9 @@ use axum::{
routing::{any, delete, get, post, put}, routing::{any, delete, get, post, put},
Router, Router,
}; };
use serde::Deserialize; use serde::{Deserialize};
use tetratto_core::model::{ use tetratto_core::model::{
apps::AppQuota, apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota},
auth::AchievementName, auth::AchievementName,
communities::{ communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
@ -32,7 +33,7 @@ use tetratto_core::model::{
littleweb::{DomainData, DomainTld, ServiceFsEntry}, littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope, oauth::AppScope,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
products::{ProductType, ProductPrice}, products::{ProductPrice, ProductType},
reactions::AssetType, reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort}, stacks::{StackMode, StackPrivacy, StackSort},
}; };
@ -419,6 +420,7 @@ pub fn routes() -> Router {
) )
// apps // apps
.route("/apps", post(apps::create_request)) .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}/title", post(apps::update_title_request))
.route("/apps/{id}/homepage", post(apps::update_homepage_request)) .route("/apps/{id}/homepage", post(apps::update_homepage_request))
.route("/apps/{id}/redirect", post(apps::update_redirect_request)) .route("/apps/{id}/redirect", post(apps::update_redirect_request))
@ -427,8 +429,13 @@ pub fn routes() -> Router {
post(apps::update_quota_status_request), post(apps::update_quota_status_request),
) )
.route("/apps/{id}/scopes", post(apps::update_scopes_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}/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 // warnings
.route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", get(auth::user_warnings::get_request))
.route("/warnings/{id}", post(auth::user_warnings::create_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request))
@ -1148,3 +1155,20 @@ pub struct UpdateProductPrice {
pub struct UpdateUploadAlt { pub struct UpdateUploadAlt {
pub alt: String, 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,
}

View file

@ -6,7 +6,7 @@ use axum::{
Extension, Extension,
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{permissions::FinePermission, Error}; use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error};
/// `/developer` /// `/developer`
pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> 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 lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("app", &app); context.insert("app", &app);
context.insert("data_limit", &data_limit);
// return // return
Ok(Html(data.1.render("developer/app.html", &context).unwrap())) Ok(Html(data.1.render("developer/app.html", &context).unwrap()))

View file

@ -1,17 +1,17 @@
use oiseau::cache::Cache; 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 crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
use oiseau::PostgresRow; pub const FREE_DATA_LIMIT: usize = 512_000;
pub const PASS_DATA_LIMIT: usize = 5_242_880;
use oiseau::{execute, get, query_rows, params};
impl DataManager { impl DataManager {
/// Get a [`AppData`] from an SQL row. /// Get a [`AppData`] from an SQL row.
pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData {
AppData { AppData {
id: get!(x->0(i64)) as usize, id: get!(x->0(i64)) as usize,
owner: get!(x->1(i64)) as usize,
app: get!(x->2(i64)) as usize, app: get!(x->2(i64)) as usize,
key: get!(x->3(String)), key: get!(x->3(String)),
value: get!(x->4(String)), value: get!(x->4(String)),
@ -48,24 +48,39 @@ impl DataManager {
/// ///
/// # Arguments /// # Arguments
/// * `id` - the ID of the user to fetch app_data for /// * `id` - the ID of the user to fetch app_data for
pub async fn get_app_data_by_owner(&self, id: usize) -> Result<Vec<AppData>> { pub async fn query_app_data(&self, query: AppDataQuery) -> Result<AppDataQueryResult> {
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
}; };
let res = query_rows!( let query_str = query.to_string().replace(
&conn, "%q%",
"SELECT * FROM app_data WHERE owner = $1 ORDER BY created DESC", &match query.query {
&[&(id as i64)], AppDataSelectQuery::Like(_, _) => format!("v LIKE $1"),
|x| { Self::get_app_data_from_row(x) } },
); );
if res.is_err() { let res = match query.mode {
return Err(Error::GeneralNotFound("app_data".to_string())); 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; const MAXIMUM_FREE_APP_DATA: usize = 5;
@ -114,10 +129,9 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO app_data VALUES ($1, $2, $3, $4, $5)", "INSERT INTO app_data VALUES ($1, $2, $3, $4)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.owner as i64),
&(data.app as i64), &(data.app as i64),
&data.key, &data.key,
&data.value &data.value
@ -131,18 +145,7 @@ impl DataManager {
Ok(data) Ok(data)
} }
pub async fn delete_app_data(&self, id: usize, user: &User) -> Result<()> { pub async fn delete_app_data(&self, id: usize) -> Result<()> {
let app_data = self.get_app_data_by_id(id).await?;
let app = self.get_app_by_id(app_data.app).await?;
// check user permission
if ((user.id != app.owner) | (user.id != app_data.owner))
&& !user.permissions.check(FinePermission::MANAGE_APPS)
{
return Err(Error::NotAllowed);
}
// ...
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
Ok(c) => c, Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -158,6 +161,6 @@ impl DataManager {
Ok(()) 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_key(&str) -> "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_value(&str) -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}");
} }

View file

@ -7,10 +7,7 @@ use crate::model::{
Error, Result, Error, Result,
}; };
use crate::{auto_method, DataManager}; 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};
impl DataManager { impl DataManager {
/// Get a [`ThirdPartyApp`] from an SQL row. /// Get a [`ThirdPartyApp`] from an SQL row.
@ -26,10 +23,13 @@ impl DataManager {
banned: get!(x->7(i32)) as i8 == 1, banned: get!(x->7(i32)) as i8 == 1,
grants: get!(x->8(i32)) as usize, grants: get!(x->8(i32)) as usize,
scopes: serde_json::from_str(&get!(x->9(String))).unwrap(), 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_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. /// Get all apps by user.
/// ///
@ -90,7 +90,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &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![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -102,6 +102,8 @@ impl DataManager {
&{ if data.banned { 1 } else { 0 } }, &{ if data.banned { 1 } else { 0 } },
&(data.grants as i32), &(data.grants as i32),
&serde_json::to_string(&data.scopes).unwrap(), &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; 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(()) 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_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_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_scopes(Vec<AppScope>)@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_scopes(Vec<AppScope>)@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!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr);
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants);

View file

@ -5,7 +5,6 @@ use crate::model::{
communities_permissions::CommunityPermission, channels::Channel, communities_permissions::CommunityPermission, channels::Channel,
}; };
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
impl DataManager { impl DataManager {

View file

@ -1,4 +1,4 @@
mod app_data; pub mod app_data;
mod apps; mod apps;
mod audit_log; mod audit_log;
mod auth; mod auth;

View file

@ -2,7 +2,10 @@ use std::fmt::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; 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)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum AppQuota { pub enum AppQuota {
@ -83,6 +86,10 @@ pub struct ThirdPartyApp {
/// ///
/// Your app should handle informing users when scopes change. /// Your app should handle informing users when scopes change.
pub scopes: Vec<AppScope>, pub scopes: Vec<AppScope>,
/// 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 { impl ThirdPartyApp {
@ -99,6 +106,8 @@ impl ThirdPartyApp {
banned: false, banned: false,
grants: 0, grants: 0,
scopes: Vec::new(), scopes: Vec::new(),
api_key: String::new(),
data_used: 0,
} }
} }
} }
@ -106,7 +115,6 @@ impl ThirdPartyApp {
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppData { pub struct AppData {
pub id: usize, pub id: usize,
pub owner: usize,
pub app: usize, pub app: usize,
pub key: String, pub key: String,
pub value: String, pub value: String,
@ -114,15 +122,26 @@ pub struct AppData {
impl AppData { impl AppData {
/// Create a new [`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 { Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(), id: Snowflake::new().to_string().parse::<usize>().unwrap(),
owner,
app, app,
key, key,
value, 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)] #[derive(Serialize, Deserialize, Debug, Clone)]
@ -154,7 +173,8 @@ impl Display for AppDataSelectMode {
Self::One => "LIMIT 1".to_string(), Self::One => "LIMIT 1".to_string(),
Self::Many(order_by_top_level_key, limit, offset) => { Self::Many(order_by_top_level_key, limit, offset) => {
format!( 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 { impl Display for AppDataQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!( 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 self.app, self.mode
)) ))
} }
} }
#[derive(Serialize, Deserialize)]
pub enum AppDataQueryResult {
One(AppData),
Many(Vec<AppData>),
}

View file

@ -51,6 +51,7 @@ pub enum Error {
QuestionsDisabled, QuestionsDisabled,
RequiresSupporter, RequiresSupporter,
DrawingsDisabled, DrawingsDisabled,
AppHitStorageLimit,
Unknown, Unknown,
} }
@ -75,6 +76,7 @@ impl Display for Error {
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(),
Self::DrawingsDisabled => "You are not allowed to submit drawings there".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), _ => format!("An unknown error as occurred: ({:?})", self),
}) })
} }

View file

@ -177,6 +177,7 @@ bitflags! {
const MANAGE_DOMAINS = 1 << 2; const MANAGE_DOMAINS = 1 << 2;
const MANAGE_SERVICES = 1 << 3; const MANAGE_SERVICES = 1 << 3;
const MANAGE_PRODUCTS = 1 << 4; const MANAGE_PRODUCTS = 1 << 4;
const DEVELOPER_PASS = 1 << 5;
const _ = !0; const _ = !0;
} }

View file

@ -33,6 +33,18 @@ pub fn salt() -> String {
.collect() .collect()
} }
pub fn salt_len(len: usize) -> String {
rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
pub fn random_id() -> String { pub fn random_id() -> String {
hash(uuid()) hash(uuid())
} }
pub fn random_id_salted_len(len: usize) -> String {
hash(uuid() + &salt_len(len))
}

View file

@ -0,0 +1,2 @@
ALTER TABLE apps
ADD COLUMN api_key TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,2 @@
ALTER TABLE apps
ADD COLUMN data_used INT NOT NULL DEFAULT 0;