Phase 2: auth, session management, layout, PWA manifest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jānis Kacēns
2026-05-11 11:54:37 +03:00
parent 0bc9160d97
commit d9de37d3d8
17 changed files with 487 additions and 1 deletions
+49 -1
View File
@@ -2,6 +2,11 @@ package main
import (
"context"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log/slog"
"net/http"
"os"
@@ -13,8 +18,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"qbank/internal/auth"
"qbank/internal/config"
"qbank/internal/db"
"qbank/internal/handlers"
)
func main() {
@@ -40,17 +47,38 @@ func main() {
os.Exit(1)
}
_ = db.New(database) // repo will be wired into handlers in later phases
repo := db.New(database)
authMgr := auth.NewManager(database)
renderer := handlers.NewRenderer("web/templates")
ensureIcons("web/static")
authH := handlers.NewAuthHandler(authMgr, repo, renderer)
homeH := handlers.NewHomeHandler(authMgr, renderer)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(requestLogger(logger))
r.Use(middleware.Recoverer)
r.Use(authMgr.SM.LoadAndSave)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
r.Get("/sw.js", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/static/sw.js")
})
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
r.Get("/login", authH.LoginGet)
r.Post("/login", authH.LoginPost)
r.Post("/logout", authH.Logout)
r.Group(func(r chi.Router) {
r.Use(authMgr.RequireAuth)
r.Get("/", homeH.Handle)
})
srv := &http.Server{
Addr: ":" + cfg.Port,
@@ -80,6 +108,26 @@ func main() {
slog.Info("server stopped")
}
// ensureIcons generates simple solid-color PNG icons if they don't already exist.
func ensureIcons(dir string) {
for _, size := range []int{192, 512} {
path := filepath.Join(dir, fmt.Sprintf("icon-%d.png", size))
if _, err := os.Stat(path); err == nil {
continue
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
blue := color.RGBA{R: 37, G: 99, B: 235, A: 255} // Tailwind blue-600
draw.Draw(img, img.Bounds(), &image.Uniform{C: blue}, image.Point{}, draw.Src)
f, err := os.Create(path)
if err != nil {
slog.Warn("create icon", "path", path, "err", err)
continue
}
png.Encode(f, img)
f.Close()
}
}
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) {
+1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require github.com/go-chi/chi/v5 v5.2.5
require (
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+133
View File
@@ -0,0 +1,133 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"net/http"
"time"
"github.com/alexedwards/scs/v2"
)
type contextKey string
const ctxUser contextKey = "user"
// User is the authenticated user stored in request context.
type User struct {
ID int64
Name string
}
// SQLiteStore implements scs.Store using a modernc.org/sqlite connection.
type SQLiteStore struct {
db *sql.DB
}
func NewStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Delete(token string) error {
_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
}
func (s *SQLiteStore) Find(token string) ([]byte, bool, error) {
var data []byte
err := s.db.QueryRow(
"SELECT data FROM sessions WHERE token = ? AND expiry > ?",
token, time.Now().Unix(),
).Scan(&data)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return data, true, nil
}
func (s *SQLiteStore) Commit(token string, b []byte, expiry time.Time) error {
_, err := s.db.Exec(
"INSERT OR REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, ?)",
token, b, expiry.Unix(),
)
return err
}
// Manager wraps scs.SessionManager with application-level helpers.
type Manager struct {
SM *scs.SessionManager
}
func NewManager(db *sql.DB) *Manager {
sm := scs.New()
sm.Store = NewStore(db)
sm.Lifetime = 30 * 24 * time.Hour
sm.Cookie.HttpOnly = true
sm.Cookie.SameSite = http.SameSiteLaxMode
return &Manager{SM: sm}
}
// SetUser persists the authenticated user in the session.
func (m *Manager) SetUser(r *http.Request, id int64, name string) {
m.SM.Put(r.Context(), "userID", id)
m.SM.Put(r.Context(), "userName", name)
}
// ClearUser destroys the session (used on logout).
func (m *Manager) ClearUser(r *http.Request) error {
return m.SM.Destroy(r.Context())
}
// UserFromCtx returns the authenticated user from context, or nil.
func UserFromCtx(ctx context.Context) *User {
u, _ := ctx.Value(ctxUser).(*User)
return u
}
// RequireAuth redirects unauthenticated requests to /login.
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := m.SM.GetInt64(r.Context(), "userID")
if id == 0 {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
name := m.SM.GetString(r.Context(), "userName")
ctx := context.WithValue(r.Context(), ctxUser, &User{ID: id, Name: name})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CSRFToken returns the session's CSRF token, creating one if needed.
func (m *Manager) CSRFToken(r *http.Request) string {
tok := m.SM.GetString(r.Context(), "csrf")
if tok != "" {
return tok
}
b := make([]byte, 16)
rand.Read(b)
tok = hex.EncodeToString(b)
m.SM.Put(r.Context(), "csrf", tok)
return tok
}
// CheckCSRF returns true if the form's csrf_token matches the session token.
func (m *Manager) CheckCSRF(r *http.Request) bool {
session := m.SM.GetString(r.Context(), "csrf")
form := r.FormValue("csrf_token")
return session != "" && subtle.ConstantTimeCompare([]byte(session), []byte(form)) == 1
}
// SetFlash stores a one-time flash message in the session.
func (m *Manager) SetFlash(r *http.Request, msg string) {
m.SM.Put(r.Context(), "flash", msg)
}
// PopFlash reads and removes the flash message.
func (m *Manager) PopFlash(r *http.Request) string {
return m.SM.PopString(r.Context(), "flash")
}
+7
View File
@@ -47,6 +47,13 @@ CREATE TABLE IF NOT EXISTS user_question_stats (
PRIMARY KEY (user_id, question_id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
CREATE INDEX IF NOT EXISTS idx_test_answers_test ON test_answers(test_id);
CREATE INDEX IF NOT EXISTS idx_answers_question ON answers(question_id);
CREATE INDEX IF NOT EXISTS idx_stats_user ON user_question_stats(user_id);
+63
View File
@@ -0,0 +1,63 @@
package handlers
import (
"log/slog"
"net/http"
"golang.org/x/crypto/bcrypt"
"qbank/internal/auth"
"qbank/internal/db"
)
type AuthHandler struct {
auth *auth.Manager
repo *db.Repo
render *Renderer
}
func NewAuthHandler(a *auth.Manager, repo *db.Repo, r *Renderer) *AuthHandler {
return &AuthHandler{auth: a, repo: repo, render: r}
}
func (h *AuthHandler) LoginGet(w http.ResponseWriter, r *http.Request) {
h.render.Render(w, http.StatusOK, "login", map[string]any{
"CSRFToken": h.auth.CSRFToken(r),
})
}
func (h *AuthHandler) LoginPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
user, err := h.repo.GetUserByName(username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
slog.Info("login failed", "username", username)
h.render.Render(w, http.StatusUnauthorized, "login", map[string]any{
"CSRFToken": h.auth.CSRFToken(r),
"Error": "Invalid username or password.",
})
return
}
h.auth.SetUser(r, user.ID, user.Name)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if !h.auth.CheckCSRF(r) {
HTTPError(w, http.StatusForbidden)
return
}
if err := h.auth.ClearUser(r); err != nil {
slog.Error("logout", "err", err)
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+20
View File
@@ -0,0 +1,20 @@
package handlers
import (
"net/http"
"qbank/internal/auth"
)
type HomeHandler struct {
auth *auth.Manager
render *Renderer
}
func NewHomeHandler(a *auth.Manager, r *Renderer) *HomeHandler {
return &HomeHandler{auth: a, render: r}
}
func (h *HomeHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.render.Render(w, http.StatusOK, "home", BaseData(h.auth, r))
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
"path/filepath"
"qbank/internal/auth"
)
// Renderer parses and executes HTML templates from a directory.
type Renderer struct {
dir string
}
func NewRenderer(dir string) *Renderer { return &Renderer{dir: dir} }
// Render executes layout.html + <name>.html, passing data to the "layout" template.
func (r *Renderer) Render(w http.ResponseWriter, status int, name string, data any) {
t, err := template.ParseFiles(
filepath.Join(r.dir, "layout.html"),
filepath.Join(r.dir, name+".html"),
)
if err != nil {
slog.Error("parse template", "name", name, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("execute template", "name", name, "err", err)
}
}
// HTTPError writes a plain-text HTTP error.
func HTTPError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
// BaseData builds the common template map (User, CSRFToken, Flash).
func BaseData(a *auth.Manager, r *http.Request) map[string]any {
return map[string]any{
"User": auth.UserFromCtx(r.Context()),
"CSRFToken": a.CSRFToken(r),
"Flash": a.PopFlash(r),
}
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./web/templates/**/*.html"],
theme: { extend: {} },
plugins: [],
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "QBank",
"short_name": "QBank",
"start_url": "/",
"display": "standalone",
"background_color": "#f9fafb",
"theme_color": "#2563eb",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
const CACHE = 'qbank-v1';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
self.addEventListener('fetch', e => {
// Network-first: serve fresh, fall back to cache for GET requests.
if (e.request.method !== 'GET') return;
e.respondWith(
fetch(e.request)
.then(res => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
})
.catch(() => caches.match(e.request))
);
});
+7
View File
@@ -0,0 +1,7 @@
{{define "content"}}
<h1 class="text-2xl font-bold text-gray-800 mb-2">Library</h1>
<p class="text-gray-500 text-sm">
No questions yet.
<a href="/upload" class="text-blue-600 hover:underline">Upload a document</a> to get started.
</p>
{{end}}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+73
View File
@@ -0,0 +1,73 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QBank</title>
<!-- Development: Tailwind CDN. Production: replaced by make tailwind output. -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="manifest" href="/static/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="QBank">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<meta name="theme-color" content="#2563eb">
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body class="bg-gray-50 min-h-screen text-gray-900">
{{if .User}}
<header class="bg-blue-600 text-white shadow-md">
<div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="text-lg font-bold tracking-tight">QBank</a>
<div class="flex items-center gap-3">
<nav class="hidden sm:flex items-center gap-5 text-sm font-medium">
<a href="/" class="hover:underline underline-offset-2">Library</a>
<a href="/upload" class="hover:underline underline-offset-2">Upload</a>
<a href="/test/new" class="hover:underline underline-offset-2">Take Test</a>
<a href="/history" class="hover:underline underline-offset-2">History</a>
</nav>
<span class="text-sm opacity-75 hidden sm:inline">{{.User.Name}}</span>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit"
class="text-xs bg-blue-700 hover:bg-blue-800 px-3 py-1.5 rounded-md font-medium">
Logout
</button>
</form>
</div>
</div>
<nav class="sm:hidden border-t border-blue-500 overflow-x-auto">
<div class="flex px-4 py-2 gap-5 text-sm font-medium whitespace-nowrap items-center">
<a href="/" class="hover:underline">Library</a>
<a href="/upload" class="hover:underline">Upload</a>
<a href="/test/new" class="hover:underline">Take Test</a>
<a href="/history" class="hover:underline">History</a>
<span class="opacity-75 ml-auto">{{.User.Name}}</span>
</div>
</nav>
</header>
{{else}}
<header class="bg-blue-600 text-white shadow-md">
<div class="max-w-2xl mx-auto px-4 py-3">
<span class="text-lg font-bold tracking-tight">QBank</span>
</div>
</header>
{{end}}
{{if .Flash}}
<div class="max-w-2xl mx-auto px-4 pt-4">
<div class="bg-green-50 border border-green-200 text-green-800 text-sm px-4 py-3 rounded-md">
{{.Flash}}
</div>
</div>
{{end}}
<main class="max-w-2xl mx-auto px-4 py-6">
{{block "content" .}}{{end}}
</main>
</body>
</html>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<div class="max-w-sm mx-auto mt-12">
<h1 class="text-2xl font-bold mb-8 text-center text-gray-800">Sign in to QBank</h1>
{{if .Error}}
<div class="mb-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3 rounded-md">
{{.Error}}
</div>
{{end}}
<form method="post" action="/login" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" name="username" required autofocus
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" name="password" required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-md
text-sm font-semibold shadow-sm">
Sign in
</button>
</form>
</div>
{{end}}