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, renderer) uploadH := handlers.NewUploadHandler(authMgr, repo, llmClient, renderer, cfg.DataDir) 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) }) 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()), ) }) } }