Compare commits

...

6 commits

Author SHA1 Message Date
56716cf1e5 Disable color in esbuild output, since it makes text white in my light-mode terminal -_- 2024-06-08 21:33:44 -04:00
6ea4409d68 Fixes from code overview session (#5)
A collection of small changes:

- Switch from using a boolean for permission check results to using an enum (allowed or denied)
- Remove some unused Makefile commands left over from a previous migration, add a check command
- Change some hardcoded paths to be relative paths
- Remove unused embed_migrations (duplicated)
- Log error when checking permissions if a user belongs to a project multiple times

Reviewed-on: #5
2024-06-09 01:17:55 +00:00
0611aac45f Escape HTML while parsing Markdown documents to remove XSS vulnerabilities (#4)
Here, I opted to use the Markdown parser's detection of HTML so that we don't add another library. This does limit users somewhat, because it means that *no* inline HTML is allowed, but I think this is acceptable: this is a platform for project management, not general-purpose publishing, so inline HTML is probably not necessary. There is a clear upgrade path in the future to add sanitizing instead of escaping tags, if we want.

This approach also gives us a clear place to plug in detection of extra things, like custom `@` tags or other features.

Reviewed-on: #4
2024-06-03 18:15:52 +00:00
137dfa747d Improve editor user experience (no longer WYSIWYG, fixed bugs, added view vs. edit distinction) (#3)
This makes the editor experience much better (by subjective measures). Now instead of a WYSIWYG editor, we have a markdown code editor, and we also have the ability to view documents without editing them.

While I was at it, I fixed a bug where if you didn't edit a document at all, it would save blank. This was fixed as a happenstance from the switch.

Also included here is making the UI work with Javascript disabled. If you don't have JS, you will get a textarea which allows editing the markdown directly. If you do have JS enabled, you'll get a smarter editor.

Reviewed-on: #3
2024-06-03 14:56:15 +00:00
65ad20d197 Switch DB layer to Diesel from SeaORM and Fjall (#2)
Refactors Pique to use Diesel for the backing database layer instead of the previous choices of SeaORM and Fjall (with a custom DB on top of the KV store). This choice was made to speed up development. I found SeaORM much more challenging to discover things in than Diesel, and with Fjall I was getting mired in building things that already exist. This is a migration to a boring choice, and that's the right decision for this moment in time.

Among other things, the diff stats wind up being `47 files changed, 926 insertions(+), 950 deletions(-)` when you exclude lockfile changes and markdown changes. This validates that the code is not significantly more or less verbose, but is simply structured differently. So we're not giving anything up in brevity.

I decided to structure query calls into their own submodules, called `q` (short for `query`). I shortened the names to make it easier to type and make lines shorter, which may be a controversial take, but I think that it will wind up being worth it and will be easy to get used to.

I also renamed `Context` to `Provider`, because I think `Context` implies things like cancellation, while `Provider` implies giving access to resources, which is more precisely what's going on here.

Reviewed-on: #2
2024-06-02 18:37:15 +00:00
e0653e4bdd Create project and documents (#1)
This is a big dump of a lot of code, mostly making it so that:
- we have a key-value store
- we can create/save/load projects and documents
- there's a sidebar layout with some placeholders we may or may not need
- other stuff I forgot

Reviewed-on: #1
2024-05-21 12:59:04 +00:00
69 changed files with 3773 additions and 5651 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ node_modules/
*.db
*.xml
.env
static/

2005
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,20 +12,28 @@ 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"
minijinja = { version = "1.0.14", features = ["loader"] }
free-icons = "0.7.0"
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
minijinja-autoreload = "1.0.14"
pulldown-cmark = "0.11.0"
rand = "0.8.5"
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
redb = "2.1.0"
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

@ -1,15 +1,24 @@
check:
cargo test
cargo fmt --check
cargo clippy
cargo build --release
run:
SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates
run-watch:
SECURE_SESSIONS=false RUST_LOG=debug cargo watch --why -x 'run -- --reload-templates'
run-release:
SECURE_SESSIONS=false RUST_LOG=info cargo run --release
css-watch:
npx tailwindcss -i ./src/main.css -o ./static/main.css --watch
npm run css-watch
migrate:
sea-orm-cli migrate
typescript:
npm run build
entities:
sea-orm-cli generate entity -o src/entity --with-serde both
typescript-watch:
npm run build-watch

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 = "./migrations"

40
frontend/editor.ts Normal file
View file

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

17
frontend/main.css Normal file
View file

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose li {
margin-top: 0;
margin-bottom: 0;
}
.prose ul {
margin-top: 0;
margin-bottom: 0;
}
.prose h1 {
margin-bottom: 0.25em;
}

2
frontend/main.ts Normal file
View file

@ -0,0 +1,2 @@
import { makeEditor } from './editor';
window.makeEditor = makeEditor;

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;
}

View file

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

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE CHECK (LENGTH(username) <= 32),
password_hash TEXT NOT NULL,
email TEXT NOT NULL UNIQUE CHECK (LENGTH(email) <= 100),
name TEXT NOT NULL CHECK (LENGTH(name) <= 100)
);

View file

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

View file

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS projects (
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
creator_id UUID_TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
key TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
creator_id UUID_TEXT NOT NULL,
project_id UUID_TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL
);

View file

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

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS project_memberships(
id INTEGER PRIMARY KEY NOT NULL UNIQUE,
user_id UUID_TEXT NOT NULL,
project_id UUID_TEXT NOT NULL,
role TEXT NOT NULL
);

810
package-lock.json generated
View file

@ -4,8 +4,18 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"@codemirror/lang-markdown": "^6.2.5",
"clsx": "^2.1.1",
"codemirror": "^6.0.1",
"tailwindcss": "^3.4.3"
},
"devDependencies": {
"tailwindcss": "^3.4.1"
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"daisyui": "^4.10.5",
"esbuild": "0.21.1",
"tailwindcss": "^3.4.3"
}
},
"node_modules/@alloc/quick-lru": {
@ -20,6 +30,506 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz",
"integrity": "sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.5.0.tgz",
"integrity": "sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz",
"integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
"integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz",
"integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz",
"integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.0.tgz",
"integrity": "sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.6",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz",
"integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
"integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A=="
},
"node_modules/@codemirror/view": {
"version": "6.26.3",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.3.tgz",
"integrity": "sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==",
"dependencies": {
"@codemirror/state": "^6.4.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.1.tgz",
"integrity": "sha512-O7yppwipkXvnEPjzkSXJRk2g4bS8sUx9p9oXHq9MU/U7lxUzZVsnFZMDTmeeX9bfQxrFcvOacl/ENgOh0WP9pA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.1.tgz",
"integrity": "sha512-hh3jKWikdnTtHCglDAeVO3Oyh8MaH8xZUaWMiCCvJ9/c3NtPqZq+CACOlGTxhddypXhl+8B45SeceYBfB/e8Ow==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.1.tgz",
"integrity": "sha512-jXhccq6es+onw7x8MxoFnm820mz7sGa9J14kLADclmiEUH4fyj+FjR6t0M93RgtlI/awHWhtF0Wgfhqgf9gDZA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.1.tgz",
"integrity": "sha512-NPObtlBh4jQHE01gJeucqEhdoD/4ya2owSIS8lZYS58aR0x7oZo9lB2lVFxgTANSa5MGCBeoQtr+yA9oKCGPvA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.1.tgz",
"integrity": "sha512-BLT7TDzqsVlQRmJfO/FirzKlzmDpBWwmCUlyggfzUwg1cAxVxeA4O6b1XkMInlxISdfPAOunV9zXjvh5x99Heg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.1.tgz",
"integrity": "sha512-D3h3wBQmeS/vp93O4B+SWsXB8HvRDwMyhTNhBd8yMbh5wN/2pPWRW5o/hM3EKgk9bdKd9594lMGoTCTiglQGRQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.1.tgz",
"integrity": "sha512-/uVdqqpNKXIxT6TyS/oSK4XE4xWOqp6fh4B5tgAwozkyWdylcX+W4YF2v6SKsL4wCQ5h1bnaSNjWPXG/2hp8AQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.1.tgz",
"integrity": "sha512-paAkKN1n1jJitw+dAoR27TdCzxRl1FOEITx3h201R6NoXUojpMzgMLdkXVgCvaCSCqwYkeGLoe9UVNRDKSvQgw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.1.tgz",
"integrity": "sha512-tRHnxWJnvNnDpNVnsyDhr1DIQZUfCXlHSCDohbXFqmg9W4kKR7g8LmA3kzcwbuxbRMKeit8ladnCabU5f2traA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.1.tgz",
"integrity": "sha512-G65d08YoH00TL7Xg4LaL3gLV21bpoAhQ+r31NUu013YB7KK0fyXIt05VbsJtpqh/6wWxoLJZOvQHYnodRrnbUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.1.tgz",
"integrity": "sha512-tt/54LqNNAqCz++QhxoqB9+XqdsaZOtFD/srEhHYwBd3ZUOepmR1Eeot8bS+Q7BiEvy9vvKbtpHf+r6q8hF5UA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.1.tgz",
"integrity": "sha512-MhNalK6r0nZD0q8VzUBPwheHzXPr9wronqmZrewLfP7ui9Fv1tdPmg6e7A8lmg0ziQCziSDHxh3cyRt4YMhGnQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.1.tgz",
"integrity": "sha512-YCKVY7Zen5rwZV+nZczOhFmHaeIxR4Zn3jcmNH53LbgF6IKRwmrMywqDrg4SiSNApEefkAbPSIzN39FC8VsxPg==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.1.tgz",
"integrity": "sha512-bw7bcQ+270IOzDV4mcsKAnDtAFqKO0jVv3IgRSd8iM0ac3L8amvCrujRVt1ajBTJcpDaFhIX+lCNRKteoDSLig==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.1.tgz",
"integrity": "sha512-ARmDRNkcOGOm1AqUBSwRVDfDeD9hGYRfkudP2QdoonBz1ucWVnfBPfy7H4JPI14eYtZruRSczJxyu7SRYDVOcg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.1.tgz",
"integrity": "sha512-o73TcUNMuoTZlhwFdsgr8SfQtmMV58sbgq6gQq9G1xUiYnHMTmJbwq65RzMx89l0iya69lR4bxBgtWiiOyDQZA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.1.tgz",
"integrity": "sha512-da4/1mBJwwgJkbj4fMH7SOXq2zapgTo0LKXX1VUZ0Dxr+e8N0WbS80nSZ5+zf3lvpf8qxrkZdqkOqFfm57gXwA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.1.tgz",
"integrity": "sha512-CPWs0HTFe5woTJN5eKPvgraUoRHrCtzlYIAv9wBC+FAyagBSaf+UdZrjwYyTGnwPGkThV4OCI7XibZOnPvONVw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.1.tgz",
"integrity": "sha512-xxhTm5QtzNLc24R0hEkcH+zCx/o49AsdFZ0Cy5zSd/5tOj4X2g3/2AJB625NoadUuc4A8B3TenLJoYdWYOYCew==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.1.tgz",
"integrity": "sha512-CWibXszpWys1pYmbr9UiKAkX6x+Sxw8HWtw1dRESK1dLW5fFJ6rMDVw0o8MbadusvVQx1a8xuOxnHXT941Hp1A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.1.tgz",
"integrity": "sha512-jb5B4k+xkytGbGUS4T+Z89cQJ9DJ4lozGRSV+hhfmCPpfJ3880O31Q1srPCimm+V6UCbnigqD10EgDNgjvjerQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.1.tgz",
"integrity": "sha512-PgyFvjJhXqHn1uxPhyN1wZ6dIomKjiLUQh1LjFvjiV1JmnkZ/oMPrfeEAZg5R/1ftz4LZWZr02kefNIQ5SKREQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.1.tgz",
"integrity": "sha512-W9NttRZQR5ehAiqHGDnvfDaGmQOm6Fi4vSlce8mjM75x//XKuVAByohlEX6N17yZnVXxQFuh4fDRunP8ca6bfA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -85,6 +595,66 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
"integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ=="
},
"node_modules/@lezer/css": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz",
"integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz",
"integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.4.16",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.16.tgz",
"integrity": "sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz",
"integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.0.tgz",
"integrity": "sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -130,6 +700,46 @@
"node": ">=14"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
"integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==",
"dev": true,
"dependencies": {
"mini-svg-data-uri": "^1.2.3"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
"integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
"dev": true,
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@ -263,6 +873,28 @@
"node": ">= 6"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -290,6 +922,11 @@
"node": ">= 6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -304,6 +941,16 @@
"node": ">= 8"
}
},
"node_modules/css-selector-tokenizer": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
"integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"fastparse": "^1.1.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -316,6 +963,34 @@
"node": ">=4"
}
},
"node_modules/culori": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz",
"integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/daisyui": {
"version": "4.10.5",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.10.5.tgz",
"integrity": "sha512-eOFUo5yEg0WV+3VK2C/+/XN1WH/OhFV4HzrMG5etAzcnB2hPg3aoR7gF1ZUpIv+b5MglLuAVMgub0rv660EgZg==",
"dev": true,
"dependencies": {
"css-selector-tokenizer": "^0.8",
"culori": "^3",
"picocolors": "^1",
"postcss-js": "^4"
},
"engines": {
"node": ">=16.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/daisyui"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -340,6 +1015,44 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.1.tgz",
"integrity": "sha512-GPqx+FX7mdqulCeQ4TsGZQ3djBJkx5k7zBGtqt9ycVlWNg8llJ4RO9n2vciu8BN2zAEs6lPbPl0asZsAh7oWzg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.1",
"@esbuild/android-arm": "0.21.1",
"@esbuild/android-arm64": "0.21.1",
"@esbuild/android-x64": "0.21.1",
"@esbuild/darwin-arm64": "0.21.1",
"@esbuild/darwin-x64": "0.21.1",
"@esbuild/freebsd-arm64": "0.21.1",
"@esbuild/freebsd-x64": "0.21.1",
"@esbuild/linux-arm": "0.21.1",
"@esbuild/linux-arm64": "0.21.1",
"@esbuild/linux-ia32": "0.21.1",
"@esbuild/linux-loong64": "0.21.1",
"@esbuild/linux-mips64el": "0.21.1",
"@esbuild/linux-ppc64": "0.21.1",
"@esbuild/linux-riscv64": "0.21.1",
"@esbuild/linux-s390x": "0.21.1",
"@esbuild/linux-x64": "0.21.1",
"@esbuild/netbsd-x64": "0.21.1",
"@esbuild/openbsd-x64": "0.21.1",
"@esbuild/sunos-x64": "0.21.1",
"@esbuild/win32-arm64": "0.21.1",
"@esbuild/win32-ia32": "0.21.1",
"@esbuild/win32-x64": "0.21.1"
}
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -368,6 +1081,12 @@
"node": ">= 6"
}
},
"node_modules/fastparse": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
"dev": true
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@ -429,16 +1148,16 @@
}
},
"node_modules/glob": {
"version": "10.3.10",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
"version": "10.3.12",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz",
"integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^2.3.5",
"jackspeak": "^2.3.6",
"minimatch": "^9.0.1",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
"path-scurry": "^1.10.1"
"minipass": "^7.0.4",
"path-scurry": "^1.10.2"
},
"bin": {
"glob": "dist/esm/bin.mjs"
@ -585,10 +1304,28 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lru-cache": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
"integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
"dev": true,
"engines": {
"node": "14 || >=16.14"
@ -616,10 +1353,19 @@
"node": ">=8.6"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"dev": true,
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
@ -632,9 +1378,9 @@
}
},
"node_modules/minipass": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
"integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.0.tgz",
"integrity": "sha512-oGZRv2OT1lO2UF1zUcwdTb3wqUwI0kBGTgt/T7OdSj6M6N5m3o5uPf0AIW6lVxGGoiWUR7e2AwTE+xiwK8WQig==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
@ -712,12 +1458,12 @@
"dev": true
},
"node_modules/path-scurry": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
"integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz",
"integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==",
"dev": true,
"dependencies": {
"lru-cache": "^9.1.1 || ^10.0.0",
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
@ -1141,6 +1887,11 @@
"node": ">=8"
}
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -1176,9 +1927,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -1189,7 +1940,7 @@
"fast-glob": "^3.3.0",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.19.1",
"jiti": "^1.21.0",
"lilconfig": "^2.1.0",
"micromatch": "^4.0.5",
"normalize-path": "^3.0.0",
@ -1257,6 +2008,11 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -1364,9 +2120,9 @@
}
},
"node_modules/yaml": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz",
"integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"dev": true,
"bin": {
"yaml": "bin.mjs"

View file

@ -1,5 +1,21 @@
{
"devDependencies": {
"tailwindcss": "^3.4.1"
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"daisyui": "^4.10.5",
"esbuild": "0.21.1",
"tailwindcss": "^3.4.3"
},
"scripts": {
"build": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --color=false",
"build-watch": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --watch --color=false",
"css-watch": "tailwindcss -i ./frontend/main.css -o ./static/style.css --watch",
"css": "tailwindcss -i ./frontend/main.css -o ./static/style.css"
},
"dependencies": {
"@codemirror/lang-markdown": "^6.2.5",
"clsx": "^2.1.1",
"codemirror": "^6.0.1",
"tailwindcss": "^3.4.3"
}
}

5
rustfmt.toml Normal file
View file

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

View file

@ -1,24 +1,17 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use pique::{
db::{NewUser, UserQuery},
prelude::*,
};
use rand::distributions::Alphanumeric;
use rand::{distributions::DistString, thread_rng};
use sea_orm::Database;
use pique::db::establish_connection;
use pique::models::users::{self, NewUser};
use rand::distributions::{Alphanumeric, DistString};
use rand::thread_rng;
#[tokio::main]
pub async fn main() -> Result<()> {
dotenvy::dotenv()?;
let db_url = dotenvy::var("DATABASE_URL")?;
match AdminCli::parse().command {
AdminCommand::CreateUser {
full_name,
email,
username,
password,
} => {
AdminCommand::CreateUser { name, email, username, password } => {
let password = match password {
Some(p) => p,
None => {
@ -29,15 +22,9 @@ pub async fn main() -> Result<()> {
password
}
};
handle_create_user(NewUser {
full_name,
email,
username,
password,
})
.await?
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(())
@ -51,40 +38,29 @@ struct AdminCli {
#[derive(Subcommand, Debug)]
pub enum AdminCommand {
CreateUser {
full_name: String,
email: String,
username: String,
password: Option<String>,
},
CreateUser { name: String, email: String, username: String, password: Option<String> },
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::q::create(&mut db, 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::q::all(&mut db)?;
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,5 +1,4 @@
use anyhow::Result;
use pique::server;
#[tokio::main]

View file

@ -1,39 +0,0 @@
use std::sync::Arc;
use minijinja_autoreload::AutoReloader;
use crate::{handler::internal_server_error, prelude::*};
#[derive(Clone)]
pub struct Context {
pub db: DatabaseConnection,
template_loader: Arc<AutoReloader>,
}
impl Context {
pub fn new(db: DatabaseConnection, template_loader: AutoReloader) -> Context {
Context {
db,
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,96 +1,43 @@
use sea_orm::Set;
use thiserror::Error;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::SqliteConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use crate::{entity::user, password, prelude::*};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations/");
pub struct NewUser {
pub full_name: String,
pub email: String,
pub username: String,
pub password: String,
/// 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))
}
impl NewUser {
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut validation_errors = vec![];
if self.full_name.len() > 100 {
validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
}
if self.email.len() > 100 {
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
}
if self.username.len() > 100 {
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
}
if validation_errors.is_empty() {
Ok(())
} else {
Err(validation_errors)
}
}
pub fn hash_password(&self) -> String {
password::hash(&self.password)
}
/// 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.")
}
#[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)
}
/// 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();
}

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

@ -1,14 +1,16 @@
pub mod documents;
pub mod home;
pub mod login;
pub mod projects;
use axum::http::StatusCode;
use axum::response::Response;
pub use login::login_page;
pub use login::login_submit;
pub use login::{login_page, login_submit};
use tracing::error;
pub fn internal_server_error() -> Response {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.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())
}

220
src/handler/documents.rs Normal file
View file

@ -0,0 +1,220 @@
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::Redirect;
use axum::Form;
use axum_login::AuthSession;
use crate::handler::internal_error;
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(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
) -> Result<Response, (StatusCode, String)> {
if let Some(user) = auth_session.user {
render_documents_page(provider, user).await
} else {
Ok(Redirect::to("/login").into_response())
}
}
async fn render_documents_page(
provider: Provider,
user: User,
) -> Result<Response, (StatusCode, String)> {
let mut db = provider.db_pool.get().map_err(internal_error)?;
let documents =
permissions::q::accessible_documents(&mut db, &user.id).map_err(internal_error)?;
let projects =
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
let values = context! {
user => user,
documents => documents,
projects => projects,
};
provider.render_resp("documents/list_documents.html", values)
}
pub async fn create_document_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
) -> 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 projects =
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
let values = context! {
user => user,
projects => projects,
};
provider.render_resp("documents/create_document.html", values)
}
#[derive(Debug, Deserialize)]
pub struct CreateDocumentSubmission {
pub project_id: Uuid,
pub title: String,
}
pub async fn create_document_submit(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
form: Form<CreateDocumentSubmission>,
) -> 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 = permissions::q::check_user_project(
&mut db,
&user.id,
&form.project_id.to_string(),
Permission::Write,
)
.map_err(internal_error)?;
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
}
let new_document = NewDocument::new(
&user.id,
&form.project_id.to_string(),
form.title.to_owned(),
"".to_owned(),
);
let document = documents::q::create(&mut db, new_document).map_err(internal_error)?;
info!(?document, "document created");
Ok(Redirect::to("/documents").into_response())
}
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 =
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(
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 =
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 = documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)?;
let projects =
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
let values = context! {
user => user,
document => document,
projects => projects,
};
provider.render_resp("documents/edit_document.html", values)
}
#[derive(Debug, Deserialize)]
pub struct EditDocumentSubmission {
pub title: String,
pub content: String,
}
pub async fn edit_document_submit(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
Path((document_id,)): Path<(Uuid,)>,
form: Form<EditDocumentSubmission>,
) -> 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 = permissions::q::check_user_document(
&mut db,
&user.id,
&document_id.to_string(),
Permission::Write,
)
.map_err(internal_error)?;
if matches!(access_decision, Decision::Denied) {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
}
documents::q::update(
&mut db,
&document_id.to_string(),
form.title.to_owned(),
form.content.to_owned(),
)
.map_err(internal_error)?;
let view_url = format!("/documents/view/{}", document_id);
Ok(Redirect::to(&view_url).into_response())
}

View file

@ -1,16 +1,27 @@
use axum::http::StatusCode;
use axum::response::Redirect;
use axum_login::AuthSession;
use crate::models::projects::Project;
use crate::permissions;
use crate::prelude::*;
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
pub async fn home_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
) -> Result<Response, (StatusCode, String)> {
if let Some(user) = auth_session.user {
let mut db = provider.db_pool.get().unwrap();
let projects: Vec<Project> =
permissions::q::accessible_projects(&mut db, &user.id).unwrap();
let values = context! {
user => user,
projects => projects,
};
ctx.render_resp("home.html", values)
provider.render_resp("home.html", values)
} else {
Redirect::to("/login").into_response()
Ok(Redirect::to("/login").into_response())
}
}

View file

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

87
src/handler/projects.rs Normal file
View file

@ -0,0 +1,87 @@
use axum::http::StatusCode;
use axum::response::Redirect;
use axum::Form;
use axum_login::AuthSession;
use super::internal_error;
use crate::models::project_memberships::{self, ProjectRole};
use crate::models::projects::{self, NewProject};
use crate::models::users::User;
use crate::permissions;
use crate::prelude::*;
pub async fn projects_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
) -> Result<Response, (StatusCode, String)> {
if let Some(user) = auth_session.user {
render_projects_page(provider, user).await
} else {
Ok(Redirect::to("/login").into_response())
}
}
async fn render_projects_page(
provider: Provider,
user: User,
) -> Result<Response, (StatusCode, String)> {
let mut db = provider.db_pool.get().map_err(internal_error)?;
let projects = permissions::q::accessible_projects(&mut db, &user.id).unwrap_or_default();
let values = context! {
user => user,
projects => projects,
};
provider.render_resp("projects/list_projects.html", values)
}
pub async fn create_project_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
) -> Result<Response, (StatusCode, String)> {
let user = match auth_session.user {
Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()),
};
let values = context! {
user => user,
};
provider.render_resp("projects/create_project.html", values)
}
#[derive(Debug, Deserialize)]
pub struct CreateProjectSubmission {
pub name: String,
pub description: String,
pub key: String,
}
pub async fn create_project_submit(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
form: Form<CreateProjectSubmission>,
) -> Result<Response, (StatusCode, String)> {
let mut db = provider.db_pool.get().map_err(internal_error)?;
let user = match auth_session.user {
Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()),
};
let new_project = NewProject::new(
user.id.clone(),
form.name.clone(),
form.description.clone(),
form.key.clone(),
);
// TODO: validation
let project = projects::q::create(&mut db, new_project).map_err(internal_error)?;
let _ = project_memberships::q::create(&mut db, &user.id, &project.id, ProjectRole::Admin)
.map_err(internal_error)?;
Ok(Redirect::to("/projects").into_response())
}

View file

@ -1,11 +1,14 @@
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 permissions;
pub mod prelude;
pub mod provider;
pub mod schema;
pub mod server;
pub mod session;
pub mod templates;
pub mod validation;

View file

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

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

18
src/models.rs Normal file
View file

@ -0,0 +1,18 @@
use thiserror::Error;
pub mod documents;
pub mod project_memberships;
pub mod projects;
pub mod users;
#[derive(Error, Debug)]
pub enum DbError {
#[error("Diesel error: {0}")]
DieselError(#[from] diesel::result::Error),
#[error("Diesel connection error: {0}")]
ConnectionError(#[from] diesel::ConnectionError),
#[error("Connection pool error: {0}")]
PoolError(#[from] diesel::r2d2::PoolError),
}

108
src/models/documents.rs Normal file
View file

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

@ -0,0 +1,92 @@
use std::fmt;
use diesel::expression::AsExpression;
use diesel::prelude::*;
use diesel::sql_types::Text;
#[derive(AsExpression, Debug, Clone)]
#[diesel(sql_type = Text)]
pub enum ProjectRole {
Member,
Admin,
}
impl fmt::Display for ProjectRole {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ProjectRole::Member => write!(f, "member"),
ProjectRole::Admin => write!(f, "admin"),
}
}
}
impl<S> From<S> for ProjectRole
where
S: AsRef<str>,
String: std::convert::From<S>,
{
fn from(status: S) -> Self {
match status.as_ref() {
"member" => ProjectRole::Member,
"admin" => ProjectRole::Admin,
_ => ProjectRole::Member,
}
}
}
impl From<ProjectRole> for String {
fn from(role: ProjectRole) -> Self {
match role {
ProjectRole::Member => "member".to_string(),
ProjectRole::Admin => "admin".to_string(),
}
}
}
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = crate::schema::project_memberships)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct ProjectMembership {
pub id: i32,
pub user_id: String,
pub project_id: String,
#[diesel(serialize_as = String, deserialize_as = String)]
pub role: ProjectRole,
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::project_memberships)]
pub struct NewProjectMembership {
pub user_id: String,
pub project_id: String,
#[diesel(serialize_as = String, deserialize_as = String)]
pub role: ProjectRole,
}
pub mod q {
use diesel::SqliteConnection;
use super::*;
pub fn create(
conn: &mut SqliteConnection,
user_id: &str,
project_id: &str,
role: ProjectRole,
) -> Result<ProjectMembership, diesel::result::Error> {
use crate::schema::project_memberships::dsl as pm;
let new_membership = NewProjectMembership {
user_id: user_id.to_string(),
project_id: project_id.to_string(),
role,
};
let membership =
diesel::insert_into(pm::project_memberships).values(new_membership).get_result(conn)?;
Ok(membership)
}
}

54
src/models/projects.rs Normal file
View file

@ -0,0 +1,54 @@
use diesel::prelude::*;
use serde::Serialize;
use uuid::Uuid;
use super::DbError;
use crate::schema::projects::dsl;
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
#[diesel(table_name = crate::schema::projects)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Project {
pub id: String,
pub creator_id: String,
pub name: String,
pub description: String,
pub key: String,
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::projects)]
pub struct NewProject {
pub id: String,
pub creator_id: String,
pub name: String,
pub description: String,
pub key: String,
}
impl NewProject {
pub fn new(creator_id: String, name: String, description: String, key: String) -> Self {
Self { id: Uuid::now_v7().to_string(), creator_id, name, description, key }
}
}
pub mod q {
use super::*;
pub fn for_user(conn: &mut SqliteConnection, user_id: String) -> Result<Vec<Project>, DbError> {
let projects =
dsl::projects.filter(dsl::creator_id.eq(user_id.to_string())).load::<Project>(conn)?;
Ok(projects)
}
pub fn create(
conn: &mut SqliteConnection,
new_project: NewProject,
) -> Result<Project, diesel::result::Error> {
use crate::schema::projects::dsl as p;
let project = diesel::insert_into(p::projects).values(new_project).get_result(conn)?;
Ok(project)
}
}

85
src/models/users.rs Normal file
View file

@ -0,0 +1,85 @@
use diesel::prelude::*;
use serde::Serialize;
use uuid::Uuid;
use super::DbError;
use crate::password;
use crate::schema::users::dsl;
use crate::validation::ValidationError;
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
#[diesel(table_name = crate::schema::users)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct User {
pub id: String,
pub username: String,
pub password_hash: String,
pub email: String,
pub name: String,
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::users)]
pub struct NewUser {
pub id: String,
pub name: String,
pub username: String,
pub email: String,
pub password_hash: String,
}
impl NewUser {
pub fn new(name: String, username: String, email: String, password: String) -> Self {
let password_hash = password::hash(&password);
Self { id: Uuid::now_v7().to_string(), name, username, email, password_hash }
}
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut validation_errors = vec![];
if self.name.len() > 100 {
validation_errors.push(ValidationError::on("name", "too long (max=100)"));
}
if self.email.len() > 100 {
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
}
if self.username.len() > 32 {
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
}
if validation_errors.is_empty() {
Ok(())
} else {
Err(validation_errors)
}
}
}
pub mod q {
use super::*;
pub fn all(conn: &mut SqliteConnection) -> Result<Vec<User>, DbError> {
let user_list = dsl::users.load::<User>(conn)?;
Ok(user_list)
}
pub fn by_id(conn: &mut SqliteConnection, id: &str) -> Result<User, DbError> {
let user = dsl::users.filter(dsl::id.eq(id)).first::<User>(conn)?;
Ok(user)
}
pub fn by_username(conn: &mut SqliteConnection, username: &str) -> Result<User, DbError> {
let user = dsl::users.filter(dsl::username.eq(username)).first::<User>(conn)?;
Ok(user)
}
pub fn create(conn: &mut SqliteConnection, new_user: NewUser) -> Result<User, DbError> {
let _ = diesel::insert_into(dsl::users).values(&new_user).execute(conn)?;
let new_user = dsl::users.filter(dsl::id.eq(&new_user.id)).first::<User>(conn)?;
Ok(new_user)
}
}

View file

@ -9,18 +9,14 @@ pub fn verify(hash: &str, password: &str) -> bool {
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.
pub fn hash(password: &str) -> String {
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()
}

143
src/permissions.rs Normal file
View file

@ -0,0 +1,143 @@
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,9 +1,8 @@
pub use crate::context::Context;
pub use crate::entity::prelude::*;
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;
pub use crate::provider::Provider;

55
src/provider.rs Normal file
View file

@ -0,0 +1,55 @@
use std::sync::Arc;
use axum::http::StatusCode;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::SqliteConnection;
use minijinja_autoreload::AutoReloader;
use thiserror::Error;
use crate::handler::internal_error;
use crate::prelude::*;
pub type ConnectionPool = Pool<ConnectionManager<SqliteConnection>>;
pub type PooledConnection = diesel::r2d2::PooledConnection<ConnectionManager<SqliteConnection>>;
#[derive(Clone)]
pub struct Provider {
pub db_pool: ConnectionPool,
template_loader: Arc<AutoReloader>,
}
#[derive(Error, Debug)]
pub enum ProviderError {
#[error("Error while using the connection pool: {0}")]
R2D2Error(#[from] diesel::r2d2::PoolError),
#[error("Error while rendering template: {0}")]
TemplateError(#[from] minijinja::Error),
}
impl Provider {
pub fn new(db: ConnectionPool, template_loader: AutoReloader) -> Provider {
Provider { db_pool: db, template_loader: Arc::new(template_loader) }
}
pub fn db_conn(&self) -> Result<PooledConnection, ProviderError> {
let conn = self.db_pool.get()?;
Ok(conn)
}
pub fn render<T: Serialize>(&self, path: &str, data: T) -> Result<String, ProviderError> {
let env = self.template_loader.acquire_env().unwrap();
let template = env.get_template(path)?;
let rendered = template.render(data)?;
Ok(rendered)
}
pub fn render_resp<T: Serialize>(
&self,
path: &str,
data: T,
) -> Result<Response, (StatusCode, String)> {
let rendered = self.render(path, data).map_err(internal_error)?;
Ok(Html(rendered).into_response())
}
}

42
src/schema.rs Normal file
View file

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

View file

@ -1,16 +1,30 @@
use std::str::FromStr;
use anyhow::Result;
use axum::{routing::{get, post}, Router};
use axum::routing::{get, post};
use axum::Router;
use axum_login::AuthManagerLayerBuilder;
use clap::Parser;
use sea_orm::Database;
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
use tower_http::services::ServeDir;
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
use tower_sessions_sqlx_store::sqlx::SqlitePool;
use tower_sessions_sqlx_store::SqliteStore;
use tracing::Level;
use crate::{config::CommandLineOptions, context::Context, handler::{home::home_page, login::logout, login_page, login_submit}, logging::setup_logging, templates::make_template_loader};
use crate::config::CommandLineOptions;
use crate::db;
use crate::handler::documents::{
create_document_page, create_document_submit, documents_page, edit_document_page,
edit_document_submit, 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<()> {
dotenvy::dotenv()?;
@ -21,13 +35,16 @@ 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?;
let context = Context::new(db, template_loader);
let provider = Provider::new(db_pool, template_loader);
let auth_backend = context.clone();
let auth_backend = provider.clone();
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
let trace_layer = TraceLayer::new_for_http()
@ -40,10 +57,19 @@ pub async fn run() -> Result<()> {
.route("/login", get(login_page))
.route("/login", post(login_submit))
.route("/logout", get(logout))
.route("/projects", get(projects_page))
.route("/projects/new", get(create_project_page))
.route("/projects/new", post(create_project_submit))
.route("/documents", get(documents_page))
.route("/documents/new", get(create_document_page))
.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", post(edit_document_submit))
.layer(trace_layer)
.layer(session_layer)
.layer(auth_layer)
.with_state(context);
.with_state(provider);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();

View file

@ -1,24 +1,21 @@
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};
use crate::password;
use crate::prelude::*;
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct Credentials {
pub username: String,
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] {
@ -27,8 +24,8 @@ impl AuthUser for user::Model {
}
#[async_trait]
impl AuthnBackend for Context {
type User = user::Model;
impl AuthnBackend for Provider {
type User = models::users::User;
type Credentials = Credentials;
type Error = DbError;
@ -36,14 +33,20 @@ 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 user = users::q::by_username(&mut db, &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 user = users::q::by_id(&mut db, user_id)?;
Ok(Some(user))
}
}

View file

@ -1,9 +1,12 @@
use minijinja::{path_loader, Environment};
use free_icons::IconAttrs;
use minijinja::{path_loader, Environment, Error, ErrorKind};
use minijinja_autoreload::AutoReloader;
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
let reloader = AutoReloader::new(move |notifier| {
let mut env = Environment::new();
setup_filters(&mut env);
let templates_path = "templates/";
env.set_loader(path_loader(templates_path));
if auto_reload {
@ -13,3 +16,16 @@ pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
});
reloader
}
pub fn setup_filters(env: &mut Environment) {
env.add_filter("heroicon", heroicon_filter);
}
pub fn heroicon_filter(name: String, classes: Option<String>) -> Result<String, Error> {
let class = classes.unwrap_or_else(|| "".to_owned());
let attrs = IconAttrs::default().class(&class).fill("none").stroke_color("currentColor");
free_icons::heroicons(&name, true, attrs)
.ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
}

13
src/validation.rs Normal file
View file

@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl ValidationError {
pub fn on(field: &str, message: &str) -> ValidationError {
ValidationError { field: field.to_owned(), message: message.to_owned() }
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
let mobile_open_button = document.getElementById("mobile-menu-open-button");
let mobile_close_button = document.getElementById("mobile-menu-close-button");
let mobile_menu = document.getElementById("mobile-menu");
const profile_button = document.getElementById("profile-menu-button");
let profile_dropdown = document.getElementById("profile-dropdown");
function toggle_mobile_menu() {
if (mobile_open_button.classList.contains("block")) {
mobile_menu.classList.remove("hidden");
mobile_open_button.classList.remove("block");
mobile_open_button.classList.add("hidden");
mobile_close_button.classList.remove("hidden");
mobile_close_button.classList.add("block");
} else {
mobile_menu.classList.add("hidden");
mobile_close_button.classList.remove("block");
mobile_close_button.classList.add("hidden");
mobile_open_button.classList.remove("hidden");
mobile_open_button.classList.add("block");
}
}
function toggle_profile_dropdown() {
if (profile_dropdown.classList.contains("hidden")) {
profile_dropdown.classList.remove("hidden");
} else {
profile_dropdown.classList.add("hidden");
}
}
function hide_profile_dropdown() {
let profile_dropdown = document.getElementById("profile-dropdown");
if (!profile_dropdown.classList.contains("hidden")) {
profile_dropdown.classList.add("hidden");
}
}
document.addEventListener("click", (event) => {
if (!profile_button.contains(event.target) && !profile_dropdown.contains(event.target)) {
profile_dropdown.classList.add("hidden");
}
});

View file

@ -1,9 +1,41 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**/*.html"],
content: ["./templates/**/*.html", "./frontend/**/*.ts"],
theme: {
extend: {},
},
plugins: [],
plugins: [
require('daisyui'),
require('@tailwindcss/typography'),
require('@tailwindcss/forms'),
],
daisyui: {
themes: [
{
"pique": {
"primary": "#F5A9B8",
"primary-content": "#150a0c",
"secondary": "#5BCEFA",
"secondary-content": "#030f15",
"accent": "#A2E4B8",
"accent-content": "#0a120c",
"neutral": "#ff00ff",
"neutral-content": "#160016",
"base-100": "#ffffff",
"base-200": "#dedede",
"base-300": "#bebebe",
"base-content": "#161616",
"info": "#4f46e5",
"info-content": "#d6ddfe",
"success": "#0f766e",
"success-content": "#d3e3e0",
"warning": "#eab308",
"warning-content": "#130c00",
"error": "#ef4444",
"error-content": "#140202",
}
},
],
},
}

View file

@ -0,0 +1,17 @@
<li>
{% if selected %}
<a href="{{ path }}" class="bg-gray-100 text-gray-900 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
<svg class="h-6 w-6 shrink-0 text-gray-900" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ svg }}" />
</svg>
{{ text }}
</a>
{% else %}
<a href="{{ path }}" class="text-gray-700 hover:text-gray-900 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
<svg class="h-6 w-6 shrink-0 text-gray-400 group-hover:text-gray-900" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ svg }}" />
</svg>
{{ text }}
</a>
{% endif %}
</li>

View file

@ -0,0 +1,76 @@
<div class="fixed inset-y-0 z-50 flex w-72 flex-col">
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-20 bg-white px-6">
<div class="flex h-16 shrink-0 items-center">
<span class="h-8 w-auto text-xl">⛰️</span>
</div>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" class="-mx-2 space-y-1">
{% with path = "/", text = "Dashboard", selected = (current_page == "home"), svg = "M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" %}
{% include "components/nav/main_item.html" %}
{% endwith %}
{% with path = "/projects", text = "Projects", selected = (current_page == "projects"), svg = "M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" %}
{% include "components/nav/main_item.html" %}
{% endwith %}
{% with path = "/documents", text = "Documents", selected = (current_page == "documents"), svg = "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" %}
{% include "components/nav/main_item.html" %}
{% endwith %}
{% with path = "/chats", text = "Chats", selected = (current_page == "chats"), svg = "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" %}
{% include "components/nav/main_item.html" %}
{% endwith %}
</ul>
</li>
<li>
<div class="text-xs font-semibold leading-6 text-gray-400">Your projects</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
{% for project in projects %}
<li>
<!-- Current: "bg-gray-50 text-indigo-600", Default: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" -->
<a href="#" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-50 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
<span class="flex h-6 w-10 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">{{ project.key }}</span>
<span class="truncate">{{ project.name }}</span>
</a>
</li>
{% else %}
<div class="text-gray-500 p-2 text-xs leading-6 italic">
No projects.
</div>
{% endfor %}
</ul>
</li>
<li class="-mx-6 mt-auto">
<div class="w-auto flex justify-around items-center text-sm font-semibold leading-6 text-gray-900">
<a href="/profile" class="flex items-center gap-x-4 px-6 py-3 hover:bg-gray-50 rounded-md">
<div class="h-8 w-8 rounded-full bg-gray-50">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<span class="sr-only">Your profile</span>
<span aria-hidden="true" class="">Profile</span>
</a>
<a href="/logout" class="flex px-6 py-3 hover:bg-gray-50 rounded-md">
<span>Log out</span>
</a>
</li>
</ul>
</nav>
</div>
</div>

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<div class="h-full">
{% set current_page = "projects" %}
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<div class="navbar bg-accent text-accent-content">
<div class="navbar-start">
<a class="btn" href="/projects">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
</a>
</div>
</div>
<form action="/documents/new" method="POST">
<div class="px-8 py-8 flex flex-col gap-y-4">
<label class="input input-bordered flex items-center gap-2">
Title
<input type="text" id="title" name="title" class="grow" placeholder="The title of the document is..." />
</label>
<label class="input input-bordered flex items-center gap-2">
Project
<select id="project_id" name="project_id" class="grow">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</label>
<button class="btn btn-primary">Create</button>
</div>
</form>
</main>
<script type="text/javascript" src="/static/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,37 @@
<!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="/documents/edit/{{ document.id }}" 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">
<input type="text" id="title" name="title" class="grow font-bold" value="{{ document.title }}" />
<a class="btn btn-sm my-auto" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
</a>
<button class="btn btn-sm my-auto">Save</button>
</div>
<div class="flex flex-col h-full">
<div id="editor-{{ document.id }}" name="editor" class="w-full h-full">
<noscript>
<textarea id="content-{{ document.id }}" name="content" class="w-full h-full">{{ document.content }}</textarea>
</noscript>
</div>
</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

@ -0,0 +1,45 @@
<!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">
<div class="navbar bg-accent text-accent-content">
<div class="navbar-start">
<a class="btn" href="/documents/new">New Document</a>
</div>
<div class="navbar-end">
<div class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</div>
</div>
</div>
<div class="px-8 py-8 flex flex-col gap-y-4">
{% for document in documents %}
<div class="card w-96 bg-base-100 shadow-md border-2 border-solid">
<div class="card-body">
<h2 class="card-title">{{ document.title }}</h2>
<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">Edit</a>
</div>
</div>
</div>
{% endfor %}
</div>
<div id="editor" class="prose bg-white"></div>
</main>
<script type="text/javascript" src="/static/main.js"></script>
</body>
</html>

View file

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

@ -3,6 +3,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pique</title>
<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="stylesheet" href="/static/style.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>"/>
</head>

View file

@ -2,115 +2,17 @@
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<!--
This example requires updating your template:
```
<html class="h-full bg-gray-100">
<body class="h-full">
```
-->
<div class="min-h-full">
<nav class="bg-purple-200">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
👀
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<!-- Current: "bg-purple-700 text-white", Default: "text-gray-700 hover:bg-purple-500 hover:bg-opacity-75" -->
<a href="#" class="bg-purple-700 text-white rounded-md px-3 py-2 text-sm font-medium" aria-current="page">Dashboard</a>
<a href="#" class="text-gray-700 hover:text-white hover:bg-purple-500 hover:bg-opacity-75 rounded-md px-3 py-2 text-sm font-medium">Placeholder</a>
</div>
</div>
</div>
<div class="hidden md:block">
<div class="ml-4 flex items-center md:ml-6">
<!-- Profile dropdown -->
<div class="relative ml-3">
<div>
<button type="button" class="relative flex max-w-xs items-center rounded-full bg-purple-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-purple-600" id="profile-menu-button" aria-expanded="false" aria-haspopup="true" onClick="toggle_profile_dropdown()">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Open user menu</span>
<!-- placeholder icon -->
<span class="inline-block h-6 w-6 overflow-hidden rounded-full bg-gray-200">
<svg class="h-full w-full text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
</button>
</div>
<div class="hidden absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button" tabindex="-1" id="profile-dropdown">
<div class="ml-3">
<div class="text-base font-medium text-gray-600">{{ user.full_name }}</div>
<div class="text-sm font-medium text-gray-400">{{ user.email }}</div>
</div>
<!-- Active: "bg-gray-100", Not Active: "" -->
<a href="/logout" class="block px-4 py-2 text-sm text-gray-700" role="menuitem" tabindex="-1" id="user-menu-item-2">Sign out</a>
</div>
</div>
</div>
</div>
<div class="-mr-2 flex md:hidden">
<!-- Mobile menu button -->
<button type="button" class="relative inline-flex items-center justify-center rounded-md bg-purple-600 p-2 text-purple-200 hover:bg-purple-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-purple-600" aria-controls="mobile-menu" aria-expanded="false" onClick="toggle_mobile_menu()">
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open main menu</span>
<!-- Menu open: "hidden", Menu closed: "block" -->
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" id="mobile-menu-open-button">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
<!-- Menu open: "block", Menu closed: "hidden" -->
<svg class="hidden h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" id="mobile-menu-close-button">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div class="md:hidden hidden" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2 sm:px-3">
<!-- Current: "bg-purple-700 text-white", Default: "text-white hover:bg-purple-500 hover:bg-opacity-75" -->
<a href="#" class="bg-purple-700 text-white block rounded-md px-3 py-2 text-base font-medium" aria-current="page">Dashboard</a>
<a href="#" class="text-gray-700 hover:text-white hover:bg-purple-500 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">Placeholder</a>
</div>
<div class="border-t border-purple-700 pb-3 pt-4">
<div class="flex items-center px-5">
<div class="flex-shrink-0">
<!-- placeholder icon -->
<span class="inline-block h-6 w-6 overflow-hidden rounded-full bg-gray-200">
<svg class="h-full w-full text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
</div>
<div class="ml-3">
<div class="text-base font-medium text-gray-600">{{ user.full_name }}</div>
<div class="text-sm font-medium text-gray-400">{{ user.email }}</div>
</div>
</div>
<div class="mt-3 space-y-1 px-2">
<a href="/logout" class="block rounded-md px-3 py-2 text-base font-medium text-gray-700 hover:bg-purple-500 hover:bg-opacity-75">Sign out</a>
</div>
</div>
</div>
</nav>
<main>
<div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
Hi there!
<div>
{% set current_page = "home" %}
{% include "components/sidebar.html" %}
<main class="py-10 pl-72">
<div class="px-8">
Main content.
</div>
</main>
</div>
<script type="text/javascript" src="/static/main.js"></script>
</body>

View file

@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-purple-100">
<html lang="en" class="h-full bg-secondary">
{% include "head.html" %}
<body class="h-full">
<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">Sign in to Pique</h2>
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-secondary-content">Sign in to Pique</h2>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
@ -14,19 +14,19 @@
<div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">Username</label>
<div class="">
<input id="username" name="username" type="username" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6">
<input id="username" name="username" type="username" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-accent sm:text-sm sm:leading-6">
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
<div class="">
<input id="password" name="password" type="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6">
<input id="password" name="password" type="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-accent sm:text-sm sm:leading-6">
</div>
</div>
<div>
<button type="submit" class="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600">Sign in</button>
<button type="submit" class="btn btn-primary w-full">Sign in</button>
</div>
</form>

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<div class="h-full">
{% set current_page = "projects" %}
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<div class="navbar bg-accent text-accent-content">
<div class="navbar-start">
<a class="btn" href="/projects">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
</a>
</div>
</div>
<form action="/projects/new" method="POST">
<div class="px-8 py-8 flex flex-col gap-y-4">
<label class="input input-bordered flex items-center gap-2">
Name
<input type="text" id="name" name="name" class="grow" placeholder="My project is called..." />
</label>
<label class="input input-bordered flex items-center gap-2">
Key
<input type="text" id="key" name="key" class="grow" placeholder="Short display code, like BUG" />
</label>
<label class="input input-bordered flex items-center gap-2">
Description
<input type="text" id="description" name="description" class="grow" placeholder="What it's all about" />
</label>
<button class="btn btn-primary">Create</button>
</div>
</form>
</main>
<script type="text/javascript" src="/static/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<div class="h-full">
{% set current_page = "projects" %}
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<div class="navbar bg-accent text-accent-content">
<div class="navbar-start">
<a class="btn" href="/projects/new">New Project</a>
</div>
<div class="navbar-end">
<div class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</div>
</div>
</div>
<div class="px-8 py-8 flex flex-col gap-y-4">
{% for project in projects %}
<div class="card w-96 bg-base-100 shadow-md border-2 border-solid">
<div class="card-body">
<h2 class="card-title">{{ project.key }} - {{ project.name }}</h2>
<p>{{ project.description }}</p>
<div class="card-actions justify-end">
<button class="btn btn-primary">View</button>
<button class="btn">Edit</button>
</div>
</div>
</div>
{% endfor %}
</div>
</main>
<script type="text/javascript" src="/static/main.js"></script>
</body>
</html>