major features update

This commit is contained in:
Robert Perce 2025-11-27 13:45:21 -06:00
parent 519fb49901
commit 4e2fab67c5
48 changed files with 3925 additions and 208 deletions

5
.gitignore vendored
View file

@ -1 +1,6 @@
/target
e2e/node_modules
e2e/playwright-report
e2e/test-results
some_user.db
dbs

914
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,40 @@ name = "mascarpone"
version = "0.1.0"
edition = "2024"
[build]
rustflags = ["--cfg=sqlx_macros_unstable"]
[dependencies]
maud = { version = "0.27.0", features = ["axum"] }
anyhow = "1.0.100"
axum = { version = "0.8.6", features = ["macros"] }
axum = { version = "0.8.6", features = ["macros", "form"] }
axum-extra = { version = "0.10.3", features = ["form"] }
axum-htmx = "0.8.1"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls-aws-lc-rs"] }
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tower-sessions = "0.14.0"
axum-login = "0.18.0"
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"
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"
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-http = { version = "0.6.6", features = ["fs"] }
tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
vcard = "0.4.13"
[dev-dependencies]
cargo-watch = "8.5.3"

View file

@ -3,34 +3,24 @@
I always write "cream cheese" on my grocery list as "crm chs", so that's what
I think of when I see "CRM".
## Planned Features
## Current features
* In-app contacts
* For each contact:
* Names
* Birthday
* Last-contact-time mapping
* Journal with Obsidian-like `[[link]]` syntax
* ical server for birthday reminders
* Local contacts
* Contacts stored on a remote CardDAV server
## Planned features
* Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server
* Act as CardDAV server for other clients
* For each contact:
* Name, address as single fields (plus code? lat/long? go crazy!)
* Address as single field (plus code? lat/long? go crazy!)
* Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar
* Relationship mapping
* Birthday reminders
* Desired contact periodicity
* Last-contact-time mapping
* Additional arbitrary fields
* Journal with Obsidian-like `[[link]]` syntax
* Additional arbitrary fields (no special handling)
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* "Named in journal but has no contact entry" detection
* CalDAV server for birthday reminders
* Email birthday reminders over SMTP
## Tech
axum: fast and scalable, lots of middleware from tower
axum-htmx: helpers when dealing with htmx headers
axum-login: user auth, has oauth2 and user permissions
tower-sessions: save user sessions (Redis, sqlite, memory, etc.)
fred: Redis client for user sessions
tracing: trace and instrument async logs
reqwest: for API calls, oath2
anyhow: turn any error into an AppError returning: “Internal Server Error”
maud: templating html, can split fragments into functions in a single file (LoB)
sqlx: dealing with a database, migrations, reverts

28
Taskfile Executable file
View file

@ -0,0 +1,28 @@
#!/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..."
sqlite3 some_user.db < "$migration"
done
}
deploy_to_server() {
where="$1"
refresh_sqlx_db
env DATABASE_URL=sqlite:some_user.db cargo build --release
rsync -v -essh ./target/release/mascarpone "$where:~" \
&& rsync -rav -essh ./static "$where:~" \
&& ssh -t "$where" "sudo mv -f mascarpone /usr/bin/ && sudo rm -rf /var/local/mascarpone/static && sudo mv -f static /var/local/mascarpone/ && sudo systemctl restart mascarpone"
}
"$@"

57
e2e/Taskfile Executable file
View file

@ -0,0 +1,57 @@
#!/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" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
bash -c "cd /e2e && env PROJECT_FILTER=firefox ./Taskfile _test $*"
}
playwright:ui() {
xhost +local:docker
exec docker run \
--interactive --tty --rm --ipc=host --net=host\
--env DISPLAY="$DISPLAY" \
--volume /tmp/.X11-unix:/tmp/.X11-unix \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
/bin/bash -c "cd /e2e && ./Taskfile _test --ui $*"
}
playwright:ci() {
exec docker run \
--interactive --tty --rm --ipc=host --net=host \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"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 "$@"
}
"$@"

16
e2e/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "mascarpone/e2e",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "playwright test --project=firefox && playwright test"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "^24.9.1"
}
}

49
e2e/pages/home.spec.ts Normal file
View file

@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { login, verifyCreateUser, todate } from './util';
test('can log out', async ({ page }) => {
await login(page);
await page.getByText("Logout").click();
await expect(page.getByLabel("Username")).toBeVisible();
});
test('has no contacts', async ({ page }) => {
await login(page);
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0);
});
test('can add contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack Contact'] });
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
});
test('shows "never" for unfreshened contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect(page.locator('#freshness')).toContainText('John Contactnever');
});
test('shows the date for fresh contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: /fresh/i }).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`);
});
test('sidebar is sorted alphabetically', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['Zulu'] });
await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['Golf'] });
await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
});

37
e2e/pages/index.spec.ts Normal file
View file

@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Mascarpone/);
});
test('disbles submit button with empty fields', async ({ page }) => {
await page.goto('/');
// Submit button should require both fields
// to be non-empty
await expect(page.getByRole("button")).toBeDisabled();
await page.getByLabel("Username").fill("bogus");
await expect(page.getByRole("button")).toBeDisabled();
await page.getByLabel("Username").clear();
await page.getByLabel("Password").fill("bogus");
await expect(page.getByRole("button")).toBeDisabled();
await page.getByLabel("Username").fill("bogus");
await expect(page.getByRole("button")).not.toBeDisabled();
});
test('has error message for invalid login', async ({ page }) => {
await page.goto('/');
await page.getByLabel("Username").fill("bogus");
await page.getByLabel("Password").fill("bogus");
await page.getByRole("button").click();
await expect(page.getByText(/do not match/i)).toBeVisible();
});

117
e2e/pages/journal.spec.ts Normal file
View file

@ -0,0 +1,117 @@
import { test, expect } from '@playwright/test';
import { login, verifyCreateUser, todate } from './util';
test('can add journal entries', async ({ page }) => {
await login(page);
const entryBox = page.getByPlaceholder(/new entry/i);
await entryBox.fill('banana banana banana');
await page.getByRole('button', { name: /add entry/i }).click();
await expect(entryBox).toBeEmpty();
await expect(page.getByText('banana banana banana')).toBeVisible();
});
test('journal entries autolink', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
await page.getByRole('button', { name: /add entry/i }).click();
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
});
test("changing a contact's names updates journal entries", async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack Contact'] });
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
await page.getByRole('button', { name: /add entry/i }).click();
const nav = page.getByRole('navigation');
const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// add a new name
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
console.log(await journal.innerHTML());
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// put it back, then...
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// ...add a name that makes it no longer n=1
await nav.getByRole("link", { name: 'Jack Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.getByRole('button', { name: /save/i }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// delete a name that makes it now n=1
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
});
test('can edit existing journal entries on home page', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
await page.getByRole('button', { name: /add entry/i }).click();
await page.reload();
await page.getByRole('checkbox', { name: /edit/i }).click();
const textbox = page.locator('form').filter({
has: page.getByRole('button', { name: '✓' })
}).locator('textarea');
await textbox.fill('met with [[John Contact]]');
await page.getByRole('button', { name: '✓' }).click();
await page.getByRole('checkbox', { name: /edit/i }).click();
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1);
});
test('can have multiple links', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['alice'] });
await verifyCreateUser(page, { names: ['bob'] });
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
await page.getByRole('button', { name: /add entry/i }).click();
const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);
await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1);
});

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

@ -0,0 +1,31 @@
import type { Page } from '@playwright/test';
export const login = async (page: Page) => {
await page.goto('/');
await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click();
};
export const todate = () => new Date().toISOString().split('T')[0];
type UserFields = {
names?: Array<string>,
birthday?: string,
};
export const verifyCreateUser = async (page: Page, fields: UserFields) => {
await page.getByRole('button', { name: /add contact/i }).click();
const { names, ...simple } = fields;
for (const name of (names ?? [])) {
await page.getByRole('textbox', { name: 'New name' }).fill(name);
await page.getByRole('button', { name: 'Add', exact: true }).click();
}
for (const [label, value] of Object.entries(simple)) {
await page.getByLabel(label).fill(value);
}
await page.getByRole('button', { name: /save/i }).click();
};

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

@ -0,0 +1,71 @@
import { defineConfig, devices } from '@playwright/test';
// 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,
});

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

@ -0,0 +1,57 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
devDependencies:
'@playwright/test':
specifier: ^1.56.1
version: 1.56.1
'@types/node':
specifier: ^24.9.1
version: 24.9.1
packages:
/@playwright/test@1.56.1:
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
dependencies:
playwright: 1.56.1
dev: true
/@types/node@24.9.1:
resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==}
dependencies:
undici-types: 7.16.0
dev: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
dev: true
/playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
dependencies:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
dev: true
/undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
dev: true

BIN
e2e/users.db Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
create table if not exists contacts (
id integer primary key autoincrement,
birthday text,
manually_freshened_at date -- text, iso8601 date
);
create table if not exists names (
id integer primary key,
contact_id integer not null references contacts(id) on delete cascade,
sort integer not null,
name text not null
);

View file

@ -0,0 +1,13 @@
create table if not exists journal_entries (
id integer primary key autoincrement,
value text not null,
date text not null
);
create table if not exists contact_mentions (
entry_id integer not null references journal_entries(id) on delete cascade,
contact_id integer not null references contacts(id) on delete cascade,
input_text text not null,
byte_range_start integer not null,
byte_range_end integer not null
);

View file

@ -0,0 +1,33 @@
INSERT INTO contacts(id, birthday, manually_freshened_at)
values (0, '--0415', '2000-01-01T12:00:00');
INSERT INTO names(contact_id, sort, name)
values (0, 0, 'Alex Aaronson');
INSERT INTO names(contact_id, sort, name)
values (0, 1, 'Alexi');
INSERT INTO names(contact_id, sort, name)
values (0, 2, 'Алексей');
INSERT INTO contacts(id, birthday)
values (1, 'April?');
INSERT INTO names(contact_id, sort, name)
values (1, 0, 'Bazel Bagend');
INSERT INTO contacts(id, birthday)
values (2, '19951018');
INSERT INTO names(contact_id, sort, name)
values (2, 0, 'Charlie Certaindate');
insert into journal_entries(id, date, value)
values (0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife');
insert into journal_entries(id, value, date)
values (1, 'Dinner with [[Alexi]]', '2025-10-10');
insert into journal_entries(id, date, value)
values (2, '2022-06-06', 'Movies with [[Daniel Doesn''texist]] et al');
insert into contact_mentions(
entry_id, contact_id, input_text,
byte_range_start, byte_range_end
) values (0, 1, 'Bazel Bagend', 11, 27);
insert into contact_mentions(
entry_id, contact_id, input_text,
byte_range_start, byte_range_end
) values (1, 0, 'Alexi', 12, 21);

View file

@ -0,0 +1,6 @@
create table if not exists settings (
id integer primary key,
ics_path text
);
insert into settings (id) values (1) on conflict (id) do nothing;

View file

@ -0,0 +1,6 @@
create table if not exists addresses (
id integer primary key,
contact_id integer not null references contacts(id) on delete cascade,
label text,
value text not null
);

View file

@ -0,0 +1,12 @@
create table if not exists contacts (
id integer primary key autoincrement,
birthday text,
manually_freshened_at date -- text, iso8601 date
);
create table if not exists names (
id integer primary key,
contact_id integer not null references contacts(id) on delete cascade,
sort integer not null,
name text not null
);

View file

@ -0,0 +1,13 @@
create table if not exists journal_entries (
id integer primary key autoincrement,
value text not null,
date text not null
);
create table if not exists contact_mentions (
entry_id integer not null references journal_entries(id) on delete cascade,
contact_id integer not null references contacts(id) on delete cascade,
input_text text not null,
byte_range_start integer not null,
byte_range_end integer not null
);

View file

@ -0,0 +1,6 @@
create table if not exists settings (
id integer primary key,
ics_path text
);
insert into settings (id) values (1) on conflict (id) do nothing;

View file

@ -0,0 +1,6 @@
create table if not exists addresses (
id integer primary key,
contact_id integer not null references contacts(id) on delete cascade,
label text,
value text not null
);

View file

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

View file

@ -0,0 +1,5 @@
alter table users add column
ephemeral boolean not null default false
;
update users set ephemeral = false where ephemeral is null;

View file

@ -3,6 +3,4 @@
"watchexec" = "latest"
"rust-analyzer" = "latest"
"jj" = "latest"
[tasks."serve:dev"]
run = "systemfd --no-pid -s http::0.0.0.0:3000 -- watchexec -r cargo run"
"pnpm" = "latest"

View file

@ -1,4 +0,0 @@
#[derive(Clone)]
pub struct Contact {
pub name: String,
}

35
src/db.rs Normal file
View file

@ -0,0 +1,35 @@
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?;
let migrator = if user.username == "demo" {
sqlx::migrate!("./migrations/demo.db/")
} else {
sqlx::migrate!("./migrations/each_user/")
};
migrator.run(&pool).await?;
Ok(Self { pool })
}
}

View file

@ -1,46 +1,189 @@
use axum::{Router, extract::State, response::IntoResponse, routing::get};
use maud::html;
use axum::Router;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum_login::AuthUser;
use axum_login::{AuthManagerLayerBuilder, login_required};
use clap::{Parser, Subcommand, arg, command};
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_http::services::ServeDir;
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod contact;
use contact::Contact;
mod models;
use models::contact::ContactTrie;
use models::user::{Backend, User};
mod db;
use db::{Database, DbId};
mod web;
use web::{auth, contact, home, ics, journal, settings};
#[derive(Clone)]
struct AppStateEntry {
database: Arc<Database>,
contact_search: Arc<RwLock<ContactTrie>>,
}
#[derive(Clone)]
struct AppState {
contacts: Vec<Contact>,
map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>,
}
#[axum::debug_handler]
async fn contacts(
// access the state via the `State` extractor
// extracting a state of the wrong type results in a compile error
State(state): State<AppState>,
) -> impl IntoResponse {
html! {
ul {
@for contact in &state.contacts {
li { (&contact.name) }
struct NameReference {
name: String,
contact_id: DbId,
}
impl AppState {
pub fn new() -> Self {
AppState {
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 trie = radix_trie::Trie::new();
let rows = sqlx::query_as!(
NameReference,
"select name, contact_id from (
select contact_id, name, count(name) as ct from names group by name
) where ct = 1;",
)
.fetch_all(&database.pool)
.await?;
for row in rows {
trie.insert(row.name, DbId::try_from(row.contact_id)?);
}
let mut map = self.map.write().expect("rwlock poisoned");
Ok(map.insert(
user.id(),
crate::AppStateEntry {
database: Arc::new(database),
contact_search: Arc::new(RwLock::new(trie)),
},
))
}
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()
}
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<ContactTrie>> {
let map = self.map.read().expect("rwlock poisoned");
map.get(&user.id()).unwrap().contact_search.clone()
}
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let state = AppState {
contacts: vec![
Contact {
name: "Foo Bar".to_string(),
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 mascarpone server (default)
Serve {
/// port to bind
#[arg(short, long, default_value_t = 3000)]
port: u32,
},
Contact {
name: "Baz Qux".to_string(),
SetPassword {
/// username to create or set password
username: String,
},
],
}
async fn serve(port: &u32) -> 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();
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()
.route("/", get(|| async { "Hello, World!" }))
.route("/contacts", get(contacts))
.route("/", get(home::get::home))
.merge(contact::router())
.merge(journal::router())
.merge(settings::router())
.route_layer(login_required!(Backend, login_url = "/login"))
.merge(auth::router())
.merge(ics::router())
.nest_service("/static", ServeDir::new("./static"))
.layer(auth_layer)
.with_state(state);
let mut listenfd = listenfd::ListenFd::from_env();
@ -49,9 +192,83 @@ async fn main() -> Result<(), anyhow::Error> {
listener.set_nonblocking(true)?;
TcpListener::from_std(listener)
}
None => TcpListener::bind("0.0.0.0:3000").await,
None => TcpListener::bind(format!("0.0.0.0:{}", port)).await,
}?;
axum::serve(listener, app).await.unwrap();
tracing::debug!("Starting axum on 0.0.0.0:3000...");
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::Serve { port }) => {
serve(port).await?;
}
None => {
serve(&3000).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() },
}
}

13
src/models.rs Normal file
View file

@ -0,0 +1,13 @@
pub mod contact;
pub use contact::HydratedContact;
mod year_optional_date;
pub use year_optional_date::YearOptionalDate;
mod birthday;
pub use birthday::Birthday;
mod journal;
pub use journal::JournalEntry;
pub mod user;

93
src/models/birthday.rs Normal file
View file

@ -0,0 +1,93 @@
use chrono::Local;
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row};
use std::fmt::Display;
use std::str::FromStr;
use crate::models::YearOptionalDate;
#[derive(Debug, Clone)]
pub struct Text {
// language: Option<String>,
pub value: String,
}
#[derive(Debug, Clone)]
pub enum Birthday {
Date(YearOptionalDate),
Text(Text),
}
impl Display for Birthday {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let str = match self {
Birthday::Date(date) => date.to_string(),
Birthday::Text(t) => t.value.clone(),
};
write!(f, "{}", str)
}
}
impl Birthday {
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
}
}
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
self.next_occurrence()
.map(|when| when.signed_duration_since(Local::now().date_naive()))
}
/// None if this is a text birthday or doesn't have a year
pub fn age(&self) -> Option<u32> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => date
.to_date_naive()
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
}
}
pub fn serialize(&self) -> String {
match &self {
Birthday::Text(text) => text.value.clone(),
Birthday::Date(date) => date.serialize(),
}
}
}
impl FromStr for Birthday {
type Err = ();
fn from_str(str: &str) -> Result<Self, Self::Err> {
if let Some(date) = YearOptionalDate::from_str(str).ok() {
Ok(Birthday::Date(date))
} else {
Ok(Birthday::Text(super::birthday::Text {
value: str.to_string(),
}))
}
}
}
impl FromRow<'_, SqliteRow> for Birthday {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let birthday_str = row.try_get("birthday")?;
Ok(Birthday::from_str(birthday_str).unwrap())
}
}
use sqlx::{Database, Decode, Sqlite};
impl<'r> Decode<'r, Sqlite> for Birthday
where
&'r str: Decode<'r, Sqlite>,
{
fn decode(
value: <Sqlite as Database>::ValueRef<'r>,
) -> Result<Birthday, Box<dyn std::error::Error + 'static + Send + Sync>> {
let value = <&str as Decode<Sqlite>>::decode(value)?;
Ok(Birthday::from_str(value).unwrap())
}
}

86
src/models/contact.rs Normal file
View file

@ -0,0 +1,86 @@
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row};
use std::str::FromStr;
use super::Birthday;
use crate::db::DbId;
#[derive(Clone, Debug)]
pub struct Contact {
pub id: DbId,
pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<NaiveDate>,
pub names: Vec<String>,
}
impl std::ops::Deref for HydratedContact {
type Target = Contact;
fn deref(&self) -> &Self::Target {
&self.contact
}
}
impl HydratedContact {
pub fn display_name(&self) -> String {
if let Some(name) = self.names.first() {
name.clone()
} else {
"(unnamed)".to_string()
}
}
}
pub type ContactTrie = radix_trie::Trie<String, DbId>;
impl FromRow<'_, SqliteRow> for Contact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let id: DbId = row.try_get("id")?;
let birthday = Birthday::from_row(row).ok();
let manually_freshened_at = row
.try_get::<String, &str>("manually_freshened_at")
.ok()
.and_then(|str| {
DateTime::parse_from_str(&str, "%+")
.ok()
.map(|d| d.to_utc())
});
Ok(Self {
id,
birthday,
manually_freshened_at,
})
}
}
impl FromRow<'_, SqliteRow> for HydratedContact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let contact = Contact::from_row(row)?;
let names_str: String = row.try_get("names").unwrap_or("".to_string());
let names = if names_str.is_empty() {
vec![]
} else {
names_str.split('\x1c').map(|s| s.to_string()).collect()
};
let last_mention_date = row
.try_get::<String, &str>("last_mention_date")
.ok()
.and_then(|str| NaiveDate::from_str(&str).ok());
Ok(Self {
contact,
names,
last_mention_date,
})
}
}

144
src/models/journal.rs Normal file
View file

@ -0,0 +1,144 @@
use chrono::NaiveDate;
use maud::{Markup, PreEscaped, html};
use regex::Regex;
use serde_json::json;
use sqlx::sqlite::{SqlitePool, SqliteRow};
use sqlx::{FromRow, Row};
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use super::contact::ContactTrie;
use crate::AppError;
use crate::db::DbId;
#[derive(Debug)]
pub struct JournalEntry {
pub id: DbId,
pub value: String,
pub date: NaiveDate,
}
#[derive(Debug, PartialEq, Eq, Hash, FromRow)]
pub struct ContactMention {
pub entry_id: DbId,
pub contact_id: DbId,
pub input_text: String,
pub byte_range_start: u32,
pub byte_range_end: u32,
}
impl JournalEntry {
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> {
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
name_re
.captures_iter(&self.value)
.map(|caps| {
let range = caps.get_match().range();
trie.get(&caps[1]).map(|cid| ContactMention {
entry_id: self.id,
contact_id: cid.to_owned(),
input_text: caps[1].to_string(),
byte_range_start: u32::try_from(range.start).unwrap(),
byte_range_end: u32::try_from(range.end).unwrap(),
})
})
.filter(|o| o.is_some())
.map(|o| o.unwrap())
.collect()
}
pub async fn insert_mentions(
&self,
trie: Arc<RwLock<ContactTrie>>,
pool: &SqlitePool,
) -> Result<HashSet<ContactMention>, AppError> {
let mentions = {
let trie = trie.read().unwrap();
self.extract_mentions(&trie)
};
for mention in &mentions {
sqlx::query!(
"insert into contact_mentions(
entry_id, contact_id, input_text,
byte_range_start, byte_range_end
) values ($1, $2, $3, $4, $5)",
mention.entry_id,
mention.contact_id,
mention.input_text,
mention.byte_range_start,
mention.byte_range_end
)
.execute(pool)
.await?;
}
Ok(mentions)
}
pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
// important to sort desc so that changing contents early in the string
// doesn't break inserting mentions at byte offsets further in
let mentions: Vec<ContactMention> = sqlx::query_as(
"select * from contact_mentions
where entry_id = $1 order by byte_range_start desc",
)
.bind(self.id)
.fetch_all(pool)
.await?;
let mut value = self.value.clone();
for mention in mentions {
value.replace_range(
(mention.byte_range_start as usize)..(mention.byte_range_end as usize),
&format!("[{}](/contact/{})", mention.input_text, mention.contact_id),
);
}
let entry_url = format!("/journal_entry/{}", self.id);
let date = self.date.to_string();
Ok(html! {
.entry {
.view ":class"="{ hide: edit }" {
.date { (date) }
.content { (PreEscaped(markdown::to_html(&value))) }
}
form .edit ":class"="{ hide: !edit }" x-data=(json!({ "date": date, "initial_date": date, "value": self.value, "initial_value": self.value })) {
input name="date" x-model="date";
.controls {
textarea name="value" x-model="value" {}
button title="Delete"
hx-delete=(entry_url)
hx-target="closest .entry"
hx-swap="delete" {
svg .icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" {
path d="M232.7 69.9C237.1 56.8 249.3 48 263.1 48L377 48C390.8 48 403 56.8 407.4 69.9L416 96L512 96C529.7 96 544 110.3 544 128C544 145.7 529.7 160 512 160L128 160C110.3 160 96 145.7 96 128C96 110.3 110.3 96 128 96L224 96L232.7 69.9zM128 208L512 208L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 208zM216 272C202.7 272 192 282.7 192 296L192 488C192 501.3 202.7 512 216 512C229.3 512 240 501.3 240 488L240 296C240 282.7 229.3 272 216 272zM320 272C306.7 272 296 282.7 296 296L296 488C296 501.3 306.7 512 320 512C333.3 512 344 501.3 344 488L344 296C344 282.7 333.3 272 320 272zM424 272C410.7 272 400 282.7 400 296L400 488C400 501.3 410.7 512 424 512C437.3 512 448 501.3 448 488L448 296C448 282.7 437.3 272 424 272z";
}
}
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
x-on:click="initial_date = date; initial_value = value"
hx-patch=(entry_url)
hx-target="previous .entry"
hx-swap="outerHTML"
title="Save" { "" }
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
title="Discard changes"
x-on:click="date = initial_date; value = initial_value"
{ "" }
}
}
}
})
}
}
impl FromRow<'_, SqliteRow> for JournalEntry {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let id: DbId = row.try_get("id")?;
let value: String = row.try_get("value")?;
let date_str: &str = row.try_get("date")?;
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
Ok(Self { id, value, date })
}
}

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

@ -0,0 +1,126 @@
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(|| {
// We're using password-based authentication--this works by comparing our form
// input with an argon2 password hash.
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>;

View file

@ -0,0 +1,114 @@
use chrono::{Datelike, Local, NaiveDate};
use regex::Regex;
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
use std::fmt::Display;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct YearOptionalDate {
pub year: Option<i32>,
pub month: u32,
pub day: u32,
}
impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date >= now.date_naive() {
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
}
}
date
}
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date < now.date_naive() {
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
}
}
date
}
pub fn to_date_naive(&self) -> Option<NaiveDate> {
if let Some(year) = self.year {
NaiveDate::from_ymd_opt(year, self.month, self.day)
} else {
None
}
}
pub fn serialize(&self) -> String {
format!(
"{}{:0>2}{:0>2}",
self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)),
self.month,
self.day
)
}
}
impl Display for YearOptionalDate {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(year) = self.year {
write!(f, "{:0>4}-", year)?;
}
write!(f, "{:0>2}-{:0>2}", self.month, self.day)
}
}
impl FromStr for YearOptionalDate {
type Err = anyhow::Error;
fn from_str(str: &str) -> Result<Self, Self::Err> {
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
if let Some(caps) = date_re.captures(str) {
let year_str = &caps[1];
let month = u32::from_str(&caps[2]).unwrap();
let day = u32::from_str(&caps[3]).unwrap();
let year = if year_str == "--" {
None
} else {
Some(i32::from_str(year_str).unwrap())
};
return Ok(Self { year, month, day });
}
Err(anyhow::Error::msg(format!(
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/",
str
)))
}
}
// `'r` is the lifetime of the `Row` being decoded
impl<'r, DB: Database> Decode<'r, DB> for YearOptionalDate
where
// we want to delegate some of the work to string decoding so let's make sure strings
// are supported by the database
&'r str: Decode<'r, DB>,
{
fn decode(
value: <DB as Database>::ValueRef<'r>,
) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
let value = <&str as Decode<DB>>::decode(value)?;
Ok(value.parse()?)
}
}
impl<'r> Encode<'r, Sqlite> for YearOptionalDate
where
&'r str: Encode<'r, Sqlite>,
{
fn encode_by_ref(
&self,
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
}
}

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

@ -0,0 +1,114 @@
use axum::extract::State;
use axum::http::HeaderMap;
use axum::{
Form, Router,
extract::Query,
response::{IntoResponse, Redirect},
routing::{get, post},
};
use maud::{DOCTYPE, html};
use serde::Deserialize;
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(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 {
meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
link rel="stylesheet" type="text/css" href="/static/index.css";
link rel="stylesheet" type="text/css" href="/static/login.css";
title { "Mascarpone" }
}
body hx-ext="response-targets" {
h1 { "Mascarpone" }
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" {
label for="username" { "Username" }
input name="username" #username autofocus x-model="user";
label for="password" { "Password" }
input name="password" #password type="password" x-model="pass";
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)";
#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())
}
}

443
src/web/contact.rs Normal file
View file

@ -0,0 +1,443 @@
use axum::{
Router,
extract::{State, path::Path},
http::HeaderMap,
response::IntoResponse,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
use chrono::DateTime;
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use serde_json::json;
use sqlx::{QueryBuilder, Sqlite};
use super::Layout;
use super::home::journal_section;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry};
use crate::{AppError, AppState};
#[derive(serde::Serialize)]
pub struct Address {
pub id: DbId,
pub contact_id: DbId,
pub label: Option<String>,
pub value: String,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/contact/new", post(self::post::contact))
.route("/contact/{contact_id}", get(self::get::contact))
.route("/contact/{contact_id}", put(self::put::contact))
.route("/contact/{contact_id}", delete(self::delete::contact))
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
}
fn human_delta(delta: &chrono::TimeDelta) -> String {
if delta.num_days() == 0 {
return "today".to_string();
}
let mut result = "in ".to_string();
let mut rem = delta.clone();
if rem.num_days().abs() >= 7 {
let weeks = rem.num_days() / 7;
rem -= chrono::TimeDelta::days(weeks * 7);
result.push_str(&format!("{}w ", weeks));
}
if rem.num_days().abs() > 0 {
result.push_str(&format!("{}d ", rem.num_days()));
}
result.trim().to_string()
}
mod get {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names
from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let entries: Vec<JournalEntry> = sqlx::query_as(
"select j.id, j.value, j.date from journal_entries j
join contact_mentions cm on j.id = cm.entry_id
where cm.contact_id = $1",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
Ok(layout.render(
Some(vec!["/static/contact.css", "/static/journal.css"]),
html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div id="fields" {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div {
@for name in &contact.names {
div { (name) }
}
}
@if let Some(bday) = &contact.birthday {
label { "birthday" }
div {
(bday.to_string())
@if let Some(delta) = &bday.until_next() {
" ("
(human_delta(delta))
@if let Some(age) = &bday.age() {
", turning " (age + 1)
}
")"
}
}
}
label { "freshened" }
div {
@if let Some(when) = &contact.manually_freshened_at {
(when.date_naive().to_string())
} @else {
"(never)"
}
}
@if addresses.len() == 1 {
label { "address" }
#addresses {
.value { (addresses[0].value) }
}
} @else if addresses.len() > 0 {
label { "addresses" }
#addresses {
@for address in addresses {
.label {
span { (address.label.unwrap_or(String::new())) }
// raw nbsp instead of col-gap since i want no
// gap when all labels are empty
span { (PreEscaped("&nbsp;")) }
}
.value { (address.value) }
}
}
}
}
(journal_section(pool, &entries).await?)
},
))
}
pub async fn contact_edit(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id
order by jes.date desc limit 1
) as last_mention_date from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
let cid_url = format!("/contact/{}", contact.id);
let mfresh_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| m.to_rfc3339());
Ok(layout.render(Some(vec!["/static/contact.css"]), html! {
form hx-ext="response-targets" {
div {
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
input type="button" value="Delete" hx-delete=(cid_url) hx-target-error="#error";
div #error;
}
div #fields {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" {
div {
input name="name" x-model="name";
input type="button" value="×" x-bind:disabled="idx == 0" x-on:click="names.splice(idx, 1)";
input type="button" value="" x-bind:disabled="idx == 0" x-on:click="[names[idx-1], names[idx]] = [names[idx], names[idx-1]]";
input type="button" value="" x-bind:disabled="idx == names.length - 1" x-on:click="[names[idx+1], names[idx]] = [names[idx], names[idx+1]]";
}
}
div {
input name="name" x-model="new_name" placeholder="New name";
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
}
}
label { "birthday" }
div {
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
}
label { "freshened" }
div x-data=(json!({ "date": mfresh_str })) {
input type="hidden" name="manually_freshened_at" x-model="date";
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
}
label { "addresses" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" {
div {
input name="address_label" x-show="addresses.length > 1" x-model="address.label" placeholder="label";
input name="address_value" x-model="address.value" placeholder="address";
}
}
div {
input x-show="addresses.length > 1" name="address_label" x-model="new_label" placeholder="label";
input name="address_value" x-model="new_address" placeholder="new address";
}
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
}
}
}
}))
}
}
mod post {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contact_id: (u32,) =
sqlx::query_as("insert into contacts (birthday) values (null) returning id")
.fetch_one(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert(
"HX-Redirect",
format!("/contact/{}/edit", contact_id.0).parse()?,
);
Ok((headers, "ok"))
}
}
mod put {
use super::*;
#[derive(Deserialize)]
pub struct PutContact {
name: Option<Vec<String>>,
birthday: String,
manually_freshened_at: String,
address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>,
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
Form(payload): Form<PutContact>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let birthday = if payload.birthday.is_empty() {
None
} else {
Some(payload.birthday)
};
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
None
} else {
Some(
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_utc()
.to_rfc3339(),
)
};
sqlx::query!(
"update contacts set (birthday, manually_freshened_at) = ($1, $2) where id = $3",
birthday,
manually_freshened_at,
contact_id
)
.execute(pool)
.await?;
{
// update addresses
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
.execute(pool)
.await?;
if let Some(values) = payload.address_value {
let labels = if values.len() == 1 {
Some(vec![String::new()])
} else {
payload.address_label
};
if let Some(labels) = labels {
let new_addresses = labels
.into_iter()
.zip(values)
.filter(|(_, val)| val.len() > 0);
for (label, value) in new_addresses {
sqlx::query!(
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
contact_id,
label,
value
)
.execute(pool)
.await?;
}
}
}
}
let old_names: Vec<(String,)> = sqlx::query_as(
"delete from contact_mentions;
delete from names where contact_id = $1 returning name;",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
"select name, contact_id from (
select name, contact_id, count(name) as ct from names where name in (",
);
let mut name_list = recalc_counts.separated(", ");
for (name,) in &old_names {
name_list.push_bind(name);
}
if let Some(names) = payload.name {
let names: Vec<String> = names.into_iter().filter(|n| n.len() > 0).collect();
if !names.is_empty() {
for name in &names {
name_list.push_bind(name.clone());
}
let mut name_insert: QueryBuilder<Sqlite> =
QueryBuilder::new("insert into names (contact_id, sort, name) ");
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
builder
.push_bind(contact_id)
.push_bind(DbId::try_from(sort).unwrap())
.push_bind(name);
});
name_insert.build().persistent(false).execute(pool).await?;
}
}
name_list.push_unseparated(") group by name) where ct = 1");
let recalc_names: Vec<(String, DbId)> = recalc_counts
.build_query_as()
.persistent(false)
.fetch_all(pool)
.await?;
{
let trie_mutex = state.contact_search(&user);
let mut trie = trie_mutex.write().unwrap();
for name in &old_names {
trie.remove(&name.0);
}
for name in recalc_names {
trie.insert(name.0, name.1);
}
}
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
.fetch_all(pool)
.await?;
for entry in journal_entries {
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
}
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
Ok((headers, "ok"))
}
}
mod delete {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query(
"delete from contact_mentions where contact_id = $1;
delete from names where contact_id = $1;
delete from contacts where id = $1;",
)
.bind(contact_id)
.execute(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);
Ok((headers, "ok"))
}
}

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

@ -0,0 +1,241 @@
use axum::extract::State;
use axum::response::IntoResponse;
use chrono::{Local, NaiveDate, TimeDelta};
use maud::{Markup, html};
use sqlx::sqlite::SqlitePool;
use super::Layout;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{Birthday, HydratedContact, JournalEntry};
use crate::{AppError, AppState};
#[derive(Debug, Clone)]
struct ContactFreshness {
contact_id: DbId,
display: String,
fresh_date: NaiveDate,
fresh_str: String,
elapsed_str: String,
}
fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppError> {
Ok(html! {
div id="freshness" {
h2 { "Stale Contacts" }
div class="grid" {
span .th { "name" }
span .th { "freshened" }
span .th { "elapsed" }
@for contact in &freshens[0..std::cmp::min(5, freshens.len())] {
span {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
}
span { (contact.fresh_str) }
span { (contact.elapsed_str) }
}
}
}
})
}
#[derive(Debug, Clone)]
struct KnownBirthdayContact {
contact_id: i64,
display: String,
prev_birthday: NaiveDate,
next_birthday: NaiveDate,
}
fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>,
upcoming_birthdays: &Vec<KnownBirthdayContact>,
) -> Result<Markup, AppError> {
Ok(html! {
div id="birthdays" {
h2 { "Birthdays" }
#birthday-sections {
.datelist {
h3 { "upcoming" }
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
span {
(contact.next_birthday.format("%m-%d"))
}
}
}
.datelist {
h3 { "recent" }
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
span {
(contact.prev_birthday.format("%m-%d"))
}
}
}
}
}
})
}
pub async fn journal_section(
pool: &SqlitePool,
entries: &Vec<JournalEntry>,
) -> Result<Markup, AppError> {
Ok(html! {
div id="journal" x-data="{ edit: false }" {
header {
h2 { "Journal" }
input id="journal-edit-mode" type="checkbox" x-model="edit" {
label for="journal-edit-mode" { "Edit" }
}
}
.disclaimer {
"Leave off year or year and month in the date field to default to what they
are now, or leave everything blank to default to 'today'. Entries will be
added to the top of the list regardless of date; refresh the page to re-sort."
}
form hx-post="/journal_entry" hx-target="next .entries" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
input name="date" placeholder=(Local::now().date_naive().to_string());
textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry";
}
.entries {
@for entry in entries {
(entry.to_html(pool).await?)
}
}
}
})
}
pub mod get {
use super::*;
pub async fn home(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contacts: Vec<HydratedContact> = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id
order by jes.date desc limit 1
) as last_mention_date from contacts c",
)
.fetch_all(pool)
.await?;
let mut freshens: Vec<ContactFreshness> = contacts
.clone()
.into_iter()
.map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap();
let fresh_date = std::cmp::max(
contact
.manually_freshened_at
.map(|x| x.date_naive())
.unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero),
);
if fresh_date == zero {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: "never".to_string(),
elapsed_str: "".to_string(),
}
} else {
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
let mut elapsed: Vec<String> = Vec::new();
let y = duration.num_weeks() / 52;
let count = |n: i64, noun: &str| {
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
};
if y > 0 {
elapsed.push(count(y, "year"));
duration -= TimeDelta::weeks(y * 52);
}
let w = duration.num_weeks();
if w > 0 {
elapsed.push(count(w, "week"));
duration -= TimeDelta::weeks(w);
}
let d = duration.num_days();
if d > 0 {
elapsed.push(count(d, "day"));
}
let elapsed_str = if elapsed.is_empty() {
"today".to_string()
} else {
elapsed.join(", ")
};
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: fresh_date.to_string(),
elapsed_str,
}
}
})
.collect();
freshens.sort_by_key(|x| x.fresh_date);
let birthdays = contacts
.into_iter()
.map(|contact| {
if let Some(Birthday::Date(date)) = &contact.birthday {
Some(KnownBirthdayContact {
contact_id: contact.id,
display: contact.display_name(),
prev_birthday: date.prev_month_day_occurrence().unwrap(),
next_birthday: date.next_month_day_occurrence().unwrap(),
})
} else {
None
}
})
.filter(|x| x.is_some())
.map(|x| x.unwrap())
.collect::<Vec<KnownBirthdayContact>>();
let mut prev_birthdays = birthdays.clone();
prev_birthdays.sort_by_key(|x| x.prev_birthday);
prev_birthdays.reverse();
let mut upcoming_birthdays = birthdays;
upcoming_birthdays.sort_by_key(|x| x.next_birthday);
// I'm writing this as an n+1 query pattern deliberately
// since I *think* the overhead of string_agg+split might
// be worse than that of the n+1 since we're in sqlite.
let entries: Vec<JournalEntry> =
sqlx::query_as("select id,value,date from journal_entries order by date desc")
.fetch_all(pool)
.await?;
Ok(layout.render(
Some(vec!["/static/home.css", "/static/journal.css"]),
html! {
(freshness_section(&freshens)?)
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?)
(journal_section(pool, &entries).await?)
},
))
}
}

88
src/web/ics.rs Normal file
View file

@ -0,0 +1,88 @@
use axum::{Router, extract::Path, response::IntoResponse, routing::get};
use chrono::NaiveDate;
use icalendar::{Calendar, Component, Event, EventLike};
use regex::Regex;
use crate::models::user::{AuthSession, User};
use crate::models::{Birthday, HydratedContact};
use crate::{AppError, AppState, Database};
pub fn router() -> Router<AppState> {
Router::new().route("/cal/{path}", get(self::get::calendar))
}
mod get {
use super::*;
pub async fn calendar(
auth_session: AuthSession,
Path(ics_path): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let path_re = Regex::new(r"^(?<username>.+)-(?<hash>[0-9a-zA-Z]+).ics$").unwrap();
let username = if let Some(caps) = path_re.captures(&ics_path) {
caps.name("username").unwrap().as_str()
} else {
tracing::debug!(
"No username match in path {:?} for re /^.+-[0-9a-zA-Z]+.ics$/",
ics_path
);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
};
let user: Option<User> = auth_session.backend.find_user(username).await?;
if user.is_none() {
tracing::debug!("No matching user for username {:?}", username);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
}
let user = user.unwrap();
let pool = Database::for_user(&user).await?.pool;
let expected_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
.fetch_one(&pool)
.await?;
let debug_ics_path = ics_path.clone();
if expected_path.0 != Some(ics_path) {
tracing::debug!(
"Expected path {:?} did not match request path {:?}",
expected_path.0,
debug_ics_path
);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
}
let calname = format!("Contact birthdays for {}", user.username);
let mut calendar = Calendar::new();
calendar.name(&calname);
calendar.append_property(("PRODID", "Mascarpone CRM"));
let contacts: Vec<HydratedContact> = sqlx::query_as(
"select id, birthday, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names
from contacts c",
)
.fetch_all(&pool)
.await?;
for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt(
yo_date.year.unwrap_or(1900),
yo_date.month,
yo_date.day,
) {
calendar.push(
Event::new()
.starts(date) // start-with-no-end is "all day"
.summary(&format!("{}'s Birthday", &contact.display_name()))
.add_property("RRULE", "FREQ=YEARLY"),
);
}
}
}
tracing::debug!("{}", calendar);
Ok(calendar.to_string())
}
}

146
src/web/journal.rs Normal file
View file

@ -0,0 +1,146 @@
use axum::{
Form, Router,
extract::{State, path::Path},
response::IntoResponse,
routing::{delete, patch, post},
};
use chrono::{Datelike, Local, NaiveDate};
use maud::Markup;
use regex::Regex;
use serde::Deserialize;
use crate::models::JournalEntry;
use crate::models::user::AuthSession;
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/journal_entry", post(self::post::entry))
.route("/journal_entry/{entry_id}", patch(self::patch::entry))
.route("/journal_entry/{entry_id}", delete(self::delete::entry))
}
#[derive(Deserialize)]
pub struct PostJournalEntryBody {
date: String,
value: String,
}
mod post {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostJournalEntryBody>,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let now = Local::now().date_naive();
let date = if payload.date.is_empty() {
now
} else {
let date_re =
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
.unwrap();
let caps = date_re.captures(&payload.date).ok_or(anyhow::Error::msg(
"invalid date: must match (yyyy-)?(mm-)?dd",
))?;
// unwrapping these parses is safe since it's matching [0-9]{2,4}
let year = caps
.name("year")
.map(|m| m.as_str().parse::<i32>().unwrap())
.unwrap_or(now.year());
let month = caps
.name("month")
.map(|m| m.as_str().parse::<u32>().unwrap())
.unwrap_or(now.month());
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
"invalid date: failed NaiveDate construction",
))?
};
// not a macro query, we want to use JournalEntry's custom FromRow
let entry: JournalEntry = sqlx::query_as(
"insert into journal_entries (value, date) values ($1, $2) returning id, value, date",
)
.bind(payload.value)
.bind(date.to_string())
.fetch_one(pool)
.await?;
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
Ok(entry.to_html(pool).await?)
}
}
mod patch {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Path(entry_id): Path<u32>,
Form(payload): Form<PostJournalEntryBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
// not a macro query, we want to use JournalEntry's custom FromRow
let entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1")
.bind(entry_id)
.fetch_one(pool)
.await?;
sqlx::query!(
"update journal_entries set date = $1, value = $2 where id = $3",
payload.date,
payload.value,
entry_id
)
.execute(pool)
.await?;
if entry.value != payload.value {
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
.execute(pool)
.await?;
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
}
Ok(entry.to_html(pool).await?)
}
}
mod delete {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Path(entry_id): Path<u32>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query(
"delete from contact_mentions where entry_id = $1;
delete from journal_entries where id = $2 returning id,date,value",
)
.bind(entry_id)
.bind(entry_id)
.execute(pool)
.await?;
Ok(())
}
}

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

@ -0,0 +1,105 @@
use axum::RequestPartsExt;
use axum::extract::FromRequestParts;
// use axum::response::{IntoResponse, Redirect};
use http::request::Parts;
use maud::{DOCTYPE, Markup, html};
use sqlx::FromRow;
use super::models::user::{AuthSession, User};
use super::{AppError, AppState};
pub mod auth;
pub mod contact;
pub mod home;
pub mod ics;
pub mod journal;
pub mod settings;
#[derive(Debug, FromRow)]
struct ContactLink {
name: String,
contact_id: u32,
}
pub struct Layout {
contact_links: Vec<ContactLink>,
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();
let contact_links: Vec<ContactLink> = sqlx::query_as(
"select c.id as contact_id,
coalesce(n.name, '(unnamed)') as name
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
Ok(Layout {
contact_links,
user,
})
}
}
impl Layout {
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
head {
link rel="stylesheet" type="text/css" href="/static/index.css";
meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
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=(href);
}
}
}
body x-data="{ sidebar: false }" {
header {
input #sidebar-show-hide type="button" x-on:click="sidebar = !sidebar" value="";
h1 { a href="/" { "Mascarpone" } }
span { (self.user.username) }
a href="/settings" { "Settings" }
a href="/logout" { "Logout" }
}
section #content {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
ul {
li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links {
li {
a href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
}
}
}
main {
(content)
}
}
}
}
}
}
}

167
src/web/settings.rs Normal file
View file

@ -0,0 +1,167 @@
use axum::{
Router,
extract::State,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use short_uuid::ShortUuid;
use super::Layout;
use crate::models::user::{AuthSession, Credentials};
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/settings", get(self::get::settings))
.route("/settings/ics_path", post(self::post::ics_path))
.route("/settings/ics_path", delete(self::delete::ics_path))
.route("/password", put(self::put::password))
}
fn calendar_link(path: Option<String>) -> Markup {
if let Some(path) = path {
html! {
#cal-link x-data=(json!({ "path": path })) hx-target="this" hx-swap="outerHTML" {
a x-bind:href="window.location.origin + '/cal/' + path" {
span x-text="window.location.origin + '/cal/'" {}
span { (path) }
}
p {
"Warning: These actions unrecoverably change your calendar's URL."
}
button hx-post="/settings/ics_path" { "Regenerate path" }
button hx-delete="/settings/ics_path" { "Destroy calendar" }
}
}
} else {
html! {
#cal-link hx-target="this" hx-swap="outerHTML" {
div { "Birthdays calendar is disabled." }
button hx-post="/settings/ics_path" { "Enable calendar" }
}
}
}
}
mod get {
use super::*;
pub async fn settings(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let ics_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
.fetch_one(pool)
.await?;
let ics_path: Option<String> = ics_path.0;
Ok(layout.render(
Some(vec!["static/settings.css"]),
html! {
h2 { "Birthdays Calendar URL" }
(calendar_link(ics_path))
h2 { "Change Password" }
form x-data="{ old_p: '', new_p: '', confirm: '' }" hx-put="/password"
hx-on::after-request="if(event.detail.successful) { this.reset(); setTimeout(() => window.location.reload(), 5000); }"
hx-target="this" hx-target-error="this" hx-swap="beforeend" {
label for="old" { "Current password:" }
input id="old" name="current" x-model="old_p" type="password";
label for="new" { "New password:" }
input id="new" name="new_password" x-model="new_p" type="password";
label for="confirm" { "Confirm:" }
input id="confirm" x-model="confirm" type="password";
button type="submit" x-bind:disabled="!(new_p.length && new_p === confirm)" { "Submit" }
.error x-show="new_p.length && confirm.length && new_p !== confirm" {
"Passwords do not match"
}
}
},
))
}
}
mod post {
use super::*;
pub async fn ics_path(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let ics_path = format!("{}-{}.ics", &user.username, ShortUuid::generate());
sqlx::query!("update settings set ics_path=$1", ics_path)
.execute(pool)
.await?;
Ok(calendar_link(Some(ics_path)))
}
}
mod delete {
use super::*;
pub async fn ics_path(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
sqlx::query!("update settings set ics_path=null")
.execute(pool)
.await?;
Ok(calendar_link(None))
}
}
mod put {
use super::*;
#[derive(Deserialize)]
pub struct PassChange {
current: String,
new_password: String,
}
pub async fn password(
auth_session: AuthSession,
Form(payload): Form<PassChange>,
) -> Result<Markup, AppError> {
let username = auth_session.user.as_ref().unwrap().username.clone();
tracing::debug!("Resetting password for {}...", username);
let current_creds = Credentials {
username: username.clone(),
password: payload.current,
};
let new_creds = Credentials {
username: username,
password: payload.new_password,
};
match auth_session.authenticate(current_creds).await {
Err(_) => Ok(html! { .error { "Server error; could not verify authentication." } }),
Ok(None) => Ok(html! { .error { "Current password is incorrect." } }),
Ok(Some(_)) => {
auth_session.backend.set_password(new_creds).await?;
Ok(html! { .msg {
"Password changed successfully. Redirecting to login page after 5 seconds..."
} })
}
}
}
}

48
static/contact.css Normal file
View file

@ -0,0 +1,48 @@
main {
padding-top: 1em;
}
#fields {
display: grid;
grid-template-columns: min-content auto;
gap: 0.5em;
label {
color: var(--line-color);
text-align: right;
&::after {
content: ":"
}
}
}
#names {
display: flex;
flex-direction: column;
width: min-content;
div {
display: flex;
flex-direction: row;
}
}
#addresses {
display: grid;
grid-template-columns: min-content auto;
row-gap: 0.5em;
.label {
color: var(--line-color);
text-align: right;
}
}
.hint {
font-size: small;
}
code {
font-family: monospace;
}

68
static/home.css Normal file
View file

@ -0,0 +1,68 @@
h2 {
font-weight: bold;
}
#freshness {
padding: 1em 0;
}
#freshness .grid {
display: grid;
grid-template-columns: fit-content(50%) min-content auto;
width: fit-content;
/* do this instead of grid gap so we can use
* borders/backgrounds for rows */
span {
padding: 0.3em 0;
border-bottom: 1px solid var(--line-color);
&:nth-child(3n+1),
&:nth-child(3n+2) {
padding-right: 0.5em;
}
&:nth-child(3n+2),
&:nth-child(3n+3) {
padding-left: 0.5em;
}
}
.th {
font-size: small;
font-weight: bold;
border-bottom: 1px solid navy;
}
}
#birthdays {
display: flex;
flex-direction: column;
padding: 1em 0;
width: fit-content;
h2 {
width: 100%;
}
#birthday-sections {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1em;
}
.datelist {
flex: 1;
display: grid;
grid-template-columns: fit-content min-content;
column-gap: 0.5em;
row-gap: 0.3em;
h3 {
grid-column: span 2;
font-weight: bold;
font-size: small;
}
}
}

101
static/index.css Normal file
View file

@ -0,0 +1,101 @@
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 {
--main-bg-color: #f2f3f6;
--line-color: #8b687f;
--link-color: #3e7543;
}
body {
width: 100%;
min-height: 100vh;
font-size: 24px;
display: flex;
flex-direction: column;
align-items: center;
padding: 1em;
background-color: var(--main-bg-color);
}
body > header {
display: flex;
flex-direction: row;
gap: 0.5em;
border-bottom: 1px solid var(--line-color);
padding-bottom: 0.5em;
width: 100%;
@media only screen and (min-width: 651px) {
#sidebar-show-hide { display: none; }
}
h1 {
flex: 1;
}
}
section#content {
display: flex;
flex-direction: row;
width: 100%;
@media only screen and (max-width: 650px) {
position: relative;
}
}
#contacts-sidebar {
display: flex;
flex-direction: column;
padding-right: 1em;
@media only screen and (max-width: 650px) {
position: absolute;
float: left;
z-index: 1;
left: 0;
width: 100%;
background-color: #0003;
height: 100%;
&.hide {
display: none;
}
}
ul {
flex: 1;
width: fit-content;
background-color: var(--main-bg-color);
padding: 0.5em 1em 0 0;
@media only screen and (min-width: 651px) {
border-right: 1px solid var(--line-color);
}
button {
width: 100%;
}
}
li {
border-bottom: 1px solid var(--line-color);
padding: 0.5em 0;
&:last-of-type {
border-bottom: none;
}
}
}
main {
display: flex;
flex-direction: column;
flex: 1;
}
.icon {
width: 1em;
height: 1em;
vertical-align: -0.125em;
overflow: visible;
}
a, a:visited {
color: var(--link-color);
text-decoration: underline dotted;
}

77
static/journal.css Normal file
View file

@ -0,0 +1,77 @@
#journal {
padding: 1em 0;
header {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.5em;
a {
font-size: x-small;
}
}
.disclaimer {
font-size: x-small;
margin: 0.5em 0;
}
&>form {
display: grid;
gap: 0.25em;
grid-template-columns: 6em auto;
width: 100%;
input {
height: 1.4lh;
}
textarea {
grid-row: span 2;
}
}
.entries {
display: flex;
flex-direction: column;
gap: 0.25em;
margin: 0.25em 0;
.entry .view,
.entry .edit {
display: grid;
grid-template-columns: fit-content(6em) auto;
gap: 0.25em;
align-items: stretch;
}
.date {
font-weight: bold;
&:after {
content: ": ";
font-weight: normal;
}
}
.entry .hide {
display: none;
border: 2px solid red;
}
input {
min-width: 5em;
}
.controls {
display: grid;
grid-template-columns: auto min-content;
gap: 0.1em;
textarea {
grid-row: span 3;
}
}
}
}

26
static/login.css Normal file
View file

@ -0,0 +1,26 @@
body {
max-width: min-content;
margin: auto;
}
h1 {
align-self: flex-start;
font-size: large;
font-weight: bold;
margin: 2em 0;
}
form {
display: flex;
flex-direction: column;
gap: 0.25em;
input {
margin-bottom: 1em;
}
input[type="submit"] {
margin-top: 1em;
grid-column: span 2;
}
}

31
static/settings.css Normal file
View file

@ -0,0 +1,31 @@
h2 {
font-size: large;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
#cal-link {
display: flex;
flex-direction: column;
gap: 0.5em;
button {
max-width: 10em;
}
}
form {
display: grid;
grid-template-columns: fit-content(50%) min-content;
gap: 0.5em;
label {
text-align: right;
}
.error {
color: darkred;
font-size: small;
grid-column: span 2;
}
}