diff --git a/Cargo.lock b/Cargo.lock index 614d3bb..1a9e39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,6 +2669,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -3317,6 +3318,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", + "tokio", "toml 0.9.2", "totp-rs", ] diff --git a/Cargo.toml b/Cargo.toml index e8d6326..b5beca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"] package.authors = ["trisuaso"] package.repository = "https://trisua.com/t/tetratto" package.license = "AGPL-3.0-or-later" +package.homepage = "https://tetratto.com" [profile.dev] incremental = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 170d252..5c60129 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -2,6 +2,10 @@ name = "tetratto" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 1aa9a2d..13333da 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,13 @@ macro_rules! user_banned { macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { // check is_deactivated - if $other_user.is_deactivated { + if ($user.is_none() && $other_user.is_deactivated) + | !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { return Err(Html( render_error( Error::GeneralNotFound("user".to_string()), diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 4b5599b..2ca1f53 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -117,6 +117,29 @@ export default function tetratto({ ); } + async function update(id, value) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/value`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + value, + }), + }) + ).text(), + ), + ); + } + async function remove(id) { if (!api_key) { throw Error("No API key provided."); @@ -241,6 +264,7 @@ export default function tetratto({ app, query, insert, + update, remove, remove_query, // user connection diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 72d6481..526b195 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -2,6 +2,16 @@ name = "tetratto-core" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true + +[features] +database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"] +types = ["dep:totp-rs", "dep:paste", "dep:bitflags"] +sdk = ["types", "dep:reqwest"] +default = ["database", "types", "sdk"] [dependencies] pathbufd = "0.1.4" @@ -10,17 +20,20 @@ toml = "0.9.2" tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" -totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.22", features = ["json"] } -bitflags = "2.9.1" -async-recursion = "1.1.1" -md-5 = "0.10.6" -base16ct = { version = "0.2.0", features = ["alloc"] } -base64 = "0.22.1" +totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } +reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } +bitflags = { version = "2.9.1", optional = true } +async-recursion = { version = "1.1.1", optional = true } +md-5 = { version = "0.10.6", optional = true } +base16ct = { version = "0.2.0", features = ["alloc"], optional = true } +base64 = { version = "0.22.1", optional = true } emojis = "0.7.0" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = [ "postgres", "redis", -] } -paste = "1.0.15" +], optional = true } +paste = { version = "1.0.15", optional = true } + +[dev-dependencies] +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/examples/sdk_db.rs b/crates/core/examples/sdk_db.rs new file mode 100644 index 0000000..becdca1 --- /dev/null +++ b/crates/core/examples/sdk_db.rs @@ -0,0 +1,65 @@ +extern crate tetratto_core; +use tetratto_core::{ + model::apps::{AppDataSelectMode, AppDataSelectQuery, AppDataQueryResult}, + sdk::{DataClient, SimplifiedQuery}, +}; +use std::env::var; + +// mirror of https://trisua.com/t/tetratto/src/branch/master/example/app_sdk_test.js ... but in rust +#[tokio::main] +pub async fn main() { + let client = DataClient::new( + Some("http://localhost:4118".to_string()), + var("APP_API_KEY").unwrap(), + ); + + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // record insert + client + .insert("rust_test".to_string(), "Hello, world!".to_string()) + .await + .unwrap(); + println!("record created"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // testing record query then delete + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client + .update(record.id, "Hello, world! 1".to_string()) + .await + .unwrap(); + println!("record updated"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client.remove(record.id).await.unwrap(); + println!("record deleted"); + println!("data used: {}", client.get_app().await.unwrap().data_used); +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index aa61770..b785f89 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,13 @@ +#[cfg(feature = "types")] pub mod config; +#[cfg(feature = "database")] pub mod database; +#[cfg(feature = "types")] pub mod model; +#[cfg(feature = "sdk")] +pub mod sdk; +#[cfg(feature = "database")] pub use database::DataManager; +#[cfg(feature = "database")] pub use oiseau::cache; diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs new file mode 100644 index 0000000..72b82e8 --- /dev/null +++ b/crates/core/src/sdk.rs @@ -0,0 +1,313 @@ +use crate::model::{ + apps::{ + AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery, ThirdPartyApp, + }, + ApiReturn, Error, Result, +}; +use reqwest::{ + multipart::{Form, Part}, + Client as HttpClient, +}; +pub use reqwest::Method; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +macro_rules! api_return_ok { + ($ret:ty, $res:ident) => { + match $res.json::>().await { + Ok(x) => { + if x.ok { + Ok(x.payload) + } else { + Err(Error::MiscError(x.message)) + } + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + }; +} + +/// A simplified app data query which matches what the API endpoint actually requires. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimplifiedQuery { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +/// The data client is used to access an app's data storage capabilities. +#[derive(Debug, Clone)] +pub struct DataClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The app's API key. You can retrieve this from the web dashboard. + pub api_key: String, + /// The origin of the Tetratto server. When creating with [`DataClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl DataClient { + /// Create a new [`DataClient`]. + pub fn new(host: Option, api_key: String) -> Self { + Self { + http: HttpClient::new(), + api_key, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Get the current app using the provided API key. + /// + /// # Usage + /// ```rust + /// let client = DataClient::new("https://tetratto.com".to_string(), "...".to_string()); + /// let app = client.get_app().await.expect("failed to get app"); + /// ``` + pub async fn get_app(&self) -> Result { + match self + .http + .get(format!("{}/api/v1/app_data/app", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!(ThirdPartyApp, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Query the app's data. + pub async fn query(&self, query: &SimplifiedQuery) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!(AppDataQueryResult, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Insert a key, value pair into the app's data. + pub async fn insert(&self, key: String, value: String) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!(String, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Update a record's value given its ID and the new value. + pub async fn update(&self, id: usize, value: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/value", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete a row from the app's data by its `id`. + pub async fn remove(&self, id: usize) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/{id}", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete row(s) from the app's data by a query. + pub async fn remove_query(&self, query: &AppDataQuery) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} + +/// The state of the [`ApiClient`]. +#[derive(Debug, Clone, Default)] +pub struct ApiClientState { + /// The token you received from an app grant request. + pub user_token: String, + /// The verifier you received from an app grant request. + pub user_verifier: String, + /// The ID of the user this client is connecting to. + pub user_id: usize, + /// The ID of the app that is being used for user grants. + /// + /// You can get this from the web dashboard. + pub app_id: usize, +} + +/// The API client is used to manage authentication flow and send requests on behalf of a user. +/// +/// This client assumes you already have the required information for the given user. +/// If you don't, try using the JS SDK to extract this information. +#[derive(Debug, Clone)] +pub struct ApiClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The general state of the client. Will be updated whenever you refresh the user's token. + pub state: ApiClientState, + /// The origin of the Tetratto server. When creating with [`ApiClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl ApiClient { + /// Create a new [`ApiClient`]. + pub fn new(host: Option, state: ApiClientState) -> Self { + Self { + http: HttpClient::new(), + state, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Refresh the client's user_token. + pub async fn refresh_token(&mut self) -> Result { + match self + .http + .post(format!( + "{}/api/v1/auth/user/{}/grants/{}/refresh", + self.host, self.state.user_id, self.state.app_id + )) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert( + "verifier".to_string(), + serde_json::Value::String(self.state.user_verifier.to_owned()), + ); + map + })) + .send() + .await + { + Ok(x) => { + let ret = api_return_ok!(String, x)?; + self.state.user_token = ret.clone(); + Ok(ret) + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Send a simple JSON request to the given endpoint. + pub async fn request( + &self, + route: String, + method: Method, + body: Option<&B>, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + if let Some(body) = body { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } else { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + } + + /// Send a JSON request with attachments to the given endpoint. + /// + /// This type of request is only required for routes which use JsonMultipart, + /// such as `POST /api/v1/posts` (`create_post`). + /// + /// Method is locked to `POST` for this type of request. + pub async fn request_attachments( + &self, + route: String, + attachments: Vec>, + body: &B, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + let mut multipart_body = Form::new(); + + // add attachments + for v in attachments.clone() { + // the file name doesn't matter + multipart_body = multipart_body.part(String::new(), Part::bytes(v)); + } + + drop(attachments); + + // add json + multipart_body = multipart_body.part( + String::new(), + Part::text(serde_json::to_string(body).unwrap()), + ); + + // ... + match self + .http + .post(format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .multipart(multipart_body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index d7661c2..bfccdbf 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" authors.workspace = true repository.workspace = true license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js index bd69519..37f2063 100644 --- a/example/app_sdk_test.js +++ b/example/app_sdk_test.js @@ -15,7 +15,7 @@ console.log("record created"); console.log("data used:", (await sdk.app()).data_used); // testing record query then delete -const record = ( +let record = ( await sdk.query({ query: { KeyIs: "deno_test" }, mode: { One: 0 }, @@ -23,6 +23,20 @@ const record = ( ).One; console.log(record); + +await sdk.update("deno_test", "Hello, Deno! 1"); +console.log("record updated"); +console.log("data used:", (await sdk.app()).data_used); + +record = ( + await sdk.query({ + query: { KeyIs: "deno_test" }, + mode: { One: 0 }, + }) +).One; + +console.log(record); + await sdk.remove(record.id); console.log("record deleted"); console.log("data used:", (await sdk.app()).data_used);