Merge branch 'main' of git.hackbever.be:rusty-bever/blog

pull/3/head
Jef Roosens 2021-12-23 21:12:41 +01:00
commit b80a060faf
Signed by: Jef Roosens
GPG Key ID: B580B976584B5F30
10 changed files with 155 additions and 34 deletions

View File

@ -1,3 +1,38 @@
# blog
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.

View File

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

View File

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

View File

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

View File

@ -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<String>,
}
/// 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<Vec<Post>>
{
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<Post>
{
match posts.find(id_).first(conn) {
@ -55,6 +61,7 @@ pub fn find(conn: &PgConnection, id_: &Uuid) -> RbOption<Post>
}
}
/// Create a new post & store it in the database.
pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult<Post>
{
Ok(insert_into(posts)
@ -65,6 +72,7 @@ pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult<Post>
// 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<Post>
{
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)))

View File

@ -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<bool>,
}
/// 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<bool>,
}
/// 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<Vec<Section>>
{
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<Section>
{
match sections.filter(shortname.eq(shortname_)).first(conn) {
@ -58,6 +65,7 @@ pub fn find_with_shortname(conn: &PgConnection, shortname_: &str) -> RbOption<Se
}
}
/// Create a new section.
pub fn create(conn: &PgConnection, new_section: &NewSection) -> RbResult<Section>
{
Ok(insert_into(sections)
@ -68,6 +76,7 @@ pub fn create(conn: &PgConnection, new_section: &NewSection) -> RbResult<Section
// TODO check for conflict?
}
/// Update a section given its ID.
pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchSection) -> RbResult<Section>
{
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)))

View File

@ -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<Build>) -> Result<Rocket<Build>, Rocket<Build>>
{
embed_migrations!();
let conn = RbDbConn::get_one(&rocket)
.await
.expect("database connection");
@ -50,12 +53,15 @@ async fn run_db_migrations(rocket: Rocket<Build>) -> Result<Rocket<Build>, 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::<JwtConf>())
.register("/", catchers![default_catcher])
.mount(
"/v1/sections",

View File

@ -2,9 +2,8 @@ table! {
posts (id) {
id -> Uuid,
section_id -> Uuid,
title -> Nullable<Varchar>,
publish_date -> Date,
content -> Text,
is_private -> Bool,
is_archived -> Bool,
}
}
@ -16,9 +15,26 @@ table! {
description -> Nullable<Text>,
is_default -> Bool,
has_titles -> Bool,
is_private -> Bool,
}
}
table! {
versions (id) {
id -> Uuid,
post_id -> Uuid,
title -> Nullable<Varchar>,
publish_date -> Nullable<Date>,
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,
);

View File

@ -7,6 +7,7 @@ use rocket::serde::json::Json;
use crate::RbDbConn;
/// Get one or more posts.
#[get("/?<offset>&<limit>")]
pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult<Json<Vec<db::Post>>>
{
@ -15,6 +16,7 @@ pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult<Json<Vec<d
))
}
/// Create a new post.
#[post("/", data = "<new_post>")]
pub async fn create(
_admin: Admin,
@ -28,6 +30,7 @@ pub async fn create(
))
}
/// Get a post given its ID.
#[get("/<id>")]
pub async fn find(conn: RbDbConn, id: uuid::Uuid) -> RbOption<Json<db::Post>>
{
@ -37,6 +40,7 @@ pub async fn find(conn: RbDbConn, id: uuid::Uuid) -> RbOption<Json<db::Post>>
.and_then(|p| Some(Json(p))))
}
/// Patch a post given its ID.
#[patch("/<id>", data = "<patch_post>")]
pub async fn patch(
_admin: Admin,
@ -51,6 +55,7 @@ pub async fn patch(
))
}
/// Delete a post given its ID..
#[delete("/<id>")]
pub async fn delete(_admin: Admin, conn: RbDbConn, id: uuid::Uuid) -> RbResult<()>
{

View File

@ -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("/?<offset>&<limit>")]
pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult<Json<Vec<db::Section>>>
{
@ -18,6 +20,7 @@ pub async fn get(conn: RbDbConn, offset: u32, limit: u32) -> RbResult<Json<Vec<d
))
}
/// Create a new section.
#[post("/", data = "<new_section>")]
pub async fn create(
_admin: Admin,
@ -31,6 +34,7 @@ pub async fn create(
))
}
/// Get a section by its shortname.
#[get("/<shortname>")]
pub async fn find(conn: RbDbConn, shortname: String) -> RbOption<Json<db::Section>>
{
@ -40,6 +44,7 @@ pub async fn find(conn: RbDbConn, shortname: String) -> RbOption<Json<db::Sectio
.and_then(|p| Some(Json(p))))
}
/// Patch a section given its shortname.
#[patch("/<shortname>", data = "<patch_section>")]
pub async fn patch(
_admin: Admin,
@ -56,6 +61,7 @@ pub async fn patch(
))
}
/// Delete a section given its ID.
#[delete("/<id>")]
pub async fn delete(_admin: Admin, conn: RbDbConn, id: uuid::Uuid) -> RbResult<()>
{