@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user