package main import ( "crypto/subtle" "embed" "flag" "fmt" "io/fs" "log" "net/http" "os" "strings" "time" "kb-editor/internal/store" ) //go:embed web/* viewer/* var webFS embed.FS func main() { var dataDir string var listen string flag.StringVar(&dataDir, "data", envOr("DATA_DIR", "./data/knowledge"), "directory containing JSON knowledge files") flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address") flag.Parse() cfg, staticDir, err := configFromEnv() if err != nil { log.Fatal(err) } s, err := store.New(dataDir) if err != nil { log.Fatalf("initialize store: %v", err) } reloadInterval, err := autoReloadInterval(cfg.Mode) if err != nil { log.Fatal(err) } if reloadInterval > 0 { go startAutoReload(s, reloadInterval) } sub, err := fs.Sub(webFS, staticDir) if err != nil { log.Fatal(err) } app := newApp(s, sub, cfg) handler := requestLogger(optionalBasicAuth(app.routes())) srv := &http.Server{ Addr: listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 90 * time.Second, } log.Printf("KB service listening on %s", listen) log.Printf("Mode: %s (writable=%t)", cfg.Mode, cfg.Writable) log.Printf("Data directory: %s (%d JSON files indexed)", s.DataDir(), s.Count()) if reloadInterval > 0 { log.Printf("Automatic index reload: %s", reloadInterval) } if u := os.Getenv("BASIC_AUTH_USER"); u != "" { log.Printf("Basic authentication enabled for user %q", u) } if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } } func configFromEnv() (appConfig, string, error) { mode := strings.ToLower(strings.TrimSpace(envOr("APP_MODE", "editor"))) switch mode { case "editor": return appConfig{ Mode: "editor", Title: envOr("APP_TITLE", "Knowledge Base Editor"), Subtitle: envOr("APP_SUBTITLE", "JSON · Massenbearbeitung · Docker"), Writable: true, }, "web", nil case "google", "viewer", "search": return appConfig{ Mode: "google", Title: envOr("APP_TITLE", "Helpdesk Search"), Subtitle: envOr("APP_SUBTITLE", "Interne Wissenssuche für den Helpdesk"), Writable: false, }, "viewer", nil default: return appConfig{}, "", fmt.Errorf("invalid APP_MODE %q: expected editor or google", mode) } } func autoReloadInterval(mode string) (time.Duration, error) { raw := strings.TrimSpace(os.Getenv("AUTO_RELOAD_INTERVAL")) if raw == "" { if mode == "google" { return 60 * time.Second, nil } return 0, nil } if raw == "0" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") { return 0, nil } d, err := time.ParseDuration(raw) if err != nil { return 0, fmt.Errorf("invalid AUTO_RELOAD_INTERVAL %q: %w", raw, err) } if d < 5*time.Second { return 0, fmt.Errorf("AUTO_RELOAD_INTERVAL must be 0/off or at least 5s") } return d, nil } func startAutoReload(s *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { if err := s.Reload(); err != nil { log.Printf("automatic index reload failed: %v", err) } } } func envOr(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func optionalBasicAuth(next http.Handler) http.Handler { user := os.Getenv("BASIC_AUTH_USER") pass := os.Getenv("BASIC_AUTH_PASSWORD") if user == "" && pass == "" { return next } if user == "" || pass == "" { log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty") } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 if !ok || !userOK || !passOK { w.Header().Set("WWW-Authenticate", `Basic realm="KB Helpdesk", charset="UTF-8"`) http.Error(w, "authentication required", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.RequestURI(), time.Since(start).Round(time.Millisecond)) }) } func mustJSONContentType(w http.ResponseWriter, r *http.Request) bool { ct := r.Header.Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { http.Error(w, fmt.Sprintf("Content-Type must be application/json, got %q", ct), http.StatusUnsupportedMediaType) return false } return true }