285 lines
8.1 KiB
Go
285 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"kb-editor/internal/aifallback"
|
|
"kb-editor/internal/staging"
|
|
"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)
|
|
}
|
|
|
|
stagingStore, err := stagingStoreFromEnv(s.DataDir())
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, stagingStore)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if aiService != nil {
|
|
cfg.AIFallbackEnabled = true
|
|
cfg.AIFallbackTimeoutSeconds = int(aiTimeout.Seconds())
|
|
cfg.AIFallbackModel = aiService.Model()
|
|
}
|
|
|
|
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).withStaging(stagingStore).withAI(aiService)
|
|
handler := requestLogger(optionalBasicAuth(app.routes()))
|
|
|
|
writeTimeout := 60 * time.Second
|
|
if aiService != nil && aiTimeout+30*time.Second > writeTimeout {
|
|
writeTimeout = aiTimeout + 30*time.Second
|
|
}
|
|
srv := &http.Server{
|
|
Addr: listen,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: writeTimeout,
|
|
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())
|
|
log.Printf("Staging directory: %s (%d JSON files)", stagingStore.Dir(), stagingStore.Count())
|
|
if reloadInterval > 0 {
|
|
log.Printf("Automatic index reload: %s", reloadInterval)
|
|
}
|
|
if aiService != nil {
|
|
log.Printf("AI fallback enabled: model=%q timeout=%s staging=%s", aiService.Model(), aiTimeout, aiService.StagingDir())
|
|
}
|
|
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 stagingStoreFromEnv(dataDir string) (*staging.Store, error) {
|
|
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
|
|
if stagingDir == "" {
|
|
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
|
|
}
|
|
stagingAbs, err := filepath.Abs(stagingDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dataAbs, err := filepath.Abs(dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
|
|
return nil, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
|
|
}
|
|
return staging.New(stagingAbs)
|
|
}
|
|
|
|
func aiServiceFromEnv(mode string, st *staging.Store) (*aifallback.Service, time.Duration, error) {
|
|
enabled, err := envBool("AI_FALLBACK_ENABLED", false)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if !enabled {
|
|
return nil, 0, nil
|
|
}
|
|
if mode != "google" {
|
|
return nil, 0, fmt.Errorf("AI_FALLBACK_ENABLED is only supported with APP_MODE=google")
|
|
}
|
|
|
|
timeout, err := time.ParseDuration(envOr("OLLAMA_TIMEOUT", "10m"))
|
|
if err != nil || timeout < time.Second {
|
|
return nil, 0, fmt.Errorf("invalid OLLAMA_TIMEOUT: expected a duration such as 10m")
|
|
}
|
|
maxConcurrent, err := strconv.Atoi(envOr("OLLAMA_MAX_CONCURRENT", "1"))
|
|
if err != nil || maxConcurrent < 1 || maxConcurrent > 16 {
|
|
return nil, 0, fmt.Errorf("OLLAMA_MAX_CONCURRENT must be an integer between 1 and 16")
|
|
}
|
|
autoReply, err := envBool("OLLAMA_STAGING_AUTO_REPLY", false)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
minScore, err := strconv.ParseFloat(envOr("OLLAMA_STAGING_MIN_SCORE", "0.78"), 64)
|
|
if err != nil || minScore < 0 || minScore > 1 {
|
|
return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1")
|
|
}
|
|
if st == nil {
|
|
return nil, 0, fmt.Errorf("staging store is required for AI fallback")
|
|
}
|
|
svc, err := aifallback.New(aifallback.Config{
|
|
BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"),
|
|
Model: strings.TrimSpace(os.Getenv("OLLAMA_MODEL")),
|
|
Timeout: timeout,
|
|
MaxConcurrent: maxConcurrent,
|
|
AutoReply: autoReply,
|
|
MinScore: minScore,
|
|
}, st)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return svc, timeout, nil
|
|
}
|
|
|
|
func pathContains(parent, child string) bool {
|
|
rel, err := filepath.Rel(parent, child)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
|
}
|
|
|
|
func envBool(key string, fallback bool) (bool, error) {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
value, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid %s %q: expected true or false", key, raw)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
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
|
|
}
|