switch somewhat to diesel

This commit is contained in:
Nicole Tietz-Sokolskaya 2024-05-31 20:10:22 -04:00
parent e0653e4bdd
commit 2f9d2e2617
41 changed files with 1392 additions and 3687 deletions

1275
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,12 @@ argon2 = { version = "0.5.3", features = ["rand", "std"] }
async-trait = "0.1.78"
axum = "0.7.4"
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
axum-login = "0.14.0"
axum-login = "0.15"
bincode = "1.3.3"
chrono = "0.4.38"
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"
env_logger = "0.11.3"
fjall = "0.6.5"
@ -24,15 +27,14 @@ minijinja-autoreload = "1.0.14"
model_derive = { path = "./model_derive" }
rand = "0.8.5"
redb = "2.1.0"
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
serde = { version = "1.0.197", features = ["derive"] }
sled = "=1.0.0-alpha.121"
thiserror = "1.0.58"
tokio = { version = "1.36.0", features = ["rt", "full"] }
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
tower-sessions = "0.11.1"
tower-sessions-moka-store = "0.11.0"
tower-sessions-sqlx-store = { version = "0.11.0", features = ["sqlite"] }
tower-sessions = "0.12"
tower-sessions-moka-store = "0.12"
tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "v7", "serde"] }

View File

@ -34,10 +34,15 @@ We use nightly, and installation and management using [rustup][rustup] is
recommended.
### SeaORM
### DB (Diesel)
We use SeaORM for database interaction. You'll want the CLI, which you can
install with `cargo install sea-orm-cli`.
We use [Diesel](https://diesel.rs/) for database interaction. You'll want the
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

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View File

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS users;

View File

@ -0,0 +1,9 @@
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),
created TIMESTAMP NOT NULL,
updated TIMESTAMP NOT NULL
);

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS documents;

View File

@ -0,0 +1,24 @@
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,
created TIMESTAMP NOT NULL,
updated TIMESTAMP 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,
created TIMESTAMP NOT NULL,
updated TIMESTAMP NOT NULL
);

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS project_memberships;
DROP TABLE IF EXISTS document_memberships;

View File

@ -0,0 +1,22 @@
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,
created TIMESTAMP NOT NULL,
updated TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS document_memberships(
id INTEGER PRIMARY KEY NOT NULL UNIQUE,
user_id UUID_TEXT NOT NULL,
document_id UUID_TEXT NOT NULL,
role TEXT NOT NULL,
created TIMESTAMP NOT NULL,
updated TIMESTAMP NOT NULL
);

View File

@ -1,20 +1,18 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use pique::{
db::{NewUser, UserQuery},
prelude::*,
};
use pique::models::users::{self, NewUser};
use pique::db::establish_connection;
use rand::distributions::Alphanumeric;
use rand::{distributions::DistString, thread_rng};
use sea_orm::Database;
#[tokio::main]
pub async fn main() -> Result<()> {
dotenvy::dotenv()?;
let db_url = dotenvy::var("DATABASE_URL")?;
match AdminCli::parse().command {
AdminCommand::CreateUser {
full_name,
name,
email,
username,
password,
@ -29,15 +27,15 @@ pub async fn main() -> Result<()> {
password
}
};
handle_create_user(NewUser {
full_name,
email,
handle_create_user(&db_url, NewUser::new(
name,
username,
email,
password,
})
))
.await?
}
AdminCommand::ListUsers => handle_list_users().await?,
AdminCommand::ListUsers => handle_list_users(&db_url).await?,
};
Ok(())
@ -52,7 +50,7 @@ struct AdminCli {
#[derive(Subcommand, Debug)]
pub enum AdminCommand {
CreateUser {
full_name: String,
name: String,
email: String,
username: String,
password: Option<String>,
@ -61,30 +59,24 @@ pub enum AdminCommand {
ListUsers,
}
async fn handle_create_user(new_user: NewUser) -> Result<()> {
let db = connect_to_db().await?;
async fn handle_create_user(db_url: &str, new_user: NewUser) -> Result<()> {
let mut db = establish_connection(&db_url);
let user = UserQuery(&db).insert(new_user).await?;
println!("User created successfully with id = {}", user.id.unwrap());
let user = users::Query::new(&mut db).create(new_user)?;
println!("User created successfully with id = {}", user.id);
Ok(())
}
async fn handle_list_users() -> Result<()> {
let db = connect_to_db().await?;
async fn handle_list_users(db_url: &str) -> Result<()> {
let mut db = establish_connection(&db_url);
let users = UserQuery(&db).all().await?;
let users = users::Query::new(&mut db).all()?;
println!("Found {} users.", users.len());
for user in users {
println!(" > {}: {} ({})", user.id, user.username, user.full_name);
println!(" > {}: {} ({})", user.id, user.username, user.name);
}
Ok(())
}
async fn connect_to_db() -> Result<DatabaseConnection> {
let db_url = dotenvy::var("DATABASE_URL")?;
let db = Database::connect(db_url).await?;
Ok(db)
}

View File

@ -1,21 +1,31 @@
use diesel::{
r2d2::{ConnectionManager, Pool},
SqliteConnection,
};
use std::sync::Arc;
use minijinja_autoreload::AutoReloader;
use crate::{handler::internal_server_error, kv::KvHandle, prelude::*};
pub type ConnectionPool = Pool<ConnectionManager<SqliteConnection>>;
#[derive(Clone)]
pub struct Context {
pub db: DatabaseConnection,
pub db_pool: ConnectionPool,
// 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 {
pub fn new(
db: ConnectionPool,
kv_handles: KvHandle,
template_loader: AutoReloader,
) -> Context {
Context {
db,
db_pool: db,
kv_handles,
template_loader: Arc::new(template_loader),
}

View File

@ -1,7 +1,52 @@
use sea_orm::Set;
use diesel::prelude::*;
use diesel::r2d2::ConnectionManager;
use diesel::r2d2::Pool;
use diesel::SqliteConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
/// Establishes a connection to the database using the given URL.
///
/// # Arguments
/// * `url` - The database URL to connect to.
///
/// # 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))
}
/// Builds a connection pool for the given URL.
///
/// # Arguments
/// * `url` - The database URL to connect to.
///
/// # Panics
/// Panics if the connection pool cannot be created.
pub fn build_connection_pool(url: &str) -> Pool<ConnectionManager<SqliteConnection>> {
let manager = ConnectionManager::<SqliteConnection>::new(url);
Pool::builder()
.build(manager)
.expect("Failed to create connection pool.")
}
/// Runs any pending migrations.
///
/// This function should be called before the application starts.
///
/// # Arguments
/// * `conn` - The database connection to run the migrations on.
///
/// # Panics
/// Panics if there is an error running the migrations.
pub fn migrate(conn: &mut SqliteConnection) {
conn.run_pending_migrations(MIGRATIONS).unwrap();
}
use thiserror::Error;
use crate::{entity::user, password, prelude::*};
use crate::{password, prelude::*};
pub struct NewUser {
pub full_name: String,
@ -52,45 +97,3 @@ impl ValidationError {
}
}
}
#[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)
}
}

View File

@ -1,5 +0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
pub mod prelude;
pub mod user;

View File

@ -1,3 +0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
pub use super::user::Entity as User;

View File

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

View File

@ -7,6 +7,7 @@ use axum::http::StatusCode;
use axum::response::Response;
pub use login::login_page;
pub use login::login_submit;
use tracing::error;
pub fn internal_server_error() -> Response {
Response::builder()
@ -14,3 +15,11 @@ pub fn internal_server_error() -> Response {
.body("Internal Server Error".into())
.unwrap()
}
pub fn internal_error<E>(err: E) -> (StatusCode, String)
where
E: std::error::Error,
{
error!(?err, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".into())
}

View File

@ -1,7 +1,7 @@
use axum::{extract::Path, response::Redirect, Form};
use axum_login::AuthSession;
use crate::{handler::internal_server_error, models::{Document, ModelPermission, ModelType, Permission}, prelude::*};
use crate::{handler::internal_server_error, models::users::User, prelude::*};
pub async fn documents_page(
State(ctx): State<Context>,
@ -14,33 +14,35 @@ pub async fn documents_page(
}
}
async fn render_documents_page(ctx: Context, user: crate::entity::user::Model) -> Response {
let documents = ModelPermission::user_documents(&ctx.kv_handles, user.id).unwrap_or_default();
async fn render_documents_page(ctx: Context, user: User) -> Response {
todo!()
//let documents = ModelPermission::user_documents(&ctx.kv_handles, user.id).unwrap_or_default();
let values = context! {
user => user,
documents => documents,
};
//let values = context! {
// user => user,
// documents => documents,
//};
ctx.render_resp("documents/list_documents.html", values)
//ctx.render_resp("documents/list_documents.html", values)
}
pub async fn create_document_page(
State(ctx): State<Context>,
auth_session: AuthSession<Context>,
) -> Response {
let user = match auth_session.user {
Some(user) => user,
None => return Redirect::to("/login").into_response(),
};
todo!()
//let user = match auth_session.user {
// Some(user) => user,
// None => return Redirect::to("/login").into_response(),
//};
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
//let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
let values = context! {
user => user,
projects => projects,
};
ctx.render_resp("documents/create_document.html", values)
//let values = context! {
// user => user,
// projects => projects,
//};
//ctx.render_resp("documents/create_document.html", values)
}
#[derive(Debug, Deserialize)]
@ -54,48 +56,47 @@ pub async fn create_document_submit(
auth_session: AuthSession<Context>,
form: Form<CreateDocumentSubmission>,
) -> Response {
let user = match auth_session.user {
Some(user) => user,
None => return Redirect::to("/login").into_response(),
};
todo!()
//let user = match auth_session.user {
// Some(user) => user,
// None => return Redirect::to("/login").into_response(),
//};
let project = match ModelPermission::user_project(&ctx.kv_handles, user.id, form.project_id) {
Ok(Some(project)) => project,
Ok(None) => return Redirect::to("/documents/create").into_response(),
Err(err) => {
error!(?err, "failed to access kv store");
return Redirect::to("/documents/create").into_response();
}
};
//let project = match ModelPermission::user_project(&ctx.kv_handles, user.id, form.project_id) {
// Ok(Some(project)) => project,
// Ok(None) => return Redirect::to("/documents/create").into_response(),
// Err(err) => {
// error!(?err, "failed to access kv store");
// return Redirect::to("/documents/create").into_response();
// }
//};
let document = Document {
id: Uuid::now_v7(),
project_id: project.id,
title: form.title.to_owned(),
content: "".to_owned(),
};
//let document = Document {
// id: Uuid::now_v7(),
// project_id: project.id,
// title: form.title.to_owned(),
// content: "".to_owned(),
//};
if let Err(err) = document.save(&ctx.kv_handles) {
error!(?err, "failed to save document");
return internal_server_error();
}
info!(?document, "document created");
//if let Err(err) = document.save(&ctx.kv_handles) {
// error!(?err, "failed to save document");
// return internal_server_error();
//}
//info!(?document, "document created");
let permission = ModelPermission {
user_id: user.id,
model_type: ModelType::Document,
role: Permission::Admin,
model_id: document.id,
};
//let permission = ModelPermission {
// 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();
}
//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()
//Redirect::to("/documents").into_response()
}
pub async fn edit_document_page(
@ -103,27 +104,28 @@ pub async fn edit_document_page(
auth_session: AuthSession<Context>,
Path((id,)): Path<(Uuid,)>,
) -> Response {
let user = match auth_session.user {
Some(user) => user,
None => return Redirect::to("/login").into_response(),
};
todo!()
//let user = match auth_session.user {
// Some(user) => user,
// None => return Redirect::to("/login").into_response(),
//};
let document = match ModelPermission::user_document(&ctx.kv_handles, user.id, id) {
Ok(Some(document)) => document,
Ok(None) => return Redirect::to("/documents").into_response(),
Err(err) => {
error!(?err, "failed to load document");
return internal_server_error();
}
};
//let document = match ModelPermission::user_document(&ctx.kv_handles, user.id, id) {
// Ok(Some(document)) => document,
// Ok(None) => return Redirect::to("/documents").into_response(),
// Err(err) => {
// error!(?err, "failed to load document");
// return internal_server_error();
// }
//};
dbg!(&document);
let values = context! {
user => user,
document => document,
};
//dbg!(&document);
//let values = context! {
// user => user,
// document => document,
//};
ctx.render_resp("documents/edit_document.html", values)
//ctx.render_resp("documents/edit_document.html", values)
}
#[derive(Debug, Deserialize)]
@ -132,39 +134,39 @@ pub struct EditDocumentSubmission {
pub content: String,
}
pub async fn edit_document_submit(
State(ctx): State<Context>,
auth_session: AuthSession<Context>,
Path((document_id,)): Path<(Uuid,)>,
form: Form<EditDocumentSubmission>,
) -> Response {
let user = match auth_session.user {
Some(user) => user,
None => return Redirect::to("/login").into_response(),
};
todo!()
//let user = match auth_session.user {
// Some(user) => user,
// None => return Redirect::to("/login").into_response(),
//};
let mut document = match ModelPermission::user_document(&ctx.kv_handles, user.id, document_id) {
Ok(Some(document)) => document,
Ok(None) => return Redirect::to("/documents").into_response(),
Err(err) => {
error!(?err, "failed to load document");
return internal_server_error();
}
};
let new_document = Document {
id: document.id,
project_id: document.id,
title: form.title.to_owned(),
content: form.content.to_owned(),
};
if let Err(err) = new_document.save(&ctx.kv_handles) {
error!(?err, "failed to save document");
return internal_server_error();
}
info!(?new_document, "document updated");
Redirect::to("/documents").into_response()
//let mut document = match ModelPermission::user_document(&ctx.kv_handles, user.id, document_id) {
// Ok(Some(document)) => document,
// Ok(None) => return Redirect::to("/documents").into_response(),
// Err(err) => {
// error!(?err, "failed to load document");
// return internal_server_error();
// }
//};
//let new_document = Document {
// id: document.id,
// project_id: document.id,
// title: form.title.to_owned(),
// content: form.content.to_owned(),
//};
//if let Err(err) = new_document.save(&ctx.kv_handles) {
// error!(?err, "failed to save document");
// return internal_server_error();
//}
//info!(?new_document, "document updated");
//Redirect::to("/documents").into_response()
}

View File

@ -1,11 +1,15 @@
use axum::response::Redirect;
use axum_login::AuthSession;
use crate::{models::{ModelPermission, Project}, prelude::*};
use crate::models::projects::Project;
use {crate::permissions, crate::prelude::*};
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
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 = ctx.db_pool.get().unwrap();
let projects: Vec<Project> =
permissions::query::accessible_projects(&mut db, &user.id).unwrap();
let values = context! {
user => user,

View File

@ -1,12 +1,19 @@
use axum::{response::Redirect, Form};
use axum::{http::StatusCode, response::Redirect, Form};
use axum_login::AuthSession;
use crate::{
handler::internal_server_error,
models::{ModelPermission, ModelType, Permission, Project},
models::{
project_memberships::{self, ProjectRole},
projects::{self, NewProject},
users::User,
},
permissions,
prelude::*,
};
use super::internal_error;
pub async fn projects_page(
State(ctx): State<Context>,
auth_session: AuthSession<Context>,
@ -18,8 +25,15 @@ pub async fn projects_page(
}
}
async fn render_projects_page(ctx: Context, user: crate::entity::user::Model) -> Response {
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
async fn render_projects_page(ctx: Context, user: User) -> Response {
let mut db = match ctx.db_pool.get() {
Ok(db) => db,
Err(err) => {
error!(?err, "failed to get db connection");
return internal_server_error();
}
};
let projects = permissions::query::accessible_projects(&mut db, &user.id).unwrap_or_default();
let values = context! {
user => user,
@ -47,46 +61,34 @@ pub async fn create_project_page(
#[derive(Debug, Deserialize)]
pub struct CreateProjectSubmission {
pub name: String,
pub key: String,
pub description: String,
pub key: String,
}
pub async fn create_project_submit(
State(ctx): State<Context>,
auth_session: AuthSession<Context>,
form: Form<CreateProjectSubmission>,
) -> Response {
) -> Result<Response, (StatusCode, String)> {
let mut db = ctx.db_pool.get().map_err(internal_error)?;
let user = match auth_session.user {
Some(user) => user,
None => return Redirect::to("/login").into_response(),
None => return Ok(Redirect::to("/login").into_response()),
};
let project = Project {
id: Uuid::now_v7(),
owner_id: user.id,
name: form.name.clone(),
key: form.key.clone(),
description: form.description.clone(),
};
let new_project = NewProject::new(
user.id.clone(),
form.name.clone(),
form.description.clone(),
form.key.clone(),
);
// TODO: validation
if let Err(err) = project.save(&ctx.kv_handles) {
error!(?err, "failed to save new project");
return internal_server_error();
}
let project = projects::query::create(&mut db, new_project).map_err(internal_error)?;
let permission = ModelPermission {
user_id: user.id,
model_type: ModelType::Project,
role: Permission::Admin,
model_id: project.id,
};
let _ = project_memberships::query::create(&mut db, &user.id, &project.id, ProjectRole::Admin)
.map_err(internal_error)?;
if let Err(err) = permission.add(&ctx.kv_handles) {
error!(?err, "failed to save new project permission");
return internal_server_error();
}
Redirect::to("/projects").into_response()
Ok(Redirect::to("/projects").into_response())
}

View File

@ -1,14 +1,15 @@
pub mod config;
pub mod context;
pub mod db;
pub mod entity;
pub mod handler;
pub mod logging;
pub mod models;
pub mod password;
pub mod prelude;
pub mod schema;
pub mod serialize;
pub mod server;
pub mod session;
pub mod templates;
pub mod kv;
pub mod permissions;

View File

@ -1,190 +1,20 @@
use core::fmt::{self, Display};
use crate::prelude::*;
use thiserror::Error;
use anyhow::Result;
use fjall::PartitionHandle;
use model_derive::Model;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub mod users;
pub mod projects;
pub mod types;
pub mod document_memberships;
pub mod project_memberships;
pub mod documents;
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> {
type Id;
#[error("Diesel connection error: {0}")]
ConnectionError(#[from] diesel::ConnectionError),
fn id(&self) -> Self::Id;
fn key(id: Self::Id) -> Vec<u8>;
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),
}
}
#[error("Connection pool error: {0}")]
PoolError(#[from] diesel::r2d2::PoolError),
}

View File

58
src/models/documents.rs Normal file
View File

@ -0,0 +1,58 @@
use diesel::prelude::*;
use crate::schema::documents::dsl;
use uuid::Uuid;
use super::DbError;
#[derive(Queryable, Selectable, Debug, Clone)]
#[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,
pub created: chrono::NaiveDateTime,
pub updated: chrono::NaiveDateTime,
}
#[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: Uuid, project_id: Uuid, title: String, content: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
creator_id: creator_id.to_string(),
project_id: project_id.to_string(),
title,
content,
}
}
}
pub struct Query<'a> {
db: &'a mut SqliteConnection,
}
impl<'a> Query<'a> {
pub fn new(db: &'a mut SqliteConnection) -> Self {
Self { db }
}
pub fn for_user(&mut self, user_id: String) -> Result<Vec<Document>, DbError> {
let documents = dsl::documents
.filter(dsl::creator_id.eq(user_id.to_string()))
.load::<Document>(self.db)?;
Ok(documents)
}
}

View File

@ -0,0 +1,92 @@
use diesel::{expression::AsExpression, prelude::*, sql_types::Text};
use std::fmt;
#[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,
pub created: chrono::NaiveDateTime,
pub updated: chrono::NaiveDateTime,
}
#[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 query {
use diesel::SqliteConnection;
use super::*;
pub fn create (
db: &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(db)?;
Ok(membership)
}
}

68
src/models/projects.rs Normal file
View File

@ -0,0 +1,68 @@
use diesel::prelude::*;
use serde::{Serialize};
use crate::schema::projects::dsl;
use uuid::Uuid;
use super::DbError;
#[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,
#[serde(skip)]
pub created: chrono::NaiveDateTime,
#[serde(skip)]
pub updated: chrono::NaiveDateTime,
}
#[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::new_v4().to_string(),
creator_id,
name,
description,
key,
}
}
}
pub mod query {
use super::*;
pub fn for_user(db: &mut SqliteConnection, user_id: String) -> Result<Vec<Project>, DbError> {
let projects = dsl::projects
.filter(dsl::creator_id.eq(user_id.to_string()))
.load::<Project>(db)?;
Ok(projects)
}
pub fn create(
db: &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(db)?;
Ok(project)
}
}

0
src/models/types.rs Normal file
View File

82
src/models/users.rs Normal file
View File

@ -0,0 +1,82 @@
use diesel::prelude::*;
use serde::Serialize;
use crate::schema::users::dsl;
use uuid::Uuid;
use crate::password;
use super::DbError;
#[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,
#[serde(skip)]
pub created: chrono::NaiveDateTime,
#[serde(skip)]
pub updated: chrono::NaiveDateTime,
}
#[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::new_v4().to_string(),
name,
username,
email,
password_hash,
}
}
}
pub struct Query<'a> {
db: &'a mut SqliteConnection,
}
impl<'a> Query<'a> {
pub fn new(db: &'a mut SqliteConnection) -> Self {
Self { db }
}
pub fn all(&mut self) -> Result<Vec<User>, DbError> {
let user_list = dsl::users.load::<User>(self.db)?;
Ok(user_list)
}
pub fn by_id(&mut self, id: &str) -> Result<User, DbError> {
let user = dsl::users.filter(dsl::id.eq(id)).first::<User>(self.db)?;
Ok(user)
}
pub fn by_username(&mut self, username: &str) -> Result<User, DbError> {
let user = dsl::users.filter(dsl::username.eq(username)).first::<User>(self.db)?;
Ok(user)
}
pub fn create(&mut self, new_user: NewUser) -> Result<User, DbError> {
let _ = diesel::insert_into(dsl::users)
.values(&new_user)
.execute(self.db)?;
let new_user = dsl::users.filter(dsl::id.eq(&new_user.id)).first::<User>(self.db)?;
Ok(new_user)
}
}

81
src/permissions.rs Normal file
View File

@ -0,0 +1,81 @@
pub mod query {
use diesel::prelude::*;
use crate::models::documents::Document;
use crate::models::projects::Project;
use diesel::SqliteConnection;
/// 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.into_iter())
.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)
}
}

View File

@ -1,10 +1,7 @@
pub use crate::context::Context;
pub use crate::entity::prelude::*;
pub use crate::models::Model;
pub use axum::extract::State;
pub use axum::response::{Html, IntoResponse, Response};
pub use minijinja::context;
pub use sea_orm::prelude::*;
pub use sea_orm::{ActiveModelTrait, DatabaseConnection};
pub use serde::{Deserialize, Serialize};
pub use tracing::{debug, error, info, warn};
pub use uuid::Uuid;

67
src/schema.rs Normal file
View File

@ -0,0 +1,67 @@
// @generated automatically by Diesel CLI.
diesel::table! {
document_memberships (id) {
id -> Integer,
user_id -> Text,
document_id -> Text,
role -> Text,
created -> Timestamp,
updated -> Timestamp,
}
}
diesel::table! {
documents (id) {
id -> Text,
creator_id -> Text,
project_id -> Text,
title -> Text,
content -> Text,
created -> Timestamp,
updated -> Timestamp,
}
}
diesel::table! {
project_memberships (id) {
id -> Integer,
user_id -> Text,
project_id -> Text,
role -> Text,
created -> Timestamp,
updated -> Timestamp,
}
}
diesel::table! {
projects (id) {
id -> Text,
creator_id -> Text,
name -> Text,
description -> Text,
key -> Text,
created -> Timestamp,
updated -> Timestamp,
}
}
diesel::table! {
users (id) {
id -> Text,
username -> Text,
password_hash -> Text,
email -> Text,
name -> Text,
created -> Timestamp,
updated -> Timestamp,
}
}
diesel::allow_tables_to_appear_in_same_query!(
document_memberships,
documents,
project_memberships,
projects,
users,
);

View File

@ -4,13 +4,15 @@ use anyhow::Result;
use axum::{routing::{get, post}, Router};
use axum_login::AuthManagerLayerBuilder;
use clap::Parser;
use sea_orm::Database;
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
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, context::Context, db, 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};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations/");
pub async fn run() -> Result<()> {
dotenvy::dotenv()?;
@ -21,14 +23,17 @@ pub async fn run() -> Result<()> {
let template_loader = make_template_loader(opts.reload_templates);
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?;
// TODO: better name, also make it an option
let kv_handles = KvHandle::open("./kvdata/")?;
let context = Context::new(db, kv_handles, template_loader);
let context = Context::new(db_pool, kv_handles, template_loader);
let auth_backend = context.clone();
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();

View File

@ -1,12 +1,7 @@
use async_trait::async_trait;
use axum_login::{AuthUser, AuthnBackend, UserId};
use crate::{
db::{DbError, UserQuery},
entity::user,
password,
prelude::*,
};
use crate::{models::{self, users, DbError}, password, prelude::*};
#[derive(Serialize, Deserialize)]
pub struct Credentials {
@ -14,11 +9,11 @@ pub struct Credentials {
pub password: String,
}
impl AuthUser for user::Model {
type Id = i32;
impl AuthUser for models::users::User {
type Id = String;
fn id(&self) -> Self::Id {
self.id
self.id.clone()
}
fn session_auth_hash(&self) -> &[u8] {
@ -28,7 +23,7 @@ impl AuthUser for user::Model {
#[async_trait]
impl AuthnBackend for Context {
type User = user::Model;
type User = models::users::User;
type Credentials = Credentials;
type Error = DbError;
@ -36,14 +31,24 @@ impl AuthnBackend for Context {
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = UserQuery(&self.db)
.by_username(&creds.username)
.await?
.filter(|u| password::verify(&u.password_hash, &creds.password));
Ok(user)
let mut db = self.db_pool.get()?;
let mut q = users::Query::new(&mut db);
let user = q.by_username(&creds.username)?;
if password::verify(&user.password_hash, &creds.password) {
Ok(Some(user))
} else {
Ok(None)
}
}
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 mut q = users::Query::new(&mut db);
let user = q.by_id(&user_id)?;
Ok(Some(user))
}
}