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>
This commit is contained in:
36
.env.example
36
.env.example
@@ -16,21 +16,27 @@ REGISTRY=192.168.0.19:5000
|
|||||||
IMAGE_TAG=latest
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
|
|
||||||
# ── Host paths ─────────────────────────────────────────────────────────────────
|
# ── Host paths (docker-compose.yml) ───────────────────────────────────────────
|
||||||
# Base directory — all data lives under this path.
|
# DOCKER_PATH is the base directory on the HOST where data is stored.
|
||||||
# Adjust to wherever you want to store Archivum data on the host.
|
# docker-compose.yml maps:
|
||||||
ARCHIVUM_BASE=/opt/archivum
|
# DOCKER_PATH/config → /config (config.json written here by Setup Wizard)
|
||||||
|
# DOCKER_PATH/data → /data (wiki files in data/wiki, db in data/db)
|
||||||
# Configuration directory (config.json, settings.json).
|
#
|
||||||
# The Setup Wizard will create config.json here on first access.
|
# Windows example (Docker Desktop):
|
||||||
ARCHIVUM_CONFIG=/opt/archivum/config
|
# DOCKER_PATH=C:\Repo\AW\Archivum\docker
|
||||||
|
#
|
||||||
# AsciiDoc wiki files + git repository root.
|
# Linux / WSL example:
|
||||||
ARCHIVUM_WIKI=/opt/archivum/wiki
|
# DOCKER_PATH=/opt/archivum
|
||||||
|
#
|
||||||
# SQLite database directory.
|
# The directory structure on the host will be:
|
||||||
# The database file will be created as archivum.db inside this directory.
|
# DOCKER_PATH/
|
||||||
ARCHIVUM_DB=/opt/archivum/db
|
# ├── 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 ────────────────────────────────────────────────────────────────────
|
# ── Network ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package graph
|
package graph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/brasse-b/archivum/internal/config"
|
"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.
|
// Server holds runtime state that can change after the setup wizard completes.
|
||||||
@@ -19,11 +21,21 @@ type Server struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
|
store *storage.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
||||||
s := &Server{cfg: cfg, configPath: configPath}
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("/graphql", s.handleGraphQL)
|
mux.HandleFunc("/graphql", s.handleGraphQL)
|
||||||
mux.HandleFunc("/health", s.handleHealth)
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
@@ -71,6 +83,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
cfg := s.cfg
|
cfg := s.cfg
|
||||||
|
store := s.store
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
// ── Mutations available before setup ─────────────────────────────────────
|
// ── Mutations available before setup ─────────────────────────────────────
|
||||||
@@ -79,8 +92,8 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── System status ─────────────────────────────────────────────────────────
|
// ── System status (always available) ─────────────────────────────────────
|
||||||
if strings.Contains(req.Query, "systemStatus") {
|
if strings.Contains(req.Query, "systemStatus") && !strings.Contains(req.Query, "documents") {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
log.Printf("[graphql] systemStatus → REQUIRE_SETUP")
|
log.Printf("[graphql] systemStatus → REQUIRE_SETUP")
|
||||||
writeJSON(w, `{"data":{"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"):
|
case strings.Contains(req.Query, "login"):
|
||||||
writeJSON(w, `{"data":{"login":"stub-token"}}`)
|
writeJSON(w, `{"data":{"login":"stub-token"}}`)
|
||||||
|
|
||||||
case strings.Contains(req.Query, "documents"):
|
case strings.Contains(req.Query, "documents") || strings.Contains(req.Query, "document"):
|
||||||
writeJSON(w, `{"data":{"documents":[]}}`)
|
s.handleDocuments(w, req, store)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
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) {
|
func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
||||||
input, ok := req.Variables["i"].(map[string]interface{})
|
input, ok := req.Variables["i"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -137,7 +219,6 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
|||||||
ListenAddr: ":4000",
|
ListenAddr: ":4000",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional LDAP
|
|
||||||
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
|
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
|
||||||
port := 389
|
port := 389
|
||||||
if p, ok := ldapRaw["port"].(float64); ok {
|
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 {
|
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
|
||||||
log.Printf("[setup] mkdir failed: %v", err)
|
log.Printf("[setup] mkdir failed: %v", err)
|
||||||
writeGQLError(w, fmt.Sprintf("could not create config directory: %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)
|
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.mu.Lock()
|
||||||
s.cfg = cfg
|
s.cfg = cfg
|
||||||
|
s.store = store
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[setup] complete — storage: %s", storagePath)
|
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
|
ready := s.cfg != nil
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if ready {
|
if ready {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
@@ -197,18 +286,15 @@ func spaHandler(dir string) http.Handler {
|
|||||||
fileServer := http.FileServer(fsys)
|
fileServer := http.FileServer(fsys)
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
f, err := fsys.Open(r.URL.Path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fi, statErr := f.Stat()
|
fi, statErr := f.Stat()
|
||||||
f.Close()
|
f.Close()
|
||||||
// Serve directories as SPA root (e.g. GET /)
|
|
||||||
if statErr == nil && !fi.IsDir() {
|
if statErr == nil && !fi.IsDir() {
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Path doesn't exist or is a directory → serve the SPA shell.
|
|
||||||
http.ServeFile(w, r, dir+"/index.html")
|
http.ServeFile(w, r, dir+"/index.html")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -230,7 +316,6 @@ func requestLogger(next http.Handler) http.Handler {
|
|||||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
next.ServeHTTP(sw, r)
|
next.ServeHTTP(sw, r)
|
||||||
// Skip noisy health-check logs unless they fail.
|
|
||||||
if r.URL.Path == "/health" && sw.status == 200 {
|
if r.URL.Path == "/health" && sw.status == 200 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -240,10 +325,30 @@ func requestLogger(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── 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) {
|
func writeJSON(w http.ResponseWriter, body string) {
|
||||||
_, _ = w.Write([]byte(body))
|
_, _ = 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) {
|
func writeGQLError(w http.ResponseWriter, msg string) {
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"errors": []map[string]string{{"message": msg}},
|
"errors": []map[string]string{{"message": msg}},
|
||||||
@@ -259,7 +364,6 @@ func gqlOpName(query string) string {
|
|||||||
case strings.HasPrefix(q, "query"):
|
case strings.HasPrefix(q, "query"):
|
||||||
return "query " + firstWord(strings.TrimSpace(q[len("query"):]))
|
return "query " + firstWord(strings.TrimSpace(q[len("query"):]))
|
||||||
case strings.HasPrefix(q, "{"):
|
case strings.HasPrefix(q, "{"):
|
||||||
// Shorthand query: extract first field name from { field ... }
|
|
||||||
inner := strings.TrimSpace(q[1:])
|
inner := strings.TrimSpace(q[1:])
|
||||||
return "query {" + firstWord(inner) + "...}"
|
return "query {" + firstWord(inner) + "...}"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -7,29 +7,23 @@ services:
|
|||||||
container_name: archivum
|
container_name: archivum
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:4000"
|
- "${HOST_PORT:-8080}:4000"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Points to the config directory inside the container.
|
|
||||||
DOCKER_PATH: /config
|
DOCKER_PATH: /config
|
||||||
UI_DIR: /srv/archivum/ui
|
UI_DIR: /srv/archivum/ui
|
||||||
# Set to match the owner of the host directories (run `id` to check).
|
|
||||||
PUID: ${PUID:-1000}
|
PUID: ${PUID:-1000}
|
||||||
PGID: ${PGID:-1000}
|
PGID: ${PGID:-1000}
|
||||||
TZ: ${TZ:-Europe/Stockholm}
|
TZ: ${TZ:-Europe/Stockholm}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration (config.json written here by Setup Wizard).
|
# DOCKER_PATH in .env is the base directory on the HOST.
|
||||||
- archivum-config:/config
|
# Example .env:
|
||||||
# All data: wiki files live in /data/wiki, SQLite db in /data/db.
|
# DOCKER_PATH=C:\Repo\AW\Archivum\docker
|
||||||
# Using named volumes for local dev avoids Windows/WSL2 permission issues.
|
# Results in:
|
||||||
- archivum-data:/data
|
# C:\Repo\AW\Archivum\docker\config → /config (config.json lives here)
|
||||||
|
# C:\Repo\AW\Archivum\docker\data → /data (wiki/ and db/ live here)
|
||||||
# Named volumes let Docker manage ownership automatically.
|
#
|
||||||
# Replace with bind mounts if you need direct host access:
|
# If DOCKER_PATH is not set, falls back to ./local relative to this file.
|
||||||
# - ./local/config:/config
|
- ${DOCKER_PATH:-./local}/config:/config
|
||||||
# - ./local/data:/data
|
- ${DOCKER_PATH:-./local}/data:/data
|
||||||
# (and make sure the host directories are owned by PUID:PGID)
|
|
||||||
volumes:
|
|
||||||
archivum-config:
|
|
||||||
archivum-data:
|
|
||||||
|
|||||||
Reference in New Issue
Block a user