227 lines
6.3 KiB
Go
227 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
|
||
|
|
}
|