major features update
This commit is contained in:
parent
519fb49901
commit
4e2fab67c5
48 changed files with 3925 additions and 208 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1 +1,6 @@
|
|||
/target
|
||||
e2e/node_modules
|
||||
e2e/playwright-report
|
||||
e2e/test-results
|
||||
some_user.db
|
||||
dbs
|
||||
|
|
|
|||
914
Cargo.lock
generated
914
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
35
Cargo.toml
35
Cargo.toml
|
|
@ -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"
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -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
28
Taskfile
Executable 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
57
e2e/Taskfile
Executable 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
16
e2e/package.json
Normal 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
49
e2e/pages/home.spec.ts
Normal 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
37
e2e/pages/index.spec.ts
Normal 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
117
e2e/pages/journal.spec.ts
Normal 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
31
e2e/pages/util.ts
Normal 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
71
e2e/playwright.config.ts
Normal 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
57
e2e/pnpm-lock.yaml
generated
Normal 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
BIN
e2e/users.db
Normal file
Binary file not shown.
12
migrations/demo.db/0001_contact-tables.sql
Normal file
12
migrations/demo.db/0001_contact-tables.sql
Normal 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
|
||||
);
|
||||
13
migrations/demo.db/0002_journal-entry-tables.sql
Normal file
13
migrations/demo.db/0002_journal-entry-tables.sql
Normal 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
|
||||
);
|
||||
33
migrations/demo.db/0003_demo-data.sql
Normal file
33
migrations/demo.db/0003_demo-data.sql
Normal 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);
|
||||
6
migrations/demo.db/0004_user-settings.sql
Normal file
6
migrations/demo.db/0004_user-settings.sql
Normal 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;
|
||||
6
migrations/demo.db/0005_address-tables.sql
Normal file
6
migrations/demo.db/0005_address-tables.sql
Normal 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
|
||||
);
|
||||
12
migrations/each_user/0001_contact-tables.sql
Normal file
12
migrations/each_user/0001_contact-tables.sql
Normal 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
|
||||
);
|
||||
13
migrations/each_user/0002_journal-entry-tables.sql
Normal file
13
migrations/each_user/0002_journal-entry-tables.sql
Normal 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
|
||||
);
|
||||
6
migrations/each_user/0004_user-settings.sql
Normal file
6
migrations/each_user/0004_user-settings.sql
Normal 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;
|
||||
6
migrations/each_user/0005_address-tables.sql
Normal file
6
migrations/each_user/0005_address-tables.sql
Normal 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
|
||||
);
|
||||
5
migrations/users.db/01_create-users.sql
Normal file
5
migrations/users.db/01_create-users.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
create table if not exists users (
|
||||
id integer primary key autoincrement,
|
||||
username not null unique,
|
||||
password not null
|
||||
);
|
||||
5
migrations/users.db/02_add-ephemeral.sql
Normal file
5
migrations/users.db/02_add-ephemeral.sql
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
#[derive(Clone)]
|
||||
pub struct Contact {
|
||||
pub name: String,
|
||||
}
|
||||
35
src/db.rs
Normal file
35
src/db.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
273
src/main.rs
273
src/main.rs
|
|
@ -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
13
src/models.rs
Normal 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
93
src/models/birthday.rs
Normal 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
86
src/models/contact.rs
Normal 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
144
src/models/journal.rs
Normal 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
126
src/models/user.rs
Normal 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>;
|
||||
114
src/models/year_optional_date.rs
Normal file
114
src/models/year_optional_date.rs
Normal 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
114
src/web/auth.rs
Normal 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
443
src/web/contact.rs
Normal 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(" ")) }
|
||||
}
|
||||
.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
241
src/web/home.rs
Normal 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
88
src/web/ics.rs
Normal 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
146
src/web/journal.rs
Normal 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
105
src/web/mod.rs
Normal 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
167
src/web/settings.rs
Normal 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
48
static/contact.css
Normal 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
68
static/home.css
Normal 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
101
static/index.css
Normal 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
77
static/journal.css
Normal 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
26
static/login.css
Normal 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
31
static/settings.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue