Database time

This commit is contained in:
Daniel Flanagan 2024-05-17 12:00:37 -05:00
parent 27dd80830f
commit 1a1aee1195
19 changed files with 2006 additions and 406 deletions

1742
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,8 @@ opt-level = 3
[profile.release]
strip = true
opt-level = "z"
lto = true
opt-level = "s"
lto = "fat"
codegen-units = 1
panic = "abort"
@ -24,9 +24,11 @@ clap = { version = "4.5.4", features = ["derive", "env"] }
color-eyre = "0.6.3"
config = "0.14.0"
futures = "0.3.30"
minijinja = { version = "2.0.1", features = ["loader"] }
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"
thiserror = "1.0.60"
tokio = { version = "1.37.0", features = ["full"] }

View file

@ -1,10 +1,9 @@
use crate::{observe, prelude::*, router::NewRouterError};
use prelude::*;
use thiserror::Error;
pub mod prelude;
mod prelude {
pub use clap::{Args, Parser, Subcommand};
}
mod run;
use crate::{observe, prelude::*};
use prelude::*;
/// Web application for managing lyrics and live displays
#[derive(Parser)]
@ -21,61 +20,23 @@ pub struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Run the web application server
Run(Run),
}
/// Doc comment
#[derive(Args)]
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,
}
#[derive(Error, Debug)]
pub enum RunError {
#[error("router error: {0}")]
Router(#[from] NewRouterError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl Run {
pub async fn run(&self) -> Result<(), RunError> {
let (router, _watchers) = crate::router::router(self.watch).await?;
Ok(
crate::webserver::webserver(router, self.watch, Some(&self.host), Some(self.port))
.await?,
)
}
}
pub fn cli() -> Cli {
Cli::parse()
Run(run::Run),
}
#[derive(Error, Debug)]
pub enum ExecError {
#[error("run error: {0}")]
Run(#[from] RunError),
Run(#[from] run::RunError),
#[error("{0}")]
Eyre(#[from] color_eyre::Report),
}
impl Cli {
pub async fn exec(self) -> Result<(), ExecError> {
observe::setup_logging(&self.log_env_filter)?;
match self.command {
pub async fn exec() -> Result<(), ExecError> {
let cli = Cli::parse();
observe::setup_logging(&cli.log_env_filter)?;
match cli.command {
Commands::Run(args) => Ok(args.run().await?),
}
}

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;

52
src/cli/run.rs Normal file
View file

@ -0,0 +1,52 @@
use super::prelude::*;
use crate::{
router::NewRouterError,
state::{NewStateError, State},
};
/// Doc comment
#[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(
short,
long,
default_value = "postgresql://lyrs?host=/var/run/postgresql"
)]
pub database_connection_string: String,
}
#[derive(Error, Debug)]
pub enum RunError {
#[error("router error: {0}")]
Router(#[from] NewRouterError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("new state error: {0}")]
State(#[from] NewStateError),
}
impl Run {
pub async fn run(&self) -> Result<(), RunError> {
let app_state = State::try_new(&self.database_connection_string).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?,
)
}
}

View file

@ -1,5 +1,3 @@
use crate::prelude::*;
mod cli;
mod file_watcher;
mod observe;
@ -8,10 +6,12 @@ mod router;
mod state;
mod static_files;
mod tailwind;
mod templates;
mod webserver;
use crate::prelude::*;
use cli::Cli;
#[tokio::main]
async fn main() -> AnonResult<()> {
Ok(cli::cli().exec().await?)
Ok(Cli::exec().await?)
}

View file

@ -17,7 +17,7 @@ pub fn setup_logging(env_filter: &str) -> Result<(), color_eyre::Report> {
))
.parse_lossy(env_filter);
info!("{filter}");
info!(%filter);
Ok(())
}

View file

@ -1,5 +1,3 @@
use std::time::Duration;
use crate::{
file_watcher::FileWatcher,
prelude::*,
@ -7,34 +5,26 @@ use crate::{
static_files,
};
use axum::{
body::Bytes,
extract::{MatchedPath, State},
http::{HeaderMap, Request, Response, StatusCode},
http::StatusCode,
response::{Html, IntoResponse},
routing::get,
Router,
routing::{get, post},
Form, Router,
};
use minijinja::context;
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
use tower_livereload::LiveReloadLayer;
use maud::{html, Markup, PreEscaped, DOCTYPE};
use redact::Secret;
use serde::Deserialize;
use thiserror::Error;
use tracing::{info_span, Span};
use tower_http::trace::TraceLayer;
use tower_livereload::LiveReloadLayer;
#[derive(Error, Debug)]
pub enum NewRouterError {
#[error("new state error: {0}")]
State(#[from] NewStateError),
#[error("watcher error: {0}")]
Watcher(#[from] notify::Error),
}
#[derive(Error, Debug)]
pub enum ReqError {
#[error("template error: {0}")]
Template(#[from] minijinja::Error),
}
pub enum ReqError {}
impl IntoResponse for ReqError {
fn into_response(self) -> axum::http::Response<axum::body::Body> {
@ -51,10 +41,9 @@ impl IntoResponse for ReqError {
pub type ReqResult<T> = Result<T, ReqError>;
pub async fn router(
state: AppState,
with_watchers: bool,
) -> Result<(Router, Vec<Option<FileWatcher>>), NewRouterError> {
let state = AppState::try_new().await?;
let live_reload_layer: Option<LiveReloadLayer> = if with_watchers {
Some(LiveReloadLayer::new())
} else {
@ -69,86 +58,174 @@ pub async fn router(
}
};
let template_file_watcher = state.clone().templates.start_watcher(orl()).await?;
let (static_file_service, static_file_watcher) = static_files::router(orl())?;
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("/static", static_file_service)
// `TraceLayer` is provided by tower-http so you have to add that as a dependency.
// It provides good defaults but is also very customizable.
//
// See https://docs.rs/tower-http/0.1.1/tower_http/trace/index.html for more details.
//
// If you want to customize the behavior using closures here is how.
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
// Log the matched route's path (with placeholders not filled in).
// Use request.uri() or OriginalUri if you want the real path.
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http_request",
method = ?request.method(),
matched_path,
some_other_field = tracing::field::Empty,
)
})
.on_request(|_request: &Request<_>, _span: &Span| {
// You can use `_span.record("some_other_field", value)` in one of these
// closures to attach a value to the initially empty field in the info_span
// created above.
})
.on_response(|response: &Response<_>, latency: Duration, _span: &Span| {
trace!("on_response: {response:?} in {latency:?}");
})
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {
// ...
})
.on_eos(
|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {
// ...
},
)
.on_failure(
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
error!("on_failure: {error:?} in {latency:?}");
// ...
},
),
)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
if let Some(lr) = live_reload_layer {
result = result.clone().layer(lr);
}
let watchers = vec![template_file_watcher, static_file_watcher];
let watchers = vec![static_file_watcher];
Ok((result, watchers))
}
#[instrument(skip(state))]
async fn index(State(state): State<AppState>) -> ReqResult<Html<String>> {
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(
state
.templates
.render("pages/index.jinja.html", context!())
.await?,
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()
))
}
#[instrument(skip(state))]
async fn about(State(state): State<AppState>) -> ReqResult<Html<String>> {
async fn index() -> ReqResult<Html<String>> {
page("index", html! { "Index" })
}
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(
state
.templates
.render("pages/about.jinja.html", context!())
.await?,
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(),
))
}

View file

@ -1,26 +1,22 @@
use crate::{
prelude::*,
templates::{TemplateLoadError, Templates},
};
use std::sync::Arc;
use crate::prelude::*;
use sea_orm::{Database, DatabaseConnection};
use thiserror::Error;
#[derive(Clone)]
pub struct State {
pub templates: Arc<Templates>,
db: DatabaseConnection,
}
impl State {
pub async fn try_new() -> Result<Self, NewStateError> {
let templates = Arc::new(Templates::try_load("src/templates").await?);
Ok(Self { templates })
pub async fn try_new(database_connection_string: &str) -> Result<Self, NewStateError> {
Ok(Self {
db: Database::connect(database_connection_string).await?,
})
}
}
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NewStateError {
#[error("template load error: {0}")]
TemplateLoad(#[from] TemplateLoadError),
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
}

View file

@ -1,7 +1,64 @@
@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;
@ -63,3 +120,9 @@
--Crust: #dce0e8;
}
}
:autofill,
:-webkit-autofill {
filter: none !important;
background-color: #f00 !important;
}

View file

@ -1,115 +0,0 @@
use crate::{file_watcher::prelude::*, prelude::*};
use minijinja::Environment;
use pathdiff::diff_paths;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use thiserror::Error;
use tokio::sync::Mutex;
use tower_livereload::Reloader;
#[derive(Error, Debug)]
pub enum TemplateLoadError {
#[error("template error: {0}")]
Minijinja(#[from] minijinja::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Clone, Debug)]
pub struct Templates {
env: Arc<Mutex<Environment<'static>>>,
dir: PathBuf,
}
impl Templates {
pub fn for_dir<P: Into<PathBuf>>(dir: P) -> Self {
let env = Arc::new(Mutex::new(Environment::new()));
Self {
env,
dir: dir.into(),
}
}
pub async fn try_load<P: Into<PathBuf>>(dir: P) -> Result<Self, TemplateLoadError> {
let result = Self::for_dir(dir);
result.load_env().await?;
Ok(result)
}
pub async fn start_watcher(
self: Arc<Self>,
reloader: Option<Reloader>,
) -> Result<Option<FileWatcher>, notify::Error> {
if let Some(rl) = reloader {
Ok(Some(self.watch(rl).await?))
} else {
Ok(None)
}
}
async fn watch(self: Arc<Self>, reloader: Reloader) -> Result<FileWatcher, notify::Error> {
let watcher = file_watcher(self.dir.clone(), move |ev| {
futures::executor::block_on(async {
if ev.kind.is_create() || ev.kind.is_modify() {
for p in ev.paths {
let _ = self.load_template(&p).await;
}
reloader.reload();
}
});
})?;
Ok(watcher)
}
#[instrument(skip(self, p))]
pub async fn load_template<P>(&self, p: P) -> Result<(), TemplateLoadError>
where
P: AsRef<Path> + Copy,
{
let p = p.as_ref();
if p.is_dir() {
return Ok(());
}
let filename: String = diff_paths(p, "src/templates")
.unwrap()
.to_string_lossy()
.into_owned();
if [".bck", ".tmp"].iter().any(|s| filename.ends_with(s)) {
debug!("skipping temporary file");
return Ok(());
}
info!(filename);
Ok(self
.env
.lock()
.await
.add_template_owned(filename, std::fs::read_to_string(p)?)?)
}
#[instrument(skip(self))]
pub async fn load_env(&self) -> Result<(), TemplateLoadError> {
for d in walkdir::WalkDir::new(&self.dir) {
match d {
Ok(d) => self.load_template(d.path()).await?,
Err(_) => todo!(),
}
}
Ok(())
}
pub async fn render<S: serde::ser::Serialize>(
&self,
template_name: &str,
context: S,
) -> Result<String, minijinja::Error> {
Ok(self
.env
.lock()
.await
.get_template(template_name)?
.render(context)?)
}
}

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html>
<head hx-preserve="true">
<title>Sup</title>
<link rel="stylesheet" type="text/css" href="/static/style.css" />
<script hx-preserve="true" src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
crossorigin="anonymous" defer></script>
</head>
<body class="bg-base text-text">
Page Template
<nav>
<a href="/">Index</a>
<a href="/about">About</a>
</nav>
{% block body %}{% endblock %}
</body>
</html>

View file

@ -1,7 +0,0 @@
{% extends "page.jinja.html" %}
{% block body %}
<h1>About</h1>
<p class="important">
Welcome to my awesome about page!
</p>
{% endblock %}

View file

@ -1,7 +0,0 @@
{% extends "page.jinja.html" %}
{% block body %}
<h1>Index</h1>
<p class="important">
Welcome to my awesome index!
</p>
{% endblock %}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,35 +1,39 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*"], theme: {
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)",
base: "var(--Base)",
mantle: "var(--Mantle)",
crust: "var(--Crust)",
},
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: [],