This commit is contained in:
Daniel Flanagan 2024-05-20 11:35:39 -05:00
parent 36eb96416d
commit 0879a84df1
18 changed files with 440 additions and 2201 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
/target
/.direnv
/static/style.css
/data
# Added by cargo
#

2127
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,9 +3,6 @@ name = "lyrs"
version = "0.1.0"
edition = "2021"
[workspace]
members = [".", "migration"]
[profile.dev]
opt-level = 1
@ -22,8 +19,7 @@ panic = "abort"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
migration = { path = "./migration" }
axum = { version = "0.7.5", features = ["macros", "tokio", "tracing"] }
argon2 = { version = "0.5.3", features = ["std"] }
clap = { version = "4.5.4", features = ["derive", "env"] }
color-eyre = "0.6.3"
config = "0.14.0"
@ -32,12 +28,18 @@ maud = "0.26.0"
notify = "6.1.1"
pathdiff = "0.2.1"
redact = { version = "0.1.10", features = ["serde"] }
sea-orm = { version = "0.12.15", features = ["sqlx-postgres", "runtime-tokio-rustls", "debug-print"] }
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"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
uuid = { version = "1.8.0", features = ["v7"] }
walkdir = "2.5.0"
[dependencies.axum]
version = "0.7.5"
features = ["macros", "tokio", "tracing"]

9
readme.md Normal file
View file

@ -0,0 +1,9 @@
# lyricscreen (lyrs)
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'
```

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())?,
)
}

View file

@ -18,15 +18,14 @@ pub struct Run {
/// 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,
// 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,
// Delete all data, recreate new database, run migrations, and seed database
// #[arg(long, default_value = "false")]
// pub database_reset: bool,
}
#[derive(Error, Debug)]
@ -43,8 +42,7 @@ pub enum RunError {
impl Run {
pub async fn run(&self) -> Result<(), RunError> {
let app_state =
State::try_new(&self.database_connection_string, self.database_reset).await?;
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))

View file

@ -1,27 +1,42 @@
use migration::MigratorTrait;
use sea_orm::Database;
use sea_orm::DatabaseConnection;
use sled::{Db, IVec};
use thiserror::Error;
#[derive(Clone)]
pub struct Data(DatabaseConnection);
pub struct Data {
db: Db,
}
#[derive(Error, Debug)]
pub enum NewDataError {
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
pub enum Error {
#[error("sled error: {0}")]
Sled(#[from] sled::Error),
}
impl Data {
pub async fn try_new(
database_connection_string: &str,
reset: bool,
) -> Result<Self, NewDataError> {
let db = Database::connect(database_connection_string).await?;
if reset {
migration::Migrator::fresh(&db).await?;
}
migration::Migrator::up(&db, None).await?;
Ok(Self(db))
pub fn try_new() -> Result<Self, Error> {
Ok(Self {
db: sled::open("data/lyrs")?,
})
}
pub fn get<K: AsRef<[u8]>, V: From<IVec>>(
&self,
tree_name: &str,
key: K,
) -> Result<Option<V>, Error> {
Ok(self
.db
.open_tree(tree_name)?
.get(key.as_ref())?
.map(V::from))
}
pub fn insert<K: AsRef<[u8]>, V: Into<IVec>>(
&self,
tree_name: &str,
key: K,
value: V,
) -> Result<Option<IVec>, Error> {
Ok(self.db.open_tree(tree_name)?.insert(key, value.into())?)
}
}

View file

@ -1,12 +1,15 @@
mod auth;
mod cli;
mod db;
mod file_watcher;
mod observe;
mod partials;
mod prelude;
mod router;
mod service;
mod state;
mod static_files;
mod tailwind;
mod user;
mod webserver;
use crate::prelude::*;

79
src/partials.rs Normal file
View file

@ -0,0 +1,79 @@
use crate::router::ReqResult;
use axum::response::Html;
use maud::{html, Markup, PreEscaped, DOCTYPE};
pub fn stylesheet(url: &str) -> Markup {
html! {
link rel="stylesheet" type="text/css" href=(url);
}
}
pub fn head(page_title: &str) -> Markup {
html! {
(DOCTYPE)
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 border-t-2 border-surface0 flex overflow-x-scroll" {
section {
(PreEscaped("&copy; 2024 "))
a .underline.text-mauve href="https://lyte.dev" { "lytedev" }
}
section .ml-auto {("Made with ❤️")}
}
}
}
pub fn page(title: &str, content: Markup) -> ReqResult<Html<String>> {
Ok(Html(
html! {
(head(title))
body .bg-bg.text-text.min-h-lvh.flex.flex-col.font-sans {
header class="drop-shadow border-b-2 border-surface0 bg-blue-500 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" {
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/auth/login" { "Login" }
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/auth/register" { "Register" }
}
}
main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" {
(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)
}
}
}

View file

@ -1,13 +1,17 @@
use crate::{file_watcher::FileWatcher, prelude::*, state::State as AppState, static_files};
use crate::partials::page;
use crate::{
file_watcher::FileWatcher,
prelude::*,
service::{auth, static_files},
state::State as AppState,
};
use axum::{
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
Form, Router,
routing::get,
Router,
};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use redact::Secret;
use serde::Deserialize;
use maud::html;
use thiserror::Error;
use tower_http::trace::TraceLayer;
use tower_livereload::LiveReloadLayer;
@ -19,7 +23,10 @@ pub enum NewRouterError {
}
#[derive(Error, Debug)]
pub enum ReqError {}
pub enum ReqError {
#[error("argon2 error: {0}")]
Argon2(#[from] argon2::password_hash::Error),
}
impl IntoResponse for ReqError {
fn into_response(self) -> axum::http::Response<axum::body::Body> {
@ -54,14 +61,12 @@ pub async fn router(
};
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
let auth_service = auth::router().unwrap();
let mut result = Router::new()
.route("/", get(index))
.route("/about", get(about))
.route("/login", get(login))
.route("/login", post(authenticate))
.route("/register", get(register))
.route("/register", post(create_user))
.nest_service("/auth", auth_service)
.nest_service("/static", static_file_service)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
@ -75,58 +80,6 @@ pub async fn router(
Ok((result, watchers))
}
fn stylesheet(url: &str) -> Markup {
html! {
link rel="stylesheet" type="text/css" href=(url);
}
}
fn head(page_title: &str) -> Markup {
html! {
(DOCTYPE)
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 {}
}
}
fn foot() -> Markup {
html! {
footer class="p-2 border-t-2 border-surface0 flex overflow-x-scroll" {
section {
(PreEscaped("&copy; 2024 "))
a .underline.text-mauve href="https://lyte.dev" { "lytedev" }
}
section .ml-auto {("Made with ❤️")}
}
}
}
fn page(title: &str, content: Markup) -> ReqResult<Html<String>> {
Ok(Html(
html! {
(head(title))
body .bg-bg.text-text.min-h-lvh.flex.flex-col.font-sans {
header class="drop-shadow border-b-2 border-surface0 bg-blue-500 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" {
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/login" { "Login" }
a class="flex items-center p-2 hover:bg-mauve hover:text-bg" href="/register" { "Register" }
}
}
main class="flex flex-col flex-1 relative overflow-x-scroll bg-mantle" {
(content)
}
}
(foot())
}.into_string()
))
}
async fn index() -> ReqResult<Html<String>> {
page("index", html! { "Index" })
}
@ -134,93 +87,3 @@ async fn index() -> ReqResult<Html<String>> {
async fn about() -> ReqResult<Html<String>> {
page("index", html! { "About" })
}
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)
}
}
}
fn labelled_input(label: &str, input: Markup) -> Markup {
html! {
label class="flex flex-col" {
(label)
(input)
}
}
}
async fn login() -> ReqResult<Html<String>> {
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="/register" {"Get one"}
"."
}
};
page("login", center_hero_form("Login", form, subaction))
}
async fn register() -> ReqResult<Html<String>> {
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="/login" {"Login"}
"."
}
};
page("login", center_hero_form("Register", form, subaction))
}
#[derive(Deserialize, Debug)]
struct Creds {
username: String,
password: Secret<String>,
}
#[instrument]
async fn authenticate(Form(creds): Form<Creds>) -> ReqResult<Html<String>> {
info!("login attempt");
Ok(Html(
html! {
"no"
}
.into_string(),
))
}
#[instrument]
async fn create_user(Form(creds): Form<Creds>) -> ReqResult<Html<String>> {
info!("registration attempt");
Ok(Html(
html! {
"no"
}
.into_string(),
))
}

2
src/service.rs Normal file
View file

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

0
src/service/account.rs Normal file
View file

95
src/service/auth.rs Normal file
View file

@ -0,0 +1,95 @@
use crate::router::ReqResult;
use crate::state::State as AppState;
use crate::{partials::*, user::User};
use axum::extract::State;
use axum::response::Html;
use axum::Form;
use maud::html;
use redact::Secret;
use serde::Deserialize;
use std::convert::Infallible;
use axum::{
routing::{get, post},
Router,
};
use crate::prelude::*;
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))
.with_state(state))
}
async fn login() -> ReqResult<Html<String>> {
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="/register" {"Get one"}
"."
}
};
page("login", center_hero_form("Login", form, subaction))
}
async fn register() -> ReqResult<Html<String>> {
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="/login" {"Login"}
"."
}
};
page("login", center_hero_form("Register", form, subaction))
}
#[derive(Deserialize, Debug)]
struct Creds {
username: String,
password: Secret<String>,
}
#[instrument]
async fn authenticate(Form(creds): Form<Creds>) -> ReqResult<Html<String>> {
info!("login attempt");
Ok(Html(
html! {
"no"
}
.into_string(),
))
}
#[instrument(skip(state))]
async fn create_user(
State(state): State<AppState>,
Form(creds): Form<Creds>,
) -> ReqResult<Html<String>> {
let user = User::try_new(&creds.username, creds.password.expose_secret())?;
Ok(Html(
html! {
({user.username}) " has been registered"
}
.into_string(),
))
}

View file

@ -0,0 +1,4 @@
segment_size: 524288
use_compression: false
version: 0.34
v

BIN
src/service/data/lyrs/db Normal file

Binary file not shown.

View file

@ -6,16 +6,13 @@ use thiserror::Error;
#[derive(Clone)]
pub struct State {
db: Data,
pub db: Data,
}
impl State {
pub async fn try_new(
database_connection_string: &str,
database_reset: bool,
) -> Result<Self, NewStateError> {
pub async fn try_new() -> Result<Self, NewStateError> {
Ok(Self {
db: Data::try_new(database_connection_string, database_reset).await?,
db: Data::try_new()?,
})
}
}
@ -23,5 +20,5 @@ impl State {
#[derive(Error, Debug)]
pub enum NewStateError {
#[error("database error: {0}")]
Database(#[from] db::NewDataError),
Database(#[from] db::Error),
}

38
src/user.rs Normal file
View file

@ -0,0 +1,38 @@
use crate::prelude::*;
use redact::Secret;
use serde::Deserialize;
use thiserror::Error;
pub const USER_TREE: &str = "user";
#[derive(Deserialize, Debug)]
pub struct User {
pub username: String,
password_digest: Secret<String>,
}
#[derive(Error, Debug)]
pub enum Error {
#[error("username exists: {0}")]
UsernameExists(Box<String>),
#[error("username not found: {0}")]
UsernameNotFound(Box<String>),
}
impl User {
pub const fn tree() -> &'static str {
USER_TREE
}
pub fn try_new(username: &str, password: &str) -> Result<Self, argon2::password_hash::Error> {
Ok(Self {
username: username.to_owned(),
password_digest: Secret::new(crate::auth::password_digest(password)?.to_string()),
})
}
pub fn verify(&self, password: &str) -> Result<(), argon2::password_hash::Error> {
crate::auth::verified_password(password, self.password_digest.expose_secret())
}
}