diff --git a/Rb.yaml b/Rb.yaml index e3f980a..db78338 100644 --- a/Rb.yaml +++ b/Rb.yaml @@ -23,13 +23,21 @@ debug: url: "postgres://rb:rb@localhost:5432/rb" release: + keep_alive: 5 + read_timeout: 5 + write_timeout: 5 + log_level: "normal" + limits: + forms: 32768 + admin_user: "admin" admin_pass: "password" jwt: key: "secret" refresh_token_size: 64 - refresh_token_expire: 86400 + # Just 5 seconds for debugging + refresh_token_expire: 60 databases: postgres_rb: - url: "postgres://rb:rb@db:5432/rb" + url: "postgres://rb:rb@localhost:5432/rb" diff --git a/migrations/2021-09-13-143540_sections/down.sql b/migrations/2021-09-13-143540_sections/down.sql new file mode 100644 index 0000000..7af43ff --- /dev/null +++ b/migrations/2021-09-13-143540_sections/down.sql @@ -0,0 +1,7 @@ +-- 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 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 new file mode 100644 index 0000000..0c5ca76 --- /dev/null +++ b/migrations/2021-09-13-143540_sections/up.sql @@ -0,0 +1,56 @@ +-- Your SQL goes here +create table sections ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + + -- Title of the section + title varchar(255) UNIQUE NOT NULL, + -- Optional description of the section + description text, + -- 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 +); + +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 +); + +create function enforce_post_titles() returns trigger as $enforce_post_titles$ + begin + -- 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 + ) 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 + ) then + raise exception 'Expected an empty post title, but got a value.'; + end if; + + return new; + end; +$enforce_post_titles$ language plpgsql; + +create trigger insert_enforce_post_titles + before insert on posts + for each row + execute function enforce_post_titles(); + +create trigger update_enforce_post_titles + before update of title on posts + for each row + when (old.title is distinct from new.title) + execute function enforce_post_titles(); diff --git a/rustfmt.toml b/rustfmt.toml index 5e52857..8e8627b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -36,7 +36,7 @@ license_template_path = "" make_backup = false match_arm_blocks = true match_arm_leading_pipes = "Never" -match_block_trailing_comma = false +match_block_trailing_comma = true max_width = 100 merge_derives = true newline_style = "Auto" diff --git a/src/db/mod.rs b/src/db/mod.rs index 9c831dd..35e4995 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,11 @@ +//! The db module contains all Diesel-related logic. This is to prevent the various Diesel imports +//! from poluting other modules' namespaces. + +pub mod posts; +pub mod sections; pub mod tokens; pub mod users; +pub use sections::{NewSection, Section}; pub use tokens::{NewRefreshToken, RefreshToken}; pub use users::{NewUser, User}; diff --git a/src/db/posts.rs b/src/db/posts.rs new file mode 100644 index 0000000..163902e --- /dev/null +++ b/src/db/posts.rs @@ -0,0 +1,58 @@ +use chrono::NaiveDate; +use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; +use uuid::Uuid; + +use crate::{ + errors::{RbError, RbResult}, + schema::{posts, posts::dsl::*}, +}; + +#[derive(Queryable)] +pub struct Post +{ + pub id: Uuid, + pub section_id: Uuid, + pub title: Option, + pub publish_date: NaiveDate, + pub content: String, +} + +#[derive(Insertable)] +#[table_name = "posts"] +pub struct NewPost +{ + pub section_id: Uuid, + pub title: Option, + pub publish_date: NaiveDate, +} + +/// Returns all posts in the database; should be used with care as this method could quickly return +/// a large amount of data. +/// +/// # Arguments +/// +/// * `conn` - a reference to a database connection +pub fn all(conn: &PgConnection) -> RbResult> +{ + posts + .load::(conn) + .map_err(|_| RbError::DbError("Couldn't get all posts.")) +} + +/// Insert a new post into the database. +/// +/// # Arguments +/// +/// * `conn` - reference to a database connection +/// * `new_post` - the new post object to insert +pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult<()> +{ + insert_into(posts) + .values(new_post) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't insert post."))?; + + // TODO check for conflict? + + Ok(()) +} diff --git a/src/db/sections.rs b/src/db/sections.rs new file mode 100644 index 0000000..b429c85 --- /dev/null +++ b/src/db/sections.rs @@ -0,0 +1,63 @@ +//! Handles all section-related database operations. + +use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + errors::{RbError, RbResult}, + schema::{sections, sections::dsl::*}, +}; + +/// Represents a section contained in the database. +#[derive(Queryable)] +pub struct Section +{ + pub id: Uuid, + pub title: String, + pub description: Option, + pub is_default: bool, + pub has_titles: bool, +} + +/// A new section to be added into the database. +#[derive(Deserialize, Insertable)] +#[table_name = "sections"] +#[serde(rename_all = "camelCase")] +pub struct NewSection +{ + title: String, + description: Option, + is_default: Option, + has_titles: Option, +} + +/// Returns all sections in the database. +/// +/// # Arguments +/// +/// * `conn` - reference to a database connection +pub fn all(conn: &PgConnection) -> RbResult> +{ + sections + .load::
(conn) + .map_err(|_| RbError::DbError("Couldn't get all sections")) +} + +/// Inserts a new section into the database. +/// +/// # Arguments +/// +/// * `conn` - reference to a database connection +/// * `new_section` - the new section to be added +pub fn create(conn: &PgConnection, new_section: &NewSection) -> RbResult<()> +{ + insert_into(sections) + .values(new_section) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't insert section."))?; + + // TODO check for conflict? + + Ok(()) +} diff --git a/src/db/tokens.rs b/src/db/tokens.rs index 8940721..cbb8898 100644 --- a/src/db/tokens.rs +++ b/src/db/tokens.rs @@ -1,3 +1,5 @@ +//! Handles refresh token-related database operations. + use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; use uuid::Uuid; @@ -6,6 +8,7 @@ use crate::{ schema::{refresh_tokens, refresh_tokens::dsl::*}, }; +/// A refresh token as stored in the database #[derive(Queryable)] pub struct RefreshToken { @@ -15,6 +18,7 @@ pub struct RefreshToken pub last_used_at: Option, } +/// A new refresh token to be added into the database #[derive(Insertable)] #[table_name = "refresh_tokens"] pub struct NewRefreshToken @@ -24,6 +28,12 @@ pub struct NewRefreshToken pub expires_at: chrono::NaiveDateTime, } +// TODO add pagination as this could grow very quickly +/// Returns all refresh tokens contained in the database. +/// +/// # Arguments +/// +/// * `conn` - database connection to use pub fn all(conn: &PgConnection) -> RbResult> { refresh_tokens @@ -31,18 +41,30 @@ pub fn all(conn: &PgConnection) -> RbResult> .map_err(|_| RbError::DbError("Couldn't get all refresh tokens.")) } +/// Insert a new refresh token into the database. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `new_refresh_token` - token to insert pub fn create(conn: &PgConnection, new_refresh_token: &NewRefreshToken) -> RbResult<()> { insert_into(refresh_tokens) .values(new_refresh_token) .execute(conn) - .map_err(|_| RbError::Custom("Couldn't insert refresh token."))?; + .map_err(|_| RbError::DbError("Couldn't insert refresh token."))?; // TODO check for conflict? Ok(()) } +/// Returns the token & user data associated with the given refresh token value. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `token_val` - token value to search for pub fn find_with_user( conn: &PgConnection, token_val: &[u8], @@ -53,10 +75,20 @@ pub fn find_with_user( .inner_join(crate::schema::users::dsl::users) .filter(token.eq(token_val)) .first::<(RefreshToken, super::users::User)>(conn) - .map_err(|_| RbError::Custom("Couldn't get refresh token & user.")) + .map_err(|_| RbError::DbError("Couldn't get refresh token & user.")) .ok() } +/// Updates a token's `last_used_at` column value. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `token_` - value of the refresh token to update +/// * `last_used_at_` - date value to update column with +/// +/// **NOTE**: argument names use trailing underscores as to not conflict with Diesel's imported dsl +/// names. pub fn update_last_used_at( conn: &PgConnection, token_: &[u8], diff --git a/src/db/users.rs b/src/db/users.rs index efc74db..37ef9c2 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -1,3 +1,5 @@ +//! Handles user-related database operations. + use diesel::{prelude::*, AsChangeset, Insertable, Queryable}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -7,6 +9,7 @@ use crate::{ schema::{users, users::dsl::*}, }; +/// A user as stored in the database. #[derive(Queryable, Serialize)] pub struct User { @@ -18,6 +21,7 @@ pub struct User pub admin: bool, } +/// A new user to add to the database. #[derive(Insertable, AsChangeset, Deserialize)] #[table_name = "users"] pub struct NewUser @@ -27,6 +31,11 @@ pub struct NewUser pub admin: bool, } +/// Returns all users in the database. +/// +/// # Arguments +/// +/// * `conn` - database connection to use pub fn all(conn: &PgConnection) -> RbResult> { users @@ -34,11 +43,23 @@ pub fn all(conn: &PgConnection) -> RbResult> .map_err(|_| RbError::DbError("Couldn't get all users.")) } +/// Find a user with a given ID. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `user_id` - ID to search for pub fn find(conn: &PgConnection, user_id: Uuid) -> Option { users.find(user_id).first::(conn).ok() } +/// Find a user with a given username. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `username_` - username to search for pub fn find_by_username(conn: &PgConnection, username_: &str) -> RbResult { Ok(users @@ -47,6 +68,12 @@ pub fn find_by_username(conn: &PgConnection, username_: &str) -> RbResult .map_err(|_| RbError::DbError("Couldn't find users by username."))?) } +/// Insert a new user into the database +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `new_user` - user to insert pub fn create(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> { let count = diesel::insert_into(users) @@ -61,6 +88,12 @@ pub fn create(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> Ok(()) } +/// Either create a new user or update an existing one on conflict. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `new_user` - user to insert/update pub fn create_or_update(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> { diesel::insert_into(users) @@ -74,6 +107,12 @@ pub fn create_or_update(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> Ok(()) } +/// Delete the user with the given ID. +/// +/// # Arguments +/// +/// `conn` - database connection to use +/// `user_id` - ID of user to delete pub fn delete(conn: &PgConnection, user_id: Uuid) -> RbResult<()> { diesel::delete(users.filter(id.eq(user_id))) @@ -83,6 +122,14 @@ pub fn delete(conn: &PgConnection, user_id: Uuid) -> RbResult<()> Ok(()) } +/// Block a user given an ID. +/// In practice, this means updating the user's entry so that the `blocked` column is set to +/// `true`. +/// +/// # Arguments +/// +/// `conn` - database connection to use +/// `user_id` - ID of user to block pub fn block(conn: &PgConnection, user_id: Uuid) -> RbResult<()> { diesel::update(users.filter(id.eq(user_id))) diff --git a/src/errors.rs b/src/errors.rs index bb7856a..1f9aff3 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -61,7 +61,7 @@ impl RbError RbError::AuthInvalidRefreshToken => "This refresh token is not valid.", RbError::AuthDuplicateRefreshToken => { "This refresh token has already been used. The user has been blocked." - } + }, RbError::AuthMissingHeader => "Missing Authorization header.", RbError::UMDuplicateUser => "This user already exists.", diff --git a/src/guards.rs b/src/guards.rs index 7b40bdd..3510163 100644 --- a/src/guards.rs +++ b/src/guards.rs @@ -10,7 +10,7 @@ use sha2::Sha256; use crate::{auth::jwt::Claims, errors::RbError, RbConfig}; -/// Extracts a "Authorization: Bearer" string from the headers. +/// Extracts an "Authorization: Bearer" string from the headers. pub struct Bearer<'a>(&'a str); #[rocket::async_trait] @@ -22,7 +22,7 @@ impl<'r> FromRequest<'r> for Bearer<'r> { // If the header isn't present, just forward to the next route let header = match req.headers().get_one("Authorization") { - None => return Outcome::Failure((Status::BadRequest, Self::Error::AuthMissingHeader)), + None => return Outcome::Forward(()), Some(val) => val, }; @@ -31,12 +31,10 @@ impl<'r> FromRequest<'r> for Bearer<'r> } // Extract the jwt token from the header - let auth_string = match header.get(7..) { - Some(s) => s, - None => return Outcome::Failure((Status::Unauthorized, Self::Error::AuthUnauthorized)), - }; - - Outcome::Success(Self(auth_string)) + match header.get(7..) { + Some(s) => Outcome::Success(Self(s)), + None => Outcome::Failure((Status::Unauthorized, Self::Error::AuthUnauthorized)), + } } } @@ -63,14 +61,14 @@ impl<'r> FromRequest<'r> for Jwt Status::InternalServerError, Self::Error::Custom("Failed to do Hmac thing."), )) - } + }, }; // Verify token using key let claims: Claims = match bearer.verify_with_key(&key) { Ok(claims) => claims, Err(_) => { return Outcome::Failure((Status::Unauthorized, Self::Error::AuthUnauthorized)) - } + }, }; Outcome::Success(Self(claims)) diff --git a/src/main.rs b/src/main.rs index 2c0c7c4..4a6db39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ pub mod db; pub mod errors; pub mod guards; pub(crate) mod schema; +pub mod sections; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -110,4 +111,5 @@ fn rocket() -> _ "/api/admin", routes![admin::get_users, admin::create_user, admin::get_user_info], ) + .mount("/api/sections", routes![sections::create_section]) } diff --git a/src/schema.rs b/src/schema.rs index e3854e3..45b9813 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,3 +1,13 @@ +table! { + posts (id) { + id -> Uuid, + section_id -> Uuid, + title -> Nullable, + publish_date -> Date, + content -> Text, + } +} + table! { refresh_tokens (token) { token -> Bytea, @@ -7,6 +17,16 @@ table! { } } +table! { + sections (id) { + id -> Uuid, + title -> Varchar, + description -> Nullable, + is_default -> Bool, + has_titles -> Bool, + } +} + table! { users (id) { id -> Uuid, @@ -17,6 +37,7 @@ table! { } } +joinable!(posts -> sections (section_id)); joinable!(refresh_tokens -> users (user_id)); -allow_tables_to_appear_in_same_query!(refresh_tokens, users,); +allow_tables_to_appear_in_same_query!(posts, refresh_tokens, sections, users,); diff --git a/src/sections.rs b/src/sections.rs new file mode 100644 index 0000000..e4def24 --- /dev/null +++ b/src/sections.rs @@ -0,0 +1,24 @@ +//! This module handles management of site sections (aka blogs). + +use rocket::serde::json::Json; + +use crate::{db, errors::RbResult, guards::Admin, RbDbConn}; + +/// Route for creating a new section. +/// +/// # Arguments +/// +/// * `_admin` - guard ensuring user is admin +/// * `conn` - guard providing a connection to the database +/// * `new_section` - Json-encoded NewSection object +#[post("/", data = "")] +pub async fn create_section( + _admin: Admin, + conn: RbDbConn, + new_section: Json, +) -> RbResult<()> +{ + Ok(conn + .run(move |c| db::sections::create(c, &new_section.into_inner())) + .await?) +} diff --git a/tests/admin.py b/tests/admin.py index 19a1c5b..069c2dd 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -2,7 +2,7 @@ import requests class RbClient: - def __init__(self, username, password, base_url = "http://localhost:8000/api"): + def __init__(self, username = "admin", password = "password", base_url = "http://localhost:8000/api"): self.username = username self.password = password self.base_url = base_url @@ -17,6 +17,7 @@ class RbClient: }) if r.status_code != 200: + print(r.text) raise Exception("Couldn't login") res = r.json() @@ -56,9 +57,15 @@ class RbClient: def get(self, url, *args, **kwargs): return self._request("GET", f"{self.base_url}{url}", *args, **kwargs) + def post(self, url, *args, **kwargs): + return self._request("POST", f"{self.base_url}{url}", *args, **kwargs) + if __name__ == "__main__": - client = RbClient("admin", "password") + client = RbClient() - print(client.get("/admin/users").json()) + # print(client.get("/admin/users").json()) + client.post("/sections", json={ + "title": "this is a title" + }) diff --git a/web/package.json b/web/package.json index 6dfbfa6..b1070aa 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,9 @@ "build": "astro build" }, "devDependencies": { + "@astrojs/renderer-svelte": "^0.1.1", "astro": "0.19.0-next.2", - "@astrojs/renderer-svelte": "^0.1.1" + "miragejs": "^0.1.41", + "typescript": "^4.4.3" } } diff --git a/web/src/components/MirageTest.svelte b/web/src/components/MirageTest.svelte new file mode 100644 index 0000000..ac3676b --- /dev/null +++ b/web/src/components/MirageTest.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/src/components/SvelteCounter.svelte b/web/src/components/SvelteCounter.svelte index f493c25..ea6cf52 100644 --- a/web/src/components/SvelteCounter.svelte +++ b/web/src/components/SvelteCounter.svelte @@ -1,4 +1,4 @@ -