2477130dd9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
4.4 KiB
Go
165 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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"
|
|
"qbank/internal/llm"
|
|
)
|
|
|
|
func main() {
|
|
cfg := config.Load()
|
|
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
slog.SetDefault(logger)
|
|
|
|
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
|
slog.Error("create data dir", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
database, err := db.Open(filepath.Join(cfg.DataDir, "qbank.db"))
|
|
if err != nil {
|
|
slog.Error("open database", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer database.Close()
|
|
|
|
if err := db.Seed(database, cfg.AdminUsers); err != nil {
|
|
slog.Error("seed users", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
repo := db.New(database)
|
|
authMgr := auth.NewManager(database)
|
|
renderer := handlers.NewRenderer("web/templates")
|
|
llmClient := llm.New(cfg.OpenAIAPIKey, cfg.LLMModel)
|
|
|
|
ensureIcons("web/static")
|
|
|
|
authH := handlers.NewAuthHandler(authMgr, repo, renderer)
|
|
homeH := handlers.NewHomeHandler(authMgr, repo, renderer)
|
|
uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir)
|
|
questionH := handlers.NewQuestionHandler(authMgr, repo, renderer)
|
|
testH := handlers.NewTestHandler(authMgr, repo, renderer)
|
|
historyH := handlers.NewHistoryHandler(authMgr, repo, 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)
|
|
r.Get("/upload", uploadH.UploadGet)
|
|
r.Post("/upload", uploadH.UploadPost)
|
|
r.Get("/import/{id}", uploadH.ImportGet)
|
|
r.Post("/import/{id}", uploadH.ImportPost)
|
|
r.Get("/questions/{id}", questionH.Show)
|
|
r.Post("/questions/{id}", questionH.Edit)
|
|
r.Post("/questions/{id}/delete", questionH.Delete)
|
|
r.Get("/test/new", testH.NewGet)
|
|
r.Post("/test/new", testH.NewPost)
|
|
r.Get("/test/{id}/q/{n}", testH.QuestionGet)
|
|
r.Post("/test/{id}/q/{n}", testH.QuestionPost)
|
|
r.Get("/test/{id}/results", testH.ResultsGet)
|
|
r.Get("/history", historyH.Handle)
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: r,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 120 * time.Second, // LLM extraction can take a while
|
|
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 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}
|
|
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) {
|
|
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()),
|
|
)
|
|
})
|
|
}
|
|
}
|