Initial commit; adaptation from mascarpone

This commit is contained in:
Robert Perce 2026-05-30 12:15:04 -05:00
commit 3d45ec4b0a
36 changed files with 5877 additions and 0 deletions

View file

@ -0,0 +1,14 @@
on: [workflow_dispatch]
jobs:
integration-test--firefox:
runs-on: playwright-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: pnpm/action-setup@v4
with:
version: 11.0.0-dev.1005
- run: cd e2e && pnpm install
- run: cd e2e && env PROJECT_FILTER=firefox ./Taskfile _test

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
/target
e2e/node_modules
e2e/playwright-report
e2e/test-results
/some_user.db
/dbs/*
/hashed_static
/users.db
/.sqlx

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
## [0.2.0] - 2026-04-08
### Features
- Scroll to current contact in sidebar by default
- Clicking off sidebar on small windows closes it
- Sort contacts sidebar ignoring case
- Report version information
### Refactor
- Cloak input elements while alpine.js loads
### Performance
- *(test)* Test performance improvements
- Large cache time on immutable hashed statics
### Testing
- Deflake by waiting for Save response
- Open sidebar on mobile
### ⚙️ Miscellaneous Tasks
- Prepare for tagged releases
## [0.1.0] - 2026-04-05
# Features
* In-app contacts
* For each contact:
* Names
* Birthday
* Last-contact-time mapping
* Address as single field (plus code? lat/long? go crazy!)
* Free-text-entry field
* Desired contact periodicity
* Journal with Obsidian-like `[[link]]` syntax
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* ical server for birthday reminders
* Mark contacts as inactive or prevent stale checks

3933
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

48
Cargo.toml Normal file
View file

@ -0,0 +1,48 @@
[package]
name = "entretien"
version = "0.0.1"
edition = "2024"
[profile.release]
strip = true
lto = true
[dependencies]
anyhow = "1.0.100"
axum = { version = "0.8.6", features = ["macros", "form"] }
axum-extra = { version = "0.10.3", features = ["form"] }
axum-htmx = "0.8.1"
axum-login = "0.18.0"
cache_bust = { version = "0.2.0", features = ["macro"] }
chrono = { version = "0.4.42", features = ["clock", "alloc"] }
clap = { version = "4.5.53", features = ["derive"] }
http = "1.3.1"
icalendar = "0.17.5"
itertools = "0.14.0"
jiff = { version = "0.2.23", features = ["serde"] }
listenfd = "1.0.2"
markdown = "1.0.0"
maud = { version = "0.27.0", features = ["axum"] }
password-auth = "1.0.0"
radix_trie = "0.3.0"
regex = "1.12.2"
rpassword = "7.4.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
short-uuid = "0.2.0"
slug = "0.1.6"
sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] }
thiserror = "2.0.17"
time = "0.3.44"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
tower = "0.5.3"
tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] }
tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = { version = "0.1.41", features = ["attributes"] }
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
vcard = "0.4.13"
[build-dependencies]
cache_bust = "0.2.0"
vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] }

5
README.md Normal file
View file

@ -0,0 +1,5 @@
# Rust web stack template
My favorite webapp stack right now

59
Taskfile Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
playwright:local() {
bash e2e/Taskfile playwright:local "$@"
}
playwright:ui() {
bash e2e/Taskfile playwright:ui "$@"
}
refresh_sqlx_db() {
rm -f some_user.db
for migration in migrations/each_user/*.sql; do
echo "Applying $migration..."
echo "BEGIN TRANSACTION;$(cat "$migration");COMMIT TRANSACTION;"\
| sqlite3 some_user.db
done
}
_env() {
refresh_sqlx_db > /dev/null
DATABASE_URL=sqlite:some_user.db \
CACHE_BUST_ASSETS_DIR=static \
"$@"
}
_cargo() {
_env cargo "$@"
}
edit() {
_env nvim
}
deploy_to_server() {
where="$1"
_cargo build --release
rsync -v -essh ./target/release/entretien "$where:~" \
&& rsync -rav -essh ./hashed_static "$where:~/" \
&& ssh -t "$where" "sudo mv -f entretien /usr/bin/ && sudo rm -rf /var/local/entretien/hashed_static && sudo mv -f hashed_static /var/local/entretien/ && sudo systemctl restart entretien"
}
dev() {
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve "$@"
}
release() {
set -euo pipefail
bash e2e/Taskfile playwright:ci
_cargo test
new_tag=$(git-cliff --unreleased --bumped-version)
git tag -m "$new_tag" "$new_tag"
cargo set-version "${new_tag#v}"
mv CHANGELOG.md CHANGELOG.old
cat <(git cliff) <(printf "\n") CHANGELOG.old > CHANGELOG.md
rm CHANGELOG.old
}
"$@"

23
build.rs Normal file
View file

@ -0,0 +1,23 @@
use cache_bust::CacheBust;
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
fn main() {
println!("cargo:rerun-if-changed=migrations");
let cache_bust = CacheBust::builder()
.in_dir("static".to_owned())
.out_dir("hashed_static".to_owned())
.build();
cache_bust.hash_dir().expect("Cache busting failed");
let build = BuildBuilder::all_build().expect("build information failed");
let gitcl = GitclBuilder::all_git().expect("gitcl information failed");
Emitter::default()
.add_instructions(&build)
.unwrap()
.add_instructions(&gitcl)
.unwrap()
.emit()
.unwrap();
}

94
cliff.toml Normal file
View file

@ -0,0 +1,94 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# A Tera template to be rendered for each release in the changelog.
# See https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
# Render body even when there are no releases to process.
render_always = true
# An array of regex based postprocessors to modify the changelog.
postprocessors = [
# Replace the placeholder <REPO> with a URL.
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
]
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = true
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = true
# Require all commits to be conventional.
# Takes precedence over filter_unconventional.
require_conventional = false
# Split commits on newlines, treating each line as an individual commit.
split_commits = false
# An array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit message using https://github.com/crate-ci/typos.
# If the spelling is incorrect, it will be fixed automatically.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# Prevent commits that are breaking from being excluded by commit parsers.
protect_breaking_commits = false
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# Fail on a commit that is not matched by any commit parser.
fail_on_unmatched_commit = false
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
link_parsers = []
# Include only the tags that belong to the current branch.
use_branch_tags = false
# Order releases topologically instead of chronologically.
topo_order = false
# Order commits topologically instead of chronologically.
topo_order_commits = true
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"
# Process submodules commits
recurse_submodules = false

22
e2e/README.md Normal file
View file

@ -0,0 +1,22 @@
# e2e
Install deps with `corepack pnpm i`.
Ensure that if you update `@playwright/test` that
(a) it remains a devdep, and
(b) it is a `=`-type dependency.
Start a dev server with `cargo run`. Tests expect an ephemeral user with username and
password both `test`. Achieve this with
```
cargo run set-password test
```
then
```
sqlite3 users.db "update users set ephemeral=true where username='test'"
```
Run tests in the docker image with the right browsers installed with `./Taskfile
playwright:local` or `./Taskfile playwright:ui`.

47
e2e/Taskfile Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
PATH=$PATH:node_modules/.bin
SCRIPT_DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
_playwright_version() {
jq -r '.devDependencies["@playwright/test"]' "$SCRIPT_DIR/package.json" | sed -e 's/\=/v/'
}
playwright:local() {
exec docker run \
--interactive --tty --rm --ipc=host --net=host \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
bash -c "cd /e2e && env PROJECT_FILTER=firefox ./Taskfile _test $*"
}
playwright:ui() {
playwright:local --ui-host=0.0.0.0
}
playwright:ci() {
exec docker run \
--interactive --tty --rm --ipc=host --net=host \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
bash -c "cd /e2e && ./Taskfile _test $*"
}
_test:full() {
if ! test -f /.dockerenv; then
echo "Don't run _test directly; use playwright:local."
fi
# run only in firefox first to see if there's errors and,
# only if not, run in everything else
env PROJECT_FILTER=firefox playwright test "$@" && \
env PROJECT_FILTER=!firefox playwright test "$@"
}
_test() {
if ! test -f /.dockerenv; then
echo "Don't run _test directly; use playwright:local."
fi
playwright test "$@"
}
"$@"

37
e2e/custom-expects.ts Normal file
View file

@ -0,0 +1,37 @@
import { expect, type Locator } from '@playwright/test';
expect.extend({
async toBeAbove(self: Locator, other: Locator) {
const name = 'toBeAbove';
let pass: boolean;
let matcherResult: any;
let selfY: number | null = null;
let otherY: number | null = null;
try {
selfY = (await self.boundingBox())?.y ?? null;
otherY = (await self.boundingBox())?.y ?? null;
pass = selfY !== null && otherY !== null && (selfY < otherY);
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
if (this.isNot) {
pass =!pass;
}
const message = () => this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot }) +
'\n\n' +
`Locator: ${self}\n` +
`Expected: above ${other} (y=${this.utils.printExpected(otherY)})\n` +
(matcherResult ? `Received: y=${this.utils.printReceived(selfY)}` : '');
return {
message,
pass,
name,
expected: (this.isNot ? '>=' : '<') + otherY,
actual: selfY,
};
}
});

17
e2e/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "entretien/e2e",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo use Taskfile instead"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "=1.57.0",
"@types/node": "^24.9.1"
},
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
}

11
e2e/pages/util.ts Normal file
View file

@ -0,0 +1,11 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
export const login = async (page: Page) => {
await page.goto('/login');
await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click();
await page.waitForURL('/');
};

72
e2e/playwright.config.ts Normal file
View file

@ -0,0 +1,72 @@
import { defineConfig, devices } from '@playwright/test';
import './custom-expects';
// purposefully not using ??: we want to replace empty empty string with default
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
let addlConfig = {
retries: process.env.CI ? 2 : 0,
};
let projects = [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
];
const pfil = process.env.PROJECT_FILTER;
if (pfil) {
if (pfil.startsWith('!')) {
projects = projects.filter(p => p.name !== pfil.slice(1));
} else {
projects = projects.filter(p => p.name === pfil);
}
}
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './pages',
fullyParallel: true,
workers: 1,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: Boolean(process.env.CI),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects,
...addlConfig,
});

67
e2e/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,67 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: '=1.57.0'
version: 1.57.0
'@types/node':
specifier: ^24.9.1
version: 24.10.1
packages:
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'}
hasBin: true
'@types/node@24.10.1':
resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
engines: {node: '>=18'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@playwright/test@1.57.0':
dependencies:
playwright: 1.57.0
'@types/node@24.10.1':
dependencies:
undici-types: 7.16.0
fsevents@2.3.2:
optional: true
playwright-core@1.57.0: {}
playwright@1.57.0:
dependencies:
playwright-core: 1.57.0
optionalDependencies:
fsevents: 2.3.2
undici-types@7.16.0: {}

1
e2e/static Symbolic link
View file

@ -0,0 +1 @@
../static

25
migrations/demo.sql Normal file
View file

@ -0,0 +1,25 @@
-- (1, 'daily'),
-- (2, 'weekly'),
-- (3, 'fortnightly'),
-- (4, 'monthly'),
-- (5, 'seasonally'),
-- (6, 'semiannually'),
-- (7, 'yearly')
insert into tasks (description, frequency_id) values
('**Kitchen**: Tidy', 2),
('**Kitchen**: Wipe counters', 2),
('**Dining**: Tidy', 2),
('**Dining**: Wipe table', 2),
('**Kitchen**: Clean sink', 3),
('**Bathroom**: Change towels', 3),
('**Bathroom**: Tidy', 3),
('**Bathroom**: Wipe counters & sinks', 4),
('**Bathroom**: Sweep/vacuum', 4),
('**Bathroom**: Empty trash', 4),
('**Bathroom**: Clean toilets', 4),
('Mirrors', 5),
('**kitchen**: Verticals', 5),
('Clean oven', 6),
('Wash dish rack', 6),
('Oil hinges', 7),
('Wash patio', 7);

View file

@ -0,0 +1,27 @@
create table if not exists frequencies (
id integer primary key autoincrement,
description text not null default ''
);
insert into frequencies (id, description) values
(1, 'daily'),
(2, 'weekly'),
(3, 'fortnightly'),
(4, 'monthly'),
(5, 'seasonally'),
(6, 'semiannually'),
(7, 'yearly')
on conflict (id) do update set description=excluded.description;
create table if not exists tasks (
id integer primary key autoincrement,
description text not null default '',
frequency_id integer not null references frequencies(id) on delete cascade
);
create table if not exists completions (
id integer primary key autoincrement,
task_id integer not null references tasks(id) on delete cascade,
datestamp text not null
);

View file

@ -0,0 +1,6 @@
create table if not exists users (
id integer primary key autoincrement,
username not null unique,
password not null,
ephemeral boolean not null default false
);

5
mise.toml Normal file
View file

@ -0,0 +1,5 @@
[tools]
"rust-analyzer" = "latest"
"jj" = "latest"
node = "24"
git-cliff = "latest"

37
src/db.rs Normal file
View file

@ -0,0 +1,37 @@
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use std::str::FromStr;
use crate::models::user::User;
pub struct Database {
pub pool: SqlitePool,
}
pub type DbId = i64;
impl Database {
pub async fn for_user(user: &User) -> Result<Self, anyhow::Error> {
let file = if user.ephemeral {
":memory:".to_string()
} else {
format!("./dbs/{}.db", user.username)
};
let db_options = SqliteConnectOptions::from_str(&file)?
.create_if_missing(true)
.to_owned();
let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
tracing::debug!("migrating...");
sqlx::migrate!("./migrations/each_user/").run(&pool).await?;
if user.username == "demo" {
sqlx::query_file!("./migrations/demo.sql")
.execute(&pool)
.await?;
};
tracing::debug!("...done.");
Ok(Self { pool })
}
}

336
src/main.rs Normal file
View file

@ -0,0 +1,336 @@
use axum::Router;
use axum::response::{IntoResponse, Response};
use axum_login::AuthUser;
use axum_login::{AuthManagerLayerBuilder, login_required};
// use cache_bust::asset;
use clap::{Parser, Subcommand};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use tokio::net::TcpListener;
use tokio::signal;
use tokio::task::AbortHandle;
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
// use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod models;
use models::user::{Backend, User};
mod db;
use db::{Database, DbId};
mod web;
#[derive(Clone)]
struct AppStateEntry {
database: Arc<Database>,
}
#[derive(Clone)]
struct AppState {
autologin_user: String,
autologin_pass: String,
map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>,
}
impl AppState {
pub fn new(autologin_user: String, autologin_pass: String) -> Self {
AppState {
autologin_user,
autologin_pass,
map: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> {
let database = Database::for_user(&user).await?;
let mut map = self.map.write().expect("rwlock poisoned");
Ok(map.insert(
user.id(),
crate::AppStateEntry {
database: Arc::new(database),
},
))
}
pub fn remove(&mut self, user: &impl AuthUser<Id = DbId>) {
let mut map = self.map.write().expect("rwlock poisoned");
map.remove(&user.id());
}
pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> {
let map = self.map.read().expect("rwlock poisoned");
map.get(&user.id()).unwrap().database.clone()
}
}
#[derive(Debug)]
pub struct AppError(anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// run entretien server (default)
Serve {
/// port to bind
#[arg(short, long, default_value_t = 3000)]
port: u32,
#[arg(long, default_value_t = String::from(""))]
autologin_user: String,
#[arg(long, default_value_t = String::from(""))]
autologin_pass: String,
},
/// set password of user, creating if necessary
SetPassword {
/// username to create or set password
username: String,
},
/// set a user's ephemerality
SetEphemeral {
/// username to set ephemerality
username: String,
#[arg(action = clap::ArgAction::Set)]
ephemeral: bool,
},
/// print version information
Version,
}
async fn serve(
port: &u32,
autologin_user: String,
autologin_pass: String,
) -> Result<(), anyhow::Error> {
let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true)
.to_owned();
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
sqlx::migrate!("./migrations/users.db").run(&db).await?;
db
};
let state = AppState::new(autologin_user, autologin_pass);
let session_store = SqliteStore::new(users_db.clone());
session_store.migrate().await?;
let deletion_task = tokio::task::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(600)),
);
// Generate a cryptographic key to sign the session cookie.
let key = Key::generate();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(time::Duration::days(10)))
.with_signed(key);
let backend = Backend::new(users_db.clone());
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!(
"{}=debug,tower_http=debug,axum=trace",
env!("CARGO_CRATE_NAME")
)
.into()
}),
)
.with(tracing_subscriber::fmt::layer().without_time())
.init();
let app = Router::new()
.merge(web::home::router())
.merge(web::task::router())
.route_layer(login_required!(Backend, login_url = "/login"))
.merge(web::auth::router())
.nest_service(
"/static",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
http::header::CACHE_CONTROL,
http::header::HeaderValue::from_static("public, max-age=31536000, immutable"),
))
.service(ServeDir::new("./hashed_static")),
)
//.nest_service(
// "/favicon.ico",
// ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))),
//)
.layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state);
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
tracing::debug!("Starting axum on 0.0.0.0:{}...", port);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle()))
.await
.unwrap();
deletion_task.await??;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let cli = Cli::parse();
match &cli.command {
Some(Commands::SetPassword { username }) => {
let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true)
.to_owned();
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
sqlx::migrate!("./migrations/users.db").run(&db).await?;
db
};
let password =
rpassword::prompt_password(format!("New password for {}: ", username)).unwrap();
let update = sqlx::query(
"insert into users (username, password) values ($1, $2) on conflict do update set password=excluded.password",
)
.bind(username)
.bind(password_auth::generate_hash(password))
.execute(&users_db)
.await?;
if update.rows_affected() > 0 {
println!("Updated password for {}.", username);
} else {
println!("No update was made; probably something went wrong.");
}
}
Some(Commands::SetEphemeral {
username,
ephemeral,
}) => {
let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true)
.to_owned();
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
sqlx::migrate!("./migrations/users.db").run(&db).await?;
db
};
let eph: Option<bool> =
sqlx::query_scalar("select ephemeral from users where username = ?")
.bind(&username)
.fetch_optional(&users_db)
.await?;
if let Some(eph) = eph {
if eph == *ephemeral {
println!(
"User {} is already {}.",
username,
if eph { "ephemeral" } else { "not ephemeral" }
);
} else {
let update = sqlx::query("update users set ephemeral=$1 where username = $2")
.bind(ephemeral)
.bind(&username)
.execute(&users_db)
.await?;
if update.rows_affected() > 0 {
println!("Updated ephemerality for {}.", username);
} else {
println!("No update was made; probably something went wrong.");
}
}
} else {
println!(
"User {} does not exist. Create them first with set-password.",
username
);
}
}
Some(Commands::Serve {
port,
autologin_user,
autologin_pass,
}) => {
serve(port, autologin_user.clone(), autologin_pass.clone()).await?;
}
Some(Commands::Version) => {
println!("entretien v{}", env!("CARGO_PKG_VERSION"));
println!("from git commit {}", env!("VERGEN_GIT_SHA"));
}
None => {
serve(&3000, String::from(""), String::from("")).await?;
}
}
Ok(())
}
async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => { deletion_task_abort_handle.abort() },
_ = terminate => { deletion_task_abort_handle.abort() },
}
}

1
src/models/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod user;

124
src/models/user.rs Normal file
View file

@ -0,0 +1,124 @@
use axum_login::{AuthUser, AuthnBackend, UserId};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use tokio::task;
#[derive(Clone, Serialize, Deserialize, FromRow)]
pub struct User {
id: i64,
pub username: String,
password: String,
pub ephemeral: bool,
}
// Here we've implemented `Debug` manually to avoid accidentally logging the
// password hash.
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password", &"[redacted]")
.field("ephemeral", &self.ephemeral)
.finish()
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
// We use the password hash as the auth hash--what this means
// is when the user changes their password the auth session becomes invalid.
self.password.as_bytes()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone)]
pub struct Backend {
db: SqlitePool,
}
impl Backend {
pub fn new(db: SqlitePool) -> Self {
Self { db }
}
pub async fn set_password(&self, creds: Credentials) -> Result<(), anyhow::Error> {
if creds.username != "demo" {
sqlx::query("update users set password=$2 where username=$1")
.bind(creds.username)
.bind(password_auth::generate_hash(creds.password))
.execute(&self.db)
.await?;
}
Ok(())
}
pub async fn find_user(
&self,
username: impl AsRef<str>,
) -> Result<Option<User>, anyhow::Error> {
let user = sqlx::query_as("select * from users where username = ?")
.bind(username.as_ref())
.fetch_optional(&self.db)
.await?;
Ok(user)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
TaskJoin(#[from] task::JoinError),
}
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user: Option<Self::User> = sqlx::query_as("select * from users where username = $1")
.bind(creds.username)
.fetch_optional(&self.db)
.await?;
// Verifying the password is blocking and potentially slow, so we'll do so via
// `spawn_blocking`.
task::spawn_blocking(|| {
Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok()))
})
.await?
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = sqlx::query_as("select * from users where id = ?")
.bind(user_id)
.fetch_optional(&self.db)
.await?;
Ok(user)
}
}
pub type AuthSession = axum_login::AuthSession<Backend>;

129
src/web/auth.rs Normal file
View file

@ -0,0 +1,129 @@
use axum::extract::State;
use axum::http::HeaderMap;
use axum::{
Form, Router,
extract::Query,
response::{IntoResponse, Redirect},
routing::{get, post},
};
use cache_bust::asset;
use maud::{DOCTYPE, html};
use serde::Deserialize;
use serde_json::json;
use crate::models::user::{AuthSession, Credentials};
use crate::{AppError, AppState};
#[derive(Deserialize, Debug)]
struct NextUrl {
next: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(self::post::login))
.route("/login", get(self::get::login))
.route("/logout", get(self::get::logout))
}
mod post {
use super::*;
#[axum::debug_handler]
pub async fn login(
mut auth_session: AuthSession,
State(mut state): State<AppState>,
Query(NextUrl { next }): Query<NextUrl>,
Form(creds): Form<Credentials>,
) -> Result<impl IntoResponse, AppError> {
let mut headers = HeaderMap::new();
let user = match auth_session.authenticate(creds.clone()).await {
Ok(Some(user)) => user,
Ok(None) => {
return Err(AppError(anyhow::Error::msg(
"Username and password do not match",
)));
}
Err(_) => return Err(AppError(anyhow::Error::msg("Internal server error"))),
};
if auth_session.login(&user).await.is_err() {
return Err(AppError(anyhow::Error::msg("Server error during login")));
}
state.init(&user).await?;
if let Some(url) = next {
headers.insert("HX-Redirect", url.parse()?);
} else {
headers.insert("HX-Redirect", "/".parse()?);
}
Ok((headers, "ok"))
}
}
mod get {
use super::*;
pub async fn login(
State(state): State<AppState>,
Query(NextUrl { next }): Query<NextUrl>,
) -> impl IntoResponse {
let post_url = format!(
"/login{}",
next.map_or("".to_string(), |n| format!("?next={}", n))
);
html! {
(DOCTYPE)
html {
head {
// link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png")));
// link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png")));
// link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png")));
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Entretien" }
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
}
body hx-ext="response-targets" {
h1 { "Entretien" }
form hx-post=(post_url) hx-target-error="#error" x-data=(json!({"user": state.autologin_user, "pass": state.autologin_pass, "htmx": false})) "x-on:htmx:load.document"="htmx = true" hx-trigger=(if state.autologin_pass.is_empty() { "submit" } else { "load" }) {
label for="username" { "username" }
input name="username" #username autofocus x-model="user" x-cloak;
label for="password" { "password" }
input name="password" #password type="password" x-model="pass" x-cloak;
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length && htmx)" hx-disabled-elt;
#error {}
}
}
}
}
}
pub async fn logout(
mut auth_session: AuthSession,
State(mut state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.clone();
auth_session.logout().await?;
if let Some(user) = user {
state.remove(&user);
}
Ok(Redirect::to("/login").into_response())
}
}

190
src/web/home.rs Normal file
View file

@ -0,0 +1,190 @@
use axum::{
Router,
// extract::{State, path::Path},
extract::State,
response::IntoResponse,
routing::get,
};
use cache_bust::asset;
use jiff::{SpanTotal, Unit, Zoned};
use serde_json::json;
use std::collections::HashMap;
use maud::{PreEscaped, html};
use super::Layout;
use crate::models::user::AuthSession;
use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> {
Router::new().route("/", get(self::get::home))
}
mod get {
use super::*;
#[derive(serde::Serialize, Debug, Clone)]
struct Task {
id: DbId,
description: String,
frequency: String,
datestamp: Option<String>,
}
#[derive(serde::Serialize, Debug, Clone)]
struct TaskWithSince {
task: Task,
since: String,
}
impl std::ops::Deref for TaskWithSince {
type Target = Task;
fn deref(&self) -> &Self::Target {
&self.task
}
}
fn since(datestamp: &Option<String>) -> Result<f64, AppError> {
if let Some(datestamp) = datestamp {
let now = Zoned::now();
let then =
jiff::civil::Date::strptime("%F", datestamp)?.to_zoned(now.time_zone().clone())?;
return Ok(now
.since(&then)?
.total(SpanTotal::from(Unit::Day).days_are_24_hours())?
.floor());
}
Ok(f64::INFINITY)
}
pub async fn home(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let last_completions = sqlx::query_as!(
Task,
"select
tasks.id as \"id!\",
tasks.description as \"description!\",
frequencies.description as \"frequency!\",
max(completions.datestamp) as datestamp
from
tasks
join frequencies on tasks.frequency_id = frequencies.id
left join completions on completions.task_id = tasks.id
group by
tasks.id"
)
.fetch_all(pool)
.await?;
let mut by_freq = last_completions
.into_iter()
.map(|task| TaskWithSince {
since: since(&task.datestamp).unwrap().to_string(),
task: task,
})
.fold(
HashMap::<String, Vec<TaskWithSince>>::new(),
|mut map, each| {
map.entry(each.frequency.clone())
.or_insert_with(Vec::new)
.push(each.clone());
map
},
);
for (_, list) in &mut by_freq {
list.sort_by(|a, b| b.since.partial_cmp(&a.since).unwrap())
}
let frequencies = vec![
"weekly",
"fortnightly",
"monthly",
"seasonally",
"semiannually",
"yearly",
]
.into_iter()
.filter(|k| by_freq.contains_key(&k.to_string()));
Ok(layout.render(
"Home",
Some(vec![asset!("home.css")]),
html! {
form #new-task hx-post="/task" x-data=(json!({"description": ""})) hx-on::after-on-load="window.location.reload()" hx-target-error="find .error" {
.col {
label for="new_task" { "new task" }
input id="new_task" name="description" x-model="description";
}
.col {
label for="frequency" { "frequency" }
select #frequency name="frequency" {
option value="weekly" { "Weekly" }
option value="fortnightly" { "Fortnightly" }
option value="monthly" { "Monthly" }
option value="seasonally" { "Seasonally" }
option value="semiannually" { "Semiannually" }
option value="yearly" { "Yearly" }
}
}
button x-bind:disabled="description.length === 0" { "add" }
.error {}
}
form #bulk-complete
hx-ext="response-targets"
x-data=(json!({ "regex": "", "date": "", "ct": 0 }))
hx-post="/task/bulk_completion"
hx-on::after-request="if (event.detail.xhr.status === 200) window.location.reload()"
hx-target-error="find .error" {
.col {
label for="regex" { "bulk complete regex" }
input id="regex" name="regex" x-model="regex";
}
.col {
label for="bulk-date" { "date" }
input #bulk-date name="datestamp" type="date" x-model="date";
}
.col {
label for="expected-count" { "count" }
input #expected-count name="count" x-model="ct" type="number" min="0" size="2";
}
button x-bind:disabled="regex.length === 0 || ct === 0 || date.length === 0" { "bulk complete" }
.error {}
}
.table {
@for frequency in frequencies {
h1 .span { (frequency) }
.th { "task" }
.th { "since" }
.th .span { "last completed" }
@for task in by_freq.get(frequency).unwrap() {
span {
a href=(format!("/task/{}", task.id)) { (PreEscaped(markdown::to_html(&task.description))) }
}
span .since id=(format!("since-{}", task.id)) {
(task.since)
}
span .datestamp {
(task.datestamp.as_ref().map_or("never", |v| v))
}
form hx-post="/task/completion" hx-target="previous .datestamp" hx-vals=(json!({
"task_id": task.id,
"datestamp": Zoned::now().date().strftime("%F").to_string(),
})) hx-on::after-on-load=(format!("document.getElementById('since-{}').innerText = 0", task.id)) {
button { "Complete" }
}
}
}
}
},
))
}
}

80
src/web/mod.rs Normal file
View file

@ -0,0 +1,80 @@
use axum::RequestPartsExt;
use axum::extract::FromRequestParts;
use cache_bust::asset;
use http::request::Parts;
use maud::{DOCTYPE, Markup, html};
use super::models::user::{AuthSession, User};
use super::{AppError, AppState};
// use crate::db::DbId;
pub mod auth;
pub mod home;
pub mod task;
#[derive(Debug)]
pub struct Layout {
user: User,
}
impl FromRequestParts<AppState> for Layout {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_session = parts
.extract::<AuthSession>()
.await
.map_err(|_| anyhow::Error::msg("could not get session"))?;
let user = auth_session.user.unwrap();
Ok(Layout { user })
}
}
impl Layout {
pub fn render(
&self,
title: impl AsRef<str>,
css: Option<Vec<&str>>,
content: Markup,
) -> Markup {
html! {
(DOCTYPE)
html {
head {
title { (format!("{} | Entretien", title.as_ref())) }
// link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png")));
// link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png")));
// link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png")));
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
}
}
}
body {
main {
(content)
}
script type="text/javascript" { "htmx.config.responseTargetUnsetsError = false" }
}
}
}
}
}

247
src/web/task.rs Normal file
View file

@ -0,0 +1,247 @@
use axum::{
Form, Router,
extract::{State, path::Path},
response::IntoResponse,
routing::{get, post},
};
use cache_bust::asset;
use http::status::StatusCode;
use maud::{Markup, PreEscaped, html};
use regex::{Regex, RegexBuilder};
use serde::Deserialize;
use serde_json::json;
use sqlx::QueryBuilder;
use crate::models::user::AuthSession;
use crate::web::Layout;
use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> {
Router::new()
.route("/task/completion", post(self::post::task_completion))
.route(
"/task/bulk_completion",
post(self::post::bulk_task_completion),
)
.route("/task/description", post(self::post::task_description))
.route("/task/{task_id}", get(self::get::task))
.route("/task", post(self::post::task))
}
#[derive(Debug)]
struct Task {
id: DbId,
description: String,
frequency_id: DbId,
}
mod get {
use super::*;
pub async fn task(
auth_session: AuthSession,
State(state): State<AppState>,
Path(task_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let task = sqlx::query_as!(Task, "select * from tasks where id = ?", task_id)
.fetch_one(pool)
.await?;
let completions = sqlx::query!(
"select * from completions where task_id = ? order by datestamp desc",
task_id
)
.fetch_all(pool)
.await?;
Ok(layout.render(
&task.description,
Some(vec![asset!("task.css")]),
html! {
header { a href="/" { "Home" } }
main {
h1 x-data="{'edit':false}" {
span x-show="!edit" x-on:click="edit = true; $nextTick(() => { $refs.edit.focus(); $refs.edit.selectionStart = $refs.edit.selectionEnd = $refs.edit.value.length; })" {
(PreEscaped(markdown::to_html(&task.description)))
}
input #edit-description x-show="edit" x-on:focus="edit = true" autofocus x-ref="edit"
name="description" value=(task.description)
hx-post="/task/description" hx-vals=(json!({"task_id": task_id}))
hx-trigger="change,input changed delay:5s"
hx-target="previous span"
x-on:htmx:after-on-load="edit = false"
x-on:blur="edit = false";
}
form
hx-post="/task/completion"
hx-vals=(json!({"task_id": task_id}))
hx-target="#completions"
hx-swap="afterbegin" {
.col {
label for="when" { "when" }
input id="when" name="datestamp" type="date";
}
#error {}
button { "add completion" }
}
#completions {
@if completions.is_empty() {
div { "(never completed)" }
}
@for completion in completions {
div { (completion.datestamp) }
}
}
}
},
))
}
}
mod post {
use super::*;
#[derive(Deserialize)]
pub struct PostTaskBody {
description: String,
frequency: String,
}
pub async fn task(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostTaskBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let frequency_id = sqlx::query_scalar!(
"select id from frequencies where description = ?",
payload.frequency
)
.fetch_one(pool)
.await?;
sqlx::query!(
"insert into tasks (description, frequency_id) values (?, ?)",
payload.description,
frequency_id,
)
.execute(pool)
.await?;
Ok("")
}
#[derive(Deserialize)]
pub struct PostTaskCompletionBody {
task_id: DbId,
datestamp: String,
}
pub async fn task_completion(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostTaskCompletionBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query!(
"insert into completions (task_id, datestamp) values (?, ?)",
payload.task_id,
payload.datestamp
)
.execute(pool)
.await?;
Ok(html! { div { (payload.datestamp) }})
}
#[derive(Deserialize)]
pub struct PostTaskBulkCompletionBody {
regex: String,
datestamp: String,
count: usize,
}
pub async fn bulk_task_completion(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostTaskBulkCompletionBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let tasks: Vec<Task> = sqlx::query_as!(Task, "select * from tasks")
.fetch_all(pool)
.await?;
let normalize = Regex::new(r"[^\pL]").unwrap();
let regex = RegexBuilder::new(&payload.regex)
.case_insensitive(true)
.build()?;
tracing::debug!("regex {:?}", regex);
let matching: Vec<Task> = tasks
.into_iter()
.filter(|task| {
let normalized = normalize.replace_all(&task.description, "");
tracing::debug!("-> {} {:?}", regex.is_match(&normalized), normalized);
regex.is_match(&normalized)
})
.collect();
tracing::debug!("matching {:?}", matching);
if matching.len() != payload.count {
return Ok((
StatusCode::BAD_REQUEST,
PreEscaped(markdown::to_html(&format!(
"expected {} matches but got {}:\n{}",
payload.count,
matching.len(),
matching
.into_iter()
.map(|task| format!("* {}", task.description))
.collect::<Vec<String>>()
.join("\n")
))),
));
}
let mut qb =
QueryBuilder::<sqlx::Sqlite>::new("insert into completions (task_id, datestamp) ");
qb.push_values(matching, |mut b, task| {
b.push_bind(task.id).push_bind(&payload.datestamp);
});
qb.build().execute(pool).await?;
Ok((StatusCode::OK, html! {"ok"}))
}
#[derive(Deserialize)]
pub struct PostTaskDescriptionBody {
task_id: DbId,
description: String,
}
pub async fn task_description(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostTaskDescriptionBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query!(
"update tasks set description = ? where id = ?",
payload.description,
payload.task_id
)
.execute(pool)
.await?;
Ok(PreEscaped(markdown::to_html(&payload.description)))
}
}

5
static/alpinejs.min.js vendored Normal file

File diff suppressed because one or more lines are too long

48
static/home.css Normal file
View file

@ -0,0 +1,48 @@
#new-task,
#bulk-complete {
border: 1px solid var(--line-color);
border-radius: 100vh;
padding: 0.5em 1em;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
flex-wrap: wrap;
gap: 1em;
margin-bottom: 1em;
button {
padding: 0.2em 1em;
}
.error {
width: 100%;
}
}
#regex {
font-family: monospace;
}
.table {
display: grid;
grid-template-columns: auto repeat(3, max-content);
gap: 0.5rem;
align-items: center;
h1 {
margin-top: 1rem;
grid-column: 1 / -1;
font-weight: bold;
}
.th {
font-size: x-small;
font-weight: bold;
font-variant-caps: small-caps;
}
.th.span {
grid-column: 3 / -1;
}
}

View file

@ -0,0 +1 @@
(function(){var f;var o="hx-target-";function g(e,r){return e.substring(0,r.length)===r}function s(e,r){if(!e||!r)return null;var t=r.toString();var s=[t,t.substring(0,2)+"*",t.substring(0,2)+"x",t.substring(0,1)+"*",t.substring(0,1)+"x",t.substring(0,1)+"**",t.substring(0,1)+"xx","*","x","***","xxx"];if(g(t,"4")||g(t,"5")){s.push("error")}for(var n=0;n<s.length;n++){var i=o+s[n];var a=f.getClosestAttributeValue(e,i);if(a){if(a==="this"){return f.findThisElement(e,i)}else{return f.querySelectorExt(e,a)}}}return null}function n(e){if(e.detail.isError){if(htmx.config.responseTargetUnsetsError){e.detail.isError=false}}else if(htmx.config.responseTargetSetsError){e.detail.isError=true}}htmx.defineExtension("response-targets",{init:function(e){f=e;if(htmx.config.responseTargetUnsetsError===undefined){htmx.config.responseTargetUnsetsError=true}if(htmx.config.responseTargetSetsError===undefined){htmx.config.responseTargetSetsError=false}if(htmx.config.responseTargetPrefersExisting===undefined){htmx.config.responseTargetPrefersExisting=false}if(htmx.config.responseTargetPrefersRetargetHeader===undefined){htmx.config.responseTargetPrefersRetargetHeader=true}},onEvent:function(e,r){if(e==="htmx:beforeSwap"&&r.detail.xhr&&r.detail.xhr.status!==200){if(r.detail.target){if(htmx.config.responseTargetPrefersExisting){r.detail.shouldSwap=true;n(r);return true}if(htmx.config.responseTargetPrefersRetargetHeader&&r.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)){r.detail.shouldSwap=true;n(r);return true}}if(!r.detail.requestConfig){return true}var t=s(r.detail.requestConfig.elt,r.detail.xhr.status);if(t){n(r);r.detail.shouldSwap=true;r.detail.target=t}return true}}})})();

1
static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

60
static/index.css Normal file
View file

@ -0,0 +1,60 @@
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{box-sizing:border-box;margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0}
:root {
--color1: #c6ebbe;
--color2: #a9dbb8;
--color3: #7ca5b8;
--color4: #38369a;
--color5: #020887;
--main-bg-color: var(--color1);
--line-color: var(--color3);
--link-color: var(--color5);
--input-border-radius: 10px;
}
body {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 1em;
background-color: var(--main-bg-color);
}
main {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
max-width: 100%;
}
a, a:visited {
color: var(--link-color);
text-decoration: underline dotted;
}
[x-cloak] { display: none !important; }
button,input,select {
border-radius: var(--input-border-radius);
padding-left: var(--input-border-radius);
padding-right: var(--input-border-radius);
border-color: var(--color2);
}
input:focus {
outline: 3px solid var(--color4);
}
form label {
padding-left: var(--input-border-radius);
}
.col {
display: flex;
flex-direction: column;
}

20
static/login.css Normal file
View file

@ -0,0 +1,20 @@
body {
padding-top: 2em;
font-family: sans-serif;
}
form {
display: flex;
flex-direction: column;
}
input {
margin-bottom: 1.5em;
}
h1 {
font-size: x-large;
font-weight: bold;
margin-bottom: 2em;
}

34
static/task.css Normal file
View file

@ -0,0 +1,34 @@
h1 {
font-size: x-large;
font-weight: bold;
margin: 1em 0;
}
form {
border: 1px solid var(--line-color);
border-radius: 100vh;
padding: 0.5em 1em;
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 1em;
}
#completions {
margin-top: 1em;
display: flex;
flex-direction: column;
gap: 0.5em;
}
h1 input {
border: none;
border-bottom: 2px solid var(--line-color);
border-radius: 0;
background-color: inherit;
font-size: x-large;
}
h1 input:focus {
outline: none;
}