From 5c520f4308ae00daecaea5151f6673be7dfdef61 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 01:30:27 -0400 Subject: [PATCH] add: app_data table --- crates/core/src/database/app_data.rs | 163 ++++++++++++++++++ crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_app_data.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/apps.rs | 76 ++++++++ 6 files changed, 249 insertions(+) create mode 100644 crates/core/src/database/app_data.rs create mode 100644 crates/core/src/database/drivers/sql/create_app_data.sql diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs new file mode 100644 index 0000000..b614e85 --- /dev/null +++ b/crates/core/src/database/app_data.rs @@ -0,0 +1,163 @@ +use oiseau::cache::Cache; +use crate::model::{apps::AppData, auth::User, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; + +use oiseau::PostgresRow; + +use oiseau::{execute, get, query_rows, params}; + +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)), + } + } + + auto_method!(get_app_data_by_id(usize as i64)@get_app_data_from_row -> "SELECT * FROM app_data WHERE id = $1" --name="app_data" --returns=AppData --cache-key-tmpl="atto.app_data:{}"); + + /// Get all app_data by app. + /// + /// # Arguments + /// * `id` - the ID of the app to fetch app_data for + pub async fn get_app_data_by_app(&self, id: usize) -> 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 app = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all app_data by owner. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch app_data for + pub async fn get_app_data_by_owner(&self, id: usize) -> 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) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_APP_DATA: usize = 5; + const MAXIMUM_DATA_SIZE: usize = 205_000; + + /// Create a new app_data in the database. + /// + /// # Arguments + /// * `data` - a mock [`AppData`] object to insert + pub async fn create_app_data(&self, data: AppData) -> Result { + let app = self.get_app_by_id(data.app).await?; + + // check values + if data.key.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.key.len() > 32 { + return Err(Error::DataTooLong("key".to_string())); + } + + if data.value.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { + return Err(Error::DataTooLong("key".to_string())); + } + + // check number of app_data + let owner = self.get_user_by_id(app.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let app_data = self + .get_table_row_count_where("app_data", &format!("app = {}", data.app)) + .await? as usize; + + if app_data >= Self::MAXIMUM_FREE_APP_DATA { + return Err(Error::MiscError( + "You already have the maximum number of app_data you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO app_data VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.owner as i64), + &(data.app as i64), + &data.key, + &data.value + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + 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); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM app_data WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.app_data:{}", id)).await; + 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:{}"); +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index e61b565..075d0f7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -43,6 +43,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 7bee30a..2535f43 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -30,3 +30,4 @@ pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_co pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); +pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); diff --git a/crates/core/src/database/drivers/sql/create_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql new file mode 100644 index 0000000..28a8379 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS app_data ( + id BIGINT NOT NULL PRIMARY KEY, + owner BIGINT NOT NULL, + app BIGINT NOT NULL, + k TEXT NOT NULL, + v TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 730c54a..a4cdb3d 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +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 713df48..8f90899 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::oauth::AppScope; @@ -100,3 +102,77 @@ 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, +} + +impl AppData { + /// Create a new [`AppData`]. + pub fn new(owner: usize, app: usize, key: String, value: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + owner, + app, + key, + value, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectQuery { + Like(String, String), +} + +impl Display for AppDataSelectQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::Like(k, v) => format!("%\"{k}\":\"{v}\"%"), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectMode { + /// Select a single row. + One, + /// Select multiple rows at once. + /// + /// `(order by top level key, limit, offset)` + Many(String, usize, usize), +} + +impl Display for AppDataSelectMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + 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}" + ) + } + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppDataQuery { + pub app: usize, + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +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 {}", + self.app, self.mode + )) + } +}