diff --git a/.env.example b/.env.example index 88731ad..0a2479e 100644 --- a/.env.example +++ b/.env.example @@ -16,21 +16,27 @@ REGISTRY=192.168.0.19:5000 IMAGE_TAG=latest -# ── Host paths ───────────────────────────────────────────────────────────────── -# Base directory — all data lives under this path. -# Adjust to wherever you want to store Archivum data on the host. -ARCHIVUM_BASE=/opt/archivum - -# Configuration directory (config.json, settings.json). -# The Setup Wizard will create config.json here on first access. -ARCHIVUM_CONFIG=/opt/archivum/config - -# AsciiDoc wiki files + git repository root. -ARCHIVUM_WIKI=/opt/archivum/wiki - -# SQLite database directory. -# The database file will be created as archivum.db inside this directory. -ARCHIVUM_DB=/opt/archivum/db +# ── Host paths (docker-compose.yml) ─────────────────────────────────────────── +# DOCKER_PATH is the base directory on the HOST where data is stored. +# docker-compose.yml maps: +# DOCKER_PATH/config → /config (config.json written here by Setup Wizard) +# DOCKER_PATH/data → /data (wiki files in data/wiki, db in data/db) +# +# Windows example (Docker Desktop): +# DOCKER_PATH=C:\Repo\AW\Archivum\docker +# +# Linux / WSL example: +# DOCKER_PATH=/opt/archivum +# +# The directory structure on the host will be: +# DOCKER_PATH/ +# ├── config/ +# │ └── config.json ← written by Setup Wizard +# └── data/ +# ├── wiki/ ← put your .adoc files here +# └── db/ +# └── archivum.db ← auto-created +DOCKER_PATH=C:\Repo\AW\Archivum\docker # ── Network ──────────────────────────────────────────────────────────────────── diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 4f7ae2f..81f706c 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -1,6 +1,7 @@ package graph import ( + "bufio" "encoding/json" "fmt" "io" @@ -12,6 +13,7 @@ import ( "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. @@ -19,11 +21,21 @@ 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) @@ -71,6 +83,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { s.mu.RLock() cfg := s.cfg + store := s.store s.mu.RUnlock() // ── Mutations available before setup ───────────────────────────────────── @@ -79,8 +92,8 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { return } - // ── System status ───────────────────────────────────────────────────────── - if strings.Contains(req.Query, "systemStatus") { + // ── 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"}}`) @@ -105,15 +118,84 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { case strings.Contains(req.Query, "login"): writeJSON(w, `{"data":{"login":"stub-token"}}`) - case strings.Contains(req.Query, "documents"): - writeJSON(w, `{"data":{"documents":[]}}`) + case strings.Contains(req.Query, "documents") || strings.Contains(req.Query, "document"): + s.handleDocuments(w, req, store) default: writeJSON(w, `{"data":{"systemStatus":"OK"}}`) } } -// handleSetup parses the setup input, writes config.json, and activates the config. +// 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 { @@ -137,7 +219,6 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { ListenAddr: ":4000", } - // Optional LDAP if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil { port := 389 if p, ok := ldapRaw["port"].(float64); ok { @@ -152,7 +233,6 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { } } - // Ensure the config directory exists before writing. 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)) @@ -167,9 +247,17 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { log.Printf("[setup] config written to %s", s.configPath) - // Activate new config in memory so subsequent requests see OK immediately. + 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) @@ -181,6 +269,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 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"}`)) @@ -197,18 +286,15 @@ func spaHandler(dir string) http.Handler { fileServer := http.FileServer(fsys) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Always try to open the exact path first. f, err := fsys.Open(r.URL.Path) if err == nil { fi, statErr := f.Stat() f.Close() - // Serve directories as SPA root (e.g. GET /) if statErr == nil && !fi.IsDir() { fileServer.ServeHTTP(w, r) return } } - // Path doesn't exist or is a directory → serve the SPA shell. http.ServeFile(w, r, dir+"/index.html") }) } @@ -230,7 +316,6 @@ func requestLogger(next http.Handler) http.Handler { sw := &statusWriter{ResponseWriter: w, status: 200} start := time.Now() next.ServeHTTP(sw, r) - // Skip noisy health-check logs unless they fail. if r.URL.Path == "/health" && sw.status == 200 { return } @@ -240,10 +325,30 @@ func requestLogger(next http.Handler) http.Handler { // ── 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}}, @@ -259,7 +364,6 @@ func gqlOpName(query string) string { case strings.HasPrefix(q, "query"): return "query " + firstWord(strings.TrimSpace(q[len("query"):])) case strings.HasPrefix(q, "{"): - // Shorthand query: extract first field name from { field ... } inner := strings.TrimSpace(q[1:]) return "query {" + firstWord(inner) + "...}" default: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a0ecb8b..5198649 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,29 +7,23 @@ services: container_name: archivum restart: unless-stopped ports: - - "8080:4000" + - "${HOST_PORT:-8080}:4000" environment: - # Points to the config directory inside the container. DOCKER_PATH: /config UI_DIR: /srv/archivum/ui - # Set to match the owner of the host directories (run `id` to check). PUID: ${PUID:-1000} PGID: ${PGID:-1000} TZ: ${TZ:-Europe/Stockholm} volumes: - # Configuration (config.json written here by Setup Wizard). - - archivum-config:/config - # All data: wiki files live in /data/wiki, SQLite db in /data/db. - # Using named volumes for local dev avoids Windows/WSL2 permission issues. - - archivum-data:/data - -# Named volumes let Docker manage ownership automatically. -# Replace with bind mounts if you need direct host access: -# - ./local/config:/config -# - ./local/data:/data -# (and make sure the host directories are owned by PUID:PGID) -volumes: - archivum-config: - archivum-data: + # DOCKER_PATH in .env is the base directory on the HOST. + # Example .env: + # DOCKER_PATH=C:\Repo\AW\Archivum\docker + # Results in: + # C:\Repo\AW\Archivum\docker\config → /config (config.json lives here) + # C:\Repo\AW\Archivum\docker\data → /data (wiki/ and db/ live here) + # + # If DOCKER_PATH is not set, falls back to ./local relative to this file. + - ${DOCKER_PATH:-./local}/config:/config + - ${DOCKER_PATH:-./local}/data:/data