Files
website/README.md
Stan Runge 35f78ec829
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Money: private Documents page with admin-only file endpoint
Lists contracts/pension docs with written summaries, an inline viewer and
download at /trackers/money/documents. Files and summaries live on the host
(MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in
git or static/; the file endpoint is admin-only (401/403) and private, no-store.
2026-10-24 14:32:28 +00:00

478 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Stan Website
This is my personal website, including:
- Contact info / social links
- About me
- Blog posts + comments
- Projects like Animaldex, CatchEmAll
## House tracker
Available at `/projects/house-tracker` (admin only). Listings come from a worker
that runs at home; setup, settings and the ban-avoidance rules are in
[docs/house-tracker.md](docs/house-tracker.md).
## osu! Map Finder
Available at `/projects/spotify-beatmap-finder`. Built with the existing Svelte 5,
SvelteKit 3, Tailwind 4, and adapter-bun setup. Uses server-only direct `fetch`
calls; no Spotify/osu! SDK or additional services.
Set these private variables in your local `.env` or deployment environment:
```text
SPOTIFY_CLIENT_ID
SPOTIFY_CLIENT_SECRET
OSU_CLIENT_ID
OSU_CLIENT_SECRET
```
Never prefix these with `PUBLIC_` or commit `.env`. Providers read
`$app/env/private`; the browser receives only display metadata and results.
The other projects on this personal site retain their own environment requirements.
Supported inputs: song text (`Deftones Change`), Spotify track, playlist, and album
URLs, and `spotify:track:ID`, `spotify:playlist:ID`, `spotify:album:ID` URIs.
URLs allow HTTP/HTTPS, `www`, locale prefixes, query parameters and trailing slashes.
Artist, episode, show and other resources receive a validation error.
Playlists and albums preserve order and process up to **50 usable unique tracks**
(`MAX_PLAYLIST_TRACKS`), scanning at most **10 pages** (`MAX_PLAYLIST_PAGES`).
Null, unavailable, local, malformed and episode entries are skipped. Truncation is
shown explicitly. Album tracks inherit the album artwork/name. Each match has its
own error state; an osu! failure cannot erase other completed tracks. The enhanced
form shows a pending state and renders the complete batch; native form submission
also works without JavaScript. Filters require JavaScript.
Spotify playlist availability depends on application quota/access. Both legacy
`tracks/track` and newer `items/item` payloads are supported. Spotify's
[2026 development-mode changes](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)
restrict playlist access; client credentials cannot unlock private/user-authorized
playlists. Such responses produce an access error. This project intentionally does
not add user authentication.
Matching is pure and testable in `src/lib/map-finder/matching.ts`: Unicode and
punctuation normalization, diacritic removal, common annotation cleanup, title and
artist token overlap, Unicode metadata alternatives, and version penalties. Title
contributes 65 points and artist 35. High confidence starts at 88, medium at 72;
weak artist evidence is capped below medium, and a different version costs 24.
These are heuristics, not probabilities. Candidates below 40 are omitted; up to six
are sorted and deduplicated by beatmapset ID. Low-confidence candidates are collapsed.
Search first uses artist + title, then one cleaned-title fallback if no medium/high
match exists. Duration is omitted because osu! map length may reflect a song cut.
Tokens are reused until 30 seconds before expiry with a shared refresh promise.
Matching results cache for five minutes (maximum 500 entries), including no-match
results, and identical pending searches are coalesced. Three concurrent osu!
matching jobs are allowed per process. The site allows one active search per IP,
up to six active searches globally, and 12 budget units per IP per 10 minutes:
a single track costs one, a playlist/album six. Expired records are cleaned on
requests; the limiter has a 10,000-record ceiling. It uses `getClientAddress()`,
never arbitrary forwarded headers; unsupported adapters share a conservative key.
Configure adapter-bun's trusted proxy/client-address settings (`ADDRESS_HEADER`, `XFF_DEPTH`) correctly at deployment.
Requests have a 12-second timeout and at most two retries for network errors,
429s or server errors. Backoff includes jitter. `Retry-After` is honored; delays
above four seconds return a helpful error and retain a provider cooldown rather
than holding the request open. All caches, cooldowns, concurrency and rate limits
are per process and reset on restart; multiple instances do not share them.
Local commands (npm scripts and existing tooling; no new dependencies):
```sh
npm install
npm run dev
npm run test:unit -- --run
npm run test:integration
npm run check
npm run lint
npm run build
```
Finder unit tests mock upstream APIs. Browser tests cover initial layout and
validation with and without enhancement, and do not call Spotify or osu!.
## Language learner
`/projects/language-learner` teaches Dutch and Korean (explained in English) and
English (explained in Dutch), from A1 to C1. Korean starts with a Hangul level.
It runs entirely in the browser: no database tables, migrations or API keys.
- Course content lives in `src/lib/language-learner/courses/<lang>/<level>.ts` as
compact `target | translation | note` lines, plus markdown notes and
multiple-choice grammar questions (`prompt | answer | wrong; wrong | explanation`).
Exercises (choice, typing, sentence building, fill-in-the-blank, listening,
matching) are generated from that data. `content.test.ts` validates every lesson.
- Endless drills are generated by code in `src/lib/language-learner/drills/`:
number words, Dutch/English clock times, de/het, plurals, Dutch and Korean
conjugation (a rule-based Korean conjugator handling the ㅂ/ㄷ/르/ㅅ/ㅎ
irregulars), Korean particles and counters, Hangul reading, English irregular
verbs, comparatives and a/an.
- Challenges: a seeded daily challenge (first score of the day counts), 60-second
sprint, survival with three lives, mixed practice, level tests that mark a level
as done at 80%, and spaced-repetition reviews (simplified SM-2).
- Audio uses the browser's speech synthesis, so listening exercises appear only
when the device has a voice for the language. Romanization of Korean is
approximate Revised Romanization.
- Progress, streaks and settings are stored in `localStorage`
(`language-learner-v1`), per browser. Export/import on the overview page moves
them between devices.
## Shared accounts and permissions
`/login`, `/register`, and `/account` are the central account pages. Better Auth
handles email/password credentials and database-backed sessions for the entire
site. Google and osu! buttons appear when the corresponding client ID and secret
are configured. The header exposes account access everywhere.
Copy the auth settings from `.env.example`. `BETTER_AUTH_SECRET` must be at least
32 random characters; there is no runtime fallback secret. Set `ORIGIN` and
`BETTER_AUTH_URL` to the same public origin (for example,
`https://dev.stanrunge.dev` behind the local proxy). Register these exact callbacks:
- Google: `<BETTER_AUTH_URL>/api/auth/callback/google`
- osu!: `<BETTER_AUTH_URL>/api/auth/callback/osu`
osu! uses the `identify` scope and a stable numeric account ID. Its API does not
supply an email address, so these accounts use a reserved, unverified
`<id>@osu.invalid` internal identifier, hidden from the account UI. Credential
registration cannot claim that namespace. Provider accounts are linked explicitly
from `/account` while logged in; there is no automatic merging by email. Link a
provider **before** using it to create a separate account if you want shared
progress. Existing separate accounts are not automatically merged.
Every new user has role `user`. The shared server guards enforce administrator
access for blog drafts/writes, list creation/editing, Animaldex moderation, and
Progress edits. Legacy `ADMIN_SECRET`, `AUTH_TOKEN`, `admin_token`, and `token`
logins no longer authorize anything. To grant yourself admin access, register,
copy the account ID shown on `/account`, then run on the server:
```sh
npm run auth:role -- <user-id> admin
# To revoke access:
npm run auth:role -- <user-id> user
```
Role changes take effect on the next request; roles cannot be set through signup
or profile updates. Public registrations are never made admin automatically.
Future projects should reference the shared `user.id`, use `requireUser(event)`,
and scope **both reads and writes** to the session user's ID. Use
`requireAdmin(event)` for site-wide administration; never accept an owner ID from
a form or infer permission from the presence of a cookie.
Email/password registration works without a mail service. Email verification and
password recovery emails are **not configured** yet. Connect a transactional mail
sender and Better Auth's verification/reset hooks before offering those flows.
Better Auth's rate limiting is enabled and currently uses per-process memory;
a multi-instance deployment needs shared rate-limit storage. Account pages and
saved progress require a working database. Authentication forms require JavaScript.
## Game tracker
`/projects/game-tracker` is the hub for completionist progress. Catchemall lives at
`/projects/game-tracker/pokemon` (the old `/projects/catch-em-all` URL redirects),
with location pages under `/projects/game-tracker/pokemon/locations`. Call of Duty
games live at `/projects/game-tracker/call-of-duty/<slug>`. All catalog data is
owned by the site: it is checked in under `data/` and seeded into Postgres, and
nothing calls PokéAPI or Steam at runtime.
### Pokémon catalog
`npm run catalog:build` rebuilds two files from the pinned PokéAPI CSV snapshot
(revision recorded inside them):
- `data/pokemon-catalog.json`: **1,025 species, 53 game versions/expansions and
27,267 species/game entries**, plus each species' evolution chain and the
evolution method that applies **in each game** (Magnezone needs Mt. Coronet in
Platinum but a Thunder Stone in Sword).
- `data/pokemon-locations.json`: 1,104 locations and about 67,000 encounters
(area, method, levels, rarity, form and conditions such as time of day, season
or swarm). Max Raid dens are folded per location.
Each entry has one availability:
- `catchable`: a documented encounter (wild, gift, static, in-game trade).
- `evolve`: a pre-evolution is obtainable in that game and the method exists
there, e.g. Blastoise in Red is “Evolve Wartortle: Level 36+”.
- `breed`: an obtainable relative can breed it (not in Gen 1, Let’s Go,
Colosseum/XD or the Legends games); incense babies name their incense.
- `event_only`: a mythical in the game with no other route.
- `unknown`: in the game's Pokédex data, but no confirmed route.
`catchable`, `evolve` and `breed` can be ticked and count toward progress.
PokéAPI has **no encounter data** for Brilliant Diamond/Shining Pearl, Legends:
Arceus, Scarlet/Violet and their DLC, or Legends: Z-A, so those games stay
`unknown` until another source is added. Missing pairs are not proof of absence.
Administrators can correct any entry from the Pokémon detail dialog and add notes
and a picture (JPEG/PNG/WebP, 5 MB, stored in R2 under `locations/`) on any
location page. Rows carry a `source`: seeding refreshes `pokeapi` and `derived`
rows and never touches `manual` ones. Location notes and pictures always survive
reseeding, and user progress is keyed by entry, so it survives too.
### Call of Duty catalog
`data/call-of-duty.json` covers Black Ops, Black Ops II, Ghosts, Advanced
Warfare, Black Ops III, Infinite Warfare and WWII. Weapon classes, post-launch
(DLC/update) weapons and camo rules are **hand-maintained** from the Call of Duty
Wiki (CC BY-SA). `npm run catalog:cod` refreshes only the achievement lists from
the public Steam Community pages. Users tick per-weapon milestones (bought or
unlocked, max level, Gold, Gold Cheetah); class and overall camos (Diamond, Dark
Matter, Solar, Black Sky, Chrome, Chrome Tiger) are derived in
`src/lib/game-tracker/mastery.ts`, and post-launch weapons never gate them.
Weapon progress is keyed by a slug of the weapon name, so renaming a weapon in
the JSON starts its progress over; the seed keeps removed items that already have
progress and warns about them.
### Database
The schema is managed by Drizzle (`src/lib/server/db/schema/`, migrations in
`drizzle/`). Postgres schemas: `auth`, `money`, `house`, `ref`, `games`, `cs`,
`animaldex`, `site`, `meta` (Drizzle-managed) and `lists` (one real table per list,
created at runtime through the `meta.create_list` family of SQL functions, so it is
excluded from Drizzle via `schemaFilter`). See [Development](#development) for the
local database and migrations, and `db/reorg/README.md` for the one-time move of the
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
upsert reference data and run on every production deploy.
## Book tracker
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
position per file, autosaves progress and sessions, and stores highlights with notes.
Files are never public. They are streamed (with Range support) by
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
`hooks.server.ts`).
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
be opened.
## Money
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns.
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
written summary, an inline viewer and a download. Nothing about them is in git: the
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
`/money-documents` when it exists, otherwise `~/money-documents`) as
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
`/home/stan/money-documents` read-only at `/money-documents` when it has a
`documents.json`. Files are only streamed to admins via
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
no redeploy is needed.
Transactions can be entered manually or imported from a CSV or tab-separated
export with `date,account,description,amount` headers. Dates accept
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
such as `-€1,234.50`. Quoted fields and comma, semicolon, or tab delimiters
are supported, and empty rows are skipped. Imported entries are stored in date
order. `Start balance` rows are imported as ordinary transactions, so leave
the account opening balance at zero for those accounts. Import creates
missing accounts and adds every row in one database transaction. It does not
deduplicate repeat uploads, so import each file once. Accounts have an editable
opening balance for money held before the first recorded transaction.
The current balance and history stop at today; future dated transactions feed the
wealth projection. The projection invests spendable money above the configured
reserve in a modeled balance, even without an existing investment account.
An existing investment account can optionally be selected as the starting
balance for growth. The projection treats recurring entries as monthly
equivalents from next month and can disable them to avoid double counting entries
already recorded as future transactions. Its reserve, hypothetical return,
contribution rule, and end date are editable. Wish list priority runs from tier 5
(highest) to tier 1 (lowest), with cumulative estimated costs on the overview.
The utility comparison uses meter readings within the configured billing period,
the current per-unit rate, and prorated monthly advances. It excludes fixed
charges, taxes, and past rate changes. Set the billing start to a recorded opening
meter reading for a meaningful annual comparison.
Seeding runs in one transaction and is repeatable. It preserves row IDs, existing
catches, selected games, and hand-maintained availability notes. Pokémon are keyed
by national number, games by PokéAPI version ID, and entries by Pokémon/game pair.
Existing games with exactly matching names are adopted to preserve their IDs.
The old broken reset and auth-schema overwrite commands have been removed.
Availability is deliberately conservative:
- `catchable`: PokéAPI supplies an encounter record. Conditions or a particular
form may be required. These entries contribute to the catch checklist.
- `unknown` (`?`): a Pokédex or game-index record exists, but the acquisition method
has not been confirmed. Membership alone never becomes “catchable”.
- A dash: no source record; this does **not** prove the Pokémon is unavailable.
- Manually maintained trade/transfer/event entries remain supported.
Encounter coverage varies by game and is especially incomplete for newer titles.
The import does not invent evolution, breeding, gift, trade, transfer, or event
rules from missing data. The page explains these limits. Catalog refreshes are
explicit: update `REVISION` in `scripts/build-pokemon-catalog.py`, run
`npm run catalog:build`, review the snapshot, and rerun the seed. Source:
[PokéAPI data](https://github.com/PokeAPI/pokeapi/tree/master/data/v2/csv).
Catches and selected games are stored server-side under the authenticated user's
ID. Repeated catch requests are idempotent, writes validate catalog IDs, and an
explicit empty game selection survives reloads. The UI rolls back failed saves
and prevents overlapping changes to the same catch.
`npm run test:unit -- --run` covers authorization, safe return paths, osu! identity
mapping, and catalog integrity alongside existing tests. `npm run test:auth` runs
browser/database checks against a migrated, seeded **development** database; it
creates temporary test accounts/content and removes them afterward. It checks
registration, login/logout, cookie replay, role escalation attempts, cross-user
progress isolation, admin routes, game selection, and OAuth authorization startup.
Completing Google/osu! consent still requires real provider credentials and the
registered callback URL. The integration follows the installed Better Auth APIs;
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
## Development
There are two environments: local dev and production. Prod is the source of truth
for data; the dev database is a disposable copy of it.
```sh
cp .env.example .env # DB_URL (app role) + DB_MIGRATE_URL (owner role) -> local personal_dev
scripts/db-refresh-dev.sh # fresh prod dump -> personal_dev (sessions cleared)
bun run db:migrate # apply this branch's pending Drizzle migrations
bun run dev
```
`db-refresh-dev.sh` reads `PROD_DB_URL` (a role that can read everything, e.g.
`personal`) and `DEV_ADMIN_URL` (superuser on the dev cluster) from the environment
or `.env.dev-refresh` (git-ignored, never commit it). It refuses to overwrite the
source database or any database named `personal`. `PG_DOCKER=1` uses the
`postgres:17-alpine` image when local `pg_dump` 17 isn't installed.
Schema changes: edit `src/lib/server/db/schema/`, run `bun run db:generate --name
<change>`, review the SQL in `drizzle/`, then `bun run db:migrate`. Never edit an
applied migration; never use `db:push` against prod.
`scripts/preview.sh <branch> [port]` runs a branch against a throwaway copy of the
dev database (migrated with that branch's migrations) and cleans everything up on
Ctrl-C.
### Database roles
| env var | role | used for |
| ---------------- | -------------- | --------------------------------------------------------------- |
| `DB_URL` | `personal_app` | the app at runtime: DML only, calls the `meta.*` list functions |
| `DB_MIGRATE_URL` | `personal` | Drizzle migrations, seeds, backups (`pg_dump`) |
`personal_ddl` owns the `lists` schema (only reachable through the SECURITY DEFINER
`meta.*` functions); `personal_ro` is read-only. The deploy preflight fails if
`DB_URL` can create objects, i.e. if it isn't the app role.
## Production
Push or merge to `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) builds a
SHA-tagged image (unit tests + production build run inside the Docker build),
transfers it, and runs `scripts/deploy-remote.sh <sha> deploy` on the host:
1. checks the image and that `DB_MIGRATE_URL` is set in `/etc/personal-website/prod.env`;
2. `pg_dump` of prod (postgres:17-alpine) into the backup directory;
3. `bun scripts/db-migrate.ts` (Drizzle migrations) and the catalog seeds;
4. preflight (tables, column types, app-role privileges), switch the container, health checks.
A failed health check restores the previous image tag automatically. Manual
rollback: Gitea Actions → **Deploy production** → **Run workflow** with
`rollback_tag=<previous sha>`; this redeploys that image without migrating or
seeding. Database changes are not rolled back with the image; restore the
pre-deploy dump if a migration has to be undone. Only `main` deploys; other
branches never trigger the workflow. The runner needs Docker CLI/socket access
and the `DEPLOY_KEY` secret.
`/etc/personal-website/prod.env` needs `DB_URL`, `DB_MIGRATE_URL`, a unique random
`BETTER_AUTH_SECRET` (32+ characters), and `ORIGIN`/`BETTER_AUTH_URL` set to
`https://stanrunge.dev`, plus the Google and osu! client ID/secret pairs and the five
`R2_*` values from `.env.example`. Picture uploads (lists, Animaldex) go through the
app server to R2 (5 MB per image; the container sets `BODY_SIZE_LIMIT=6M`). OAuth
callback URLs:
```text
https://stanrunge.dev/api/auth/callback/google
https://stanrunge.dev/api/auth/callback/osu
```
After registering, promote your account (ID shown on `/account`):
```sh
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
git.stanrunge.dev/stan/website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
```
No public signup grants admin privileges.
# CS prices
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
individual listing. The separate `cs.item_dimensions` table makes it possible to
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
additional imported dimensions such as collection or rarity. Group chart points
average the available per-listing prices equally within each time bucket. Raw
observations retain their source, currency, timestamp, kind, price in cents, and
volume. Chart responses use up to roughly 240 time buckets per series and allow
up to 1,000 selected series; actual response time still depends on database size
and hardware.
With `DB_URL` pointed at a migrated database:
```sh
npm run cs-prices:ingest -- catalog
STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... npm run cs-prices:ingest -- history
```
The catalog pass pages through Counter-Strike market listings and saves a current
lowest-listing snapshot. The history pass visits each catalog item and stores
Steam's available historical sale-price and volume points. It needs a Steam web
session, may take many hours for the full catalog, and can be resumed with
`--start=<item offset>` and bounded with `--limit=<items>`. Requests pause for two
seconds by default (`--delay-ms=...`, minimum one second), retry temporary
failures, and upsert observations safely. Run catalog regularly for new listings
and snapshots; run history regularly for new sale points. Steam's market web
endpoints are undocumented and may change or limit access. The historical
granularity and completeness are bounded by what Steam returns; running this
collector cannot reconstruct older high-frequency prices that Steam no longer
exposes.
For another provider or a prepared archive, import JSON Lines instead:
```sh
npm run cs-prices:ingest -- import ./prices.jsonl
```
Each line needs `marketHashName`, `source`, `currency` (three-letter ISO code),
`observedAt` (ISO timestamp), `priceCents`, and `kind`. Optional `name`, `volume`,
and `dimensions` add detail. For example:
```json
{
"marketHashName": "AK-47 | Redline (Field-Tested)",
"source": "archive",
"currency": "USD",
"observedAt": "2026-09-24T12:00:00Z",
"priceCents": 1850,
"volume": 42,
"kind": "sale-median",
"dimensions": { "collection": "Phoenix", "rarity": "classified" }
}
```
Imported sources and price kinds appear in the page's source and kind controls.