diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 80ed3ba..18820ce 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -112,7 +112,19 @@ ("class" "button") ("data-turbo" "false") (icon (text "rabbit")) - (str (text "general:link.reference")))))) + (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"))))) (text "{%- endif %}"))) (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 07d24a1..bb0277d 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -585,7 +585,9 @@ (li (text "Add unlimited users to stacks")) (li - (text "Increased proxied image size"))) + (text "Increased proxied image size")) + (li + (text "Create infinite journals"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index a7cfb4a..d126e16 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -25,7 +25,7 @@ globalThis.ns_config = { root: \"/js/\", verbose: globalThis.ns_verbose, - version: \"cache-breaker-{{ random_cache_breaker }}\", + version: \"tetratto-{{ random_cache_breaker }}\", }; globalThis._app_base = { @@ -38,8 +38,8 @@ globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; ") - (script ("src" "/js/loader.js" )) - (script ("src" "/js/atto.js" )) + (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 7e7a7f6..36bbdb7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -36,6 +36,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); + execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); + execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 94cc123..64a9dfc 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -23,3 +23,5 @@ pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); +pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"); +pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql new file mode 100644 index 0000000..01f49e5 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + view 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 new file mode 100644 index 0000000..0ee4686 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + journal BIGINT NOT NULL, + content TEXT NOT NULL, + edited BIGINT NOT NULL +) diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs new file mode 100644 index 0000000..0bc5ded --- /dev/null +++ b/crates/core/src/database/journals.rs @@ -0,0 +1,141 @@ +use oiseau::cache::Cache; +use crate::{ + model::{ + auth::User, + permissions::FinePermission, + journals::{Journal, JournalViewPermission}, + Error, Result, + }, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`Journal`] from an SQL row. + pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal { + Journal { + 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)), + view: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}"); + + /// Get all journals by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch journals for + pub async fn get_journals_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 journals WHERE owner = $1 ORDER BY name ASC", + &[&(id as i64)], + |x| { Self::get_journal_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("journal".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_JOURNALS: usize = 15; + + /// Create a new journal in the database. + /// + /// # Arguments + /// * `data` - a mock [`Journal`] object to insert + pub async fn create_journal(&self, data: Journal) -> 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())); + } + + // check number of journals + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let journals = self.get_journals_by_user(data.owner).await?; + + if journals.len() >= Self::MAXIMUM_FREE_JOURNALS { + return Err(Error::MiscError( + "You already have the maximum number of journals 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 journals VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &serde_json::to_string(&data.view).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> { + let journal = self.get_journal_by_id(id).await?; + + // check user permission + if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) { + 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 journals WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!( + &conn, + "DELETE FROM notes WHERE journal = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.journal:{}", id)).await; + Ok(()) + } + + 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:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index b26afbf..e56bc93 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -10,8 +10,10 @@ mod drivers; mod emojis; mod ipbans; mod ipblocks; +mod journals; mod memberships; mod messages; +mod notes; mod notifications; mod polls; mod pollvotes; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs new file mode 100644 index 0000000..f7afc46 --- /dev/null +++ b/crates/core/src/database/notes.rs @@ -0,0 +1,124 @@ +use oiseau::cache::Cache; +use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`Note`] from an SQL row. + pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note { + Note { + 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)), + journal: get!(x->4(i64)) as usize, + content: get!(x->5(String)), + edited: get!(x->6(i64)) as usize, + } + } + + auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + + /// Get all notes by journal. + /// + /// # Arguments + /// * `id` - the ID of the journal to fetch notes for + pub async fn get_notes_by_journal(&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 notes WHERE journal = $1 ORDER BY edited", + &[&(id as i64)], + |x| { Self::get_note_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new note in the database. + /// + /// # Arguments + /// * `data` - a mock [`Note`] object to insert + pub async fn create_note(&self, data: Note) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 64 { + return Err(Error::DataTooLong("title".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 16384 { + return Err(Error::DataTooLong("content".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 notes VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &(data.journal as i64), + &data.content, + &(data.edited as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> { + let note = self.get_note_by_id(id).await?; + + // check user permission + if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) { + 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 notes WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!(&conn, "DELETE FROM notes WHERE note = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.note:{}", id)).await; + Ok(()) + } + + 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:{}"); +} diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index a02d2d4..29dd75a 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1620,7 +1620,14 @@ impl DataManager { // create notification for question owner // (if the current user isn't the owner) - if (question.owner != data.owner) && (question.owner != 0) { + if (question.owner != data.owner) + && (question.owner != 0) + && (!owner.settings.private_profile + | self + .get_userfollow_by_initiator_receiver(data.owner, question.owner) + .await + .is_ok()) + { self.create_notification(Notification::new( "Your question has received a new answer!".to_string(), format!( diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs new file mode 100644 index 0000000..9b33bcc --- /dev/null +++ b/crates/core/src/model/journals.rs @@ -0,0 +1,69 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum JournalViewPermission { + /// Can be accessed by anyone via link. + Public, + /// Visible only to the journal owner. + Private, +} + +impl Default for JournalViewPermission { + fn default() -> Self { + Self::Private + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Journal { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub view: JournalViewPermission, +} + +impl Journal { + /// Create a new [`Journal`]. + pub fn new(owner: usize, title: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + view: JournalViewPermission::default(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Note { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + /// The ID of the [`Journal`] this note belongs to. + /// + /// The note is subject to the settings set for the journal it's in. + pub journal: usize, + pub content: String, + pub edited: usize, +} + +impl Note { + /// Create a new [`Note`]. + pub fn new(owner: usize, title: String, journal: usize, content: String) -> Self { + let created = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created, + owner, + title, + journal, + content, + edited: created, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 8beb286..c50ea7c 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod communities_permissions; +pub mod journals; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index c0c3542..9cd6dcb 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -37,6 +37,8 @@ bitflags! { const MANAGE_STACKS = 1 << 26; const STAFF_BADGE = 1 << 27; const MANAGE_APPS = 1 << 28; + const MANAGE_JOURNALS = 1 << 29; + const MANAGE_NOTES = 1 << 30; const _ = !0; }