diff --git a/crates/app/src/routes/api/v1/auth/links.rs b/crates/app/src/routes/api/v1/auth/links.rs new file mode 100644 index 0000000..ecc921b --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/links.rs @@ -0,0 +1,285 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, + routes::api::v1::{ + CreateLink, UpdateLinkHref, UpdateLinkLabel, UpdateLinkPosition, UploadLinkIcon, + }, + State, +}; +use tetratto_core::model::{ + links::Link, + oauth, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks).is_none() { + return Json(Error::NotAllowed.into()); + }; + + let link = match data.get_link_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(link), + }) +} + +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::UserReadLinks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_links_by_owner(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(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLinks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_link( + Link::new( + user.id, + props.label, + props.href, + match data.get_links_by_owner_count(user.id).await { + Ok(c) => (c + 1) as usize, + Err(e) => return Json(e.into()), + }, + ), + &user, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Link created".to_string(), + payload: Some(x.id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_label_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let link = match data.get_link_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if link.owner != user.id { + return Json(Error::NotAllowed.into()); + } + + // ... + match data.update_link_label(id, &props.label).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Link updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_href_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let link = match data.get_link_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if link.owner != user.id { + return Json(Error::NotAllowed.into()); + } + + // ... + match data.update_link_href(id, &props.href).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Link updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_position_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let link = match data.get_link_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if link.owner != user.id { + return Json(Error::NotAllowed.into()); + } + + if props.position < 0 { + return Json(Error::MiscError("Position must be an unsigned integer".to_string()).into()); + } + + // ... + match data.update_link_position(id, props.position).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Link updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub const MAXIMUM_FILE_SIZE: usize = 131072; // 128 KiB + +pub async fn upload_icon_request( + jar: CookieJar, + Extension(data): Extension, + JsonMultipart(images, props): JsonMultipart, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let id = match props.id.parse::() { + Ok(i) => i, + Err(_) => return Json(Error::Unknown.into()), + }; + + let link = match data.get_link_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if link.owner != user.id { + return Json(Error::NotAllowed.into()); + } + + // create upload + let upload = match data + .create_upload(MediaUpload::new(MediaType::Webp, user.id)) + .await + { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + let image = match images.get(0) { + Some(i) => i, + None => return Json(Error::MiscError("Missing file".to_string()).into()), + }; + + if image.len() > MAXIMUM_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + + // upload + if let Err(e) = save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) { + return Json(Error::MiscError(e.to_string()).into()); + } + + // ... + match data.update_link_upload_id(id, upload.id as i64).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Link 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::UserManageStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let link = match data.get_link_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if link.owner != user.id { + return Json(Error::NotAllowed.into()); + } + + match data.delete_link(id).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Link deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index a332dd8..670a44f 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -1,6 +1,7 @@ pub mod connections; pub mod images; pub mod ipbans; +pub mod links; pub mod profile; pub mod social; pub mod user_warnings; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d235b68..e7b9f46 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -597,6 +597,18 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + // links + .route("/links", get(auth::links::list_request)) + .route("/links", post(auth::links::create_request)) + .route("/links/{id}", get(auth::links::get_request)) + .route("/links/{id}", delete(auth::links::delete_request)) + .route("/links/icon", post(auth::links::upload_icon_request)) + .route("/links/{id}/label", post(auth::links::update_label_request)) + .route("/links/{id}/href", post(auth::links::update_href_request)) + .route( + "/links/{id}/position", + post(auth::links::update_position_request), + ) } #[derive(Deserialize)] @@ -970,3 +982,29 @@ pub struct RemoveJournalDir { pub struct UpdateNoteTags { pub tags: Vec, } + +#[derive(Deserialize)] +pub struct CreateLink { + pub label: String, + pub href: String, +} + +#[derive(Deserialize)] +pub struct UpdateLinkLabel { + pub label: String, +} + +#[derive(Deserialize)] +pub struct UpdateLinkHref { + pub href: String, +} + +#[derive(Deserialize)] +pub struct UpdateLinkPosition { + pub position: i32, +} + +#[derive(Deserialize)] +pub struct UploadLinkIcon { + pub id: String, +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 45111db..b3695b7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,6 +40,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); + execute!(&conn, common::CREATE_TABLE_LINKS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e1cfad7..04417c2 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,3 +27,4 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); +pub const CREATE_TABLE_LINKS: &str = include_str!("./sql/create_links.sql"); diff --git a/crates/core/src/database/drivers/sql/create_links.sql b/crates/core/src/database/drivers/sql/create_links.sql new file mode 100644 index 0000000..0c1fc25 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_links.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS links ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + label TEXT NOT NULL, + href TEXT NOT NULL, + upload_id BIGINT NOT NULL, + clicks INT NOT NULL, + position INT NOT NULL +) diff --git a/crates/core/src/database/links.rs b/crates/core/src/database/links.rs new file mode 100644 index 0000000..c70f256 --- /dev/null +++ b/crates/core/src/database/links.rs @@ -0,0 +1,146 @@ +use oiseau::{cache::Cache, query_row, query_rows}; +use crate::model::{auth::User, links::Link, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, params}; + +impl DataManager { + /// Get a [`Link`] from an SQL row. + pub(crate) fn get_link_from_row(x: &PostgresRow) -> Link { + Link { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + label: get!(x->3(String)), + href: get!(x->4(String)), + upload_id: get!(x->5(i64)) as usize, + clicks: get!(x->6(i32)) as usize, + position: get!(x->7(i32)) as usize, + } + } + + auto_method!(get_link_by_id()@get_link_from_row -> "SELECT * FROM links WHERE id = $1" --name="link" --returns=Link --cache-key-tmpl="atto.link:{}"); + + /// Get links by `owner`. + pub async fn get_links_by_owner(&self, owner: 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 links WHERE owner = $1 ORDER BY position DESC", + &[&(owner as i64)], + |x| { Self::get_link_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("link".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get links by `owner`. + pub async fn get_links_by_owner_count(&self, owner: usize) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT COUNT(*)::int FROM links WHERE owner = $1", + &[&(owner as i64)], + |x| Ok(x.get::(0)) + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("link".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_LINKS: usize = 10; + const MAXIMUM_SUPPORTER_LINKS: usize = 20; + + /// Create a new link in the database. + /// + /// # Arguments + /// * `data` - a mock [`Link`] object to insert + pub async fn create_link(&self, data: Link, user: &User) -> Result { + if !user.permissions.check(FinePermission::SUPPORTER) { + if (self.get_links_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_FREE_LINKS + { + return Err(Error::MiscError( + "You already have the maximum number of links you can create".to_string(), + )); + } + } else if !user.permissions.check(FinePermission::MANAGE_USERS) { + if (self.get_links_by_owner_count(user.id).await? as usize) + >= Self::MAXIMUM_SUPPORTER_LINKS + { + return Err(Error::MiscError( + "You already have the maximum number of links you can create".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 links VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.label, + &data.href, + &(data.upload_id as i64), + &(data.clicks as i32), + &(data.position as i32), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_link(&self, id: usize) -> Result<()> { + let y = self.get_link_by_id(id).await?; + + 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 links WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete upload + if y.upload_id != 0 { + self.delete_upload(id).await?; + } + + // ... + self.0.1.remove(format!("atto.link:{}", id)).await; + Ok(()) + } + + auto_method!(update_link_label(&str) -> "UPDATE links SET label = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); + auto_method!(update_link_href(&str) -> "UPDATE links SET href = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); + auto_method!(update_link_upload_id(i64) -> "UPDATE links SET upload_id = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); + auto_method!(update_link_clicks(i32) -> "UPDATE links SET clicks = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); + auto_method!(update_link_position(i32) -> "UPDATE links SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.link:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5f81259..20575e0 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 links; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index a42c4a0..46a3e30 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -9,10 +9,7 @@ use crate::{ }, }; use crate::{auto_method, DataManager}; - -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; impl DataManager { /// Get a [`UserStack`] from an SQL row. diff --git a/crates/core/src/model/links.rs b/crates/core/src/model/links.rs new file mode 100644 index 0000000..19b77f2 --- /dev/null +++ b/crates/core/src/model/links.rs @@ -0,0 +1,41 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Link { + pub id: usize, + pub created: usize, + /// Links should be selected by their owner, not their link list ID. + /// This is why we do not store link list ID. + pub owner: usize, + pub label: String, + pub href: String, + /// As link icons are optional, `upload_id` is allowed to be 0. + pub upload_id: usize, + /// Clicks are tracked for supporters only. + /// + /// When a user clicks on a link through the UI, they'll be redirect to + /// `/links/{id}`. If the link's owner is a supporter, the link's clicks will + /// be incremented. + /// + /// The page should just serve a simple HTML document with a meta tag to redirect. + /// We only really care about knowing they clicked it, so an automatic redirect will do. + pub clicks: usize, + pub position: usize, +} + +impl Link { + /// Create a new [`Link`]. + pub fn new(owner: usize, label: String, href: String, position: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + label, + href, + upload_id: 0, + clicks: 0, + position, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 839310f..5a6933b 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; +pub mod links; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index df34f3d..e783a1e 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,6 +68,8 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, + /// Read the user's links. + UserReadLinks, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -86,6 +88,8 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, + /// Create links on behalf of the user. + UserCreateLinks, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -120,6 +124,8 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, + /// Manage the user's links. + UserManageLinks, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user.