All checks were successful
release-tag / release-image (push) Successful in 1m35s
175 lines
4.6 KiB
Go
175 lines
4.6 KiB
Go
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
|
|
}
|