From 5b9b2a16e6f27a952924f192dd70a25238473563 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Wed, 7 Oct 2026 19:40:26 +0330 Subject: [PATCH] docs: add product overview and engineering documentation Document architecture, security, deployment, and development for contributors. --- CONTEXT.md | 32 +++++++++++++ README.md | 67 +++++++++++++++++++++++++++ docs/README.md | 28 +++++++++++ docs/api.md | 80 ++++++++++++++++++++++++++++++++ docs/architecture.md | 74 +++++++++++++++++++++++++++++ docs/configuration.md | 102 ++++++++++++++++++++++++++++++++++++++++ docs/deployment.md | 105 ++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 66 ++++++++++++++++++++++++++ docs/operations.md | 82 +++++++++++++++++++++++++++++++++ docs/product.md | 54 ++++++++++++++++++++++ docs/security.md | 56 ++++++++++++++++++++++ 11 files changed, 746 insertions(+) create mode 100644 CONTEXT.md create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/operations.md create mode 100644 docs/product.md create mode 100644 docs/security.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..962a10d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,32 @@ +# Pargar — domain summary + +> Full source of truth: [`docs/`](docs/README.md) · repo entry: [`README.md`](README.md) + +Gated LMS: verified watch → quiz with hearts → skill-tree unlock (staff) → mentor chat, events, weekly league, monthly XP challenges, certificates. + +## Stack (short) + +- **Backend:** Go (`backend/`) · Postgres · Redis · S3/RustFS · WS hub +- **Frontend:** Next.js App Router · React Query · Tailwind · Vazirmatn FD +- **Edge:** Traefik v3.7 — Compose and Helm/k3s + +## Student path (short) + +Login/CAPTCHA or register (public/invite) → `/cap` → `/player` → `/quiz` → staff unlock → certificate `/c/{publicId}` · profile `/profile` · hub `/unwrap`. + +Public password recovery is disabled for students; resets are admin-only. Browser sessions use HttpOnly cookies. + +## Docs + +| Topic | File | +|-------|------| +| Architecture | [docs/architecture.md](docs/architecture.md) | +| Product | [docs/product.md](docs/product.md) | +| Development | [docs/development.md](docs/development.md) | +| Deployment | [docs/deployment.md](docs/deployment.md) | +| Security | [docs/security.md](docs/security.md) | +| Operations | [docs/operations.md](docs/operations.md) | +| API | [docs/api.md](docs/api.md) | +| Env | [docs/configuration.md](docs/configuration.md) | + +Phone registration, unlisted certificates, chat-export, rate limits, and production notes live in those docs (not duplicated here to avoid drift). diff --git a/README.md b/README.md new file mode 100644 index 0000000..7134603 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Pargar + +A gated LMS for bootcamps: verified watch → quiz with hearts → skill-tree unlock → mentor chat, events, weekly league, monthly challenges, and certificates. + +| Layer | Stack | +|-------|--------| +| API | Go 1.27+ · Postgres · Redis · S3/RustFS · WebSocket | +| UI | Next.js 15 · React Query · Tailwind · Vazirmatn FD | +| Edge | Traefik v3.7 (Compose and k3s) | + +## Quick start (local) + +```bash +cp .env.example .env # fill in real secrets +./scripts/compose-dev.sh up -d --build +``` + +- App: **https://localhost** (Traefik default cert → one browser warning is expected) +- Traefik dashboard: http://127.0.0.1:8088 +- Health: `curl -k https://localhost/api/health` + +k3s: + +```bash +./scripts/k3s-load-images.sh +./scripts/helm-k3s.sh +curl -k https://pargar.local/api/health +``` + +Deployment details: [docs/deployment.md](docs/deployment.md) + +## Documentation + +| Doc | Contents | +|-----|----------| +| [docs/README.md](docs/README.md) | Doc index and reading map | +| [docs/architecture.md](docs/architecture.md) | System architecture and data flow | +| [docs/product.md](docs/product.md) | Student / admin paths and features | +| [docs/development.md](docs/development.md) | Development, tests, code conventions | +| [docs/deployment.md](docs/deployment.md) | Compose · Helm/k3s · production | +| [docs/security.md](docs/security.md) | Auth, secrets, rate limits, uploads | +| [docs/operations.md](docs/operations.md) | Backup, metrics, troubleshooting | +| [docs/api.md](docs/api.md) | API contract and key routes | +| [docs/configuration.md](docs/configuration.md) | Environment variable reference | + +Domain summary for agents: [CONTEXT.md](CONTEXT.md) + +## Repository layout + +```text +backend/ Go API, migrations, seed, media/sample.mp4 +frontend/ Next.js App Router +deploy/ Compose + Traefik + Helm chart +scripts/ compose / k3s / backup / regenerate-sample +docs/ engineering documentation +``` + +## Tests + +```bash +cd backend && go test ./internal/api/... ./internal/config/... +cd frontend && bunx tsc --noEmit +``` + +## License + +This project is released under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2fc17e5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# Pargar documentation + +This folder is the engineering source of truth. [`README.md`](../README.md) is only the entry point; details live here. + +## Reading map + +1. **Newcomer / product** → [product.md](product.md) +2. **Architecture** → [architecture.md](architecture.md) +3. **Local development** → [development.md](development.md) +4. **Deployment** → [deployment.md](deployment.md) (plus the ops shortcut in [`deploy/README.md`](../deploy/README.md)) +5. **Security & launch** → [security.md](security.md) +6. **Day-to-day ops** → [operations.md](operations.md) +7. **API contract** → [api.md](api.md) +8. **Env & settings** → [configuration.md](configuration.md) + +## Writing conventions + +- File paths, env names, shell commands, and route names stay in **exact English**. +- Prefer linking over long duplication across files. +- When behavior changes, update the matching doc in the same PR. + +## Related files outside `docs/` + +| File | Role | +|------|------| +| [`CONTEXT.md`](../CONTEXT.md) | Short domain summary for agents / fast onboarding | +| [`deploy/README.md`](../deploy/README.md) | Deploy command shortcut | +| [`.env.example`](../.env.example) / [`.env.prod.example`](../.env.prod.example) | Secret templates | diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..7a0bb3f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,80 @@ +# API + +Base: `/api`. Signed media: `/media/…`. Health: `GET /api/health` (outside the rate-limited mux). + +## Response contract + +### Success + +Ordinary JSON; standard 2xx codes. + +### Error + +```json +{ + "error": "user-facing message", + "code": "unauthorized", + "requestId": "…" +} +``` + +Response header: `X-Request-ID` (generated by the server if the client did not send one). + +The frontend maps these via `toUserError` and `ApiError`. + +## Authentication + +| Method | Use | +|--------|-----| +| Cookie `pargar_access` | Same-origin browser | +| `Authorization: Bearer ` | Tests, tools, in-memory token frontends | +| Cookie / body `refreshToken` | `POST /api/auth/refresh` | + +WS: Bearer → subprotocol `bearer` + token → access cookie. Query string is forbidden. + +## Public (no login) + +| Method | Path | Notes | +|--------|------|--------| +| GET | `/api/auth/captcha` | Image challenge | +| POST | `/api/auth/login` | Login | +| POST | `/api/auth/refresh` | Session rotation | +| POST | `/api/auth/recovery/challenge` | Disabled (404) — public student recovery is off | +| POST | `/api/auth/recovery/reset` | Disabled (404) | +| GET | `/api/auth/register-status` | Whether public registration is on | +| POST | `/api/auth/register` | Public registration | +| GET/POST | `/api/auth/register-invite/{token}` | Invite status / consume | +| GET | `/api/certificates/{publicId}` | Public certificate | +| GET | `/api/community` | Donation / support | +| GET | `/api/sponsors` | Landing sponsors | +| GET | `/api/exports/chats/{token}` | Single-use chat archive download | + +## Authenticated (student+) + +Key examples: + +- `GET /api/auth/me` · `POST /api/auth/logout` +- Tree and lessons: `/api/tree` · `/api/lessons/{id}` · heartbeat · quiz +- `GET /api/leaderboard/weekly` · `GET /api/challenges` +- Events and RSVP: `/api/events…` +- Chat: `/api/chats/…` · upload `/api/chats/upload` +- Notifications, unlock/reset requests +- My certificate: `/api/me/certificate` · issue · physical +- Avatar: `PUT /api/me/avatar` · `POST /api/me/avatar-photo` +- `GET /api/ws` + +Full handler list: [`backend/internal/api/router.go`](../backend/internal/api/router.go). + +## Staff / admin + +Prefix `/api/admin/…`: + +- **admin:** stats, users (CRUD, lock, role, reset-password), challenges, announcements, settings, sponsors, physical orders, invites, phone whitelist/blacklist, chat-export (+ tokens) +- **staff (admin + mentor):** learning overview/students, student unlock, chapters / lessons / questions / videos, events +- `GET /api/media/sign` staff only + +Mentors do not manage accounts, registration, challenges, announcements, or site settings; they handle learning status and teaching content. + +## Versioning + +No version prefix for now; call out breaking changes in CHANGELOG/PR. External clients should prefer same-origin cookies or short-lived Bearer tokens. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d9cec21 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,74 @@ +# Architecture + +## Overview + +```text +Browser + │ HTTPS (same origin) + ▼ +Traefik ──► frontend (Next.js :3000) paths: UI + └──► backend (Go :8080) paths: /api/*, /media/*, /c/* (via FE + API) + │ + ├── Postgres (stateful: users, progress, chats, certs, …) + ├── Redis (rate limit, captcha, refresh metadata, pub/sub WS) + └── Object storage (RustFS/S3) videos, avatars, chat attachments +``` + +- **One public origin:** the browser talks only to the public domain; Traefik splits paths. +- **Backend** owns business logic, auth, media signing, and WebSocket. +- **Frontend** is an App Router SPA; data comes from React Query with `credentials: "include"`. + +Edge path details: [deployment.md](deployment.md#routing) + +## Backend packages + +| Package | Role | +|---------|------| +| `internal/api` | HTTP handlers, middleware, captcha, rate limit, cookies | +| `internal/store` | Postgres access | +| `internal/config` | Env loading and `Validate()` | +| `internal/storage` | Local / S3; `EnsureSampleMP4` at startup | +| `internal/seed` | Admin bootstrap + optional curriculum | +| `internal/db/migrations` | Ordered SQL | +| `cmd/server` | Process entrypoint | + +## Learning flow (core) + +```mermaid +flowchart LR + login[Login_CAPTCHA] + tree[Skill_tree_cap] + watch[Verified_watch] + quiz[Quiz_hearts] + unlock[Staff_unlock] + cert[Digital_cert] + login --> tree --> watch --> quiz + quiz -->|fail_hearts| lockout[Lockout] + quiz -->|pass| unlock + unlock --> watch + unlock -->|curriculum_done| cert +``` + +1. Watch accumulates verified time via anti-cheat heartbeats until the completion threshold. +2. Quizzes spend hearts; zero → `/lockout` until staff reopens the account. +3. Unlocking the next lesson is **staff-only**; the student requests it and follows up in chat. +4. Completing the curriculum → unlisted digital certificate; physical copy is optional and offline. + +## Sessions and realtime + +- Browser session: HttpOnly cookies `pargar_access` / `pargar_refresh` (+ JSON tokens for non-browser clients). Details: [security.md](security.md). +- WebSocket: `GET /api/ws` — Bearer, `Sec-WebSocket-Protocol: bearer,`, or same-origin access cookie. +- Hub coordinates across replicas via Redis pub/sub (online presence is per-process). + +## Media storage + +- Lesson keys (e.g. `sample.mp4`) live in object storage; browser URLs are short-lived HMAC-signed. +- At startup, if the sample key is missing, `backend/media/sample.mp4` is uploaded. +- Chat / avatar / admin video uploads use content sniffing (not extension alone). + +## Errors and observability + +- Error body: JSON `{ error, code, requestId }` plus `X-Request-ID`. +- Frontend: `toUserError`, toasts for mutations, empty vs error states in lists. +- Prometheus metrics on `/metrics` (requires `METRICS_TOKEN` outside DevMode). +- Optional OTLP via `OTEL_*`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c0c5a1b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,102 @@ +# Configuration + +Templates: [`.env.example`](../.env.example) (Compose dev), [`.env.prod.example`](../.env.prod.example) (Compose prod). In Kubernetes, values come from Secret / chart values. + +## Required in production (`DEV_MODE=false`) + +| Variable | Notes | +|----------|--------| +| `JWT_SECRET` | ≥32 characters; not a placeholder | +| `ADMIN_PASSWORD` | Strong; only for initial missing-admin bootstrap | +| `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` | When `STORAGE_DRIVER=s3` | +| `PUBLIC_URL` | Public origin with scheme | +| `FRONTEND_ORIGINS` | CSV of allowed CORS origins | + +`config.Validate()` fails boot if secrets look like `change-me` / `local-dev-only`. + +## Database and Redis + +One of two patterns: + +**A) Ready-made URLs** + +- `DATABASE_URL` +- `REDIS_URL` + +**B) Parts (Helm in-cluster)** — URL is built with correct encoding: + +| Variable | Conceptual default | +|----------|--------------------| +| `POSTGRES_USER` | pargar | +| `POSTGRES_PASSWORD` | — | +| `POSTGRES_HOST` | postgres service | +| `POSTGRES_PORT` | 5432 | +| `POSTGRES_DB` | pargar | +| `POSTGRES_SSLMODE` | disable | +| `REDIS_HOST` | redis service | +| `REDIS_PORT` | 6379 | +| `REDIS_PASSWORD` | — | +| `REDIS_DB` | optional | + +## Storage + +| Variable | Notes | +|----------|--------| +| `STORAGE_DRIVER` | `s3` (default prod/compose) or local in tests | +| `STORAGE_ENDPOINT` | e.g. `http://rustfs:9000` | +| `STORAGE_REGION` | us-east-1 | +| `STORAGE_BUCKET` | pargar | +| `STORAGE_DIR` | Local driver only | + +## Admin bootstrap + +| Variable | Notes | +|----------|--------| +| `ADMIN_NAME` | Display name | +| `ADMIN_USERNAME` | e.g. admin | +| `ADMIN_EMAIL` | Login identity | +| `ADMIN_PASSWORD` | Applied only if the admin user does not exist | + +## Product behavior + +| Variable | Logical default | Notes | +|----------|-----------------|--------| +| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior | +| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events | +| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production | +| `DONATION_ENABLED` | true | Support card | +| `DONATION_NOTE` | Copy text | | +| `PHYSICAL_CERT_ENABLED` | false in config/chart | Physical certificate | +| `PHYSICAL_CERT_PRICE_IRR` | 2500000 | | +| `PHYSICAL_CERT_WINDOW_DAYS` | 30 | | +| `HEART_REGEN_HOURS` | 4 | | + +Many boolean/text settings can later be overridden from Admin → site settings (`app_settings`). + +## Network and observability + +| Variable | Notes | +|----------|--------| +| `DOMAIN` | Traefik Host (Compose) | +| `TRUSTED_PROXY_CIDRS` | CSV of networks trusted for X-Forwarded-* | +| `METRICS_TOKEN` | Guards `/metrics` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Empty = off | +| `OTEL_SERVICE_NAME` | pargar-api | +| `PORT` | 8080 | + +## Frontend + +| Build variable | Notes | +|----------------|--------| +| `NEXT_PUBLIC_API_URL` | Empty = same-origin | +| `NEXT_PUBLIC_WS_URL` | Empty = `ws(s)://host/api/ws` | + +## Helm (summary) + +See `deploy/helm/pargar/values.yaml`: + +- `global.domain` / `publicUrl` / `frontendOrigins` / `trustedProxyCIDRs` +- `backend.env.*` → container env +- Empty `backend.secrets` → generate-once +- `backup.*` → CronJob +- `postgres` / `redis` / `rustfs` or `external.*` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a23c261 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,105 @@ +# Deployment + +Fuller command reference: [`deploy/README.md`](../deploy/README.md). This document covers deployment models and production requirements. + +## Options + +| Environment | Tooling | TLS | Data | +|-------------|---------|-----|------| +| Laptop | Compose + `compose.dev.yaml` | Traefik default cert | Postgres/Redis/RustFS in compose | +| Single-host VPS | Compose + `compose.prod.yaml` | Let's Encrypt HTTP-01 | Same + no published DB ports | +| Local k3s | Helm `values-k3s.yaml` | Chart self-signed | In-cluster | +| Production K8s | Helm `values-prod.yaml` | cert-manager | Usually external DB/Redis/S3 | + +## Routing + +| Path | Service | +|------|---------| +| `/api/*`, `/media/*` | backend | +| `/api/certificates/*`, `/c/*` | backend (+ noindex middleware) | +| everything else | frontend | +| `/api/ws` | backend WebSocket via Traefik | + +## Compose — development + +```bash +cp .env.example .env +./scripts/compose-dev.sh up -d --build +``` + +- App: https://localhost +- Traefik dashboard: http://127.0.0.1:8088 +- DB ports only on `127.0.0.1` + +## Compose — production + +```bash +cp .env.prod.example .env.prod +./scripts/compose-prod.sh up -d --build +``` + +DNS must point at the host. `DOMAIN`, `ACME_EMAIL`, and strong secrets are required. + +## k3s + Helm + +```bash +# Install k3s and kubeconfig once (see deploy/README.md) +./scripts/k3s-load-images.sh # build and import local images +./scripts/helm-k3s.sh # upgrade --install with values-k3s.yaml +echo "127.0.0.1 pargar.local" | sudo tee -a /etc/hosts # if needed +curl -k https://pargar.local/api/health +``` + +### Values + +| File | Purpose | +|------|---------| +| `deploy/helm/pargar/values.yaml` | Defaults | +| `values-k3s.yaml` | Local: seed, backup CronJob, self-signed TLS | +| `values-prod.yaml` | Registry, HPA, PDB, NetworkPolicy, `existingSecret` | + +```bash +helm lint ./deploy/helm/pargar -f ./deploy/helm/pargar/values-k3s.yaml +helm template pargar ./deploy/helm/pargar -f ./deploy/helm/pargar/values-k3s.yaml +``` + +Helm 4 rejects negation patterns in `.helmignore` (`!…`); do not add them. + +### Chart secrets + +- Without `backend.existingSecret`: the chart generate-once via `lookup` + `randAlphaNum` and keeps values across upgrades. +- `change-me-*` placeholders in values are ignored. +- An existing Postgres password is **never rotated** (data depends on it). +- Production: always pre-create the Secret and set `backend.existingSecret`. + +Read a generated admin password: + +```bash +kubectl -n pargar get secret pargar-app -o jsonpath='{.data.admin-password}' | base64 -d; echo +``` + +### DB/Redis wiring in Helm + +Instead of a raw URL with an unencoded password, the backend builds from parts: + +- `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_HOST` / `POSTGRES_DB` / `POSTGRES_SSLMODE` +- `REDIS_HOST` / `REDIS_PASSWORD` + +Or for external production: `DATABASE_URL` / `REDIS_URL` inside the Secret. + +### In-cluster backup + +With `postgres.enabled` and `backup.enabled=true` (on for k3s): daily CronJob `pg_dump | gzip` onto a PVC with retention. For managed Postgres in production, set `backup.enabled=false`. + +## Production checklist + +1. `DEV_MODE=false` +2. Strong `JWT_SECRET` and `ADMIN_PASSWORD` (no placeholders) — otherwise `config.Validate()` refuses to boot +3. Exact public `PUBLIC_URL` and `FRONTEND_ORIGINS` +4. `TRUSTED_PROXY_CIDRS` limited to the proxy network +5. `METRICS_TOKEN` to expose `/metrics` +6. Images from a private registry + `imagePullSecrets` +7. cert-manager / real TLS +8. DB and object-storage backups defined +9. `PHYSICAL_CERT_ENABLED` only if the offline payment process is ready +10. NetworkPolicy / HPA per `values-prod.yaml` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..eae98a2 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,66 @@ +# Development + +## Prerequisites + +- Docker + Compose +- Go (aligned with the backend Dockerfile; currently 1.27-alpine) +- Node 22+ / bun for the frontend (image builds use `npm ci`) +- Optional: k3s, Helm 3/4, kubectl, ffmpeg (only to regenerate the sample video) + +## Local run (recommended) + +```bash +cp .env.example .env +# Fill POSTGRES_PASSWORD, REDIS_PASSWORD, RUSTFS_*, JWT_SECRET, ADMIN_PASSWORD +./scripts/compose-dev.sh up -d --build +``` + +Equivalent: + +```bash +docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.dev.yaml --env-file .env up -d --build +``` + +The root `docker-compose.yml` includes the same pair. + +For UI iteration without rebuilding the image, run `bun run dev` inside `frontend/` and keep `PUBLIC_URL` / `FRONTEND_ORIGINS` aligned; the standard path is still behind Traefik. + +## Code structure + +- Copy naming and patterns from sibling files; do not invent new top-level folders without agreement. +- Backend tests use Go `testing`; frontend typechecks with `tsc`. +- Frontend calls the API mainly via `http` / `@/lib/api`. + +## Tests + +```bash +# With Postgres/Redis available (compose) and a correct REDIS_URL +cd backend +export GOTMPDIR="${GOTMPDIR:-$HOME/.cache/go-tmp}" +go test ./internal/config/ -count=1 +go test ./internal/api/ -count=1 +# or filter: +go test ./internal/api/ -count=1 -run 'TestPasswordRecovery|TestSessionCookie|TestAdminEvent' + +cd ../frontend +bunx tsc --noEmit +``` + +Note: if Redis has a password, build `REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379` from `.env`. + +## Seed and sample video + +- `SEED_CURRICULUM=true` seeds curriculum/events at startup. +- `backend/media/sample.mp4` is tracked in git; the Docker image copies it; startup PutObjects if the key is missing. +- Regenerate a corrupted file with `./scripts/regenerate-sample-mp4.sh` (needs ffmpeg; not a runtime dependency). + +## UI error contract + +- Empty list ≠ network error: show error UI + retry. +- Mutations: `toUserError`. +- Player: no infinite skeleton; explicit error state. + +## Fonts + +- Persian body: **Vazirmatn FD** (Persian digits) +- Latin fields (password, username, captcha): regular Vazirmatn / `font-latin` diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7681b30 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,82 @@ +# Operations + +## Health + +```bash +curl -k "$PUBLIC_URL/api/health" # {"status":"ok"} +kubectl -n pargar get pods,ingressroute,svc +kubectl -n pargar logs deploy/pargar-backend --tail=100 +``` + +## Backup + +### Compose + +```bash +./scripts/backup.sh [outdir] +``` + +Output: `postgres.sql.gz` + RustFS data archive (when possible). + +### Helm / k3s + +With `backup.enabled=true`: + +- CronJob `pargar-pg-backup` on `backup.schedule` (default daily 03:00 UTC) +- Files on PVC `pargar-backups` +- Auto-delete older than `backup.retentionDays` + +Manual restore (summary): + +```bash +# Example: copy dump from pod/PVC then +gunzip -c pargar-YYYYMMDD.sql.gz | psql "$DATABASE_URL" +``` + +Back up object storage separately (bucket versioning / RustFS PVC snapshot). + +## Metrics and tracing + +- `GET /metrics` — without `METRICS_TOKEN` and outside DevMode → 404 +- Header `X-Metrics-Token` or `Authorization: Bearer …` +- OTLP: `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_SERVICE_NAME` + +## Updating images on k3s + +```bash +./scripts/k3s-load-images.sh +./scripts/helm-k3s.sh +kubectl -n pargar rollout restart deploy/pargar-backend deploy/pargar-frontend +kubectl -n pargar rollout status deploy/pargar-backend +``` + +The `local` tag uses `IfNotPresent`; always restart after import. + +## Common troubleshooting + +| Symptom | Likely cause | Action | +|---------|--------------|--------| +| Seed video will not play | `sample.mp4` missing in storage | Check `sample media` logs; ensure file is in the image; restart backend | +| Sudden 401 after upgrade | JWT secret changed | Do not rotate the secret unless intentional; users must log in again | +| Backend CrashLoop after changing DB password in Secret | Old PVC password ≠ new Secret | Restore previous Secret password or re-seed DB | +| Repeated 429 | Rate limit | Wait; honor `Retry-After`; DevMode behaves differently | +| Empty CAPTCHA in tests | `CAPTCHA_EXPOSE_ANSWER` only with DevMode | Tests set both true | +| Helm lint/template differs from cluster | `lookup` empty without API | Install/upgrade against a real cluster | +| Frontend does not show changes | Stale image | Rebuild + import + rollout restart | + +## Logging + +- Requests with `request_id`, `user_id`, status, duration +- Security events: login, recovery, password reset, … +- Critical-path internals: `writeInternalErr` (log cause, generic user message) + +## Useful scripts + +| Script | Purpose | +|--------|---------| +| `scripts/compose-dev.sh` | Development Compose | +| `scripts/compose-prod.sh` | Production Compose | +| `scripts/k3s-load-images.sh` | Build + import into k3s containerd | +| `scripts/helm-k3s.sh` | helm upgrade --install | +| `scripts/backup.sh` | Compose backup | +| `scripts/regenerate-sample-mp4.sh` | Rebuild color bars with ffmpeg | diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..173ba37 --- /dev/null +++ b/docs/product.md @@ -0,0 +1,54 @@ +# Product + +## Roles + +| Role | Access | +|------|--------| +| `student` | Learning path, mentor chat, events, league, challenges, profile, certificate | +| `mentor` | Same + curriculum/events/challenges content (staff) + student learning views | +| `admin` | All staff + users, site settings, invites, whitelist/blacklist, chat archive, physical orders | + +## Student journey + +1. **Login** `/login` — self-hosted image CAPTCHA + adaptive rate limits; “remember me” lengthens refresh lifetime. +2. **Public password recovery** — disabled for students. Password changes only from the admin panel (requires the account security answer). +3. **Registration** + - Public `/register` when `registration_enabled` is on; phone must be on the one-time whitelist. + - Invite `/register/invite/[token]` with capacity limits; works even if public registration is off. +4. **Skill tree** `/cap` — “next step”; progress and unlock requests. +5. **Player** `/player/[lessonId]` — verified watch. +6. **Quiz** `/quiz/[lessonId]` — wrong answers spend hearts; zero → `/lockout`. +7. **Hub** `/unwrap` — events, chat, league, support. +8. **Weekly league** `/leaderboard` — resets on Monday (ISO week); next reset shown in Jalali. Separate from monthly challenges. +9. **Monthly challenge** — XP goal in a time window; progress from quiz XP; cards on league/unwrap/profile. +10. **Profile** `/profile` — identity, stats, active challenge, certificate slot, avatar edit. +11. **Certificate** `/c/{publicId}` — unlisted link; physical copy only if admin enabled it and payment is **offline**. + +## Registration and phone + +- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts. +- A phone already tied to an account cannot re-enter either list as a fresh entry. +- One account per phone and per IP on registration paths. +- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab. + +## Certificates + +- `publicId` is a random 32-char hex (non-sequential). +- Anti-crawl: `X-Robots-Tag`, meta robots, `robots.txt` for `/c/` and `/api/certificates/`. +- Fixed slot on `/profile` (not inside the avatar dialog). +- Physical: price/window from site settings; chart default off; no online payment gateway. + +## Admin panel (summary) + +- Users, role, lock/unlock, password reset (requires target security answer) +- Chapters / lessons / questions / videos +- Events (Jalali date picker; real `isActive`) +- Monthly challenges (XP goal + window) +- Membership invites, whitelist/blacklist +- Site settings: public registration, donations, physical cert, landing sponsors +- Chat archives (ZIP export/restore + single-use external links) +- Physical certificate orders + +## Support / donations + +The community page is `chat` mode: no public payment card; coordination happens in private messages with the team. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..269d51b --- /dev/null +++ b/docs/security.md @@ -0,0 +1,56 @@ +# Security + +## Authentication and sessions + +| Mechanism | Details | +|-----------|---------| +| Login | username/email + password + image CAPTCHA | +| Access JWT | Short-lived (~15 minutes) | +| Refresh | Hashed in DB; remember-me ≈ 30 days, short session ≈ 24 hours | +| Browser cookies | `pargar_access` / `pargar_refresh` — `HttpOnly`, `SameSite=Lax`, `Secure` when `PUBLIC_URL` is https | +| Frontend storage | Only `user` in `localStorage` (`pargar.auth`); tokens are not persisted | +| API | `credentials: "include"`; optional Bearer for tests/clients | +| Logout | Revoke refresh + clear cookies | + +Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled for students (404). Resets are admin-only and require the target account’s security answer. + +## Secrets + +- Outside `DEV_MODE`, `Validate()` rejects values like `change-me` / `local-dev-only` and JWTs shorter than 32 characters. +- Local Helm: generate-once; production: `existingSecret` required. +- `ADMIN_PASSWORD` only bootstraps when the admin user is missing; restarts do not overwrite the password. + +## Proxy and CORS + +- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`. +- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`. + +## Rate limiting + +| Scope | Approximate behavior | +|-------|----------------------| +| Auth (login/register/captcha/refresh/recovery) | Redis per-IP budget; tighter after failures | +| Global `/api/*` | ~240 req/min/IP; 429 + user message + `Retry-After`; does not revoke tokens | +| External chat export | ~10/min/IP | +| Per-user uploads | Separately limited | + +`/api/health` is excluded from the global ceiling. + +## Uploads + +| Type | Controls | +|------|----------| +| Admin video | Extension + MIME + magic (MP4/WebM) | +| Avatar | JPEG/PNG/WebP via magic only; client Content-Type is not trusted | +| Chat | Extension allow-list + sniff for image/video/audio/PDF/Office; raw public zip removed | + +## Certificates and privacy + +- Certificate URLs are unlisted; noindex via API, Traefik, Next, and robots.txt. +- Chat-export tokens are single-use, revocable, and redacted from logs/metric labels. + +## Known trade-offs (current model) + +- SameSite=Lax + same-origin instead of a separate CSRF token (adequate for this model; revisit if FE and API split domains). +- Donations and physical certificates have no online payment gateway — intentional. +- XSS remains dangerous; HttpOnly cookies reduce token theft from localStorage but do not eliminate XSS.