add: extended app storage limits

This commit is contained in:
trisua 2025-07-20 20:19:33 -04:00
parent c757ddb77a
commit 9aed5de097
12 changed files with 143 additions and 20 deletions

View file

@ -258,6 +258,7 @@ version = "1.0.0"
"developer:label.change_homepage" = "Change homepage" "developer:label.change_homepage" = "Change homepage"
"developer:label.change_redirect" = "Change redirect URL" "developer:label.change_redirect" = "Change redirect URL"
"developer:label.change_quota_status" = "Change quota status" "developer:label.change_quota_status" = "Change quota status"
"developer:label.change_storage_capacity" = "Change storage capacity"
"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"

View file

@ -44,6 +44,28 @@
("value" "Unlimited") ("value" "Unlimited")
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}") ("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
(text "Unlimited"))))) (text "Unlimited")))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "database-zap"))
(b (str (text "developer:label.change_storage_capacity"))))
(div
("class" "card")
(select
("onchange" "save_storage_capacity(event)")
(option
("value" "Tier1")
("selected" "{% if app.storage_capacity == 'Tier1' -%}true{% else %}false{%- endif %}")
(text "Tier 1 (25 MB)"))
(option
("value" "Tier2")
("selected" "{% if app.storage_capacity == 'Tier2' -%}true{% else %}false{%- endif %}")
(text "Tier 2 (50 MB)"))
(option
("value" "Tier3")
("selected" "{% if app.storage_capacity == 'Tier3' -%}true{% else %}false{%- endif %}")
(text "Tier 3 (100 MB)")))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("class" "card-nest") ("class" "card-nest")
@ -232,6 +254,26 @@
}); });
}; };
globalThis.save_storage_capacity = (event) => {
const selected = event.target.selectedOptions[0];
fetch(\"/api/v1/apps/{{ app.id }}/storage_capacity\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
storage_capacity: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_title = async (e) => { globalThis.change_title = async (e) => {
e.preventDefault(); e.preventDefault();

View file

@ -72,7 +72,7 @@ pub async fn create_request(
// check size // check size
let new_size = app.data_used + req.value.len(); let new_size = app.data_used + req.value.len();
if new_size > AppData::user_limit(&owner) { if new_size > AppData::user_limit(&owner, &app) {
return Json(Error::AppHitStorageLimit.into()); return Json(Error::AppHitStorageLimit.into());
} }
@ -155,7 +155,7 @@ pub async fn update_value_request(
let size_without = app.data_used - app_data.value.len(); let size_without = app.data_used - app_data.value.len();
let new_size = size_without + req.value.len(); let new_size = size_without + req.value.len();
if new_size > AppData::user_limit(&owner) { if new_size > AppData::user_limit(&owner, &app) {
return Json(Error::AppHitStorageLimit.into()); return Json(Error::AppHitStorageLimit.into());
} }

View file

@ -15,7 +15,7 @@ use tetratto_core::model::{
ApiReturn, Error, ApiReturn, Error,
}; };
use tetratto_shared::{hash::random_id, unix_epoch_timestamp}; use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
use super::CreateApp; use super::{CreateApp, UpdateAppStorageCapacity};
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,
@ -138,6 +138,35 @@ pub async fn update_quota_status_request(
} }
} }
pub async fn update_storage_capacity_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppStorageCapacity>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_APPS) {
return Json(Error::NotAllowed.into());
}
match data
.update_app_storage_capacity(id, req.storage_capacity)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "App updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_scopes_request( pub async fn update_scopes_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,

View file

@ -20,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::{AppDataSelectMode, AppDataSelectQuery, AppQuota}, apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName, auth::AchievementName,
communities::{ communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
@ -432,6 +432,10 @@ pub fn routes() -> Router {
"/apps/{id}/quota_status", "/apps/{id}/quota_status",
post(apps::update_quota_status_request), post(apps::update_quota_status_request),
) )
.route(
"/apps/{id}/storage_capacity",
post(apps::update_storage_capacity_request),
)
.route("/apps/{id}/scopes", post(apps::update_scopes_request)) .route("/apps/{id}/scopes", post(apps::update_scopes_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)) .route("/apps/{id}/roll", post(apps::roll_api_key_request))
@ -1031,6 +1035,11 @@ pub struct UpdateAppQuotaStatus {
pub quota_status: AppQuota, pub quota_status: AppQuota,
} }
#[derive(Deserialize)]
pub struct UpdateAppStorageCapacity {
pub storage_capacity: DeveloperPassStorageQuota,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAppScopes { pub struct UpdateAppScopes {
pub scopes: Vec<AppScope>, pub scopes: Vec<AppScope>,

View file

@ -62,7 +62,7 @@ pub async fn app_request(
)); ));
} }
let data_limit = AppData::user_limit(&user); let data_limit = AppData::user_limit(&user, &app);
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;

View file

@ -1,6 +1,6 @@
use oiseau::cache::Cache; use oiseau::cache::Cache;
use crate::model::{ use crate::model::{
apps::{AppQuota, ThirdPartyApp}, apps::{AppQuota, ThirdPartyApp, DeveloperPassStorageQuota},
auth::User, auth::User,
oauth::AppScope, oauth::AppScope,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
@ -25,6 +25,7 @@ impl DataManager {
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)), api_key: get!(x->10(String)),
data_used: get!(x->11(i32)) as usize, data_used: get!(x->11(i32)) as usize,
storage_capacity: serde_json::from_str(&get!(x->12(String))).unwrap(),
} }
} }
@ -95,7 +96,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, $11, $12)", "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -108,7 +109,8 @@ impl DataManager {
&(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.api_key,
&(data.data_used as i32) &(data.data_used as i32),
&serde_json::to_string(&data.storage_capacity).unwrap(),
] ]
); );
@ -167,6 +169,7 @@ impl DataManager {
auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);
auto_method!(update_app_storage_capacity(DeveloperPassStorageQuota)@get_app_by_id -> "UPDATE apps SET storage_capacity = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app);
auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);
auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app);

View file

@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS apps (
banned INT NOT NULL, banned INT NOT NULL,
grants INT NOT NULL, grants INT NOT NULL,
scopes TEXT NOT NULL, scopes TEXT NOT NULL,
data_used INT NOT NULL CHECK (data_used >= 0) data_used INT NOT NULL CHECK (data_used >= 0),
storage_capacity TEXT NOT NULL
) )

View file

@ -5,3 +5,7 @@ ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]';
-- users is_deactivated -- users is_deactivated
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0; ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0;
-- apps storage_capacity
ALTER TABLE apps
ADD COLUMN IF NOT EXISTS storage_capacity TEXT DEFAULT '"Tier1"';

View file

@ -21,6 +21,35 @@ impl Default for AppQuota {
} }
} }
/// The storage limit for apps where the owner has a developer pass.
///
/// Free users are always limited to 500 KB.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum DeveloperPassStorageQuota {
/// The app is limited to 25 MB.
Tier1,
/// The app is limited to 50 MB.
Tier2,
/// The app is limited to 100 MB.
Tier3,
}
impl Default for DeveloperPassStorageQuota {
fn default() -> Self {
Self::Tier1
}
}
impl DeveloperPassStorageQuota {
pub fn limit(&self) -> usize {
match self {
DeveloperPassStorageQuota::Tier1 => 26214400,
DeveloperPassStorageQuota::Tier2 => 52428800,
DeveloperPassStorageQuota::Tier3 => 104857600,
}
}
}
/// An app is required to request grants on user accounts. /// An app is required to request grants on user accounts.
/// ///
/// Users must approve grants through a web portal. /// Users must approve grants through a web portal.
@ -90,6 +119,8 @@ pub struct ThirdPartyApp {
pub api_key: String, pub api_key: String,
/// The number of bytes the app's app_data rows are using. /// The number of bytes the app's app_data rows are using.
pub data_used: usize, pub data_used: usize,
/// The app's storage capacity.
pub storage_capacity: DeveloperPassStorageQuota,
} }
impl ThirdPartyApp { impl ThirdPartyApp {
@ -102,12 +133,13 @@ impl ThirdPartyApp {
title, title,
homepage, homepage,
redirect, redirect,
quota_status: AppQuota::Limited, quota_status: AppQuota::default(),
banned: false, banned: false,
grants: 0, grants: 0,
scopes: Vec::new(), scopes: Vec::new(),
api_key: String::new(), api_key: String::new(),
data_used: 0, data_used: 0,
storage_capacity: DeveloperPassStorageQuota::default(),
} }
} }
} }
@ -132,12 +164,16 @@ impl AppData {
} }
/// Get the data limit of a given user. /// Get the data limit of a given user.
pub fn user_limit(user: &User) -> usize { pub fn user_limit(user: &User, app: &ThirdPartyApp) -> usize {
if user if user
.secondary_permissions .secondary_permissions
.check(SecondaryPermission::DEVELOPER_PASS) .check(SecondaryPermission::DEVELOPER_PASS)
{ {
PASS_DATA_LIMIT if app.storage_capacity != DeveloperPassStorageQuota::Tier1 {
app.storage_capacity.limit()
} else {
PASS_DATA_LIMIT
}
} else { } else {
FREE_DATA_LIMIT FREE_DATA_LIMIT
} }

View file

@ -1,5 +1,4 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::{ use super::{
oauth::AuthGrant, oauth::AuthGrant,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},

View file

@ -159,12 +159,11 @@ pub fn parse_alignment(input: &str) -> String {
let mut buffer = String::new(); let mut buffer = String::new();
for line in lines { for line in lines {
match line { if line.starts_with("```") {
"```" => { is_in_pre = !is_in_pre;
is_in_pre = !is_in_pre; output.push_str(&format!("{line}\n"));
output.push_str(&format!("{line}\n")); } else {
} parse_alignment_line(line, &mut output, &mut buffer, is_in_pre)
_ => parse_alignment_line(line, &mut output, &mut buffer, is_in_pre),
} }
} }