Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
@ -1,3 +1,10 @@
|
||||||
[env]
|
[build]
|
||||||
RUST_BACKTRACE = "1"
|
target = "x86_64-unknown-linux-musl"
|
||||||
RUSTFLAGS = "--cfg uuid_unstable"
|
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "clang"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-fuse-ld=/nix/store/xmykcqa2dch39s7fh4z6s0hbw7kdmbfi-mold-2.30.0/bin/mold"]
|
||||||
|
|
||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
linker = "clang"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-fuse-ld=/nix/store/xmykcqa2dch39s7fh4z6s0hbw7kdmbfi-mold-2.30.0/bin/mold"]
|
||||||
|
|
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -1,3 +1,10 @@
|
||||||
/target
|
/target
|
||||||
/.direnv
|
/.direnv
|
||||||
*.sqlitedb
|
/static/style.css
|
||||||
|
/data
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
#
|
||||||
|
# already existing elements were commented out
|
||||||
|
|
||||||
|
#/target
|
||||||
|
|
2577
Cargo.lock
generated
2577
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
73
Cargo.toml
73
Cargo.toml
|
@ -3,35 +3,50 @@ name = "lyrs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
opt-level = "s"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.75"
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
|
axum-login = "0.15.3"
|
||||||
# web
|
bincode = "1.3.3"
|
||||||
axum = { version = "0.6.20", features = ["headers"] }
|
chrono = { version = "0.4.38", features = ["serde"] }
|
||||||
axum_csrf = { version = "0.8.0", features = ["layer"] }
|
clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||||
base64 = "0.21.5"
|
color-eyre = "0.6.3"
|
||||||
cookie = "0.18.0"
|
config = "0.14.0"
|
||||||
maud = "0.25.0"
|
futures = "0.3.30"
|
||||||
serde = { version = "1.0.192", features = ["derive"] }
|
maud = "0.26.0"
|
||||||
tokio = { version = "1.34.0", features = ["full", "macros", "rt-multi-thread"] }
|
|
||||||
tower-http = { version = "0.4.4", features = ["fs"] }
|
|
||||||
|
|
||||||
# instrumentation
|
|
||||||
tracing = "0.1.40"
|
|
||||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
|
||||||
|
|
||||||
# fancy during-development stuff
|
|
||||||
notify = "6.1.1"
|
notify = "6.1.1"
|
||||||
tower-livereload = "0.8.2"
|
pathdiff = "0.2.1"
|
||||||
argon2 = { version = "0.5.2", features = ["std"] }
|
rand = "0.8.5"
|
||||||
thiserror = "1.0.50"
|
redact = { version = "0.1.10", features = ["serde"] }
|
||||||
axum-macros = "0.3.8"
|
regex = { version = "1.10.5" }
|
||||||
color-eyre = "0.6.2"
|
serde = "1.0.201"
|
||||||
|
sled = { version = "0.34.7", features = [] }
|
||||||
|
thiserror = "1.0.60"
|
||||||
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
|
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
|
||||||
|
tower-livereload = "0.9.2"
|
||||||
|
tower-sessions = "0.12.2"
|
||||||
|
tower-sessions-sled-store = { git = "https://github.com/lytedev/tower-sessions-sled-store.git", branch = "tower-sessions-0.12" }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1.8.0", features = ["v7", "serde"] }
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
|
||||||
|
[dependencies.axum]
|
||||||
|
version = "0.7.5"
|
||||||
|
features = ["macros", "tokio", "tracing"]
|
||||||
|
|
||||||
# db
|
|
||||||
sea-orm = { version = "0.12.6", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
|
|
||||||
sea-orm-migration = { version = "0.12.6", features = ["sqlx-sqlite"] }
|
|
||||||
uuid = { version = "1.5.0", features = ["v7", "atomic", "fast-rng", "macro-diagnostics"] }
|
|
||||||
password-hash = "0.5.0"
|
|
||||||
axum-login = "0.7.3"
|
|
||||||
tower = "0.4.13"
|
|
||||||
|
|
|
@ -1,267 +0,0 @@
|
||||||
/* reset */
|
|
||||||
|
|
||||||
html {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
-moz-text-size-adjust: none;
|
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul[class],
|
|
||||||
ol[class] {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6,
|
|
||||||
button,
|
|
||||||
.button,
|
|
||||||
input,
|
|
||||||
label {
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
picture {
|
|
||||||
max-width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
.button,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea:not([rows]) {
|
|
||||||
min-height: 10em;
|
|
||||||
/* form-sizing: normal; */
|
|
||||||
}
|
|
||||||
|
|
||||||
:target {
|
|
||||||
scroll-margin-block: 5ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* end reset */
|
|
||||||
|
|
||||||
/* global classes */
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap {
|
|
||||||
gap: 1ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* end global classes */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--font: monospace;
|
|
||||||
--br: 3px;
|
|
||||||
|
|
||||||
/* catppuccin latte */
|
|
||||||
--Rosewater: #dc8a78;
|
|
||||||
--Flamingo: #dd7878;
|
|
||||||
--Pink: #ea76cb;
|
|
||||||
--Mauve: #8839ef;
|
|
||||||
--Red: #d20f39;
|
|
||||||
--Maroon: #e64553;
|
|
||||||
--Peach: #fe640b;
|
|
||||||
--Yellow: #df8e1d;
|
|
||||||
--Green: #40a02b;
|
|
||||||
--Teal: #179299;
|
|
||||||
--Sky: #04a5e5;
|
|
||||||
--Sapphire: #209fb5;
|
|
||||||
--Blue: #1e66f5;
|
|
||||||
--Lavender: #7287fd;
|
|
||||||
--Text: #4c4f69;
|
|
||||||
--Subtext1: #5c5f77;
|
|
||||||
--Subtext0: #6c6f85;
|
|
||||||
--Overlay2: #7c7f93;
|
|
||||||
--Overlay1: #8c8fa1;
|
|
||||||
--Overlay0: #9ca0b0;
|
|
||||||
--Surface2: #acb0be;
|
|
||||||
--Surface1: #bcc0cc;
|
|
||||||
--Surface0: #ccd0da;
|
|
||||||
--Base: #eff1f5;
|
|
||||||
--Mantle: #e6e9ef;
|
|
||||||
--Crust: #dce0e8;
|
|
||||||
|
|
||||||
--bg: var(--Base);
|
|
||||||
--bg2: var(--Surface0);
|
|
||||||
--primary: var(--Mauve);
|
|
||||||
--text: var(--Text);
|
|
||||||
--link-visited: var(--Lavender);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
/* catppuccin mocha */
|
|
||||||
--Rosewater: #f5e0dc;
|
|
||||||
--Flamingo: #f2cdcd;
|
|
||||||
--Pink: #f5c2e7;
|
|
||||||
--Mauve: #cba6f7;
|
|
||||||
--Red: #f38ba8;
|
|
||||||
--Maroon: #eba0ac;
|
|
||||||
--Peach: #fab387;
|
|
||||||
--Yellow: #f9e2af;
|
|
||||||
--Green: #a6e3a1;
|
|
||||||
--Teal: #94e2d5;
|
|
||||||
--Sky: #89dceb;
|
|
||||||
--Sapphire: #74c7ec;
|
|
||||||
--Blue: #89b4fa;
|
|
||||||
--Lavender: #b4befe;
|
|
||||||
--Text: #cdd6f4;
|
|
||||||
--Subtext1: #bac2de;
|
|
||||||
--Subtext0: #a6adc8;
|
|
||||||
--Overlay2: #9399b2;
|
|
||||||
--Overlay1: #7f849c;
|
|
||||||
--Overlay0: #6c7086;
|
|
||||||
--Surface2: #585b70;
|
|
||||||
--Surface1: #45475a;
|
|
||||||
--Surface0: #313244;
|
|
||||||
--Base: #1e1e2e;
|
|
||||||
--Mantle: #181825;
|
|
||||||
--Crust: #11111b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
padding-left: 2ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 1ex 2ex;
|
|
||||||
background-color: var(--Surface0);
|
|
||||||
border: solid 1px var(--Surface2);
|
|
||||||
border-radius: var(--br);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:hover {
|
|
||||||
border: solid 1px var(--Overlay0);
|
|
||||||
/* background-color: var(--Surface0); */
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 1ex 2ex;
|
|
||||||
background-color: var(--Surface0);
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--br);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: var(--Surface1);
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body>header h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body>header {
|
|
||||||
justify-content: space-between;
|
|
||||||
/* border-bottom: solid 1px var(--bg2); */
|
|
||||||
background-color: color-mix(in srgb, var(--Surface0) 30%, transparent);
|
|
||||||
box-shadow: 0 -1px 1ex var(--Mantle);
|
|
||||||
}
|
|
||||||
|
|
||||||
body>header>nav {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
body>header a:not([class]) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1ex 2ex;
|
|
||||||
/* transition: color 0.1s ease-out, background-color 0.1s ease-out; */
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--primary);
|
|
||||||
gap: 0.5ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
main.prose {
|
|
||||||
padding: 1ex;
|
|
||||||
max-width: 80ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background-color: var(--Surface0);
|
|
||||||
padding: 1ex 2ex;
|
|
||||||
text-decoration: none;
|
|
||||||
color: currentColor;
|
|
||||||
box-shadow: 0 -1px 1px var(--Mantle);
|
|
||||||
border-radius: var(--br);
|
|
||||||
}
|
|
||||||
|
|
||||||
body>header a:hover {
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-primary {
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
form>label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5ex;
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
GET {{base}}
|
|
||||||
HTTP 404
|
|
||||||
content-length: 0
|
|
||||||
|
|
||||||
GET {{base}}/hello-world
|
|
||||||
HTTP 200
|
|
||||||
Content-Type: text/html; charset=utf-8
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
|
|
||||||
GET {{base}}/hello-world-text
|
|
||||||
HTTP 200
|
|
||||||
Content-Type: text/plain; charset=utf-8
|
|
||||||
"Hello, world!"
|
|
|
@ -1 +0,0 @@
|
||||||
|
|
121
flake.lock
121
flake.lock
|
@ -1,135 +1,24 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681202837,
|
|
||||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"naersk": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1698153314,
|
|
||||||
"narHash": "sha256-TunvZMCxXHvU6fz5kq3XTLfojIvTDlbFGfPUFtwCU5o=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "naersk",
|
|
||||||
"rev": "06a99941d72e2202ed62b8aa08b9869817fea56f",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"ref": "master",
|
|
||||||
"repo": "naersk",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1697915759,
|
"lastModified": 1720768451,
|
||||||
"narHash": "sha256-WyMj5jGcecD+KC8gEs+wFth1J1wjisZf8kVZH13f1Zo=",
|
"narHash": "sha256-EYekUHJE2gxeo2pM/zM9Wlqw1Uw2XTJXOSAO79ksc4Y=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "51d906d2341c9e866e48c2efcaac0f2d70bfd43e",
|
"rev": "7e7c39ea35c5cdd002cd4588b03a3fb9ece6fad9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"naersk": "naersk",
|
"nixpkgs": "nixpkgs"
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay",
|
|
||||||
"utils": "utils"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1699409596,
|
|
||||||
"narHash": "sha256-L3g1smIol3dGTxkUQOlNShJtZLvjLzvtbaeTRizwZBU=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "58240e1ac627cef3ea30c7732fedfb4f51afd8e7",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1694529238,
|
|
||||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
89
flake.nix
89
flake.nix
|
@ -1,72 +1,35 @@
|
||||||
{
|
{
|
||||||
inputs = {
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
outputs = {
|
||||||
utils.url = "github:numtide/flake-utils";
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
inherit (self) outputs;
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
|
||||||
rust-overlay = {
|
"x86_64-darwin"
|
||||||
url = "github:oxalica/rust-overlay";
|
"aarch64-darwin"
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
|
|
||||||
naersk = {
|
|
||||||
url = "github:nix-community/naersk/master";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = inputs @ {self, ...}:
|
|
||||||
inputs.utils.lib.eachDefaultSystem (system: let
|
|
||||||
pkgs = import inputs.nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
|
|
||||||
overlays = [
|
|
||||||
(import inputs.rust-overlay)
|
|
||||||
];
|
];
|
||||||
};
|
forEachSupportedSystem = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
|
||||||
toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
|
||||||
|
|
||||||
naersk = pkgs.callPackage inputs.naersk {
|
|
||||||
cargo = toolchain;
|
|
||||||
rustc = toolchain;
|
|
||||||
};
|
|
||||||
in {
|
in {
|
||||||
packages.default = naersk.buildPackage {
|
devShells = forEachSupportedSystem (system: let
|
||||||
src = ./.;
|
pkgs = import nixpkgs {inherit system;};
|
||||||
buildInputs = with pkgs; [sqlite];
|
in {
|
||||||
};
|
rust-dev = pkgs.mkShell {
|
||||||
formatter = pkgs.alejandra;
|
buildInputs = with pkgs; [
|
||||||
checks = {
|
rustup
|
||||||
inherit (self.packages.${system}) default;
|
mold
|
||||||
# TODO: clippy and other checks?
|
clang
|
||||||
};
|
|
||||||
devShell = with pkgs;
|
|
||||||
mkShell {
|
|
||||||
buildInputs = [
|
|
||||||
# dedupe from package inputs?
|
|
||||||
sqlite
|
|
||||||
|
|
||||||
toolchain
|
|
||||||
|
|
||||||
rustfmt
|
|
||||||
rustPackages.clippy
|
|
||||||
rust-analyzer
|
|
||||||
nodePackages_latest.vscode-langservers-extracted
|
|
||||||
|
|
||||||
# to install sea-orm-cli
|
|
||||||
pkg-config
|
pkg-config
|
||||||
openssl
|
inotify-tools
|
||||||
|
tailwindcss
|
||||||
hurl
|
nodePackages.typescript-language-server
|
||||||
];
|
];
|
||||||
RUST_SRC_PATH = rustPlatform.rustLibSrc;
|
|
||||||
shellHook = ''
|
|
||||||
export MIGRATION_DIR="src/migrator"
|
|
||||||
export DATABASE_URL="sqlite://./data/lyrs.sqlitedb?mode=rwc";
|
|
||||||
export COOKIE_KEY="2z49_8yfKUkoTOo0cjzzjwufCfhKvfOIc1CGleuTXC5zRqY4U0Xhkd34ipREQN5iHRH62tt5O7y6U5mmFBH3MA"
|
|
||||||
export RUST_BACKTRACE="1"
|
|
||||||
export RUSTFLAGS="--cfg uuid_unstable"
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
default = outputs.devShells.${system}.rust-dev;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
30
readme.md
30
readme.md
|
@ -1,25 +1,9 @@
|
||||||
# Setup
|
# lyricscreen (lyrs)
|
||||||
|
|
||||||
```shell
|
Manage lyrics and live displays for them at shows.
|
||||||
$ direnv allow
|
|
||||||
```
|
# Develop
|
||||||
|
|
||||||
# Running
|
```bash
|
||||||
|
watchexec -e rs,toml -r 'cargo run -- --log-env-filter trace,sled=debug run --watch'
|
||||||
```shell
|
|
||||||
$ cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cargo test
|
|
||||||
cargo run
|
|
||||||
hurl contract.hurl --variable base='http://localhost:3000' --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
# Regenerate Entities
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ sea-orm-cli generate entity -u $DATABASE_URL -o src/entities
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
[toolchain]
|
|
||||||
channel = "1.73"
|
|
|
@ -1,7 +0,0 @@
|
||||||
use crate::{instrumentation, router, server::listen};
|
|
||||||
|
|
||||||
pub async fn run() -> Result<(), anyhow::Error> {
|
|
||||||
instrumentation::init();
|
|
||||||
listen(router::new().await?).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
22
src/auth.rs
Normal file
22
src/auth.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use argon2::PasswordHasher;
|
||||||
|
use argon2::PasswordVerifier;
|
||||||
|
use argon2::{password_hash::SaltString, Argon2, PasswordHash};
|
||||||
|
|
||||||
|
pub fn password_digest<P: AsRef<[u8]>>(
|
||||||
|
password: P,
|
||||||
|
) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
|
||||||
|
Ok(argon2.hash_password(password.as_ref(), &salt)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verified_password<S: AsRef<str>, P: AsRef<[u8]>>(
|
||||||
|
password: P,
|
||||||
|
password_digest: S,
|
||||||
|
) -> Result<(), argon2::password_hash::Error> {
|
||||||
|
Argon2::default().verify_password(
|
||||||
|
password.as_ref(),
|
||||||
|
&PasswordHash::new(password_digest.as_ref())?,
|
||||||
|
)
|
||||||
|
}
|
37
src/cli.rs
Normal file
37
src/cli.rs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
mod admin;
|
||||||
|
mod run;
|
||||||
|
|
||||||
|
use crate::{observe, prelude::*};
|
||||||
|
use prelude::*;
|
||||||
|
|
||||||
|
/// Web application for managing lyrics and live displays
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
#[command(propagate_version = true)]
|
||||||
|
pub struct App {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
|
||||||
|
#[arg(global = true, long, value_enum, default_value = "info,lyrs=trace")]
|
||||||
|
log_env_filter: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Run the web application server
|
||||||
|
Run(run::Run),
|
||||||
|
|
||||||
|
/// Perform administrator actions
|
||||||
|
Admin(admin::Admin),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run() -> AnyResult<()> {
|
||||||
|
let cli = App::parse();
|
||||||
|
observe::setup_logging(&cli.log_env_filter)?;
|
||||||
|
match cli.command {
|
||||||
|
Commands::Run(args) => Ok(args.run().await?),
|
||||||
|
Commands::Admin(args) => Ok(args.run().await?),
|
||||||
|
}
|
||||||
|
}
|
125
src/cli/admin.rs
Normal file
125
src/cli/admin.rs
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
use super::prelude::*;
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::{db::Data, user::User};
|
||||||
|
use color_eyre::eyre::eyre;
|
||||||
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
|
use sled::IVec;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct Admin {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: AdminCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AdminCommands {
|
||||||
|
#[command(subcommand)]
|
||||||
|
Accounts(AccountsCommands),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AccountsCommands {
|
||||||
|
Create(Create),
|
||||||
|
Delete(Delete),
|
||||||
|
List(List),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Admin {
|
||||||
|
pub async fn run(&self) -> AnyResult<()> {
|
||||||
|
match &self.command {
|
||||||
|
AdminCommands::Accounts(accounts) => match accounts {
|
||||||
|
AccountsCommands::Create(args) => Ok(args.run().await?),
|
||||||
|
AccountsCommands::Delete(args) => Ok(args.run().await?),
|
||||||
|
AccountsCommands::List(args) => Ok(args.run().await?),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct List {}
|
||||||
|
impl List {
|
||||||
|
pub async fn run(&self) -> AnyResult<()> {
|
||||||
|
let db = Data::try_new()?;
|
||||||
|
for entry in db.all::<IVec, User>(User::tree())? {
|
||||||
|
if let Ok((_, user)) = entry {
|
||||||
|
println!("ID: {}, Username: {}", user.id, user.username);
|
||||||
|
} else {
|
||||||
|
println!("Invalid user entry when iterating database: {:?}", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct Delete {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
impl Delete {
|
||||||
|
pub async fn run(&self) -> Result<(), color_eyre::Report> {
|
||||||
|
let db = Data::try_new()?;
|
||||||
|
let result = db.delete::<Uuid, User>(User::tree(), self.id)?;
|
||||||
|
if let Some(user) = result {
|
||||||
|
println!(
|
||||||
|
"Deleted user with username: {} (ID: {}",
|
||||||
|
user.username, self.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Err(eyre!("user not found"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a user account
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct Create {
|
||||||
|
/// Whether or not the user is a site super admin
|
||||||
|
#[arg(long, default_value = "false")]
|
||||||
|
pub superadmin: bool,
|
||||||
|
|
||||||
|
/// The email address of the account
|
||||||
|
// #[arg(short = 'e', long)]
|
||||||
|
// pub email_address: String,
|
||||||
|
|
||||||
|
/// The username of the new account
|
||||||
|
#[arg(short = 'u', long)]
|
||||||
|
pub username: String,
|
||||||
|
|
||||||
|
/// The account's initial password - if none is set, a random one will be used and output
|
||||||
|
#[arg(short = 'p', long)]
|
||||||
|
pub initial_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Create {
|
||||||
|
pub async fn run(&self) -> AnyResult<()> {
|
||||||
|
// self.email_address
|
||||||
|
let password: String = if let Some(password) = self.initial_password.as_ref() {
|
||||||
|
password.clone()
|
||||||
|
} else {
|
||||||
|
let generated_password: String = (0..16)
|
||||||
|
.map(|_| thread_rng().sample(Alphanumeric) as char)
|
||||||
|
.collect();
|
||||||
|
println!("Generated password: {generated_password}");
|
||||||
|
generated_password
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = User::try_new(&self.username, &password)?;
|
||||||
|
let db = Data::try_new()?;
|
||||||
|
let existing_user: Option<User> = db.get(User::tree(), &self.username)?;
|
||||||
|
|
||||||
|
// TODO: fail2ban?
|
||||||
|
if existing_user.is_some() {
|
||||||
|
// timing/enumeration attacks or something
|
||||||
|
return Err(eyre!("username already exists: {}", self.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(User::tree(), &user.username, bincode::serialize(&user)?)?;
|
||||||
|
|
||||||
|
println!("Created user with username: {}", self.username);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
3
src/cli/prelude.rs
Normal file
3
src/cli/prelude.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
pub use clap::{Args, Parser, Subcommand};
|
||||||
|
pub use thiserror::Error;
|
38
src/cli/run.rs
Normal file
38
src/cli/run.rs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
use crate::cli::prelude::*;
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::state::State;
|
||||||
|
|
||||||
|
/// Run the web application server
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct Run {
|
||||||
|
/// Whether or not to watch certain resource files for changes and reload accordingly
|
||||||
|
#[arg(short, long, default_value = None)]
|
||||||
|
pub watch: bool,
|
||||||
|
|
||||||
|
/// The address to bind to - you almost certainly want to use :: or 0.0.0.0 instead of the default
|
||||||
|
#[arg(short = 'H', long, default_value = "::1")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// The port to bind to
|
||||||
|
#[arg(short, long, default_value = "3000")]
|
||||||
|
pub port: u16,
|
||||||
|
// The database connection string to use
|
||||||
|
// #[arg(long, default_value = "postgresql://lyrs?host=/var/run/postgresql")]
|
||||||
|
// pub database_connection_string: String,
|
||||||
|
|
||||||
|
// TODO: disallow in production?
|
||||||
|
// Delete all data, recreate new database, run migrations, and seed database
|
||||||
|
// #[arg(long, default_value = "false")]
|
||||||
|
// pub database_reset: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
pub async fn run(&self) -> AnyResult<()> {
|
||||||
|
let app_state = State::try_new().await?;
|
||||||
|
let (router, _watchers) = crate::router::router(app_state, self.watch).await?;
|
||||||
|
Ok(
|
||||||
|
crate::webserver::webserver(router, self.watch, Some(&self.host), Some(self.port))
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
76
src/db.rs
Normal file
76
src/db.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use sled::{Db, IVec, Tree};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Data {
|
||||||
|
db: Db,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Data {
|
||||||
|
pub fn try_new() -> AnyResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
db: sled::open("data/lyrs")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete<K, V>(&self, tree_name: &str, key: K) -> AnyResult<Option<V>>
|
||||||
|
where
|
||||||
|
K: AsRef<[u8]>,
|
||||||
|
V: DeserializeOwned,
|
||||||
|
{
|
||||||
|
match self.db.open_tree(tree_name)?.remove(key.as_ref())? {
|
||||||
|
Some(v) => Ok(Some(bincode::deserialize::<V>(&v)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get<K, V>(&self, tree_name: &str, key: K) -> AnyResult<Option<V>>
|
||||||
|
where
|
||||||
|
K: AsRef<[u8]>,
|
||||||
|
V: DeserializeOwned,
|
||||||
|
{
|
||||||
|
match self.db.open_tree(tree_name)?.get(key.as_ref())? {
|
||||||
|
Some(v) => Ok(Some(bincode::deserialize::<V>(&v)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert<K: AsRef<[u8]>, V: Into<IVec>>(
|
||||||
|
&self,
|
||||||
|
tree_name: &str,
|
||||||
|
key: K,
|
||||||
|
value: V,
|
||||||
|
) -> AnyResult<Option<IVec>> {
|
||||||
|
Ok(self.db.open_tree(tree_name)?.insert(key, value.into())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all<'de, K, V>(
|
||||||
|
&self,
|
||||||
|
tree_name: &str,
|
||||||
|
) -> AnyResult<impl Iterator<Item = AnyResult<(K, V)>>>
|
||||||
|
where
|
||||||
|
V: DeserializeOwned,
|
||||||
|
K: From<IVec>,
|
||||||
|
{
|
||||||
|
Ok(self
|
||||||
|
.db
|
||||||
|
.open_tree(tree_name)?
|
||||||
|
.scan_prefix([])
|
||||||
|
.map(|r| match r {
|
||||||
|
Ok((k, v)) => {
|
||||||
|
let key = K::from(k);
|
||||||
|
match bincode::deserialize::<V>(&v) {
|
||||||
|
Ok(v) => Ok((key, v)),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
})
|
||||||
|
.into_iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tree(&self, name: &str) -> AnyResult<Tree> {
|
||||||
|
Ok(self.db.open_tree(name)?)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
|
||||||
|
|
||||||
pub mod prelude;
|
|
||||||
|
|
||||||
pub mod user;
|
|
|
@ -1,3 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
|
||||||
|
|
||||||
pub use super::user::Entity as User;
|
|
|
@ -1,22 +0,0 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
|
||||||
#[sea_orm(table_name = "user")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(
|
|
||||||
primary_key,
|
|
||||||
auto_increment = false,
|
|
||||||
column_type = "Binary(BlobSize::Blob(None))"
|
|
||||||
)]
|
|
||||||
pub id: Vec<u8>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub username: String,
|
|
||||||
pub password_digest: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
46
src/error.rs
46
src/error.rs
|
@ -1,46 +0,0 @@
|
||||||
use axum::{
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use core::fmt;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum AppError {
|
|
||||||
Database(#[from] sea_orm::error::DbErr),
|
|
||||||
PasswordHash(#[from] password_hash::Error),
|
|
||||||
InvalidCsrf(#[from] axum_csrf::CsrfError),
|
|
||||||
Other(#[from] anyhow::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppError {
|
|
||||||
fn status_code(&self) -> StatusCode {
|
|
||||||
match self {
|
|
||||||
AppError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
AppError::InvalidCsrf(_) => StatusCode::BAD_REQUEST,
|
|
||||||
AppError::PasswordHash(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
AppError::Other(e) => format!("something went wrong: {}", e),
|
|
||||||
AppError::InvalidCsrf(e) => format!("unable to verify csrf: {}", e),
|
|
||||||
AppError::PasswordHash(e) => format!("failed to hash password: {}", e),
|
|
||||||
AppError::Database(e) => format!("database error: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for AppError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
(self.status_code(), self.message()).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
// TODO: perhaps just download all feather icons and include lazily or something?
|
|
||||||
// feather icons
|
|
||||||
pub const FEATHER_ICON_USER_PLUS: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user-plus"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>"#;
|
|
||||||
|
|
||||||
pub const FEATHER_ICON_LAYOUT: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-layout"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>"#;
|
|
||||||
|
|
||||||
pub const FEATHER_ICON_LOGIN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-in"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>"#;
|
|
||||||
|
|
||||||
pub const FEATHER_ICON_USER: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>"#;
|
|
||||||
|
|
||||||
// pub const FEATHER_ICON_: &str = r#""#;
|
|
78
src/file_watcher.rs
Normal file
78
src/file_watcher.rs
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::{sync::mpsc::channel, task::JoinHandle};
|
||||||
|
|
||||||
|
pub type WatcherType = RecommendedWatcher;
|
||||||
|
pub type FileWatcher = (WatcherType, JoinHandle<()>);
|
||||||
|
|
||||||
|
pub mod prelude {
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
pub use super::{file_monitor, file_watcher, FileWatcher};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notifies your callback for each individual event
|
||||||
|
#[instrument(skip(callback))]
|
||||||
|
pub fn file_watcher<P, F>(dir: P, callback: F) -> Result<FileWatcher, notify::Error>
|
||||||
|
where
|
||||||
|
P: AsRef<Path> + std::fmt::Debug,
|
||||||
|
F: Fn(Event) -> () + std::marker::Send + 'static,
|
||||||
|
{
|
||||||
|
// TODO: debounce?
|
||||||
|
let (tx, mut rx) = channel(1);
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res| match res {
|
||||||
|
Ok(e) => futures::executor::block_on(async {
|
||||||
|
trace!("Sending event: {e:?}");
|
||||||
|
tx.send(e).await.unwrap();
|
||||||
|
}),
|
||||||
|
Err(e) => error!("Error from file_watcher: {e}"),
|
||||||
|
},
|
||||||
|
Config::default(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!("watching");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
callback(ev)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.watch(dir.as_ref(), RecursiveMode::Recursive)?;
|
||||||
|
|
||||||
|
Ok((watcher, handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only know when something changes
|
||||||
|
#[instrument(skip(callback))]
|
||||||
|
pub fn file_monitor<P, F>(dir: P, callback: F) -> Result<FileWatcher, notify::Error>
|
||||||
|
where
|
||||||
|
P: AsRef<Path> + std::fmt::Debug,
|
||||||
|
F: Fn() -> () + std::marker::Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, mut rx) = tokio::sync::watch::channel(());
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res| match res {
|
||||||
|
Ok(_e) => futures::executor::block_on(async {
|
||||||
|
tx.send_replace(());
|
||||||
|
}),
|
||||||
|
Err(e) => error!("Error from file_watcher: {e}"),
|
||||||
|
},
|
||||||
|
Config::default(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!("watching");
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
while let Ok(_) = rx.changed().await {
|
||||||
|
callback();
|
||||||
|
// "good enough" debouncing
|
||||||
|
rx.mark_unchanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.watch(dir.as_ref(), RecursiveMode::Recursive)?;
|
||||||
|
|
||||||
|
Ok((watcher, handle))
|
||||||
|
}
|
|
@ -1,18 +0,0 @@
|
||||||
use tracing::{info, instrument};
|
|
||||||
use tracing_subscriber::{filter::LevelFilter, EnvFilter};
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
pub fn init() {
|
|
||||||
color_eyre::install().expect("Failed to install color_eyre");
|
|
||||||
setup_trace_logger();
|
|
||||||
info!("Instrumentation initialized.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
pub fn setup_trace_logger() {
|
|
||||||
let filter = EnvFilter::builder()
|
|
||||||
.with_default_directive(LevelFilter::INFO.into())
|
|
||||||
.parse_lossy("info,lyrs=trace");
|
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
|
||||||
}
|
|
31
src/main.rs
31
src/main.rs
|
@ -1,21 +1,22 @@
|
||||||
//! lyrs entrypoint
|
mod auth;
|
||||||
|
mod cli;
|
||||||
// TODO: Break this module up
|
mod db;
|
||||||
// TODO: Implement authn
|
mod file_watcher;
|
||||||
|
mod model;
|
||||||
mod app;
|
mod observe;
|
||||||
mod entities;
|
|
||||||
mod error;
|
|
||||||
mod feather_icons;
|
|
||||||
mod instrumentation;
|
|
||||||
mod migrator;
|
|
||||||
mod partials;
|
mod partials;
|
||||||
|
mod prelude;
|
||||||
mod router;
|
mod router;
|
||||||
mod server;
|
mod service;
|
||||||
mod state;
|
mod state;
|
||||||
mod views;
|
mod tailwind;
|
||||||
|
mod user;
|
||||||
|
mod uuid;
|
||||||
|
mod webserver;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), anyhow::Error> {
|
async fn main() -> AnyResult<()> {
|
||||||
app::run().await
|
Ok(cli::run().await?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
pub struct Migrator;
|
|
||||||
|
|
||||||
mod m20231114_143300_init;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigratorTrait for Migrator {
|
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
|
||||||
vec![Box::new(m20231114_143300_init::Migration)]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
impl MigrationName for Migration {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"m20231114_143300_init.rs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.create_table(
|
|
||||||
Table::create()
|
|
||||||
.table(User::Table)
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Id)
|
|
||||||
.binary_len(16)
|
|
||||||
.not_null()
|
|
||||||
.primary_key(),
|
|
||||||
)
|
|
||||||
.col(ColumnDef::new(User::Name).string())
|
|
||||||
.col(
|
|
||||||
ColumnDef::new(User::Username)
|
|
||||||
.string()
|
|
||||||
.not_null()
|
|
||||||
.unique_key(),
|
|
||||||
)
|
|
||||||
.col(ColumnDef::new(User::PasswordDigest).text().not_null())
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define how to rollback this migration: Drop the Bakery table.
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Iden)]
|
|
||||||
pub enum User {
|
|
||||||
Table,
|
|
||||||
Id,
|
|
||||||
Name,
|
|
||||||
Username,
|
|
||||||
PasswordDigest,
|
|
||||||
}
|
|
2
src/model.rs
Normal file
2
src/model.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
mod display;
|
||||||
|
mod song;
|
135
src/model/display.rs
Normal file
135
src/model/display.rs
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use super::song::{Plan, Song, Verse};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct PlaylistEntry {
|
||||||
|
pub song: Song,
|
||||||
|
pub plan_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PlaylistVerseRef {
|
||||||
|
pub song_index: usize,
|
||||||
|
pub map_verse_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Display {
|
||||||
|
pub playlist: VecDeque<PlaylistEntry>,
|
||||||
|
current: PlaylistVerseRef,
|
||||||
|
frozen_at: Option<PlaylistVerseRef>,
|
||||||
|
pub blanked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display {
|
||||||
|
pub fn verse_ref(&self) -> &PlaylistVerseRef {
|
||||||
|
self.frozen_at.as_ref().unwrap_or(&self.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verse(&self) -> Option<&Verse> {
|
||||||
|
if self.blanked {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let verse_ref = self.verse_ref();
|
||||||
|
let entry = self.entry(verse_ref)?;
|
||||||
|
entry.song.verses.get(self.verse_name(verse_ref)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self, verse_ref: &PlaylistVerseRef) -> Option<&PlaylistEntry> {
|
||||||
|
self.playlist.get(verse_ref.song_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plan(&self, verse_ref: &PlaylistVerseRef) -> Option<&Plan> {
|
||||||
|
let PlaylistEntry { song, plan_name } = self.entry(verse_ref)?;
|
||||||
|
// TODO: this could "fail silently" and use the default plan
|
||||||
|
Some(song.plan(plan_name.as_deref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verse_name(&self, verse_ref: &PlaylistVerseRef) -> Option<&String> {
|
||||||
|
self.plan(verse_ref)?.get(verse_ref.map_verse_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clamp(&mut self) {
|
||||||
|
// ensure all plan names exist or fallback to defaults
|
||||||
|
for p in self.playlist.iter_mut() {
|
||||||
|
if let Some(plan_name) = p.plan_name.as_mut() {
|
||||||
|
if !p.song.other_plans.contains_key(plan_name) {
|
||||||
|
p.plan_name = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure song index is within bounds or be 0
|
||||||
|
self.current.song_index = self.current.song_index.clamp(0, self.playlist.len());
|
||||||
|
if let Some(frozen_at) = self.frozen_at.as_mut() {
|
||||||
|
frozen_at.song_index = frozen_at.song_index.clamp(0, self.playlist.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure map verse index is within bounds or be 0
|
||||||
|
if let Some(plan) = self.plan(&self.current) {
|
||||||
|
self.current.map_verse_index = self.current.map_verse_index.clamp(0, plan.len());
|
||||||
|
}
|
||||||
|
let new_frozen_map_verse_index = match &self.frozen_at {
|
||||||
|
Some(frozen_at) => {
|
||||||
|
if let Some(plan) = self.plan(&frozen_at) {
|
||||||
|
frozen_at.map_verse_index.clamp(0, plan.len())
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
if let Some(frozen_at) = self.frozen_at.as_mut() {
|
||||||
|
frozen_at.map_verse_index = new_frozen_map_verse_index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn default(playlist: VecDeque<PlaylistEntry>) -> Display {
|
||||||
|
let current = PlaylistVerseRef {
|
||||||
|
song_index: 0,
|
||||||
|
map_verse_index: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
Display {
|
||||||
|
playlist,
|
||||||
|
current,
|
||||||
|
frozen_at: None,
|
||||||
|
blanked: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn two_song_playlist() -> VecDeque<PlaylistEntry> {
|
||||||
|
VecDeque::from([
|
||||||
|
PlaylistEntry {
|
||||||
|
song: "Song1\n\ns1verse1\n\ns1verse2".parse().unwrap(),
|
||||||
|
plan_name: None,
|
||||||
|
},
|
||||||
|
PlaylistEntry {
|
||||||
|
song: "Song2\n\ns2verse1\n\ns2verse2".parse().unwrap(),
|
||||||
|
plan_name: None,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displays_no_verse_when_empty() {
|
||||||
|
let playlist: VecDeque<PlaylistEntry> = VecDeque::from(vec![]);
|
||||||
|
let display = default(playlist);
|
||||||
|
assert_eq!(display.verse(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displays_the_first_verse_when_a_song_is_there() {
|
||||||
|
let display = default(two_song_playlist());
|
||||||
|
assert_eq!(display.verse().unwrap().content, "s1verse1")
|
||||||
|
}
|
||||||
|
}
|
266
src/model/song.rs
Normal file
266
src/model/song.rs
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, VecDeque},
|
||||||
|
str::FromStr,
|
||||||
|
sync::OnceLock,
|
||||||
|
};
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Verse {
|
||||||
|
// pub background: String, // url
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verse {
|
||||||
|
fn new(content: String) -> Self {
|
||||||
|
Self { content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequence of verse names.
|
||||||
|
pub type Plan = VecDeque<String>;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Song {
|
||||||
|
pub name: String,
|
||||||
|
pub verses: BTreeMap<String, Verse>,
|
||||||
|
pub other_plans: BTreeMap<String, Plan>,
|
||||||
|
pub default_plan: Plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Song {
|
||||||
|
pub fn plan(&self, plan_name: Option<&str>) -> &VecDeque<String> {
|
||||||
|
plan_name
|
||||||
|
.map(|plan_name| {
|
||||||
|
self.other_plans
|
||||||
|
.get(plan_name)
|
||||||
|
.unwrap_or(&self.default_plan)
|
||||||
|
})
|
||||||
|
.unwrap_or(&self.default_plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SourceRef {
|
||||||
|
line_number: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SongParseError {
|
||||||
|
EmptyString,
|
||||||
|
InvalidMetadata(SourceRef),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Song {
|
||||||
|
type Err = SongParseError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s == "" {
|
||||||
|
return Err(SongParseError::EmptyString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: some way to encode comments in a song struct so that if/when we
|
||||||
|
// serialize it back into a string format they are preserved?
|
||||||
|
// would probably best be done with an actual AST
|
||||||
|
|
||||||
|
static COMMENT_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||||
|
let comment_re = COMMENT_REGEX.get_or_init(|| Regex::new(r"(?s)\r?\n?#[^\r\n]*").unwrap());
|
||||||
|
|
||||||
|
let s = comment_re.replace_all(s, "").into_owned();
|
||||||
|
|
||||||
|
dbg!(&s);
|
||||||
|
|
||||||
|
static HUNK_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||||
|
let hunk_re = HUNK_REGEX.get_or_init(|| Regex::new(r"\s*[\r\n]\s*[\r\n]\s*").unwrap());
|
||||||
|
|
||||||
|
let mut hunks = VecDeque::new();
|
||||||
|
let mut last_end: usize = 0;
|
||||||
|
|
||||||
|
for m in hunk_re.find_iter(&s) {
|
||||||
|
hunks.push_back(s[last_end..m.start()].trim());
|
||||||
|
last_end = m.end();
|
||||||
|
}
|
||||||
|
hunks.push_back(s[last_end..s.len()].trim());
|
||||||
|
|
||||||
|
// process header
|
||||||
|
let mut header_lines = hunks.pop_front().unwrap().lines().map(|s| s.trim());
|
||||||
|
let name = header_lines.next().unwrap().trim().to_owned();
|
||||||
|
let mut other_plans = BTreeMap::new();
|
||||||
|
|
||||||
|
for (line_number, line) in header_lines.enumerate() {
|
||||||
|
if line.starts_with("plan(") {
|
||||||
|
if let Some(end) = line.find(")") {
|
||||||
|
match line[end..].find(":") {
|
||||||
|
Some(i) => {
|
||||||
|
let plan_name = &line[5..end];
|
||||||
|
let entries: VecDeque<String> = line[(end + i + 1)..]
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_owned())
|
||||||
|
.collect();
|
||||||
|
other_plans.insert(plan_name.to_owned(), entries);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(SongParseError::InvalidMetadata(SourceRef { line_number }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// map(band2): slide1, slide2
|
||||||
|
// band2: slide1, slide2
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut verses = BTreeMap::new();
|
||||||
|
|
||||||
|
let mut default_plan = Plan::new();
|
||||||
|
|
||||||
|
// process verses
|
||||||
|
for hunk in hunks {
|
||||||
|
if hunk.starts_with('(') {
|
||||||
|
if hunk.ends_with(')') && !hunk.contains('\n') {
|
||||||
|
default_plan.push_back(hunk[1..hunk.len() - 1].to_owned());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut verse_contents: &str = hunk;
|
||||||
|
let end_i = hunk.find('\n').unwrap_or(hunk.len());
|
||||||
|
let verse_name: String = if let Some(i) = &hunk[0..end_i].find(':') {
|
||||||
|
verse_contents = &&hunk[end_i + 1..];
|
||||||
|
String::from(&hunk[0..*i])
|
||||||
|
} else {
|
||||||
|
format!("Generated Verse {}", verses.len() + 1).to_owned()
|
||||||
|
};
|
||||||
|
verses.insert(verse_name.clone(), Verse::new(verse_contents.to_owned()));
|
||||||
|
default_plan.push_back(verse_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
name,
|
||||||
|
verses,
|
||||||
|
other_plans,
|
||||||
|
default_plan,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_simple_song() {
|
||||||
|
let song: Song = r#"Song Title
|
||||||
|
|
||||||
|
A verse"#
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(song.name, "Song Title");
|
||||||
|
assert_eq!(
|
||||||
|
song.verses.get("Generated Verse 1"),
|
||||||
|
Some(&Verse {
|
||||||
|
content: "A verse".to_owned()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(song.verses.len(), 1);
|
||||||
|
assert_eq!(song.default_plan[0], "Generated Verse 1");
|
||||||
|
assert_eq!(song.default_plan.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_song_with_comments() {
|
||||||
|
let song: Song = r#"Song Title
|
||||||
|
|
||||||
|
# this is a comment
|
||||||
|
A verse"#
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(song.name, "Song Title");
|
||||||
|
assert_eq!(
|
||||||
|
song.verses.get("Generated Verse 1"),
|
||||||
|
Some(&Verse {
|
||||||
|
content: "A verse".to_owned()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(song.verses.len(), 1);
|
||||||
|
assert_eq!(song.default_plan[0], "Generated Verse 1");
|
||||||
|
assert_eq!(song.default_plan.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_song_with_plan() {
|
||||||
|
let song: Song = r#"Song Title
|
||||||
|
plan(another_plan): Generated Verse 1, Generated Verse 1, Generated Verse 1
|
||||||
|
|
||||||
|
A verse"#
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(song.name, "Song Title");
|
||||||
|
assert_eq!(
|
||||||
|
song.verses.get("Generated Verse 1"),
|
||||||
|
Some(&Verse {
|
||||||
|
content: "A verse".to_owned()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(song.verses.len(), 1);
|
||||||
|
assert_eq!(song.default_plan[0], "Generated Verse 1");
|
||||||
|
assert_eq!(song.default_plan.len(), 1);
|
||||||
|
dbg!(&song.other_plans);
|
||||||
|
assert_eq!(
|
||||||
|
song.other_plans.get("another_plan"),
|
||||||
|
Some(&VecDeque::from(vec![
|
||||||
|
"Generated Verse 1".to_owned(),
|
||||||
|
"Generated Verse 1".to_owned(),
|
||||||
|
"Generated Verse 1".to_owned()
|
||||||
|
]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_song_with_verse_ref() {
|
||||||
|
let song: Song = r#"Title
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
v1:
|
||||||
|
v1content
|
||||||
|
|
||||||
|
|
||||||
|
v2:
|
||||||
|
v2
|
||||||
|
|
||||||
|
(v2)
|
||||||
|
|
||||||
|
(v1)"#
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(song.name, "Title");
|
||||||
|
assert_eq!(
|
||||||
|
song.verses.get("v1"),
|
||||||
|
Some(&Verse {
|
||||||
|
content: "v1content".to_owned()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(song.verses.len(), 2);
|
||||||
|
assert_eq!(song.default_plan[0], "v1");
|
||||||
|
assert_eq!(song.default_plan.len(), 4);
|
||||||
|
dbg!(&song.other_plans);
|
||||||
|
assert_eq!(
|
||||||
|
song.default_plan,
|
||||||
|
VecDeque::from(vec![
|
||||||
|
"v1".to_owned(),
|
||||||
|
"v2".to_owned(),
|
||||||
|
"v2".to_owned(),
|
||||||
|
"v1".to_owned(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
23
src/observe.rs
Normal file
23
src/observe.rs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub fn setup_logging(env_filter: &str) -> Result<(), color_eyre::Report> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
let filter = tracing_subscriber::EnvFilter::builder()
|
||||||
|
.with_default_directive(<tracing_subscriber::filter::Directive>::from(
|
||||||
|
tracing::level_filters::LevelFilter::TRACE,
|
||||||
|
))
|
||||||
|
.parse_lossy(env_filter);
|
||||||
|
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
|
let filter = tracing_subscriber::EnvFilter::builder()
|
||||||
|
.with_default_directive(<tracing_subscriber::filter::Directive>::from(
|
||||||
|
tracing::level_filters::LevelFilter::TRACE,
|
||||||
|
))
|
||||||
|
.parse_lossy(env_filter);
|
||||||
|
|
||||||
|
info!(%filter);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
117
src/partials.rs
117
src/partials.rs
|
@ -1,65 +1,84 @@
|
||||||
use axum_login::AuthSession;
|
use crate::service::accounts::AuthSession;
|
||||||
|
use axum::response::Html;
|
||||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||||
|
|
||||||
use crate::{feather_icons, state};
|
pub fn stylesheet(url: &str) -> Markup {
|
||||||
|
html! {
|
||||||
|
link rel="stylesheet" type="text/css" href=(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn header(sess: &AuthSession<state::State>) -> Markup {
|
pub fn head(page_title: &str) -> Markup {
|
||||||
let is_logged_in = sess.user.is_some();
|
|
||||||
html! {
|
html! {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
head {
|
meta charset="utf-8" {}
|
||||||
link rel="stylesheet" href="/assets/styles.css" {}
|
meta name="viewport" content="width=device-width, initial-scale=1" {}
|
||||||
link rel="icon" href="/assets/favicon.svg" {}
|
title { (page_title) " - lyrs" }
|
||||||
}
|
(stylesheet("/static/style.css"));
|
||||||
body hx-ext="preload" {
|
|
||||||
header class="flex" {
|
script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {}
|
||||||
nav class="flex" {
|
|
||||||
h1 {
|
|
||||||
a href="/" { "lyrs" }
|
|
||||||
}
|
|
||||||
// ul class="flex"
|
|
||||||
// {
|
|
||||||
// a href="/login" { "Login" }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
nav class="flex" {
|
|
||||||
ul class="flex"
|
|
||||||
{
|
|
||||||
// a href="/dashboard" {
|
|
||||||
// (PreEscaped(FEATHER_ICON_LAYOUT))
|
|
||||||
// "Dashboard"
|
|
||||||
// }
|
|
||||||
@if is_logged_in {
|
|
||||||
a href="/account" preload="" {
|
|
||||||
(PreEscaped(feather_icons::FEATHER_ICON_USER))
|
|
||||||
"Account"
|
|
||||||
}
|
|
||||||
a href="/app" preload="" {
|
|
||||||
(PreEscaped(feather_icons::FEATHER_ICON_LAYOUT))
|
|
||||||
"Dashboard"
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
a href="/register" preload="" {
|
|
||||||
(PreEscaped(feather_icons::FEATHER_ICON_USER_PLUS))
|
|
||||||
"Register"
|
|
||||||
}
|
|
||||||
a href="/login" preload="" {
|
|
||||||
(PreEscaped(feather_icons::FEATHER_ICON_LOGIN))
|
|
||||||
"Login"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn foot() -> Markup {
|
||||||
|
html! {
|
||||||
|
footer class="p-2 bg-mantle border-t-2 border-surface0 flex overflow-x-scroll mt-auto" {
|
||||||
|
section {
|
||||||
|
(PreEscaped("© 2024 "))
|
||||||
|
a class="underline text-mauve" href="https://lyte.dev" { "lytedev" }
|
||||||
}
|
}
|
||||||
|
section .ml-auto {"Made with ❤️"}
|
||||||
|
" "
|
||||||
|
a .underline.text-mauve href="/about" { "About" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn footer() -> Markup {
|
pub fn page(title: &str, content: Markup, auth_session: Option<AuthSession>) -> Html<String> {
|
||||||
|
let current_user = auth_session.map(|s| s.user).flatten();
|
||||||
|
Html(
|
||||||
html! {
|
html! {
|
||||||
footer {
|
(head(title))
|
||||||
script src="https://unpkg.com/htmx.org@1.9.8" crossorigin="anonymous" integrity="sha384-rgjA7mptc2ETQqXoYC3/zJvkU7K/aP44Y+z7xQuJiVnB/422P/Ak+F/AqFR7E4Wr" {}
|
body hx-boost="true" class="bg-bg text-text min-h-lvh flex flex-col font-sans overflow-x-hidden" {
|
||||||
script src="https://unpkg.com/htmx.org@1.9.8/dist/ext/preload.js" crossorigin="anonymous" {}
|
header class="drop-shadow border-b-2 border-surface0 bg-mantle flex overflow-x-scroll" {
|
||||||
|
a class="flex p-2 text-3xl font-mono text-mauve opacity-80 hover:bg-mauve hover:text-bg" href="/" { "lyrs" }
|
||||||
|
nav class="flex flex-1 justify-start" {
|
||||||
|
@if let Some(user) = current_user {
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/dashboard" {( user.username )}
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/accounts/logout" { "Logout" }
|
||||||
|
} @else {
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/accounts/login" { "Login" }
|
||||||
|
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/accounts/register" { "Register" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(content)
|
||||||
|
}
|
||||||
|
(foot())
|
||||||
|
}.into_string()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_hero_form(title: &str, content: Markup, subform: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
section class="hero grow" {
|
||||||
|
form class="flex flex-col gap-2 w-full max-w-sm" method="post" {
|
||||||
|
header {
|
||||||
|
h1 class="pb-2 text-center text-xl" { (title) }
|
||||||
|
}
|
||||||
|
(content)
|
||||||
|
input class="bg-mauve" value="Submit" type="submit";
|
||||||
|
}
|
||||||
|
(subform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn labelled_input(label: &str, input: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
label class="flex flex-col" {
|
||||||
|
(label)
|
||||||
|
(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
7
src/prelude.rs
Normal file
7
src/prelude.rs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
|
||||||
|
pub use color_eyre::eyre::eyre;
|
||||||
|
pub use color_eyre::eyre::Error;
|
||||||
|
pub use color_eyre::eyre::Result as AnyResult;
|
||||||
|
pub use std::result::Result;
|
||||||
|
pub use tracing::{debug, error, event, info, instrument, span, trace, warn, Level};
|
322
src/router.rs
322
src/router.rs
|
@ -1,221 +1,147 @@
|
||||||
use crate::entities::{prelude::*, *};
|
use crate::partials::page;
|
||||||
use crate::state;
|
use crate::service::accounts::AuthSession;
|
||||||
use crate::{error::AppError, views};
|
use crate::user::User;
|
||||||
use argon2::password_hash::rand_core::OsRng;
|
use crate::{
|
||||||
use argon2::password_hash::SaltString;
|
file_watcher::FileWatcher,
|
||||||
use argon2::{Argon2, PasswordHasher};
|
prelude::*,
|
||||||
use axum::error_handling::HandleErrorLayer;
|
service::{accounts, static_files},
|
||||||
|
state::State as AppState,
|
||||||
|
};
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::{async_trait, BoxError};
|
use axum::http::StatusCode;
|
||||||
use axum::{http::StatusCode, response::Html, routing::get, Form, Router};
|
use axum::response::{Html, IntoResponse};
|
||||||
use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken};
|
use axum::{routing::get, Router};
|
||||||
use axum_login::tower_sessions::{MemoryStore, SessionManagerLayer};
|
use axum_login::{login_required, AuthManagerLayerBuilder};
|
||||||
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
|
|
||||||
use base64::prelude::*;
|
|
||||||
use maud::html;
|
use maud::html;
|
||||||
use notify::Watcher;
|
use sled::IVec;
|
||||||
use password_hash::{PasswordHash, PasswordVerifier};
|
use tower_http::trace::TraceLayer;
|
||||||
use sea_orm::*;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::{env, path::Path};
|
|
||||||
use tower::ServiceBuilder;
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tower_livereload::LiveReloadLayer;
|
use tower_livereload::LiveReloadLayer;
|
||||||
use tracing::{info, instrument};
|
use tower_sessions::SessionManagerLayer;
|
||||||
|
|
||||||
#[instrument]
|
#[derive(Debug)]
|
||||||
pub async fn new() -> Result<Router, anyhow::Error> {
|
#[allow(dead_code)]
|
||||||
let app_router = Router::new()
|
pub struct WebError(Error);
|
||||||
.route("/hello-world", get(views::greet_world))
|
|
||||||
.route("/hello-world-text", get(views::greet_world_text));
|
|
||||||
|
|
||||||
let assets_dir = ServeDir::new("./assets");
|
impl From<Error> for WebError {
|
||||||
let state = state::State::new().await?;
|
fn from(value: Error) -> Self {
|
||||||
|
Self(value)
|
||||||
let live_reload_layer = LiveReloadLayer::new();
|
|
||||||
let reloader = live_reload_layer.reloader();
|
|
||||||
|
|
||||||
let mut watcher = notify::recommended_watcher(move |_| reloader.reload())?;
|
|
||||||
watcher.watch(Path::new("assets"), notify::RecursiveMode::Recursive)?;
|
|
||||||
|
|
||||||
let cookie_key_bytes: Vec<u8> = BASE64_URL_SAFE_NO_PAD
|
|
||||||
.decode(env::var("COOKIE_KEY").expect("COOKIE_KEY not set"))
|
|
||||||
.expect("COOKIE_KEY not base64 URL-safe encoded");
|
|
||||||
let cookie_key = cookie::Key::from(&cookie_key_bytes);
|
|
||||||
let csrf_config = CsrfConfig::default().with_key(Some(cookie_key));
|
|
||||||
|
|
||||||
let session_store = MemoryStore::default();
|
|
||||||
let login_session_manager =
|
|
||||||
SessionManagerLayer::new(session_store).with_name("login_sessions.sid");
|
|
||||||
|
|
||||||
let auth_service = ServiceBuilder::new()
|
|
||||||
.layer(HandleErrorLayer::new(|_: BoxError| async {
|
|
||||||
StatusCode::BAD_REQUEST
|
|
||||||
}))
|
|
||||||
.layer(AuthManagerLayer::new(state.clone(), login_session_manager));
|
|
||||||
|
|
||||||
let router = Router::new()
|
|
||||||
.fallback(|| async { (StatusCode::NOT_FOUND, "404 page not found") })
|
|
||||||
.nest("/app", app_router)
|
|
||||||
.nest_service("/assets", assets_dir)
|
|
||||||
.route("/", get(views::index))
|
|
||||||
.route("/login", get(views::login).post(login))
|
|
||||||
.route("/register", get(views::register).post(register))
|
|
||||||
.route("/all_users", get(views::all_users))
|
|
||||||
.with_state(state)
|
|
||||||
.layer(CsrfLayer::new(csrf_config))
|
|
||||||
.layer(auth_service)
|
|
||||||
.layer(live_reload_layer);
|
|
||||||
|
|
||||||
Ok(router)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn csrf_verify(c: CsrfToken, t: &str) -> Result<(), AppError> {
|
|
||||||
// TODO: https://docs.rs/axum_csrf/latest/axum_csrf/#prevent-post-replay-attacks-with-csrf
|
|
||||||
c.verify(t)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppRes = Result<(StatusCode, Html<String>), AppError>;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Register {
|
|
||||||
authenticity_token: String,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryInto<user::ActiveModel> for Register {
|
|
||||||
type Error = AppError;
|
|
||||||
|
|
||||||
fn try_into(self) -> Result<user::ActiveModel, Self::Error> {
|
|
||||||
Ok(user::ActiveModel {
|
|
||||||
id: ActiveValue::Set(uuid::Uuid::now_v7().into()),
|
|
||||||
name: ActiveValue::Set(None),
|
|
||||||
username: ActiveValue::Set(self.username),
|
|
||||||
password_digest: ActiveValue::Set(password_digest(self.password)?),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn password_digest<S>(s: S) -> Result<String, password_hash::Error>
|
impl From<bincode::ErrorKind> for WebError {
|
||||||
where
|
fn from(value: bincode::ErrorKind) -> Self {
|
||||||
S: AsRef<str>,
|
Self(value.into())
|
||||||
{
|
|
||||||
Ok(Argon2::default()
|
|
||||||
.hash_password(s.as_ref().as_bytes(), &SaltString::generate(&mut OsRng))?
|
|
||||||
.serialize()
|
|
||||||
.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn password_verify<S>(password: S, current_digest: S) -> Result<(), password_hash::Error>
|
|
||||||
where
|
|
||||||
S: AsRef<str>,
|
|
||||||
{
|
|
||||||
let password_bytes = password.as_ref().as_bytes();
|
|
||||||
let current = PasswordHash::new(current_digest.as_ref())?;
|
|
||||||
Argon2::default().verify_password(password_bytes, ¤t)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl user::ActiveModel {}
|
|
||||||
|
|
||||||
async fn register(c: CsrfToken, State(s): State<state::State>, Form(f): Form<Register>) -> AppRes {
|
|
||||||
csrf_verify(c, &f.authenticity_token)?;
|
|
||||||
|
|
||||||
// TODO: handle duplicate username
|
|
||||||
|
|
||||||
let new: user::ActiveModel = f.try_into()?;
|
|
||||||
let res = User::insert(new).exec(&s.db).await;
|
|
||||||
|
|
||||||
info!("insert new user result: {:?}", res);
|
|
||||||
|
|
||||||
res.map_err(anyhow::Error::from)?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::CREATED,
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
// h1 { (new_user.username) }
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
|
||||||
pub struct Login {
|
|
||||||
authenticity_token: String,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthSession = axum_login::AuthSession<state::State>;
|
|
||||||
|
|
||||||
impl AuthUser for user::Model {
|
|
||||||
type Id = uuid::Uuid;
|
|
||||||
|
|
||||||
fn id(&self) -> Self::Id {
|
|
||||||
uuid::Uuid::try_from(self.id.clone()).expect("failed to convert user ID to UUID")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_auth_hash(&self) -> &[u8] {
|
|
||||||
self.password_digest.as_bytes()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
impl From<Box<bincode::ErrorKind>> for WebError {
|
||||||
impl AuthnBackend for state::State {
|
fn from(value: Box<bincode::ErrorKind>) -> Self {
|
||||||
type User = user::Model;
|
Self(value.into())
|
||||||
type Credentials = Login;
|
|
||||||
type Error = AppError;
|
|
||||||
|
|
||||||
async fn authenticate(&self, l: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
|
|
||||||
Ok(User::find()
|
|
||||||
.filter(user::Column::Username.eq(l.username))
|
|
||||||
// TODO: will this have index problems since I'm searching over the password digest?
|
|
||||||
.one(&self.db)
|
|
||||||
.await?
|
|
||||||
.filter(|u| {
|
|
||||||
password_verify(&l.password, &u.password_digest)
|
|
||||||
.ok()
|
|
||||||
.is_some()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
|
||||||
Ok(User::find_by_id(*user_id).one(&self.db).await?)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(mut auth: AuthSession, c: CsrfToken, Form(f): Form<Login>) -> AppRes {
|
impl IntoResponse for WebError {
|
||||||
csrf_verify(c, &f.authenticity_token)?;
|
fn into_response(self) -> axum::http::Response<axum::body::Body> {
|
||||||
|
error!("webserver error: {:?}", self);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let user = match auth.authenticate(f.clone()).await {
|
pub type WebResult<T> = Result<T, WebError>;
|
||||||
Ok(Some(user)) => user,
|
|
||||||
Ok(None) => return Ok((StatusCode::UNAUTHORIZED, Html("user not found".to_string()))),
|
pub async fn router(
|
||||||
Err(e) => {
|
state: AppState,
|
||||||
return Ok((
|
with_watchers: bool,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
) -> AnyResult<(Router, Vec<Option<FileWatcher>>)> {
|
||||||
Html(format!("failed to authenticate user: {}", e)),
|
let live_reload_layer: Option<LiveReloadLayer> = if with_watchers {
|
||||||
))
|
Some(LiveReloadLayer::new())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let orl = || {
|
||||||
|
if let Some(lr) = &live_reload_layer {
|
||||||
|
Some(lr.reloader())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = auth.login(&user).await {
|
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
|
||||||
return Ok((
|
let accounts_service = accounts::router(state.clone()).unwrap();
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Html(format!("failed to login user: {}", e)),
|
let session_store = tower_sessions_sled_store::SledStore::new(state.db.tree("session")?);
|
||||||
));
|
let session_layer = SessionManagerLayer::new(session_store);
|
||||||
|
|
||||||
|
let auth_layer = AuthManagerLayerBuilder::new(state.clone(), session_layer).build();
|
||||||
|
|
||||||
|
let mut result = Router::new()
|
||||||
|
.route("/dashboard", get(dashboard))
|
||||||
|
.route_layer(login_required!(AppState, login_url = "/accounts/login"))
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/about", get(about))
|
||||||
|
.route("/users", get(users))
|
||||||
|
// TODO: admin-only pages
|
||||||
|
.nest_service("/accounts", accounts_service)
|
||||||
|
.nest_service("/static", static_file_service)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(auth_layer)
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
if let Some(lr) = live_reload_layer {
|
||||||
|
result = result.clone().layer(lr);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
let watchers = vec![static_file_watcher];
|
||||||
StatusCode::OK,
|
|
||||||
Html(
|
Ok((result, watchers))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(auth_session: Option<AuthSession>) -> Html<String> {
|
||||||
|
page(
|
||||||
|
"index",
|
||||||
html! {
|
html! {
|
||||||
h1 { (f.username) }
|
main class="p-2" {
|
||||||
h1 { (f.password) }
|
h1 class="text-2xl" { "Index" }
|
||||||
|
p class="mt-2" {
|
||||||
|
"Here, we explain to you why you may like this web application."
|
||||||
}
|
}
|
||||||
.into_string(),
|
}
|
||||||
),
|
},
|
||||||
))
|
auth_session,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn about(auth_session: Option<AuthSession>) -> Html<String> {
|
||||||
|
page(
|
||||||
|
"about",
|
||||||
|
html! {
|
||||||
|
main class="p-2" {
|
||||||
|
h1 class="text-2xl" { "About" }
|
||||||
|
p class="mt-2" {
|
||||||
|
"Here, we give a little history on why we made this."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auth_session,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dashboard(auth_session: Option<AuthSession>) -> Html<String> {
|
||||||
|
page(
|
||||||
|
"dashboard",
|
||||||
|
html! {
|
||||||
|
div class="p-2" { "Dashboard" }
|
||||||
|
},
|
||||||
|
auth_session,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn users(State(state): State<AppState>) -> WebResult<String> {
|
||||||
|
let mut s = String::new();
|
||||||
|
let mut users = state.db.all::<IVec, User>(User::tree())?;
|
||||||
|
while let Some(Ok((_, user))) = users.next() {
|
||||||
|
s.push_str(&format!("{}: {:?}", user.username, user.registered_at))
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
pub async fn listen(r: Router) {
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
|
||||||
info!("Listening on '{}'", addr);
|
|
||||||
axum::Server::bind(&addr)
|
|
||||||
.serve(r.into_make_service())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
2
src/service.rs
Normal file
2
src/service.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod accounts;
|
||||||
|
pub mod static_files;
|
125
src/service/accounts.rs
Normal file
125
src/service/accounts.rs
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::router::WebResult;
|
||||||
|
use crate::state::{Creds, State as AppState};
|
||||||
|
use crate::{partials::*, user::User};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect};
|
||||||
|
use axum::Form;
|
||||||
|
use color_eyre::eyre::eyre;
|
||||||
|
use maud::html;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Result<Router, Infallible> {
|
||||||
|
Ok(Router::new()
|
||||||
|
.route("/login", get(login))
|
||||||
|
.route("/login", post(authenticate))
|
||||||
|
.route("/register", get(register))
|
||||||
|
.route("/register", post(create_user))
|
||||||
|
.route("/logout", get(logout))
|
||||||
|
.with_state(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(auth_session: Option<AuthSession>) -> impl IntoResponse {
|
||||||
|
if auth_session.map(|s| s.user).flatten().is_some() {
|
||||||
|
return Redirect::to("/dashboard").into_response();
|
||||||
|
}
|
||||||
|
let form = html! {
|
||||||
|
(labelled_input("Username", html!{
|
||||||
|
input class="input" type="text" name="username" autocomplete="username" required;
|
||||||
|
}))
|
||||||
|
(labelled_input("Password", html!{
|
||||||
|
input class="input" type="password" name="password" autocomplete="current-password" required;
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let subaction = html! {
|
||||||
|
small class="mt-4" {
|
||||||
|
"Need an account? "
|
||||||
|
a href="/accounts/register" {"Get one"}
|
||||||
|
"."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page("login", center_hero_form("Login", form, subaction), None).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register(auth_session: Option<AuthSession>) -> impl IntoResponse {
|
||||||
|
if auth_session.map(|s| s.user).flatten().is_some() {
|
||||||
|
return Redirect::to("/dashboard").into_response();
|
||||||
|
}
|
||||||
|
let form = html! {
|
||||||
|
(labelled_input("Username", html!{
|
||||||
|
input class="input" type="text" name="username" required;
|
||||||
|
}))
|
||||||
|
(labelled_input("Password", html!{
|
||||||
|
input class="input" type="password" name="password" autocomplete="new-password" required;
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let subaction = html! {
|
||||||
|
small class="mt-4" {
|
||||||
|
"Already have an account? "
|
||||||
|
a href="/accounts/login" {"Login"}
|
||||||
|
"."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page("login", center_hero_form("Register", form, subaction), None).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AuthSession = axum_login::AuthSession<AppState>;
|
||||||
|
|
||||||
|
#[instrument(skip(auth_session))]
|
||||||
|
async fn authenticate(
|
||||||
|
mut auth_session: AuthSession,
|
||||||
|
Form(creds): Form<Creds>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user = match auth_session.authenticate(creds.clone()).await {
|
||||||
|
Ok(Some(user)) => user,
|
||||||
|
Ok(None) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if auth_session.login(&user).await.is_err() {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/dashboard").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state))]
|
||||||
|
async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(creds): Form<Creds>,
|
||||||
|
) -> WebResult<Html<String>> {
|
||||||
|
let user = User::try_new(&creds.username, creds.password.expose_secret())?;
|
||||||
|
let existing_user: Option<User> = state.db.get(User::tree(), &creds.username)?;
|
||||||
|
|
||||||
|
// TODO: fail2ban?
|
||||||
|
if existing_user.is_some() {
|
||||||
|
// timing/enumeration attacks or something
|
||||||
|
return Err(eyre!("username exists: {}", creds.username).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.insert(User::tree(), &user.username, bincode::serialize(&user)?)?;
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
html! {
|
||||||
|
({&user.username}) " has been registered"
|
||||||
|
}
|
||||||
|
.into_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
|
||||||
|
match auth_session.logout().await {
|
||||||
|
Ok(Some(_u)) => Redirect::to("/accounts/login?done").into_response(),
|
||||||
|
Ok(None) => Redirect::to("/accounts/login?unauthenticated").into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
31
src/service/static_files.rs
Normal file
31
src/service/static_files.rs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
use crate::{
|
||||||
|
file_watcher::{file_monitor, prelude::*},
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_livereload::Reloader;
|
||||||
|
|
||||||
|
fn static_file_dir() -> &'static PathBuf {
|
||||||
|
static STATIC_FILE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
STATIC_FILE_DIR.get_or_init(|| PathBuf::from_str("static").unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(reloader: Option<Reloader>) -> Result<(Router, Option<FileWatcher>), notify::Error> {
|
||||||
|
let watcher = if let Some(rl) = reloader {
|
||||||
|
// TODO: debounce?
|
||||||
|
Some(file_monitor(static_file_dir(), move || {
|
||||||
|
info!("Static File Watcher Event");
|
||||||
|
rl.reload()
|
||||||
|
})?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = Router::new().nest_service("/", ServeDir::new(static_file_dir()));
|
||||||
|
Ok((router, watcher))
|
||||||
|
}
|
91
src/state.rs
91
src/state.rs
|
@ -1,22 +1,85 @@
|
||||||
use std::env;
|
use crate::{db::Data, prelude::*, user::User};
|
||||||
|
use axum::async_trait;
|
||||||
use sea_orm::{Database, DatabaseConnection};
|
use axum_login::{AuthnBackend, UserId};
|
||||||
use sea_orm_migration::MigratorTrait;
|
use redact::Secret;
|
||||||
|
use serde::Deserialize;
|
||||||
use crate::migrator::Migrator;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub db: DatabaseConnection,
|
pub db: Data,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
pub async fn new() -> Result<Self, anyhow::Error> {
|
pub async fn try_new() -> AnyResult<Self> {
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
Ok(Self {
|
||||||
|
db: Data::try_new()?,
|
||||||
let db = Database::connect(database_url).await?;
|
})
|
||||||
Migrator::refresh(&db).await?;
|
}
|
||||||
|
}
|
||||||
Ok(State { db })
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct Creds {
|
||||||
|
pub username: String,
|
||||||
|
pub password: Secret<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AuthnError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AuthnError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("authentication error");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Error> for AuthnError {
|
||||||
|
fn from(_value: Error) -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AuthnError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"description() is deprecated; use Display"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cause(&self) -> Option<&dyn std::error::Error> {
|
||||||
|
self.source()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provide<'a>(&'a self, request: &mut std::error::Request<'a>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthnBackend for State {
|
||||||
|
type User = User;
|
||||||
|
type Credentials = Creds;
|
||||||
|
type Error = AuthnError;
|
||||||
|
|
||||||
|
async fn authenticate(
|
||||||
|
&self,
|
||||||
|
Creds { username, password }: Self::Credentials,
|
||||||
|
) -> Result<Option<Self::User>, Self::Error> {
|
||||||
|
if let Some(user) = self.db.get::<String, User>(User::tree(), username)? {
|
||||||
|
if let Err(err) = user.verify(password.expose_secret()) {
|
||||||
|
Err(err.into())
|
||||||
|
} else {
|
||||||
|
Ok(Some(user))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, username: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||||
|
match self.db.get::<&str, User>(User::tree(), username)? {
|
||||||
|
Some(user) => Ok(Some(user)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
127
src/style.css
Normal file
127
src/style.css
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
@tailwind base;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* main a[href] { */
|
||||||
|
/* @apply text-mauve underline; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
/* input { */
|
||||||
|
/* @apply opacity-80 hover:opacity-100 p-2 border-2 bg-bg border-surface2 rounded; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
/* button, */
|
||||||
|
/* input[type=submit] { */
|
||||||
|
/* @apply opacity-80 hover:opacity-100 p-2 border-0 rounded cursor-pointer text-bg; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
/* .hero { */
|
||||||
|
/* @apply flex flex-col p-2 justify-center items-center relative; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
/* .card { */
|
||||||
|
/* @apply flex flex-col drop-shadow-xl border-2 border-surface2 rounded p-2 gap-2; */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-regular.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-italic.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-bold.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: iosevkalyte;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local("Iosevka"), url("/static/font/iosevkalyteweb-bolditalic.subset.woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Catppuccin Mocha */
|
||||||
|
--Rosewater: #f5e0dc;
|
||||||
|
--Flamingo: #f2cdcd;
|
||||||
|
--Pink: #f5c2e7;
|
||||||
|
--Mauve: #cba6f7;
|
||||||
|
--Red: #f38ba8;
|
||||||
|
--Maroon: #eba0ac;
|
||||||
|
--Peach: #fab387;
|
||||||
|
--Yellow: #f9e2af;
|
||||||
|
--Green: #a6e3a1;
|
||||||
|
--Teal: #94e2d5;
|
||||||
|
--Sky: #89dceb;
|
||||||
|
--Sapphire: #74c7ec;
|
||||||
|
--Blue: #89b4fa;
|
||||||
|
--Lavender: #b4befe;
|
||||||
|
--Text: #cdd6f4;
|
||||||
|
--Subtext1: #bac2de;
|
||||||
|
--Subtext0: #a6adc8;
|
||||||
|
--Overlay2: #9399b2;
|
||||||
|
--Overlay1: #7f849c;
|
||||||
|
--Overlay0: #6c7086;
|
||||||
|
--Surface2: #585b70;
|
||||||
|
--Surface1: #45475a;
|
||||||
|
--Surface0: #313244;
|
||||||
|
--Base: #1e1e2e;
|
||||||
|
--Mantle: #181825;
|
||||||
|
--Crust: #11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
/* Catppuccin Latte */
|
||||||
|
--Rosewater: #dc8a78;
|
||||||
|
--Flamingo: #dd7878;
|
||||||
|
--Pink: #ea76cb;
|
||||||
|
--Mauve: #8839ef;
|
||||||
|
--Red: #d20f39;
|
||||||
|
--Maroon: #e64553;
|
||||||
|
--Peach: #fe640b;
|
||||||
|
--Yellow: #df8e1d;
|
||||||
|
--Green: #40a02b;
|
||||||
|
--Teal: #179299;
|
||||||
|
--Sky: #04a5e5;
|
||||||
|
--Sapphire: #209fb5;
|
||||||
|
--Blue: #1e66f5;
|
||||||
|
--Lavender: #7287fd;
|
||||||
|
--Text: #4c4f69;
|
||||||
|
--Subtext1: #5c5f77;
|
||||||
|
--Subtext0: #6c6f85;
|
||||||
|
--Overlay2: #7c7f93;
|
||||||
|
--Overlay1: #8c8fa1;
|
||||||
|
--Overlay0: #9ca0b0;
|
||||||
|
--Surface2: #acb0be;
|
||||||
|
--Surface1: #bcc0cc;
|
||||||
|
--Surface0: #ccd0da;
|
||||||
|
--Base: #eff1f5;
|
||||||
|
--Mantle: #e6e9ef;
|
||||||
|
--Crust: #dce0e8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:autofill,
|
||||||
|
:-webkit-autofill {
|
||||||
|
filter: none !important;
|
||||||
|
background-color: #f00 !important;
|
||||||
|
}
|
38
src/tailwind.rs
Normal file
38
src/tailwind.rs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
pub fn start_watcher() {
|
||||||
|
info!("starting");
|
||||||
|
match Command::new("tailwindcss")
|
||||||
|
.args(["-i", "src/style.css", "-o", "static/style.css", "--watch"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut tw) => {
|
||||||
|
info!("spawned");
|
||||||
|
let mut stdout_reader = BufReader::new(tw.stdout.take().unwrap()).lines();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||||
|
if l.trim().len() > 0 {
|
||||||
|
event!(target: "tailwind::stdout", Level::INFO, "{l}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut stderr_reader = BufReader::new(tw.stderr.take().unwrap()).lines();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||||
|
if l.trim().len() > 0 {
|
||||||
|
event!(target: "tailwind::stderr", Level::INFO, "{l}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => error!("Failed to spawn Tailwind: {e}"),
|
||||||
|
}
|
||||||
|
}
|
61
src/user.rs
Normal file
61
src/user.rs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
use crate::{prelude::*, uuid};
|
||||||
|
use axum_login::AuthUser;
|
||||||
|
use chrono::Utc;
|
||||||
|
use redact::{expose_secret, Secret};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const USER_TREE: &str = "user";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
|
||||||
|
#[serde(serialize_with = "expose_secret")]
|
||||||
|
password_digest: Secret<String>,
|
||||||
|
|
||||||
|
pub registered_at: chrono::DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub const fn tree() -> &'static str {
|
||||||
|
USER_TREE
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_new(username: &str, password: &str) -> AnyResult<Self> {
|
||||||
|
let now = Utc::now();
|
||||||
|
Ok(Self {
|
||||||
|
id: uuid::v7(now),
|
||||||
|
username: username.to_owned(),
|
||||||
|
registered_at: now,
|
||||||
|
password_digest: Secret::new(crate::auth::password_digest(password)?.into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(&self, password: &str) -> AnyResult<()> {
|
||||||
|
Ok(crate::auth::verified_password(
|
||||||
|
password,
|
||||||
|
self.password_digest.expose_secret(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<sled::IVec> for User {
|
||||||
|
type Error = bincode::Error;
|
||||||
|
fn try_from(value: sled::IVec) -> Result<Self, Self::Error> {
|
||||||
|
bincode::deserialize(&value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthUser for User {
|
||||||
|
type Id = String;
|
||||||
|
|
||||||
|
fn id(&self) -> Self::Id {
|
||||||
|
self.username.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_auth_hash(&self) -> &[u8] {
|
||||||
|
self.password_digest.expose_secret().as_bytes()
|
||||||
|
}
|
||||||
|
}
|
28
src/uuid.rs
Normal file
28
src/uuid.rs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
|
pub use uuid::Uuid;
|
||||||
|
use uuid::{NoContext, Timestamp};
|
||||||
|
|
||||||
|
fn now() -> Timestamp {
|
||||||
|
from_datetime(Utc::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_datetime<T>(ts: DateTime<T>) -> Timestamp
|
||||||
|
where
|
||||||
|
T: TimeZone,
|
||||||
|
{
|
||||||
|
Timestamp::from_unix(
|
||||||
|
NoContext,
|
||||||
|
u64::from_ne_bytes(ts.timestamp().to_ne_bytes()),
|
||||||
|
ts.timestamp_subsec_micros(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn v7_now() -> Uuid {
|
||||||
|
Uuid::new_v7(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn v7<T: TimeZone>(dt: DateTime<T>) -> Uuid {
|
||||||
|
Uuid::new_v7(from_datetime(dt))
|
||||||
|
}
|
166
src/views.rs
166
src/views.rs
|
@ -1,166 +0,0 @@
|
||||||
use crate::entities::{prelude::*, *};
|
|
||||||
use crate::{
|
|
||||||
error::AppError,
|
|
||||||
partials::{footer, header},
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
http::StatusCode,
|
|
||||||
response::{Html, IntoResponse},
|
|
||||||
};
|
|
||||||
use axum_csrf::CsrfToken;
|
|
||||||
use axum_login::AuthSession;
|
|
||||||
use maud::html;
|
|
||||||
use sea_orm::EntityTrait;
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
pub async fn csrf<F>(csrf: CsrfToken, cb: F) -> impl IntoResponse
|
|
||||||
where
|
|
||||||
F: Fn(&str) -> Html<String>,
|
|
||||||
{
|
|
||||||
let token = csrf.authenticity_token().unwrap();
|
|
||||||
(csrf, cb(&token))
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppRes = Result<(StatusCode, Html<String>), AppError>;
|
|
||||||
|
|
||||||
pub async fn index(sess: AuthSession<state::State>) -> Html<String> {
|
|
||||||
let is_logged_in = sess.user.is_some();
|
|
||||||
// let username = sess
|
|
||||||
// .user
|
|
||||||
// .map(|u| u.username)
|
|
||||||
// .unwrap_or_else(|| "N/A".to_owned());
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
(header(&sess))
|
|
||||||
main class="prose" {
|
|
||||||
h1 { "Manage live lyrics and music displays" }
|
|
||||||
p { "Stop struggling to share the same messy set of PowerPoint files or Google Presentations. Make editing and controlling your live lyrics and music displays easy and simple." }
|
|
||||||
ul {
|
|
||||||
li { "Live collaboration with your team" }
|
|
||||||
li { "Fully compatible with any device" }
|
|
||||||
li { "Simple workflow" }
|
|
||||||
li { "Dark theme and light theme" }
|
|
||||||
li { "Generous free plan" }
|
|
||||||
li { "Lightweight and fast" }
|
|
||||||
}
|
|
||||||
section class="flex gap" {
|
|
||||||
@if is_logged_in {
|
|
||||||
a href="/app" class="button bg-primary" { "Open app" }
|
|
||||||
} @else {
|
|
||||||
a href="/register" class="button bg-primary" { "Try now" }
|
|
||||||
a class="button" href="/login" { "Login" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(footer())
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register(sess: AuthSession<state::State>, t: CsrfToken) -> impl IntoResponse {
|
|
||||||
csrf(t, move |token| {
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
(header(&sess))
|
|
||||||
main class="prose" {
|
|
||||||
h1 { "Register an account" }
|
|
||||||
form method="post" {
|
|
||||||
input type="hidden" name="authenticity_token" value=(token) {}
|
|
||||||
label {
|
|
||||||
"Username:"
|
|
||||||
input name="username" {}
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
"Password:"
|
|
||||||
input type="password" name="password" {}
|
|
||||||
}
|
|
||||||
button type="submit" { "Create Account" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(footer())
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(sess: AuthSession<state::State>, t: CsrfToken) -> impl IntoResponse {
|
|
||||||
csrf(t, move |token| {
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
(header(&sess))
|
|
||||||
main class="prose" {
|
|
||||||
h1 { "Login" }
|
|
||||||
form method="post" {
|
|
||||||
input type="hidden" name="authenticity_token" value=(token) {}
|
|
||||||
label {
|
|
||||||
"Username:"
|
|
||||||
input name="username" {}
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
"Password:"
|
|
||||||
input type="password" name="password" {}
|
|
||||||
}
|
|
||||||
button type="submit" { "Login" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(footer())
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn all_users(sess: AuthSession<state::State>, State(s): State<state::State>) -> AppRes {
|
|
||||||
let users: Vec<user::Model> = User::find().all(&s.db).await?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
(header(&sess))
|
|
||||||
main class="prose" {
|
|
||||||
h1 { "Users" }
|
|
||||||
ul {
|
|
||||||
@if users.is_empty() {
|
|
||||||
li { "It looks like there are no users yet!" }
|
|
||||||
} @else {
|
|
||||||
@for u in users {
|
|
||||||
li {
|
|
||||||
(u.username)
|
|
||||||
@if let Some(name) = u.name {
|
|
||||||
" ("
|
|
||||||
(name)
|
|
||||||
")"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(footer())
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
pub async fn greet_world() -> Html<String> {
|
|
||||||
Html(
|
|
||||||
html! {
|
|
||||||
h1 { (greet_world_text().await) }
|
|
||||||
}
|
|
||||||
.into_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
pub async fn greet_world_text() -> &'static str {
|
|
||||||
"Hello, World!"
|
|
||||||
}
|
|
23
src/webserver.rs
Normal file
23
src/webserver.rs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::{prelude::*, tailwind};
|
||||||
|
use axum::{serve, Router};
|
||||||
|
|
||||||
|
#[instrument(skip(router))]
|
||||||
|
pub async fn webserver(
|
||||||
|
router: Router,
|
||||||
|
with_watchers: bool,
|
||||||
|
host: Option<&str>,
|
||||||
|
port: Option<u16>,
|
||||||
|
) -> Result<(), io::Error> {
|
||||||
|
if with_watchers {
|
||||||
|
tokio::spawn(async move { tailwind::start_watcher() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind((host.unwrap_or("::1"), port.unwrap_or(3000)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let addr = listener.local_addr()?;
|
||||||
|
info!(%addr);
|
||||||
|
Ok(serve(listener, router).await?)
|
||||||
|
}
|
BIN
static/font/iosevkalyteweb-bold.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-bold.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-bolditalic.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-bolditalic.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-italic.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-italic.subset.woff2
Normal file
Binary file not shown.
BIN
static/font/iosevkalyteweb-regular.subset.woff2
Normal file
BIN
static/font/iosevkalyteweb-regular.subset.woff2
Normal file
Binary file not shown.
42
tailwind.config.js
Normal file
42
tailwind.config.js
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['ui-sans-serif', 'system-ui', 'sans-serif', "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"],
|
||||||
|
mono: ['iosevkalyte', 'ui-monospace', 'monospace'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
rosewater: "var(--Rosewater)",
|
||||||
|
flamingo: "var(--Flamingo)",
|
||||||
|
pink: "var(--Pink)",
|
||||||
|
mauve: "var(--Mauve)",
|
||||||
|
red: "var(--Red)",
|
||||||
|
maroon: "var(--Maroon)",
|
||||||
|
peach: "var(--Peach)",
|
||||||
|
yellow: "var(--Yellow)",
|
||||||
|
green: "var(--Green)",
|
||||||
|
teal: "var(--Teal)",
|
||||||
|
sky: "var(--Sky)",
|
||||||
|
sapphire: "var(--Sapphire)",
|
||||||
|
blue: "var(--Blue)",
|
||||||
|
lavender: "var(--Lavender)",
|
||||||
|
text: "var(--Text)",
|
||||||
|
subtext1: "var(--Subtext1)",
|
||||||
|
subtext0: "var(--Subtext0)",
|
||||||
|
overlay2: "var(--Overlay2)",
|
||||||
|
overlay1: "var(--Overlay1)",
|
||||||
|
overlay0: "var(--Overlay0)",
|
||||||
|
surface2: "var(--Surface2)",
|
||||||
|
surface1: "var(--Surface1)",
|
||||||
|
surface0: "var(--Surface0)",
|
||||||
|
bg: "var(--Base)",
|
||||||
|
mantle: "var(--Mantle)",
|
||||||
|
crust: "var(--Crust)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue