diff --git a/crates/core/src/database/drivers/sql/create_entries.sql b/crates/core/src/database/drivers/sql/create_entries.sql new file mode 100644 index 0000000..10b058f --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_entries.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS entries ( + id INTEGER NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + content TEXT NOT NULL, + owner INTEGER NOT NULL, + journal INTEGER NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_memberships.sql b/crates/core/src/database/drivers/sql/create_memberships.sql new file mode 100644 index 0000000..358d33a --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_memberships.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS memberships ( + id INTEGER NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + owner INTEGER NOT NULL, + journal INTEGER NOT NULL, + role INTEGER NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_pages.sql b/crates/core/src/database/drivers/sql/create_pages.sql index e107ae7..b025223 100644 --- a/crates/core/src/database/drivers/sql/create_pages.sql +++ b/crates/core/src/database/drivers/sql/create_pages.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS pages ( created INTEGER NOT NULL, title TEXT NOT NULL, prompt TEXT NOT NULL, - owner TEXT NOT NULL, + owner INTEGER NOT NULL, read_access TEXT NOT NULL, write_access TEXT NOT NULL ) diff --git a/crates/core/src/database/entries.rs b/crates/core/src/database/entries.rs index 6b6807a..53b305d 100644 --- a/crates/core/src/database/entries.rs +++ b/crates/core/src/database/entries.rs @@ -1,7 +1,9 @@ use super::*; use crate::cache::Cache; -use crate::model::auth::User; -use crate::model::{Error, Result, journal::JournalEntry, permissions::FinePermission}; +use crate::model::{ + Error, Result, auth::User, journal::JournalEntry, journal::JournalPageWriteAccess, + permissions::FinePermission, +}; use crate::{auto_method, execute, get, query_row}; #[cfg(feature = "sqlite")] @@ -39,6 +41,29 @@ impl DataManager { return Err(Error::DataTooLong("username".to_string())); } + // check permission in page + let page = match self.get_page_by_id(data.journal).await { + Ok(p) => p, + Err(e) => return Err(e), + }; + + match page.write_access { + JournalPageWriteAccess::Owner => { + if data.owner != page.owner { + return Err(Error::NotAllowed); + } + } + JournalPageWriteAccess::Joined => { + if let Err(_) = self + .get_membership_by_owner_journal(data.owner, page.id) + .await + { + return Err(Error::NotAllowed); + } + } + _ => (), + }; + // ... let conn = match self.connect().await { Ok(c) => c, diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs new file mode 100644 index 0000000..8cc7149 --- /dev/null +++ b/crates/core/src/database/memberships.rs @@ -0,0 +1,87 @@ +use super::*; +use crate::cache::Cache; +use crate::model::{ + Error, Result, auth::User, journal::JournalPageMembership, + journal_permissions::JournalPermission, permissions::FinePermission, +}; +use crate::{auto_method, execute, get, query_row}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`JournalEntry`] from an SQL row. + pub(crate) fn get_membership_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> JournalPageMembership { + JournalPageMembership { + id: get!(x->0(u64)) as usize, + created: get!(x->1(u64)) as usize, + owner: get!(x->2(u64)) as usize, + journal: get!(x->3(u64)) as usize, + role: JournalPermission::from_bits(get!(x->4(u32))).unwrap(), + } + } + + auto_method!(get_membership_by_id()@get_membership_from_row -> "SELECT * FROM memberships WHERE id = $1" --name="journal membership" --returns=JournalPageMembership --cache-key-tmpl="atto.membership:{}"); + + /// Get a journal page membership by `owner` and `journal`. + pub async fn get_membership_by_owner_journal( + &self, + owner: usize, + journal: usize, + ) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM memberships WHERE owner = $1 AND journal = $2", + &[&owner, &journal], + |x| { Ok(Self::get_membership_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("journal membership".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new journal page membership in the database. + /// + /// # Arguments + /// * `data` - a mock [`JournalPageMembership`] object to insert + pub async fn create_membership(&self, data: JournalPageMembership) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO memberships VALUES ($1, $2, $3, $4, $5", + &[ + &data.id.to_string().as_str(), + &data.created.to_string().as_str(), + &data.owner.to_string().as_str(), + &data.journal.to_string().as_str(), + &(data.role.bits()).to_string().as_str(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + + auto_method!(delete_membership()@get_membership_by_id:MANAGE_MEMBERSHIPS -> "DELETE FROM memberships WHERE id = $1" --cache-key-tmpl="atto.membership:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 2b8552d..4028982 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -2,6 +2,7 @@ mod auth; mod common; mod drivers; mod entries; +mod memberships; mod pages; #[cfg(feature = "sqlite")] diff --git a/crates/core/src/database/pages.rs b/crates/core/src/database/pages.rs index 2c13830..d5d3752 100644 --- a/crates/core/src/database/pages.rs +++ b/crates/core/src/database/pages.rs @@ -1,8 +1,14 @@ use super::*; use crate::cache::Cache; -use crate::model::auth::User; -use crate::model::journal::{JournalPageReadAccess, JournalPageWriteAccess}; -use crate::model::{Error, Result, journal::JournalPage, permissions::FinePermission}; +use crate::model::journal::JournalPageMembership; +use crate::model::journal_permissions::JournalPermission; +use crate::model::{ + Error, Result, + auth::User, + journal::JournalPage, + journal::{JournalPageReadAccess, JournalPageWriteAccess}, + permissions::FinePermission, +}; use crate::{auto_method, execute, get, query_row}; #[cfg(feature = "sqlite")] @@ -72,6 +78,16 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // add journal page owner as admin + self.create_membership(JournalPageMembership::new( + data.owner, + data.id, + JournalPermission::ADMINISTRATOR, + )) + .await + .unwrap(); + + // return Ok(()) } diff --git a/crates/core/src/model/journal.rs b/crates/core/src/model/journal.rs index ab67981..cb1d533 100644 --- a/crates/core/src/model/journal.rs +++ b/crates/core/src/model/journal.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; +use super::journal_permissions::JournalPermission; + #[derive(Serialize, Deserialize)] pub struct JournalPage { pub id: usize, @@ -37,7 +39,7 @@ impl JournalPage { } /// Who can read a [`JournalPage`]. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum JournalPageReadAccess { /// Everybody can view the journal page from the owner's profile. Everybody, @@ -54,12 +56,16 @@ impl Default for JournalPageReadAccess { } /// Who can write to a [`JournalPage`]. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum JournalPageWriteAccess { /// Everybody (authenticated + anonymous users). Everybody, /// Authenticated users only. Authenticated, + /// Only people who joined the journal page can write to it. + /// + /// Memberships can be managed by the owner of the journal page. + Joined, /// Only the owner of the journal page. Owner, } @@ -70,6 +76,30 @@ impl Default for JournalPageWriteAccess { } } +#[derive(Serialize, Deserialize)] +pub struct JournalPageMembership { + pub id: usize, + pub created: usize, + pub owner: usize, + pub journal: usize, + pub role: JournalPermission, +} + +impl JournalPageMembership { + pub fn new(owner: usize, journal: usize, role: JournalPermission) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + journal, + role, + } + } +} + #[derive(Serialize, Deserialize)] pub struct JournalEntry { pub id: usize, diff --git a/crates/core/src/model/journal_permissions.rs b/crates/core/src/model/journal_permissions.rs new file mode 100644 index 0000000..1fc73db --- /dev/null +++ b/crates/core/src/model/journal_permissions.rs @@ -0,0 +1,105 @@ +use bitflags::bitflags; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{Error as DeError, Visitor}, +}; + +bitflags! { + /// Fine-grained journal permissions built using bitwise operations. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct JournalPermission: u32 { + const DEFAULT = 1 << 0; + const ADMINISTRATOR = 1 << 1; + const MEMBER = 1 << 2; + + const _ = !0; + } +} + +impl Serialize for JournalPermission { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u32(self.bits()) + } +} + +struct JournalPermissionVisitor; +impl<'de> Visitor<'de> for JournalPermissionVisitor { + type Value = JournalPermission; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("u32") + } + + fn visit_u32(self, value: u32) -> Result + where + E: DeError, + { + if let Some(permission) = JournalPermission::from_bits(value) { + Ok(permission) + } else { + Ok(JournalPermission::from_bits_retain(value)) + } + } + + fn visit_i32(self, value: i32) -> Result + where + E: DeError, + { + if let Some(permission) = JournalPermission::from_bits(value as u32) { + Ok(permission) + } else { + Ok(JournalPermission::from_bits_retain(value as u32)) + } + } + + fn visit_u64(self, value: u64) -> Result + where + E: DeError, + { + if let Some(permission) = JournalPermission::from_bits(value as u32) { + Ok(permission) + } else { + Ok(JournalPermission::from_bits_retain(value as u32)) + } + } +} + +impl<'de> Deserialize<'de> for JournalPermission { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(JournalPermissionVisitor) + } +} + +impl JournalPermission { + /// Join two [`JournalPermission`]s into a single `u32`. + pub fn join(lhs: JournalPermission, rhs: JournalPermission) -> JournalPermission { + lhs | rhs + } + + /// Check if the given `input` contains the given [`JournalPermission`]. + pub fn check(self, permission: JournalPermission) -> bool { + if (self & JournalPermission::ADMINISTRATOR) == JournalPermission::ADMINISTRATOR { + // has administrator permission, meaning everything else is automatically true + return true; + } + + (self & permission) == permission + } + + /// Check if the given [`JournalPermission`] qualifies as "Member" status. + pub fn check_helper(self) -> bool { + self.check(JournalPermission::MEMBER) + } +} + +impl Default for JournalPermission { + fn default() -> Self { + Self::DEFAULT + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index ca9a31b..e80eb71 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod journal; +pub mod journal_permissions; pub mod permissions; use serde::{Deserialize, Serialize}; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index f91153a..629289d 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -19,6 +19,7 @@ bitflags! { const MANAGE_NOTIFICATIONS = 1 << 8; const VIEW_REPORTS = 1 << 9; const VIEW_AUDIT_LOG = 1 << 10; + const MANAGE_MEMBERSHIPS = 1 << 11; const _ = !0; } @@ -100,7 +101,7 @@ impl FinePermission { (self & permission) == permission } - /// Check if thhe given [`FinePermission`] is qualifies as "Helper" status. + /// Check if the given [`FinePermission`] qualifies as "Helper" status. pub fn check_helper(self) -> bool { self.check(FinePermission::MANAGE_JOURNAL_ENTRIES) && self.check(FinePermission::MANAGE_JOURNAL_PAGES) @@ -110,7 +111,7 @@ impl FinePermission { && self.check(FinePermission::VIEW_AUDIT_LOG) } - /// Check if thhe given [`FinePermission`] is qualifies as "Manager" status. + /// Check if the given [`FinePermission`] qualifies as "Manager" status. pub fn check_manager(self) -> bool { self.check_helper() && self.check(FinePermission::ADMINISTRATOR) }