Add Stork family name board MVP with invite auth and votes.
Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Copy to .env on the deploy host (never commit real tokens).
|
||||||
|
STORK_INVITE_TOKEN=change-me-long-random
|
||||||
|
STORK_ADMIN_TOKEN=change-me-admin-random
|
||||||
|
STORK_COOKIE_SECURE=false
|
||||||
|
STORK_DATA=./data
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
# Homelab CI — Python lane + secret scan.
|
||||||
|
# POLICY: lint and tests are HARD gates. Never add `|| true` to them.
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-ci:
|
||||||
|
runs-on: [homelab, self-hosted, linux, python]
|
||||||
|
container:
|
||||||
|
image: node:20-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Python tooling
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
|
||||||
|
python3 -m pip install --upgrade pip --break-system-packages
|
||||||
|
if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi
|
||||||
|
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi
|
||||||
|
pip install pytest ruff --break-system-packages
|
||||||
|
|
||||||
|
- name: Ruff lint (hard gate)
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Pytest (hard gate)
|
||||||
|
run: pytest -q
|
||||||
|
|
||||||
|
# Advisory-only scanners (allowed to be soft — they're noisy, not gates)
|
||||||
|
- name: Bandit (advisory)
|
||||||
|
run: pip install bandit --break-system-packages && bandit -r . -q || true
|
||||||
|
|
||||||
|
secret-scan:
|
||||||
|
runs-on: [homelab, self-hosted, linux, heavy]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Gitleaks
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
|
||||||
|
detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# --- OS / editor ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# --- Secrets & env (real values never in git) ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*password*
|
||||||
|
*credential*
|
||||||
|
!*example*
|
||||||
|
|
||||||
|
# --- Real data (commit *.example.* fixtures instead) ---
|
||||||
|
data/
|
||||||
|
!data/
|
||||||
|
data/*
|
||||||
|
!data/*.example.*
|
||||||
|
*.sqlite3
|
||||||
|
*.local.yml
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# --- Node ---
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# --- Outputs / scratch ---
|
||||||
|
out/
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
scratch/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# --- Agent tooling (local, not for the repo) ---
|
||||||
|
.cursor/
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.agents/
|
||||||
|
.opencode/
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Repo gitleaks config — same rules as the global fallback
|
||||||
|
# (~/.config/git/gitleaks.toml) so local hook and CI agree.
|
||||||
|
|
||||||
|
title = "repo gitleaks"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "env-file-committed"
|
||||||
|
description = "Raw .env file (or backup/variant) staged for commit"
|
||||||
|
path = '''(^|/)\.env(\.(backup|bak|old|orig|save|local|prod|production|dev|development|staging))?$'''
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "password-or-credential-file"
|
||||||
|
description = "File name indicates stored password/credential material"
|
||||||
|
path = '''(?i)(^|/)[^/]*(password|passwd|credential)[^/]*\.(txt|json|ya?ml|env|cfg|ini)$'''
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "private-key-file"
|
||||||
|
description = "Private key or keystore file staged for commit"
|
||||||
|
path = '''(^|/)(id_rsa|id_ed25519|id_ecdsa)[^/]*$|\.(pem|p12|pfx|key)$'''
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "dotfile-secret-token"
|
||||||
|
description = "Hidden standalone secret/token file"
|
||||||
|
path = '''(^|/)\.[^/]*(secret|token)[^/]*$'''
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
description = "Legitimate non-secret files matching the patterns above"
|
||||||
|
paths = [
|
||||||
|
'''(?i)\.(example|sample|template|dist)$''',
|
||||||
|
'''(^|/)\.env\.example[^/]*$''',
|
||||||
|
'''(^|/)[^/]*\.pub$''',
|
||||||
|
'''(^|/)package(-lock)?\.json$''',
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# AGENTS.md — stork
|
||||||
|
|
||||||
|
Short orientation for Cursor agents.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`.
|
||||||
|
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
|
||||||
|
- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens.
|
||||||
|
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
|
||||||
|
|
||||||
|
## Close-out
|
||||||
|
|
||||||
|
1. `make test` and `make lint` before claiming done.
|
||||||
|
2. Do not put LAN IPs or real invites in this repo.
|
||||||
|
3. Merge only when the user asks.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY stork /app/stork
|
||||||
|
COPY static /app/static
|
||||||
|
|
||||||
|
ENV STORK_DATA=/data
|
||||||
|
ENV STORK_COOKIE_SECURE=true
|
||||||
|
EXPOSE 8094
|
||||||
|
CMD ["uvicorn", "stork.app:app", "--host", "0.0.0.0", "--port", "8094"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Ilia Dobkin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: help install test lint run docker-build seed
|
||||||
|
|
||||||
|
help: ## Show targets
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-16s %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
install: ## Install runtime + dev deps into .venv (prefers python3.12)
|
||||||
|
@PY=$$(command -v python3.12 || command -v python3); \
|
||||||
|
$$PY -m venv .venv
|
||||||
|
.venv/bin/pip install -U pip
|
||||||
|
.venv/bin/pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
lint: ## Ruff lint (CI hard gate)
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
test: ## Pytest (CI hard gate)
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
run: ## Local uvicorn on :8094 (needs .env)
|
||||||
|
@test -f .env || (echo "Copy .env.example to .env first" && exit 1)
|
||||||
|
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
|
||||||
|
|
||||||
|
seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...])
|
||||||
|
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1)
|
||||||
|
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \
|
||||||
|
.venv/bin/python scripts/seed_names.py
|
||||||
|
|
||||||
|
docker-build: ## Build local image
|
||||||
|
docker build -t homelab-stork:latest .
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Stork
|
||||||
|
|
||||||
|
Private family board for baby **first** and **middle** name ideas: suggest
|
||||||
|
names, vote, and keep origin / meaning / pronunciation in English, Russian,
|
||||||
|
and Hebrew.
|
||||||
|
|
||||||
|
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is
|
||||||
|
the app only — no LAN IPs or production secrets here.
|
||||||
|
|
||||||
|
## Quick start (local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN)
|
||||||
|
make install
|
||||||
|
make test
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://127.0.0.1:8094 — enter the invite code and your display name.
|
||||||
|
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry.
|
||||||
|
|
||||||
|
## API sketch
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| GET | `/api/health` | Liveness |
|
||||||
|
| POST | `/api/session` | `{invite, display_name}` → cookies |
|
||||||
|
| GET | `/api/names?kind=first\|middle` | Ranked list |
|
||||||
|
| POST | `/api/names` | Add name + optional locales |
|
||||||
|
| PATCH | `/api/names/{id}` | Update locale notes |
|
||||||
|
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
|
||||||
|
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Local: gitignored `.env`
|
||||||
|
- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git
|
||||||
|
|
||||||
|
## Production shape
|
||||||
|
|
||||||
|
Same host pattern as Compare: Docker on automationlab LXC **225**, public
|
||||||
|
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`.
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create the Gitea repo for this project with gates preconfigured:
|
||||||
|
# private repo, delete-branch-after-merge, branch protection on main
|
||||||
|
# requiring the CI contexts, initial push.
|
||||||
|
#
|
||||||
|
# Usage: ./bootstrap.sh <repo-name> <python|node> [--public]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAME="${1:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
|
||||||
|
LANE="${2:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
|
||||||
|
PRIVATE=true
|
||||||
|
[[ "${3:-}" == "--public" ]] && PRIVATE=false
|
||||||
|
|
||||||
|
GITEA="https://git.levkin.ca"
|
||||||
|
OWNER="ilia"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$HOME/.config/hermes/gitea.env" # provides GITEA_TOKEN
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="$1" path="$2" body="${3:-}"
|
||||||
|
curl -sf -X "$method" -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" ${body:+-d "$body"} "${GITEA}/api/v1${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$LANE" in
|
||||||
|
python) CONTEXTS='["CI / python-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
|
||||||
|
node) CONTEXTS='["CI / node-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
|
||||||
|
*) echo "lane must be python or node" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -f ".gitea/workflows/ci.yml" ]]; then
|
||||||
|
echo "ERROR: .gitea/workflows/ci.yml missing — copy one from ci-templates/ first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating ${OWNER}/${NAME} (private=${PRIVATE})..."
|
||||||
|
api POST /user/repos "{\"name\":\"${NAME}\",\"private\":${PRIVATE},\"default_branch\":\"main\"}" >/dev/null
|
||||||
|
|
||||||
|
echo "Enabling delete-branch-after-merge..."
|
||||||
|
api PATCH "/repos/${OWNER}/${NAME}" '{"default_delete_branch_after_merge": true}' >/dev/null
|
||||||
|
|
||||||
|
echo "Pushing main..."
|
||||||
|
git remote add origin "${GITEA}/${OWNER}/${NAME}.git" 2>/dev/null || true
|
||||||
|
git push "https://${OWNER}:${GITEA_TOKEN}@git.levkin.ca/${OWNER}/${NAME}.git" main
|
||||||
|
|
||||||
|
echo "Adding branch protection (required contexts: ${CONTEXTS})..."
|
||||||
|
api POST "/repos/${OWNER}/${NAME}/branch_protections" \
|
||||||
|
"{\"branch_name\":\"main\",\"rule_name\":\"main\",\"enable_push\":false,\"enable_status_check\":true,\"status_check_contexts\":${CONTEXTS},\"required_approvals\":0}" >/dev/null
|
||||||
|
|
||||||
|
echo "Done: ${GITEA}/${OWNER}/${NAME}"
|
||||||
|
echo "Work via PRs from now on; merge with:"
|
||||||
|
echo " GITEA_REPO=${NAME} bash ~/Documents/code/hermes/scripts/gitea-merge-when-green.sh <pr>"
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"names": [
|
||||||
|
{
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Shai",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"pronunciation": "SHY (rhymes with sky)",
|
||||||
|
"origin": "Hebrew (שי)",
|
||||||
|
"meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai."
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"pronunciation": "Шай",
|
||||||
|
"origin": "иврит",
|
||||||
|
"meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai."
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"pronunciation": "שַׁי — shai",
|
||||||
|
"origin": "עברית",
|
||||||
|
"meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Roze",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"pronunciation": "ROHZ",
|
||||||
|
"origin": "Latin rosa / flower name (also Germanic Rose lineage)",
|
||||||
|
"meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)."
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"pronunciation": "Ро́уз / Ро́за",
|
||||||
|
"origin": "латинское rosa; славянская форма Роза",
|
||||||
|
"meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie."
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"pronunciation": "רוֹז",
|
||||||
|
"origin": "לטינית / שם פרח",
|
||||||
|
"meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Odet",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"pronunciation": "oh-DET",
|
||||||
|
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”",
|
||||||
|
"meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda."
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"pronunciation": "Оде́т",
|
||||||
|
"origin": "французское Odette ← германское «богатство»",
|
||||||
|
"meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta."
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"pronunciation": "אוֹדֶט",
|
||||||
|
"origin": "צרפתית (Odette) משורש גרמאני",
|
||||||
|
"meaning": "עושר / שפע. צורות: Odette, Odetta, Odet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Riva",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"pronunciation": "REE-vah",
|
||||||
|
"origin": "Hebrew / Yiddish short form of Rivka (Rebecca)",
|
||||||
|
"meaning": "Short for Rivka — “to bind / tie; captivating.” Related: Rivka, Rebecca, Rebekah, Rifka, Reba."
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"pronunciation": "Ри́ва",
|
||||||
|
"origin": "уменьшительное от Ривка / Ревекка",
|
||||||
|
"meaning": "краткое от Ривка («связывать», «пленить»). Варианты: Rivka, Rebecca, Rifka."
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"pronunciation": "רִיבָה",
|
||||||
|
"origin": "קיצור של רבקה",
|
||||||
|
"meaning": "צורת חיבה של רבקה. קשור ל־Rivka / Rebecca."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Rivka",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"pronunciation": "riv-KAH / REEV-kah",
|
||||||
|
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac",
|
||||||
|
"meaning": "From root “to bind / tie / join”; often glossed captivating. English: Rebecca, Rebekah. Short forms: Riva, Rifka, Becky."
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"pronunciation": "Ри́вка",
|
||||||
|
"origin": "библейское еврейское имя (Ревекка)",
|
||||||
|
"meaning": "«связывать», «соединять»; библейская Ревекка. Варианты: Rebecca, Rebekah, Riva, Rifka."
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"pronunciation": "רִבְקָה — rivká",
|
||||||
|
"origin": "תנ״ך — אשת יצחק",
|
||||||
|
"meaning": "שורש ר־ב־ק (לקשור / לחבר). באנגלית Rebecca. קיצורים: Riva, Rifka."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Stork — family baby-name board (local / generic bind).
|
||||||
|
# Production bind + Caddy live in ansible deploy/stork/.
|
||||||
|
|
||||||
|
services:
|
||||||
|
stork:
|
||||||
|
build: .
|
||||||
|
image: homelab-stork:latest
|
||||||
|
container_name: stork
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8094:8094"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
STORK_DATA: /data
|
||||||
|
STORK_COOKIE_SECURE: "${STORK_COOKIE_SECURE:-false}"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Writing READMEs and guides
|
||||||
|
|
||||||
|
House style for Markdown in Levkin repos. Goal: docs that read like a
|
||||||
|
competent human wrote them for another engineer, not like a product page
|
||||||
|
or a chatbot reply.
|
||||||
|
|
||||||
|
Agents: also follow `~/.cursor/rules/docs-writing.mdc` and the humanizer
|
||||||
|
skill when rewriting prose.
|
||||||
|
|
||||||
|
## What belongs where
|
||||||
|
|
||||||
|
| Doc | Job |
|
||||||
|
|-----|-----|
|
||||||
|
| `README.md` | What it is, how to run it, where config and deeper docs live |
|
||||||
|
| `docs/guides/*.md` | How-to for one task (deploy, upgrade, recover) |
|
||||||
|
| `docs/reference/*.md` | Stable facts (URLs, ports, schema, CLI flags) |
|
||||||
|
| `AGENTS.md` / `.cursorrules` | Short orientation for agents, not a second README |
|
||||||
|
| `CHANGELOG.md` | What changed, by version |
|
||||||
|
| Scratch (`*_PLAN`, `*_SUMMARY`, `FIX_*`, `HANDOFF`, `PROGRESS_*`) | Temporary. Archive or delete when done; do not polish as product docs |
|
||||||
|
|
||||||
|
Do not paste the same wall of text into README and three guides. Link once.
|
||||||
|
|
||||||
|
## README checklist
|
||||||
|
|
||||||
|
Every repo README should answer these, in roughly this order:
|
||||||
|
|
||||||
|
1. **What** — one or two plain sentences. Name the audience if it is not obvious.
|
||||||
|
2. **Status** — active, beta, archived, research-only. Put archived status in a blockquote at the top.
|
||||||
|
3. **Run it** — install or `make` entrypoint and one command that works.
|
||||||
|
4. **Config** — `.env.example`, Infisical path (`/apps/<repo>`), or vault keys. Never real secrets.
|
||||||
|
5. **Pointers** — links to guides; do not duplicate them.
|
||||||
|
6. **License** — or say the repo is private / personal.
|
||||||
|
|
||||||
|
Add when useful: short architecture sketch, known limits, deploy note
|
||||||
|
("ops runbooks live in the ansible repo").
|
||||||
|
|
||||||
|
Skip in READMEs: emoji decoration, marketing feature walls, welcome blurbs,
|
||||||
|
kanban ceremony, and change-narration ("this was added to replace…").
|
||||||
|
|
||||||
|
## Guide checklist
|
||||||
|
|
||||||
|
A how-to guide should:
|
||||||
|
|
||||||
|
1. State the goal in the first sentence ("Deploy X on LXC Y").
|
||||||
|
2. List prerequisites (access, packages, secrets location).
|
||||||
|
3. Give ordered steps with copy-pasteable commands.
|
||||||
|
4. End with a verify step and what "done" looks like.
|
||||||
|
5. Link related runbooks instead of restating them.
|
||||||
|
|
||||||
|
One job per guide. Split if you need more than one primary verb.
|
||||||
|
|
||||||
|
## Voice and formatting
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- Write in sentence case for headings: `## Getting started`, not `## Getting Started`.
|
||||||
|
- Prefer short paragraphs and concrete nouns (paths, hosts, commands).
|
||||||
|
- Use tables for env vars, ports, and host inventories.
|
||||||
|
- Use fenced code blocks with a language tag when it helps.
|
||||||
|
- Prefer `is` / `has` / `runs` over `serves as` / `boasts` / `features`.
|
||||||
|
- Keep bold for rare emphasis (a warning, a path). Not for every label.
|
||||||
|
|
||||||
|
### Do not
|
||||||
|
|
||||||
|
- Decorate headings or bullets with emoji (`🚀`, `✅`, `💡`, …). If a CLI
|
||||||
|
prints emoji, quote that output in a code block; do not restyle the doc around it.
|
||||||
|
- Use bold-colon list items as fake headings:
|
||||||
|
`- **Web-Based**: Modern React frontend…` → write a normal sentence or table.
|
||||||
|
- Stuff feature lists with adjectives: *state-of-the-art*, *seamless*,
|
||||||
|
*robust*, *powerful*, *comprehensive*, *cutting-edge*, *production-ready*,
|
||||||
|
*unlock*, *leverage*, *delve*, *landscape*, *tapestry*, *testament*.
|
||||||
|
- Open with chatbot filler: "Welcome to…", "Here's what you need to know",
|
||||||
|
"Let's dive in", "It is important to note that…".
|
||||||
|
- Overuse em dashes. Prefer a period, comma, colon, or parentheses.
|
||||||
|
- Title-Case Every Heading Like A Brochure.
|
||||||
|
- Invent facts, URLs, version numbers, or "experts say" attributions while editing.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 🚀 PunimTag
|
||||||
|
|
||||||
|
**Modern Photo Management and Facial Recognition System**
|
||||||
|
|
||||||
|
A fast, simple, and modern web application using state-of-the-art DeepFace AI.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Web-Based**: Modern React frontend with FastAPI backend
|
||||||
|
- **Privacy-First**: All data stored locally, no cloud dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# PunimTag
|
||||||
|
|
||||||
|
Local photo library with face recognition (DeepFace / ArcFace). Admin UI
|
||||||
|
(React) and viewer (Next.js) share one monorepo and a PostgreSQL backend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Face detect and match with RetinaFace + ArcFace (or other listed models)
|
||||||
|
- Search by person, date, tag, or folder
|
||||||
|
- Runs on your machine; no cloud dependency for core data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public vs private repos
|
||||||
|
|
||||||
|
Public READMEs and guides:
|
||||||
|
|
||||||
|
- No LAN IPs, no `10.0.10.x`, no root SSH one-liners.
|
||||||
|
- No absolute home paths (`/Users/…`).
|
||||||
|
- No other people's PII.
|
||||||
|
|
||||||
|
Private homelab docs (ansible, hermes, levkin) may name hosts and IPs.
|
||||||
|
Still skip emoji and marketing tone.
|
||||||
|
|
||||||
|
Repos under `Gitilia/*` are push mirrors from Gitea. After merging a release or
|
||||||
|
public README/docs change, sync and verify from the ansible repo:
|
||||||
|
`make github-mirror-sync` then `make github-mirror-health` (see
|
||||||
|
`ansible/docs/guides/github-mirrors.md`).
|
||||||
|
## When rewriting existing docs
|
||||||
|
|
||||||
|
1. Preserve every real fact (commands, paths, versions, URLs).
|
||||||
|
2. Cut fluff; keep the procedure.
|
||||||
|
3. Fix formatting to match this guide in the same pass.
|
||||||
|
4. Leave code blocks, frontmatter, and link targets intact unless the link is wrong.
|
||||||
|
5. Scratch/status files: delete or move under `docs/archive/` rather than
|
||||||
|
"humanizing" a dead plan.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Project skeleton: this repo's root `README.md`
|
||||||
|
- Hard gates (CI, secrets, LICENSE): `~/.cursor/rules/new-project-standards.mdc`
|
||||||
|
- Tone scrub: Cursor skill `humanizer`
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.5
|
||||||
|
httpx==0.28.1
|
||||||
|
ruff==0.11.5
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.2
|
||||||
|
pydantic==2.11.3
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Lint gate config — enforced in CI (no || true). Start pragmatic; tighten
|
||||||
|
# per-project by extending `select`, never by muting the CI step.
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "I"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Seed starter first names into a local or remote Stork instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# against local make run (cookies via invite):
|
||||||
|
STORK_URL=http://127.0.0.1:8094 STORK_INVITE=local-dev-invite \\
|
||||||
|
.venv/bin/python scripts/seed_names.py
|
||||||
|
|
||||||
|
# against prod after deploy:
|
||||||
|
STORK_URL=https://stork.levkin.ca STORK_INVITE="$(ssh root@10.0.10.45 grep STORK_INVITE_TOKEN /opt/stork/.env | cut -d= -f2-)" \\
|
||||||
|
.venv/bin/python scripts/seed_names.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SEED = ROOT / "data" / "seed-names.example.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
|
||||||
|
invite = os.environ.get("STORK_INVITE", "").strip()
|
||||||
|
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
|
||||||
|
if not invite:
|
||||||
|
print("Set STORK_INVITE", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
names = json.loads(SEED.read_text())["names"]
|
||||||
|
with httpx.Client(base_url=base, timeout=30.0) as client:
|
||||||
|
session = client.post("/api/session", json={"invite": invite, "display_name": display})
|
||||||
|
session.raise_for_status()
|
||||||
|
for item in names:
|
||||||
|
res = client.post("/api/names", json=item)
|
||||||
|
if res.status_code == 400 and "already exists" in res.text:
|
||||||
|
print(f"skip {item['spelling']} (exists)")
|
||||||
|
continue
|
||||||
|
res.raise_for_status()
|
||||||
|
print(f"added {item['kind']}: {item['spelling']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Stork — family name board</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe6;
|
||||||
|
--ink: #1c2430;
|
||||||
|
--muted: #5c6674;
|
||||||
|
--card: #fffdf8;
|
||||||
|
--line: #d9d0c0;
|
||||||
|
--accent: #2f6f6a;
|
||||||
|
--up: #2f6f6a;
|
||||||
|
--down: #9a4a3a;
|
||||||
|
--focus: #c9a227;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 10% -10%, #e8dfc8 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 400px at 100% 0%, #d7e4df 0%, transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
main { max-width: 720px; margin: 0 auto; padding: 1.25rem 1rem 3rem; }
|
||||||
|
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: 0.02em; }
|
||||||
|
.sub { color: var(--muted); margin: 0 0 1.25rem; }
|
||||||
|
.panel {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||||
|
input, select, textarea, button {
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
textarea { min-height: 3.2rem; resize: vertical; }
|
||||||
|
button {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
}
|
||||||
|
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
|
||||||
|
button.vote { min-width: 2.5rem; padding: 0.4rem 0.55rem; }
|
||||||
|
button.vote.active-up { background: var(--up); color: #fff; }
|
||||||
|
button.vote.active-down { background: var(--down); color: #fff; }
|
||||||
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
||||||
|
.row > * { flex: 1 1 140px; }
|
||||||
|
.tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||||
|
.tabs button { flex: 1; }
|
||||||
|
.tabs button.active { outline: 2px solid var(--focus); }
|
||||||
|
.name {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.name:first-child { border-top: 0; }
|
||||||
|
.score {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.spelling { font-size: 1.25rem; margin: 0 0 0.2rem; }
|
||||||
|
.meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; }
|
||||||
|
.langs details { margin-top: 0.35rem; }
|
||||||
|
.langs summary { cursor: pointer; color: var(--accent); }
|
||||||
|
.lang-block { margin: 0.4rem 0; padding-left: 0.2rem; }
|
||||||
|
.lang-block strong { display: inline-block; min-width: 2rem; }
|
||||||
|
.votes { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.error { color: var(--down); margin: 0.5rem 0 0; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.grid-3 { display: grid; gap: 0.5rem; }
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Stork</h1>
|
||||||
|
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
|
||||||
|
|
||||||
|
<section id="gate" class="panel">
|
||||||
|
<label for="invite">Invite code</label>
|
||||||
|
<input id="invite" autocomplete="off" />
|
||||||
|
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas/votes)</label>
|
||||||
|
<input id="display" autocomplete="nickname" />
|
||||||
|
<p class="error hidden" id="gate-error"></p>
|
||||||
|
<div class="row" style="margin-top:0.75rem">
|
||||||
|
<button id="enter" type="button">Enter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="board" class="hidden">
|
||||||
|
<div class="panel">
|
||||||
|
<p class="meta" id="who"></p>
|
||||||
|
<div class="tabs">
|
||||||
|
<button type="button" class="secondary active" data-kind="first">First names</button>
|
||||||
|
<button type="button" class="secondary" data-kind="middle">Middle names</button>
|
||||||
|
</div>
|
||||||
|
<div id="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
|
||||||
|
<label for="spelling">Spelling</label>
|
||||||
|
<input id="spelling" />
|
||||||
|
<div class="grid-3" style="margin-top:0.75rem">
|
||||||
|
<div>
|
||||||
|
<label>English pronunciation</label>
|
||||||
|
<input data-loc="en" data-field="pronunciation" />
|
||||||
|
<label style="margin-top:0.5rem">Origin</label>
|
||||||
|
<input data-loc="en" data-field="origin" />
|
||||||
|
<label style="margin-top:0.5rem">Meaning</label>
|
||||||
|
<textarea data-loc="en" data-field="meaning"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Russian pronunciation</label>
|
||||||
|
<input data-loc="ru" data-field="pronunciation" />
|
||||||
|
<label style="margin-top:0.5rem">Origin</label>
|
||||||
|
<input data-loc="ru" data-field="origin" />
|
||||||
|
<label style="margin-top:0.5rem">Meaning</label>
|
||||||
|
<textarea data-loc="ru" data-field="meaning"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Hebrew pronunciation</label>
|
||||||
|
<input data-loc="he" data-field="pronunciation" />
|
||||||
|
<label style="margin-top:0.5rem">Origin</label>
|
||||||
|
<input data-loc="he" data-field="origin" />
|
||||||
|
<label style="margin-top:0.5rem">Meaning</label>
|
||||||
|
<textarea data-loc="he" data-field="meaning"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="error hidden" id="add-error"></p>
|
||||||
|
<div class="row" style="margin-top:0.75rem">
|
||||||
|
<button id="add" type="button">Add to board</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const state = { kind: "first" };
|
||||||
|
|
||||||
|
function params() {
|
||||||
|
return new URLSearchParams(location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = data && (data.detail || data.message) || res.statusText;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localePayload() {
|
||||||
|
const locales = { en: {}, ru: {}, he: {} };
|
||||||
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
|
||||||
|
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
|
||||||
|
});
|
||||||
|
return locales;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
document.getElementById("spelling").value = "";
|
||||||
|
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLangs(locales) {
|
||||||
|
return ["en", "ru", "he"].map((lang) => {
|
||||||
|
const L = (locales && locales[lang]) || {};
|
||||||
|
const bits = [
|
||||||
|
L.pronunciation && `say: ${L.pronunciation}`,
|
||||||
|
L.origin && `origin: ${L.origin}`,
|
||||||
|
L.meaning && L.meaning,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!bits.length) return "";
|
||||||
|
return `<div class="lang-block"><strong>${lang}</strong> ${bits.join(" · ")}</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(items) {
|
||||||
|
const root = document.getElementById("list");
|
||||||
|
if (!items.length) {
|
||||||
|
root.innerHTML = `<p class="meta">No names yet — add the first idea below.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = items.map((n) => {
|
||||||
|
const langs = renderLangs(n.locales);
|
||||||
|
return `
|
||||||
|
<article class="name" data-id="${n.id}">
|
||||||
|
<div class="score">${n.score}</div>
|
||||||
|
<div>
|
||||||
|
<p class="spelling">${escapeHtml(n.spelling)}</p>
|
||||||
|
<p class="meta">suggested by ${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
|
||||||
|
${langs ? `<div class="langs"><details><summary>Origin & pronunciation</summary>${langs}</details></div>` : `<p class="meta">No notes yet</p>`}
|
||||||
|
</div>
|
||||||
|
<div class="votes">
|
||||||
|
<button type="button" class="vote secondary ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
|
||||||
|
<button type="button" class="vote secondary ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join("");
|
||||||
|
root.querySelectorAll("button[data-vote]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const id = btn.closest(".name").dataset.id;
|
||||||
|
let value = Number(btn.dataset.vote);
|
||||||
|
const item = items.find((x) => String(x.id) === String(id));
|
||||||
|
if (item && item.my_vote === value) value = 0;
|
||||||
|
await api(`/api/names/${id}/vote`, { method: "POST", body: JSON.stringify({ value }) });
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const data = await api(`/api/names?kind=${encodeURIComponent(state.kind)}`);
|
||||||
|
renderList(data.items || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBoard(displayName) {
|
||||||
|
document.getElementById("gate").classList.add("hidden");
|
||||||
|
document.getElementById("board").classList.remove("hidden");
|
||||||
|
document.getElementById("who").textContent = `Signed in as ${displayName}`;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".tabs button").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
state.kind = btn.dataset.kind;
|
||||||
|
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("enter").addEventListener("click", async () => {
|
||||||
|
const err = document.getElementById("gate-error");
|
||||||
|
err.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
const invite = document.getElementById("invite").value.trim();
|
||||||
|
const display_name = document.getElementById("display").value.trim();
|
||||||
|
const data = await api("/api/session", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ invite, display_name }),
|
||||||
|
});
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.delete("invite");
|
||||||
|
history.replaceState({}, "", url.pathname);
|
||||||
|
await showBoard(data.display_name);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("add").addEventListener("click", async () => {
|
||||||
|
const err = document.getElementById("add-error");
|
||||||
|
err.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("/api/names", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: state.kind,
|
||||||
|
spelling: document.getElementById("spelling").value.trim(),
|
||||||
|
locales: localePayload(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
clearForm();
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const invite = params().get("invite");
|
||||||
|
if (invite) document.getElementById("invite").value = invite;
|
||||||
|
try {
|
||||||
|
const session = await api("/api/session");
|
||||||
|
if (session.authenticated) {
|
||||||
|
await showBoard(session.display_name);
|
||||||
|
}
|
||||||
|
} catch (_) { /* stay on gate */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Stork — family baby-name board with votes and multilingual notes."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
"""Stork HTTP API + invite-gated family UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from stork.db import KINDS, LANGS, Store
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
|
||||||
|
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
|
||||||
|
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
|
||||||
|
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
||||||
|
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
|
||||||
|
app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
|
||||||
|
store = Store(DATA_DIR / "stork.sqlite3")
|
||||||
|
|
||||||
|
if STATIC.is_dir():
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
class LocaleIn(BaseModel):
|
||||||
|
pronunciation: str = ""
|
||||||
|
origin: str = ""
|
||||||
|
meaning: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NameIn(BaseModel):
|
||||||
|
kind: str = Field(pattern="^(first|middle)$")
|
||||||
|
spelling: str = Field(min_length=1, max_length=80)
|
||||||
|
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalesPatch(BaseModel):
|
||||||
|
locales: dict[str, LocaleIn]
|
||||||
|
|
||||||
|
|
||||||
|
class VoteIn(BaseModel):
|
||||||
|
value: int = Field(ge=-1, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionIn(BaseModel):
|
||||||
|
invite: str = Field(min_length=1, max_length=200)
|
||||||
|
display_name: str = Field(min_length=1, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_invite_configured() -> None:
|
||||||
|
if not INVITE_TOKEN:
|
||||||
|
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_ok(invite: str | None) -> bool:
|
||||||
|
_require_invite_configured()
|
||||||
|
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(
|
||||||
|
stork_invite: str | None = Cookie(default=None),
|
||||||
|
stork_voter: str | None = Cookie(default=None),
|
||||||
|
stork_name: str | None = Cookie(default=None),
|
||||||
|
x_stork_invite: str | None = Header(default=None),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
invite = x_stork_invite or stork_invite
|
||||||
|
if not _invite_ok(invite):
|
||||||
|
raise HTTPException(401, "Invite required")
|
||||||
|
voter = (stork_voter or "").strip()
|
||||||
|
name = (stork_name or "").strip()
|
||||||
|
if not voter or not name:
|
||||||
|
raise HTTPException(401, "Session required — open the invite link and enter your name")
|
||||||
|
return {"voter_key": voter, "display_name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(
|
||||||
|
x_stork_admin: str | None = Header(default=None),
|
||||||
|
) -> None:
|
||||||
|
if not ADMIN_TOKEN:
|
||||||
|
raise HTTPException(503, "Admin not configured")
|
||||||
|
if not x_stork_admin or not secrets.compare_digest(x_stork_admin, ADMIN_TOKEN):
|
||||||
|
raise HTTPException(403, "Admin token required")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, Any]:
|
||||||
|
return {"ok": True, "service": "stork"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/meta")
|
||||||
|
def meta() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"langs": list(LANGS),
|
||||||
|
"kinds": list(KINDS),
|
||||||
|
"invite_required": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/session")
|
||||||
|
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
|
||||||
|
if not _invite_ok(body.invite.strip()):
|
||||||
|
raise HTTPException(403, "Invalid invite")
|
||||||
|
voter = secrets.token_urlsafe(16)
|
||||||
|
display = body.display_name.strip()
|
||||||
|
cookie_kwargs: dict[str, Any] = {
|
||||||
|
"httponly": True,
|
||||||
|
"samesite": "lax",
|
||||||
|
"secure": COOKIE_SECURE,
|
||||||
|
"max_age": 60 * 60 * 24 * 400,
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs)
|
||||||
|
response.set_cookie("stork_voter", voter, **cookie_kwargs)
|
||||||
|
response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False})
|
||||||
|
return {"ok": True, "display_name": display}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/session")
|
||||||
|
def get_session(
|
||||||
|
stork_invite: str | None = Cookie(default=None),
|
||||||
|
stork_voter: str | None = Cookie(default=None),
|
||||||
|
stork_name: str | None = Cookie(default=None),
|
||||||
|
x_stork_invite: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
invite = x_stork_invite or stork_invite
|
||||||
|
authed = _invite_ok(invite) if INVITE_TOKEN else False
|
||||||
|
return {
|
||||||
|
"authenticated": authed and bool(stork_voter) and bool(stork_name),
|
||||||
|
"display_name": stork_name or "",
|
||||||
|
"has_invite": authed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/names")
|
||||||
|
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise HTTPException(400, "kind must be first or middle")
|
||||||
|
return {"items": store.list_names(kind, voter_key=session["voter_key"])}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/names")
|
||||||
|
def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||||
|
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||||
|
try:
|
||||||
|
return store.add_name(
|
||||||
|
kind=body.kind,
|
||||||
|
spelling=body.spelling,
|
||||||
|
created_by=session["display_name"],
|
||||||
|
locales=locales,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/names/{name_id}")
|
||||||
|
def patch_name(
|
||||||
|
name_id: int,
|
||||||
|
body: LocalesPatch,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ = session
|
||||||
|
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||||
|
updated = store.update_locales(name_id, locales)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Name not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/names/{name_id}/vote")
|
||||||
|
def vote_name(
|
||||||
|
name_id: int,
|
||||||
|
body: VoteIn,
|
||||||
|
session: dict[str, str] = Depends(_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
updated = store.vote(
|
||||||
|
name_id=name_id,
|
||||||
|
voter_key=session["voter_key"],
|
||||||
|
voter_name=session["display_name"],
|
||||||
|
value=body.value,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(404, "Name not found")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/names/{name_id}")
|
||||||
|
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
|
||||||
|
if not store.delete_name(name_id):
|
||||||
|
raise HTTPException(404, "Name not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def home() -> FileResponse:
|
||||||
|
index = STATIC / "index.html"
|
||||||
|
if not index.is_file():
|
||||||
|
raise HTTPException(404, "UI missing")
|
||||||
|
return FileResponse(index)
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
"""SQLite persistence for names, locales, and votes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
LANGS = ("en", "ru", "he")
|
||||||
|
KINDS = ("first", "middle")
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS names (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')),
|
||||||
|
spelling TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
UNIQUE (kind, spelling COLLATE NOCASE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS locales (
|
||||||
|
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
|
||||||
|
lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')),
|
||||||
|
pronunciation TEXT NOT NULL DEFAULT '',
|
||||||
|
origin TEXT NOT NULL DEFAULT '',
|
||||||
|
meaning TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (name_id, lang)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS votes (
|
||||||
|
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
|
||||||
|
voter_key TEXT NOT NULL,
|
||||||
|
voter_name TEXT NOT NULL DEFAULT '',
|
||||||
|
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (name_id, voter_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
with self._lock:
|
||||||
|
self._conn.executescript(_SCHEMA)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def _empty_locales(self) -> dict[str, dict[str, str]]:
|
||||||
|
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
|
||||||
|
|
||||||
|
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
|
||||||
|
out = self._empty_locales()
|
||||||
|
if not locales:
|
||||||
|
return out
|
||||||
|
for lang in LANGS:
|
||||||
|
raw = locales.get(lang) or {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
out[lang] = {
|
||||||
|
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
|
||||||
|
"origin": str(raw.get("origin") or "").strip()[:200],
|
||||||
|
"meaning": str(raw.get("meaning") or "").strip()[:500],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
|
||||||
|
locales = self._empty_locales()
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
for loc in cur.fetchall():
|
||||||
|
locales[loc["lang"]] = {
|
||||||
|
"pronunciation": loc["pronunciation"],
|
||||||
|
"origin": loc["origin"],
|
||||||
|
"meaning": loc["meaning"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"kind": row["kind"],
|
||||||
|
"spelling": row["spelling"],
|
||||||
|
"created_by": row["created_by"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"locales": locales,
|
||||||
|
"score": score,
|
||||||
|
"vote_count": vote_count,
|
||||||
|
"my_vote": my_vote,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise ValueError("invalid kind")
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE",
|
||||||
|
(kind,),
|
||||||
|
).fetchall()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
score_row = self._conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchone()
|
||||||
|
my_vote = None
|
||||||
|
if voter_key:
|
||||||
|
vote = self._conn.execute(
|
||||||
|
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||||
|
(row["id"], voter_key),
|
||||||
|
).fetchone()
|
||||||
|
if vote:
|
||||||
|
my_vote = int(vote["value"])
|
||||||
|
out.append(
|
||||||
|
self._row_to_name(
|
||||||
|
row,
|
||||||
|
int(score_row["score"]),
|
||||||
|
int(score_row["vote_count"]),
|
||||||
|
my_vote,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None:
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
score_row = self._conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
|
||||||
|
(name_id,),
|
||||||
|
).fetchone()
|
||||||
|
my_vote = None
|
||||||
|
if voter_key:
|
||||||
|
vote = self._conn.execute(
|
||||||
|
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||||
|
(name_id, voter_key),
|
||||||
|
).fetchone()
|
||||||
|
if vote:
|
||||||
|
my_vote = int(vote["value"])
|
||||||
|
return self._row_to_name(
|
||||||
|
row,
|
||||||
|
int(score_row["score"]),
|
||||||
|
int(score_row["vote_count"]),
|
||||||
|
my_vote,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_name(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
spelling: str,
|
||||||
|
created_by: str,
|
||||||
|
locales: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise ValueError("invalid kind")
|
||||||
|
spelling = spelling.strip()
|
||||||
|
if not spelling or len(spelling) > 80:
|
||||||
|
raise ValueError("spelling required (1–80 chars)")
|
||||||
|
created_by = (created_by or "").strip()[:80]
|
||||||
|
locs = self._normalize_locales(locales)
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(kind, spelling, created_by, now),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ValueError("name already exists for this kind") from exc
|
||||||
|
name_id = int(cur.lastrowid)
|
||||||
|
for lang, payload in locs.items():
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
result = self.get_name(name_id)
|
||||||
|
assert result is not None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
locs = self._normalize_locales(locales)
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
for lang, payload in locs.items():
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name_id, lang) DO UPDATE SET
|
||||||
|
pronunciation = excluded.pronunciation,
|
||||||
|
origin = excluded.origin,
|
||||||
|
meaning = excluded.meaning
|
||||||
|
""",
|
||||||
|
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self.get_name(name_id)
|
||||||
|
|
||||||
|
def vote(
|
||||||
|
self,
|
||||||
|
name_id: int,
|
||||||
|
voter_key: str,
|
||||||
|
voter_name: str,
|
||||||
|
value: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if value not in (-1, 0, 1):
|
||||||
|
raise ValueError("vote must be -1, 0, or 1")
|
||||||
|
voter_key = voter_key.strip()
|
||||||
|
if not voter_key or len(voter_key) > 64:
|
||||||
|
raise ValueError("invalid voter")
|
||||||
|
voter_name = (voter_name or "").strip()[:80]
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if value == 0:
|
||||||
|
self._conn.execute(
|
||||||
|
"DELETE FROM votes WHERE name_id = ? AND voter_key = ?",
|
||||||
|
(name_id, voter_key),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO votes (name_id, voter_key, voter_name, value, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name_id, voter_key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
voter_name = excluded.voter_name,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""",
|
||||||
|
(name_id, voter_key, voter_name, value, time.time()),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self.get_name(name_id, voter_key=voter_key)
|
||||||
|
|
||||||
|
def delete_name(self, name_id: int) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
|
||||||
|
self._conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def export_snapshot(self) -> str:
|
||||||
|
"""Debug helper for tests."""
|
||||||
|
with self._lock:
|
||||||
|
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
|
||||||
|
return json.dumps(names)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Shared fixtures for Stork API tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||||
|
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
|
||||||
|
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
|
||||||
|
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
|
||||||
|
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
|
||||||
|
|
||||||
|
import stork.app as app_mod
|
||||||
|
|
||||||
|
importlib.reload(app_mod)
|
||||||
|
with TestClient(app_mod.app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
app_mod.store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def authed(client: TestClient) -> TestClient:
|
||||||
|
res = client.post(
|
||||||
|
"/api/session",
|
||||||
|
json={"invite": "test-invite-token", "display_name": "Aunt Mira"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
return client
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""API tests for Stork MVP."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(client: TestClient) -> None:
|
||||||
|
res = client.get("/api/health")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_rejects_bad_invite(client: TestClient) -> None:
|
||||||
|
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_names_require_session(client: TestClient) -> None:
|
||||||
|
res = client.get("/api/names")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_vote_and_rank(authed: TestClient) -> None:
|
||||||
|
a = authed.post(
|
||||||
|
"/api/names",
|
||||||
|
json={
|
||||||
|
"kind": "first",
|
||||||
|
"spelling": "Noa",
|
||||||
|
"locales": {
|
||||||
|
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"},
|
||||||
|
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert a.status_code == 200
|
||||||
|
noa_id = a.json()["id"]
|
||||||
|
|
||||||
|
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
|
||||||
|
assert b.status_code == 200
|
||||||
|
levi_id = b.json()["id"]
|
||||||
|
|
||||||
|
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
|
||||||
|
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
|
||||||
|
|
||||||
|
listed = authed.get("/api/names?kind=first").json()["items"]
|
||||||
|
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
|
||||||
|
assert listed[0]["score"] == 1
|
||||||
|
assert listed[0]["my_vote"] == 1
|
||||||
|
assert listed[1]["score"] == -1
|
||||||
|
|
||||||
|
# clear vote
|
||||||
|
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["my_vote"] is None
|
||||||
|
assert cleared.json()["score"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_name_rejected(authed: TestClient) -> None:
|
||||||
|
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
|
||||||
|
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
|
||||||
|
assert dup.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_names_separate(authed: TestClient) -> None:
|
||||||
|
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
|
||||||
|
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
|
||||||
|
first = authed.get("/api/names?kind=first").json()["items"]
|
||||||
|
middle = authed.get("/api/names?kind=middle").json()["items"]
|
||||||
|
assert len(first) == 1
|
||||||
|
assert len(middle) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_locales(authed: TestClient) -> None:
|
||||||
|
created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json()
|
||||||
|
patched = authed.patch(
|
||||||
|
f"/api/names/{created['id']}",
|
||||||
|
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_delete(authed: TestClient) -> None:
|
||||||
|
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
|
||||||
|
denied = authed.delete(f"/api/names/{created['id']}")
|
||||||
|
assert denied.status_code == 403
|
||||||
|
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert authed.get("/api/names?kind=first").json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
|
||||||
|
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
|
||||||
|
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
||||||
|
|
||||||
|
other = client.post(
|
||||||
|
"/api/session",
|
||||||
|
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
|
||||||
|
)
|
||||||
|
assert other.status_code == 200
|
||||||
|
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
|
||||||
|
listed = client.get("/api/names?kind=first").json()["items"]
|
||||||
|
assert listed[0]["score"] == 2
|
||||||
|
assert listed[0]["vote_count"] == 2
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Unit tests for Store ranking helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stork.db import Store
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
|
||||||
|
store = Store(tmp_path / "t.sqlite3")
|
||||||
|
a = store.add_name("first", "Zed", "x")
|
||||||
|
store.add_name("first", "Ann", "x")
|
||||||
|
c = store.add_name("first", "Bo", "x")
|
||||||
|
store.vote(a["id"], "v1", "A", 1)
|
||||||
|
store.vote(c["id"], "v1", "A", 1)
|
||||||
|
store.vote(c["id"], "v2", "B", 1)
|
||||||
|
ranked = store.list_names("first")
|
||||||
|
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
|
||||||
|
store.close()
|
||||||
Reference in New Issue
Block a user