diff --git a/README.md b/README.md index bf1118d..008855f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # blog -Handles the contents of the blogging section. \ No newline at end of file +Handles the contents of the blogging section. + +## Schema & Data Objects + +All objects have a personal UUID ID, generated automatically by PostgreSQL. + +### Sections + +Sections group posts according to a common subject or some other metric by +which they could be grouped. They can be seen as sub-blogs within the larger +system. Each post can be part of only one section, & posts not part of a +section will not be shown in the public UI. + +A section has a title & optional description, along with a shortname. The +shortname is what will be used inside URLs for routing. + +A section can be part of the default posts list, meaning all posts created in +this section will be shown on the homepage. The default list is an aggregated +list containing all default sections. + +A section can be private, meaning all posts created in this section will not be +shown on the any public page without authentification. Furthermore, it can +specify wether posts should have titles, or are not allowed to. This is to +allow for creating microblogs, which do not require titles. + +### Posts & Versions + +A post represents a publication in a specific section. The posts table itself +only specifies which section a post belongs to & wether or not the post is +private. Any content of the actual post is stored a a version. A private post +can only be seen by logged-in users with the right authorization. + +Each version has its own publication date, with the last publication being +shown when visiting the post. The UI however should also expose a way to show +previous versions of the post. Each version has its own title (if allowed) & +content. A version can be a draft. This means that the version will not be shown in the public UI, allowing the user to finish it at a later time. diff --git a/migrations/2021-09-13-143540_sections/down.sql b/migrations/2021-09-13-143540_sections/down.sql index 7af43ff..bd985fd 100644 --- a/migrations/2021-09-13-143540_sections/down.sql +++ b/migrations/2021-09-13-143540_sections/down.sql @@ -1,7 +1,8 @@ -- This file should undo anything in `up.sql` -drop trigger insert_enforce_post_titles on posts; -drop trigger update_enforce_post_titles on posts; -drop function enforce_post_titles; +drop trigger insert_enforce_version_titles on versions; +drop trigger update_enforce_version_titles on versions; +drop function enforce_version_titles; +drop table versions cascade; drop table posts cascade; drop table sections cascade; diff --git a/migrations/2021-09-13-143540_sections/up.sql b/migrations/2021-09-13-143540_sections/up.sql index 64b80cb..c663de4 100644 --- a/migrations/2021-09-13-143540_sections/up.sql +++ b/migrations/2021-09-13-143540_sections/up.sql @@ -1,4 +1,3 @@ --- Your SQL goes here create table sections ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, @@ -11,48 +10,80 @@ create table sections ( -- Wether to show the section in the default list on the homepage is_default boolean NOT NULL DEFAULT false, -- Wether the posts should contain titles or not - has_titles boolean NOT NULL DEFAULT true + has_titles boolean NOT NULL DEFAULT true, + -- Wether posts in this section should be shown publicly + is_private boolean NOT NULL DEFAULT false ); create table posts ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - section_id uuid NOT NULL REFERENCES sections(id) ON DELETE CASCADE, - -- Title of the post - -- Wether this is NULL or not is enforced using the enforce_post_titles trigger - title varchar(255), - -- Post date, defaults to today - publish_date date NOT NULL DEFAULT now(), - -- Content of the post - content text NOT NULL + -- Posts shouldn't get deleted when we delete a section, as they're the + -- most valuable part of a blog + section_id uuid NOT NULL REFERENCES sections(id) ON DELETE SET NULL, + + -- Wether a post should be private + is_private boolean NOT NULL DEFAULT false, + -- Wether the post is archived + is_archived boolean NOT NULL DEFAULT false ); -create function enforce_post_titles() returns trigger as $enforce_post_titles$ +create table versions ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + + -- A version should be deleted when its referenced post is deleted + post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + + -- Title of the post. Wether this is NULL or not is enforced using the + -- enforce_post_titles trigger. + title varchar(255), + -- Publish date + publish_date date, + -- Content of the post, in Markdown + content text NOT NULL DEFAULT '', + -- Wether the version is still a draft + is_draft boolean NOT NULL default true, + + -- This check allows draft posts to be created without having to enter a + -- publish date, but forces them to have one if they're not a draft. + CHECK (is_draft OR publish_date IS NOT NULL) +); + +create function enforce_version_titles() returns trigger as $$ begin + -- Draft versions shouldn't be evaluated. + if new.is_draft then + return new; + end if; + -- Check for a wrongfully null title if new.title is null and exists ( - select 1 from sections where id = new.section_id and has_titles + select 1 from posts + inner join sections on posts.section_id = sections.id + where posts.id = new.post_id and sections.has_titles ) then raise exception 'Expected a post title, but got null.'; end if; if new.title is not null and exists ( - select 1 from sections where id = new.section_id and not has_titles + select 1 from posts + inner join sections on posts.section_id = sections.id + where posts.id = new.post_id and not sections.has_titles ) then raise exception 'Expected an empty post title, but got a value.'; end if; return new; end; -$enforce_post_titles$ language plpgsql; +$$ language plpgsql; -create trigger insert_enforce_post_titles - before insert on posts +create trigger insert_enforce_version_titles + before insert on versions for each row - execute function enforce_post_titles(); + execute function enforce_version_titles(); -create trigger update_enforce_post_titles - before update of title on posts +create trigger update_enforce_version_titles + before update of title on versions for each row when (old.title is distinct from new.title) - execute function enforce_post_titles(); + execute function enforce_version_titles(); diff --git a/src/db/mod.rs b/src/db/mod.rs index b81b9fb..2ce2174 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -6,3 +6,6 @@ pub mod sections; pub use posts::{NewPost, PatchPost, Post}; pub use sections::{NewSection, PatchSection, Section}; + +pub const MAX_POSTS: u32 = 64; +pub const MAX_SECTIONS: u32 = 64; diff --git a/src/db/posts.rs b/src/db/posts.rs index 71e8e54..e2d11d1 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::schema::{posts, posts::dsl::*}; +/// A post inside the database. #[derive(Queryable, Serialize)] pub struct Post { @@ -16,6 +17,7 @@ pub struct Post pub content: String, } +/// A new post to be added to the database. #[derive(Deserialize, Insertable)] #[table_name = "posts"] #[serde(rename_all = "camelCase")] @@ -27,6 +29,7 @@ pub struct NewPost pub content: String, } +/// A patch to be applied to a row in the database. #[derive(Deserialize, AsChangeset)] #[table_name = "posts"] pub struct PatchPost @@ -37,15 +40,18 @@ pub struct PatchPost pub content: Option, } +/// Get a list of posts, specified by the offset & a limit. The maximum for `limit_` is determined +/// by `super::MAX_POSTS`. pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult> { Ok(posts .offset(offset_.into()) - .limit(limit_.into()) + .limit(std::cmp::min(limit_, super::MAX_POSTS).into()) .load(conn) .map_err(|_| RbError::DbError("Couldn't query posts."))?) } +/// Try to find a post given its id (primary key). pub fn find(conn: &PgConnection, id_: &Uuid) -> RbOption { match posts.find(id_).first(conn) { @@ -55,6 +61,7 @@ pub fn find(conn: &PgConnection, id_: &Uuid) -> RbOption } } +/// Create a new post & store it in the database. pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult { Ok(insert_into(posts) @@ -65,6 +72,7 @@ pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult // TODO check for conflict? } +/// Update a post in the database with a given ID, returning the updated row. pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchPost) -> RbResult { Ok(diesel::update(posts.filter(id.eq(post_id))) @@ -73,6 +81,7 @@ pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchPost) -> Rb .map_err(|_| RbError::DbError("Couldn't update post."))?) } +/// Delete a post with a given ID. pub fn delete(conn: &PgConnection, post_id: &Uuid) -> RbResult<()> { diesel::delete(posts.filter(id.eq(post_id))) diff --git a/src/db/sections.rs b/src/db/sections.rs index fc5b7a0..c68b0a4 100644 --- a/src/db/sections.rs +++ b/src/db/sections.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::schema::{sections, sections::dsl::*}; +/// A section inside the database. #[derive(Queryable, Serialize)] pub struct Section { @@ -16,6 +17,8 @@ pub struct Section pub has_titles: bool, } +/// A new section to add. Any `Option` values will get replaced by their default value in the +/// database. #[derive(Serialize, Deserialize, Insertable)] #[table_name = "sections"] #[serde(rename_all = "camelCase")] @@ -28,6 +31,7 @@ pub struct NewSection pub has_titles: Option, } +/// A patch to apply to a section. #[derive(Deserialize, AsChangeset)] #[table_name = "sections"] #[serde(rename_all = "camelCase")] @@ -40,15 +44,18 @@ pub struct PatchSection has_titles: Option, } +/// Get an amount of sections from the database, given an offset & limit. The maximum value for +/// `limit_` is determined by `super::MAX_SECTIONS`. pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult> { Ok(sections .offset(offset_.into()) - .limit(limit_.into()) + .limit(std::cmp::min(limit_, super::MAX_SECTIONS).into()) .load(conn) .map_err(|_| RbError::DbError("Couldn't query sections."))?) } +/// Try to find a section given its shortname. pub fn find_with_shortname(conn: &PgConnection, shortname_: &str) -> RbOption
{ match sections.filter(shortname.eq(shortname_)).first(conn) { @@ -58,6 +65,7 @@ pub fn find_with_shortname(conn: &PgConnection, shortname_: &str) -> RbOption RbResult
{ Ok(insert_into(sections) @@ -68,6 +76,7 @@ pub fn create(conn: &PgConnection, new_section: &NewSection) -> RbResult
RbResult
{ Ok(diesel::update(sections.filter(id.eq(post_id))) @@ -76,6 +85,7 @@ pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchSection) -> .map_err(|_| RbError::DbError("Couldn't update section."))?) } +// Update a section given its shortname. pub fn update_with_shortname( conn: &PgConnection, shortname_: &str, @@ -88,6 +98,7 @@ pub fn update_with_shortname( .map_err(|_| RbError::DbError("Couldn't update section with shortname."))?) } +/// Delete a section given its ID. pub fn delete(conn: &PgConnection, post_id: &Uuid) -> RbResult<()> { diesel::delete(sections.filter(id.eq(post_id))) diff --git a/src/main.rs b/src/main.rs index 0a5696d..7ca6841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,19 +27,22 @@ pub fn auth_header() -> rocket::http::Header<'static> return rocket::http::Header::new("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjVjMjM2OTI0NjY4ZDQzZWFiNGNmNDczYjk1YWZiNzgzIiwidXNlcm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJleHAiOjE1MTYyMzkwMjIwfQ.if939L9le8LP-dtXnQs-mHPkb-VieRAvAfSu20755jY"); } +/// Used by Rocket to store database connections. #[database("postgres_rb")] pub struct RbDbConn(diesel::PgConnection); +/// Handles all error status codes. #[catch(default)] fn default_catcher(status: Status, _: &Request) -> Value { json!({"status": status.code, "message": ""}) } -embed_migrations!(); - +/// Rocket fairing that executes the necessary migrations in our database. async fn run_db_migrations(rocket: Rocket) -> Result, Rocket> { + embed_migrations!(); + let conn = RbDbConn::get_one(&rocket) .await .expect("database connection"); @@ -50,12 +53,15 @@ async fn run_db_migrations(rocket: Rocket) -> Result, Rocke .await } +/// Struct to deserialize from the config file. It contains any custom configuration our +/// application might need besides the default Rocket variables. #[derive(Debug, Deserialize, Serialize)] pub struct RbConfig { jwt: JwtConf, } +/// The main entrypoint of our program. It launches the Rocket instance. #[launch] fn rocket() -> _ { @@ -69,8 +75,6 @@ fn rocket() -> _ "Run database migrations", run_db_migrations, )) - // .attach(AdHoc::try_on_ignite("Create admin user", create_admin_user)) - // .attach(AdHoc::config::()) .register("/", catchers![default_catcher]) .mount( "/v1/sections", diff --git a/src/schema.rs b/src/schema.rs index db8b2c5..246ea3c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -2,9 +2,8 @@ table! { posts (id) { id -> Uuid, section_id -> Uuid, - title -> Nullable, - publish_date -> Date, - content -> Text, + is_private -> Bool, + is_archived -> Bool, } } @@ -16,9 +15,26 @@ table! { description -> Nullable, is_default -> Bool, has_titles -> Bool, + is_private -> Bool, + } +} + +table! { + versions (id) { + id -> Uuid, + post_id -> Uuid, + title -> Nullable, + publish_date -> Nullable, + content -> Text, + is_draft -> Bool, } } joinable!(posts -> sections (section_id)); +joinable!(versions -> posts (post_id)); -allow_tables_to_appear_in_same_query!(posts, sections,); +allow_tables_to_appear_in_same_query!( + posts, + sections, + versions, +); diff --git a/src/v1/posts.rs b/src/v1/posts.rs index 6c273db..3c65e7f 100644 --- a/src/v1/posts.rs +++ b/src/v1/posts.rs @@ -7,6 +7,7 @@ use rocket::serde::json::Json; use crate::RbDbConn; +/// Get one or more posts. #[get("/?&")] pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult>> { @@ -15,6 +16,7 @@ pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult")] pub async fn find(conn: RbDbConn, id: uuid::Uuid) -> RbOption> { @@ -37,6 +40,7 @@ pub async fn find(conn: RbDbConn, id: uuid::Uuid) -> RbOption> .and_then(|p| Some(Json(p)))) } +/// Patch a post given its ID. #[patch("/", data = "")] pub async fn patch( _admin: Admin, @@ -51,6 +55,7 @@ pub async fn patch( )) } +/// Delete a post given its ID.. #[delete("/")] pub async fn delete(_admin: Admin, conn: RbDbConn, id: uuid::Uuid) -> RbResult<()> { diff --git a/src/v1/sections.rs b/src/v1/sections.rs index 4e9d107..1bd3624 100644 --- a/src/v1/sections.rs +++ b/src/v1/sections.rs @@ -9,6 +9,8 @@ use rocket::serde::json::Json; use crate::RbDbConn; +/// Get multiple sections given an offset & a limit. The limit is bound by +/// `rb_blog::db::MAX_SECTIONS`. #[get("/?&")] pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult>> { @@ -18,6 +20,7 @@ pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult")] pub async fn find(conn: RbDbConn, shortname: String) -> RbOption> { @@ -40,6 +44,7 @@ pub async fn find(conn: RbDbConn, shortname: String) -> RbOption", data = "")] pub async fn patch( _admin: Admin, @@ -56,6 +61,7 @@ pub async fn patch( )) } +/// Delete a section given its ID. #[delete("/")] pub async fn delete(_admin: Admin, conn: RbDbConn, id: uuid::Uuid) -> RbResult<()> {