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;
}