initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
226
backend/main.go
Normal file
226
backend/main.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue