- Introduced BaseDN configuration for LDAP in the config. - Enhanced User model to include IsLDAP field. - Updated database queries to handle LDAP users. - Implemented GraphQL queries for LDAP browsing and user authentication type. - Added ACL management for users and groups, including guest user permissions. - Updated frontend to support LDAP login and guest user login. - Improved error handling and user feedback in login and ACL management.
1951 lines
49 KiB
Go
1951 lines
49 KiB
Go
package graph
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/brasse-b/archivum/internal/auth"
|
|
"github.com/brasse-b/archivum/internal/config"
|
|
"github.com/brasse-b/archivum/internal/db"
|
|
"github.com/brasse-b/archivum/internal/git"
|
|
"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
|
|
database *db.DB
|
|
authMgr *auth.Manager
|
|
gitRepo *git.Repo
|
|
}
|
|
|
|
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
|
s := &Server{cfg: cfg, configPath: configPath}
|
|
|
|
if cfg != nil {
|
|
s.initRuntime(cfg)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/graphql", s.handleGraphQL)
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
mux.HandleFunc("/api/upload", s.handleUpload)
|
|
mux.HandleFunc("/media/", s.handleMedia)
|
|
|
|
uiDir := os.Getenv("UI_DIR")
|
|
if uiDir == "" {
|
|
uiDir = "/srv/archivum/ui"
|
|
}
|
|
log.Printf("[server] UI from %s", uiDir)
|
|
mux.Handle("/", spaHandler(uiDir))
|
|
|
|
return requestLogger(mux)
|
|
}
|
|
|
|
// initRuntime opens storage, DB, auth manager and git repo from a valid config.
|
|
func (s *Server) initRuntime(cfg *config.Config) {
|
|
var st *storage.Store
|
|
if store, err := storage.New(cfg.StoragePath); err != nil {
|
|
log.Printf("[storage] failed to open at %s: %v", cfg.StoragePath, err)
|
|
} else {
|
|
st = store
|
|
log.Printf("[storage] opened at %s", cfg.StoragePath)
|
|
}
|
|
|
|
var d *db.DB
|
|
if dir := filepath.Dir(cfg.DBPath); dir != "." && dir != "" {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
log.Printf("[db] failed to create directory %s: %v", dir, err)
|
|
}
|
|
}
|
|
if database, err := db.New(cfg.DBPath); err != nil {
|
|
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
|
|
} else {
|
|
d = database
|
|
// Ensure guest user exists (handles upgrades from older versions).
|
|
if err := d.EnsureGuestUser(); err != nil {
|
|
log.Printf("[db] warning: could not ensure guest user: %v", err)
|
|
}
|
|
log.Printf("[db] opened at %s", cfg.DBPath)
|
|
}
|
|
|
|
am := auth.NewManager(cfg)
|
|
|
|
var gr *git.Repo
|
|
if repo, err := git.Open(cfg.StoragePath); err != nil {
|
|
log.Printf("[git] repo init failed: %v", err)
|
|
} else {
|
|
gr = repo
|
|
log.Printf("[git] repo ready at %s", cfg.StoragePath)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.cfg = cfg
|
|
s.store = st
|
|
s.database = d
|
|
s.authMgr = am
|
|
s.gitRepo = gr
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// ── 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))
|
|
|
|
// Extract session — may be nil for unauthenticated requests.
|
|
sess := s.sessionFromRequest(r)
|
|
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
store := s.store
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
needsSetup := cfg == nil || database == nil || !database.HasUsers()
|
|
|
|
q := req.Query
|
|
|
|
switch {
|
|
// ── Pre-setup / always-available ─────────────────────────────────────────
|
|
case strings.Contains(q, "setup") && !strings.Contains(q, "systemStatus"):
|
|
s.handleSetup(w, req)
|
|
|
|
case strings.Contains(q, "systemStatus"):
|
|
if needsSetup {
|
|
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
|
|
} else {
|
|
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
|
}
|
|
|
|
case strings.Contains(q, "testLdapConnection"):
|
|
s.handleTestLdapConnection(w, req)
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────────
|
|
case strings.Contains(q, "userAuthType"):
|
|
s.handleUserAuthType(w, req)
|
|
|
|
case strings.Contains(q, "login"):
|
|
s.handleLogin(w, req)
|
|
|
|
case strings.Contains(q, "logout"):
|
|
s.handleLogout(w, sess)
|
|
|
|
// ── All others require initialised config ─────────────────────────────────
|
|
default:
|
|
if needsSetup {
|
|
writeGQLError(w, "REQUIRE_SETUP")
|
|
return
|
|
}
|
|
s.dispatchAuthenticated(w, r, req, sess, store)
|
|
}
|
|
}
|
|
|
|
func (s *Server) dispatchAuthenticated(
|
|
w http.ResponseWriter, r *http.Request,
|
|
req gqlRequest, sess *auth.Session,
|
|
store *storage.Store,
|
|
) {
|
|
q := req.Query
|
|
|
|
switch {
|
|
case strings.Contains(q, "saveDocument"):
|
|
s.handleSaveDocument(w, req, sess)
|
|
|
|
case strings.Contains(q, "deleteDocument"):
|
|
s.handleDeleteDocument(w, req, sess)
|
|
|
|
case strings.Contains(q, "deleteImage"):
|
|
s.handleDeleteImage(w, req, sess)
|
|
|
|
case strings.Contains(q, "createUser"):
|
|
s.handleCreateUser(w, req, sess)
|
|
|
|
case strings.Contains(q, "deleteUser"):
|
|
s.handleDeleteUser(w, req, sess)
|
|
|
|
case strings.Contains(q, "updateLdapConfig"):
|
|
s.handleUpdateLdapConfig(w, req, sess)
|
|
|
|
case strings.Contains(q, "changePassword"):
|
|
s.handleChangePassword(w, req, sess)
|
|
|
|
case strings.Contains(q, "ldapBrowse"):
|
|
s.handleLdapBrowse(w, req, sess)
|
|
|
|
case strings.Contains(q, "users"):
|
|
s.handleUsers(w, sess)
|
|
|
|
case strings.Contains(q, "updateStoragePath"):
|
|
s.handleUpdateStoragePath(w, req, sess)
|
|
|
|
case strings.Contains(q, "serverDirectories"):
|
|
s.handleServerDirectories(w, req, sess)
|
|
|
|
case strings.Contains(q, "config"):
|
|
s.handleConfig(w, sess)
|
|
|
|
case strings.Contains(q, "repoStatus"):
|
|
s.handleRepoStatus(w, sess)
|
|
|
|
case strings.Contains(q, "initCommit"):
|
|
s.handleInitCommit(w, req, sess)
|
|
|
|
case strings.Contains(q, "history"):
|
|
s.handleHistory(w, req, sess)
|
|
|
|
case strings.Contains(q, "images"):
|
|
s.handleImages(w, req, sess)
|
|
|
|
case strings.Contains(q, "createFolder"):
|
|
s.handleCreateFolder(w, req, sess)
|
|
|
|
case strings.Contains(q, "moveDocument"):
|
|
s.handleMoveDocument(w, req, sess)
|
|
|
|
case strings.Contains(q, "importLdapSubject"):
|
|
s.handleImportLdapSubject(w, req, sess)
|
|
|
|
case strings.Contains(q, "aclSubjects"):
|
|
s.handleAclSubjects(w, sess)
|
|
|
|
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
|
|
s.handleAcl(w, req, sess)
|
|
|
|
case strings.Contains(q, "setAcl"):
|
|
s.handleSetAcl(w, req, sess)
|
|
|
|
case strings.Contains(q, "removeAcl"):
|
|
s.handleRemoveAcl(w, req, sess)
|
|
|
|
case strings.Contains(q, "folders"):
|
|
s.handleFolders(w, sess)
|
|
|
|
case strings.Contains(q, "diff"):
|
|
s.handleDiff(w, req, sess)
|
|
|
|
case strings.Contains(q, "documentAtCommit"):
|
|
s.handleDocumentAtCommit(w, req, sess)
|
|
|
|
case strings.Contains(q, "documents") || strings.Contains(q, "document"):
|
|
s.handleDocuments(w, req, sess, store)
|
|
|
|
default:
|
|
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
|
|
// Only allow setup state without auth
|
|
if cfg != nil {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
}
|
|
|
|
reqPath, _ := req.Variables["path"].(string)
|
|
if reqPath == "" {
|
|
// Also try "p" just in case the frontend sends p
|
|
if p, ok := req.Variables["p"].(string); ok && p != "" {
|
|
reqPath = p
|
|
}
|
|
}
|
|
if reqPath == "" {
|
|
if runtime.GOOS == "windows" {
|
|
reqPath = "C:\\"
|
|
} else {
|
|
reqPath = "/"
|
|
}
|
|
} else {
|
|
reqPath = filepath.Clean(reqPath)
|
|
}
|
|
|
|
var out []map[string]interface{}
|
|
|
|
// Add parent if not at root
|
|
parent := filepath.Dir(reqPath)
|
|
if parent != reqPath && parent != "." {
|
|
out = append(out, map[string]interface{}{
|
|
"name": "..",
|
|
"path": parent,
|
|
"isGitRepo": false,
|
|
"hasDocuments": false,
|
|
})
|
|
}
|
|
|
|
entries, err := os.ReadDir(reqPath)
|
|
if err != nil {
|
|
log.Printf("[directories] failed to read dir %q: %v", reqPath, err)
|
|
writeGQLError(w, fmt.Sprintf("failed to read directory: %v", err))
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
fullPath := filepath.Join(reqPath, entry.Name())
|
|
isGit := false
|
|
if _, err := os.Stat(filepath.Join(fullPath, ".git")); err == nil {
|
|
isGit = true
|
|
}
|
|
|
|
hasDocs := false
|
|
childEntries, _ := os.ReadDir(fullPath)
|
|
for _, child := range childEntries {
|
|
if !child.IsDir() && (strings.HasSuffix(child.Name(), ".md") || strings.HasSuffix(child.Name(), ".adoc")) {
|
|
hasDocs = true
|
|
break
|
|
}
|
|
}
|
|
|
|
out = append(out, map[string]interface{}{
|
|
"name": entry.Name(),
|
|
"path": fullPath,
|
|
"isGitRepo": isGit,
|
|
"hasDocuments": hasDocs,
|
|
})
|
|
}
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"serverDirectories": out,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Session helpers ───────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) sessionFromRequest(r *http.Request) *auth.Session {
|
|
raw := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(raw, "Bearer ") {
|
|
log.Printf("[auth] No Bearer token found in header")
|
|
return nil
|
|
}
|
|
token := strings.TrimPrefix(raw, "Bearer ")
|
|
|
|
s.mu.RLock()
|
|
mgr := s.authMgr
|
|
s.mu.RUnlock()
|
|
|
|
if mgr == nil {
|
|
log.Printf("[auth] authmgr is nil")
|
|
return nil
|
|
}
|
|
sess, err := mgr.Validate(token)
|
|
if err != nil {
|
|
log.Printf("[auth] Token validation failed: %v", err)
|
|
return nil
|
|
}
|
|
return sess
|
|
}
|
|
|
|
// ── Document handlers ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *auth.Session, store *storage.Store) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
// Single document query: document(slug: ...) or document(
|
|
if strings.Contains(req.Query, "document(") {
|
|
slug, _ := req.Variables["s"].(string)
|
|
if slug == "" {
|
|
slug, _ = req.Variables["slug"].(string)
|
|
}
|
|
if slug == "" {
|
|
writeGQLError(w, "slug is required")
|
|
return
|
|
}
|
|
|
|
log.Printf("[document] user %q is opening document %q", sess.Username, slug)
|
|
|
|
// Guest users may only read documents they have been given access to.
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
if sess.Role == "guest" && database != nil {
|
|
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
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)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"document": map[string]interface{}{
|
|
"slug": slug,
|
|
"content": content,
|
|
"meta": map[string]string{
|
|
"slug": slug,
|
|
"title": title,
|
|
"updatedAt": "",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
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("[document] user %q listed documents (found %d documents)", sess.Username, len(slugs))
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
docs := make([]map[string]string, 0, len(slugs))
|
|
for _, slug := range slugs {
|
|
// Guest users may only see documents they have explicit read or view access to.
|
|
if sess.Role == "guest" && database != nil {
|
|
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
|
|
continue
|
|
}
|
|
}
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSaveDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
input, _ := req.Variables["i"].(map[string]interface{})
|
|
if input == nil {
|
|
input, _ = req.Variables["input"].(map[string]interface{})
|
|
}
|
|
if input == nil {
|
|
writeGQLError(w, "missing input")
|
|
return
|
|
}
|
|
|
|
slug, _ := input["slug"].(string)
|
|
content, _ := input["content"].(string)
|
|
commitMsg, _ := input["commitMessage"].(string)
|
|
|
|
if slug == "" {
|
|
writeGQLError(w, "slug is required")
|
|
return
|
|
}
|
|
if commitMsg == "" {
|
|
commitMsg = "Update " + slug
|
|
}
|
|
|
|
log.Printf("[document] user %q is attempting to save document %q (commit msg: %q)", sess.Username, slug, commitMsg)
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
// Try git commit (writes file + commits).
|
|
if repo != nil {
|
|
log.Printf("[git] user %q is committing changes for %q", sess.Username, slug)
|
|
if err := repo.Commit(slug+".adoc", content, commitMsg, sess.Username, ""); err != nil {
|
|
log.Printf("[git] commit failed for %q: %v — falling back to plain write", slug, err)
|
|
} else {
|
|
log.Printf("[git] successfully committed %q", slug)
|
|
}
|
|
}
|
|
|
|
// Always ensure file is written (handles case where git failed).
|
|
log.Printf("[storage] user %q is writing raw file content for %q", sess.Username, slug)
|
|
if err := store.Write(slug, content); err != nil {
|
|
log.Printf("[storage] failed to save %q: %v", slug, err)
|
|
writeGQLError(w, fmt.Sprintf("failed to save document: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[storage] successfully saved %q by %s", slug, sess.Username)
|
|
title := extractTitle(content, slug)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"saveDocument": map[string]interface{}{
|
|
"slug": slug,
|
|
"content": content,
|
|
"meta": map[string]string{
|
|
"slug": slug,
|
|
"title": title,
|
|
"updatedAt": "",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDeleteDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
slug, _ := req.Variables["slug"].(string)
|
|
if slug == "" {
|
|
writeGQLError(w, "slug is required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
if err := store.Delete(slug); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to delete document: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[storage] deleted %q by %s", slug, sess.Username)
|
|
writeJSON(w, `{"data":{"deleteDocument":true}}`)
|
|
}
|
|
|
|
// ── Auth handlers ─────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
|
|
username, _ := req.Variables["u"].(string)
|
|
password, _ := req.Variables["p"].(string)
|
|
if username == "" {
|
|
username, _ = req.Variables["username"].(string)
|
|
}
|
|
if password == "" {
|
|
password, _ = req.Variables["password"].(string)
|
|
}
|
|
// ldapPassword carries the plaintext password sent by the frontend for LDAP users.
|
|
ldapPassword, _ := req.Variables["ldapPassword"].(string)
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
mgr := s.authMgr
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil || mgr == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
token, role, err := mgr.Login(database, username, password, ldapPassword)
|
|
if err != nil {
|
|
log.Printf("[auth] login failed for %q: %v", username, err)
|
|
writeGQLError(w, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
log.Printf("[auth] login: %s (%s)", username, role)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"login": token,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUserAuthType(w http.ResponseWriter, req gqlRequest) {
|
|
username := strVal(req.Variables, "username")
|
|
if username == "" {
|
|
username, _ = req.Variables["u"].(string)
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
authType := auth.UserAuthType(database, username)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{"userAuthType": authType},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess != nil {
|
|
s.mu.RLock()
|
|
mgr := s.authMgr
|
|
s.mu.RUnlock()
|
|
if mgr != nil {
|
|
mgr.Logout(sess.Token)
|
|
}
|
|
log.Printf("[auth] logout: %s", sess.Username)
|
|
}
|
|
writeJSON(w, `{"data":{"logout":true}}`)
|
|
}
|
|
|
|
// ── Admin handlers ────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "database not initialised")
|
|
return
|
|
}
|
|
|
|
users, err := database.ListUsers()
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to list users: %v", err))
|
|
return
|
|
}
|
|
|
|
list := make([]map[string]string, 0, len(users))
|
|
for _, u := range users {
|
|
list = append(list, map[string]string{
|
|
"username": u.Username,
|
|
"role": u.Role,
|
|
"createdAt": u.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{"users": list},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess == nil || sess.Role != "admin" {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "database not initialised")
|
|
return
|
|
}
|
|
|
|
users, err := database.ListUsers()
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to list users: %v", err))
|
|
return
|
|
}
|
|
groups, err := database.ListGroups()
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to list groups: %v", err))
|
|
return
|
|
}
|
|
|
|
userList := make([]map[string]interface{}, 0, len(users))
|
|
for _, u := range users {
|
|
userList = append(userList, map[string]interface{}{
|
|
"id": u.ID,
|
|
"name": u.Username,
|
|
"isLdap": u.IsLDAP,
|
|
})
|
|
}
|
|
groupList := make([]map[string]interface{}, 0, len(groups))
|
|
for _, g := range groups {
|
|
groupList = append(groupList, map[string]interface{}{
|
|
"id": g.ID,
|
|
"name": g.Name,
|
|
"isLdap": g.IsLDAP,
|
|
})
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"aclSubjects": map[string]interface{}{
|
|
"users": userList,
|
|
"groups": groupList,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
username, _ := req.Variables["u"].(string)
|
|
password, _ := req.Variables["p"].(string)
|
|
role, _ := req.Variables["r"].(string)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
if username == "" || password == "" {
|
|
writeGQLError(w, "username and password are required")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
writeGQLError(w, "failed to hash password")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "database not initialised")
|
|
return
|
|
}
|
|
|
|
if err := database.CreateUser(username, hash, role); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to create user: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[admin] user created: %s (%s) by %s", username, role, sess.Username)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"createUser": map[string]string{
|
|
"username": username,
|
|
"role": role,
|
|
"createdAt": time.Now().Format(time.RFC3339),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
username, _ := req.Variables["username"].(string)
|
|
if username == "" {
|
|
writeGQLError(w, "username is required")
|
|
return
|
|
}
|
|
if username == sess.Username {
|
|
writeGQLError(w, "cannot delete your own account")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "database not initialised")
|
|
return
|
|
}
|
|
|
|
if err := database.DeleteUser(username); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to delete user: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[admin] user deleted: %s by %s", username, sess.Username)
|
|
writeJSON(w, `{"data":{"deleteUser":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
oldPass, _ := req.Variables["old"].(string)
|
|
newPass, _ := req.Variables["new"].(string)
|
|
if oldPass == "" || newPass == "" {
|
|
writeGQLError(w, "oldPassword and newPassword are required")
|
|
return
|
|
}
|
|
if len(newPass) < 8 {
|
|
writeGQLError(w, "new password must be at least 8 characters")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if database == nil {
|
|
writeGQLError(w, "database not initialised")
|
|
return
|
|
}
|
|
|
|
user, err := database.GetUser(sess.Username)
|
|
if err != nil {
|
|
writeGQLError(w, "user not found")
|
|
return
|
|
}
|
|
if err := auth.CheckPassword(user.PassHash, oldPass); err != nil {
|
|
writeGQLError(w, "incorrect current password")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPass)
|
|
if err != nil {
|
|
writeGQLError(w, "failed to hash password")
|
|
return
|
|
}
|
|
if err := database.UpdatePassword(sess.Username, hash); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to update password: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[auth] password changed for %s", sess.Username)
|
|
writeJSON(w, `{"data":{"changePassword":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
mgr := s.authMgr
|
|
s.mu.RUnlock()
|
|
|
|
if cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
input, hasInput := req.Variables["input"].(map[string]interface{})
|
|
if !hasInput {
|
|
input, hasInput = req.Variables["i"].(map[string]interface{})
|
|
}
|
|
|
|
newCfg := *cfg // copy
|
|
if hasInput && input != nil {
|
|
newPass := strVal(input, "adminPass")
|
|
if newPass == "" {
|
|
newPass = cfg.LDAP.AdminPass // preserve existing password if none provided
|
|
}
|
|
newCfg.LDAP = config.LDAPConfig{
|
|
Url: strVal(input, "url"),
|
|
BaseDN: strVal(input, "baseDN"),
|
|
AdminUser: strVal(input, "adminUser"),
|
|
AdminPass: newPass,
|
|
}
|
|
} else {
|
|
// Disable LDAP
|
|
newCfg.LDAP = config.LDAPConfig{}
|
|
}
|
|
|
|
if err := config.Save(s.configPath, &newCfg); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
|
return
|
|
}
|
|
|
|
// Update the config in-place without reinitialising the runtime.
|
|
// This preserves all active sessions while applying the new LDAP settings.
|
|
s.mu.Lock()
|
|
s.cfg = &newCfg
|
|
s.mu.Unlock()
|
|
if mgr != nil {
|
|
mgr.UpdateConfig(&newCfg)
|
|
}
|
|
|
|
log.Printf("[admin] LDAP config updated by %s", sess.Username)
|
|
writeJSON(w, `{"data":{"updateLdapConfig":true}}`)
|
|
}
|
|
|
|
// ── Setup handler ─────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
|
|
if cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
var ldapCfg map[string]interface{}
|
|
if cfg.LDAP.Url != "" {
|
|
ldapCfg = map[string]interface{}{
|
|
"url": cfg.LDAP.Url,
|
|
"baseDN": cfg.LDAP.BaseDN,
|
|
"adminUser": cfg.LDAP.AdminUser,
|
|
}
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"config": map[string]interface{}{
|
|
"storagePath": cfg.StoragePath,
|
|
"ldap": ldapCfg,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRepoStatus(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
hasUncommitted := repo != nil && repo.HasUncommitted()
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"repoStatus": map[string]interface{}{
|
|
"hasUncommitted": hasUncommitted,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
message, _ := req.Variables["message"].(string)
|
|
if message == "" {
|
|
message = "Initial commit"
|
|
}
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if repo == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
if !repo.HasUncommitted() {
|
|
// Nothing to commit — return success without error.
|
|
writeJSON(w, `{"data":{"initCommit":true}}`)
|
|
return
|
|
}
|
|
|
|
email := sess.Username + "@archivum"
|
|
if err := repo.CommitAll(message, sess.Username, email); err != nil {
|
|
log.Printf("[git] initCommit failed for %s: %v", sess.Username, err)
|
|
writeGQLError(w, fmt.Sprintf("commit failed: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[git] initCommit by %s: %q", sess.Username, message)
|
|
writeJSON(w, `{"data":{"initCommit":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
log.Printf("[unauth] session is nil")
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
if sess.Role != "admin" {
|
|
log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role)
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
cfg := s.cfg
|
|
s.mu.RUnlock()
|
|
|
|
if cfg == nil {
|
|
writeGQLError(w, "server not initialised")
|
|
return
|
|
}
|
|
|
|
path, ok := req.Variables["path"].(string)
|
|
if !ok || path == "" {
|
|
writeGQLError(w, "invalid or empty path")
|
|
return
|
|
}
|
|
|
|
newCfg := *cfg
|
|
newCfg.StoragePath = path
|
|
|
|
if err := config.Save(s.configPath, &newCfg); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
|
return
|
|
}
|
|
|
|
s.initRuntime(&newCfg)
|
|
log.Printf("[admin] Storage path updated by %q to %q", sess.Username, path)
|
|
writeJSON(w, `{"data":{"updateStoragePath":true}}`)
|
|
}
|
|
|
|
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)
|
|
adminUser, _ := input["adminUser"].(string)
|
|
adminPass, _ := input["adminPass"].(string)
|
|
|
|
if storagePath == "" || jwtSecret == "" || adminUser == "" || adminPass == "" {
|
|
writeGQLError(w, "storagePath, jwtSecret, adminUser and adminPass are required")
|
|
return
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
StoragePath: storagePath,
|
|
DBPath: resolveDBPath(s.configPath),
|
|
JWTSecret: jwtSecret,
|
|
ListenAddr: ":4000",
|
|
}
|
|
|
|
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
|
|
cfg.LDAP = config.LDAPConfig{
|
|
Url: strVal(ldapRaw, "url"),
|
|
AdminUser: strVal(ldapRaw, "adminUser"),
|
|
AdminPass: strVal(ldapRaw, "adminPass"),
|
|
}
|
|
}
|
|
|
|
// Open database and create admin user BEFORE saving config,
|
|
// so a failed DB creation does not leave a broken config.json on disk.
|
|
if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err))
|
|
return
|
|
}
|
|
|
|
database, err := db.New(cfg.DBPath)
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to open database: %v", err))
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(adminPass)
|
|
if err != nil {
|
|
writeGQLError(w, "failed to hash admin password")
|
|
return
|
|
}
|
|
|
|
if err := database.CreateUser(adminUser, hash, "admin"); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to create admin user: %v", err))
|
|
return
|
|
}
|
|
|
|
// Ensure the built-in guest account exists (no password, role="guest").
|
|
if err := database.EnsureGuestUser(); err != nil {
|
|
log.Printf("[setup] warning: could not create guest user: %v", err)
|
|
}
|
|
|
|
log.Printf("[setup] admin user %q created", adminUser)
|
|
|
|
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
|
|
return
|
|
}
|
|
|
|
if err := config.Save(s.configPath, cfg); err != nil {
|
|
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)
|
|
}
|
|
|
|
repo, err := git.Open(storagePath)
|
|
if err != nil {
|
|
log.Printf("[setup] git init failed: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.cfg = cfg
|
|
s.store = store
|
|
s.database = database
|
|
s.authMgr = auth.NewManager(cfg)
|
|
s.gitRepo = repo
|
|
s.mu.Unlock()
|
|
|
|
log.Printf("[setup] complete")
|
|
writeJSON(w, `{"data":{"setup":true}}`)
|
|
}
|
|
|
|
// ── LDAP test handler ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) {
|
|
url_ := strVal(req.Variables, "url")
|
|
adminUser := strVal(req.Variables, "adminUser")
|
|
adminPass := strVal(req.Variables, "adminPass")
|
|
|
|
if url_ == "" {
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"testLdapConnection": map[string]interface{}{
|
|
"success": false, "message": "url is required",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
err := auth.TestLDAP(url_, adminUser, adminPass)
|
|
if err != nil {
|
|
log.Printf("[ldap] test failed (%s): %v", url_, err)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"testLdapConnection": map[string]interface{}{
|
|
"success": false, "message": err.Error(),
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Printf("[ldap] test succeeded (%s)", url_)
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"testLdapConnection": map[string]interface{}{
|
|
"success": true, "message": "Connection successful",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Health handler ────────────────────────────────────────────────────────────
|
|
|
|
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 ───────────────────────────────────────────────────────────────────
|
|
|
|
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, "= ")
|
|
}
|
|
}
|
|
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 {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
v, _ := m[key].(string)
|
|
return v
|
|
}
|
|
|
|
// ── LDAP & ACL Additions ──────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil || sess.Role != "admin" {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
url_ := strVal(req.Variables, "url")
|
|
adminUser := strVal(req.Variables, "adminUser")
|
|
adminPass := strVal(req.Variables, "adminPass")
|
|
baseDN := strVal(req.Variables, "baseDN")
|
|
|
|
if url_ == "" {
|
|
s.mu.RLock()
|
|
if s.cfg != nil && s.cfg.LDAP.Url != "" {
|
|
url_ = s.cfg.LDAP.Url
|
|
baseDN = s.cfg.LDAP.BaseDN
|
|
adminUser = s.cfg.LDAP.AdminUser
|
|
adminPass = s.cfg.LDAP.AdminPass
|
|
}
|
|
s.mu.RUnlock()
|
|
}
|
|
|
|
users, groups, err := auth.BrowseLDAP(url_, baseDN, adminUser, adminPass)
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
|
|
return
|
|
}
|
|
if users == nil {
|
|
users = []string{}
|
|
}
|
|
if groups == nil {
|
|
groups = []string{}
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"ldapBrowse": map[string]interface{}{
|
|
"users": users,
|
|
"groups": groups,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImportLdapSubject(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil || sess.Role != "admin" {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
typ := strVal(req.Variables, "type")
|
|
name := strVal(req.Variables, "name")
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
var err error
|
|
if typ == "user" {
|
|
err = database.CreateOrUpdateLDAPUser(name)
|
|
} else if typ == "group" {
|
|
err = database.CreateOrUpdateGroup(name, true)
|
|
} else {
|
|
writeGQLError(w, "Invalid subject type")
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, `{"data":{"importLdapSubject":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleSetAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil || sess.Role != "admin" {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
input, _ := req.Variables["input"].(map[string]interface{})
|
|
if input == nil {
|
|
writeGQLError(w, "Missing input")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
var entry db.ACLEntry
|
|
entry.Path = strVal(input, "path")
|
|
entry.SubjectType = strVal(input, "subjectType")
|
|
if idF, ok := input["subjectId"].(float64); ok {
|
|
entry.SubjectID = int64(idF)
|
|
} else {
|
|
writeGQLError(w, "Missing subjectId")
|
|
return
|
|
}
|
|
|
|
entry.CanSearch, _ = input["canSearch"].(bool)
|
|
entry.CanView, _ = input["canView"].(bool)
|
|
entry.CanRead, _ = input["canRead"].(bool)
|
|
entry.CanEdit, _ = input["canEdit"].(bool)
|
|
entry.CanCreate, _ = input["canCreate"].(bool)
|
|
entry.CanDelete, _ = input["canDelete"].(bool)
|
|
entry.CanMove, _ = input["canMove"].(bool)
|
|
|
|
if err := database.SetACL(entry); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, `{"data":{"setAcl":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleRemoveAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil || sess.Role != "admin" {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
idF, ok := req.Variables["id"].(float64)
|
|
if !ok {
|
|
writeGQLError(w, "Missing id")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if err := database.RemoveACL(int64(idF)); err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, `{"data":{"removeAcl":true}}`)
|
|
}
|
|
|
|
func (s *Server) handleAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
path := strVal(req.Variables, "path")
|
|
if path == "" {
|
|
writeGQLError(w, "Missing path")
|
|
return
|
|
}
|
|
|
|
// Example: Allow everyone to see ACLs for now, or restrict to admin
|
|
s.mu.RLock()
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
acls, err := database.GetACLsForPath(path)
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
var aclData []map[string]interface{}
|
|
for _, a := range acls {
|
|
aclData = append(aclData, map[string]interface{}{
|
|
"id": a.ID,
|
|
"path": a.Path,
|
|
"subjectType": a.SubjectType,
|
|
"subjectId": a.SubjectID,
|
|
"canSearch": a.CanSearch,
|
|
"canView": a.CanView,
|
|
"canRead": a.CanRead,
|
|
"canEdit": a.CanEdit,
|
|
"canCreate": a.CanCreate,
|
|
"canDelete": a.CanDelete,
|
|
"canMove": a.CanMove,
|
|
})
|
|
}
|
|
|
|
if aclData == nil {
|
|
aclData = []map[string]interface{}{}
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"acl": aclData,
|
|
},
|
|
})
|
|
}
|
|
|
|
func dirOf(path string) string {
|
|
idx := strings.LastIndexAny(path, "/\\")
|
|
if idx < 0 {
|
|
return "."
|
|
}
|
|
return path[:idx]
|
|
}
|
|
|
|
// resolveDBPath returns the best DB path given the config file location.
|
|
// Prefers /data/db/archivum.db (Docker volume) if /data is writable;
|
|
// otherwise places the DB next to the config file.
|
|
func resolveDBPath(configPath string) string {
|
|
const dockerDB = "/data/db/archivum.db"
|
|
if err := os.MkdirAll("/data/db", 0755); err == nil {
|
|
return dockerDB
|
|
}
|
|
return filepath.Join(dirOf(configPath), "archivum.db")
|
|
}
|
|
|
|
func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
slug, _ := req.Variables["slug"].(string)
|
|
if slug == "" {
|
|
writeGQLError(w, "slug required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if repo == nil {
|
|
writeGQLError(w, "git not initialized")
|
|
return
|
|
}
|
|
|
|
history, err := repo.Log(slug + ".adoc")
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
var out []map[string]interface{}
|
|
for _, entry := range history {
|
|
out = append(out, map[string]interface{}{
|
|
"hash": entry.Hash,
|
|
"author": entry.Author,
|
|
"email": entry.Email,
|
|
"date": entry.Date,
|
|
"subject": entry.Subject,
|
|
"added": entry.Added,
|
|
"removed": entry.Removed,
|
|
})
|
|
}
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"history": out}})
|
|
}
|
|
|
|
func (s *Server) handleDiff(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
slug, _ := req.Variables["slug"].(string)
|
|
fromHash, _ := req.Variables["fromHash"].(string)
|
|
toHash, _ := req.Variables["toHash"].(string)
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if repo == nil {
|
|
writeGQLError(w, "git not initialized")
|
|
return
|
|
}
|
|
|
|
diff, err := repo.Diff(slug+".adoc", fromHash, toHash)
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"diff": diff}})
|
|
}
|
|
|
|
func (s *Server) handleDocumentAtCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
slug, _ := req.Variables["slug"].(string)
|
|
hash, _ := req.Variables["hash"].(string)
|
|
|
|
s.mu.RLock()
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if repo == nil {
|
|
writeGQLError(w, "git not initialized")
|
|
return
|
|
}
|
|
|
|
content, err := repo.Show(hash, slug+".adoc")
|
|
if err != nil {
|
|
writeGQLError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"documentAtCommit": content}})
|
|
}
|
|
|
|
func (s *Server) handleCreateFolder(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
path, _ := req.Variables["path"].(string)
|
|
if path == "" {
|
|
writeGQLError(w, "path is required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
if err := store.CreateFolder(path); err != nil {
|
|
log.Printf("[storage] failed to create folder %q: %v", path, err)
|
|
writeGQLError(w, fmt.Sprintf("failed to create folder: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[storage] folder created at %q by %s", path, sess.Username)
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"createFolder": true}})
|
|
}
|
|
|
|
func (s *Server) handleMoveDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
oldSlug, _ := req.Variables["oldSlug"].(string)
|
|
newSlug, _ := req.Variables["newSlug"].(string)
|
|
|
|
if oldSlug == "" {
|
|
writeGQLError(w, "oldSlug is required")
|
|
return
|
|
}
|
|
if newSlug == "" {
|
|
writeGQLError(w, "newSlug is required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
repo := s.gitRepo
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
var err error
|
|
if repo != nil {
|
|
// Attempt to use git mv
|
|
dir := filepath.ToSlash(filepath.Dir(newSlug))
|
|
if dir != "." && dir != "" {
|
|
_ = store.CreateFolder(dir)
|
|
}
|
|
err = repo.Move(oldSlug+".adoc", newSlug+".adoc", "Move "+oldSlug+" to "+newSlug, sess.Username, "")
|
|
if err != nil {
|
|
err = store.MoveDocument(oldSlug, newSlug)
|
|
}
|
|
} else {
|
|
err = store.MoveDocument(oldSlug, newSlug)
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("[storage] failed to move document from %q to %q: %v", oldSlug, newSlug, err)
|
|
writeGQLError(w, fmt.Sprintf("failed to move document: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[storage] document moved from %q to %q by %s", oldSlug, newSlug, sess.Username)
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"moveDocument": true}})
|
|
}
|
|
|
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
am := s.authMgr
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
// Check auth
|
|
authHeader := r.Header.Get("Authorization")
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if token == "" || am == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
sess, errLogin := am.Validate(token)
|
|
if errLogin != nil || sess == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
err := r.ParseMultipartForm(10 << 20) // 10 MB
|
|
if err != nil {
|
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
slug := r.FormValue("slug")
|
|
if slug == "" {
|
|
http.Error(w, "Missing slug", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("image")
|
|
if err != nil {
|
|
http.Error(w, "Missing image", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read image", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := store.SaveImage(slug, header.Filename, data); err != nil {
|
|
http.Error(w, "Failed to save image", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Calculate url
|
|
relPath := strings.TrimPrefix(filepath.ToSlash(filepath.Join(filepath.Dir(slug), header.Filename)), "/")
|
|
url := "/media/" + relPath
|
|
|
|
resp := map[string]interface{}{"url": url, "name": header.Filename, "size": len(data)}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
http.Error(w, "Storage not initialized", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
relPath := strings.TrimPrefix(r.URL.Path, "/media/")
|
|
fullPath, err := store.GetImagePath(relPath)
|
|
if err != nil {
|
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
http.ServeFile(w, r, fullPath)
|
|
}
|
|
func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
database := s.database
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialised")
|
|
return
|
|
}
|
|
|
|
folders, err := store.ListFolders()
|
|
if err != nil {
|
|
log.Printf("[folders] failed to list folders: %v", err)
|
|
writeGQLError(w, fmt.Sprintf("failed to list folders: %v", err))
|
|
return
|
|
}
|
|
|
|
// Guest users may only see folders they have been granted view access to.
|
|
if sess.Role == "guest" && database != nil {
|
|
visible := folders[:0]
|
|
for _, f := range folders {
|
|
if database.CanUserViewPath(sess.Username, f) {
|
|
visible = append(visible, f)
|
|
}
|
|
}
|
|
folders = visible
|
|
}
|
|
|
|
log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders))
|
|
|
|
writeJSONObj(w, map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"folders": folders,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImages(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
slug, _ := req.Variables["slug"].(string)
|
|
if slug == "" {
|
|
writeGQLError(w, "slug is required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialized")
|
|
return
|
|
}
|
|
|
|
images, err := store.ListImages(slug)
|
|
if err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to list images: %v", err))
|
|
return
|
|
}
|
|
|
|
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}})
|
|
}
|
|
|
|
func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
|
if sess == nil {
|
|
writeGQLError(w, "UNAUTHORIZED")
|
|
return
|
|
}
|
|
|
|
slug, _ := req.Variables["slug"].(string)
|
|
filename, _ := req.Variables["filename"].(string)
|
|
|
|
if slug == "" || filename == "" {
|
|
writeGQLError(w, "slug and filename are required")
|
|
return
|
|
}
|
|
|
|
s.mu.RLock()
|
|
store := s.store
|
|
s.mu.RUnlock()
|
|
|
|
if store == nil {
|
|
writeGQLError(w, "storage not initialized")
|
|
return
|
|
}
|
|
|
|
if err := store.DeleteImage(slug, filename); err != nil {
|
|
writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err))
|
|
return
|
|
}
|
|
|
|
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
|
|
writeJSON(w, `{"data":{"deleteImage":true}}`)
|
|
}
|