// 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 }