add: journal page memberships

add: "Joined" write access option
This commit is contained in:
trisua 2025-03-24 20:19:12 -04:00
parent daa223d529
commit e87ad74d43
11 changed files with 290 additions and 10 deletions

View file

@ -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
)

View file

@ -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
)

View file

@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS pages (
created INTEGER NOT NULL, created INTEGER NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
prompt TEXT NOT NULL, prompt TEXT NOT NULL,
owner TEXT NOT NULL, owner INTEGER NOT NULL,
read_access TEXT NOT NULL, read_access TEXT NOT NULL,
write_access TEXT NOT NULL write_access TEXT NOT NULL
) )

View file

@ -1,7 +1,9 @@
use super::*; use super::*;
use crate::cache::Cache; use crate::cache::Cache;
use crate::model::auth::User; use crate::model::{
use crate::model::{Error, Result, journal::JournalEntry, permissions::FinePermission}; Error, Result, auth::User, journal::JournalEntry, journal::JournalPageWriteAccess,
permissions::FinePermission,
};
use crate::{auto_method, execute, get, query_row}; use crate::{auto_method, execute, get, query_row};
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@ -39,6 +41,29 @@ impl DataManager {
return Err(Error::DataTooLong("username".to_string())); 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 { let conn = match self.connect().await {
Ok(c) => c, Ok(c) => c,

View file

@ -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<JournalPageMembership> {
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:{}");
}

View file

@ -2,6 +2,7 @@ mod auth;
mod common; mod common;
mod drivers; mod drivers;
mod entries; mod entries;
mod memberships;
mod pages; mod pages;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]

View file

@ -1,8 +1,14 @@
use super::*; use super::*;
use crate::cache::Cache; use crate::cache::Cache;
use crate::model::auth::User; use crate::model::journal::JournalPageMembership;
use crate::model::journal::{JournalPageReadAccess, JournalPageWriteAccess}; use crate::model::journal_permissions::JournalPermission;
use crate::model::{Error, Result, journal::JournalPage, permissions::FinePermission}; use crate::model::{
Error, Result,
auth::User,
journal::JournalPage,
journal::{JournalPageReadAccess, JournalPageWriteAccess},
permissions::FinePermission,
};
use crate::{auto_method, execute, get, query_row}; use crate::{auto_method, execute, get, query_row};
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@ -72,6 +78,16 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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(()) Ok(())
} }

View file

@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp};
use super::journal_permissions::JournalPermission;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct JournalPage { pub struct JournalPage {
pub id: usize, pub id: usize,
@ -37,7 +39,7 @@ impl JournalPage {
} }
/// Who can read a [`JournalPage`]. /// Who can read a [`JournalPage`].
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum JournalPageReadAccess { pub enum JournalPageReadAccess {
/// Everybody can view the journal page from the owner's profile. /// Everybody can view the journal page from the owner's profile.
Everybody, Everybody,
@ -54,12 +56,16 @@ impl Default for JournalPageReadAccess {
} }
/// Who can write to a [`JournalPage`]. /// Who can write to a [`JournalPage`].
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum JournalPageWriteAccess { pub enum JournalPageWriteAccess {
/// Everybody (authenticated + anonymous users). /// Everybody (authenticated + anonymous users).
Everybody, Everybody,
/// Authenticated users only. /// Authenticated users only.
Authenticated, 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. /// Only the owner of the journal page.
Owner, 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::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
owner,
journal,
role,
}
}
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct JournalEntry { pub struct JournalEntry {
pub id: usize, pub id: usize,

View file

@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<E>(self, value: u32) -> Result<Self::Value, E>
where
E: DeError,
{
if let Some(permission) = JournalPermission::from_bits(value) {
Ok(permission)
} else {
Ok(JournalPermission::from_bits_retain(value))
}
}
fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
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<E>(self, value: u64) -> Result<Self::Value, E>
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<D>(deserializer: D) -> Result<Self, D::Error>
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
}
}

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod journal; pub mod journal;
pub mod journal_permissions;
pub mod permissions; pub mod permissions;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View file

@ -19,6 +19,7 @@ bitflags! {
const MANAGE_NOTIFICATIONS = 1 << 8; const MANAGE_NOTIFICATIONS = 1 << 8;
const VIEW_REPORTS = 1 << 9; const VIEW_REPORTS = 1 << 9;
const VIEW_AUDIT_LOG = 1 << 10; const VIEW_AUDIT_LOG = 1 << 10;
const MANAGE_MEMBERSHIPS = 1 << 11;
const _ = !0; const _ = !0;
} }
@ -100,7 +101,7 @@ impl FinePermission {
(self & permission) == permission (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 { pub fn check_helper(self) -> bool {
self.check(FinePermission::MANAGE_JOURNAL_ENTRIES) self.check(FinePermission::MANAGE_JOURNAL_ENTRIES)
&& self.check(FinePermission::MANAGE_JOURNAL_PAGES) && self.check(FinePermission::MANAGE_JOURNAL_PAGES)
@ -110,7 +111,7 @@ impl FinePermission {
&& self.check(FinePermission::VIEW_AUDIT_LOG) && 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 { pub fn check_manager(self) -> bool {
self.check_helper() && self.check(FinePermission::ADMINISTRATOR) self.check_helper() && self.check(FinePermission::ADMINISTRATOR)
} }