From 42421bd9068542c70d28185c9704985a61bfed18 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 18 Jun 2025 21:00:07 -0400 Subject: [PATCH] add: full journals api add: full notes api --- crates/app/src/public/html/components.lisp | 12 ++ crates/app/src/routes/api/v1/journals.rs | 153 +++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 54 ++++++ crates/app/src/routes/api/v1/notes.rs | 182 ++++++++++++++++++ crates/app/src/routes/api/v1/stacks.rs | 48 +++++ .../database/drivers/sql/create_journals.sql | 4 +- .../src/database/drivers/sql/create_notes.sql | 2 +- crates/core/src/database/journals.rs | 10 +- crates/core/src/database/notes.rs | 1 + crates/core/src/model/journals.rs | 8 +- crates/core/src/model/oauth.rs | 14 ++ 11 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 crates/app/src/routes/api/v1/journals.rs create mode 100644 crates/app/src/routes/api/v1/notes.rs diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8905fe1..02dcc59 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1019,6 +1019,18 @@ ("data-turbo" "false") (icon (text "rabbit")) (str (text "general:link.reference"))) + + (a + ("href" "{{ config.policies.terms_of_service }}") + ("class" "button") + (icon (text "heart-handshake")) + (text "Terms of service")) + + (a + ("href" "{{ config.policies.privacy }}") + ("class" "button") + (icon (text "cookie")) + (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) (button ("onclick" "trigger('me::switch_account')") diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs new file mode 100644 index 0000000..caa45be --- /dev/null +++ b/crates/app/src/routes/api/v1/journals.rs @@ -0,0 +1,153 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateJournalView, CreateJournal, UpdateJournalTitle}, + State, +}; +use tetratto_core::model::{ + journals::{Journal, JournalPrivacyPermission}, + 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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(journal), + }) +} + +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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_journals_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(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_journal(Journal::new(user.id, props.title)) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Journal created".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_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::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_journal_title(id, &user, &props.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_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::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_journal_privacy(id, &user, props.view).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_journal(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal 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 80212b1..983d3fe 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,8 @@ pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod journals; +pub mod notes; pub mod notifications; pub mod reactions; pub mod reports; @@ -22,6 +24,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + journals::JournalPrivacyPermission, oauth::AppScope, permissions::FinePermission, reactions::AssetType, @@ -530,7 +533,9 @@ pub fn routes() -> Router { delete(communities::emojis::delete_request), ) // stacks + .route("/stacks", get(stacks::list_request)) .route("/stacks", post(stacks::create_request)) + .route("/stacks/{id}", get(stacks::get_request)) .route("/stacks/{id}/name", post(stacks::update_name_request)) .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) .route("/stacks/{id}/mode", post(stacks::update_mode_request)) @@ -541,6 +546,23 @@ pub fn routes() -> Router { .route("/stacks/{id}/block", post(stacks::block_request)) .route("/stacks/{id}/block", delete(stacks::unblock_request)) .route("/stacks/{id}", delete(stacks::delete_request)) + // journals + .route("/journals", get(journals::list_request)) + .route("/journals", post(journals::create_request)) + .route("/journals/{id}", get(journals::get_request)) + .route("/journals/{id}", delete(journals::delete_request)) + .route("/journals/{id}/title", post(journals::update_title_request)) + .route( + "/journals/{id}/privacy", + post(journals::update_privacy_request), + ) + // notes + .route("/notes", post(notes::create_request)) + .route("/notes/{id}", get(notes::get_request)) + .route("/notes/{id}", delete(notes::delete_request)) + .route("/notes/{id}/title", post(notes::update_title_request)) + .route("/notes/{id}/content", post(notes::update_content_request)) + .route("/notes/from_journal/{id}", get(notes::list_request)) // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) @@ -846,3 +868,35 @@ pub struct CreateGrant { pub struct RefreshGrantToken { pub verifier: String, } + +#[derive(Deserialize)] +pub struct CreateJournal { + pub title: String, +} + +#[derive(Deserialize)] +pub struct CreateNote { + pub title: String, + pub content: String, + pub journal: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalView { + pub view: JournalPrivacyPermission, +} + +#[derive(Deserialize)] +pub struct UpdateNoteTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateNoteContent { + pub content: String, +} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs new file mode 100644 index 0000000..01645aa --- /dev/null +++ b/crates/app/src/routes/api/v1/notes.rs @@ -0,0 +1,182 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + get_user_from_token, + routes::api::v1::{CreateNote, UpdateNoteContent, UpdateNoteTitle}, + State, +}; +use tetratto_core::model::{ + journals::{JournalPrivacyPermission, Note}, + 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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let journal = match data.get_journal_by_id(note.id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(note), + }) +} + +pub async fn list_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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_notes_by_journal(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::UserCreateNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_note(Note::new( + user.id, + props.title, + match props.journal.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, + props.content, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Note created".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_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()), + }; + + match data.update_note_title(id, &user, &props.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_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()), + }; + + match data.update_note_content(id, &user, &props.content).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_note(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index ee4e5b7..d3979a2 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -19,6 +19,54 @@ use super::{ UpdateStackSort, }; +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 stack = match data.get_stack_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && ((stack.mode != StackMode::Circle) | stack.users.contains(&user.id)) + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(stack), + }) +} + +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::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_stacks_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, diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql index 01f49e5..40eafa4 100644 --- a/crates/core/src/database/drivers/sql/create_journals.sql +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -1,7 +1,7 @@ -CREATE TABLE IF NOT EXISTS channels ( +CREATE TABLE IF NOT EXISTS journals ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, title TEXT NOT NULL, - view TEXT NOT NULL + privacy TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql index 0ee4686..87361ad 100644 --- a/crates/core/src/database/drivers/sql/create_notes.sql +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS channels ( +CREATE TABLE IF NOT EXISTS notes ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 0bc5ded..ac0a589 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -3,7 +3,7 @@ use crate::{ model::{ auth::User, permissions::FinePermission, - journals::{Journal, JournalViewPermission}, + journals::{Journal, JournalPrivacyPermission}, Error, Result, }, }; @@ -18,7 +18,7 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, title: get!(x->3(String)), - view: serde_json::from_str(&get!(x->4(String))).unwrap(), + privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), } } @@ -36,7 +36,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM journals WHERE owner = $1 ORDER BY name ASC", + "SELECT * FROM journals WHERE owner = $1 ORDER BY title ASC", &[&(id as i64)], |x| { Self::get_journal_from_row(x) } ); @@ -89,7 +89,7 @@ impl DataManager { &(data.created as i64), &(data.owner as i64), &data.title, - &serde_json::to_string(&data.view).unwrap(), + &serde_json::to_string(&data.privacy).unwrap(), ] ); @@ -137,5 +137,5 @@ impl DataManager { } auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_view(JournalViewPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index f7afc46..78a25d9 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -121,4 +121,5 @@ impl DataManager { } auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); + auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); } diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs index 9b33bcc..f67b318 100644 --- a/crates/core/src/model/journals.rs +++ b/crates/core/src/model/journals.rs @@ -2,14 +2,14 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum JournalViewPermission { +pub enum JournalPrivacyPermission { /// Can be accessed by anyone via link. Public, /// Visible only to the journal owner. Private, } -impl Default for JournalViewPermission { +impl Default for JournalPrivacyPermission { fn default() -> Self { Self::Private } @@ -21,7 +21,7 @@ pub struct Journal { pub created: usize, pub owner: usize, pub title: String, - pub view: JournalViewPermission, + pub privacy: JournalPrivacyPermission, } impl Journal { @@ -32,7 +32,7 @@ impl Journal { created: unix_epoch_timestamp(), owner, title, - view: JournalViewPermission::default(), + privacy: JournalPrivacyPermission::default(), } } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index ea87034..df34f3d 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -62,6 +62,12 @@ pub enum AppScope { UserReadRequests, /// Read questions as the user. UserReadQuestions, + /// Read the user's stacks. + UserReadStacks, + /// Read the user's journals. + UserReadJournals, + /// Read the user's notes. + UserReadNotes, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -76,6 +82,10 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, + /// Create journals on behalf of the user. + UserCreateJournals, + /// Create notes on behalf of the user. + UserCreateNotes, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -106,6 +116,10 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, + /// Manage the user's journals. + UserManageJournals, + /// Manage the user's notes. + UserManageNotes, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user.