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.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"

View file

@ -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
}
};
}

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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\", [

View file

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

View file

@ -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!\") }}")

View file

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

View file

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

View file

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

View file

@ -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")

View file

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

View file

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

View file

@ -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\",

View file

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

View file

@ -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) {

View file

@ -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

View file

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

View file

@ -114,7 +114,6 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(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()),
}
}
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 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,
}

View file

@ -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<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 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()))

View file

@ -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<Vec<AppData>> {
pub async fn query_app_data(&self, query: AppDataQuery) -> Result<AppDataQueryResult> {
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:{}");
}

View file

@ -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<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!(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,
};
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
impl DataManager {

View file

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

View file

@ -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<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 {
@ -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::<usize>().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<AppData>),
}

View file

@ -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),
})
}

View file

@ -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;
}

View file

@ -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))
}

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;