From 36ee100cb2b67365ab133a2e1a30df6b2f60a728 Mon Sep 17 00:00:00 2001 From: Tait Hoyem Date: Mon, 12 Sep 2022 17:50:25 -0600 Subject: [PATCH] Initial commit --- .gitignore | 1 + Cargo.toml | 24 ++ Rocket.toml | 2 + TODO.md | 60 +++++ .../20220912041932_create_user_table.down.sql | 2 + .../20220912041932_create_user_table.up.sql | 8 + .../20220912062838_add_notes_table.down.sql | 3 + .../20220912062838_add_notes_table.up.sql | 40 ++++ .../20220912152422_add_perms_table.down.sql | 2 + .../20220912152422_add_perms_table.up.sql | 14 ++ src/db.rs | 190 ++++++++++++++++ src/forms.rs | 27 +++ src/main.rs | 208 ++++++++++++++++++ templates/base.html.tera | 16 ++ templates/error.html.tera | 5 + templates/includes/footer.html.tera | 1 + templates/includes/header.html.tera | 8 + templates/index.html.tera | 10 + templates/new.html.tera | 12 + templates/new_list.html.tera | 8 + templates/new_perms.html.tera | 19 ++ templates/show_lists.html.tera | 11 + 22 files changed, 671 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 Rocket.toml create mode 100644 TODO.md create mode 100644 migrations/20220912041932_create_user_table.down.sql create mode 100644 migrations/20220912041932_create_user_table.up.sql create mode 100644 migrations/20220912062838_add_notes_table.down.sql create mode 100644 migrations/20220912062838_add_notes_table.up.sql create mode 100644 migrations/20220912152422_add_perms_table.down.sql create mode 100644 migrations/20220912152422_add_perms_table.up.sql create mode 100644 src/db.rs create mode 100644 src/forms.rs create mode 100644 src/main.rs create mode 100644 templates/base.html.tera create mode 100644 templates/error.html.tera create mode 100644 templates/includes/footer.html.tera create mode 100644 templates/includes/header.html.tera create mode 100644 templates/index.html.tera create mode 100644 templates/new.html.tera create mode 100644 templates/new_list.html.tera create mode 100644 templates/new_perms.html.tera create mode 100644 templates/show_lists.html.tera diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b0a058 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "noted" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rocket = { version = "0.5.0-rc.2", features = ["secrets"] } +dotenvy = "0.15" +bcrypt = "0.13.0" +serde = "1.0.144" + +[dependencies.rocket_dyn_templates] +version = "0.1.0-rc.2" +features = ["tera"] + +[dependencies.rocket_db_pools] +version = "0.1.0-rc.2" +features = ["sqlx_postgres"] + +[dependencies.sqlx] +version = "0.5.13" +features = ["macros", "runtime-tokio-rustls"] diff --git a/Rocket.toml b/Rocket.toml new file mode 100644 index 0000000..977bd0d --- /dev/null +++ b/Rocket.toml @@ -0,0 +1,2 @@ +[default.databases.notes] +url = "postgres://notearoni:password@localhost/notearoni" diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..73cb3ff --- /dev/null +++ b/TODO.md @@ -0,0 +1,60 @@ +* Add `task` statues: + + ```sql + CREATE TABLE task ( + ... + status INT -- 0 = created, 1 = in-progress, 2 = complete + removed BOOL -- decides if an item has been removed (save them just in case) + ); + ``` + +* Add subtasks/subtasks: + + ```sql + CREATE TABLE `task ( + ... + parent_id INT -- self-referential ID + ); + ``` + +* Add task deadlines + + ```sql + CREATE TABLE task ( + ... + deadline DATETIME -- add an (optional) datetime as a time for the task to be complete + ); + ``` + +* Add `/new_list/` route. +* Add `/new_item/` route (choose from a dropdown of your lists). +* Add `perms` table. + + ```sql + CREATE TABLE perms ( + list_id INT, -- foregin key for list + user_id INT, -- foregin key for user + perms INT -- 0 = none, 1 = read, 2 = write, 4 = admin (can change perms) binary OR results together to find which permissions are granted + ); + ``` + +* Add a way to remove ALL your tasks. It needs to be in reverse ID order so that sub-tasks are deleted before the parent task. +* Create command-line client like the following: + + ```shell + $ todor show-lists + * school + * work + * family [selected] + * social + $ todor set-list work + $ todor show-lists + * school + * work [selected] + * family + * social + $ todor list + * Email sponsorship decal [In-Progress] + * Send email thanking authors of Rocket.rs [Incomplete] + * ... + ``` diff --git a/migrations/20220912041932_create_user_table.down.sql b/migrations/20220912041932_create_user_table.down.sql new file mode 100644 index 0000000..dc3714b --- /dev/null +++ b/migrations/20220912041932_create_user_table.down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE users; diff --git a/migrations/20220912041932_create_user_table.up.sql b/migrations/20220912041932_create_user_table.up.sql new file mode 100644 index 0000000..72f1cfc --- /dev/null +++ b/migrations/20220912041932_create_user_table.up.sql @@ -0,0 +1,8 @@ +-- Your SQL goes here +CREATE TABLE users ( + id INTEGER PRIMARY KEY NOT NULL GENERATED ALWAYS AS IDENTITY, + uuid VARCHAR(36) NOT NULL DEFAULT gen_random_uuid(), + username VARCHAR(16) NOT NULL UNIQUE, + password VARCHAR(512) NOT NULL, -- always a hashed value + email VARCHAR(32) NOT NULL UNIQUE +); diff --git a/migrations/20220912062838_add_notes_table.down.sql b/migrations/20220912062838_add_notes_table.down.sql new file mode 100644 index 0000000..a3e7c78 --- /dev/null +++ b/migrations/20220912062838_add_notes_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DROP TABLE note; +DROP TABLE list; diff --git a/migrations/20220912062838_add_notes_table.up.sql b/migrations/20220912062838_add_notes_table.up.sql new file mode 100644 index 0000000..33aa6bf --- /dev/null +++ b/migrations/20220912062838_add_notes_table.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE list ( + id INT GENERATED ALWAYS AS IDENTITY, + owner_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY(id), + CONSTRAINT fk_owner + FOREIGN KEY(owner_id) + REFERENCES users(id) +); + +CREATE TABLE note ( + id INT GENERATED ALWAYS AS IDENTITY, + list_id INT NOT NULL, -- references a list + writer_id INT NOT NULL, -- references a user + content VARCHAR(255) NOT NULL, -- about one twitter post worth + modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- the last time this item was modified + due_at TIMESTAMPTZ, -- a nullable time at which the task needs to be complete + PRIMARY KEY(id), + CONSTRAINT fk_writer + FOREIGN KEY(writer_id) + REFERENCES users(id), + CONSTRAINT fk_list + FOREIGN KEY(list_id) + REFERENCES list(id) +); + +-- The following section automatically updates the "modified" column when a record is updated +CREATE OR REPLACE FUNCTION trigger_set_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.modified_at = NOW(); + RETURN NEW; +END +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_timestamp +BEFORE UPDATE ON note +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); + diff --git a/migrations/20220912152422_add_perms_table.down.sql b/migrations/20220912152422_add_perms_table.down.sql new file mode 100644 index 0000000..11814ab --- /dev/null +++ b/migrations/20220912152422_add_perms_table.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE perms; diff --git a/migrations/20220912152422_add_perms_table.up.sql b/migrations/20220912152422_add_perms_table.up.sql new file mode 100644 index 0000000..52ee337 --- /dev/null +++ b/migrations/20220912152422_add_perms_table.up.sql @@ -0,0 +1,14 @@ +-- Add up migration script here +CREATE TABLE perms ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + list_id INTEGER NOT NULL, -- the list affected by the permission + user_id INTEGER NOT NULL, -- the user affected by the permission + read BOOLEAN NOT NULL, -- can the user read the list + write BOOLEAN NOT NULL, -- can the user write to the list + CONSTRAINT fk_list + FOREIGN KEY(list_id) + REFERENCES list(id), + CONSTRAINT fk_user + FOREIGN KEY(user_id) + REFERENCES users(id) +); diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..511b82d --- /dev/null +++ b/src/db.rs @@ -0,0 +1,190 @@ +use serde; +use rocket::{ + self, + http::Status, + serde::Serialize, + request::{ + self, + FromRequest, + }, + Request, + outcome::{ + try_outcome, + Outcome::{ + Success, + Failure, + Forward, + }, + }, +}; +use rocket_db_pools::{ + Database, + Connection, +}; +use sqlx::{ + pool::PoolConnection, + Postgres, +}; + +#[derive(Database)] +#[database("notes")] +pub struct Notes(sqlx::PgPool); + +pub type Result> = std::result::Result; + +#[derive(Serialize)] +pub struct Note { + pub id: i32, + pub content: String, + pub list_id: i32, +} +impl ToString for Note { + fn to_string(&self) -> String { + format!("Note: {}", self.content) + } +} + +#[derive(Serialize)] +pub struct User { + pub id: i32, + pub uuid: String, + pub username: String, + pub password: String, + pub email: String, +} +impl ToString for User { + fn to_string(&self) -> String { + format!("User: {}", self.username) + } +} +#[derive(Debug)] +pub enum UserError { + NoCookie, + InvalidCookie, + WrappedDBError(Option>), + DBError(sqlx::Error), +} + +#[rocket::async_trait] +impl<'a> FromRequest<'a> for User { + type Error = UserError; + + async fn from_request(req: &'a Request<'_>) -> request::Outcome { + let cookies = req.cookies(); + let mut db = match req.guard::>().await { + Success(dbv) => dbv, + Failure((http_err, err)) => return Failure((http_err, UserError::WrappedDBError(err))), + Forward(next) => return Forward(next), + }; + let user_uuid = match cookies.get_private("user_uuid") { + Some(crumb) => crumb, + None => return Forward(()), // this will redirect to the login page + }; + match sqlx::query!("SELECT * FROM users WHERE uuid = $1", user_uuid.value()) + .fetch_optional(&mut *db) + .await { + Ok(Some(row)) => { + Success(User { + id: row.id, + uuid: row.uuid, + username: row.username, + password: row.password, + email: row.email, + }) + }, + Ok(None) => Forward(()), // this will redirect to the login page + Err(e) => Failure((Status::InternalServerError, UserError::DBError(e))), + } + } +} + +#[derive(Serialize)] +pub struct List { + pub id: i32, + pub name: String, + pub owner_id: i32, +} +impl ToString for List { + fn to_string(&self) -> String { + format!("{}: {} (owned by {})", self.id, self.name, self.owner_id) + } +} + +pub async fn get_list_notes(db: &mut PoolConnection, lid: i32) -> Result> { + Ok(sqlx::query!(" + SELECT id,content,list_id + FROM note + WHERE list_id = $1", lid) + .fetch_all(db) + .await? + .iter() + .map(|r| Note { + id: r.id, + content: r.content.clone(), + list_id: r.list_id + }) + .collect()) +} + +pub async fn get_user_lists(db: &mut PoolConnection, uid: i32) -> Result> { + Ok(sqlx::query!(" + SELECT id,name,owner_id + FROM list + WHERE owner_id = $1", uid) + .fetch_all(db) + .await? + .iter() + .map(|r| List { + name: r.name.clone(), + id: r.id, + owner_id: r.owner_id + }) + .collect()) +} + +pub async fn get_user_lists_from_perms(db: &mut PoolConnection, uid: i32) -> Result> { + Ok(sqlx::query!(" + SELECT list.id,list.name,list.owner_id + FROM list + JOIN perms ON list.id=perms.list_id + WHERE perms.user_id = $1 + AND perms.read = TRUE", uid) + .fetch_all(db) + .await? + .iter() + .map(|r| List { + name: r.name.clone(), + id: r.id, + owner_id: r.owner_id + }) + .collect()) +} + +pub async fn get_user_from_email(db: &mut PoolConnection, email: String) -> Result> { + match sqlx::query!( + "SELECT * FROM users WHERE email = $1", + email + ).fetch_optional(&mut *db) + .await? { + Some(row) => Ok(Some(User { + id: row.id, + uuid: row.uuid, + username: row.username, + password: row.password, + email: row.email, + })), + None => Ok(None), + } +} + +pub async fn add_permission(db: &mut PoolConnection, user_id: i32, list_id: i32, perm: i32) -> Result<()> { + sqlx::query!(" + INSERT INTO perms (user_id, list_id, read, write) VALUES ($1, $2, $3, $4)", + user_id, + list_id, + perm >= 1, + perm >= 2 + ).execute(db) + .await?; + Ok(()) +} diff --git a/src/forms.rs b/src/forms.rs new file mode 100644 index 0000000..415ab48 --- /dev/null +++ b/src/forms.rs @@ -0,0 +1,27 @@ +use rocket::form::Form; + +#[derive(FromForm)] +pub struct NewUserForm<'a> { + pub username: &'a str, + pub password: &'a str, + pub email: &'a str, +} + +#[derive(FromForm)] +pub struct NewListForm<'a> { + pub name: &'a str, +} + +#[derive(FromForm)] +pub struct UserLoginForm<'a> { + pub username: &'a str, + pub password: &'a str, +} + +#[derive(FromForm)] +pub struct PermsForm<'a> { + pub list_id: i32, + pub user_email: &'a str, + pub perm: i32, +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d4f2f6a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,208 @@ +pub mod forms; +pub mod db; + +use bcrypt::{ + hash, + verify, + DEFAULT_COST +}; +use rocket::{ + form::Form, + response::Redirect, + State +}; +use forms::{ + UserLoginForm, + NewUserForm, + NewListForm, + PermsForm, +}; +use db::{ + List, + Notes, + Result, + add_permission, + get_user_from_email, + get_user_lists, + get_user_lists_from_perms, + User, +}; + +#[macro_use] extern crate rocket; +use rocket_dyn_templates::{Template, context}; +use rocket_db_pools::{ + Database, + Connection, + sqlx::{ + self, + Row, + }, +}; +use rocket::{ + serde::{ + Serialize + }, + http::{ + Cookie, + CookieJar, + }, +}; + +#[get("/hello//")] +async fn hello(name: &str, age: u8) -> String { + format!("Hello, {} year old named {}!", age, name) +} + +#[get("/")] +async fn home() -> Template { + Template::render("index", context!{}) +} +#[get("/create")] +async fn create() -> Template { + Template::render("new", context!{}) +} + +#[post("/new", data="")] +async fn new_user(user: Form>, mut db: Connection) -> Result { + let check_exists = sqlx::query!("SELECT id FROM users WHERE username = $1 OR email = $2", user.username, user.email) + .fetch_optional(&mut *db) + .await?; + if check_exists.is_some() { + return Ok(format!("This account already exists!")); + } + let hashed_pass = match hash(user.password, DEFAULT_COST) { + Ok(pass) => pass, + Err(e) => panic!("Could not hash a password! {}", e) + }; + sqlx::query!("INSERT INTO users (username,password,email) VALUES ($1, $2, $3)", user.username, hashed_pass, user.email) + .execute(&mut *db) + .await?; + Ok(format!("Thanks, {}, for creating an account on our service.", user.username)) +} + +#[post("/login", data="")] +async fn login(user: Form>, mut db: Connection, cookies: &CookieJar<'_>) -> Result { + match cookies.get_private("user_uuid") { + Some(crumb) => println!("UUID: {:?}", crumb.value()), + _ => {} + }; + let result = sqlx::query!("SELECT * FROM users WHERE username=$1", user.username) + .fetch_optional(&mut *db) + .await?; + let success = match result { + Some(ref db_user) => verify(&user.password, &db_user.password).unwrap(), + _ => false, + }; + if success { + cookies.add_private(Cookie::new("user_uuid", result.unwrap().uuid)); + Ok(format!("Yay! Thanks for logging in to our service!")) + } else { + Ok(format!("Incorrect login!")) + } +} + +#[post("/list", data="")] +async fn new_list(mut db: Connection, user: User, new_list: Form>) -> Result { + sqlx::query!("INSERT INTO list (owner_id, name) VALUES ($1, $2)", user.id, new_list.name) + .execute(&mut *db) + .await?; + Ok(format!("You added a new list: {}", new_list.name)) +} +#[post("/list", data="", rank=2)] +async fn new_list_not_logged_in(new_list: Form>) -> Redirect { + Redirect::to(uri!(home)) +} + +#[get("/lists")] +async fn show_list(mut db: Connection, user: User) -> Result