Compare commits

..

3 commits

67 changed files with 41615 additions and 1605 deletions

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
target/ target/
kvdata/
node_modules/ node_modules/
*.db *.db
*.xml *.xml
.env .env
static/

844
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,28 +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.15" axum-login = "0.14.0"
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 = "1"
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"
pulldown-cmark = "0.11.0" 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.12" tower-sessions = "0.11.1"
tower-sessions-moka-store = "0.12" tower-sessions-moka-store = "0.11.0"
tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] } tower-sessions-sqlx-store = { version = "0.11.0", 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"] }

View file

@ -1,10 +1,4 @@
check:
cargo test
cargo fmt --check
cargo clippy
cargo build --release
run: run:
SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates
@ -22,3 +16,9 @@ typescript:
typescript-watch: typescript-watch:
npm run build-watch npm run build-watch
migrate:
sea-orm-cli migrate
entities:
sea-orm-cli generate entity -o src/entity --with-serde both

View file

@ -34,15 +34,10 @@ We use nightly, and installation and management using [rustup][rustup] is
recommended. recommended.
### DB (Diesel) ### SeaORM
We use [Diesel](https://diesel.rs/) for database interaction. You'll want the We use SeaORM for database interaction. You'll want the CLI, which you can
CLI, which you can install with the following command. This will install it for install with `cargo install sea-orm-cli`.
your user on your system, including support for SQLite.
```bash
cargo install diesel_cli --no-default-features -F sqlite-bundled
```
### Tailwind ### Tailwind

View file

@ -1,47 +0,0 @@
# 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.

View file

@ -1,9 +0,0 @@
# 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 = "./migrations"

View file

@ -1,40 +0,0 @@
import {basicSetup} from "codemirror"
import {EditorView, keymap} from "@codemirror/view"
import {indentWithTab} from "@codemirror/commands"
import {markdown} from "@codemirror/lang-markdown"
export function makeEditor(divSelector, value) {
let div = document.querySelector(divSelector);
let documentTheme = EditorView.theme({
"&": {
"background-color": "white",
},
".cm-editor": {
"height": "100%",
},
".cm-scroller": {overflow: "auto"}
}, {})
// add a hidden textarea inside the div for form submission
let textarea = document.createElement("textarea");
textarea.setAttribute("name", "content");
textarea.style.display = "none";
div.appendChild(textarea);
let extensions = [
basicSetup,
keymap.of([indentWithTab]),
markdown(),
documentTheme,
EditorView.lineWrapping,
];
let view = new EditorView({parent: div, doc: value, extensions})
textarea.form.addEventListener("submit", () => {
textarea.value = view.state.doc.toString()
})
return view
}

View file

@ -2,16 +2,59 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.prose li {
margin-top: 0;
/* Borrowed from https://github.com/Milkdown/milkdown/blob/main/e2e/src/list-item-block/style.css
which is licensed under MIT. */
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
margin-bottom: 0; margin-bottom: 0;
} }
.prose :where(blockquote):not(:where([class~="not-prose"] *)) {
font-style: inherit;
font-weight: inherit;
}
.prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
margin-bottom: 0;
}
.prose ol,
.prose ul { .prose ul {
margin-top: 0; list-style: none !important;
margin-bottom: 0; padding: 0;
} }
.prose h1 { .prose li p {
margin-bottom: 0.25em; @apply !m-0 !leading-6;
} }
.prose li p + p {
@apply !mt-2;
}
.prose li.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
.prose li::after {
all: unset !important;
}
milkdown-list-item-block .list-item {
gap: 8px;
}
milkdown-list-item-block .label-wrapper {
height: 24px;
display: inline-flex;
justify-content: center;
align-items: center;
color: darkcyan;
}
/* End borrowed block. */

View file

@ -1,2 +1,52 @@
import { makeEditor } from './editor'; import { defaultValueCtx, Editor, rootCtx } from '@milkdown/core';
window.makeEditor = makeEditor; import { html } from 'atomico';
import { listItemBlockComponent, listItemBlockConfig, ListItemBlockConfig, listItemBlockView } from '@milkdown/components/list-item-block'
import { commonmark } from '@milkdown/preset-commonmark';
import { gfm } from '@milkdown/preset-gfm';
import { nord } from '@milkdown/theme-nord'
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import '@milkdown/theme-nord/style.css'
function configureListItem(ctx: Ctx) {
ctx.set(listItemBlockConfig.key, {
renderLabel: (label: string, listType, checked?: boolean) => {
if (checked == null) {
if (listType === 'bullet') {
return html`<span class='label'>•</span>`
}
return html`<span class='label'>${label}</span>`
} else {
return html`<input class='label' type="checkbox" checked=${checked} />`
}
},
})
}
function createEditor(rootId, fieldId, content) {
Editor
.make()
.config(ctx => {
ctx.set(rootCtx, rootId)
ctx.set(defaultValueCtx, content)
const listener = ctx.get(listenerCtx);
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
console.log(markdown);
console.log(fieldId);
document.getElementById(fieldId).value = markdown;
console.log("updated");
}
})
})
.config(configureListItem)
.use(commonmark)
.use(gfm)
.use(nord)
.use(listener)
.use(listItemBlockComponent)
.create();
}
window.createEditor = createEditor;

2351
migration/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
migration/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[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",
]

41
migration/README.md Normal file
View file

@ -0,0 +1,41 @@
# 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
```

14
migration/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,69 @@
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,
}

6
migration/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View file

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

View file

@ -1,7 +0,0 @@
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)
);

View file

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

View file

@ -1,18 +0,0 @@
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
);

View file

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

View file

@ -1,8 +0,0 @@
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 Normal file
View file

@ -0,0 +1,46 @@
# 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"

11
model_derive/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[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"] }

48
model_derive/src/lib.rs Normal file
View file

@ -0,0 +1,48 @@
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)
}

2150
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,15 +7,20 @@
"tailwindcss": "^3.4.3" "tailwindcss": "^3.4.3"
}, },
"scripts": { "scripts": {
"build": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --color=false", "build": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017",
"build-watch": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --watch --color=false", "build-watch": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --watch",
"css-watch": "tailwindcss -i ./frontend/main.css -o ./static/style.css --watch", "css-watch": "tailwindcss -i ./frontend/main.css -o ./static/style.css --watch",
"css": "tailwindcss -i ./frontend/main.css -o ./static/style.css" "css": "tailwindcss -i ./frontend/main.css -o ./static/style.css"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-markdown": "^6.2.5", "@milkdown/components": "^7.3.6",
"@milkdown/core": "^7.3.6",
"@milkdown/plugin-listener": "^7.3.6",
"@milkdown/preset-commonmark": "^7.3.6",
"@milkdown/preset-gfm": "^7.3.6",
"@milkdown/theme-nord": "^7.3.6",
"@prosemirror-adapter/lit": "^0.2.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.1",
"tailwindcss": "^3.4.3" "tailwindcss": "^3.4.3"
} }
} }

View file

@ -1,5 +0,0 @@
imports_granularity = "Module"
group_imports = "StdExternalCrate"
wrap_comments = true
use_small_heuristics = "Max"
edition = "2021"

View file

@ -1,17 +1,24 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pique::db::establish_connection; use pique::{
use pique::models::users::{self, NewUser}; db::{NewUser, UserQuery},
use rand::distributions::{Alphanumeric, DistString}; prelude::*,
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 { name, email, username, password } => { AdminCommand::CreateUser {
full_name,
email,
username,
password,
} => {
let password = match password { let password = match password {
Some(p) => p, Some(p) => p,
None => { None => {
@ -22,9 +29,15 @@ pub async fn main() -> Result<()> {
password password
} }
}; };
handle_create_user(&db_url, NewUser::new(name, username, email, password)).await? handle_create_user(NewUser {
full_name,
email,
username,
password,
})
.await?
} }
AdminCommand::ListUsers => handle_list_users(&db_url).await?, AdminCommand::ListUsers => handle_list_users().await?,
}; };
Ok(()) Ok(())
@ -38,29 +51,40 @@ struct AdminCli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum AdminCommand { pub enum AdminCommand {
CreateUser { name: String, email: String, username: String, password: Option<String> }, CreateUser {
full_name: String,
email: String,
username: String,
password: Option<String>,
},
ListUsers, ListUsers,
} }
async fn handle_create_user(db_url: &str, new_user: NewUser) -> Result<()> { async fn handle_create_user(new_user: NewUser) -> Result<()> {
let mut db = establish_connection(db_url); let db = connect_to_db().await?;
let user = users::q::create(&mut db, new_user)?; let user = UserQuery(&db).insert(new_user).await?;
println!("User created successfully with id = {}", user.id); println!("User created successfully with id = {}", user.id.unwrap());
Ok(()) Ok(())
} }
async fn handle_list_users(db_url: &str) -> Result<()> { async fn handle_list_users() -> Result<()> {
let mut db = establish_connection(db_url); let db = connect_to_db().await?;
let users = users::q::all(&mut db)?; let users = UserQuery(&db).all().await?;
println!("Found {} users.", users.len()); println!("Found {} users.", users.len());
for user in users { for user in users {
println!(" > {}: {} ({})", user.id, user.username, user.name); println!(" > {}: {} ({})", user.id, user.username, user.full_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)
}

View file

@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use pique::server; use pique::server;
#[tokio::main] #[tokio::main]

42
src/context.rs Normal file
View file

@ -0,0 +1,42 @@
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
View file

@ -1,43 +1,96 @@
use diesel::prelude::*; use sea_orm::Set;
use diesel::r2d2::{ConnectionManager, Pool}; use thiserror::Error;
use diesel::SqliteConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations/"); use crate::{entity::user, password, prelude::*};
/// Establishes a connection to the database using the given URL. pub struct NewUser {
/// pub full_name: String,
/// # Arguments pub email: String,
/// * `url` - The database URL to connect to. pub username: String,
/// 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))
} }
/// Builds a connection pool for the given URL. impl NewUser {
/// pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
/// # Arguments let mut validation_errors = vec![];
/// * `url` - The database URL to connect to.
/// if self.full_name.len() > 100 {
/// # Panics validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
/// 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. if self.email.len() > 100 {
/// validation_errors.push(ValidationError::on("email", "too long (max=100)"));
/// This function should be called before the application starts. }
///
/// # Arguments if self.username.len() > 100 {
/// * `conn` - The database connection to run the migrations on. validation_errors.push(ValidationError::on("username", "too long (max=32)"));
/// }
/// # Panics
/// Panics if there is an error running the migrations. if validation_errors.is_empty() {
pub fn migrate(conn: &mut SqliteConnection) { Ok(())
conn.run_pending_migrations(MIGRATIONS).unwrap(); } else {
Err(validation_errors)
}
}
pub fn hash_password(&self) -> String {
password::hash(&self.password)
}
}
#[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(),
}
}
}
#[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)
}
} }

5
src/entity/mod.rs Normal file
View file

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

3
src/entity/prelude.rs Normal file
View file

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

22
src/entity/user.rs Normal file
View file

@ -0,0 +1,22 @@
//! `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

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

View file

@ -1,65 +1,46 @@
use axum::extract::Path; use axum::{extract::Path, 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_error; use crate::{handler::internal_server_error, models::{Document, ModelPermission, ModelType, Permission}, prelude::*};
use crate::models::documents::{self, NewDocument};
use crate::models::users::User;
use crate::permissions::q::{Decision, Permission};
use crate::permissions::{self};
use crate::prelude::*;
pub async fn documents_page( pub async fn documents_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
if let Some(user) = auth_session.user { if let Some(user) = auth_session.user {
render_documents_page(provider, user).await render_documents_page(ctx, user).await
} else { } else {
Ok(Redirect::to("/login").into_response()) Redirect::to("/login").into_response()
} }
} }
async fn render_documents_page( async fn render_documents_page(ctx: Context, user: crate::entity::user::Model) -> Response {
provider: Provider, let documents = ModelPermission::user_documents(&ctx.kv_handles, user.id).unwrap_or_default();
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,
}; };
provider.render_resp("documents/list_documents.html", values) ctx.render_resp("documents/list_documents.html", values)
} }
pub async fn create_document_page( pub async fn create_document_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
let user = match auth_session.user { let user = match auth_session.user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let mut db = provider.db_pool.get().map_err(internal_error)?; let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
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,
}; };
provider.render_resp("documents/create_document.html", values) ctx.render_resp("documents/create_document.html", values)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -69,111 +50,80 @@ pub struct CreateDocumentSubmission {
} }
pub async fn create_document_submit( pub async fn create_document_submit(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
form: Form<CreateDocumentSubmission>, form: Form<CreateDocumentSubmission>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
let user = match auth_session.user { let user = match auth_session.user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let mut db = provider.db_pool.get().map_err(internal_error)?;
let access_decision = permissions::q::check_user_project( let project = match ModelPermission::user_project(&ctx.kv_handles, user.id, form.project_id) {
&mut db, Ok(Some(project)) => project,
&user.id, Ok(None) => return Redirect::to("/documents/create").into_response(),
&form.project_id.to_string(), Err(err) => {
Permission::Write, error!(?err, "failed to access kv store");
) return Redirect::to("/documents/create").into_response();
.map_err(internal_error)?;
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
} }
};
let new_document = NewDocument::new( let document = Document {
&user.id, id: Uuid::now_v7(),
&form.project_id.to_string(), project_id: project.id,
form.title.to_owned(), title: form.title.to_owned(),
"".to_owned(), content: "".to_owned(),
); };
let document = documents::q::create(&mut db, new_document).map_err(internal_error)?; if let Err(err) = document.save(&ctx.kv_handles) {
error!(?err, "failed to save document");
return internal_server_error();
}
info!(?document, "document created"); info!(?document, "document created");
Ok(Redirect::to("/documents").into_response()) 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();
} }
pub async fn view_document_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
Path((id,)): Path<(Uuid,)>,
) -> Result<Response, (StatusCode, String)> {
let user = match auth_session.user {
Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()),
};
let mut db = provider.db_pool.get().map_err(internal_error)?;
let access_decision = Redirect::to("/documents").into_response()
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write)
.map_err(internal_error)?;
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
}
let document = match documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)? {
Some(doc) => doc,
None => return Err((StatusCode::NOT_FOUND, "document not found".to_owned())),
};
let projects =
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
let rendered_document = document.render_html();
let values = context! {
user => user,
document => document,
projects => projects,
rendered_document => rendered_document,
};
provider.render_resp("documents/view_document.html", values)
} }
pub async fn edit_document_page( pub async fn edit_document_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
Path((id,)): Path<(Uuid,)>, Path((id,)): Path<(Uuid,)>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
let user = match auth_session.user { let user = match auth_session.user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let mut db = provider.db_pool.get().map_err(internal_error)?; let document = match ModelPermission::user_document(&ctx.kv_handles, user.id, id) {
Ok(Some(document)) => document,
let access_decision = Ok(None) => return Redirect::to("/documents").into_response(),
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write) Err(err) => {
.map_err(internal_error)?; error!(?err, "failed to load document");
return internal_server_error();
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
} }
};
let document = documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)?; dbg!(&document);
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,
}; };
provider.render_resp("documents/edit_document.html", values) ctx.render_resp("documents/edit_document.html", values)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -182,39 +132,39 @@ pub struct EditDocumentSubmission {
pub content: String, pub content: String,
} }
pub async fn edit_document_submit( pub async fn edit_document_submit(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
Path((document_id,)): Path<(Uuid,)>, Path((document_id,)): Path<(Uuid,)>,
form: Form<EditDocumentSubmission>, form: Form<EditDocumentSubmission>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
let user = match auth_session.user { let user = match auth_session.user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let mut db = provider.db_pool.get().map_err(internal_error)?; let mut document = match ModelPermission::user_document(&ctx.kv_handles, user.id, document_id) {
Ok(Some(document)) => document,
let access_decision = permissions::q::check_user_document( Ok(None) => return Redirect::to("/documents").into_response(),
&mut db, Err(err) => {
&user.id, error!(?err, "failed to load document");
&document_id.to_string(), return internal_server_error();
Permission::Write,
)
.map_err(internal_error)?;
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
} }
};
documents::q::update( let new_document = Document {
&mut db, id: document.id,
&document_id.to_string(), project_id: document.id,
form.title.to_owned(), title: form.title.to_owned(),
form.content.to_owned(), content: form.content.to_owned(),
) };
.map_err(internal_error)?;
let view_url = format!("/documents/view/{}", document_id); if let Err(err) = new_document.save(&ctx.kv_handles) {
Ok(Redirect::to(&view_url).into_response()) error!(?err, "failed to save document");
return internal_server_error();
}
info!(?new_document, "document updated");
Redirect::to("/documents").into_response()
} }

View file

@ -1,27 +1,19 @@
use axum::http::StatusCode;
use axum::response::Redirect; use axum::response::Redirect;
use axum_login::AuthSession; use axum_login::AuthSession;
use crate::models::projects::Project; use crate::{models::{ModelPermission, Project}, prelude::*};
use crate::permissions;
use crate::prelude::*;
pub async fn home_page( pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
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 mut db = provider.db_pool.get().unwrap(); let projects: Vec<Project> = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
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,
}; };
provider.render_resp("home.html", values) ctx.render_resp("home.html", values)
} else { } else {
Ok(Redirect::to("/login").into_response()) Redirect::to("/login").into_response()
} }
} }

View file

@ -1,11 +1,7 @@
use axum::http::StatusCode; use axum::{response::Redirect, Form};
use axum::response::Redirect;
use axum::Form;
use axum_login::AuthSession; use axum_login::AuthSession;
use super::internal_error; use crate::{handler::internal_server_error, prelude::*, session::Credentials};
use crate::prelude::*;
use crate::session::Credentials;
pub struct LoginTemplate { pub struct LoginTemplate {
pub username: String, pub username: String,
@ -14,23 +10,23 @@ pub struct LoginTemplate {
} }
pub async fn login_page( pub async fn login_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
if let Some(_user) = auth_session.user { if auth_session.user.is_some() {
Ok(Redirect::to("/").into_response()) return Redirect::to("/").into_response();
} else {
render_login_page(&provider, "", "", None)
} }
render_login_page(&ctx, "", "", None)
} }
fn render_login_page( fn render_login_page(
provider: &Provider, ctx: &Context,
username: &str, username: &str,
password: &str, password: &str,
error: Option<&'static str>, error: Option<&'static str>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
provider.render_resp( ctx.render_resp(
"login.html", "login.html",
context! { context! {
username => username, username => username,
@ -43,22 +39,83 @@ 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(provider): State<Provider>, State(ctx): State<Context>,
mut auth_session: AuthSession<Provider>, mut auth_session: AuthSession<Context>,
Form(creds): Form<Credentials>, Form(creds): Form<Credentials>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
if let Some(user) = auth_session.authenticate(creds).await.map_err(internal_error)? { match auth_session.authenticate(creds).await {
auth_session.login(&user).await.map_err(internal_error)?; Ok(Some(user)) => {
Ok(Redirect::to("/").into_response()) if let Err(err) = auth_session.login(&user).await {
} else { error!(?err, "error while logging in user");
render_login_page(&provider, "", "", Some(LOGIN_ERROR_MSG)) return internal_server_error();
}
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<Provider>) -> Response { pub async fn logout(mut auth_session: AuthSession<Context>) -> 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()
//}

View file

@ -1,87 +1,92 @@
use axum::http::StatusCode; use axum::{response::Redirect, Form};
use axum::response::Redirect;
use axum::Form;
use axum_login::AuthSession; use axum_login::AuthSession;
use super::internal_error; use crate::{
use crate::models::project_memberships::{self, ProjectRole}; handler::internal_server_error,
use crate::models::projects::{self, NewProject}; models::{ModelPermission, ModelType, Permission, Project},
use crate::models::users::User; prelude::*,
use crate::permissions; };
use crate::prelude::*;
pub async fn projects_page( pub async fn projects_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
if let Some(user) = auth_session.user { if let Some(user) = auth_session.user {
render_projects_page(provider, user).await render_projects_page(ctx, user).await
} else { } else {
Ok(Redirect::to("/login").into_response()) Redirect::to("/login").into_response()
} }
} }
async fn render_projects_page( async fn render_projects_page(ctx: Context, user: crate::entity::user::Model) -> Response {
provider: Provider, let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
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,
}; };
provider.render_resp("projects/list_projects.html", values) ctx.render_resp("projects/list_projects.html", values)
} }
pub async fn create_project_page( pub async fn create_project_page(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
let user = match auth_session.user { let user = match auth_session.user {
Some(user) => user, Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let values = context! { let values = context! {
user => user, user => user,
}; };
provider.render_resp("projects/create_project.html", values) ctx.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 description: String,
pub key: String, pub key: String,
pub description: String,
} }
pub async fn create_project_submit( pub async fn create_project_submit(
State(provider): State<Provider>, State(ctx): State<Context>,
auth_session: AuthSession<Provider>, auth_session: AuthSession<Context>,
form: Form<CreateProjectSubmission>, form: Form<CreateProjectSubmission>,
) -> Result<Response, (StatusCode, String)> { ) -> Response {
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 Ok(Redirect::to("/login").into_response()), None => return Redirect::to("/login").into_response(),
}; };
let new_project = NewProject::new( let project = Project {
user.id.clone(), id: Uuid::now_v7(),
form.name.clone(), owner_id: user.id,
form.description.clone(),
form.key.clone(), name: form.name.clone(),
); key: form.key.clone(),
description: form.description.clone(),
};
// TODO: validation // TODO: validation
let project = projects::q::create(&mut db, new_project).map_err(internal_error)?; if let Err(err) = project.save(&ctx.kv_handles) {
error!(?err, "failed to save new project");
let _ = project_memberships::q::create(&mut db, &user.id, &project.id, ProjectRole::Admin) return internal_server_error();
.map_err(internal_error)?; }
Ok(Redirect::to("/projects").into_response()) let permission = ModelPermission {
user_id: user.id,
model_type: ModelType::Project,
role: Permission::Admin,
model_id: project.id,
};
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()
} }

42
src/kv.rs Normal file
View file

@ -0,0 +1,42 @@
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,
})
}
}

View file

@ -1,14 +1,16 @@
#![feature(trait_alias)]
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 provider; pub mod serialize;
pub mod schema;
pub mod server; pub mod server;
pub mod session; pub mod session;
pub mod store;
pub mod templates; pub mod templates;
pub mod validation; pub mod kv;

View file

@ -1,5 +1,7 @@
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
pub fn setup_logging() { pub fn setup_logging() {
tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
} }

View file

@ -1,18 +1,190 @@
use thiserror::Error; use core::fmt::{self, Display};
use crate::prelude::*;
pub mod documents; use anyhow::Result;
pub mod project_memberships; use fjall::PartitionHandle;
pub mod projects; use model_derive::Model;
pub mod users; use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Error, Debug)] use crate::kv::KvHandle;
pub enum DbError {
#[error("Diesel error: {0}")]
DieselError(#[from] diesel::result::Error),
#[error("Diesel connection error: {0}")] pub trait Model: Sized + Serialize + for<'a> Deserialize<'a> {
ConnectionError(#[from] diesel::ConnectionError), type Id;
#[error("Connection pool error: {0}")] fn id(&self) -> Self::Id;
PoolError(#[from] diesel::r2d2::PoolError), 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),
}
}
} }

View file

@ -1,108 +0,0 @@
use diesel::prelude::*;
use pulldown_cmark as markdown;
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,
}
impl Document {
pub fn render_html(&self) -> String {
let mut options = markdown::Options::empty();
options.insert(markdown::Options::ENABLE_TASKLISTS);
options.insert(markdown::Options::ENABLE_STRIKETHROUGH);
let parser = markdown::Parser::new_ext(&self.content, options);
// If we just process things as they are, we are vulnerable to XSS
// attacks, since users can inject any HTML they'd like. To prevent
// this, we convert any parsed HTML to just text. In the future, we can
// instead sanitize the HTML using something like
// [ammonia](https://crates.io/crates/ammonia) to make the HTML safer.
// Draws inspiration from
// [pulldown-cmark/pulldown-cmark#608](https://github.com/pulldown-cmark/pulldown-cmark/issues/608)
let escaped = parser.into_iter().map(|event| match event {
markdown::Event::Html(html) => markdown::Event::Text(html),
markdown::Event::InlineHtml(html) => markdown::Event::Text(html),
_ => event,
});
let mut html_output = String::new();
markdown::html::push_html(&mut html_output, escaped);
html_output
}
}
#[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)
}
}

View file

@ -1,92 +0,0 @@
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)
}
}

View file

@ -1,54 +0,0 @@
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)
}
}

View file

@ -1,85 +0,0 @@
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)
}
}

View file

@ -9,14 +9,18 @@ pub fn verify(hash: &str, password: &str) -> bool {
Err(_) => return false, // TODO: log an error Err(_) => return false, // TODO: log an error
}; };
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() Argon2::default()
.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().hash_password(password.as_bytes(), &salt).unwrap(); let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.unwrap();
hash.to_string() hash.to_string()
} }

View file

@ -1,143 +0,0 @@
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,
}
#[derive(Debug, Clone, Copy)]
pub enum Decision {
Allowed,
Denied,
}
pub fn check_user_project(
db: &mut SqliteConnection,
user_id: &str,
project_id: &str,
requested_permission: Permission,
) -> Result<Decision, diesel::result::Error> {
use crate::schema::project_memberships::dsl as pm;
let row_count = match requested_permission {
Permission::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)?,
_ => pm::project_memberships
.filter(pm::user_id.eq(user_id))
.filter(pm::project_id.eq(project_id))
.count()
.get_result::<i64>(db)?,
};
if row_count > 0 {
if row_count > 1 {
tracing::error!(
row_count = row_count,
user_id = user_id,
project_id = project_id,
"unexpected row count: more than one project membership for this user"
)
}
Ok(Decision::Allowed)
} else {
Ok(Decision::Denied)
}
}
pub fn check_user_document(
db: &mut SqliteConnection,
user_id: &str,
document_id: &str,
permission: Permission,
) -> Result<Decision, 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(Decision::Denied),
}
}
/// 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)
}
}

View file

@ -1,8 +1,10 @@
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;

View file

@ -1,55 +0,0 @@
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())
}
}

View file

@ -1,42 +0,0 @@
// @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,);

16
src/serialize.rs Normal file
View file

@ -0,0 +1,16 @@
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)
}

View file

@ -1,30 +1,16 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::Result; use anyhow::Result;
use axum::routing::{get, post}; use axum::{routing::{get, post}, Router};
use axum::Router;
use axum_login::AuthManagerLayerBuilder; use axum_login::AuthManagerLayerBuilder;
use clap::Parser; use clap::Parser;
use tower_http::services::ServeDir; use sea_orm::Database;
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
use tower_sessions::SessionManagerLayer; use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::sqlx::SqlitePool; use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
use tower_sessions_sqlx_store::SqliteStore;
use tracing::Level; use tracing::Level;
use crate::config::CommandLineOptions; 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::db;
use crate::handler::documents::{
create_document_page, create_document_submit, documents_page, edit_document_page,
edit_document_submit, view_document_page,
};
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 async fn run() -> Result<()> { pub async fn run() -> Result<()> {
dotenvy::dotenv()?; dotenvy::dotenv()?;
@ -35,16 +21,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 mut db_conn = db::establish_connection(&db_url); let db = Database::connect(db_url).await?;
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?;
let provider = Provider::new(db_pool, template_loader); // TODO: better name, also make it an option
let kv_handles = KvHandle::open("./kvdata/")?;
let auth_backend = provider.clone(); let context = Context::new(db, kv_handles, template_loader);
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()
@ -63,13 +49,12 @@ pub async fn run() -> Result<()> {
.route("/documents", get(documents_page)) .route("/documents", get(documents_page))
.route("/documents/new", get(create_document_page)) .route("/documents/new", get(create_document_page))
.route("/documents/new", post(create_document_submit)) .route("/documents/new", post(create_document_submit))
.route("/documents/view/:id", get(view_document_page))
.route("/documents/edit/:id", get(edit_document_page)) .route("/documents/edit/:id", get(edit_document_page))
.route("/documents/edit/:id", post(edit_document_submit)) .route("/documents/edit/:id", post(edit_document_submit))
.layer(trace_layer) .layer(trace_layer)
.layer(session_layer) .layer(session_layer)
.layer(auth_layer) .layer(auth_layer)
.with_state(provider); .with_state(context);
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();

View file

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

176
src/store.rs Normal file
View file

@ -0,0 +1,176 @@
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
sync::Arc,
};
use anyhow::Result;
use uuid::Uuid;
use crate::models::{Document, Project};
pub trait Filter = Fn(&Row) -> bool;
pub trait Map<T> = Fn(&Row) -> T;
pub enum Row {
Project(Box<Project>),
Document(Box<Document>),
//User(Box<User>),
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct RowId(Uuid);
// supports comparison operators
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct IndexId(String);
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct IndexValue(String);
pub enum Index {
BTree(BTreeMap<IndexValue, HashSet<RowId>>),
Unique(HashMap<IndexValue, RowId>),
}
pub struct Store {
rows: HashMap<RowId, Arc<Row>>,
indexes: HashMap<IndexId, Index>,
keyspace: fjall::Keyspace,
data_partition: fjall::PartitionHandle,
permissions_partition: fjall::PartitionHandle,
}
impl Store {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Store> {
let rows = HashMap::new();
let indexes = HashMap::new();
let kv_config = fjall::Config::new(path)
.flush_workers(4)
.compaction_workers(4);
let keyspace = fjall::Keyspace::open(kv_config)?;
let data_partition =
keyspace.open_partition("data", fjall::PartitionCreateOptions::default())?;
let permissions_partition =
keyspace.open_partition("permissions", fjall::PartitionCreateOptions::default())?;
Ok(Store {
rows,
indexes,
keyspace,
data_partition,
permissions_partition,
})
}
pub fn set(&mut self, _item: &Row) -> Result<()> {
todo!()
}
/// Retrieves an item from the store by id. This is always an in-memory
/// operation and cannot fail.
pub fn get(&self, id: RowId) -> Option<Arc<Row>> {
self.rows.get(&id).cloned()
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Comparison {
index: IndexId,
value: IndexValue,
operator: ComparisonOperator,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum ComparisonOperator {
Eq,
Gt,
Gte,
Lt,
Lte,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum QueryOperation {
All,
Where(Comparison),
Limit(usize),
Offset(usize),
OrderBy(IndexId),
}
/// Describes a set of operations to query for data in the database. To read the
/// data, this gets transformed into a QuerySet, which contains the results.
pub struct Query {
operations: Vec<QueryOperation>,
}
impl Query {
pub fn new() -> Query {
Query {
operations: Vec::new(),
}
}
pub fn all(&mut self) -> &mut Self {
self.operations.push(QueryOperation::All);
self
}
/// Filters down the set of rows via comparison operators
pub fn restrict(
&mut self,
index: IndexId,
value: IndexValue,
operator: ComparisonOperator,
) -> &mut Self {
self.operations.push(QueryOperation::Where(Comparison {
index,
value,
operator,
}));
self
}
pub fn limit(&mut self, limit: usize) -> &mut Self {
self.operations.push(QueryOperation::Limit(limit));
self
}
pub fn offset(&mut self, offset: usize) -> &mut Self {
self.operations.push(QueryOperation::Offset(offset));
self
}
pub fn order_by(&mut self, index: IndexId) -> &mut Self {
self.operations.push(QueryOperation::OrderBy(index));
self
}
pub fn execute(&self) -> QuerySet {
todo!()
}
}
/// The results of a Query, this will contain the concrete Rows which have been
/// retrieved.
pub struct QuerySet {}
impl QuerySet {}
impl Iterator for QuerySet {
type Item = Row;
fn next(&mut self) -> Option<Self::Item> {
todo!()
}
}
// How I'd like to do queries:
//
// store.query().via(OnlyProjects).
//
//
//

View file

@ -1,5 +1,6 @@
use minijinja::{Error, ErrorKind};
use free_icons::IconAttrs; use free_icons::IconAttrs;
use minijinja::{path_loader, Environment, Error, ErrorKind}; use minijinja::{path_loader, Environment};
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 {
@ -24,8 +25,10 @@ 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().class(&class).fill("none").stroke_color("currentColor"); let attrs = IconAttrs::default()
.class(&class)
.fill("none")
.stroke_color("currentColor");
free_icons::heroicons(&name, true, attrs) free_icons::heroicons(&name, true, attrs).ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
.ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
} }

View file

@ -1,13 +0,0 @@
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() }
}
}

196
static/main.css Normal file
View file

@ -0,0 +1,196 @@
/* node_modules/@milkdown/theme-nord/lib/style.css */
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
.ProseMirror [draggable][contenteditable=false] {
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
.ProseMirror .tableWrapper {
overflow-x: auto;
}
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.ProseMirror td,
.ProseMirror th {
vertical-align: top;
box-sizing: border-box;
position: relative;
}
.ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
z-index: 20;
background-color: #adf;
pointer-events: none;
}
.ProseMirror.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.ProseMirror .selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background: #c8c8ff66;
pointer-events: none;
}
.milkdown-theme-nord blockquote {
border-left-width: 4px;
--tw-border-opacity: 1;
border-color: rgb(94 129 172 / var(--tw-border-opacity));
padding-left: 1rem;
font-family:
ui-serif,
Georgia,
Cambria,
Times New Roman,
Times,
serif;
font-style: normal;
}
.milkdown-theme-nord code {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
Liberation Mono,
Courier New,
monospace;
font-weight: 400;
--tw-text-opacity: 1;
color: rgb(94 129 172 / var(--tw-text-opacity));
}
.milkdown-theme-nord pre code {
color: inherit;
}
.milkdown-theme-nord img {
margin-top: 0 !important;
margin-bottom: 0 !important;
display: inline-block;
max-width: 100%;
}
.milkdown-theme-nord.prose :where(blockquote):not(:where([class~=not-prose] *)) {
font-weight: 400;
}
.milkdown-theme-nord.prose :where(ol > li):not(:where([class~=not-prose] *))::marker,
.milkdown-theme-nord.prose :where(ul > li):not(:where([class~=not-prose] *))::marker {
--tw-text-opacity: 1;
color: rgb(94 129 172 / var(--tw-text-opacity));
}
.milkdown-theme-nord.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose] *)):before,
.milkdown-theme-nord.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose] *)):after {
content: "";
}
.milkdown-theme-nord.prose :where(code):not(:where([class~=not-prose] *)):before,
.milkdown-theme-nord.prose :where(code):not(:where([class~=not-prose] *)):after {
content: "";
}
.milkdown-theme-nord.prose .tableWrapper {
position: relative;
margin-bottom: .5rem;
overflow-x: auto;
}
.milkdown-theme-nord.prose table {
margin: 1rem !important;
overflow: visible !important;
font-size: .875rem;
line-height: 1.25rem;
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow:
var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000),
var(--tw-shadow);
}
@media (min-width: 640px) {
.milkdown-theme-nord.prose table {
border-radius: .5rem;
}
}
.milkdown-theme-nord.prose td,
.milkdown-theme-nord.prose th {
padding: .75rem 1.5rem !important;
}
.milkdown-theme-nord.prose tr {
border-bottom-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
}
:is(.dark .milkdown-theme-nord.prose tr) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
}
.milkdown-theme-nord.prose :where(td, th) p {
margin: 0 !important;
}
.milkdown-theme-nord.prose :where(td, th):nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
:is(.dark .milkdown-theme-nord.prose :where(td, th):nth-child(odd)) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.milkdown-theme-nord.prose.ProseMirror .selectedCell:after {
background-color: #88c0d04d;
}
.milkdown-theme-nord.prose br[data-is-inline=true],
.milkdown-theme-nord.prose br[data-is-inline=true]:after {
content: " ";
}

31852
static/main.js Normal file

File diff suppressed because it is too large Load diff

3341
static/style.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,21 +8,27 @@
{% include "components/sidebar.html" %} {% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full"> <main class="pl-72 bg-gray-50 h-full">
<form action="/documents/edit/{{ document.id }}" method="POST" class="h-full flex flex-col"> <form action="/documents/edit/{{ document.id }}" method="POST">
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content"> <div class="navbar bg-accent text-accent-content">
<input type="text" id="title" name="title" class="grow font-bold" value="{{ document.title }}" /> <div class="navbar-start gap-2">
<a class="btn btn-sm my-auto" href="/documents"> <a class="btn" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit {{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
</a> </a>
<button class="btn btn-sm my-auto">Save</button> <button class="btn" onClick="saveDocument()">Save</button>
</div>
</div> </div>
<div class="flex flex-col h-full"> <div class="px-8 py-8 flex flex-col gap-y-4">
<div id="editor-{{ document.id }}" name="editor" class="w-full h-full"> <label class="input input-bordered flex items-center gap-2">
<noscript> Title
<textarea id="content-{{ document.id }}" name="content" class="w-full h-full">{{ document.content }}</textarea> <input type="text" id="title" name="title" class="grow" value="{{ document.title }}" />
</noscript> </label>
</div>
<div id="editor-{{document.id}}" class="prose bg-white"></div>
<textarea id="content-{{ document.id }}" name="content" class="hidden">
</textarea>
</div> </div>
</form> </form>
</main> </main>
@ -30,8 +36,13 @@
<script type="text/javascript" src="/static/main.js"></script> <script type="text/javascript" src="/static/main.js"></script>
<script type="text/javascript"> <script type="text/javascript">
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }}); console.log("hi");
window.createEditor("#editor-{{document.id}}", "content-{{document.id}}", {{document.content | tojson }});
console.log({{document.content | tojson}});
console.log("hi2");
function saveDocument() {
console.log("saving");
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -28,8 +28,8 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ document.title }}</h2> <h2 class="card-title">{{ document.title }}</h2>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<a href="/documents/view/{{ document.id }}" class="btn btn-primary">View</a> <a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Open</a>
<a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Edit</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<div class="h-full">
{% set current_page = "documents" %}
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<form action="" method="POST" class="h-full flex flex-col">
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content">
<h1 class="grow text-2xl font-bold">{{ document.title }}</h1>
<a class="btn btn-sm my-auto" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
</a>
<a class="btn btn-sm my-auto" href="/documents/edit/{{ document.id }}">Edit</a>
</div>
<div class="py-8 px-4 h-full prose prose-md">
{{ rendered_document|safe }}
</div>
</form>
</main>
<script type="text/javascript" src="/static/main.js"></script>
<script type="text/javascript">
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }});
</script>
</body>
</html>

View file

@ -4,5 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pique</title> <title>Pique</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/main.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛰️</text></svg>"/> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛰️</text></svg>"/>
</head> </head>