commit 34eb47b59547d5d5cddc98ed8cc0d1fe299108d9 Author: Jānis Kacēns Date: Mon May 11 11:25:00 2026 +0300 Phase 0: skeleton, config, chi router, /healthz Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40a1597 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +OPENAI_API_KEY=sk-... +SESSION_SECRET=change-me-to-a-random-32-char-string +DATA_DIR=./data +PORT=8080 +ADMIN_USERS=alice:password1,bob:password2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f960ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/data/ +*.db +.env +/tmp/ +qbank +/out/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2b182bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +# QBank + +See PLAN.md for the full execution plan. Work through phases in order. +Don't skip ahead — each phase has an acceptance test that must pass before +moving on. + +## Conventions +- Go 1.22+, pure-Go deps only (no CGo). +- All DB access through internal/db/repo.go. +- Run `go vet ./...` and `go test ./...` before declaring a phase done. +- Commit at the end of each phase with message "Phase N: ". + +## Commands +- `make run` — start the dev server +- `make tailwind` — rebuild CSS +- `make test` — run all tests \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2f633d --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +BINARY=qbank +CSS_IN=web/templates/input.css +CSS_OUT=web/static/tailwind.css + +.PHONY: run build tailwind tidy test + +run: + go run ./cmd/server + +build: + CGO_ENABLED=0 go build -o $(BINARY) ./cmd/server + +tailwind: + tailwindcss -i $(CSS_IN) -o $(CSS_OUT) --minify + +tidy: + go mod tidy + +test: + go test ./... diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f3027b8 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,461 @@ +# QBank — Execution Plan + +A self-hosted quiz app. Import PDF/DOCX documents containing multiple-choice questions, parse them with an LLM, store them, generate randomised tests, take them on phone or laptop, and review wrong answers afterward. + +This document is the build plan. Work through phases in order. Each phase has a clear acceptance test — don't move on until it passes. + +--- + +## Locked-in tech decisions + +- **Language:** Go (1.22+ for `net/http` pattern routing, though we'll use chi). +- **Router:** `github.com/go-chi/chi/v5`. +- **DB:** SQLite via `modernc.org/sqlite` (pure Go, no CGo — simplifies Docker builds). +- **Sessions:** `github.com/alexedwards/scs/v2` with SQLite store. +- **Passwords:** `golang.org/x/crypto/bcrypt`. +- **PDF text extraction:** `github.com/ledongthuc/pdf` as the primary; fall back to shelling out to `pdftotext` (poppler-utils) if quality is poor on real docs. +- **DOCX text extraction:** hand-rolled — DOCX is a zip; unzip in memory, parse `word/document.xml`, concatenate `` text nodes. ~50 lines, no extra deps beyond `archive/zip` and `encoding/xml`. +- **OpenAI client:** `github.com/sashabaranov/go-openai`. Use the current cost-efficient model that supports JSON schema response_format (verify the current model name in OpenAI docs at build time; e.g. `gpt-4o-mini` or its successor). +- **Templates:** `html/template` standard library. +- **CSS:** Tailwind via the standalone CLI (no Node.js needed) — download the binary in the Dockerfile. +- **Auth model:** two named accounts seeded at first run. Each user has their own test history. +- **Hosting target:** self-hosted on a local Portainer instance. Deployed as a Docker container via a stack (docker-compose). A named volume holds `qbank.db` and uploaded files. + +--- + +## Project layout + +``` +qbank/ +├── cmd/server/main.go # entry point +├── internal/ +│ ├── config/ # env + flags +│ ├── db/ +│ │ ├── schema.sql +│ │ ├── db.go # open, migrate, helpers +│ │ └── repo.go # queries +│ ├── models/ # Question, Answer, Test, etc. +│ ├── parse/ +│ │ ├── pdf.go +│ │ ├── docx.go +│ │ └── chunk.go +│ ├── llm/ +│ │ └── openai.go # ExtractQuestions(text) -> []Question +│ ├── auth/ # login, middleware, session +│ ├── handlers/ # http handlers, one file per area +│ └── web/ +│ ├── templates/*.html +│ └── static/ # tailwind output, manifest, sw.js, icons +├── data/ # gitignored: qbank.db, uploads/ +├── .env.example +├── Dockerfile +├── docker-compose.yml +├── go.mod +└── README.md +``` + +--- + +## Phase 0 — Skeleton & config + +Tasks: +- `go mod init qbank`. +- Wire `cmd/server/main.go` with chi, a `/healthz` route returning 200, structured logging via `log/slog`. +- Config loader in `internal/config`: reads `OPENAI_API_KEY`, `SESSION_SECRET`, `DATA_DIR` (default `./data`), `PORT` (default `8080`), `ADMIN_USERS` (comma-separated `name:password` pairs, seeded on first start only). +- `.env.example` with the above keys. +- `Makefile` with `run`, `build`, `tailwind`, `tidy`. + +**Acceptance:** `go run ./cmd/server` boots, `curl localhost:8080/healthz` returns OK. + +--- + +## Phase 1 — Database & migrations + +Schema in `internal/db/schema.sql`: + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE questions ( + id TEXT PRIMARY KEY, -- sha256(question_text)[:16] + text TEXT NOT NULL, + source TEXT, + tags TEXT, -- comma-separated, simple + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE answers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + text TEXT NOT NULL, + is_correct BOOLEAN NOT NULL, + position INTEGER NOT NULL -- canonical order as imported +); + +CREATE TABLE tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + n_questions INTEGER NOT NULL, + question_ids TEXT NOT NULL -- JSON array, in chosen order +); + +CREATE TABLE test_answers ( + test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + question_id TEXT NOT NULL REFERENCES questions(id), + selected_answer_id INTEGER REFERENCES answers(id), + is_correct BOOLEAN, + answered_at DATETIME, + PRIMARY KEY (test_id, question_id) +); + +-- Per-user mastery stats. Drives weighted sampling in Phase 6 and the +-- accuracy display in Phase 5. A missing row means "never seen" — treat +-- as (times_seen=0, times_correct=0). +CREATE TABLE user_question_stats ( + user_id INTEGER NOT NULL REFERENCES users(id), + question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + times_seen INTEGER NOT NULL DEFAULT 0, + times_correct INTEGER NOT NULL DEFAULT 0, + last_seen_at DATETIME, + PRIMARY KEY (user_id, question_id) +); + +CREATE INDEX idx_test_answers_test ON test_answers(test_id); +CREATE INDEX idx_answers_question ON answers(question_id); +CREATE INDEX idx_stats_user ON user_question_stats(user_id); +``` + +Tasks: +- `db.Open(path)` — runs schema on first start (idempotent via `IF NOT EXISTS`). +- Repository functions: `CreateUser`, `GetUserByName`, `InsertQuestion`, `GetQuestion`, `ListQuestions(filter)`, `CountQuestions`, `CountAnswers`, `CreateTest`, `RecordAnswer`, `FinishTest`, `GetTest`, `ListTestsForUser`, `UpsertStat(userID, questionID, gotItRight bool)` (atomically increments `times_seen` and conditionally `times_correct`, sets `last_seen_at`), `GetStatsForUser(userID, questionIDs)` (returns a map; missing entries mean unseen). +- Seed admin users from `ADMIN_USERS` env var on first start only (skip if `users` table non-empty). + +**Acceptance:** delete `qbank.db`, start the server, verify users are seeded; `sqlite3 qbank.db ".schema"` shows all tables. + +--- + +## Phase 2 — Auth & layout + +- Login page (POST → set session via scs). +- `RequireAuth` middleware on everything except `/login`, `/healthz`, `/static/*`. +- Base template with header (app name, current user, logout, nav: Library / Upload / Take Test / History). +- Tailwind setup: download standalone CLI in Dockerfile and `make tailwind`; `web/static/tailwind.css` is the build output. Use mobile-first layout — single column under `md:`, content max-width `2xl` on desktop. +- Add `web/static/manifest.json` and `web/static/sw.js` (minimal — cache shell, network-first for API). Wire them in the base template so "Add to Home Screen" works on iOS and Android. + +**Acceptance:** log in as each seeded user, see your name in the header, log out. On a phone, "Add to Home Screen" produces a standalone-looking icon. + +--- + +## Phase 3 — Document parsing & LLM extraction + +`internal/parse`: +- `ExtractPDF(r io.Reader) (string, error)` using ledongthuc/pdf. If text comes back empty or gibberish (heuristic: <50 chars or <2% alphanumeric ratio), return a sentinel error so the handler can show "scan-based PDF — please convert to text first." +- `ExtractDOCX(r io.Reader) (string, error)` — open as zip, find `word/document.xml`, walk XML, concatenate `` text content, insert newlines on `` boundaries. +- `Chunk(text string, maxRunes int) []string` — split on double-newlines greedily up to ~10k runes per chunk (well under model context). + +`internal/llm`: +- `ExtractQuestions(ctx, chunk string) ([]ParsedQuestion, error)` calls OpenAI chat completions with `response_format` set to a JSON schema: + +```json +{ + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "required": ["question", "answers"], + "properties": { + "question": {"type": "string"}, + "answers": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["text", "correct"], + "properties": { + "text": {"type": "string"}, + "correct": {"type": "boolean"} + } + } + } + } + } + } + }, + "required": ["questions"] +} +``` + +System prompt: "You extract multiple-choice questions from study material. Return every question found. Exactly one answer per question must be marked correct. If the source doesn't clearly mark a correct answer, omit that question entirely. Do not invent questions not present in the text." + +Validate the response: drop any question without exactly one `correct: true`. Dedupe by question text hash. + +**Acceptance:** feed in a small handcrafted PDF and DOCX with 3–4 known Q&As, the function returns them correctly. + +--- + +## Phase 4 — Upload & import review flow + +- `GET /upload` — file input (accepts `.pdf`,`.docx`), optional comma-separated tags, optional source override. +- `POST /upload`: + - Save file to `data/uploads/_`. + - Extract text → chunk → call LLM per chunk → merge & dedupe → stash candidate questions in the session (or in a temp `import_drafts` table keyed by a UUID; the temp table is cleaner, do that). + - Redirect to `/import/`. +- `GET /import/` — editable preview. Each candidate question shows: question text (editable), each answer (editable, with radio for which is correct), tag input, "delete this question" checkbox. +- `POST /import/` — write surviving questions to `questions` and `answers`. Delete the draft. Redirect to library with a flash message ("Imported N questions, M skipped"). + +Files larger than ~5MB or with >100 candidate questions should still work — paginate the review page if needed. + +**Acceptance:** upload a real PDF, edit a couple of answers in the preview, confirm; library count goes up by the right amount. + +--- + +## Phase 5 — Library & stats + +`GET /` (library page): +- Total questions, total answers (sum across all questions), per-source breakdown, per-tag breakdown. +- Searchable/filterable list of all questions. Each row shows the current user's mastery for that question: "seen 8× · 3 correct (38%)" — pull from `user_question_stats`. Unseen questions show "—". +- Sort options: alphabetical, lowest accuracy first (i.e. "weakest first"), most-seen first. +- "Take a test" CTA. + +`GET /questions/` — view & edit a single question; show the same per-user stats prominently. +`POST /questions/` — save edits. +`POST /questions//delete` — delete (cascades to answers; archive instead by adding a `deleted_at` column if you'd rather, but hard delete is fine for v1). + +**Acceptance:** counts match `SELECT COUNT(*)`; editing a question persists; deleting a question removes its answers too. + +--- + +## Phase 6 — Take a test + +`GET /test/new` — form: +- Number of questions (default 10, capped at total available after filters). +- Optional tag filter, optional source filter. Show count of matching questions live. +- **Sampling mode** (radio): "Focus on weak spots" (default, weighted) vs "Mix evenly" (uniform random). Weighted mode biases toward questions the current user gets wrong more often; uniform gives a representative cross-section. See "Weighting algorithm" appendix below. + +`POST /test/new` — +- Resolve the candidate pool from filters. +- For each candidate, compute the user's weight from `user_question_stats` (formula in appendix). Uniform mode skips weighting (all weights = 1). +- Weighted-sample N candidates without replacement using the A-Res algorithm. +- Shuffle the resulting order. +- Create `tests` row with `question_ids` JSON. +- Redirect to `/test//q/1`. + +`GET /test//q/` — show the n-th question. Answers shuffled deterministically per (test_id, question_id) using a seeded RNG so refreshing doesn't re-shuffle. Progress indicator ("Question 3 of 10"). Mobile-friendly: large tap targets for answer choices. + +`POST /test//q/` — inside one transaction: +- Record selection in `test_answers` (compute `is_correct` server-side). +- Call `UpsertStat(userID, questionID, isCorrect)` to bump `times_seen`, conditionally `times_correct`, and update `last_seen_at`. +- If more questions remain → redirect to next. +- Else → mark test complete (`completed_at`), redirect to `/test//results`. + +Only the owning user can access their test. + +**Acceptance:** +- Start a 5-question test, answer all 5, end on results page. +- After several tests, weighted mode demonstrably surfaces previously-wrong questions more often than ones consistently answered correctly — verify by inspecting `user_question_stats` and re-running test creation a few times. +- A mastered question (e.g. seen 10×, correct 10×) still has a non-zero chance of appearing — confirm by running enough tests with a small pool that the floor weight is exercised. + +--- + +## Phase 7 — Results & review + +`GET /test//results`: +- Score: X / N (Y%). +- Time taken. +- For each question: question text, every answer listed, with markers: ✅ correct answer, ❌ user's choice (if wrong), ✅ both (if user got it right). +- Source & tags shown per question. + +**Acceptance:** wrong answers are clearly visible, correct answer always shown, page renders cleanly on a phone. + +--- + +## Phase 8 — History + +`GET /history`: +- List of past tests for the current user: date, score, n_questions, link to results. +- Aggregate stat: overall % correct across all attempts. +- Stretch: "Weak spots" — questions this user has gotten wrong more than once, with link to the question. + +**Acceptance:** taking multiple tests builds up a history; clicking back into a past test shows the full review. + +--- + +## Phase 9 — Containerise & deploy to Portainer + +Build: +- `Dockerfile`: multi-stage. + - Stage 1 (`golang:1.22-alpine` or newer): `go mod download`, run the Tailwind standalone CLI to produce `web/static/tailwind.css`, then `CGO_ENABLED=0 go build -o /out/qbank ./cmd/server`. + - Stage 2 (`gcr.io/distroless/static-debian12` if not using `pdftotext`, otherwise `alpine:3.20` with `apk add --no-cache poppler-utils`). Copy the binary, `web/templates/`, and `web/static/`. `EXPOSE 8080`. Non-root user. `ENTRYPOINT ["/qbank"]`. +- `.dockerignore`: at minimum `data/`, `*.db`, `.env`, `.git`, `node_modules` (if any sneak in). + +`docker-compose.yml` (this is what you paste into Portainer's "Stacks → Add stack → Web editor"): + +```yaml +services: + qbank: + image: ghcr.io//qbank:latest # or build locally and push, see below + container_name: qbank + restart: unless-stopped + ports: + - "8080:8080" # or put it behind your existing reverse proxy + environment: + DATA_DIR: /data + PORT: "8080" + OPENAI_API_KEY: ${OPENAI_API_KEY} + SESSION_SECRET: ${SESSION_SECRET} + ADMIN_USERS: ${ADMIN_USERS} # e.g. "alice:hunter2,bob:correcthorse" + volumes: + - qbank-data:/data + healthcheck: + test: ["CMD", "/qbank", "healthcheck"] # implement a tiny subcommand, or use wget if base image has it + interval: 30s + timeout: 5s + retries: 3 + +volumes: + qbank-data: +``` + +Set the three env vars in Portainer's stack "Environment variables" section so they don't end up in the compose file. `ADMIN_USERS` is only read on first start (when the `users` table is empty) — subsequent restarts ignore it, so it's safe to leave set. + +Image options — pick one: +1. **Build on your machine, push to a registry:** `docker build -t ghcr.io//qbank:latest .` then `docker push`. Portainer pulls it. Simplest if you already use GHCR/Docker Hub. +2. **Build on the Portainer host directly:** in the stack, replace `image:` with `build: { context: ., dockerfile: Dockerfile }` and use Portainer's git-based stack deployment pointing at the repo. Portainer pulls the repo and builds. Good for iterating without a registry round-trip. + +HTTPS / external access: +- If you have an existing reverse proxy on that host (Traefik, Caddy, Nginx Proxy Manager), don't publish 8080 — put `qbank` on the proxy's network and add labels/config so the proxy terminates TLS and forwards to the container's internal `:8080`. Mention this in the README so future-you remembers. +- If you don't have a reverse proxy yet and want it reachable from her phone outside your LAN, the easiest add-on is Caddy in front, or a Tailscale sidecar so the app is only reachable on your tailnet (private, no public exposure, works on her phone with the Tailscale app installed). + +Healthcheck note: the distroless image has no shell, so the healthcheck command must be the binary itself. Implement a `healthcheck` subcommand in `cmd/server/main.go` that does a localhost GET against `/healthz` and exits non-zero on failure. Or, if you switch the base image to `alpine`, you can use `wget --spider -q http://localhost:8080/healthz` and skip the subcommand. + +Backups: the `qbank-data` volume holds everything that matters. A simple cron on the host runs `docker run --rm -v qbank-data:/data -v /backups:/out alpine tar czf /out/qbank-$(date +%F).tar.gz /data` weekly. Document this in the README. + +**Acceptance:** stack comes up in Portainer, container is healthy, you can log in from your laptop and from her phone, restarting the container preserves all questions and history. + +--- + +## Appendix A — Weighting algorithm + +Each question has a per-user weight that determines its sampling probability in "Focus on weak spots" mode. Implement in `internal/sampling` (new package). + +### Weight formula + +For a question with `times_seen = s`, `times_correct = c`, and `last_seen_at = t`: + +``` +wrong = s - c + +# Laplace-smoothed error rate. Pretending you've seen the question once +# right and once wrong dampens noise from tiny sample sizes — one wrong +# answer doesn't catapult the question to top priority, and one right +# answer doesn't bury it. +error_rate = (wrong + 1) / (s + 2) + +# Floor so mastered questions still appear occasionally. 0.15 means a +# perfectly-mastered question shows up at roughly 15% the frequency of a +# brand-new one. Tune to taste. +base = max(0.15, error_rate) + +# Recency nudge. Long-unseen questions creep back up regardless of past +# accuracy — this prevents the "staleness death-spiral" where low-weight +# questions never get sampled, never get updated, and stay low forever. +# Caps at 2× after ~30 days; unseen questions (t is NULL) get the full 2×. +days_since = (now - t).days if t != NULL else 30 +recency = 1 + min(days_since / 30.0, 1.0) + +weight = base * recency +``` + +Unseen questions (no row in `user_question_stats`) get `base = 0.5` (mid-range, not punished and not boosted) and full recency multiplier — they end up at weight `~1.0`, which is roughly the same as a question you've gotten wrong half the time. + +Knobs worth exposing as constants at the top of the file (don't make them config — just easy to find for tuning): + +```go +const ( + FloorWeight = 0.15 // never drop below this + RecencyCapDays = 30.0 // days at which recency multiplier saturates + RecencyMaxMult = 2.0 // peak recency multiplier + UnseenBaseWeight = 0.5 // base for questions with no stats row +) +``` + +### Weighted sample without replacement (A-Res) + +```go +// SelectWeighted picks n distinct items from candidates using their weights. +// Equivalent to repeated weighted draws without replacement, in one pass. +// Time: O(m log m) where m = len(candidates). +func SelectWeighted(candidates []Candidate, n int, rng *rand.Rand) []Candidate { + if n >= len(candidates) { + return candidates + } + type keyed struct { + c Candidate + key float64 + } + keys := make([]keyed, len(candidates)) + for i, c := range candidates { + u := rng.Float64() + if u == 0 { + u = 1e-12 // avoid log(0) / pow weirdness + } + // Efraimidis–Spirakis A-Res key: u^(1/weight) + keys[i] = keyed{c, math.Pow(u, 1.0/c.Weight)} + } + sort.Slice(keys, func(i, j int) bool { return keys[i].key > keys[j].key }) + out := make([]Candidate, n) + for i := 0; i < n; i++ { + out[i] = keys[i].c + } + return out +} +``` + +### Testing the sampler + +Write a deterministic test in `internal/sampling/sampling_test.go` that: +- Builds a fake pool of 100 questions with hand-crafted stats (some mastered, some weak, some unseen). +- Runs `SelectWeighted` 10,000 times with a seeded RNG. +- Asserts that high-error-rate questions are sampled at >3× the rate of mastered ones, and that mastered ones are still sampled at least N times (proving the floor works). + +### Where it plugs in + +- `internal/sampling/weight.go` — `ComputeWeight(stat *UserQuestionStat, now time.Time) float64`. Takes `nil` for unseen. +- `internal/sampling/select.go` — `SelectWeighted` above. +- `internal/handlers/test.go` `POST /test/new` — + 1. `candidates := repo.ListQuestions(filter)`. + 2. `stats := repo.GetStatsForUser(userID, candidateIDs)` (map keyed by question_id). + 3. For each candidate: `cand.Weight = sampling.ComputeWeight(stats[c.ID], time.Now())` — or `1.0` if mode is "uniform". + 4. `picked := sampling.SelectWeighted(candidates, n, rng)`. + 5. Shuffle `picked`, persist as the test's `question_ids`. + +--- + +## Conventions for the build + +- Errors bubble up; handlers translate to HTTP via a small `httpErr` helper. Never `panic` in a handler. +- All DB access goes through `internal/db/repo.go` — no inline SQL in handlers. +- Templates use a single `layout.html` with `{{block "content"}}`. Pages define `content` only. +- CSRF: scs has built-in support; enable it on POST routes. +- File upload limit: 20MB, enforced via `http.MaxBytesReader`. +- All user-supplied text rendered with `html/template`'s default escaping — never use `template.HTML` on imported content. +- Log: one structured line per request with method, path, status, duration, user. +- Tests: write table-driven tests for `parse/`, `llm/` (mock the OpenAI client behind an interface), and the question-dedup logic. Handler tests with `httptest` for the auth flow and the test-taking flow. + +--- + +## Open questions to confirm before Phase 0 + +1. Two separate accounts or one shared? (Plan above assumes separate.) +2. Should the LLM auto-suggest tags during import, or are tags manual-only? (Plan above is manual; auto-tagging is a small addition to the prompt + schema.) +3. Hard delete or soft delete for questions? (Plan above is hard delete.) +4. Comfortable with the weighting defaults in Appendix A (floor 0.15, recency cap at 30 days, unseen base 0.5)? These are tuneable later but it's worth a sanity check up front — if you want mastered questions to appear more rarely, raise the floor; more often, lower it. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..3add94e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "qbank/internal/config" +) + +func main() { + cfg := config.Load() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(requestLogger(logger)) + r.Use(middleware.Recoverer) + + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("server starting", "port", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("shutdown error", "err", err) + } + slog.Info("server stopped") +} + +func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "duration_ms", time.Since(start).Milliseconds(), + "request_id", middleware.GetReqID(r.Context()), + ) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12a145c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module qbank + +go 1.24.6 + +require github.com/go-chi/chi/v5 v5.2.5 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a4ac04e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2ee39a0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,53 @@ +package config + +import ( + "os" + "strings" +) + +type Config struct { + OpenAIAPIKey string + SessionSecret string + DataDir string + Port string + AdminUsers []AdminUser +} + +type AdminUser struct { + Name string + Password string +} + +func Load() *Config { + cfg := &Config{ + OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), + SessionSecret: os.Getenv("SESSION_SECRET"), + DataDir: envOr("DATA_DIR", "./data"), + Port: envOr("PORT", "8080"), + } + cfg.AdminUsers = parseAdminUsers(os.Getenv("ADMIN_USERS")) + return cfg +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func parseAdminUsers(raw string) []AdminUser { + if raw == "" { + return nil + } + var users []AdminUser + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + name, pass, ok := strings.Cut(pair, ":") + if !ok || name == "" || pass == "" { + continue + } + users = append(users, AdminUser{Name: name, Password: pass}) + } + return users +}