Switch DB layer to Diesel from SeaORM and Fjall #2
52 changed files with 1514 additions and 4216 deletions
1399
Cargo.lock
generated
1399
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
@ -12,27 +12,27 @@ argon2 = { version = "0.5.3", features = ["rand", "std"] }
|
||||||
async-trait = "0.1.78"
|
async-trait = "0.1.78"
|
||||||
axum = "0.7.4"
|
axum = "0.7.4"
|
||||||
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
|
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
|
||||||
axum-login = "0.14.0"
|
axum-login = "0.15"
|
||||||
bincode = "1.3.3"
|
bincode = "1.3.3"
|
||||||
|
chrono = "0.4.38"
|
||||||
clap = { version = "4.5.3", features = ["derive", "env"] }
|
clap = { version = "4.5.3", features = ["derive", "env"] }
|
||||||
|
diesel = { version = "2.2.0", features = ["extras", "returning_clauses_for_sqlite_3_35", "sqlite"] }
|
||||||
|
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.3"
|
||||||
fjall = "0.6.5"
|
|
||||||
free-icons = "0.7.0"
|
free-icons = "0.7.0"
|
||||||
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
|
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
|
||||||
minijinja-autoreload = "1.0.14"
|
minijinja-autoreload = "1.0.14"
|
||||||
model_derive = { path = "./model_derive" }
|
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
redb = "2.1.0"
|
redb = "2.1.0"
|
||||||
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
|
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
sled = "=1.0.0-alpha.121"
|
sled = "=1.0.0-alpha.121"
|
||||||
thiserror = "1.0.58"
|
thiserror = "1.0.58"
|
||||||
tokio = { version = "1.36.0", features = ["rt", "full"] }
|
tokio = { version = "1.36.0", features = ["rt", "full"] }
|
||||||
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
|
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
|
||||||
tower-sessions = "0.11.1"
|
tower-sessions = "0.12"
|
||||||
tower-sessions-moka-store = "0.11.0"
|
tower-sessions-moka-store = "0.12"
|
||||||
tower-sessions-sqlx-store = { version = "0.11.0", features = ["sqlite"] }
|
tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "v7", "serde"] }
|
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "v7", "serde"] }
|
||||||
|
|
11
README.md
11
README.md
|
@ -34,10 +34,15 @@ We use nightly, and installation and management using [rustup][rustup] is
|
||||||
recommended.
|
recommended.
|
||||||
|
|
||||||
|
|
||||||
### SeaORM
|
### DB (Diesel)
|
||||||
|
|
||||||
We use SeaORM for database interaction. You'll want the CLI, which you can
|
We use [Diesel](https://diesel.rs/) for database interaction. You'll want the
|
||||||
install with `cargo install sea-orm-cli`.
|
CLI, which you can install with the following command. This will install it for
|
||||||
|
your user on your system, including support for SQLite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install diesel_cli --no-default-features -F sqlite-bundled
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Tailwind
|
### Tailwind
|
||||||
|
|
47
_docs/decisions/0004-uuids-for-primary-keys.md
Normal file
47
_docs/decisions/0004-uuids-for-primary-keys.md
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# 4. UUIDs for primary keys
|
||||||
|
|
||||||
|
Date: 2024-05-31
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need primary keys in our database.
|
||||||
|
|
||||||
|
I've used integers and UUIDs for this in different contexts. Ultimately, we have
|
||||||
|
to decide on which one to use.
|
||||||
|
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We're going to use UUIDs for our primary keys.
|
||||||
|
|
||||||
|
The primary motivation here is that it will give us the ability to generate IDs
|
||||||
|
before inserting records, and it lets us expose the IDs more easily. Instead of
|
||||||
|
either leaking information (count of users, etc.) or having a secondary mapping
|
||||||
|
for URLs, we can easily use the ID in a URL to map to a record for lookup.
|
||||||
|
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
There are some drawbacks:
|
||||||
|
|
||||||
|
- We lose some type safety, becasue SQLite only supports text/blob types and
|
||||||
|
it's been a blocker trying to implement custom sql types in Diesel, so this is
|
||||||
|
going to be done by converting to strings and operating on these IDs as
|
||||||
|
strings.
|
||||||
|
- They take up more space
|
||||||
|
|
||||||
|
However, we get these benefits:
|
||||||
|
|
||||||
|
- We can expose primary keys without leaking information. This makes it so we
|
||||||
|
do not need secondary IDs (and associated indexes) for looking up specific
|
||||||
|
records and putting them in URLs, where if we used integers we'd need that or
|
||||||
|
we would have to accept exposing the number of records we have.
|
||||||
|
- IDs are unique across tables, so they should give us the ability to find a
|
||||||
|
particular row even if we don't know the table. This also means we could link
|
||||||
|
events, like edit events, to any table via UUID.
|
9
diesel.toml
Normal file
9
diesel.toml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# For documentation on how to configure this file,
|
||||||
|
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||||
|
|
||||||
|
[print_schema]
|
||||||
|
file = "src/schema.rs"
|
||||||
|
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||||
|
|
||||||
|
[migrations_directory]
|
||||||
|
dir = "/home/nicole/Code/pique/migrations"
|
2351
migration/Cargo.lock
generated
2351
migration/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,19 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "migration"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "migration"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
|
||||||
|
|
||||||
[dependencies.sea-orm-migration]
|
|
||||||
version = "0.12.0"
|
|
||||||
features = [
|
|
||||||
"runtime-tokio-rustls",
|
|
||||||
"sqlx-sqlite",
|
|
||||||
]
|
|
|
@ -1,41 +0,0 @@
|
||||||
# Running Migrator CLI
|
|
||||||
|
|
||||||
- Generate a new migration file
|
|
||||||
```sh
|
|
||||||
cargo run -- generate MIGRATION_NAME
|
|
||||||
```
|
|
||||||
- Apply all pending migrations
|
|
||||||
```sh
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
```sh
|
|
||||||
cargo run -- up
|
|
||||||
```
|
|
||||||
- Apply first 10 pending migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- up -n 10
|
|
||||||
```
|
|
||||||
- Rollback last applied migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- down
|
|
||||||
```
|
|
||||||
- Rollback last 10 applied migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- down -n 10
|
|
||||||
```
|
|
||||||
- Drop all tables from the database, then reapply all migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- fresh
|
|
||||||
```
|
|
||||||
- Rollback all applied migrations, then reapply all migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- refresh
|
|
||||||
```
|
|
||||||
- Rollback all applied migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- reset
|
|
||||||
```
|
|
||||||
- Check the status of all migrations
|
|
||||||
```sh
|
|
||||||
cargo run -- status
|
|
||||||
```
|
|
|
@ -1,14 +0,0 @@
|
||||||
pub use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
mod m20240316_155147_create_users_table;
|
|
||||||
|
|
||||||
pub struct Migrator;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigratorTrait for Migrator {
|
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
|
||||||
vec![
|
|
||||||
Box::new(m20240316_155147_create_users_table::Migration),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
#[derive(DeriveMigrationName)]
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
Table::create()
|
|
||||||
.table(User::Table)
|
|
||||||
.if_not_exists()
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Id)
|
|
||||||
.integer()
|
|
||||||
.not_null()
|
|
||||||
.auto_increment()
|
|
||||||
.primary_key(),
|
|
||||||
)
|
|
||||||
.col(ColumnDef::new(User::FullName).string_len(100).not_null())
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Email)
|
|
||||||
.string_len(100)
|
|
||||||
.unique()
|
|
||||||
.not_null(),
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Username)
|
|
||||||
.string_len(32)
|
|
||||||
.unique()
|
|
||||||
.not_null(),
|
|
||||||
)
|
|
||||||
.col(ColumnDef::new(User::PasswordHash).string().not_null())
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Created)
|
|
||||||
.date_time()
|
|
||||||
.default(Expr::current_timestamp())
|
|
||||||
.not_null(),
|
|
||||||
)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Updated)
|
|
||||||
.date_time()
|
|
||||||
.default(Expr::current_timestamp())
|
|
||||||
.not_null(),
|
|
||||||
)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
|
||||||
enum User {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
FullName,
|
|
||||||
Email,
|
|
||||||
Username,
|
|
||||||
PasswordHash,
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
#[async_std::main]
|
|
||||||
async fn main() {
|
|
||||||
cli::run_cli(migration::Migrator).await;
|
|
||||||
}
|
|
0
migrations/.keep
Normal file
0
migrations/.keep
Normal file
1
migrations/2024-05-31-175324_users/down.sql
Normal file
1
migrations/2024-05-31-175324_users/down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS users;
|
7
migrations/2024-05-31-175324_users/up.sql
Normal file
7
migrations/2024-05-31-175324_users/up.sql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
username TEXT NOT NULL UNIQUE CHECK (LENGTH(username) <= 32),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE CHECK (LENGTH(email) <= 100),
|
||||||
|
name TEXT NOT NULL CHECK (LENGTH(name) <= 100)
|
||||||
|
);
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
DROP TABLE IF EXISTS documents;
|
18
migrations/2024-05-31-203133_projects_and_documents/up.sql
Normal file
18
migrations/2024-05-31-203133_projects_and_documents/up.sql
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
creator_id UUID_TEXT NOT NULL,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
|
||||||
|
key TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
creator_id UUID_TEXT NOT NULL,
|
||||||
|
project_id UUID_TEXT NOT NULL,
|
||||||
|
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
);
|
1
migrations/2024-05-31-204416_permissions/down.sql
Normal file
1
migrations/2024-05-31-204416_permissions/down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS project_memberships;
|
8
migrations/2024-05-31-204416_permissions/up.sql
Normal file
8
migrations/2024-05-31-204416_permissions/up.sql
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_memberships(
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
user_id UUID_TEXT NOT NULL,
|
||||||
|
project_id UUID_TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL
|
||||||
|
);
|
46
model_derive/Cargo.lock
generated
46
model_derive/Cargo.lock
generated
|
@ -1,46 +0,0 @@
|
||||||
# This file is automatically @generated by Cargo.
|
|
||||||
# It is not intended for manual editing.
|
|
||||||
version = 3
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "model_derive"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro2"
|
|
||||||
version = "1.0.82"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quote"
|
|
||||||
version = "1.0.36"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "2.0.61"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-ident"
|
|
||||||
version = "1.0.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
|
|
@ -1,11 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "model_derive"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
proc-macro = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quote = "1.0.36"
|
|
||||||
syn = { version = "2.0.61", features = ["full", "derive"] }
|
|
|
@ -1,48 +0,0 @@
|
||||||
use proc_macro::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{parse_macro_input, DeriveInput, LitInt};
|
|
||||||
|
|
||||||
#[proc_macro_derive(Model, attributes(model_version))]
|
|
||||||
pub fn model(input: TokenStream) -> TokenStream {
|
|
||||||
let input = parse_macro_input!(input as DeriveInput);
|
|
||||||
|
|
||||||
let name = input.ident;
|
|
||||||
let attrs = input.attrs;
|
|
||||||
|
|
||||||
let model_version = attrs.iter().find_map(|attr| {
|
|
||||||
if attr.path().is_ident("model_version") {
|
|
||||||
attr.parse_args::<LitInt>().ok().and_then(|lit| {
|
|
||||||
Some(lit.base10_parse::<u64>().unwrap())
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}).unwrap_or(0);
|
|
||||||
|
|
||||||
let lower_name = name.to_string().to_lowercase();
|
|
||||||
let lower_name_ident = format_ident!("{}", lower_name);
|
|
||||||
|
|
||||||
|
|
||||||
let expanded = quote! {
|
|
||||||
impl Model for #name {
|
|
||||||
type Id = uuid::Uuid;
|
|
||||||
|
|
||||||
fn id(&self) -> Self::Id {
|
|
||||||
self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key(id: Self::Id) -> Vec<u8> {
|
|
||||||
let mut key = vec![];
|
|
||||||
key.extend_from_slice(format!("{}:{}:", #lower_name, #model_version).as_bytes());
|
|
||||||
key.extend_from_slice(&id.into_bytes());
|
|
||||||
key
|
|
||||||
}
|
|
||||||
|
|
||||||
fn partition(kv_handle: &KvHandle) -> &PartitionHandle {
|
|
||||||
&kv_handle.#lower_name_ident
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
TokenStream::from(expanded)
|
|
||||||
}
|
|
5
rustfmt.toml
Normal file
5
rustfmt.toml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
imports_granularity = "Module"
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
wrap_comments = true
|
||||||
|
use_small_heuristics = "Max"
|
||||||
|
edition = "2021"
|
|
@ -1,24 +1,17 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use pique::{
|
use pique::db::establish_connection;
|
||||||
db::{NewUser, UserQuery},
|
use pique::models::users::{self, NewUser};
|
||||||
prelude::*,
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
};
|
use rand::thread_rng;
|
||||||
use rand::distributions::Alphanumeric;
|
|
||||||
use rand::{distributions::DistString, thread_rng};
|
|
||||||
use sea_orm::Database;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
pub async fn main() -> Result<()> {
|
pub async fn main() -> Result<()> {
|
||||||
dotenvy::dotenv()?;
|
dotenvy::dotenv()?;
|
||||||
|
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||||
|
|
||||||
match AdminCli::parse().command {
|
match AdminCli::parse().command {
|
||||||
AdminCommand::CreateUser {
|
AdminCommand::CreateUser { name, email, username, password } => {
|
||||||
full_name,
|
|
||||||
email,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
} => {
|
|
||||||
let password = match password {
|
let password = match password {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
|
@ -29,15 +22,9 @@ pub async fn main() -> Result<()> {
|
||||||
password
|
password
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handle_create_user(NewUser {
|
handle_create_user(&db_url, NewUser::new(name, username, email, password)).await?
|
||||||
full_name,
|
|
||||||
email,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
}
|
}
|
||||||
AdminCommand::ListUsers => handle_list_users().await?,
|
AdminCommand::ListUsers => handle_list_users(&db_url).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -51,40 +38,29 @@ struct AdminCli {
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum AdminCommand {
|
pub enum AdminCommand {
|
||||||
CreateUser {
|
CreateUser { name: String, email: String, username: String, password: Option<String> },
|
||||||
full_name: String,
|
|
||||||
email: String,
|
|
||||||
username: String,
|
|
||||||
password: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
ListUsers,
|
ListUsers,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_create_user(new_user: NewUser) -> Result<()> {
|
async fn handle_create_user(db_url: &str, new_user: NewUser) -> Result<()> {
|
||||||
let db = connect_to_db().await?;
|
let mut db = establish_connection(db_url);
|
||||||
|
|
||||||
let user = UserQuery(&db).insert(new_user).await?;
|
let user = users::q::create(&mut db, new_user)?;
|
||||||
println!("User created successfully with id = {}", user.id.unwrap());
|
println!("User created successfully with id = {}", user.id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_list_users() -> Result<()> {
|
async fn handle_list_users(db_url: &str) -> Result<()> {
|
||||||
let db = connect_to_db().await?;
|
let mut db = establish_connection(db_url);
|
||||||
|
|
||||||
let users = UserQuery(&db).all().await?;
|
let users = users::q::all(&mut db)?;
|
||||||
|
|
||||||
println!("Found {} users.", users.len());
|
println!("Found {} users.", users.len());
|
||||||
for user in users {
|
for user in users {
|
||||||
println!(" > {}: {} ({})", user.id, user.username, user.full_name);
|
println!(" > {}: {} ({})", user.id, user.username, user.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect_to_db() -> Result<DatabaseConnection> {
|
|
||||||
let db_url = dotenvy::var("DATABASE_URL")?;
|
|
||||||
let db = Database::connect(db_url).await?;
|
|
||||||
Ok(db)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use pique::server;
|
use pique::server;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use minijinja_autoreload::AutoReloader;
|
|
||||||
|
|
||||||
use crate::{handler::internal_server_error, kv::KvHandle, prelude::*};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Context {
|
|
||||||
pub db: DatabaseConnection,
|
|
||||||
// TODO: add a design doc explaining why this not relational
|
|
||||||
pub kv_handles: KvHandle,
|
|
||||||
template_loader: Arc<AutoReloader>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Context {
|
|
||||||
pub fn new(db: DatabaseConnection, kv_handles: KvHandle, template_loader: AutoReloader) -> Context {
|
|
||||||
Context {
|
|
||||||
db,
|
|
||||||
kv_handles,
|
|
||||||
template_loader: Arc::new(template_loader),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render<T: Serialize>(&self, path: &str, data: T) -> anyhow::Result<String> {
|
|
||||||
// TODO: more graceful handling of the potential errors here; this should not use anyhow
|
|
||||||
let env = self.template_loader.acquire_env().unwrap();
|
|
||||||
let template = env.get_template(path)?;
|
|
||||||
let rendered = template.render(data)?;
|
|
||||||
Ok(rendered)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_resp<T: Serialize>(&self, path: &str, data: T) -> Response {
|
|
||||||
let rendered = self.render(path, data);
|
|
||||||
match rendered {
|
|
||||||
Ok(rendered) => Html(rendered).into_response(),
|
|
||||||
Err(err) => {
|
|
||||||
error!(?err, "error while rendering template");
|
|
||||||
internal_server_error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
125
src/db.rs
125
src/db.rs
|
@ -1,96 +1,43 @@
|
||||||
use sea_orm::Set;
|
use diesel::prelude::*;
|
||||||
use thiserror::Error;
|
use diesel::r2d2::{ConnectionManager, Pool};
|
||||||
|
use diesel::SqliteConnection;
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
|
||||||
use crate::{entity::user, password, prelude::*};
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||||
|
|
||||||
pub struct NewUser {
|
/// Establishes a connection to the database using the given URL.
|
||||||
pub full_name: String,
|
///
|
||||||
pub email: String,
|
/// # Arguments
|
||||||
pub username: String,
|
/// * `url` - The database URL to connect to.
|
||||||
pub password: String,
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if the database URL is not set or if the connection cannot be
|
||||||
|
/// established.
|
||||||
|
pub fn establish_connection(url: &str) -> SqliteConnection {
|
||||||
|
SqliteConnection::establish(url).unwrap_or_else(|_| panic!("Error connecting to {}", url))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewUser {
|
/// Builds a connection pool for the given URL.
|
||||||
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
|
///
|
||||||
let mut validation_errors = vec![];
|
/// # Arguments
|
||||||
|
/// * `url` - The database URL to connect to.
|
||||||
if self.full_name.len() > 100 {
|
///
|
||||||
validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
|
/// # Panics
|
||||||
}
|
/// Panics if the connection pool cannot be created.
|
||||||
|
pub fn build_connection_pool(url: &str) -> Pool<ConnectionManager<SqliteConnection>> {
|
||||||
if self.email.len() > 100 {
|
let manager = ConnectionManager::<SqliteConnection>::new(url);
|
||||||
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
|
Pool::builder().build(manager).expect("Failed to create connection pool.")
|
||||||
}
|
|
||||||
|
|
||||||
if self.username.len() > 100 {
|
|
||||||
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if validation_errors.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(validation_errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hash_password(&self) -> String {
|
|
||||||
password::hash(&self.password)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
/// Runs any pending migrations.
|
||||||
pub struct ValidationError {
|
///
|
||||||
pub field: String,
|
/// This function should be called before the application starts.
|
||||||
pub message: String,
|
///
|
||||||
}
|
/// # Arguments
|
||||||
|
/// * `conn` - The database connection to run the migrations on.
|
||||||
impl ValidationError {
|
///
|
||||||
pub fn on(field: &str, message: &str) -> ValidationError {
|
/// # Panics
|
||||||
ValidationError {
|
/// Panics if there is an error running the migrations.
|
||||||
field: field.to_owned(),
|
pub fn migrate(conn: &mut SqliteConnection) {
|
||||||
message: message.to_owned(),
|
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum DbError {
|
|
||||||
#[error("internal database error")]
|
|
||||||
Internal(#[from] sea_orm::DbErr),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UserQuery<'a>(pub &'a DatabaseConnection);
|
|
||||||
|
|
||||||
impl UserQuery<'_> {
|
|
||||||
pub async fn insert(&self, new_user: NewUser) -> Result<user::ActiveModel, DbError> {
|
|
||||||
let password_hash = new_user.hash_password();
|
|
||||||
let user = user::ActiveModel {
|
|
||||||
full_name: Set(new_user.full_name),
|
|
||||||
email: Set(new_user.email),
|
|
||||||
username: Set(new_user.username),
|
|
||||||
password_hash: Set(password_hash),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.save(self.0)
|
|
||||||
.await?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn all(&self) -> Result<Vec<user::Model>, DbError> {
|
|
||||||
let users = User::find().all(self.0).await?;
|
|
||||||
Ok(users)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn by_id(&self, id: i32) -> Result<Option<user::Model>, DbError> {
|
|
||||||
let user = User::find_by_id(id).one(self.0).await?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn by_username(&self, username: &str) -> Result<Option<user::Model>, DbError> {
|
|
||||||
let user = User::find()
|
|
||||||
.filter(user::Column::Username.eq(username))
|
|
||||||
.one(self.0)
|
|
||||||
.await?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
|
||||||
|
|
||||||
pub mod prelude;
|
|
||||||
|
|
||||||
pub mod user;
|
|
|
@ -1,3 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
|
||||||
|
|
||||||
pub use super::user::Entity as User;
|
|
|
@ -1,22 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "user")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: i32,
|
|
||||||
pub full_name: String,
|
|
||||||
pub email: String,
|
|
||||||
pub username: String,
|
|
||||||
pub password_hash: String,
|
|
||||||
pub created: String,
|
|
||||||
pub updated: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
|
@ -4,13 +4,13 @@ pub mod login;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Response;
|
pub use login::{login_page, login_submit};
|
||||||
pub use login::login_page;
|
use tracing::error;
|
||||||
pub use login::login_submit;
|
|
||||||
|
|
||||||
pub fn internal_server_error() -> Response {
|
pub fn internal_error<E>(err: E) -> (StatusCode, String)
|
||||||
Response::builder()
|
where
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
E: std::error::Error,
|
||||||
.body("Internal Server Error".into())
|
{
|
||||||
.unwrap()
|
error!(?err, "internal error");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".into())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,46 +1,65 @@
|
||||||
use axum::{extract::Path, response::Redirect, Form};
|
use axum::extract::Path;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum::Form;
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
use crate::{handler::internal_server_error, models::{Document, ModelPermission, ModelType, Permission}, prelude::*};
|
use crate::handler::internal_error;
|
||||||
|
use crate::models::documents::{self, NewDocument};
|
||||||
|
use crate::models::users::User;
|
||||||
|
use crate::permissions::q::Permission;
|
||||||
|
use crate::permissions::{self};
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
pub async fn documents_page(
|
pub async fn documents_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if let Some(user) = auth_session.user {
|
if let Some(user) = auth_session.user {
|
||||||
render_documents_page(ctx, user).await
|
render_documents_page(provider, user).await
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/login").into_response()
|
Ok(Redirect::to("/login").into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_documents_page(ctx: Context, user: crate::entity::user::Model) -> Response {
|
async fn render_documents_page(
|
||||||
let documents = ModelPermission::user_documents(&ctx.kv_handles, user.id).unwrap_or_default();
|
provider: Provider,
|
||||||
|
user: User,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
|
let documents =
|
||||||
|
permissions::q::accessible_documents(&mut db, &user.id).map_err(internal_error)?;
|
||||||
|
let projects =
|
||||||
|
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
documents => documents,
|
documents => documents,
|
||||||
|
projects => projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.render_resp("documents/list_documents.html", values)
|
provider.render_resp("documents/list_documents.html", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_document_page(
|
pub async fn create_document_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
|
|
||||||
|
let projects =
|
||||||
|
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
projects => projects,
|
projects => projects,
|
||||||
};
|
};
|
||||||
ctx.render_resp("documents/create_document.html", values)
|
provider.render_resp("documents/create_document.html", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
@ -50,80 +69,72 @@ pub struct CreateDocumentSubmission {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_document_submit(
|
pub async fn create_document_submit(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
form: Form<CreateDocumentSubmission>,
|
form: Form<CreateDocumentSubmission>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
|
|
||||||
let project = match ModelPermission::user_project(&ctx.kv_handles, user.id, form.project_id) {
|
let project_allowed = permissions::q::check_user_project(
|
||||||
Ok(Some(project)) => project,
|
&mut db,
|
||||||
Ok(None) => return Redirect::to("/documents/create").into_response(),
|
&user.id,
|
||||||
Err(err) => {
|
&form.project_id.to_string(),
|
||||||
error!(?err, "failed to access kv store");
|
Permission::Write,
|
||||||
return Redirect::to("/documents/create").into_response();
|
)
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
if !project_allowed {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let document = Document {
|
let new_document = NewDocument::new(
|
||||||
id: Uuid::now_v7(),
|
&user.id,
|
||||||
project_id: project.id,
|
&form.project_id.to_string(),
|
||||||
title: form.title.to_owned(),
|
form.title.to_owned(),
|
||||||
content: "".to_owned(),
|
"".to_owned(),
|
||||||
};
|
);
|
||||||
|
|
||||||
if let Err(err) = document.save(&ctx.kv_handles) {
|
let document = documents::q::create(&mut db, new_document).map_err(internal_error)?;
|
||||||
error!(?err, "failed to save document");
|
|
||||||
return internal_server_error();
|
|
||||||
}
|
|
||||||
info!(?document, "document created");
|
info!(?document, "document created");
|
||||||
|
|
||||||
let permission = ModelPermission {
|
Ok(Redirect::to("/documents").into_response())
|
||||||
user_id: user.id,
|
|
||||||
model_type: ModelType::Document,
|
|
||||||
role: Permission::Admin,
|
|
||||||
model_id: document.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = permission.add(&ctx.kv_handles) {
|
|
||||||
error!(?err, "failed to save new project permission");
|
|
||||||
return internal_server_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Redirect::to("/documents").into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn edit_document_page(
|
pub async fn edit_document_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
Path((id,)): Path<(Uuid,)>,
|
Path((id,)): Path<(Uuid,)>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let document = match ModelPermission::user_document(&ctx.kv_handles, user.id, id) {
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
Ok(Some(document)) => document,
|
|
||||||
Ok(None) => return Redirect::to("/documents").into_response(),
|
let document_allowed =
|
||||||
Err(err) => {
|
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write)
|
||||||
error!(?err, "failed to load document");
|
.map_err(internal_error)?;
|
||||||
return internal_server_error();
|
|
||||||
|
if !document_allowed {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
dbg!(&document);
|
let document = documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)?;
|
||||||
|
let projects =
|
||||||
|
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
document => document,
|
document => document,
|
||||||
|
projects => projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.render_resp("documents/edit_document.html", values)
|
provider.render_resp("documents/edit_document.html", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
@ -132,39 +143,38 @@ pub struct EditDocumentSubmission {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn edit_document_submit(
|
pub async fn edit_document_submit(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
Path((document_id,)): Path<(Uuid,)>,
|
Path((document_id,)): Path<(Uuid,)>,
|
||||||
form: Form<EditDocumentSubmission>,
|
form: Form<EditDocumentSubmission>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut document = match ModelPermission::user_document(&ctx.kv_handles, user.id, document_id) {
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
Ok(Some(document)) => document,
|
|
||||||
Ok(None) => return Redirect::to("/documents").into_response(),
|
let document_allowed = permissions::q::check_user_document(
|
||||||
Err(err) => {
|
&mut db,
|
||||||
error!(?err, "failed to load document");
|
&user.id,
|
||||||
return internal_server_error();
|
&document_id.to_string(),
|
||||||
|
Permission::Write,
|
||||||
|
)
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
if !document_allowed {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let new_document = Document {
|
documents::q::update(
|
||||||
id: document.id,
|
&mut db,
|
||||||
project_id: document.id,
|
&document_id.to_string(),
|
||||||
title: form.title.to_owned(),
|
form.title.to_owned(),
|
||||||
content: form.content.to_owned(),
|
form.content.to_owned(),
|
||||||
};
|
)
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
if let Err(err) = new_document.save(&ctx.kv_handles) {
|
Ok(Redirect::to("/documents").into_response())
|
||||||
error!(?err, "failed to save document");
|
|
||||||
return internal_server_error();
|
|
||||||
}
|
|
||||||
info!(?new_document, "document updated");
|
|
||||||
|
|
||||||
Redirect::to("/documents").into_response()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,27 @@
|
||||||
|
use axum::http::StatusCode;
|
||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
use crate::{models::{ModelPermission, Project}, prelude::*};
|
use crate::models::projects::Project;
|
||||||
|
use crate::permissions;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
|
pub async fn home_page(
|
||||||
|
State(provider): State<Provider>,
|
||||||
|
auth_session: AuthSession<Provider>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if let Some(user) = auth_session.user {
|
if let Some(user) = auth_session.user {
|
||||||
let projects: Vec<Project> = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
let mut db = provider.db_pool.get().unwrap();
|
||||||
|
let projects: Vec<Project> =
|
||||||
|
permissions::q::accessible_projects(&mut db, &user.id).unwrap();
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
projects => projects,
|
projects => projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.render_resp("home.html", values)
|
provider.render_resp("home.html", values)
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/login").into_response()
|
Ok(Redirect::to("/login").into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
use axum::{response::Redirect, Form};
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum::Form;
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
use crate::{handler::internal_server_error, prelude::*, session::Credentials};
|
use super::internal_error;
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::session::Credentials;
|
||||||
|
|
||||||
pub struct LoginTemplate {
|
pub struct LoginTemplate {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
@ -10,23 +14,23 @@ pub struct LoginTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_page(
|
pub async fn login_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if auth_session.user.is_some() {
|
if let Some(_user) = auth_session.user {
|
||||||
return Redirect::to("/").into_response();
|
Ok(Redirect::to("/").into_response())
|
||||||
|
} else {
|
||||||
|
render_login_page(&provider, "", "", None)
|
||||||
}
|
}
|
||||||
|
|
||||||
render_login_page(&ctx, "", "", None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_login_page(
|
fn render_login_page(
|
||||||
ctx: &Context,
|
provider: &Provider,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
error: Option<&'static str>,
|
error: Option<&'static str>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
ctx.render_resp(
|
provider.render_resp(
|
||||||
"login.html",
|
"login.html",
|
||||||
context! {
|
context! {
|
||||||
username => username,
|
username => username,
|
||||||
|
@ -39,83 +43,22 @@ fn render_login_page(
|
||||||
const LOGIN_ERROR_MSG: &str = "Invalid username or password";
|
const LOGIN_ERROR_MSG: &str = "Invalid username or password";
|
||||||
|
|
||||||
pub async fn login_submit(
|
pub async fn login_submit(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
mut auth_session: AuthSession<Context>,
|
mut auth_session: AuthSession<Provider>,
|
||||||
Form(creds): Form<Credentials>,
|
Form(creds): Form<Credentials>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
match auth_session.authenticate(creds).await {
|
if let Some(user) = auth_session.authenticate(creds).await.map_err(internal_error)? {
|
||||||
Ok(Some(user)) => {
|
let _ = auth_session.login(&user).await.map_err(internal_error)?;
|
||||||
if let Err(err) = auth_session.login(&user).await {
|
Ok(Redirect::to("/").into_response())
|
||||||
error!(?err, "error while logging in user");
|
} else {
|
||||||
return internal_server_error();
|
render_login_page(&provider, "", "", Some(LOGIN_ERROR_MSG))
|
||||||
}
|
|
||||||
|
|
||||||
Redirect::to("/").into_response()
|
|
||||||
}
|
|
||||||
Ok(None) => render_login_page(&ctx, "", "", Some(LOGIN_ERROR_MSG)),
|
|
||||||
Err(err) => {
|
|
||||||
error!(?err, "error while authenticating user");
|
|
||||||
internal_server_error()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(mut auth_session: AuthSession<Context>) -> Response {
|
pub async fn logout(mut auth_session: AuthSession<Provider>) -> Response {
|
||||||
if let Err(err) = auth_session.logout().await {
|
if let Err(err) = auth_session.logout().await {
|
||||||
error!(?err, "error while logging out user");
|
error!(?err, "error while logging out user");
|
||||||
}
|
}
|
||||||
|
|
||||||
Redirect::to("/login").into_response()
|
Redirect::to("/login").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
//const INVALID_LOGIN_MESSAGE: &str = "Invalid username/password, please try again.";
|
|
||||||
//
|
|
||||||
//pub async fn login_submission(
|
|
||||||
// request: HttpRequest,
|
|
||||||
// context: web::Data<Context>,
|
|
||||||
// form: web::Form<LoginForm>,
|
|
||||||
//) -> impl Responder {
|
|
||||||
// let mut conn = match context.pool.get() {
|
|
||||||
// Ok(conn) => conn,
|
|
||||||
// Err(_) => return internal_server_error(),
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// let user = match fetch_user_by_username(&mut conn, &form.username) {
|
|
||||||
// Ok(Some(user)) => user,
|
|
||||||
// Ok(None) => {
|
|
||||||
// return LoginTemplate {
|
|
||||||
// username: form.username.clone(),
|
|
||||||
// password: String::new(),
|
|
||||||
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
|
||||||
// }
|
|
||||||
// .to_response()
|
|
||||||
// }
|
|
||||||
// Err(_) => return internal_server_error(),
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// if !user.check_password(&form.password) {
|
|
||||||
// return LoginTemplate {
|
|
||||||
// username: form.username.clone(),
|
|
||||||
// password: String::new(),
|
|
||||||
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
|
||||||
// }
|
|
||||||
// .to_response();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if Identity::login(&request.extensions(), user.id.to_string()).is_err() {
|
|
||||||
// return internal_server_error();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return HttpResponse::Found()
|
|
||||||
// .append_header(("Location", "/"))
|
|
||||||
// .finish();
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//#[get("/logout")]
|
|
||||||
//pub async fn logout(user: Option<Identity>) -> impl Responder {
|
|
||||||
// if let Some(user) = user {
|
|
||||||
// user.logout();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// redirect_to_login()
|
|
||||||
//}
|
|
||||||
|
|
|
@ -1,92 +1,87 @@
|
||||||
use axum::{response::Redirect, Form};
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum::Form;
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
|
|
||||||
use crate::{
|
use super::internal_error;
|
||||||
handler::internal_server_error,
|
use crate::models::project_memberships::{self, ProjectRole};
|
||||||
models::{ModelPermission, ModelType, Permission, Project},
|
use crate::models::projects::{self, NewProject};
|
||||||
prelude::*,
|
use crate::models::users::User;
|
||||||
};
|
use crate::permissions;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
pub async fn projects_page(
|
pub async fn projects_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
if let Some(user) = auth_session.user {
|
if let Some(user) = auth_session.user {
|
||||||
render_projects_page(ctx, user).await
|
render_projects_page(provider, user).await
|
||||||
} else {
|
} else {
|
||||||
Redirect::to("/login").into_response()
|
Ok(Redirect::to("/login").into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_projects_page(ctx: Context, user: crate::entity::user::Model) -> Response {
|
async fn render_projects_page(
|
||||||
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
provider: Provider,
|
||||||
|
user: User,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
|
let projects = permissions::q::accessible_projects(&mut db, &user.id).unwrap_or_default();
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
projects => projects,
|
projects => projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.render_resp("projects/list_projects.html", values)
|
provider.render_resp("projects/list_projects.html", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_project_page(
|
pub async fn create_project_page(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let values = context! {
|
let values = context! {
|
||||||
user => user,
|
user => user,
|
||||||
};
|
};
|
||||||
ctx.render_resp("projects/create_project.html", values)
|
provider.render_resp("projects/create_project.html", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateProjectSubmission {
|
pub struct CreateProjectSubmission {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub key: String,
|
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_project_submit(
|
pub async fn create_project_submit(
|
||||||
State(ctx): State<Context>,
|
State(provider): State<Provider>,
|
||||||
auth_session: AuthSession<Context>,
|
auth_session: AuthSession<Provider>,
|
||||||
form: Form<CreateProjectSubmission>,
|
form: Form<CreateProjectSubmission>,
|
||||||
) -> Response {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||||
|
|
||||||
let user = match auth_session.user {
|
let user = match auth_session.user {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Redirect::to("/login").into_response(),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let project = Project {
|
let new_project = NewProject::new(
|
||||||
id: Uuid::now_v7(),
|
user.id.clone(),
|
||||||
owner_id: user.id,
|
form.name.clone(),
|
||||||
|
form.description.clone(),
|
||||||
name: form.name.clone(),
|
form.key.clone(),
|
||||||
key: form.key.clone(),
|
);
|
||||||
description: form.description.clone(),
|
|
||||||
};
|
|
||||||
// TODO: validation
|
// TODO: validation
|
||||||
|
|
||||||
if let Err(err) = project.save(&ctx.kv_handles) {
|
let project = projects::q::create(&mut db, new_project).map_err(internal_error)?;
|
||||||
error!(?err, "failed to save new project");
|
|
||||||
return internal_server_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
let permission = ModelPermission {
|
let _ = project_memberships::q::create(&mut db, &user.id, &project.id, ProjectRole::Admin)
|
||||||
user_id: user.id,
|
.map_err(internal_error)?;
|
||||||
model_type: ModelType::Project,
|
|
||||||
role: Permission::Admin,
|
|
||||||
model_id: project.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = permission.add(&ctx.kv_handles) {
|
Ok(Redirect::to("/projects").into_response())
|
||||||
error!(?err, "failed to save new project permission");
|
|
||||||
return internal_server_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
Redirect::to("/projects").into_response()
|
|
||||||
}
|
}
|
||||||
|
|
42
src/kv.rs
42
src/kv.rs
|
@ -1,42 +0,0 @@
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use fjall::{Config, Keyspace, PartitionCreateOptions, PartitionHandle};
|
|
||||||
|
|
||||||
/// Contains the handles needed to reference key-value data.
|
|
||||||
///
|
|
||||||
/// This contains both the Keyspace and multiple PartitionHandle.
|
|
||||||
/// The Keyspace allows operational control and reporting at the top level,
|
|
||||||
/// while each PartitionHandle controls reading, writing, and removing from a
|
|
||||||
/// particular partition of the data.
|
|
||||||
///
|
|
||||||
/// All fields are public, because this is meant to be used internally as a
|
|
||||||
/// wrapper to pass everything around, instead of passing each handle around by
|
|
||||||
/// itself.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct KvHandle {
|
|
||||||
pub keyspace: Keyspace,
|
|
||||||
|
|
||||||
pub project: PartitionHandle,
|
|
||||||
pub document: PartitionHandle,
|
|
||||||
pub permissions: PartitionHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KvHandle {
|
|
||||||
pub fn open<P: AsRef<Path>>(p: P) -> Result<KvHandle> {
|
|
||||||
// TODO: those should probably be configurable, or like, not just hard coded.
|
|
||||||
let config = Config::new(p).flush_workers(4).compaction_workers(4);
|
|
||||||
let keyspace = Keyspace::open(config)?;
|
|
||||||
|
|
||||||
let project = keyspace.open_partition("project", PartitionCreateOptions::default())?;
|
|
||||||
let document = keyspace.open_partition("document", PartitionCreateOptions::default())?;
|
|
||||||
let permissions = keyspace.open_partition("permissions", PartitionCreateOptions::default())?;
|
|
||||||
|
|
||||||
Ok(KvHandle {
|
|
||||||
keyspace,
|
|
||||||
project,
|
|
||||||
document,
|
|
||||||
permissions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,14 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod context;
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod entity;
|
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
pub mod permissions;
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
pub mod serialize;
|
pub mod provider;
|
||||||
|
pub mod schema;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod templates;
|
pub mod templates;
|
||||||
pub mod kv;
|
pub mod validation;
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
pub fn setup_logging() {
|
pub fn setup_logging() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init();
|
||||||
.with_env_filter(EnvFilter::from_default_env())
|
|
||||||
.init();
|
|
||||||
}
|
}
|
||||||
|
|
198
src/models.rs
198
src/models.rs
|
@ -1,190 +1,18 @@
|
||||||
use core::fmt::{self, Display};
|
use thiserror::Error;
|
||||||
use crate::prelude::*;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
pub mod documents;
|
||||||
use fjall::PartitionHandle;
|
pub mod project_memberships;
|
||||||
use model_derive::Model;
|
pub mod projects;
|
||||||
use serde::{Deserialize, Serialize};
|
pub mod users;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::kv::KvHandle;
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("Diesel error: {0}")]
|
||||||
|
DieselError(#[from] diesel::result::Error),
|
||||||
|
|
||||||
pub trait Model: Sized + Serialize + for<'a> Deserialize<'a> {
|
#[error("Diesel connection error: {0}")]
|
||||||
type Id;
|
ConnectionError(#[from] diesel::ConnectionError),
|
||||||
|
|
||||||
fn id(&self) -> Self::Id;
|
#[error("Connection pool error: {0}")]
|
||||||
fn key(id: Self::Id) -> Vec<u8>;
|
PoolError(#[from] diesel::r2d2::PoolError),
|
||||||
fn partition(kv_handle: &KvHandle) -> &PartitionHandle;
|
|
||||||
|
|
||||||
fn save(&self, kv_handle: &KvHandle) -> Result<()> {
|
|
||||||
let key = Self::key(self.id());
|
|
||||||
let value = bincode::serialize(self)?;
|
|
||||||
let partition = Self::partition(kv_handle);
|
|
||||||
|
|
||||||
partition.insert(key, value)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load(kv_handle: &KvHandle, id: Self::Id) -> Result<Option<Self>> {
|
|
||||||
let key = Self::key(id);
|
|
||||||
let partition = Self::partition(kv_handle);
|
|
||||||
|
|
||||||
match partition.get(key.as_slice())? {
|
|
||||||
Some(bytes) => {
|
|
||||||
let bytes = bytes.to_vec();
|
|
||||||
let value: Self = bincode::deserialize(&bytes)?;
|
|
||||||
Ok(Some(value))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Model, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[model_version(0)]
|
|
||||||
pub struct Project {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub owner_id: i32,
|
|
||||||
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
|
|
||||||
// The key is the short code, like BUG, which is used to refer to a project
|
|
||||||
// quickly and to display it more compactly. This must be unique across the
|
|
||||||
// projects a user owns.
|
|
||||||
pub key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Model, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[model_version(0)]
|
|
||||||
pub struct Document {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub project_id: Uuid,
|
|
||||||
|
|
||||||
pub title: String,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum ModelType {
|
|
||||||
Project = 0,
|
|
||||||
Document = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for ModelType {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
ModelType::Project => write!(f, "project"),
|
|
||||||
ModelType::Document => write!(f, "document"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum Permission {
|
|
||||||
Admin = 0,
|
|
||||||
Read = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Permission {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Permission::Admin => write!(f, "admin"),
|
|
||||||
Permission::Read => write!(f, "read"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct ModelPermission {
|
|
||||||
pub user_id: i32,
|
|
||||||
pub model_type: ModelType,
|
|
||||||
pub role: Permission,
|
|
||||||
pub model_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelPermission {
|
|
||||||
pub fn add(&self, kv_handle: &KvHandle) -> Result<()> {
|
|
||||||
let key = format!(
|
|
||||||
"{}:{}:{}:{}",
|
|
||||||
self.user_id, self.model_type, self.role, self.model_id
|
|
||||||
);
|
|
||||||
let value = bincode::serialize(self)?;
|
|
||||||
|
|
||||||
kv_handle.permissions.insert(key, value)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_projects(kv_handle: &KvHandle, user_id: i32) -> Result<Vec<Project>> {
|
|
||||||
let prefix = format!("{}:{}:", user_id, ModelType::Project);
|
|
||||||
|
|
||||||
let mut ids = vec![];
|
|
||||||
for row in kv_handle.permissions.prefix(prefix).into_iter() {
|
|
||||||
let (_key, value) = row?;
|
|
||||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
|
||||||
ids.push(permission.model_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let projects: Vec<Project> = ids
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|id| {
|
|
||||||
let res = Project::load(kv_handle, id);
|
|
||||||
res.ok().flatten()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_project(kv_handle: &KvHandle, user_id: i32, project_id: Uuid) -> Result<Option<Project>> {
|
|
||||||
let key = format!("{}:{}:{}:{}", user_id, ModelType::Project, Permission::Admin, project_id);
|
|
||||||
let value = kv_handle.permissions.get(key)?;
|
|
||||||
|
|
||||||
match value {
|
|
||||||
Some(value) => {
|
|
||||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
|
||||||
let project = Project::load(kv_handle, permission.model_id)?;
|
|
||||||
Ok(project)
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_documents(kv_handle: &KvHandle, user_id: i32) -> Result<Vec<Document>> {
|
|
||||||
let prefix = format!("{}:{}:", user_id, ModelType::Document);
|
|
||||||
|
|
||||||
let mut ids = vec![];
|
|
||||||
for row in kv_handle.permissions.prefix(prefix).into_iter() {
|
|
||||||
let (_key, value) = row?;
|
|
||||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
|
||||||
ids.push(permission.model_id);
|
|
||||||
}
|
|
||||||
dbg!(&ids);
|
|
||||||
|
|
||||||
let documents: Vec<Document> = ids
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|id| Document::load(kv_handle, id).ok().flatten())
|
|
||||||
.collect();
|
|
||||||
dbg!(&documents);
|
|
||||||
|
|
||||||
Ok(documents)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_document(kv_handle: &KvHandle, user_id: i32, document_id: Uuid) -> Result<Option<Document>> {
|
|
||||||
let key = format!("{}:{}:{}:{}", user_id, ModelType::Document, Permission::Admin, document_id);
|
|
||||||
let value = kv_handle.permissions.get(key)?;
|
|
||||||
|
|
||||||
match value {
|
|
||||||
Some(value) => {
|
|
||||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
|
||||||
let document = Document::load(kv_handle, permission.model_id)?;
|
|
||||||
Ok(document)
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
79
src/models/documents.rs
Normal file
79
src/models/documents.rs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::DbError;
|
||||||
|
use crate::schema::documents::dsl;
|
||||||
|
|
||||||
|
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||||
|
#[diesel(table_name = crate::schema::documents)]
|
||||||
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
pub struct Document {
|
||||||
|
pub id: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[diesel(table_name = crate::schema::documents)]
|
||||||
|
pub struct NewDocument {
|
||||||
|
pub id: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewDocument {
|
||||||
|
pub fn new(creator_id: &str, project_id: &str, title: String, content: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::now_v7().to_string(),
|
||||||
|
creator_id: creator_id.to_string(),
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod q {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
new_document: NewDocument,
|
||||||
|
) -> Result<Document, DbError> {
|
||||||
|
diesel::insert_into(dsl::documents).values(&new_document).execute(conn)?;
|
||||||
|
|
||||||
|
let document = dsl::documents.filter(dsl::id.eq(&new_document.id)).first(conn)?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
document_id: &str,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<Document, DbError> {
|
||||||
|
diesel::update(dsl::documents.filter(dsl::id.eq(document_id)))
|
||||||
|
.set((dsl::title.eq(title), dsl::content.eq(content)))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let document = dsl::documents.filter(dsl::id.eq(document_id)).first(conn)?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_id(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<Option<Document>, DbError> {
|
||||||
|
let document =
|
||||||
|
dsl::documents.filter(dsl::id.eq(document_id)).first::<Document>(conn).optional()?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
}
|
92
src/models/project_memberships.rs
Normal file
92
src/models/project_memberships.rs
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use diesel::expression::AsExpression;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::sql_types::Text;
|
||||||
|
|
||||||
|
#[derive(AsExpression, Debug, Clone)]
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
pub enum ProjectRole {
|
||||||
|
Member,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProjectRole {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ProjectRole::Member => write!(f, "member"),
|
||||||
|
ProjectRole::Admin => write!(f, "admin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> From<S> for ProjectRole
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
String: std::convert::From<S>,
|
||||||
|
{
|
||||||
|
fn from(status: S) -> Self {
|
||||||
|
match status.as_ref() {
|
||||||
|
"member" => ProjectRole::Member,
|
||||||
|
"admin" => ProjectRole::Admin,
|
||||||
|
_ => ProjectRole::Member,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProjectRole> for String {
|
||||||
|
fn from(role: ProjectRole) -> Self {
|
||||||
|
match role {
|
||||||
|
ProjectRole::Member => "member".to_string(),
|
||||||
|
ProjectRole::Admin => "admin".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Selectable, Debug, Clone)]
|
||||||
|
#[diesel(table_name = crate::schema::project_memberships)]
|
||||||
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
pub struct ProjectMembership {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
|
||||||
|
#[diesel(serialize_as = String, deserialize_as = String)]
|
||||||
|
pub role: ProjectRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[diesel(table_name = crate::schema::project_memberships)]
|
||||||
|
pub struct NewProjectMembership {
|
||||||
|
pub user_id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
|
||||||
|
#[diesel(serialize_as = String, deserialize_as = String)]
|
||||||
|
pub role: ProjectRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod q {
|
||||||
|
use diesel::SqliteConnection;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
project_id: &str,
|
||||||
|
role: ProjectRole,
|
||||||
|
) -> Result<ProjectMembership, diesel::result::Error> {
|
||||||
|
use crate::schema::project_memberships::dsl as pm;
|
||||||
|
|
||||||
|
let new_membership = NewProjectMembership {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
let membership =
|
||||||
|
diesel::insert_into(pm::project_memberships).values(new_membership).get_result(conn)?;
|
||||||
|
|
||||||
|
Ok(membership)
|
||||||
|
}
|
||||||
|
}
|
54
src/models/projects.rs
Normal file
54
src/models/projects.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::DbError;
|
||||||
|
use crate::schema::projects::dsl;
|
||||||
|
|
||||||
|
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||||
|
#[diesel(table_name = crate::schema::projects)]
|
||||||
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
pub struct Project {
|
||||||
|
pub id: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[diesel(table_name = crate::schema::projects)]
|
||||||
|
pub struct NewProject {
|
||||||
|
pub id: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewProject {
|
||||||
|
pub fn new(creator_id: String, name: String, description: String, key: String) -> Self {
|
||||||
|
Self { id: Uuid::now_v7().to_string(), creator_id, name, description, key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod q {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn for_user(conn: &mut SqliteConnection, user_id: String) -> Result<Vec<Project>, DbError> {
|
||||||
|
let projects =
|
||||||
|
dsl::projects.filter(dsl::creator_id.eq(user_id.to_string())).load::<Project>(conn)?;
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
new_project: NewProject,
|
||||||
|
) -> Result<Project, diesel::result::Error> {
|
||||||
|
use crate::schema::projects::dsl as p;
|
||||||
|
|
||||||
|
let project = diesel::insert_into(p::projects).values(new_project).get_result(conn)?;
|
||||||
|
|
||||||
|
Ok(project)
|
||||||
|
}
|
||||||
|
}
|
85
src/models/users.rs
Normal file
85
src/models/users.rs
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::DbError;
|
||||||
|
use crate::password;
|
||||||
|
use crate::schema::users::dsl;
|
||||||
|
use crate::validation::ValidationError;
|
||||||
|
|
||||||
|
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||||
|
#[diesel(table_name = crate::schema::users)]
|
||||||
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[diesel(table_name = crate::schema::users)]
|
||||||
|
pub struct NewUser {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewUser {
|
||||||
|
pub fn new(name: String, username: String, email: String, password: String) -> Self {
|
||||||
|
let password_hash = password::hash(&password);
|
||||||
|
Self { id: Uuid::now_v7().to_string(), name, username, email, password_hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
|
||||||
|
let mut validation_errors = vec![];
|
||||||
|
|
||||||
|
if self.name.len() > 100 {
|
||||||
|
validation_errors.push(ValidationError::on("name", "too long (max=100)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.email.len() > 100 {
|
||||||
|
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.username.len() > 32 {
|
||||||
|
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if validation_errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(validation_errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod q {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn all(conn: &mut SqliteConnection) -> Result<Vec<User>, DbError> {
|
||||||
|
let user_list = dsl::users.load::<User>(conn)?;
|
||||||
|
Ok(user_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_id(conn: &mut SqliteConnection, id: &str) -> Result<User, DbError> {
|
||||||
|
let user = dsl::users.filter(dsl::id.eq(id)).first::<User>(conn)?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_username(conn: &mut SqliteConnection, username: &str) -> Result<User, DbError> {
|
||||||
|
let user = dsl::users.filter(dsl::username.eq(username)).first::<User>(conn)?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(conn: &mut SqliteConnection, new_user: NewUser) -> Result<User, DbError> {
|
||||||
|
let _ = diesel::insert_into(dsl::users).values(&new_user).execute(conn)?;
|
||||||
|
|
||||||
|
let new_user = dsl::users.filter(dsl::id.eq(&new_user.id)).first::<User>(conn)?;
|
||||||
|
|
||||||
|
Ok(new_user)
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,18 +9,14 @@ pub fn verify(hash: &str, password: &str) -> bool {
|
||||||
Err(_) => return false, // TODO: log an error
|
Err(_) => return false, // TODO: log an error
|
||||||
};
|
};
|
||||||
|
|
||||||
Argon2::default()
|
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
|
||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
|
||||||
.is_ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hashes the given password.
|
/// Hashes the given password.
|
||||||
pub fn hash(password: &str) -> String {
|
pub fn hash(password: &str) -> String {
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
let hash = Argon2::default()
|
let hash = Argon2::default().hash_password(password.as_bytes(), &salt).unwrap();
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
hash.to_string()
|
hash.to_string()
|
||||||
}
|
}
|
||||||
|
|
128
src/permissions.rs
Normal file
128
src/permissions.rs
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
pub mod q {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::SqliteConnection;
|
||||||
|
|
||||||
|
use crate::models::documents::Document;
|
||||||
|
use crate::models::project_memberships::ProjectRole;
|
||||||
|
use crate::models::projects::Project;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Permission {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_user_project(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
project_id: &str,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<bool, diesel::result::Error> {
|
||||||
|
use crate::schema::project_memberships::dsl as pm;
|
||||||
|
|
||||||
|
if permission == Permission::Admin {
|
||||||
|
let is_admin = pm::project_memberships
|
||||||
|
.filter(pm::user_id.eq(user_id))
|
||||||
|
.filter(pm::project_id.eq(project_id))
|
||||||
|
.filter(pm::role.eq(ProjectRole::Admin.to_string()))
|
||||||
|
.count()
|
||||||
|
.get_result::<i64>(db)?;
|
||||||
|
|
||||||
|
Ok(is_admin > 0)
|
||||||
|
} else {
|
||||||
|
let is_member = pm::project_memberships
|
||||||
|
.filter(pm::user_id.eq(user_id))
|
||||||
|
.filter(pm::project_id.eq(project_id))
|
||||||
|
.count()
|
||||||
|
.get_result::<i64>(db)?;
|
||||||
|
|
||||||
|
Ok(is_member > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_user_document(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
document_id: &str,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<bool, diesel::result::Error> {
|
||||||
|
use crate::schema::documents::dsl as d;
|
||||||
|
|
||||||
|
let document =
|
||||||
|
d::documents.filter(d::id.eq(document_id)).first::<Document>(db).optional()?;
|
||||||
|
|
||||||
|
match document {
|
||||||
|
Some(doc) => check_user_project(db, user_id, &doc.project_id, permission),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users have permissions directly on projects which they are members of.
|
||||||
|
pub fn accessible_project_ids(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<String>, diesel::result::Error> {
|
||||||
|
use crate::schema::project_memberships::dsl as pm;
|
||||||
|
|
||||||
|
let project_ids = pm::project_memberships
|
||||||
|
.filter(pm::user_id.eq(user_id))
|
||||||
|
.select(pm::project_id)
|
||||||
|
.load::<String>(db)?;
|
||||||
|
|
||||||
|
Ok(project_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users have permissions directly on projects which they are members of.
|
||||||
|
pub fn accessible_projects(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<Project>, diesel::result::Error> {
|
||||||
|
use crate::schema::projects::dsl as p;
|
||||||
|
|
||||||
|
let project_ids = accessible_project_ids(db, user_id)?;
|
||||||
|
let projects = p::projects.filter(p::id.eq_any(project_ids)).load::<Project>(db)?;
|
||||||
|
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users can access documents which they are members of or which are in
|
||||||
|
/// projects they're members of.
|
||||||
|
pub fn accessible_document_ids(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<String>, diesel::result::Error> {
|
||||||
|
use crate::schema::documents::dsl as d;
|
||||||
|
use crate::schema::project_memberships::dsl as pm;
|
||||||
|
|
||||||
|
let project_ids = accessible_project_ids(db, user_id)?;
|
||||||
|
|
||||||
|
let direct_documents = pm::project_memberships
|
||||||
|
.filter(pm::user_id.eq(user_id))
|
||||||
|
.select(pm::project_id)
|
||||||
|
.load::<String>(db)?;
|
||||||
|
|
||||||
|
let project_documents = d::documents
|
||||||
|
.filter(d::project_id.eq_any(project_ids))
|
||||||
|
.select(d::id)
|
||||||
|
.load::<String>(db)?;
|
||||||
|
|
||||||
|
let document_ids = direct_documents.into_iter().chain(project_documents).collect();
|
||||||
|
|
||||||
|
Ok(document_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users can access documents which they are members of or which are in
|
||||||
|
/// projects they're members of.
|
||||||
|
pub fn accessible_documents(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<Document>, diesel::result::Error> {
|
||||||
|
use crate::schema::documents::dsl as d;
|
||||||
|
|
||||||
|
let document_ids = accessible_document_ids(db, user_id)?;
|
||||||
|
let documents = d::documents.filter(d::id.eq_any(document_ids)).load::<Document>(db)?;
|
||||||
|
|
||||||
|
Ok(documents)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,8 @@
|
||||||
pub use crate::context::Context;
|
|
||||||
pub use crate::entity::prelude::*;
|
|
||||||
pub use crate::models::Model;
|
|
||||||
pub use axum::extract::State;
|
pub use axum::extract::State;
|
||||||
pub use axum::response::{Html, IntoResponse, Response};
|
pub use axum::response::{Html, IntoResponse, Response};
|
||||||
pub use minijinja::context;
|
pub use minijinja::context;
|
||||||
pub use sea_orm::prelude::*;
|
|
||||||
pub use sea_orm::{ActiveModelTrait, DatabaseConnection};
|
|
||||||
pub use serde::{Deserialize, Serialize};
|
pub use serde::{Deserialize, Serialize};
|
||||||
pub use tracing::{debug, error, info, warn};
|
pub use tracing::{debug, error, info, warn};
|
||||||
|
pub use uuid::Uuid;
|
||||||
|
|
||||||
|
pub use crate::provider::Provider;
|
||||||
|
|
55
src/provider.rs
Normal file
55
src/provider.rs
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use diesel::r2d2::{ConnectionManager, Pool};
|
||||||
|
use diesel::SqliteConnection;
|
||||||
|
use minijinja_autoreload::AutoReloader;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::handler::internal_error;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub type ConnectionPool = Pool<ConnectionManager<SqliteConnection>>;
|
||||||
|
pub type PooledConnection = diesel::r2d2::PooledConnection<ConnectionManager<SqliteConnection>>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Provider {
|
||||||
|
pub db_pool: ConnectionPool,
|
||||||
|
template_loader: Arc<AutoReloader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ProviderError {
|
||||||
|
#[error("Error while using the connection pool: {0}")]
|
||||||
|
R2D2Error(#[from] diesel::r2d2::PoolError),
|
||||||
|
|
||||||
|
#[error("Error while rendering template: {0}")]
|
||||||
|
TemplateError(#[from] minijinja::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider {
|
||||||
|
pub fn new(db: ConnectionPool, template_loader: AutoReloader) -> Provider {
|
||||||
|
Provider { db_pool: db, template_loader: Arc::new(template_loader) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db_conn(&self) -> Result<PooledConnection, ProviderError> {
|
||||||
|
let conn = self.db_pool.get()?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render<T: Serialize>(&self, path: &str, data: T) -> Result<String, ProviderError> {
|
||||||
|
let env = self.template_loader.acquire_env().unwrap();
|
||||||
|
let template = env.get_template(path)?;
|
||||||
|
let rendered = template.render(data)?;
|
||||||
|
Ok(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_resp<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
data: T,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let rendered = self.render(path, data).map_err(internal_error)?;
|
||||||
|
Ok(Html(rendered).into_response())
|
||||||
|
}
|
||||||
|
}
|
42
src/schema.rs
Normal file
42
src/schema.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
documents (id) {
|
||||||
|
id -> Text,
|
||||||
|
creator_id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
title -> Text,
|
||||||
|
content -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
project_memberships (id) {
|
||||||
|
id -> Integer,
|
||||||
|
user_id -> Text,
|
||||||
|
project_id -> Text,
|
||||||
|
role -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
projects (id) {
|
||||||
|
id -> Text,
|
||||||
|
creator_id -> Text,
|
||||||
|
name -> Text,
|
||||||
|
description -> Text,
|
||||||
|
key -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
users (id) {
|
||||||
|
id -> Text,
|
||||||
|
username -> Text,
|
||||||
|
password_hash -> Text,
|
||||||
|
email -> Text,
|
||||||
|
name -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::allow_tables_to_appear_in_same_query!(documents, project_memberships, projects, users,);
|
|
@ -1,16 +0,0 @@
|
||||||
use bincode::{DefaultOptions, Options};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
fn bincode_options() -> impl Options {
|
|
||||||
DefaultOptions::new().with_big_endian()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn serialize<T: ?Sized + Serialize>(value: &T) -> Result<Vec<u8>, bincode::Error> {
|
|
||||||
let options = bincode_options();
|
|
||||||
options.serialize(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result<T, bincode::Error> {
|
|
||||||
let options = bincode_options();
|
|
||||||
options.deserialize(bytes)
|
|
||||||
}
|
|
|
@ -1,16 +1,33 @@
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{routing::{get, post}, Router};
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
use axum_login::AuthManagerLayerBuilder;
|
use axum_login::AuthManagerLayerBuilder;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use sea_orm::Database;
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
|
||||||
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||||
use tower_sessions::SessionManagerLayer;
|
use tower_sessions::SessionManagerLayer;
|
||||||
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
|
use tower_sessions_sqlx_store::sqlx::SqlitePool;
|
||||||
|
use tower_sessions_sqlx_store::SqliteStore;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
|
||||||
use crate::{config::CommandLineOptions, context::Context, handler::{documents::{create_document_page, create_document_submit, documents_page, edit_document_page, edit_document_submit}, home::home_page, login::logout, login_page, login_submit, projects::{create_project_page, create_project_submit, projects_page}}, kv::KvHandle, logging::setup_logging, templates::make_template_loader};
|
use crate::config::CommandLineOptions;
|
||||||
|
use crate::db;
|
||||||
|
use crate::handler::documents::{
|
||||||
|
create_document_page, create_document_submit, documents_page, edit_document_page,
|
||||||
|
edit_document_submit,
|
||||||
|
};
|
||||||
|
use crate::handler::home::home_page;
|
||||||
|
use crate::handler::login::logout;
|
||||||
|
use crate::handler::projects::{create_project_page, create_project_submit, projects_page};
|
||||||
|
use crate::handler::{login_page, login_submit};
|
||||||
|
use crate::logging::setup_logging;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::templates::make_template_loader;
|
||||||
|
|
||||||
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations/");
|
||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
pub async fn run() -> Result<()> {
|
||||||
dotenvy::dotenv()?;
|
dotenvy::dotenv()?;
|
||||||
|
@ -21,16 +38,16 @@ pub async fn run() -> Result<()> {
|
||||||
let template_loader = make_template_loader(opts.reload_templates);
|
let template_loader = make_template_loader(opts.reload_templates);
|
||||||
|
|
||||||
let db_url = dotenvy::var("DATABASE_URL")?;
|
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||||
let db = Database::connect(db_url).await?;
|
let mut db_conn = db::establish_connection(&db_url);
|
||||||
|
db::migrate(&mut db_conn);
|
||||||
|
|
||||||
|
let db_pool = db::build_connection_pool(&db_url);
|
||||||
|
|
||||||
let session_layer = create_session_manager_layer().await?;
|
let session_layer = create_session_manager_layer().await?;
|
||||||
|
|
||||||
// TODO: better name, also make it an option
|
let provider = Provider::new(db_pool, template_loader);
|
||||||
let kv_handles = KvHandle::open("./kvdata/")?;
|
|
||||||
|
|
||||||
let context = Context::new(db, kv_handles, template_loader);
|
let auth_backend = provider.clone();
|
||||||
|
|
||||||
let auth_backend = context.clone();
|
|
||||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
|
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
|
||||||
|
|
||||||
let trace_layer = TraceLayer::new_for_http()
|
let trace_layer = TraceLayer::new_for_http()
|
||||||
|
@ -54,7 +71,7 @@ pub async fn run() -> Result<()> {
|
||||||
.layer(trace_layer)
|
.layer(trace_layer)
|
||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
.layer(auth_layer)
|
.layer(auth_layer)
|
||||||
.with_state(context);
|
.with_state(provider);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
|
|
@ -1,24 +1,21 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||||
|
|
||||||
use crate::{
|
use crate::models::{self, users, DbError};
|
||||||
db::{DbError, UserQuery},
|
use crate::password;
|
||||||
entity::user,
|
use crate::prelude::*;
|
||||||
password,
|
|
||||||
prelude::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthUser for user::Model {
|
impl AuthUser for models::users::User {
|
||||||
type Id = i32;
|
type Id = String;
|
||||||
|
|
||||||
fn id(&self) -> Self::Id {
|
fn id(&self) -> Self::Id {
|
||||||
self.id
|
self.id.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_auth_hash(&self) -> &[u8] {
|
fn session_auth_hash(&self) -> &[u8] {
|
||||||
|
@ -27,8 +24,8 @@ impl AuthUser for user::Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AuthnBackend for Context {
|
impl AuthnBackend for Provider {
|
||||||
type User = user::Model;
|
type User = models::users::User;
|
||||||
type Credentials = Credentials;
|
type Credentials = Credentials;
|
||||||
type Error = DbError;
|
type Error = DbError;
|
||||||
|
|
||||||
|
@ -36,14 +33,20 @@ impl AuthnBackend for Context {
|
||||||
&self,
|
&self,
|
||||||
creds: Self::Credentials,
|
creds: Self::Credentials,
|
||||||
) -> Result<Option<Self::User>, Self::Error> {
|
) -> Result<Option<Self::User>, Self::Error> {
|
||||||
let user = UserQuery(&self.db)
|
let mut db = self.db_pool.get()?;
|
||||||
.by_username(&creds.username)
|
let user = users::q::by_username(&mut db, &creds.username)?;
|
||||||
.await?
|
|
||||||
.filter(|u| password::verify(&u.password_hash, &creds.password));
|
if password::verify(&user.password_hash, &creds.password) {
|
||||||
Ok(user)
|
Ok(Some(user))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||||
UserQuery(&self.db).by_id(*user_id).await
|
let mut db = self.db_pool.get()?;
|
||||||
|
let user = users::q::by_id(&mut db, user_id)?;
|
||||||
|
|
||||||
|
Ok(Some(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
use minijinja::{Error, ErrorKind};
|
|
||||||
use free_icons::IconAttrs;
|
use free_icons::IconAttrs;
|
||||||
use minijinja::{path_loader, Environment};
|
use minijinja::{path_loader, Environment, Error, ErrorKind};
|
||||||
use minijinja_autoreload::AutoReloader;
|
use minijinja_autoreload::AutoReloader;
|
||||||
|
|
||||||
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
|
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
|
||||||
|
@ -25,10 +24,8 @@ pub fn setup_filters(env: &mut Environment) {
|
||||||
pub fn heroicon_filter(name: String, classes: Option<String>) -> Result<String, Error> {
|
pub fn heroicon_filter(name: String, classes: Option<String>) -> Result<String, Error> {
|
||||||
let class = classes.unwrap_or_else(|| "".to_owned());
|
let class = classes.unwrap_or_else(|| "".to_owned());
|
||||||
|
|
||||||
let attrs = IconAttrs::default()
|
let attrs = IconAttrs::default().class(&class).fill("none").stroke_color("currentColor");
|
||||||
.class(&class)
|
|
||||||
.fill("none")
|
|
||||||
.stroke_color("currentColor");
|
|
||||||
|
|
||||||
free_icons::heroicons(&name, true, attrs).ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
|
free_icons::heroicons(&name, true, attrs)
|
||||||
|
.ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
|
||||||
}
|
}
|
||||||
|
|
13
src/validation.rs
Normal file
13
src/validation.rs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationError {
|
||||||
|
pub fn on(field: &str, message: &str) -> ValidationError {
|
||||||
|
ValidationError { field: field.to_owned(), message: message.to_owned() }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue