use oiseau::cache::Cache; use crate::database::common::NAME_REGEX; use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; use oiseau::{execute, get, params, query_row, query_rows, PostgresRow}; 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, dir: get!(x->7(i64)) as usize, tags: serde_json::from_str(&get!(x->8(String))).unwrap(), is_global: get!(x->9(i32)) as i8 == 1, } } 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:{}"); auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); /// Get the number of global notes a user has. pub async fn get_user_global_notes_count(&self, owner: usize) -> Result { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; let res = query_row!( &conn, "SELECT COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1", &[&(owner as i64)], |x| Ok(x.get::(0)) ); if let Err(e) = res { return Err(Error::DatabaseError(e.to_string())); } Ok(res.unwrap()) } /// Get a note by `journal` and `title`. pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; let res = query_row!( &conn, "SELECT * FROM notes WHERE journal = $1 AND title = $2", params![&(journal as i64), &title], |x| { Ok(Self::get_note_from_row(x)) } ); if res.is_err() { return Err(Error::GeneralNotFound("note".to_string())); } Ok(res.unwrap()) } /// 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 DESC", &[&(id as i64)], |x| { Self::get_note_from_row(x) } ); if res.is_err() { return Err(Error::GeneralNotFound("note".to_string())); } Ok(res.unwrap()) } /// Get all notes by journal with the given tag. /// /// # Arguments /// * `id` - the ID of the journal to fetch notes for /// * `tag` pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> 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 AND tags::jsonb ? $2 ORDER BY edited DESC", params![&(id as i64), tag], |x| { Self::get_note_from_row(x) } ); if res.is_err() { return Err(Error::GeneralNotFound("note".to_string())); } Ok(res.unwrap()) } const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10; pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50; /// Create a new note in the database. /// /// # Arguments /// * `data` - a mock [`Note`] object to insert pub async fn create_note(&self, mut 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() > 262144 { return Err(Error::DataTooLong("content".to_string())); } data.title = data.title.replace(" ", "_").to_lowercase(); // check number of notes let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { let journals = self.get_notes_by_journal(data.owner).await?; if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL { return Err(Error::MiscError( "You already have the maximum number of notes you can have in this journal" .to_string(), )); } } // check name let regex = regex::RegexBuilder::new(NAME_REGEX) .multi_line(true) .build() .unwrap(); if regex.captures(&data.title).is_some() { return Err(Error::MiscError( "This title contains invalid characters".to_string(), )); } // make sure this title isn't already in use if self .get_note_by_journal_title(data.journal, &data.title) .await .is_ok() { return Err(Error::TitleInUse); } // check permission let journal = self.get_journal_by_id(data.journal).await?; if data.owner != journal.owner { 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, "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", 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), &(data.dir as i64), &serde_json::to_string(&data.tags).unwrap(), &if data.is_global { 1 } else { 0 } ] ); 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())); } // ... self.cache_clear_note(¬e).await; Ok(()) } /// Delete all notes by dir ID. /// /// # Arguments /// * `journal` /// * `dir` pub async fn delete_notes_by_journal_dir( &self, journal: usize, dir: usize, user: &User, ) -> Result<()> { let journal = self.get_journal_by_id(journal).await?; if journal.owner != user.id && !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 dir = $1 AND journal = $2 ORDER BY edited DESC", &[&(dir as i64), &(journal.id as i64)] ); if let Err(e) = res { return Err(Error::DatabaseError(e.to_string())); } Ok(()) } /// Incremenet note views. Views are only stored in the cache. /// /// This should only be done for global notes. pub async fn incr_note_views(&self, id: usize) { self.0.1.incr(format!("atto.note:{id}/views")).await; } pub async fn get_note_views(&self, id: usize) -> Option { self.0.1.get(format!("atto.note:{id}/views")).await } pub async fn cache_clear_note(&self, x: &Note) { self.0.1.remove(format!("atto.note:{}", x.id)).await; self.0.1.remove(format!("atto.note:{}", x.title)).await; } auto_method!(update_note_title(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_content(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_dir(i64)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_tags(Vec)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); }