Initial commit; adaptation from mascarpone
This commit is contained in:
commit
3d45ec4b0a
36 changed files with 5877 additions and 0 deletions
14
.forgejo/workflows/test.yaml
Normal file
14
.forgejo/workflows/test.yaml
Normal 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
9
.gitignore
vendored
Normal 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
42
CHANGELOG.md
Normal 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
3933
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
48
Cargo.toml
Normal file
48
Cargo.toml
Normal 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
5
README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Rust web stack template
|
||||||
|
|
||||||
|
My favorite webapp stack right now
|
||||||
|
|
||||||
|
|
||||||
59
Taskfile
Executable file
59
Taskfile
Executable 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
23
build.rs
Normal 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
94
cliff.toml
Normal 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
22
e2e/README.md
Normal 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
47
e2e/Taskfile
Executable 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
37
e2e/custom-expects.ts
Normal 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
17
e2e/package.json
Normal 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
11
e2e/pages/util.ts
Normal 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
72
e2e/playwright.config.ts
Normal 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
67
e2e/pnpm-lock.yaml
generated
Normal 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
1
e2e/static
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../static
|
||||||
25
migrations/demo.sql
Normal file
25
migrations/demo.sql
Normal 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);
|
||||||
27
migrations/each_user/0001_task-tables.sql
Normal file
27
migrations/each_user/0001_task-tables.sql
Normal 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
|
||||||
|
);
|
||||||
6
migrations/users.db/01_create-users.sql
Normal file
6
migrations/users.db/01_create-users.sql
Normal 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
5
mise.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[tools]
|
||||||
|
"rust-analyzer" = "latest"
|
||||||
|
"jj" = "latest"
|
||||||
|
node = "24"
|
||||||
|
git-cliff = "latest"
|
||||||
37
src/db.rs
Normal file
37
src/db.rs
Normal 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
336
src/main.rs
Normal 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
1
src/models/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod user;
|
||||||
124
src/models/user.rs
Normal file
124
src/models/user.rs
Normal 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
129
src/web/auth.rs
Normal 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
190
src/web/home.rs
Normal 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
80
src/web/mod.rs
Normal 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
247
src/web/task.rs
Normal 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
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
48
static/home.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
static/htmx-ext-response-targets.js
Normal file
1
static/htmx-ext-response-targets.js
Normal 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
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
60
static/index.css
Normal 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
20
static/login.css
Normal 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
34
static/task.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue