Initial commit

master
Tait Hoyem 2 years ago
commit 36ee100cb2

1
.gitignore vendored

@ -0,0 +1 @@
/target

@ -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"]

@ -0,0 +1,2 @@
[default.databases.notes]
url = "postgres://notearoni:password@localhost/notearoni"

@ -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]
* ...
```

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE users;

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

@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE note;
DROP TABLE list;

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

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE perms;

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

@ -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<T, E = rocket::response::Debug<sqlx::Error>> = std::result::Result<T, E>;
#[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<rocket_db_pools::Error<sqlx::Error>>),
DBError(sqlx::Error),
}
#[rocket::async_trait]
impl<'a> FromRequest<'a> for User {
type Error = UserError;
async fn from_request(req: &'a Request<'_>) -> request::Outcome<Self, Self::Error> {
let cookies = req.cookies();
let mut db = match req.guard::<Connection<Notes>>().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<Postgres>, lid: i32) -> Result<Vec<Note>> {
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<Postgres>, uid: i32) -> Result<Vec<List>> {
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<Postgres>, uid: i32) -> Result<Vec<List>> {
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<Postgres>, email: String) -> Result<Option<User>> {
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<Postgres>, 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(())
}

@ -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,
}

@ -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/<name>/<age>")]
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="<user>")]
async fn new_user(user: Form<NewUserForm<'_>>, mut db: Connection<Notes>) -> Result<String> {
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="<user>")]
async fn login(user: Form<UserLoginForm<'_>>, mut db: Connection<Notes>, cookies: &CookieJar<'_>) -> Result<String> {
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="<new_list>")]
async fn new_list(mut db: Connection<Notes>, user: User, new_list: Form<NewListForm<'_>>) -> Result<String> {
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="<new_list>", rank=2)]
async fn new_list_not_logged_in(new_list: Form<NewListForm<'_>>) -> Redirect {
Redirect::to(uri!(home))
}
#[get("/lists")]
async fn show_list(mut db: Connection<Notes>, user: User) -> Result<Template> {
Ok(Template::render("show_lists", context!{
lists: get_user_lists(&mut db, user.id).await?,
perm_lists: get_user_lists_from_perms(&mut db, user.id).await?
}))
}
#[get("/lists", rank=2)]
async fn show_list_not_logged_in() -> Redirect {
Redirect::to(uri!(home))
}
#[get("/new/list")]
async fn new_list_form(_user: User) -> Template {
Template::render("new_list", context!{})
}
#[get("/new/list", rank=2)]
async fn new_list_form_not_logged_in() -> Redirect {
Redirect::to(uri!(home))
}
#[post("/perms", data="<perms>")]
async fn new_perms(perms: Form<PermsForm<'_>>, user: User, mut db: Connection<Notes>) -> Result<String> {
let perm_user = match get_user_from_email(&mut db, perms.user_email.to_string()).await? {
Some(u) => {
if u.id == user.id { return Ok(format!("You may not grant yourself a permission.")); } else { u }
},
None => return Ok(format!("Permission is added.")),
};
match add_permission(&mut db, perm_user.id, perms.list_id, perms.perm).await {
Err(_) => Ok(format!("There was an error adding this permission.")),
Ok(_) => Ok(format!("Permission is added.")),
}
}
#[post("/perms", data="<perms>", rank=2)]
async fn new_perms_not_logged_in(perms: Form<PermsForm<'_>>) -> Redirect {
Redirect::to(uri!(home))
}
#[get("/new/perms")]
async fn add_perms_form(mut db: Connection<Notes>, user: User) -> Result<Template> {
Ok(Template::render("new_perms", context!{
lists: get_user_lists(&mut db, user.id).await?
}))
}
#[get("/new/perms", rank=2)]
async fn add_perms_form_not_logged_in() -> Redirect {
Redirect::to(uri!(home))
}
#[get("/logout")]
async fn logout(mut db: Connection<Notes>, cookies: &CookieJar<'_>) -> Result<String> {
let uuid = cookies.get_private("user_uuid");
if uuid.is_none() {
return Ok(format!("You aren't logged in, lol!"));
}
cookies.remove_private(Cookie::named("user_uuid"));
let actual_uuid = uuid.unwrap();
let result = sqlx::query!("SELECT * FROM users WHERE uuid = $1", actual_uuid.value())
.fetch_optional(&mut *db)
.await?;
if let Some(user) = result {
Ok(format!("User '{}', logged out.", user.username))
} else {
Ok(format!("We don't know what happened..."))
}
}
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Template::fairing())
.attach(Notes::init())
.mount("/", routes![
hello,
home,
login,
new_user,
create,
logout
])
.mount("/new", routes![
new_list, new_list_not_logged_in,
new_perms, new_perms_not_logged_in,
])
.mount("/show", routes![
show_list, show_list_not_logged_in
])
.mount("/forms", routes![
add_perms_form, add_perms_form_not_logged_in,
new_list_form, new_list_form_not_logged_in
])
}

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<header>
{% include "includes/header" %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
{% include "includes/footer" %}
</footer>
</body>
</html>

@ -0,0 +1,5 @@
{% extends "base" %}
{% block content %}
<h1>Error</h1>
<p>{{ error }}</p>
{% endblock %}

@ -0,0 +1 @@
&copy; Tait Hoyem, 2022

@ -0,0 +1,8 @@
<nav>
<a href="/">Home</a>
<a href="/create">New User</a>
<a href="/forms/new/list">New List</a>
<a href="/show/lists">Show Lists</a>
<a href="/forms/new/perms">Add Permissions</a>
<a href="/logout/">Log out</a>
</nav>

@ -0,0 +1,10 @@
{% extends "base" %}
{% block content %}
<form method="POST" action="/login/">
<label for="username">Username</label>
<input type="text" name="username" id="username">
<label for="password">Password</label>
<input type="password" name="password" id="password">
<input type="submit" value="Login">
</form>
{% endblock %}

@ -0,0 +1,12 @@
{% extends "base" %}
{% block content %}
<form method="POST" action="/new/">
<label for="username">Username</label>
<input type="text" id="username" name="username">
<label for="password">Password</label>
<input type="text" id="password" name="password">
<label for="email">Email</label>
<input type="email" id="email" name="email">
<input type="submit" value="Create Account">
</form>
{% endblock %}

@ -0,0 +1,8 @@
{% extends "base" %}
{% block content %}
<form method="POST" action="/new/list">
<label for="name">Name</label>
<input type="text" name="name" id="name">
<input type="submit" value="Create New List">
</form>
{% endblock %}

@ -0,0 +1,19 @@
{% extends "base" %}
{% block content %}
<form method="POST" action="/new/perms">
<label for="list_id">List to share</label>
<select id="list_id" name="list_id">
{% for list in lists %}
<option value="{{ list.id }}">{{ list.name }}</option>
{% endfor %}
</select>
<label for="email">Email of user you want to add these perms to</label>
<input type="email" name="user_email" id="email">
<label for="perms">Permissions</label>
<select id="perms" name="perm">
<option value="1">Read</option>
<option value="2">Write</option>
</select>
<input type="submit" value="Add Permission">
</form>
{% endblock %}

@ -0,0 +1,11 @@
{% extends "base" %}
{% block content %}
<ol>
{% for list in lists %}
<li><a href="/show/list/{{ list.id }}">{{ list.name }}</a></li>
{% endfor %}
{% for list in perm_lists %}
<li><a href="/show/list/{{ list.id }}">{{ list.name }}</a> (shared from {{ list.owner_id }})</li>
{% endfor %}
</ol>
{% endblock %}
Loading…
Cancel
Save