Files
glpi-ai-knowledgebase/cmd/server/main.go
groot dc369dab65
All checks were successful
release-tag / release-image (push) Successful in 1m34s
Freigabe-Anpassung und Soft-Trash
2026-07-29 11:08:03 +02:00

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
}