Files
Archivum/backend/internal/graph/server.go
brasse b 4ba3b7dd49 fix: bind mounts from DOCKER_PATH + real storage reads from disk
docker-compose.yml:
- Switch from named volumes back to bind mounts driven by DOCKER_PATH in .env
- DOCKER_PATH/config → /config, DOCKER_PATH/data → /data
- Works with both Windows paths (C:\...) and Linux paths (/opt/...)

server.go:
- Wire in storage.Store so documents query reads real .adoc files from disk
- handleDocuments: calls store.List("") and store.Read(slug) for actual content
- extractTitle: parses first "= Title" line from AsciiDoc, falls back to slug
- handleSetup: also initialises store after writing config so docs appear immediately
- Single document query (document(slug:...)) reads and returns full content

.env.example:
- Clarify DOCKER_PATH usage with Windows and Linux examples
- Show resulting directory layout on host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:45:08 +02:00

396 lines
11 KiB
Go

package graph
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/brasse-b/archivum/internal/config"
"github.com/brasse-b/archivum/internal/storage"
)
// Server holds runtime state that can change after the setup wizard completes.
type Server struct {
mu sync.RWMutex
cfg *config.Config
configPath string
store *storage.Store
}
func NewServer(cfg *config.Config, configPath string) http.Handler {
s := &Server{cfg: cfg, configPath: configPath}
if cfg != nil {
if store, err := storage.New(cfg.StoragePath); err != nil {
log.Printf("[storage] failed to open storage at %s: %v", cfg.StoragePath, err)
} else {
s.store = store
log.Printf("[storage] opened at %s", cfg.StoragePath)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/graphql", s.handleGraphQL)
mux.HandleFunc("/health", s.handleHealth)
uiDir := os.Getenv("UI_DIR")
if uiDir == "" {
uiDir = "/srv/archivum/ui"
}
log.Printf("serving UI from %s", uiDir)
mux.Handle("/", spaHandler(uiDir))
return requestLogger(mux)
}
// ── GraphQL handler ───────────────────────────────────────────────────────────
type gqlRequest struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables"`
}
func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeGQLError(w, "failed to read request body")
return
}
var req gqlRequest
if err := json.Unmarshal(body, &req); err != nil {
writeGQLError(w, fmt.Sprintf("invalid JSON: %v", err))
return
}
log.Printf("[graphql] op: %s", gqlOpName(req.Query))
s.mu.RLock()
cfg := s.cfg
store := s.store
s.mu.RUnlock()
// ── Mutations available before setup ─────────────────────────────────────
if strings.Contains(req.Query, "setup") {
s.handleSetup(w, req)
return
}
// ── System status (always available) ─────────────────────────────────────
if strings.Contains(req.Query, "systemStatus") && !strings.Contains(req.Query, "documents") {
if cfg == nil {
log.Printf("[graphql] systemStatus → REQUIRE_SETUP")
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
} else {
log.Printf("[graphql] systemStatus → OK")
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
}
return
}
// ── All other operations require a valid config ───────────────────────────
if cfg == nil {
log.Printf("[graphql] blocked — config not initialised")
writeGQLError(w, "REQUIRE_SETUP")
return
}
switch {
case strings.Contains(req.Query, "testLdapConnection"):
writeJSON(w, `{"data":{"testLdapConnection":{"success":false,"message":"LDAP resolver not yet implemented"}}}`)
case strings.Contains(req.Query, "login"):
writeJSON(w, `{"data":{"login":"stub-token"}}`)
case strings.Contains(req.Query, "documents") || strings.Contains(req.Query, "document"):
s.handleDocuments(w, req, store)
default:
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
}
}
// handleDocuments reads document list (or a single doc) from the real Store.
func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *storage.Store) {
if store == nil {
writeGQLError(w, "storage not initialised")
return
}
// Single document query
if strings.Contains(req.Query, "document(") || strings.Contains(req.Query, "document(slug") {
slug, _ := req.Variables["s"].(string)
if slug == "" {
writeGQLError(w, "slug is required")
return
}
content, err := store.Read(slug)
if err != nil {
log.Printf("[storage] read %q: %v", slug, err)
writeGQLError(w, fmt.Sprintf("document not found: %s", slug))
return
}
title := extractTitle(content, slug)
resp := map[string]interface{}{
"data": map[string]interface{}{
"document": map[string]interface{}{
"slug": slug,
"content": content,
"meta": map[string]string{
"slug": slug,
"title": title,
"updatedAt": "",
},
},
},
}
writeJSONObj(w, resp)
return
}
// Document list
slugs, err := store.List("")
if err != nil {
log.Printf("[storage] list: %v", err)
writeGQLError(w, fmt.Sprintf("failed to list documents: %v", err))
return
}
log.Printf("[storage] listed %d documents", len(slugs))
docs := make([]map[string]string, 0, len(slugs))
for _, slug := range slugs {
content, err := store.Read(slug)
title := slug
if err == nil {
title = extractTitle(content, slug)
}
docs = append(docs, map[string]string{
"slug": slug,
"title": title,
"updatedAt": "",
})
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"documents": docs,
},
})
}
// handleSetup parses the setup input, writes config.json, and activates config + storage.
func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
input, ok := req.Variables["i"].(map[string]interface{})
if !ok {
log.Printf("[setup] invalid input — variables: %v", req.Variables)
writeGQLError(w, "invalid setup input")
return
}
storagePath, _ := input["storagePath"].(string)
jwtSecret, _ := input["jwtSecret"].(string)
if storagePath == "" || jwtSecret == "" {
writeGQLError(w, "storagePath and jwtSecret are required")
return
}
cfg := &config.Config{
StoragePath: storagePath,
DBPath: "/data/db/archivum.db",
JWTSecret: jwtSecret,
ListenAddr: ":4000",
}
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
port := 389
if p, ok := ldapRaw["port"].(float64); ok {
port = int(p)
}
cfg.LDAP = config.LDAPConfig{
Host: strVal(ldapRaw, "host"),
Port: port,
BaseDN: strVal(ldapRaw, "baseDN"),
BindDN: strVal(ldapRaw, "bindDN"),
BindPassword: strVal(ldapRaw, "bindPassword"),
}
}
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
log.Printf("[setup] mkdir failed: %v", err)
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
return
}
if err := config.Save(s.configPath, cfg); err != nil {
log.Printf("[setup] failed to write config: %v", err)
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
log.Printf("[setup] config written to %s", s.configPath)
store, err := storage.New(storagePath)
if err != nil {
log.Printf("[setup] failed to open storage at %s: %v", storagePath, err)
// Non-fatal — storage can be retried, but config is saved.
} else {
log.Printf("[setup] storage opened at %s", storagePath)
}
s.mu.Lock()
s.cfg = cfg
s.store = store
s.mu.Unlock()
log.Printf("[setup] complete — storage: %s", storagePath)
writeJSON(w, `{"data":{"setup":true}}`)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
ready := s.cfg != nil
s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
if ready {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
} else {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"setup_required"}`))
}
}
// ── SPA static file handler ───────────────────────────────────────────────────
func spaHandler(dir string) http.Handler {
fsys := http.Dir(dir)
fileServer := http.FileServer(fsys)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := fsys.Open(r.URL.Path)
if err == nil {
fi, statErr := f.Stat()
f.Close()
if statErr == nil && !fi.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
}
http.ServeFile(w, r, dir+"/index.html")
})
}
// ── Request logger middleware ─────────────────────────────────────────────────
type statusWriter struct {
http.ResponseWriter
status int
}
func (sw *statusWriter) WriteHeader(status int) {
sw.status = status
sw.ResponseWriter.WriteHeader(status)
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(sw, r)
if r.URL.Path == "/health" && sw.status == 200 {
return
}
log.Printf("[http] %s %s → %d (%s)", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Microsecond))
})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// extractTitle reads the first AsciiDoc heading line (= Title) from content.
// Falls back to the slug if no heading is found.
func extractTitle(content, slug string) string {
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "= ") {
return strings.TrimPrefix(line, "= ")
}
}
// Use the last path segment of the slug as a readable fallback.
parts := strings.Split(slug, "/")
return parts[len(parts)-1]
}
func writeJSON(w http.ResponseWriter, body string) {
_, _ = w.Write([]byte(body))
}
func writeJSONObj(w http.ResponseWriter, v interface{}) {
b, _ := json.Marshal(v)
_, _ = w.Write(b)
}
func writeGQLError(w http.ResponseWriter, msg string) {
body, _ := json.Marshal(map[string]interface{}{
"errors": []map[string]string{{"message": msg}},
})
_, _ = w.Write(body)
}
func gqlOpName(query string) string {
q := strings.TrimSpace(query)
switch {
case strings.HasPrefix(q, "mutation"):
return "mutation " + firstWord(strings.TrimSpace(q[len("mutation"):]))
case strings.HasPrefix(q, "query"):
return "query " + firstWord(strings.TrimSpace(q[len("query"):]))
case strings.HasPrefix(q, "{"):
inner := strings.TrimSpace(q[1:])
return "query {" + firstWord(inner) + "...}"
default:
return firstWord(q)
}
}
func firstWord(s string) string {
s = strings.TrimSpace(s)
for i, c := range s {
if c == ' ' || c == '(' || c == '{' {
return s[:i]
}
}
return s
}
func strVal(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
func dirOf(path string) string {
idx := strings.LastIndexAny(path, "/\\")
if idx < 0 {
return "."
}
return path[:idx]
}