Files
Archivum/backend/internal/graph/server.go
Bjorn Blomberg 5819a6bdce
All checks were successful
build-and-push / build (push) Successful in 1m7s
feat(gitsync): webhook-triggad synk + helt manuellt läge
- POST /api/git-hook: HMAC-SHA256-verifierad (X-Gitea-Signature),
  reagerar bara på pushar till live-grenen, svarar 202 och synkar async
- webhook-hemlighet genereras server-side (headless via config.json),
  roteras med gitRegenerateWebhookSecret
- auto_sync_minutes=0 + webhook av ⇒ ingen automatisk hämtning alls;
  webhook på ⇒ catch-up-synk vid uppstart (missade event)
- admin-UI: webhook-toggle, target-URL + secret med copy/rotate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:52:01 +02:00

2681 lines
70 KiB
Go

package graph
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"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
oidc *auth.OIDCProvider
// Git-sync engine state (see gitsync.go).
syncMu sync.Mutex
syncStop chan struct{}
lastSyncAt string
lastSyncErr string
syncAttention bool
}
func NewServer(cfg *config.Config, configPath string) http.Handler {
s := &Server{cfg: cfg, configPath: configPath, oidc: auth.NewOIDCProvider()}
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("/api/git-hook", s.handleGitWebhook)
mux.HandleFunc("/media/", s.handleMedia)
mux.HandleFunc("/auth/oidc/login", s.handleOIDCLogin)
mux.HandleFunc("/auth/oidc/callback", s.handleOIDCCallback)
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 + built-in role groups exist (handles upgrades).
if err := d.EnsureGuestUser(); err != nil {
log.Printf("[db] warning: could not ensure guest user: %v", err)
}
if err := d.EnsureBuiltinGroups(); err != nil {
log.Printf("[db] warning: could not ensure built-in groups: %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
if s.oidc == nil {
s.oidc = auth.NewOIDCProvider()
}
s.mu.Unlock()
s.configureOIDC(cfg)
s.configureGitSync(cfg)
}
// configureOIDC (re)initialises the OIDC provider from config. Discovery
// requires reaching the IdP, so it runs in the background to avoid blocking
// startup if Authentik is momentarily unavailable.
func (s *Server) configureOIDC(cfg *config.Config) {
if cfg == nil || s.oidc == nil {
return
}
oc := cfg.OIDC
redirect := cfg.ResolvedRedirectURL()
provider := s.oidc
go func() {
if err := provider.Configure(context.Background(), oc, redirect); err != nil {
log.Printf("[oidc] provider not ready: %v", err)
} else if oc.Enabled {
log.Printf("[oidc] provider configured (issuer=%s, redirect=%s)", oc.Issuer, redirect)
}
}()
}
// ── 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"}}`)
}
// Public: what sign-in methods the login page should offer. Must be
// matched before the "login" case since it also contains that substring.
case strings.Contains(q, "loginOptions"):
s.handleLoginOptions(w)
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 {
// ── Git sync (matched first; none of these names collide with the
// generic "config"/"document"/"history" substrings further down) ────────
case strings.Contains(q, "updateGitSync"):
s.handleUpdateGitSync(w, req, sess)
case strings.Contains(q, "gitSyncSettings"):
s.handleGitSyncSettings(w, sess)
case strings.Contains(q, "gitSyncStatus"):
s.handleGitSyncStatus(w, sess)
case strings.Contains(q, "gitBranches"):
s.handleGitBranches(w, sess)
case strings.Contains(q, "gitCreateBranch"):
s.handleGitCreateBranch(w, req, sess)
case strings.Contains(q, "gitSwitchBranch"):
s.handleGitSwitchBranch(w, req, sess)
case strings.Contains(q, "gitPull"):
s.handleGitPull(w, sess)
case strings.Contains(q, "gitPush"):
s.handleGitPush(w, sess)
case strings.Contains(q, "gitResolveMerge"):
s.handleGitResolveMerge(w, req, sess)
case strings.Contains(q, "gitAbortMerge"):
s.handleGitAbortMerge(w, sess)
case strings.Contains(q, "gitResetFromRemote"):
s.handleGitResetFromRemote(w, sess)
case strings.Contains(q, "gitRegenerateWebhookSecret"):
s.handleGitRegenerateWebhookSecret(w, sess)
case strings.Contains(q, "gitRegenerateKey"):
s.handleGitRegenerateKey(w, sess)
// ── Effective-access queries (matched before generic acl/subject cases) ────
case strings.Contains(q, "myAccess"):
s.handleMyAccess(w, req, sess)
case strings.Contains(q, "subjectAccess"):
s.handleSubjectAccess(w, req, sess)
// ── Admin: OIDC / groups / membership / login gate (matched first) ─────────
case strings.Contains(q, "updateOidcConfig"):
s.handleUpdateOidcConfig(w, req, sess)
case strings.Contains(q, "oidcConfig"):
s.handleOidcConfig(w, sess)
case strings.Contains(q, "createGroup"):
s.handleCreateGroup(w, req, sess)
case strings.Contains(q, "deleteGroup"):
s.handleDeleteGroup(w, req, sess)
case strings.Contains(q, "addUserToGroup"):
s.handleAddUserToGroup(w, req, sess)
case strings.Contains(q, "removeUserFromGroup"):
s.handleRemoveUserFromGroup(w, req, sess)
case strings.Contains(q, "setUserLogin"):
s.handleSetUserLogin(w, req, sess)
case strings.Contains(q, "setGroupLogin"):
s.handleSetGroupLogin(w, req, sess)
case strings.Contains(q, "setUserRole"):
s.handleSetUserRole(w, req, sess)
case strings.Contains(q, "userGroups"):
s.handleUserGroups(w, req, sess)
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, "aclSubjects"):
s.handleAclSubjects(w, sess)
case strings.Contains(q, "users"):
s.handleUsers(w, sess)
case strings.Contains(q, "currentUserRole"):
s.handleCurrentUserRole(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, "subjectAcl"):
s.handleSubjectAcl(w, req, 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
}
// allPerms is the permission set granted to administrators (who bypass ACLs).
var allPerms = db.Perms{Search: true, View: true, Read: true, Edit: true, Create: true, Delete: true, Move: true}
// access resolves the effective permissions the session has on a path.
// Administrators always get the full set; everyone else (users, readers,
// guest/public) is resolved through the allow/deny ACL model, which defaults
// to deny. A nil session gets nothing.
func (s *Server) access(sess *auth.Session, path string) db.Perms {
if sess == nil {
return db.Perms{}
}
if sess.Role == "admin" {
return allPerms
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if database == nil {
return db.Perms{}
}
return database.EffectiveAccess(sess.Username, sess.Groups, path)
}
// parentDir returns the folder containing a slug, or "" at the root.
func parentDir(slug string) string {
slug = strings.Trim(slug, "/")
if idx := strings.LastIndex(slug, "/"); idx >= 0 {
return slug[:idx]
}
return ""
}
// ── 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)
// Everyone except admin is gated by the allow/deny ACL model.
if !s.access(sess, slug).Read {
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))
docs := make([]map[string]string, 0, len(slugs))
for _, slug := range slugs {
// Non-admins only see documents they can view, read or search for.
if sess.Role != "admin" {
p := s.access(sess, slug)
if !(p.Read || p.View || p.Search) {
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
}
// Saving requires edit (existing docs) or create (new docs) permission.
if p := s.access(sess, slug); !(p.Edit || p.Create) {
log.Printf("[acl] save denied for %q on %q", sess.Username, slug)
writeGQLError(w, "UNAUTHORIZED")
return
}
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
}
if !s.access(sess, slug).Delete {
writeGQLError(w, "UNAUTHORIZED")
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]interface{}, 0, len(users))
for _, u := range users {
list = append(list, map[string]interface{}{
"username": u.Username,
"role": u.Role,
"isLdap": u.IsLDAP,
"allowLogin": u.AllowLogin,
"createdAt": u.CreatedAt.Format(time.RFC3339),
})
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{"users": list},
})
}
func (s *Server) handleSubjectAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
subjectType := strVal(req.Variables, "subjectType")
subjectIDf, ok := req.Variables["subjectId"].(float64)
if !ok || subjectType == "" {
writeGQLError(w, "Missing subjectType or subjectId")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
acls, err := database.GetACLsForSubject(subjectType, int64(subjectIDf))
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,
"effect": a.Effect,
"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{}{"subjectAcl": aclData},
})
}
func (s *Server) handleCurrentUserRole(w http.ResponseWriter, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"currentUserRole": sess.Role,
},
})
}
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,
"role": u.Role,
"allowLogin": u.AllowLogin,
})
}
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,
"allowLogin": g.AllowLogin,
})
}
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)
}
// Ensure the Archivum-admin / Archivum-reader role groups exist.
if err := database.EnsureBuiltinGroups(); err != nil {
log.Printf("[setup] warning: could not create built-in groups: %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
if s.oidc == nil {
s.oidc = auth.NewOIDCProvider()
}
s.mu.Unlock()
s.configureOIDC(cfg)
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.CreateOrUpdateExternalUser(name, "user", true)
} 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")
entry.Effect = strVal(input, "effect")
if entry.Effect != "deny" {
entry.Effect = "allow"
}
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,
"effect": a.Effect,
"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
}
if !s.access(sess, slug).Read {
writeGQLError(w, "UNAUTHORIZED")
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)
if !s.access(sess, slug).Read {
writeGQLError(w, "UNAUTHORIZED")
return
}
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)
if !s.access(sess, slug).Read {
writeGQLError(w, "UNAUTHORIZED")
return
}
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
}
if p := s.access(sess, path); !(p.Create || p.Edit) {
writeGQLError(w, "UNAUTHORIZED")
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
}
// Moving requires move permission on the source and write on the destination.
if !s.access(sess, oldSlug).Move {
writeGQLError(w, "UNAUTHORIZED")
return
}
if dst := s.access(sess, newSlug); !(dst.Edit || dst.Create) {
writeGQLError(w, "UNAUTHORIZED")
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
}
if p := s.access(sess, slug); !(p.Edit || p.Create) {
http.Error(w, "Forbidden", http.StatusForbidden)
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
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
}
// Non-admins only see folders they may view/read/search into.
if sess.Role != "admin" {
visible := folders[:0]
for _, f := range folders {
p := s.access(sess, f)
if p.View || p.Read || p.Search {
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
}
if !s.access(sess, slug).Read {
writeGQLError(w, "UNAUTHORIZED")
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
}
if !s.access(sess, slug).Edit {
writeGQLError(w, "UNAUTHORIZED")
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}}`)
}
// ── OIDC HTTP endpoints ─────────────────────────────────────────────────────────
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
provider := s.oidc
s.mu.RUnlock()
if provider == nil || !provider.Enabled() {
http.Error(w, "OIDC sign-in is not configured", http.StatusServiceUnavailable)
return
}
authURL, err := provider.AuthURL()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
provider := s.oidc
database := s.database
mgr := s.authMgr
s.mu.RUnlock()
if provider == nil || database == nil || mgr == nil {
s.redirectLoginError(w, r, "server not ready")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
desc := q.Get("error_description")
if desc == "" {
desc = e
}
s.redirectLoginError(w, r, desc)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
oidcUser, err := provider.Exchange(ctx, q.Get("state"), q.Get("code"))
if err != nil {
log.Printf("[oidc] token exchange failed: %v", err)
s.redirectLoginError(w, r, "sign-in failed")
return
}
token, role, err := mgr.LoginOIDC(database, oidcUser)
if err != nil {
log.Printf("[oidc] login denied for %q: %v", oidcUser.Username, err)
s.redirectLoginError(w, r, err.Error())
return
}
log.Printf("[oidc] login: %s (%s) groups=%v", oidcUser.Username, role, oidcUser.Groups)
// The SPA reads these from the URL fragment; the fragment is never sent to
// a server, so the token stays on the client (mirrors the localStorage model).
frag := fmt.Sprintf("#token=%s&username=%s&role=%s",
url.QueryEscape(token), url.QueryEscape(oidcUser.Username), url.QueryEscape(role))
http.Redirect(w, r, "/oidc/callback"+frag, http.StatusFound)
}
func (s *Server) redirectLoginError(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/oidc/callback#error="+url.QueryEscape(msg), http.StatusFound)
}
// ── Login options (public) ───────────────────────────────────────────────────────
func (s *Server) handleLoginOptions(w http.ResponseWriter) {
s.mu.RLock()
provider := s.oidc
database := s.database
s.mu.RUnlock()
oidcEnabled := provider != nil && provider.Enabled()
label := "Sign in with Authentik"
publicEnabled := false
if database != nil {
if _, err := database.GetUser("guest"); err == nil {
publicEnabled = true
}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"loginOptions": map[string]interface{}{
"localEnabled": true,
"oidcEnabled": oidcEnabled,
"oidcButtonLabel": label,
"publicEnabled": publicEnabled,
},
},
})
}
// ── Admin helpers ─────────────────────────────────────────────────────────────
// requireAdmin writes an UNAUTHORIZED error and returns false if the session
// is missing or not an administrator.
func (s *Server) requireAdmin(w http.ResponseWriter, sess *auth.Session) bool {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return false
}
return true
}
func (s *Server) db() *db.DB {
s.mu.RLock()
defer s.mu.RUnlock()
return s.database
}
// ── OIDC config (admin) ────────────────────────────────────────────────────────
func (s *Server) handleOidcConfig(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
s.mu.RLock()
cfg := s.cfg
provider := s.oidc
s.mu.RUnlock()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
oc := cfg.OIDC
oc.Normalize()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"oidcConfig": map[string]interface{}{
"enabled": oc.Enabled,
"ready": provider != nil && provider.Enabled(),
"issuer": oc.Issuer,
"clientId": oc.ClientID,
"clientSecretSet": oc.ClientSecret != "",
"redirectUrl": cfg.ResolvedRedirectURL(),
"publicUrl": cfg.PublicURL,
"groupsClaim": oc.GroupsClaim,
"usernameClaim": oc.UsernameClaim,
"adminGroup": oc.AdminGroup,
"readerGroup": oc.ReaderGroup,
},
},
})
}
func (s *Server) handleUpdateOidcConfig(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
input, ok := req.Variables["input"].(map[string]interface{})
if !ok {
input, _ = req.Variables["i"].(map[string]interface{})
}
if input == nil {
writeGQLError(w, "missing input")
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
newCfg := *cfg
oc := config.OIDCConfig{
Enabled: boolVal(input, "enabled"),
Issuer: strings.TrimSpace(strVal(input, "issuer")),
ClientID: strings.TrimSpace(strVal(input, "clientId")),
ClientSecret: strVal(input, "clientSecret"),
RedirectURL: strings.TrimSpace(strVal(input, "redirectUrl")),
GroupsClaim: strVal(input, "groupsClaim"),
UsernameClaim: strVal(input, "usernameClaim"),
AdminGroup: strVal(input, "adminGroup"),
ReaderGroup: strVal(input, "readerGroup"),
}
// Empty client secret means "keep existing".
if oc.ClientSecret == "" {
oc.ClientSecret = cfg.OIDC.ClientSecret
}
oc.Normalize()
newCfg.OIDC = oc
if pu := strings.TrimSpace(strVal(input, "publicUrl")); pu != "" {
newCfg.PublicURL = pu
}
if err := config.Save(s.configPath, &newCfg); err != nil {
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
s.mu.Lock()
s.cfg = &newCfg
mgr := s.authMgr
s.mu.Unlock()
if mgr != nil {
mgr.UpdateConfig(&newCfg)
}
s.configureOIDC(&newCfg)
log.Printf("[admin] OIDC config updated by %s (enabled=%v)", sess.Username, oc.Enabled)
writeJSON(w, `{"data":{"updateOidcConfig":true}}`)
}
// ── Group management (admin) ─────────────────────────────────────────────────────
func (s *Server) handleCreateGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
name := strings.TrimSpace(strVal(req.Variables, "name"))
if name == "" {
writeGQLError(w, "group name is required")
return
}
if err := s.db().CreateOrUpdateGroup(name, false); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[admin] group created: %s by %s", name, sess.Username)
writeJSON(w, `{"data":{"createGroup":true}}`)
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
name := strVal(req.Variables, "name")
if name == db.GroupAdmin || name == db.GroupReader {
writeGQLError(w, "built-in role groups cannot be deleted")
return
}
if err := s.db().DeleteGroup(name); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[admin] group deleted: %s by %s", name, sess.Username)
writeJSON(w, `{"data":{"deleteGroup":true}}`)
}
func (s *Server) handleAddUserToGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
username := strVal(req.Variables, "username")
group := strVal(req.Variables, "group")
if err := s.db().AddUserToGroup(username, group); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"addUserToGroup":true}}`)
}
func (s *Server) handleRemoveUserFromGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
username := strVal(req.Variables, "username")
group := strVal(req.Variables, "group")
if err := s.db().RemoveUserFromGroup(username, group); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"removeUserFromGroup":true}}`)
}
func (s *Server) handleUserGroups(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
username := strVal(req.Variables, "username")
names, err := s.db().GetUserGroupNames(username)
if err != nil {
writeGQLError(w, err.Error())
return
}
if names == nil {
names = []string{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{"userGroups": names},
})
}
func (s *Server) handleSetUserLogin(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
username := strVal(req.Variables, "username")
allow := boolVal(req.Variables, "allow")
if err := s.db().SetUserLogin(username, allow); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[admin] user %q login set to %v by %s", username, allow, sess.Username)
writeJSON(w, `{"data":{"setUserLogin":true}}`)
}
func (s *Server) handleSetGroupLogin(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
name := strVal(req.Variables, "name")
allow := boolVal(req.Variables, "allow")
if err := s.db().SetGroupLogin(name, allow); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[admin] group %q login set to %v by %s", name, allow, sess.Username)
writeJSON(w, `{"data":{"setGroupLogin":true}}`)
}
func (s *Server) handleSetUserRole(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
username := strVal(req.Variables, "username")
role := strVal(req.Variables, "role")
if role != "admin" && role != "user" {
writeGQLError(w, "role must be 'admin' or 'user'")
return
}
if username == "guest" {
writeGQLError(w, "cannot change the public user's role")
return
}
if username == sess.Username && role != "admin" {
writeGQLError(w, "cannot remove your own admin role")
return
}
if err := s.db().SetUserRole(username, role); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[admin] user %q role set to %s by %s", username, role, sess.Username)
writeJSON(w, `{"data":{"setUserRole":true}}`)
}
func boolVal(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
b, _ := m[key].(bool)
return b
}
func strSlice(m map[string]interface{}, key string) []string {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func permMap(path string, p db.Perms) map[string]interface{} {
return map[string]interface{}{
"path": path,
"canSearch": p.Search,
"canView": p.View,
"canRead": p.Read,
"canEdit": p.Edit,
"canCreate": p.Create,
"canDelete": p.Delete,
"canMove": p.Move,
}
}
// handleMyAccess returns the current session's effective permissions for each
// requested path. Used by the UI to hide actions the user cannot perform.
func (s *Server) handleMyAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
paths := strSlice(req.Variables, "paths")
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
out = append(out, permMap(p, s.access(sess, p)))
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"myAccess": out}})
}
// handleSubjectAccess returns the effective permissions a given subject
// (user or group) has for each requested path — for the admin access tree.
// For a user this includes their group memberships; for a group it is the
// group's own rules. Both walk ancestor folders with deny-wins.
func (s *Server) handleSubjectAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
database := s.db()
if database == nil {
writeGQLError(w, "database not initialised")
return
}
subjectType := strVal(req.Variables, "subjectType")
idF, ok := req.Variables["subjectId"].(float64)
if !ok {
writeGQLError(w, "missing subjectId")
return
}
paths := strSlice(req.Variables, "paths")
var username string
var groups []string
if subjectType == "group" {
groups = []string{database.GroupNameByID(int64(idF))}
} else {
username = database.UsernameByID(int64(idF))
groups, _ = database.GetUserGroupNames(username)
}
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
out = append(out, permMap(p, database.EffectiveAccess(username, groups, p)))
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"subjectAccess": out}})
}