diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs new file mode 100644 index 0000000..b86bfd2 --- /dev/null +++ b/crates/app/src/routes/api/v1/layouts.rs @@ -0,0 +1,175 @@ +use crate::{ + get_user_from_token, + routes::{ + api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + model::{ + layouts::{Layout, LayoutPrivacy}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let layout = match data.get_layout_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if layout.privacy == LayoutPrivacy::Public + && user.id != layout.owner + && !user.permissions.check(FinePermission::MANAGE_USERS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(layout), + }) +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_layouts_by_user(user.id).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, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_layout(Layout::new(req.name, user.id, req.replaces)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Layout created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_title(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_privacy(id, &user, req.privacy).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_pages_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_pages(id, &user, req.pages).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_layout(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index c88b003..f207f1c 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod journals; +pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -26,6 +27,7 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, + layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -612,6 +614,17 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + // layouts + .route("/layouts", get(layouts::list_request)) + .route("/layouts", post(layouts::create_request)) + .route("/layouts/{id}", get(layouts::get_request)) + .route("/layouts/{id}", delete(layouts::delete_request)) + .route("/layouts/{id}/title", post(layouts::update_name_request)) + .route( + "/layouts/{id}/privacy", + post(layouts::update_privacy_request), + ) + .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } #[derive(Deserialize)] @@ -993,3 +1006,24 @@ pub struct UpdateNoteTags { pub struct AwardAchievement { pub name: AchievementName, } + +#[derive(Deserialize)] +pub struct CreateLayout { + pub name: String, + pub replaces: CustomizablePage, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPrivacy { + pub privacy: LayoutPrivacy, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPages { + pub pages: Vec, +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4038fb9..f6fb848 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,7 +112,6 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), - layouts: serde_json::from_str(&get!(x->24(String)).to_string()).unwrap(), } } @@ -294,7 +293,6 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), - &serde_json::to_string(&data.layouts).unwrap(), ] ); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 45111db..6a22ba9 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -397,10 +397,7 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!( - "invoked `{}` with x value `{id}` and y value `{x:?}`", - stringify!($name) - ), + format!("invoked `{}` with x value `{id}`", stringify!($name)), )) .await? } diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs new file mode 100644 index 0000000..6ab1f48 --- /dev/null +++ b/crates/core/src/database/layouts.rs @@ -0,0 +1,117 @@ +use crate::model::{ + auth::User, + layouts::{Layout, LayoutPage, LayoutPrivacy}, + permissions::FinePermission, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; + +impl DataManager { + /// Get a [`Layout`] from an SQL row. + pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { + Layout { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), + pages: serde_json::from_str(&get!(x->5(String))).unwrap(), + replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); + + /// Get all layouts by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch layouts for + pub async fn get_layouts_by_user(&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 layouts WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_layout_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("layout".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new layout in the database. + /// + /// # Arguments + /// * `data` - a mock [`Layout`] object to insert + pub async fn create_layout(&self, data: Layout) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 32 { + return Err(Error::DataTooLong("title".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 layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &serde_json::to_string(&data.privacy).unwrap(), + &serde_json::to_string(&data.pages).unwrap(), + &serde_json::to_string(&data.replaces).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { + let layout = self.get_layout_by_id(id).await?; + + // check user permission + if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { + 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 layouts WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.layout:{}", id)).await; + Ok(()) + } + + auto_method!(update_layout_title(&str)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_pages(Vec)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5f81259..6877100 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -12,6 +12,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod layouts; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c0e761..bac4ae6 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use crate::model::layouts::CustomizablePage; - use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -63,11 +61,6 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, - /// The ID of each layout the user is using. - /// - /// Only applies if the user is a supporter. - #[serde(default)] - pub layouts: HashMap, } pub type UserConnections = @@ -326,7 +319,6 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), - layouts: HashMap::new(), } } diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs index 7254d0a..a9d60a4 100644 --- a/crates/core/src/model/layouts.rs +++ b/crates/core/src/model/layouts.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt::Display}; use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::auth::DefaultTimelineChoice; /// Each different page which can be customized. @@ -20,10 +21,26 @@ pub struct Layout { pub title: String, pub privacy: LayoutPrivacy, pub pages: Vec, + pub replaces: CustomizablePage, +} + +impl Layout { + /// Create a new [`Layout`]. + pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + privacy: LayoutPrivacy::Public, + pages: Vec::new(), + replaces, + } + } } /// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum LayoutPrivacy { Public, Private, diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index e783a1e..7d5ebb6 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,8 +68,8 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, - /// Read the user's links. - UserReadLinks, + /// Read the user's layouts. + UserReadLayouts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -88,8 +88,8 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, - /// Create links on behalf of the user. - UserCreateLinks, + /// Create layouts on behalf of the user. + UserCreateLayouts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -124,8 +124,8 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, - /// Manage the user's links. - UserManageLinks, + /// Manage the user's layouts. + UserManageLayouts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql index 0d8e489..d80e60b 100644 --- a/sql_changes/users_layouts.sql +++ b/sql_changes/users_layouts.sql @@ -1,2 +1,2 @@ ALTER TABLE users -ADD COLUMN layouts TEXT NOT NULL DEFAULT '{}'; +DROP COLUMN layouts;