Compare commits

...

No commits in common. "master" and "main" have entirely different histories.
master ... main

53 changed files with 2452 additions and 2957 deletions

View file

@ -1,3 +1,10 @@
[env]
RUST_BACKTRACE = "1"
RUSTFLAGS = "--cfg uuid_unstable"
[build]
target = "x86_64-unknown-linux-musl"
[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
View file

@ -1,3 +1,10 @@
/target
/.direnv
*.sqlitedb
/static/style.css
/data
# Added by cargo
#
# already existing elements were commented out
#/target

2577
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,35 +3,50 @@ name = "lyrs"
version = "0.1.0"
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]
anyhow = "1.0.75"
# web
axum = { version = "0.6.20", features = ["headers"] }
axum_csrf = { version = "0.8.0", features = ["layer"] }
base64 = "0.21.5"
cookie = "0.18.0"
maud = "0.25.0"
serde = { version = "1.0.192", features = ["derive"] }
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
argon2 = { version = "0.5.3", features = ["std"] }
axum-login = "0.15.3"
bincode = "1.3.3"
chrono = { version = "0.4.38", features = ["serde"] }
clap = { version = "4.5.4", features = ["derive", "env"] }
color-eyre = "0.6.3"
config = "0.14.0"
futures = "0.3.30"
maud = "0.26.0"
notify = "6.1.1"
tower-livereload = "0.8.2"
argon2 = { version = "0.5.2", features = ["std"] }
thiserror = "1.0.50"
axum-macros = "0.3.8"
color-eyre = "0.6.2"
pathdiff = "0.2.1"
rand = "0.8.5"
redact = { version = "0.1.10", features = ["serde"] }
regex = { version = "1.10.5" }
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"

View file

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

View file

@ -1,3 +0,0 @@
fn main() {
println!("cargo:rerun-if-changed=./migrations");
}

View file

@ -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!"

View file

@ -1 +0,0 @@

View file

@ -1,135 +1,24 @@
{
"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": {
"locked": {
"lastModified": 1697915759,
"narHash": "sha256-WyMj5jGcecD+KC8gEs+wFth1J1wjisZf8kVZH13f1Zo=",
"lastModified": 1720768451,
"narHash": "sha256-EYekUHJE2gxeo2pM/zM9Wlqw1Uw2XTJXOSAO79ksc4Y=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "51d906d2341c9e866e48c2efcaac0f2d70bfd43e",
"rev": "7e7c39ea35c5cdd002cd4588b03a3fb9ece6fad9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"naersk": "naersk",
"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"
"nixpkgs": "nixpkgs"
}
}
},

View file

@ -1,72 +1,35 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
utils.url = "github:numtide/flake-utils";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = {
self,
nixpkgs,
}: let
inherit (self) outputs;
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
rust-overlay = {
url = "github:oxalica/rust-overlay";
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)
"x86_64-darwin"
"aarch64-darwin"
];
forEachSupportedSystem = nixpkgs.lib.genAttrs supportedSystems;
in {
devShells = forEachSupportedSystem (system: let
pkgs = import nixpkgs {inherit system;};
in {
rust-dev = pkgs.mkShell {
buildInputs = with pkgs; [
rustup
mold
clang
pkg-config
inotify-tools
tailwindcss
nodePackages.typescript-language-server
];
};
toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
naersk = pkgs.callPackage inputs.naersk {
cargo = toolchain;
rustc = toolchain;
};
in {
packages.default = naersk.buildPackage {
src = ./.;
buildInputs = with pkgs; [sqlite];
};
formatter = pkgs.alejandra;
checks = {
inherit (self.packages.${system}) default;
# TODO: clippy and other checks?
};
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
openssl
hurl
];
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;
});
};
}

View file

@ -1,25 +1,9 @@
# Setup
# lyricscreen (lyrs)
```shell
$ direnv allow
```
# Running
```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
Manage lyrics and live displays for them at shows.
# Develop
```bash
watchexec -e rs,toml -r 'cargo run -- --log-env-filter trace,sled=debug run --watch'
```

View file

@ -1,2 +0,0 @@
[toolchain]
channel = "1.73"

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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)?)
}
}

View file

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

View file

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

View file

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

View file

@ -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()
}
}

View file

@ -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
View 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))
}

View file

@ -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();
}

View file

@ -1,21 +1,22 @@
//! lyrs entrypoint
// TODO: Break this module up
// TODO: Implement authn
mod app;
mod entities;
mod error;
mod feather_icons;
mod instrumentation;
mod migrator;
mod auth;
mod cli;
mod db;
mod file_watcher;
mod model;
mod observe;
mod partials;
mod prelude;
mod router;
mod server;
mod service;
mod state;
mod views;
mod tailwind;
mod user;
mod uuid;
mod webserver;
use crate::prelude::*;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
app::run().await
async fn main() -> AnyResult<()> {
Ok(cli::run().await?)
}

View file

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

View file

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

@ -0,0 +1,2 @@
mod display;
mod song;

135
src/model/display.rs Normal file
View 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
View 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
View 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(())
}

View file

@ -1,65 +1,84 @@
use axum_login::AuthSession;
use crate::service::accounts::AuthSession;
use axum::response::Html;
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 {
let is_logged_in = sess.user.is_some();
pub fn head(page_title: &str) -> Markup {
html! {
(DOCTYPE)
head {
link rel="stylesheet" href="/assets/styles.css" {}
link rel="icon" href="/assets/favicon.svg" {}
meta charset="utf-8" {}
meta name="viewport" content="width=device-width, initial-scale=1" {}
title { (page_title) " - lyrs" }
(stylesheet("/static/style.css"));
script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous" defer {}
}
}
pub fn foot() -> Markup {
html! {
footer class="p-2 bg-mantle border-t-2 border-surface0 flex overflow-x-scroll mt-auto" {
section {
(PreEscaped("&copy; 2024 "))
a class="underline text-mauve" href="https://lyte.dev" { "lytedev" }
}
section .ml-auto {"Made with ❤️"}
" "
a .underline.text-mauve href="/about" { "About" }
}
body hx-ext="preload" {
header class="flex" {
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 page(title: &str, content: Markup, auth_session: Option<AuthSession>) -> Html<String> {
let current_user = auth_session.map(|s| s.user).flatten();
Html(
html! {
(head(title))
body hx-boost="true" class="bg-bg text-text min-h-lvh flex flex-col font-sans overflow-x-hidden" {
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 footer() -> Markup {
pub fn labelled_input(label: &str, input: Markup) -> Markup {
html! {
footer {
script src="https://unpkg.com/htmx.org@1.9.8" crossorigin="anonymous" integrity="sha384-rgjA7mptc2ETQqXoYC3/zJvkU7K/aP44Y+z7xQuJiVnB/422P/Ak+F/AqFR7E4Wr" {}
script src="https://unpkg.com/htmx.org@1.9.8/dist/ext/preload.js" crossorigin="anonymous" {}
label class="flex flex-col" {
(label)
(input)
}
}
}

7
src/prelude.rs Normal file
View 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};

View file

@ -1,221 +1,147 @@
use crate::entities::{prelude::*, *};
use crate::state;
use crate::{error::AppError, views};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use axum::error_handling::HandleErrorLayer;
use crate::partials::page;
use crate::service::accounts::AuthSession;
use crate::user::User;
use crate::{
file_watcher::FileWatcher,
prelude::*,
service::{accounts, static_files},
state::State as AppState,
};
use axum::extract::State;
use axum::{async_trait, BoxError};
use axum::{http::StatusCode, response::Html, routing::get, Form, Router};
use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken};
use axum_login::tower_sessions::{MemoryStore, SessionManagerLayer};
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
use base64::prelude::*;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse};
use axum::{routing::get, Router};
use axum_login::{login_required, AuthManagerLayerBuilder};
use maud::html;
use notify::Watcher;
use password_hash::{PasswordHash, PasswordVerifier};
use sea_orm::*;
use serde::Deserialize;
use std::{env, path::Path};
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
use sled::IVec;
use tower_http::trace::TraceLayer;
use tower_livereload::LiveReloadLayer;
use tracing::{info, instrument};
use tower_sessions::SessionManagerLayer;
#[instrument]
pub async fn new() -> Result<Router, anyhow::Error> {
let app_router = Router::new()
.route("/hello-world", get(views::greet_world))
.route("/hello-world-text", get(views::greet_world_text));
#[derive(Debug)]
#[allow(dead_code)]
pub struct WebError(Error);
let assets_dir = ServeDir::new("./assets");
let state = state::State::new().await?;
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)?),
})
impl From<Error> for WebError {
fn from(value: Error) -> Self {
Self(value)
}
}
fn password_digest<S>(s: S) -> Result<String, password_hash::Error>
where
S: AsRef<str>,
{
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, &current)
}
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()
impl From<bincode::ErrorKind> for WebError {
fn from(value: bincode::ErrorKind) -> Self {
Self(value.into())
}
}
#[async_trait]
impl AuthnBackend for state::State {
type User = user::Model;
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?)
impl From<Box<bincode::ErrorKind>> for WebError {
fn from(value: Box<bincode::ErrorKind>) -> Self {
Self(value.into())
}
}
async fn login(mut auth: AuthSession, c: CsrfToken, Form(f): Form<Login>) -> AppRes {
csrf_verify(c, &f.authenticity_token)?;
impl IntoResponse for WebError {
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 {
Ok(Some(user)) => user,
Ok(None) => return Ok((StatusCode::UNAUTHORIZED, Html("user not found".to_string()))),
Err(e) => {
return Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(format!("failed to authenticate user: {}", e)),
))
pub type WebResult<T> = Result<T, WebError>;
pub async fn router(
state: AppState,
with_watchers: bool,
) -> AnyResult<(Router, Vec<Option<FileWatcher>>)> {
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 {
return Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Html(format!("failed to login user: {}", e)),
));
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
let accounts_service = accounts::router(state.clone()).unwrap();
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((
StatusCode::OK,
Html(
html! {
h1 { (f.username) }
h1 { (f.password) }
}
.into_string(),
),
))
let watchers = vec![static_file_watcher];
Ok((result, watchers))
}
async fn index(auth_session: Option<AuthSession>) -> Html<String> {
page(
"index",
html! {
main class="p-2" {
h1 class="text-2xl" { "Index" }
p class="mt-2" {
"Here, we explain to you why you may like this web application."
}
}
},
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)
}

View file

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

@ -0,0 +1,2 @@
pub mod accounts;
pub mod static_files;

125
src/service/accounts.rs Normal file
View 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(),
}
}

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

View file

@ -1,22 +1,85 @@
use std::env;
use sea_orm::{Database, DatabaseConnection};
use sea_orm_migration::MigratorTrait;
use crate::migrator::Migrator;
use crate::{db::Data, prelude::*, user::User};
use axum::async_trait;
use axum_login::{AuthnBackend, UserId};
use redact::Secret;
use serde::Deserialize;
#[derive(Clone)]
pub struct State {
pub db: DatabaseConnection,
pub db: Data,
}
impl State {
pub async fn new() -> Result<Self, anyhow::Error> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let db = Database::connect(database_url).await?;
Migrator::refresh(&db).await?;
Ok(State { db })
pub async fn try_new() -> AnyResult<Self> {
Ok(Self {
db: Data::try_new()?,
})
}
}
#[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
View 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
View 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
View 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
View 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))
}

View file

@ -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
View 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?)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
tailwind.config.js Normal file
View 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: [],
}