go-doc/backend/main.go
2026-10-07 15:03:50 +02:00

226 lines
6.3 KiB
Go

// Command godoc runs the go-doc documentation and feedback platform.
//
// It serves the REST API on /api/* and, when the React bundle has been compiled
// in via `make build`, serves the single-page application on every other path.
// Shutdown is graceful: SIGINT/SIGTERM stop accepting connections, let in-flight
// requests finish, and close the process cleanly.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go-doc/go-loose/client"
"go-doc/router"
"go-doc/storage"
"go-doc/webui"
)
const (
defaultAddr = "0.0.0.0:8080"
readHeaderTimeout = 10 * time.Second
readTimeout = 30 * time.Second
writeTimeout = 60 * time.Second
idleTimeout = 120 * time.Second
shutdownGracePeriod = 10 * time.Second
shutdownSignalWindow = 5 * time.Second
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
var (
addr = flag.String("addr", envOr("GO_DOC_ADDR", defaultAddr), "host:port the API listens on")
dataDir = flag.String("data", "", "flat-file data directory (defaults to GO_DOC_DATA_DIR or auto-discovery)")
origins = flag.String("cors", envOr("GO_DOC_CORS", ""), "comma-separated allowed origins (default: *)")
logLevel = flag.String("log-level", envOr("GO_DOC_LOG_LEVEL", "info"), "debug, info, warn or error")
logFormat = flag.String("log-format", envOr("GO_DOC_LOG_FORMAT", "text"), "text or json")
looseBaseURL = flag.String("loose-base-url", envOr("GO_LOOSE_BASE_URL", ""), "go-loose base URL for authentication (e.g., https://auth.example.com)")
looseClientID = flag.String("loose-client-id", envOr("GO_LOOSE_CLIENT_ID", ""), "go-loose client ID for OAuth")
looseSecret = flag.String("loose-client-secret", envOr("GO_LOOSE_CLIENT_SECRET", ""), "go-loose client secret for OAuth")
looseRedirect = flag.String("loose-redirect-url", envOr("GO_LOOSE_REDIRECT_URL", ""), "go-loose redirect URL after login (e.g., https://doc.example.com/auth/callback)")
)
flag.Parse()
logger := newLogger(*logLevel, *logFormat)
slog.SetDefault(logger)
root := *dataDir
if root == "" {
root = storage.ResolveRoot()
}
engine, err := storage.New(root)
if err != nil {
return fmt.Errorf("initialise storage: %w", err)
}
logger.Info("storage ready",
"data_root", engine.Root(),
"docs", engine.DocsDir(),
"comments", engine.CommentsDir(),
)
staticFS := webui.FS()
if staticFS == nil {
logger.Warn("no embedded frontend bundle found; serving API only",
"hint", "run `make frontend-run` for the Vite dev server or `make build` to embed assets")
}
// Initialize go-loose browser auth if configured
var browserAuth *client.BrowserAuth
if *looseBaseURL != "" && *looseClientID != "" && *looseSecret != "" && *looseRedirect != "" {
browserAuthConfig := client.BrowserAuthConfig{
BaseURL: *looseBaseURL,
ClientID: *looseClientID,
ClientSecret: *looseSecret,
RedirectURL: *looseRedirect,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: "/auth/login",
CookieName: "go_doc_session",
CookieSecure: true,
}
browserAuth, err = client.NewBrowserAuth(browserAuthConfig)
if err != nil {
logger.Warn("go-loose browser auth not configured", "error", err)
} else {
logger.Info("go-loose browser auth enabled", "base_url", *looseBaseURL)
}
}
cfg := router.Config{
Engine: engine,
Static: staticFS,
AllowedOrigins: splitAndTrim(*origins),
Logger: logger,
BrowserAuth: browserAuth,
}
if len(cfg.AllowedOrigins) == 0 {
cfg.AllowedOrigins = []string{router.DefaultAllowedOrigin}
}
handler, err := router.NewWithConfig(cfg)
if err != nil {
return fmt.Errorf("build router: %w", err)
}
srv := &http.Server{
Addr: *addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
listener, err := net.Listen("tcp", *addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *addr, err)
}
logger.Info("go-doc listening",
"addr", listener.Addr().String(),
"frontend_embedded", staticFS != nil,
"cors_origins", strings.Join(cfg.AllowedOrigins, ", "),
)
serveErr := make(chan error, 1)
go func() {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
return
}
serveErr <- nil
}()
select {
case err := <-serveErr:
if err != nil {
return fmt.Errorf("http server: %w", err)
}
return nil
case <-ctx.Done():
stop()
logger.Info("shutdown signal received", "grace", shutdownGracePeriod.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed; forcing close", "error", err)
if closeErr := srv.Close(); closeErr != nil {
return fmt.Errorf("force close: %w", closeErr)
}
return nil
}
select {
case err := <-serveErr:
if err != nil {
return fmt.Errorf("http server: %w", err)
}
case <-time.After(shutdownSignalWindow):
}
logger.Info("stopped cleanly")
return nil
}
func newLogger(level, format string) *slog.Logger {
var lvl slog.Level
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
if strings.EqualFold(strings.TrimSpace(format), "json") {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func splitAndTrim(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}