diff --git a/cmd/server/main.go b/cmd/server/main.go index 1de2d4f..95a0103 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) { diff --git a/go.mod b/go.mod index 8af926b..cd1d58b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3364fe8..99c4b57 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f739cb5 --- /dev/null +++ b/internal/auth/auth.go @@ -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") +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 59b87cc..471b435 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -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); diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..312e1e7 --- /dev/null +++ b/internal/handlers/auth.go @@ -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) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..b08ae98 --- /dev/null +++ b/internal/handlers/home.go @@ -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)) +} diff --git a/internal/handlers/render.go b/internal/handlers/render.go new file mode 100644 index 0000000..31ec021 --- /dev/null +++ b/internal/handlers/render.go @@ -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 + .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), + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..8ee7fbd --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./web/templates/**/*.html"], + theme: { extend: {} }, + plugins: [], +} diff --git a/web/static/icon-192.png b/web/static/icon-192.png new file mode 100644 index 0000000..3d5626f Binary files /dev/null and b/web/static/icon-192.png differ diff --git a/web/static/icon-512.png b/web/static/icon-512.png new file mode 100644 index 0000000..9d99c54 Binary files /dev/null and b/web/static/icon-512.png differ diff --git a/web/static/manifest.json b/web/static/manifest.json new file mode 100644 index 0000000..09a0a07 --- /dev/null +++ b/web/static/manifest.json @@ -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" + } + ] +} diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..aeae871 --- /dev/null +++ b/web/static/sw.js @@ -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)) + ); +}); diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..b24e7d5 --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,7 @@ +{{define "content"}} +

Library

+

+ No questions yet. + Upload a document to get started. +

+{{end}} diff --git a/web/templates/input.css b/web/templates/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/web/templates/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..90acde7 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,73 @@ +{{define "layout"}} + + + + + + QBank + + + + + + + + + + + + + {{if .User}} +
+
+ QBank +
+ + +
+ + +
+
+
+ +
+ {{else}} +
+
+ QBank +
+
+ {{end}} + + {{if .Flash}} +
+
+ {{.Flash}} +
+
+ {{end}} + +
+ {{block "content" .}}{{end}} +
+ + + +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..96931be --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,32 @@ +{{define "content"}} +
+

Sign in to QBank

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+ +
+ + +
+
+ + +
+ +
+
+{{end}}