9 Commits

Author SHA1 Message Date
5819a6bdce feat(gitsync): webhook-triggad synk + helt manuellt läge
All checks were successful
build-and-push / build (push) Successful in 1m7s
- 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
9854d85237 Merge feat/git-sync: auto-bootstrap från remote
All checks were successful
build-and-push / build (push) Successful in 45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:19:49 +02:00
19cf285d80 feat(gitsync): auto-bootstrap tom wiki från remote live-gren
Ett repo utan commits adopterar origin/<live_branch> automatiskt vid
första bakgrundssynken — färsk instans behöver ingen manuell reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:19:48 +02:00
0798b76c3a Merge feat/git-sync: git-synk med SSH-nyckelstöd
All checks were successful
build-and-push / build (push) Successful in 2m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:11:26 +02:00
e181e8e68e feat(gitsync): synka wiki-repot mot remote över SSH
- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600),
  GIT_SSH_COMMAND med egen known_hosts (accept-new)
- inställningar: remote-URL, live-gren, read-only, auto-synk-intervall
- bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov
- pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt),
  abort, reset från remote med automatisk backup-gren
- headless-konfig via config.json eller updateGitSync-mutationen
- openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:10:34 +02:00
952ff5f951 ci: re-trigger build (previous run interrupted by host overload)
All checks were successful
build-and-push / build (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:39:51 +02:00
9180033543 feat(acl): tree-based access editor + permission-aware UI
Some checks failed
build-and-push / build (push) Failing after 1h0m8s
Admin — Access Control is now a folder/file tree. Pick a user/group, click a
node to set an allow and/or deny rule there; each node shows the subject's
*effective* rights (letters S V R E C D M) and allow/deny "set here" badges,
computed server-side incl. group membership, inherited folder rules and
deny-wins. New queries: myAccess(paths) and subjectAccess(subjectType,subjectId,paths).

Main UI — actions are hidden when the current (non-admin) user lacks the
permission: create folder/document (Sidebar), drag-to-move, and Save/Delete in
the document view. Adds a permission-gated Delete button to the document
toolbar. Admins bypass and see everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:28:16 +02:00
d4cea04941 fix(oidc): don't strip issuer trailing slash (Authentik discovery match)
All checks were successful
build-and-push / build (push) Successful in 9m0s
go-oidc requires the issuer passed to NewProvider to exactly match the issuer
in the discovery document. Authentik returns ".../application/o/<slug>/" with a
trailing slash; we were stripping it, causing "issuer did not match". Pass the
issuer verbatim and fall back to the alternate slash form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:03:15 +02:00
95e0d8fce1 ci: cap build to 2 of 4 cores so the Pi5 stays responsive
The self-hosted runner builds on the Pi5 alongside live services; the
unthrottled build saturated all cores (Go compiler + esbuild) and took the
reverse-proxied sites down. Add BUILD_CPUS build-arg → GOMAXPROCS in both
build stages (caps the Go compiler and Vite's esbuild) and go build -p,
plus nice/ionice on the build. Slower build, responsive host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:35:39 +02:00
18 changed files with 2241 additions and 89 deletions

View File

@@ -7,6 +7,7 @@ name: build-and-push
# (ingen insecure-registry-config behövs där).
#
# Pull:a på Pi5 med localhost:5000/archivum:latest (t.ex. i dockge-stacken).
# rebuild trigger: 2 (previous run was interrupted by a host overload)
on:
push:
branches: [main, master]
@@ -25,8 +26,13 @@ jobs:
uses: actions/checkout@v4
- name: Build & push image
# The runner builds on the Pi5 next to live services. BUILD_CPUS caps the
# CPU-heavy build tools (Go compiler + esbuild) to 2 of 4 cores so the box
# stays responsive; nice/ionice further deprioritise the client-side work.
# Slower build, but nginx/NPM and the other containers keep serving.
run: |
docker build --progress=plain \
nice -n 19 ionice -c 3 docker build --progress=plain \
--build-arg BUILD_CPUS=2 \
-f docker/Dockerfile \
-t localhost:5000/archivum:latest \
-t "localhost:5000/archivum:${GITHUB_SHA::12}" \

View File

@@ -254,6 +254,43 @@ resolveDBPath(configPath):
└─ Annars → <config-katalog>/archivum.db (dev-miljö)
```
### Git-synk mot remote (SSH)
Wiki-repot kan synkas mot ett remote git-repo (t.ex. Gitea) över SSH.
Implementation: `internal/git/sync.go` (remote-operationer + mutex) och
`internal/graph/gitsync.go` (GraphQL-handlers, nyckelhantering, bakgrunds-
ticker). Admin-UI: `frontend/src/components/GitSyncSection.vue` med
`MergeConflictDialog.vue` som popup vid konflikter.
- **SSH-nyckel:** servern genererar själv ett ed25519-nyckelpar vid första
aktivering, lagrat i `<config-dir>/ssh/` (0600). Publika nyckeln visas i
admin-panelen och registreras som deploy key hos git-värden. `GIT_SSH_COMMAND`
pekar på nyckeln + egen `known_hosts` (accept-new) eftersom appen kör utan
hemkatalog. Privata nyckeln exponeras aldrig via API:t.
- **Live-gren** (`live_branch`): grenen som bakgrundssynken håller uppdaterad.
Bakgrundssynk (intervall i `auto_sync_minutes`, 0 = manuell) gör endast
fast-forward-pulls; allt som kräver riktig merge flaggas för admin
(`attention` i `gitSyncStatus`).
- **Read-only** (`read_only`): pull tillåts men aldrig push (spärras i backend).
- **Grenar:** skapa och byt gren i admin-panelen; remote-grenar spåras
automatiskt vid byte.
- **Merge-konflikter:** pull lämnar mergen öppen och UI:t visar en popup där
man väljer lokal/remote per fil eller för allt; alternativt avbryt mergen.
- **Reset från remote:** tvingar lokala grenen att matcha remoten; lokalt
läge sparas alltid först i en backup-gren (`backup/<timestamp>`). Används
också för bootstrap när historikerna är orelaterade (`UNRELATED_HISTORIES`).
- **Webhook** (`webhook_enabled`/`webhook_secret`): git-värden kan trigga
synk direkt vid push via `POST /api/git-hook` i stället för (eller utöver)
intervall-pollning. Anropet verifieras med HMAC-SHA256 över råa bodyn
(Giteas `X-Gitea-Signature`); hemligheten genereras server-side och roteras
med `gitRegenerateWebhookSecret`. Endast pushar till live-grenen triggar;
vid uppstart görs en catch-up-synk (event som missats medan servern var
nere). Med `auto_sync_minutes: 0` **och** webhook av sker ingen automatisk
hämtning alls.
- **Headless-konfiguration:** allt kan sättas via `config.json`-sektionen
`git_sync` + omstart, eller via GraphQL-mutationen `updateGitSync` — inget
webbgui krävs.
### Konfiguration
`config.json` hanteras av `internal/config`:
@@ -263,6 +300,8 @@ resolveDBPath(configPath):
- `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000`
- `public_url` — extern bas-URL (t.ex. `https://archivum.brasse-pc.eu`), används
för att härleda OIDC-redirect-URI
- `git_sync` — se avsnittet ovan: `enabled`, `remote_url`, `live_branch`,
`read_only`, `auto_sync_minutes`, `webhook_enabled`, `webhook_secret`
- `oidc` — valfri OIDC/SSO-konfiguration (Authentik):
- `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url`
- `groups_claim` (default `groups`), `username_claim` (default `preferred_username`)

View File

@@ -64,7 +64,23 @@ func (p *OIDCProvider) Configure(ctx context.Context, cfg config.OIDCConfig, red
return nil
}
provider, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
// go-oidc strictly checks that the issuer we pass matches the issuer field
// in the discovery document. Providers differ on the trailing slash
// (Authentik returns ".../application/o/<slug>/" WITH a slash), so try the
// value verbatim first and then the alternate slash form.
issuer := strings.TrimSpace(cfg.Issuer)
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
var alt string
if strings.HasSuffix(issuer, "/") {
alt = strings.TrimRight(issuer, "/")
} else {
alt = issuer + "/"
}
if provider2, err2 := oidc.NewProvider(ctx, alt); err2 == nil {
provider, err = provider2, nil
}
}
if err != nil {
return err
}

View File

@@ -19,6 +19,43 @@ type Config struct {
// (e.g. https://archivum.brasse-pc.eu). Used to build the OIDC
// redirect URL when one is not set explicitly.
PublicURL string `json:"public_url"`
GitSync GitSyncConfig `json:"git_sync"`
}
// GitSyncConfig configures synchronisation of the wiki git repository
// with a remote (SSH or HTTPS). The SSH keypair is generated by the
// server itself and lives next to config.json so the whole feature can
// be driven headlessly by editing config.json and restarting.
type GitSyncConfig struct {
Enabled bool `json:"enabled"`
RemoteURL string `json:"remote_url"` // e.g. ssh://git@git.brasse-pc.eu:2222/brasse/Archivum-documets.git
// LiveBranch is the branch that counts as the live content of this
// instance: auto-sync pulls/pushes it and resets target it.
LiveBranch string `json:"live_branch"`
// ReadOnly forbids every push to the remote; the instance only
// pulls. Local edits still commit locally.
ReadOnly bool `json:"read_only"`
// AutoSyncMinutes > 0 enables background sync on that interval.
// Background pulls are fast-forward only; anything needing a real
// merge is left for an admin to resolve in the UI. With 0 and
// webhook disabled, nothing is fetched automatically at all.
AutoSyncMinutes int `json:"auto_sync_minutes"`
// WebhookEnabled exposes POST /api/git-hook so the git host can
// trigger a sync on push instead of (or besides) polling.
WebhookEnabled bool `json:"webhook_enabled"`
// WebhookSecret authenticates hook calls (HMAC-SHA256 signature in
// X-Gitea-Signature). Generated by the server when left empty.
WebhookSecret string `json:"webhook_secret"`
}
// Normalize fills in defaults for optional git-sync fields.
func (g *GitSyncConfig) Normalize() {
if g.LiveBranch == "" {
g.LiveBranch = "main"
}
if g.AutoSyncMinutes < 0 {
g.AutoSyncMinutes = 0
}
}
type LDAPConfig struct {
@@ -105,6 +142,7 @@ func Load(path string) (*Config, error) {
}
cfg.OIDC.Normalize()
cfg.GitSync.Normalize()
return &cfg, nil
}

View File

@@ -691,6 +691,20 @@ func (d *DB) EffectiveAccess(username string, groupNames []string, path string)
}
}
// UsernameByID returns the username for a user id, or "" if not found.
func (d *DB) UsernameByID(id int64) string {
var n string
d.sql.QueryRow(`SELECT username FROM users WHERE id=?`, id).Scan(&n)
return n
}
// GroupNameByID returns the group name for a group id, or "" if not found.
func (d *DB) GroupNameByID(id int64) string {
var n string
d.sql.QueryRow(`SELECT name FROM groups WHERE id=?`, id).Scan(&n)
return n
}
func (d *DB) userID(username string) (int64, bool) {
var id int64
err := d.sql.QueryRow(`SELECT id FROM users WHERE username=?`, username).Scan(&id)

View File

@@ -8,11 +8,19 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
)
// Repo wraps a bare directory that is a Git repository.
type Repo struct {
root string
// mu serialises whole operations (a background sync racing a user
// save would otherwise corrupt index/merge state).
mu sync.Mutex
// sshCommand is exported to git as GIT_SSH_COMMAND when set, so
// remotes can be reached with a dedicated key and known_hosts file
// even though the server runs without a home directory.
sshCommand string
}
// Open returns a Repo for an existing directory, initialising Git if needed.
@@ -35,6 +43,8 @@ func (r *Repo) initIfNeeded() error {
// Commit writes data to path and creates a Git commit authored by author.
func (r *Repo) Commit(path, content, message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
full := filepath.Join(r.root, path)
if err := writeFile(full, content); err != nil {
return err
@@ -52,6 +62,8 @@ func (r *Repo) Commit(path, content, message, author, email string) error {
// Log returns the commit history for a file, including added/removed line counts.
func (r *Repo) Log(path string) ([]LogEntry, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path)
if err != nil {
return nil, err
@@ -92,24 +104,32 @@ func (r *Repo) Log(path string) ([]LogEntry, error) {
// Diff returns the unified diff between two commits for a file.
func (r *Repo) Diff(path, fromHash, toHash string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("diff", fromHash, toHash, "--", path)
return out, err
}
// Show returns the file content at a specific commit.
func (r *Repo) Show(hash, path string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
}
// HasUncommitted returns true if the working tree contains untracked or
// modified files that have not yet been committed.
func (r *Repo) HasUncommitted() bool {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("status", "--porcelain")
return err == nil && strings.TrimSpace(out) != ""
}
// CommitAll stages every file in the repo root and creates a commit.
func (r *Repo) CommitAll(message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("add", "-A"); err != nil {
return err
}
@@ -123,6 +143,8 @@ func (r *Repo) CommitAll(message, author, email string) error {
// Move renames a file from oldPath to newPath and creates a Git commit.
func (r *Repo) Move(oldPath, newPath, message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("mv", oldPath, newPath); err != nil {
return err
}
@@ -153,6 +175,10 @@ func writeFile(path, content string) error {
func (r *Repo) run(args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", r.root}, args...)...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if r.sshCommand != "" {
cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND="+r.sshCommand)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

View File

@@ -0,0 +1,298 @@
package git
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ErrMergeConflict is returned by Pull when the merge stopped on
// conflicts. The merge is left in progress so the caller can resolve
// per file (ours/theirs), commit, or abort.
var ErrMergeConflict = errors.New("MERGE_CONFLICT")
// ErrUnrelatedHistories is returned by Pull when local and remote do
// not share any history. The caller should offer a reset from remote.
var ErrUnrelatedHistories = errors.New("UNRELATED_HISTORIES")
// SetSSHKey configures the key and known_hosts file used for all
// remote operations. Host keys are accepted on first contact
// (accept-new) and pinned in knownHosts after that.
func (r *Repo) SetSSHKey(keyPath, knownHostsPath string) {
r.mu.Lock()
defer r.mu.Unlock()
r.sshCommand = fmt.Sprintf(
"ssh -i %q -o UserKnownHostsFile=%q -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes",
keyPath, knownHostsPath,
)
}
// SetRemote points origin at url, creating the remote if needed.
func (r *Repo) SetRemote(url string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("remote", "get-url", "origin"); err != nil {
_, err = r.run("remote", "add", "origin", url)
return err
}
_, err := r.run("remote", "set-url", "origin", url)
return err
}
// Fetch updates all remote-tracking refs.
func (r *Repo) Fetch() error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("fetch", "origin", "--prune")
return err
}
// CurrentBranch returns the checked-out branch name. It works on an
// unborn branch (fresh init) too, where rev-parse would fail.
func (r *Repo) CurrentBranch() (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("symbolic-ref", "--short", "HEAD")
if err != nil {
// Detached HEAD.
out, err = r.run("rev-parse", "--short", "HEAD")
if err != nil {
return "", err
}
}
return strings.TrimSpace(out), nil
}
// Branches lists local branch names plus remote branches that have no
// local counterpart yet (reported as "origin/<name>").
func (r *Repo) Branches() (local []string, remote []string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
local, remote = []string{}, []string{} // non-nil so they serialise as []
out, err := r.run("for-each-ref", "--format=%(refname:short)", "refs/heads")
if err != nil {
return local, remote, err
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l != "" {
local = append(local, l)
}
}
out, err = r.run("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin")
if err != nil {
return local, remote, nil // no remote yet is fine
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
name := strings.TrimPrefix(l, "origin/")
if name == "" || name == "HEAD" || l == "origin" {
continue
}
remote = append(remote, name)
}
return local, remote, nil
}
// CreateBranch creates a branch at the current HEAD and switches to it.
func (r *Repo) CreateBranch(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("checkout", "-b", name)
return err
}
// SwitchBranch checks out an existing branch. A branch that only
// exists on the remote gets a local tracking branch.
func (r *Repo) SwitchBranch(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("rev-parse", "--verify", "refs/heads/"+name); err == nil {
_, err = r.run("checkout", name)
return err
}
if _, err := r.run("rev-parse", "--verify", "refs/remotes/origin/"+name); err == nil {
_, err = r.run("checkout", "-b", name, "--track", "origin/"+name)
return err
}
return fmt.Errorf("branch %q not found locally or on origin", name)
}
// AheadBehind reports how many commits branch is ahead of and behind
// origin/branch. hasUpstream is false when origin/branch is missing.
func (r *Repo) AheadBehind(branch string) (ahead, behind int, hasUpstream bool) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("rev-list", "--left-right", "--count",
branch+"...origin/"+branch)
if err != nil {
return 0, 0, false
}
fmt.Sscanf(strings.TrimSpace(out), "%d\t%d", &ahead, &behind)
return ahead, behind, true
}
// Pull merges origin/branch into the current branch. ffOnly restricts
// the merge to fast-forwards (used by background sync so it never
// creates surprise merges). On conflict the merge is left open and
// ErrMergeConflict is returned.
func (r *Repo) Pull(branch, author, email string, ffOnly bool) error {
r.mu.Lock()
defer r.mu.Unlock()
args := []string{
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"merge", "--no-edit",
}
if ffOnly {
args = append(args, "--ff-only")
}
args = append(args, "origin/"+branch)
_, err := r.run(args...)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "unrelated histories") {
return ErrUnrelatedHistories
}
if files := r.conflictFiles(); len(files) > 0 {
return ErrMergeConflict
}
return err
}
// Push publishes branch to origin and sets it as upstream.
func (r *Repo) Push(branch string) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("push", "-u", "origin", branch)
return err
}
// HasCommits reports whether the repository has any commit (false on
// a freshly initialised, unborn branch).
func (r *Repo) HasCommits() bool {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("rev-parse", "--verify", "HEAD")
return err == nil
}
// RemoteBranchExists reports whether origin/branch is known locally
// (run Fetch first for a fresh answer).
func (r *Repo) RemoteBranchExists(branch string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("rev-parse", "--verify", "refs/remotes/origin/"+branch)
return err == nil
}
// MergeInProgress reports whether a merge has been started but not
// yet committed or aborted.
func (r *Repo) MergeInProgress() bool {
_, err := os.Stat(filepath.Join(r.root, ".git", "MERGE_HEAD"))
return err == nil
}
// ConflictFiles lists paths that are in conflicted state.
func (r *Repo) ConflictFiles() []string {
r.mu.Lock()
defer r.mu.Unlock()
return r.conflictFiles()
}
func (r *Repo) conflictFiles() []string {
files := []string{} // non-nil so it serialises as [] not null
out, err := r.run("diff", "--name-only", "--diff-filter=U")
if err != nil {
return files
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l != "" {
files = append(files, l)
}
}
return files
}
// ResolveConflicts settles conflicted paths by taking "ours" (local)
// or "theirs" (remote). Empty paths means every conflicted file. When
// no conflicts remain the merge is committed.
func (r *Repo) ResolveConflicts(strategy string, paths []string, author, email string) error {
if strategy != "ours" && strategy != "theirs" {
return fmt.Errorf("unknown strategy %q (want ours or theirs)", strategy)
}
r.mu.Lock()
defer r.mu.Unlock()
if len(paths) == 0 {
paths = r.conflictFiles()
}
for _, p := range paths {
if _, err := r.run("checkout", "--"+strategy, "--", p); err != nil {
// Delete/modify conflicts have no blob on one side; taking
// the missing side means removing the file.
if _, rmErr := r.run("rm", "--", p); rmErr != nil {
return err
}
continue
}
if _, err := r.run("add", "--", p); err != nil {
return err
}
}
if len(r.conflictFiles()) > 0 {
return nil // partial resolve; merge stays open
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "--no-edit",
)
return err
}
// AbortMerge throws away an in-progress merge.
func (r *Repo) AbortMerge() error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("merge", "--abort")
return err
}
// ResetToRemote force-matches branch to origin/branch. Any local-only
// state is kept in a backup branch first, so the operation is
// recoverable. Returns the backup branch name ("" when the repo had
// no commits to back up).
func (r *Repo) ResetToRemote(branch, author, email string) (backup string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.MergeInProgress() {
if _, err := r.run("merge", "--abort"); err != nil {
return "", err
}
}
// Snapshot uncommitted edits so the backup branch is complete.
if out, _ := r.run("status", "--porcelain"); strings.TrimSpace(out) != "" {
r.run("add", "-A")
r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", "Snapshot före reset från remote",
)
}
if _, err := r.run("rev-parse", "--verify", "HEAD"); err == nil {
backup = "backup/" + time.Now().Format("20060102-150405")
if _, err := r.run("branch", backup); err != nil {
return "", err
}
}
if _, err := r.run("fetch", "origin", "--prune"); err != nil {
return backup, err
}
if _, err := r.run("checkout", "-B", branch, "origin/"+branch); err != nil {
return backup, err
}
_, err = r.run("branch", "--set-upstream-to", "origin/"+branch, branch)
return backup, err
}

View File

@@ -0,0 +1,758 @@
package graph
import (
"bytes"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"github.com/brasse-b/archivum/internal/auth"
"github.com/brasse-b/archivum/internal/config"
"github.com/brasse-b/archivum/internal/git"
)
// The identity used for commits that the sync engine itself creates
// (merge commits, pre-reset snapshots).
const (
syncAuthor = "Archivum Sync"
syncEmail = "sync@archivum.local"
)
// ── SSH key management ────────────────────────────────────────────────────────
// sshDir returns the directory holding the sync keypair and known_hosts,
// next to config.json so it lives on the persistent /config volume.
func (s *Server) sshDir() string {
return filepath.Join(filepath.Dir(s.configPath), "ssh")
}
// ensureSSHKey generates an ed25519 keypair on first use and returns
// (keyPath, knownHostsPath, publicKey). The private key never leaves
// the server; the public key is what gets registered as a deploy key.
func (s *Server) ensureSSHKey() (string, string, string, error) {
dir := s.sshDir()
keyPath := filepath.Join(dir, "id_ed25519")
pubPath := keyPath + ".pub"
khPath := filepath.Join(dir, "known_hosts")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", "", err
}
if f, err := os.OpenFile(khPath, os.O_CREATE, 0644); err == nil {
f.Close()
}
if pub, err := os.ReadFile(pubPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
return keyPath, khPath, strings.TrimSpace(string(pub)), nil
}
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", "", err
}
block, err := ssh.MarshalPrivateKey(priv, "archivum-git-sync")
if err != nil {
return "", "", "", err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil {
return "", "", "", err
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
return "", "", "", err
}
pubLine := bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub))
pubLine = append(pubLine, []byte(" archivum-git-sync\n")...)
if err := os.WriteFile(pubPath, pubLine, 0644); err != nil {
return "", "", "", err
}
log.Printf("[gitsync] generated new ssh keypair at %s", keyPath)
return keyPath, khPath, strings.TrimSpace(string(pubLine)), nil
}
// ── Engine wiring ─────────────────────────────────────────────────────────────
// configureGitSync (re)applies git-sync settings: key, remote and the
// background ticker. Called from initRuntime and after updateGitSync.
func (s *Server) configureGitSync(cfg *config.Config) {
s.syncMu.Lock()
if s.syncStop != nil {
close(s.syncStop)
s.syncStop = nil
}
s.syncMu.Unlock()
if cfg == nil {
return
}
gs := cfg.GitSync
gs.Normalize()
s.mu.RLock()
repo := s.gitRepo
s.mu.RUnlock()
if repo == nil || !gs.Enabled || gs.RemoteURL == "" {
return
}
// Headless path: a config.json with webhook_enabled but no secret
// gets one generated and persisted here at startup.
if gs.WebhookEnabled && gs.WebhookSecret == "" {
if secret, err := newWebhookSecret(); err == nil {
s.mu.Lock()
s.cfg.GitSync.WebhookSecret = secret
cfgCopy := *s.cfg
s.mu.Unlock()
if err := config.Save(s.configPath, &cfgCopy); err != nil {
log.Printf("[gitsync] could not persist webhook secret: %v", err)
} else {
log.Printf("[gitsync] generated webhook secret")
}
}
}
keyPath, khPath, _, err := s.ensureSSHKey()
if err != nil {
log.Printf("[gitsync] ssh key setup failed: %v", err)
return
}
repo.SetSSHKey(keyPath, khPath)
if err := repo.SetRemote(gs.RemoteURL); err != nil {
log.Printf("[gitsync] setting remote failed: %v", err)
return
}
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
// Fully manual mode: neither interval nor webhook is on, so nothing
// fetches automatically — not even at startup.
if gs.AutoSyncMinutes <= 0 && !gs.WebhookEnabled {
return
}
stop := make(chan struct{})
s.syncMu.Lock()
s.syncStop = stop
s.syncMu.Unlock()
go func() {
// One eager sync shortly after (re)configuration: catches up on
// webhook events missed while the server was down, and seeds
// status. Then the configured interval, when one is set.
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case <-stop:
return
case <-timer.C:
s.backgroundSync()
}
if gs.AutoSyncMinutes <= 0 {
return
}
ticker := time.NewTicker(time.Duration(gs.AutoSyncMinutes) * time.Minute)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
s.backgroundSync()
}
}
}()
}
// ── Webhook (push-triggered sync) ─────────────────────────────────────────────
// handleGitWebhook is the plain-HTTP endpoint the git host calls on
// push. It is unauthenticated but requires a valid HMAC-SHA256
// signature over the raw body (Gitea's X-Gitea-Signature header).
func (s *Server) handleGitWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil || !cfg.GitSync.Enabled || !cfg.GitSync.WebhookEnabled ||
cfg.GitSync.WebhookSecret == "" {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(cfg.GitSync.WebhookSecret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
got := r.Header.Get("X-Gitea-Signature")
if got == "" || !hmac.Equal([]byte(want), []byte(got)) {
log.Printf("[gitsync] webhook rejected: bad signature from %s", r.RemoteAddr)
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
// Only pushes to the live branch are interesting; everything else
// is acknowledged and ignored.
var payload struct {
Ref string `json:"ref"`
}
_ = json.Unmarshal(body, &payload)
gs := cfg.GitSync
gs.Normalize()
if payload.Ref != "" && payload.Ref != "refs/heads/"+gs.LiveBranch {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ignored","ref":%q}`, payload.Ref)
return
}
log.Printf("[gitsync] webhook push event (%s) — scheduling sync", payload.Ref)
go s.backgroundSync()
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"sync scheduled"}`))
}
// newWebhookSecret returns a fresh random hex secret.
func newWebhookSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// backgroundSync fetches and, when the live branch is checked out,
// fast-forwards it and pushes local commits. It never merges: anything
// that needs a real merge raises the attention flag for an admin.
func (s *Server) backgroundSync() {
s.mu.RLock()
repo := s.gitRepo
cfg := s.cfg
s.mu.RUnlock()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
return
}
gs := cfg.GitSync
record := func(err error, attention bool) {
s.syncMu.Lock()
s.lastSyncAt = time.Now().Format(time.RFC3339)
if err != nil {
s.lastSyncErr = err.Error()
} else {
s.lastSyncErr = ""
}
s.syncAttention = attention
s.syncMu.Unlock()
}
if repo.MergeInProgress() {
record(errors.New("merge väntar på att lösas i admin-panelen"), true)
return
}
if err := repo.Fetch(); err != nil {
record(fmt.Errorf("fetch: %w", err), false)
return
}
// Bootstrap: an empty wiki (no commits yet) adopts the remote live
// branch automatically, so a fresh instance needs no manual reset.
if !repo.HasCommits() {
if repo.RemoteBranchExists(gs.LiveBranch) {
_, err := repo.ResetToRemote(gs.LiveBranch, syncAuthor, syncEmail)
if err != nil {
record(fmt.Errorf("bootstrap från remote: %w", err), true)
} else {
log.Printf("[gitsync] empty wiki bootstrapped from origin/%s", gs.LiveBranch)
record(nil, false)
}
return
}
}
current, err := repo.CurrentBranch()
if err != nil || current != gs.LiveBranch {
// Someone is working on another branch; leave it alone.
record(nil, false)
return
}
ahead, behind, hasUpstream := repo.AheadBehind(gs.LiveBranch)
if !hasUpstream {
if gs.ReadOnly {
record(nil, false)
return
}
record(repo.Push(gs.LiveBranch), false)
return
}
if behind > 0 {
if err := repo.Pull(gs.LiveBranch, syncAuthor, syncEmail, true); err != nil {
// Not fast-forwardable (or unrelated histories): an admin
// must pull/merge or reset from the UI.
record(fmt.Errorf("kan inte snabbspola — manuell merge eller reset krävs (%w)", err), true)
return
}
}
if ahead > 0 && !gs.ReadOnly {
if err := repo.Push(gs.LiveBranch); err != nil {
record(fmt.Errorf("push: %w", err), false)
return
}
}
record(nil, false)
}
// gitSyncState returns the shared handles used by every handler below.
func (s *Server) gitSyncState() (*git.Repo, *config.Config) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.gitRepo, s.cfg
}
// ── GraphQL handlers (admin only) ─────────────────────────────────────────────
func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
_, cfg := s.gitSyncState()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
gs := cfg.GitSync
gs.Normalize()
publicKey := ""
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
publicKey = strings.TrimSpace(string(pub))
}
webhookURL := ""
if cfg.PublicURL != "" {
webhookURL = strings.TrimRight(cfg.PublicURL, "/") + "/api/git-hook"
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitSyncSettings": map[string]interface{}{
"enabled": gs.Enabled,
"remoteUrl": gs.RemoteURL,
"liveBranch": gs.LiveBranch,
"readOnly": gs.ReadOnly,
"autoSyncMinutes": gs.AutoSyncMinutes,
"publicKey": publicKey,
"keySet": publicKey != "",
"webhookEnabled": gs.WebhookEnabled,
"webhookSecret": gs.WebhookSecret,
"webhookUrl": webhookURL,
},
},
})
}
func (s *Server) handleUpdateGitSync(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
gs := config.GitSyncConfig{
Enabled: boolVal(input, "enabled"),
RemoteURL: strings.TrimSpace(strVal(input, "remoteUrl")),
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
ReadOnly: boolVal(input, "readOnly"),
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
WebhookEnabled: boolVal(input, "webhookEnabled"),
WebhookSecret: cfg.GitSync.WebhookSecret, // never set from input; rotated via its own mutation
}
if gs.WebhookEnabled && gs.WebhookSecret == "" {
secret, err := newWebhookSecret()
if err != nil {
writeGQLError(w, fmt.Sprintf("webhook secret generation failed: %v", err))
return
}
gs.WebhookSecret = secret
}
gs.Normalize()
newCfg.GitSync = gs
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
s.mu.Unlock()
s.configureGitSync(&newCfg)
log.Printf("[admin] git sync updated by %s (enabled=%v remote=%s)",
sess.Username, gs.Enabled, gs.RemoteURL)
writeJSON(w, `{"data":{"updateGitSync":true}}`)
}
func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
dir := s.sshDir()
os.Remove(filepath.Join(dir, "id_ed25519"))
os.Remove(filepath.Join(dir, "id_ed25519.pub"))
_, _, publicKey, err := s.ensureSSHKey()
if err != nil {
writeGQLError(w, fmt.Sprintf("key generation failed: %v", err))
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg != nil {
s.configureGitSync(cfg)
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitRegenerateKey": map[string]interface{}{"publicKey": publicKey},
},
})
}
func (s *Server) handleGitRegenerateWebhookSecret(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
secret, err := newWebhookSecret()
if err != nil {
writeGQLError(w, fmt.Sprintf("secret generation failed: %v", err))
return
}
newCfg := *cfg
newCfg.GitSync.WebhookSecret = secret
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
s.mu.Unlock()
log.Printf("[admin] webhook secret rotated by %s", sess.Username)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitRegenerateWebhookSecret": map[string]interface{}{"secret": secret},
},
})
}
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil {
writeGQLError(w, "server not initialised")
return
}
gs := cfg.GitSync
gs.Normalize()
current, _ := repo.CurrentBranch()
ahead, behind, hasUpstream := 0, 0, false
if gs.Enabled {
ahead, behind, hasUpstream = repo.AheadBehind(current)
}
conflicts := repo.ConflictFiles()
s.syncMu.Lock()
lastAt, lastErr, attention := s.lastSyncAt, s.lastSyncErr, s.syncAttention
s.syncMu.Unlock()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitSyncStatus": map[string]interface{}{
"enabled": gs.Enabled,
"currentBranch": current,
"liveBranch": gs.LiveBranch,
"readOnly": gs.ReadOnly,
"ahead": ahead,
"behind": behind,
"hasUpstream": hasUpstream,
"dirty": repo.HasUncommitted(),
"mergeInProgress": repo.MergeInProgress(),
"conflicts": conflicts,
"lastSyncAt": lastAt,
"lastSyncError": lastErr,
"attention": attention || repo.MergeInProgress(),
},
},
})
}
func (s *Server) handleGitBranches(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
local, remote, err := repo.Branches()
if err != nil {
writeGQLError(w, err.Error())
return
}
current, _ := repo.CurrentBranch()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitBranches": map[string]interface{}{
"current": current,
"local": local,
"remote": remote,
},
},
})
}
func (s *Server) handleGitCreateBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
name := strings.TrimSpace(strVal(req.Variables, "name"))
if repo == nil || name == "" {
writeGQLError(w, "missing branch name")
return
}
if err := repo.CreateBranch(name); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s created branch %s", sess.Username, name)
writeJSON(w, `{"data":{"gitCreateBranch":true}}`)
}
func (s *Server) handleGitSwitchBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
name := strings.TrimSpace(strVal(req.Variables, "name"))
if repo == nil || name == "" {
writeGQLError(w, "missing branch name")
return
}
if repo.MergeInProgress() {
writeGQLError(w, "merge in progress — resolve or abort it first")
return
}
if err := repo.SwitchBranch(name); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s switched to branch %s", sess.Username, name)
writeJSON(w, `{"data":{"gitSwitchBranch":true}}`)
}
func (s *Server) handleGitPull(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if err := repo.Fetch(); err != nil {
writeGQLError(w, err.Error())
return
}
current, err := repo.CurrentBranch()
if err != nil {
writeGQLError(w, err.Error())
return
}
result := "OK"
err = repo.Pull(current, sess.Username, sess.Username+"@archivum.local", false)
switch {
case errors.Is(err, git.ErrMergeConflict):
result = "MERGE_CONFLICT"
case errors.Is(err, git.ErrUnrelatedHistories):
result = "UNRELATED_HISTORIES"
case err != nil:
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.lastSyncAt = time.Now().Format(time.RFC3339)
s.lastSyncErr = ""
s.syncAttention = result != "OK"
s.syncMu.Unlock()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitPull": map[string]interface{}{
"result": result,
"conflicts": repo.ConflictFiles(),
},
},
})
}
func (s *Server) handleGitPush(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if cfg.GitSync.ReadOnly {
writeGQLError(w, "repository is configured read-only — pushing is disabled")
return
}
current, err := repo.CurrentBranch()
if err != nil {
writeGQLError(w, err.Error())
return
}
if err := repo.Push(current); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s pushed %s", sess.Username, current)
writeJSON(w, `{"data":{"gitPush":true}}`)
}
func (s *Server) handleGitResolveMerge(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
strategy := strVal(req.Variables, "strategy")
paths := strSlice(req.Variables, "paths")
if err := repo.ResolveConflicts(strategy, paths, sess.Username, sess.Username+"@archivum.local"); err != nil {
writeGQLError(w, err.Error())
return
}
remaining := repo.ConflictFiles()
if len(remaining) == 0 {
s.syncMu.Lock()
s.syncAttention = false
s.syncMu.Unlock()
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitResolveMerge": map[string]interface{}{
"resolved": len(remaining) == 0,
"remaining": remaining,
},
},
})
}
func (s *Server) handleGitAbortMerge(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
if err := repo.AbortMerge(); err != nil {
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.syncAttention = false
s.syncMu.Unlock()
writeJSON(w, `{"data":{"gitAbortMerge":true}}`)
}
func (s *Server) handleGitResetFromRemote(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if err := repo.Fetch(); err != nil {
writeGQLError(w, err.Error())
return
}
// Reset the current branch when it exists on the remote; otherwise
// adopt the live branch (bootstrap case: local repo predates the
// remote and is on an unrelated branch like master).
current, err := repo.CurrentBranch()
if err != nil || current == "" {
current = cfg.GitSync.LiveBranch
} else if _, _, hasUpstream := repo.AheadBehind(current); !hasUpstream {
current = cfg.GitSync.LiveBranch
}
backup, err := repo.ResetToRemote(current, syncAuthor, syncEmail)
if err != nil {
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.syncAttention = false
s.lastSyncErr = ""
s.syncMu.Unlock()
log.Printf("[gitsync] %s reset %s from remote (backup: %s)", sess.Username, current, backup)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitResetFromRemote": map[string]interface{}{"backupBranch": backup},
},
})
}
func intVal(m map[string]interface{}, key string) int {
if f, ok := m[key].(float64); ok {
return int(f)
}
return 0
}

View File

@@ -61,6 +61,33 @@ type Query {
# Names of the groups a user belongs to (admin only).
userGroups(username: String!): [String!]!
# Effective permissions the current user has on each path (for UI gating).
myAccess(paths: [String!]!): [PathAccess!]!
# Effective permissions a subject has on each path (admin only, for the
# access-control tree). For a user this includes their group memberships.
subjectAccess(subjectType: String!, subjectId: Int!, paths: [String!]!): [PathAccess!]!
# Git sync settings incl. the public deploy key (admin only).
gitSyncSettings: GitSyncSettings!
# Live git sync state: branch, ahead/behind, conflicts (admin only).
gitSyncStatus: GitSyncStatus!
# Local and remote branches (admin only).
gitBranches: GitBranches!
}
type PathAccess {
path: String!
canSearch: Boolean!
canView: Boolean!
canRead: Boolean!
canEdit: Boolean!
canCreate: Boolean!
canDelete: Boolean!
canMove: Boolean!
}
type Mutation {
@@ -117,6 +144,41 @@ type Mutation {
# Commit all uncommitted files in the storage path.
initCommit(message: String!): Boolean!
# ── Git sync (admin only) ────────────────────────────────────────────────────
# Update sync settings; the server (re)connects the remote and ticker.
updateGitSync(input: GitSyncInput!): Boolean!
# Merge origin/<current> into the current branch. result is OK,
# MERGE_CONFLICT (merge left open for gitResolveMerge/gitAbortMerge)
# or UNRELATED_HISTORIES (use gitResetFromRemote).
gitPull: GitPullResult!
# Push the current branch to origin. Fails when read-only.
gitPush: Boolean!
# Create a branch at HEAD and switch to it.
gitCreateBranch(name: String!): Boolean!
# Switch to a local branch, or track a remote-only one.
gitSwitchBranch(name: String!): Boolean!
# Settle conflicted paths with "ours" or "theirs" (empty paths = all),
# committing the merge when nothing remains.
gitResolveMerge(strategy: String!, paths: [String!]): GitResolveResult!
# Throw away an in-progress merge.
gitAbortMerge: Boolean!
# Force the current branch to match origin. Local-only state is kept
# in the returned backup branch.
gitResetFromRemote: GitResetResult!
# Replace the sync keypair (register the new public key as deploy key).
gitRegenerateKey: GitKeyResult!
# Rotate the webhook secret (update the webhook config on the git host).
gitRegenerateWebhookSecret: GitWebhookSecret!
# Delete an image file.
deleteImage(slug: String!, filename: String!): Boolean!
@@ -232,6 +294,78 @@ type RepoStatus {
hasUncommitted: Boolean!
}
type GitSyncSettings {
enabled: Boolean!
remoteUrl: String!
liveBranch: String!
readOnly: Boolean!
# 0 = no interval polling. With webhook off too, nothing is fetched
# automatically at all (fully manual).
autoSyncMinutes: Int!
publicKey: String!
keySet: Boolean!
# Push-triggered sync: POST /api/git-hook with HMAC-SHA256 signature
# (X-Gitea-Signature) over the raw body using webhookSecret.
webhookEnabled: Boolean!
webhookSecret: String!
webhookUrl: String!
}
type GitSyncStatus {
enabled: Boolean!
currentBranch: String!
liveBranch: String!
readOnly: Boolean!
ahead: Int!
behind: Int!
hasUpstream: Boolean!
dirty: Boolean!
mergeInProgress: Boolean!
conflicts: [String!]!
lastSyncAt: String!
lastSyncError: String!
attention: Boolean!
}
type GitBranches {
current: String!
local: [String!]!
remote: [String!]!
}
type GitPullResult {
result: String!
conflicts: [String!]!
}
type GitResolveResult {
resolved: Boolean!
remaining: [String!]!
}
type GitResetResult {
backupBranch: String!
}
type GitKeyResult {
publicKey: String!
}
input GitSyncInput {
enabled: Boolean!
remoteUrl: String!
liveBranch: String!
readOnly: Boolean!
autoSyncMinutes: Int!
# The secret is never taken from input — it is generated server-side
# on first enable and rotated via gitRegenerateWebhookSecret.
webhookEnabled: Boolean!
}
type GitWebhookSecret {
secret: String!
}
type ImageFile {
name: String!
url: String!

View File

@@ -33,6 +33,13 @@ type Server struct {
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 {
@@ -46,6 +53,7 @@ func NewServer(cfg *config.Config, configPath string) http.Handler {
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)
@@ -112,6 +120,7 @@ func (s *Server) initRuntime(cfg *config.Config) {
s.mu.Unlock()
s.configureOIDC(cfg)
s.configureGitSync(cfg)
}
// configureOIDC (re)initialises the OIDC provider from config. Discovery
@@ -225,6 +234,54 @@ func (s *Server) dispatchAuthenticated(
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)
@@ -2542,3 +2599,82 @@ func boolVal(m map[string]interface{}, key string) bool {
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}})
}

View File

@@ -1,5 +1,14 @@
# CPU budget for the build. The self-hosted runner builds on the Raspberry Pi 5
# (4 cores) alongside live services, so we cap the CPU-heavy tools (the Go
# compiler and esbuild — both honour GOMAXPROCS) to leave cores for nginx/NPM
# and the other containers. Lower = gentler but slower; raise for a faster box.
ARG BUILD_CPUS=2
# ── Stage 1: Build frontend ───────────────────────────────────────────────────
FROM node:20-alpine AS frontend-builder
ARG BUILD_CPUS
# esbuild (used by Vite) is a Go program and respects GOMAXPROCS.
ENV GOMAXPROCS=${BUILD_CPUS}
WORKDIR /app/frontend
COPY frontend/package*.json ./
@@ -10,6 +19,9 @@ RUN npm run build
# ── Stage 2: Build backend ────────────────────────────────────────────────────
# Go 1.25 required by the OIDC dependency chain (go-oidc/v3, go-jose/v4).
FROM golang:1.25-alpine AS backend-builder
ARG BUILD_CPUS
# Cap the Go compiler's parallelism to keep the Pi responsive during CI.
ENV GOMAXPROCS=${BUILD_CPUS}
WORKDIR /app/backend
# Copy go.mod first for layer caching. go.sum is written by go mod download
@@ -18,15 +30,16 @@ COPY backend/go.mod ./
COPY backend/go.su[m] ./
RUN go mod download -x
COPY backend/ ./
# CGO disabled — pure Go SQLite (modernc.org/sqlite).
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /archivum ./cmd/server
# CGO disabled — pure Go SQLite (modernc.org/sqlite). -p limits parallel builds.
RUN CGO_ENABLED=0 go build -p ${BUILD_CPUS} -ldflags="-s -w" -o /archivum ./cmd/server
# ── Stage 3: Runtime image ────────────────────────────────────────────────────
FROM alpine:3.20
# su-exec: lightweight tool to drop privileges (replaces gosu / setpriv).
# git: needed for wiki commit operations.
RUN apk add --no-cache su-exec git
# openssh-client: git-sync over ssh:// remotes (git shells out to ssh)
RUN apk add --no-cache su-exec git openssh-client
# Create the app group and user that the entrypoint will run as.
# PUID/PGID can be overridden at runtime via environment variables;

View File

@@ -0,0 +1,377 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { gql } from '@/lib/gql'
import MergeConflictDialog from '@/components/MergeConflictDialog.vue'
const emit = defineEmits<{
(e: 'status', msg: string, isError?: boolean): void
}>()
interface GitSettings {
enabled: boolean; remoteUrl: string; liveBranch: string
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
webhookEnabled: boolean; webhookSecret: string; webhookUrl: string
}
interface GitStatus {
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
ahead: number; behind: number; hasUpstream: boolean; dirty: boolean
mergeInProgress: boolean; conflicts: string[]
lastSyncAt: string; lastSyncError: string; attention: boolean
}
const settings = ref<GitSettings>({
enabled: false, remoteUrl: '', liveBranch: 'main',
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
webhookEnabled: false, webhookSecret: '', webhookUrl: '',
})
const status = ref<GitStatus | null>(null)
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
const busy = ref(false)
const switchTarget = ref('')
const newBranchName = ref('')
const confirmReset = ref(false)
const confirmRegen = ref(false)
const confirmRegenHook = ref(false)
const showMergeDialog = ref(false)
const suggestReset = ref(false)
let poll: ReturnType<typeof setInterval> | undefined
// Remote branches that have no local counterpart yet.
const allBranches = computed(() => {
const extra = branches.value.remote.filter(b => !branches.value.local.includes(b))
return [...branches.value.local, ...extra.map(b => `${b} (remote)`)]
})
async function loadSettings() {
const d = await gql<{ gitSyncSettings: GitSettings }>(
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet webhookEnabled webhookSecret webhookUrl } }`)
settings.value = d.gitSyncSettings
}
async function loadStatus() {
try {
const d = await gql<{ gitSyncStatus: GitStatus }>(
`{ gitSyncStatus { enabled currentBranch liveBranch readOnly ahead behind hasUpstream dirty mergeInProgress conflicts lastSyncAt lastSyncError attention } }`)
status.value = d.gitSyncStatus
if (d.gitSyncStatus.mergeInProgress && d.gitSyncStatus.conflicts.length > 0) showMergeDialog.value = true
} catch { /* repo not ready yet — non-fatal */ }
}
async function loadBranches() {
try {
const d = await gql<{ gitBranches: { current: string; local: string[]; remote: string[] } }>(
`{ gitBranches { current local remote } }`)
branches.value = d.gitBranches
switchTarget.value = d.gitBranches.current
} catch { /* non-fatal */ }
}
async function refresh() {
await Promise.all([loadStatus(), loadBranches()])
}
async function saveSettings() {
busy.value = true
try {
await gql(
`mutation UpdateGitSync($input: GitSyncInput!) { updateGitSync(input: $input) }`,
{ input: {
enabled: settings.value.enabled,
remoteUrl: settings.value.remoteUrl,
liveBranch: settings.value.liveBranch,
readOnly: settings.value.readOnly,
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
webhookEnabled: settings.value.webhookEnabled,
} },
)
emit('status', 'Git sync settings saved.')
await loadSettings()
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function copyKey() {
await navigator.clipboard.writeText(settings.value.publicKey)
emit('status', 'Public key copied to clipboard.')
}
async function regenerateKey() {
confirmRegen.value = false
busy.value = true
try {
const d = await gql<{ gitRegenerateKey: { publicKey: string } }>(
`mutation { gitRegenerateKey { publicKey } }`)
settings.value.publicKey = d.gitRegenerateKey.publicKey
settings.value.keySet = true
emit('status', 'New keypair generated — update the deploy key on your git host.')
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function copyWebhookSecret() {
await navigator.clipboard.writeText(settings.value.webhookSecret)
emit('status', 'Webhook secret copied to clipboard.')
}
async function regenerateWebhookSecret() {
confirmRegenHook.value = false
busy.value = true
try {
const d = await gql<{ gitRegenerateWebhookSecret: { secret: string } }>(
`mutation { gitRegenerateWebhookSecret { secret } }`)
settings.value.webhookSecret = d.gitRegenerateWebhookSecret.secret
emit('status', 'New webhook secret generated — update the webhook on your git host.')
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function pull() {
busy.value = true
suggestReset.value = false
try {
const d = await gql<{ gitPull: { result: string; conflicts: string[] } }>(
`mutation { gitPull { result conflicts } }`)
if (d.gitPull.result === 'MERGE_CONFLICT') {
showMergeDialog.value = true
} else if (d.gitPull.result === 'UNRELATED_HISTORIES') {
suggestReset.value = true
} else {
emit('status', 'Pulled from remote.')
}
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function push() {
busy.value = true
try {
await gql(`mutation { gitPush }`)
emit('status', 'Pushed to remote.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function resolveMerge(strategy: 'ours' | 'theirs', paths?: string[]) {
busy.value = true
try {
const d = await gql<{ gitResolveMerge: { resolved: boolean; remaining: string[] } }>(
`mutation Resolve($strategy: String!, $paths: [String!]) { gitResolveMerge(strategy: $strategy, paths: $paths) { resolved remaining } }`,
{ strategy, paths: paths ?? null },
)
if (d.gitResolveMerge.resolved) {
showMergeDialog.value = false
emit('status', 'Merge resolved and committed.')
}
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function abortMerge() {
busy.value = true
try {
await gql(`mutation { gitAbortMerge }`)
showMergeDialog.value = false
emit('status', 'Merge aborted.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function resetFromRemote() {
confirmReset.value = false
busy.value = true
try {
const d = await gql<{ gitResetFromRemote: { backupBranch: string } }>(
`mutation { gitResetFromRemote { backupBranch } }`)
suggestReset.value = false
showMergeDialog.value = false
const backup = d.gitResetFromRemote.backupBranch
emit('status', backup ? `Reset from remote. Previous state kept in branch ${backup}.` : 'Reset from remote.')
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function createBranch() {
const name = newBranchName.value.trim()
if (!name) return
busy.value = true
try {
await gql(`mutation Create($name: String!) { gitCreateBranch(name: $name) }`, { name })
emit('status', `Created and switched to branch ${name}.`)
newBranchName.value = ''
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
async function switchBranch() {
const name = switchTarget.value.replace(/ \(remote\)$/, '')
if (!name || name === branches.value.current) return
busy.value = true
try {
await gql(`mutation Switch($name: String!) { gitSwitchBranch(name: $name) }`, { name })
emit('status', `Switched to branch ${name}.`)
await refresh()
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
}
onMounted(async () => {
try { await loadSettings() } catch { /* not initialised */ }
if (settings.value.enabled) await refresh()
poll = setInterval(() => { if (settings.value.enabled && !busy.value) loadStatus() }, 30000)
})
onUnmounted(() => { if (poll) clearInterval(poll) })
</script>
<template>
<section class="card p-6">
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-2 mb-4">
<h2 class="text-lg font-semibold">Git Sync (remote repository)</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settings.enabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
<span class="text-sm font-medium">Enable</span>
</label>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Sync the wiki with a remote git repository over SSH. Register the public key below as a
deploy key (with write access unless read-only) on your git host.
<span v-if="status?.attention" class="font-medium text-amber-600 dark:text-amber-400">Needs attention see status below.</span>
</p>
<div v-if="settings.enabled" class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">Remote URL (SSH)</label>
<input v-model="settings.remoteUrl" class="input font-mono text-sm" placeholder="ssh://git@git.example.com:2222/user/repo.git" />
</div>
<div>
<label class="label">Live branch</label>
<input v-model="settings.liveBranch" class="input" placeholder="main" />
<p class="text-xs text-slate-500 mt-1">The branch auto-sync keeps in sync</p>
</div>
<div>
<label class="label">Auto-sync interval (minutes)</label>
<input v-model.number="settings.autoSyncMinutes" type="number" min="0" class="input" />
<p class="text-xs text-slate-500 mt-1">0 = no polling. With the webhook off too, nothing is fetched automatically.</p>
</div>
<div class="sm:col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settings.readOnly" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
<span class="text-sm font-medium">Read-only (pull from remote, never push)</span>
</label>
</div>
<div class="sm:col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settings.webhookEnabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
<span class="text-sm font-medium">Webhook (sync immediately when the git host reports a push)</span>
</label>
</div>
</div>
<!-- Webhook details -->
<div v-if="settings.webhookEnabled && settings.webhookSecret" class="rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-2">
<p class="text-sm text-slate-600 dark:text-slate-400">
Add a webhook on the git repository (Gitea: Settings Webhooks Add webhook Gitea)
with these values. Only pushes to the live branch trigger a sync.
</p>
<div class="text-sm flex flex-wrap items-center gap-2">
<span class="font-medium shrink-0">Target URL:</span>
<code class="font-mono text-xs bg-slate-100 dark:bg-slate-900/60 rounded px-2 py-1 break-all">{{ settings.webhookUrl || '<public URL not set>/api/git-hook' }}</code>
</div>
<div class="text-sm flex flex-wrap items-center gap-2">
<span class="font-medium shrink-0">Secret:</span>
<code class="font-mono text-xs bg-slate-100 dark:bg-slate-900/60 rounded px-2 py-1 break-all select-all">{{ settings.webhookSecret }}</code>
<button class="btn-secondary text-xs" @click="copyWebhookSecret">Copy</button>
<button class="btn-ghost text-xs" @click="confirmRegenHook = true">Rotate</button>
</div>
<div v-if="confirmRegenHook" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm flex items-center justify-between gap-2">
<span>The old secret stops working immediately. Continue?</span>
<span class="flex gap-2">
<button class="btn-ghost text-xs" @click="confirmRegenHook = false">Cancel</button>
<button class="btn-primary text-xs" :disabled="busy" @click="regenerateWebhookSecret">Rotate secret</button>
</span>
</div>
</div>
<!-- Deploy key -->
<div>
<label class="label">Deploy key (public)</label>
<div v-if="settings.keySet" class="flex gap-2 items-start">
<code class="flex-1 block text-xs font-mono bg-slate-100 dark:bg-slate-900/60 rounded-lg p-3 break-all select-all">{{ settings.publicKey }}</code>
<div class="flex flex-col gap-2">
<button class="btn-secondary text-xs" @click="copyKey">Copy</button>
<button class="btn-ghost text-xs" @click="confirmRegen = true">Regenerate</button>
</div>
</div>
<p v-else class="text-xs text-slate-500">The keypair is generated when sync is first enabled and saved.</p>
<div v-if="confirmRegen" class="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm flex items-center justify-between gap-2">
<span>The old key stops working immediately. Continue?</span>
<span class="flex gap-2">
<button class="btn-ghost text-xs" @click="confirmRegen = false">Cancel</button>
<button class="btn-primary text-xs" :disabled="busy" @click="regenerateKey">Regenerate key</button>
</span>
</div>
</div>
<div class="flex justify-end">
<button class="btn-primary" :disabled="busy" @click="saveSettings">Save Git Sync Config</button>
</div>
<!-- Status -->
<div v-if="status?.enabled" class="rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-3">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>Branch: <code class="font-mono">{{ status.currentBranch }}</code>
<span v-if="status.currentBranch === status.liveBranch" class="ml-1 text-[10px] font-bold px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">live</span>
</span>
<span v-if="status.hasUpstream" class="text-slate-500">{{ status.ahead }} {{ status.behind }}</span>
<span v-else class="text-amber-600 dark:text-amber-400">no upstream yet</span>
<span v-if="status.dirty" class="text-amber-600 dark:text-amber-400">uncommitted changes</span>
<span v-if="status.mergeInProgress" class="text-red-600 dark:text-red-400 font-medium">merge in progress</span>
<span v-if="status.lastSyncAt" class="text-slate-400 text-xs ml-auto">last sync {{ new Date(status.lastSyncAt).toLocaleString() }}</span>
</div>
<p v-if="status.lastSyncError" class="text-xs text-red-600 dark:text-red-400 font-mono">{{ status.lastSyncError }}</p>
<div class="flex flex-wrap gap-2">
<button class="btn-secondary" :disabled="busy" @click="pull">Pull</button>
<button class="btn-secondary" :disabled="busy || settings.readOnly" :title="settings.readOnly ? 'Repository is read-only' : ''" @click="push">Push</button>
<button v-if="status.mergeInProgress" class="btn-primary" :disabled="busy" @click="showMergeDialog = true">Resolve merge</button>
<button class="btn-ghost text-red-600 dark:text-red-400 ml-auto" :disabled="busy" @click="confirmReset = true">Reset from remote</button>
</div>
<div v-if="suggestReset" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm">
Local and remote histories are unrelated the safe way forward is a reset from remote
(current state is kept in a backup branch).
</div>
<div v-if="confirmReset" class="rounded-lg border border-red-300 dark:border-red-700 bg-red-50/50 dark:bg-red-900/10 p-3 text-sm flex items-center justify-between gap-2">
<span>Replace the local <code class="font-mono">{{ status.currentBranch }}</code> with the remote version? Current state is saved in a backup branch.</span>
<span class="flex gap-2 shrink-0">
<button class="btn-ghost text-xs" @click="confirmReset = false">Cancel</button>
<button class="btn-primary text-xs" :disabled="busy" @click="resetFromRemote">Reset</button>
</span>
</div>
<!-- Branches -->
<div class="flex flex-wrap items-end gap-2 pt-2 border-t border-slate-100 dark:border-slate-700/50">
<div>
<label class="label">Switch branch</label>
<div class="flex gap-2">
<select v-model="switchTarget" class="input">
<option v-for="b in allBranches" :key="b" :value="b">{{ b }}</option>
</select>
<button class="btn-secondary" :disabled="busy || switchTarget.replace(/ \(remote\)$/, '') === branches.current" @click="switchBranch">Switch</button>
</div>
</div>
<div class="ml-auto">
<label class="label">New branch</label>
<div class="flex gap-2">
<input v-model="newBranchName" class="input" placeholder="draft/my-changes" @keydown.enter.prevent="createBranch" />
<button class="btn-secondary" :disabled="busy || !newBranchName.trim()" @click="createBranch">Create</button>
</div>
</div>
</div>
</div>
</div>
<MergeConflictDialog
:is-open="showMergeDialog"
:conflicts="status?.conflicts ?? []"
:busy="busy"
@close="showMergeDialog = false"
@resolve="resolveMerge"
@abort="abortMerge"
/>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
defineProps<{
isOpen: boolean
conflicts: string[]
busy: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'resolve', strategy: 'ours' | 'theirs', paths?: string[]): void
(e: 'abort'): void
}>()
</script>
<template>
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]">
<!-- Header -->
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Merge conflicts</h3>
<button type="button" @click="emit('close')" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Body -->
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 flex-1 overflow-auto flex flex-col gap-3">
<p class="text-sm text-slate-600 dark:text-slate-400">
The pull stopped because these files were changed both locally and on the remote.
Pick which version to keep per file, or for everything at once.
</p>
<ul class="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg divide-y divide-slate-100 dark:divide-slate-700/50 overflow-y-auto">
<li v-for="file in conflicts" :key="file" class="flex items-center gap-2 px-3 py-2">
<span class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate flex-1">{{ file }}</span>
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'ours', [file])">Keep local</button>
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'theirs', [file])">Take remote</button>
</li>
</ul>
</div>
<!-- Footer -->
<div class="p-4 border-t border-slate-200 dark:border-slate-700 flex flex-wrap justify-end gap-2 bg-white dark:bg-slate-800">
<button type="button" class="btn-ghost mr-auto" :disabled="busy" @click="emit('abort')">Abort merge</button>
<button type="button" class="btn-secondary" :disabled="busy" @click="emit('resolve', 'ours')">Keep all local</button>
<button type="button" class="btn-primary" :disabled="busy" @click="emit('resolve', 'theirs')">Take all remote</button>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -3,11 +3,33 @@ import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { gql, createFolder, moveDocument } from '@/lib/gql'
import { useAppStore } from '@/stores/app'
import { fetchMyAccess, type PathAccess } from '@/lib/access'
import CreateItemDialog from './CreateItemDialog.vue'
const emit = defineEmits<{ close: [] }>()
const app = useAppStore()
// ── Permission gating ──────────────────────────────────────────────────────
const isAdmin = computed(() => app.role === 'admin')
const perms = ref<Record<string, PathAccess>>({})
function canCreate(path: string) {
return isAdmin.value || !!perms.value[path]?.canCreate
}
function canMove(slug: string) {
return isAdmin.value || !!perms.value[slug]?.canMove
}
async function refreshPerms() {
if (isAdmin.value) return
try {
const paths = ['', ...allFolders.value, ...allDocs.value.map(d => d.slug)]
perms.value = await fetchMyAccess(paths)
} catch {
perms.value = {}
}
}
interface DocMeta { slug: string; title: string }
interface TreeFolder {
@@ -101,6 +123,7 @@ async function handleDialogConfirm(name: string) {
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
allFolders.value = fData.folders ?? []
if (parentPath) openFolders[parentPath] = true
await refreshPerms()
} catch (err) {
console.error('Failed to create folder:', err)
}
@@ -175,6 +198,7 @@ onMounted(async () => {
openFolders[parts.slice(0, i).join('/')] = true
}
}
await refreshPerms()
} catch {
allDocs.value = []
allFolders.value = []
@@ -309,6 +333,7 @@ function isActive(slug: string) {
<span class="truncate font-medium">{{ item.name }}</span>
</button>
<button
v-if="canCreate(item.path)"
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 mr-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600"
@click.prevent.stop="showCreateMenu[item.path] = !showCreateMenu[item.path]"
title="Add into folder"
@@ -339,7 +364,7 @@ function isActive(slug: string) {
<!-- Document row -->
<button
v-else
draggable="true"
:draggable="canMove((item as any).slug)"
@dragstart="handleDragStart($event, item)"
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
:class="[
@@ -364,6 +389,7 @@ function isActive(slug: string) {
<!-- Bottom actions -->
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
<button
v-if="canCreate('')"
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
@click="promptCreateRootFolder"
>
@@ -372,6 +398,7 @@ function isActive(slug: string) {
</button>
<button
v-if="canCreate('')"
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
@click="promptCreateItem('document', '')"
>

View File

@@ -0,0 +1,35 @@
// Helpers for permission-aware UI. The backend resolves the current user's
// effective permissions (allow/deny, inherited, group-aware); the UI uses them
// to hide actions the user cannot perform. Admins bypass ACLs entirely.
import { gql } from './gql'
export interface PathAccess {
path: string
canSearch: boolean
canView: boolean
canRead: boolean
canEdit: boolean
canCreate: boolean
canDelete: boolean
canMove: boolean
}
export type Perm =
| 'canSearch' | 'canView' | 'canRead'
| 'canEdit' | 'canCreate' | 'canDelete' | 'canMove'
/** Fetch the current user's effective permissions for the given paths. */
export async function fetchMyAccess(paths: string[]): Promise<Record<string, PathAccess>> {
const uniq = [...new Set(paths)]
const map: Record<string, PathAccess> = {}
if (uniq.length === 0) return map
const data = await gql<{ myAccess: PathAccess[] }>(
`query MyAccess($paths: [String!]!) {
myAccess(paths: $paths) { path canSearch canView canRead canEdit canCreate canDelete canMove }
}`,
{ paths: uniq },
)
for (const a of data.myAccess) map[a.path] = a
return map
}

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { gql } from '@/lib/gql'
import { hashPassword } from '@/lib/crypto'
import FolderPicker from '@/components/FolderPicker.vue'
import GitSyncSection from '@/components/GitSyncSection.vue'
// ── Shared ──────────────────────────────────────────────────────────────────
const loading = ref(true)
@@ -217,73 +218,182 @@ async function toggleMembership(username: string, group: string, member: boolean
await gql(`mutation RUG($username:String!,$group:String!){ removeUserFromGroup(username:$username, group:$group) }`, { username, group })
userGroupMap.value[username] = (userGroupMap.value[username] || []).filter(g => g !== group)
}
if (selectedSubject.value) await loadSubjectAccess()
} catch (err: any) { showStatus(err.message, true) }
}
// ── Access control (allow / deny ACL) ─────────────────────────────────────────
// ── Access control (tree + allow/deny) ─────────────────────────────────────────
type PermKey = 'canSearch' | 'canView' | 'canRead' | 'canEdit' | 'canCreate' | 'canDelete' | 'canMove'
const ALL_PERMS: PermKey[] = ['canSearch', 'canView', 'canRead', 'canEdit', 'canCreate', 'canDelete', 'canMove']
const PERM_LABELS: Record<PermKey, string> = {
canSearch: 'Search', canView: 'View', canRead: 'Read', canEdit: 'Edit', canCreate: 'Create', canDelete: 'Delete', canMove: 'Move',
}
interface AclEntry { id: number; path: string; subjectType: string; subjectId: number; effect: string;
const PERM_SHORT: Record<PermKey, string> = {
canSearch: 'S', canView: 'V', canRead: 'R', canEdit: 'E', canCreate: 'C', canDelete: 'D', canMove: 'M',
}
interface AclEntry { id: number; path: string; effect: string;
canSearch: boolean; canView: boolean; canRead: boolean; canEdit: boolean; canCreate: boolean; canDelete: boolean; canMove: boolean }
interface PathAccess { path: string; canSearch: boolean; canView: boolean; canRead: boolean; canEdit: boolean; canCreate: boolean; canDelete: boolean; canMove: boolean }
const selectedSubject = ref<{ type: 'user' | 'group'; id: number; name: string; isLdap: boolean } | null>(null)
const subjectAclEntries = ref<AclEntry[]>([])
const editedPerms = ref<Record<number, Record<PermKey, boolean>>>({})
const newEntry = ref<{ path: string; effect: 'allow' | 'deny'; perms: Record<PermKey, boolean> }>({
path: '', effect: 'allow',
perms: { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false },
})
const savingEntry = ref<number | null>(null)
const addingEntry = ref(false)
const guestSelected = computed(() => selectedSubject.value?.type === 'user' && selectedSubject.value?.name === 'guest')
// Tree data (folders + documents in the repo).
const treeFolders = ref<string[]>([])
const treeDocs = ref<{ slug: string; title: string }[]>([])
const openNodes = ref<Record<string, boolean>>({ '': true })
// Per-subject state.
const rulesByPath = ref<Record<string, { allow?: AclEntry; deny?: AclEntry }>>({})
const effectiveByPath = ref<Record<string, PathAccess>>({})
const selectedNode = ref<string | null>(null)
const editAllow = ref<Record<PermKey, boolean>>(blankPerms())
const editDeny = ref<Record<PermKey, boolean>>(blankPerms())
const savingNode = ref(false)
function blankPerms(): Record<PermKey, boolean> {
return { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false }
}
interface FolderNode { name: string; path: string; folders: FolderNode[]; docs: { slug: string; title: string }[] }
function buildTree(): FolderNode {
const root: FolderNode = { name: '', path: '', folders: [], docs: [] }
for (const f of treeFolders.value) {
const parts = f.split('/'); let node = root
for (let i = 0; i < parts.length; i++) {
const p = parts.slice(0, i + 1).join('/')
let c = node.folders.find(x => x.path === p)
if (!c) { c = { name: parts[i], path: p, folders: [], docs: [] }; node.folders.push(c) }
node = c
}
}
for (const d of treeDocs.value) {
const parts = d.slug.split('/')
if (parts.length === 1) { root.docs.push(d); continue }
let node = root
for (let i = 0; i < parts.length - 1; i++) {
const p = parts.slice(0, i + 1).join('/')
let c = node.folders.find(x => x.path === p)
if (!c) { c = { name: parts[i], path: p, folders: [], docs: [] }; node.folders.push(c) }
node = c
}
node.docs.push(d)
}
return root
}
interface Row { type: 'folder' | 'doc'; label: string; path: string; depth: number; hasChildren: boolean }
const flatTree = computed<Row[]>(() => {
const root = buildTree()
const rows: Row[] = []
rows.push({ type: 'folder', label: '(root)', path: '', depth: 0, hasChildren: root.folders.length + root.docs.length > 0 })
function traverse(node: FolderNode, depth: number) {
for (const f of node.folders) {
rows.push({ type: 'folder', label: f.name, path: f.path, depth, hasChildren: f.folders.length + f.docs.length > 0 })
if (openNodes.value[f.path]) traverse(f, depth + 1)
}
for (const d of node.docs) {
rows.push({ type: 'doc', label: d.title || d.slug.split('/').pop() || d.slug, path: d.slug, depth, hasChildren: false })
}
}
if (openNodes.value[''] !== false) traverse(root, 1)
return rows
})
function toggleNode(path: string) {
openNodes.value = { ...openNodes.value, [path]: !openNodes.value[path] }
}
function allNodePaths(): string[] {
return ['', ...treeFolders.value, ...treeDocs.value.map(d => d.slug)]
}
function effLetters(path: string): string {
const a = effectiveByPath.value[path]
if (!a) return '—'
const on = ALL_PERMS.filter(k => (a as any)[k]).map(k => PERM_SHORT[k])
return on.length ? on.join(' ') : '—'
}
async function loadTree() {
try {
const [fData, dData] = await Promise.all([
gql<{ folders: string[] }>(`{ folders }`),
gql<{ documents: { slug: string; title: string }[] }>(`{ documents { slug title } }`),
])
treeFolders.value = fData.folders ?? []
treeDocs.value = dData.documents ?? []
const open: Record<string, boolean> = { '': true }
for (const f of treeFolders.value) open[f] = true
openNodes.value = open
} catch { /* non-fatal */ }
}
async function selectSubject(type: 'user' | 'group', id: number, name: string, isLdap: boolean) {
selectedSubject.value = { type, id, name, isLdap }
await loadSubjectAcl()
selectedNode.value = null
await Promise.all([loadSubjectAcl(), loadSubjectAccess()])
}
async function loadSubjectAcl() {
if (!selectedSubject.value) return
try {
const data = await gql<{ subjectAcl: AclEntry[] }>(
`query SubjectAcl($t: String!, $id: Int!) { subjectAcl(subjectType: $t, subjectId: $id) { id path subjectType subjectId effect canSearch canView canRead canEdit canCreate canDelete canMove } }`,
`query SubjectAcl($t: String!, $id: Int!) { subjectAcl(subjectType: $t, subjectId: $id) { id path effect canSearch canView canRead canEdit canCreate canDelete canMove } }`,
{ t: selectedSubject.value.type, id: selectedSubject.value.id })
subjectAclEntries.value = data.subjectAcl
const perms: Record<number, Record<PermKey, boolean>> = {}
const map: Record<string, { allow?: AclEntry; deny?: AclEntry }> = {}
for (const e of data.subjectAcl) {
perms[e.id] = { canSearch: e.canSearch, canView: e.canView, canRead: e.canRead, canEdit: e.canEdit, canCreate: e.canCreate, canDelete: e.canDelete, canMove: e.canMove }
if (!map[e.path]) map[e.path] = {}
if (e.effect === 'deny') map[e.path].deny = e; else map[e.path].allow = e
}
editedPerms.value = perms
rulesByPath.value = map
} catch (err: any) { showStatus(err.message, true) }
}
async function saveEntryPerms(entry: AclEntry) {
async function loadSubjectAccess() {
if (!selectedSubject.value) return
savingEntry.value = entry.id
try {
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
{ input: { path: entry.path, subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id, effect: entry.effect, ...editedPerms.value[entry.id] } })
showStatus('Permission saved.'); await loadSubjectAcl()
} catch (err: any) { showStatus(err.message, true) } finally { savingEntry.value = null }
const data = await gql<{ subjectAccess: PathAccess[] }>(
`query SA($t: String!, $id: Int!, $p: [String!]!) { subjectAccess(subjectType: $t, subjectId: $id, paths: $p) { path canSearch canView canRead canEdit canCreate canDelete canMove } }`,
{ t: selectedSubject.value.type, id: selectedSubject.value.id, p: allNodePaths() })
const map: Record<string, PathAccess> = {}
for (const a of data.subjectAccess) map[a.path] = a
effectiveByPath.value = map
} catch { /* non-fatal */ }
}
async function addNewEntry() {
if (!newEntry.value.path.trim() || !selectedSubject.value) { showStatus('Enter a path first.', true); return }
addingEntry.value = true
try {
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
{ input: { path: newEntry.value.path.trim(), subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id, effect: newEntry.value.effect, ...newEntry.value.perms } })
showStatus('Permission added.')
newEntry.value = { path: '', effect: 'allow', perms: { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false } }
await loadSubjectAcl()
} catch (err: any) { showStatus(err.message, true) } finally { addingEntry.value = false }
function selectNode(path: string) {
selectedNode.value = path
const r = rulesByPath.value[path] || {}
editAllow.value = r.allow ? pick(r.allow) : blankPerms()
editDeny.value = r.deny ? pick(r.deny) : blankPerms()
}
async function removeEntry(id: number) {
function pick(e: AclEntry): Record<PermKey, boolean> {
const o = blankPerms()
for (const k of ALL_PERMS) o[k] = (e as any)[k]
return o
}
async function saveNode() {
if (!selectedSubject.value || selectedNode.value === null) return
savingNode.value = true
try {
await gql(`mutation RemoveAcl($id: Int!) { removeAcl(id: $id) }`, { id })
subjectAclEntries.value = subjectAclEntries.value.filter(e => e.id !== id); showStatus('Permission removed.')
} catch (err: any) { showStatus(err.message, true) }
const path = selectedNode.value
const subj = { subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id }
const existing = rulesByPath.value[path] || {}
if (ALL_PERMS.some(k => editAllow.value[k])) {
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`, { input: { path, ...subj, effect: 'allow', ...editAllow.value } })
} else if (existing.allow) {
await gql(`mutation RA($id: Int!) { removeAcl(id: $id) }`, { id: existing.allow.id })
}
if (ALL_PERMS.some(k => editDeny.value[k])) {
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`, { input: { path, ...subj, effect: 'deny', ...editDeny.value } })
} else if (existing.deny) {
await gql(`mutation RA($id: Int!) { removeAcl(id: $id) }`, { id: existing.deny.id })
}
showStatus('Rules saved.')
await Promise.all([loadSubjectAcl(), loadSubjectAccess()])
} catch (err: any) { showStatus(err.message, true) } finally { savingNode.value = false }
}
// ── Init ──────────────────────────────────────────────────────────────────────
@@ -299,7 +409,7 @@ onMounted(async () => {
ldapPasswordSet.value = true
}
} catch (err: any) { showStatus(err.message, true) } finally { loading.value = false }
await Promise.all([loadOidc(), loadUsers(), loadSubjects()])
await Promise.all([loadOidc(), loadUsers(), loadSubjects(), loadTree()])
})
</script>
@@ -334,6 +444,9 @@ onMounted(async () => {
</div>
</section>
<!-- Git sync -->
<GitSyncSection @status="showStatus" />
<!-- Admin password -->
<section class="card p-6">
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Your Password</h2>
@@ -460,18 +573,19 @@ onMounted(async () => {
</div>
</section>
<!-- Access control -->
<!-- Access control (tree) -->
<section class="card p-6">
<h2 class="text-lg font-semibold mb-2 border-b border-slate-200 dark:border-slate-700 pb-2">Access Control</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">
Default is <strong>deny</strong>. An <span class="text-green-600 dark:text-green-400 font-medium">allow</span> grants access to a path (and everything under it);
a <span class="text-red-600 dark:text-red-400 font-medium">deny</span> always wins over an allow.
Default is <strong>deny</strong>. An <span class="text-green-600 dark:text-green-400 font-medium">allow</span> grants access to a node (and everything under it);
a <span class="text-red-600 dark:text-red-400 font-medium">deny</span> always wins. Rules combine across the user's groups and parent folders.
</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-5">
Paths are document slugs (e.g. <code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1 rounded">docs/intro</code>) or folders (e.g. <code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1 rounded">docs</code>).
Select the <strong>public</strong> user to control what anonymous visitors can see.
Pick a user or group, then click a folder/file in the tree to set its rule. Letters after each node show the subject's <em>effective</em> rights there
(<span class="font-mono">S V R E C D M</span> = Search View Read Edit Create Delete Move).
</p>
<!-- Subject pickers -->
<div class="grid sm:grid-cols-2 gap-4 mb-6">
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div class="bg-slate-50 dark:bg-slate-800/60 px-3 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-semibold">Users <span class="text-xs text-slate-400 ml-1">{{ subjectUsers.length }}</span></div>
@@ -499,51 +613,68 @@ onMounted(async () => {
</div>
</div>
<div v-if="selectedSubject" class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div class="bg-slate-50 dark:bg-slate-800/60 px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2 text-sm font-semibold">
{{ selectedSubject.type === 'user' ? '👤' : '👥' }} {{ selectedSubject.name }}
<span class="ml-auto text-xs text-slate-400">{{ subjectAclEntries.length }} rule{{ subjectAclEntries.length !== 1 ? 's' : '' }}</span>
<div v-if="selectedSubject" class="grid md:grid-cols-2 gap-4">
<!-- Tree -->
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div class="bg-slate-50 dark:bg-slate-800/60 px-3 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-semibold flex items-center gap-2">
<span>📂 Tree</span>
<span class="ml-auto text-xs text-slate-400">{{ selectedSubject.name }}</span>
</div>
<p v-if="guestSelected" class="px-4 py-2 text-xs text-purple-600 dark:text-purple-400 bg-purple-50/50 dark:bg-purple-900/10 border-b border-slate-200 dark:border-slate-700">
These rules define what anonymous visitors (the public user) can see.
<ul class="max-h-96 overflow-y-auto py-1 text-sm">
<li v-for="row in flatTree" :key="row.type + ':' + row.path"
:style="{ paddingLeft: `${0.4 + row.depth * 0.9}rem` }"
:class="['flex items-center gap-1.5 pr-2 py-1 cursor-pointer', selectedNode === row.path ? 'bg-accent-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800/60']"
@click="selectNode(row.path)">
<button v-if="row.type === 'folder' && row.hasChildren" class="w-3.5 flex-shrink-0" @click.stop="toggleNode(row.path)">
<svg class="w-3 h-3 transition-transform" :class="openNodes[row.path] ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
<span v-else class="w-3.5 flex-shrink-0"></span>
<span class="flex-shrink-0">{{ row.type === 'folder' ? (row.path === '' ? '🏠' : '📁') : '📄' }}</span>
<span class="truncate" :class="row.path === '' ? 'font-semibold' : ''">{{ row.label }}</span>
<span v-if="rulesByPath[row.path]?.allow" class="text-[10px] px-1 rounded bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 flex-shrink-0">allow</span>
<span v-if="rulesByPath[row.path]?.deny" class="text-[10px] px-1 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 flex-shrink-0">deny</span>
<span class="ml-auto text-[10px] font-mono text-slate-400 flex-shrink-0">{{ effLetters(row.path) }}</span>
</li>
</ul>
</div>
<!-- Node editor -->
<div v-if="selectedNode !== null" class="border border-slate-200 dark:border-slate-700 rounded-lg p-4">
<div class="text-sm font-semibold mb-1">
{{ selectedNode === '' ? '🏠 (root — everything)' : selectedNode }}
</div>
<p class="text-xs text-slate-500 mb-3">
Effective here: <span class="font-mono">{{ effLetters(selectedNode) }}</span>
</p>
<p v-if="guestSelected" class="text-xs text-purple-600 dark:text-purple-400 mb-3">
Rules for the public (anonymous) user.
</p>
<div class="divide-y divide-slate-100 dark:divide-slate-700/50">
<div v-if="subjectAclEntries.length === 0" class="px-4 py-6 text-sm text-slate-400 text-center italic">No rules yet. Add one below.</div>
<div v-for="entry in subjectAclEntries" :key="entry.id" class="px-4 py-3">
<div class="flex items-start gap-3 flex-wrap">
<span :class="['text-xs font-semibold px-2 py-0.5 rounded mt-0.5', entry.effect === 'deny' ? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400' : 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400']">{{ entry.effect }}</span>
<code class="text-sm font-mono bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">{{ entry.path }}</code>
<div class="flex flex-wrap gap-x-4 gap-y-1 flex-1 min-w-0">
<label v-for="perm in ALL_PERMS" :key="perm" class="flex items-center gap-1 text-xs cursor-pointer text-slate-600 dark:text-slate-400">
<input type="checkbox" v-model="editedPerms[entry.id][perm]" class="rounded border-slate-300 text-accent-600" />
<div class="mb-3">
<div class="text-xs font-semibold text-green-600 dark:text-green-400 mb-1">Allow</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<label v-for="perm in ALL_PERMS" :key="'a'+perm" class="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" v-model="editAllow[perm]" class="rounded border-slate-300 text-green-600" />
{{ PERM_LABELS[perm] }}
</label>
</div>
<div class="flex gap-2 flex-shrink-0">
<button class="btn-primary text-xs py-1 px-3" :disabled="savingEntry === entry.id" @click="saveEntryPerms(entry)">{{ savingEntry === entry.id ? 'Saving' : 'Save' }}</button>
<button class="btn-ghost text-xs py-1 px-2 text-red-500 hover:text-red-700" @click="removeEntry(entry.id)">Remove</button>
</div>
</div>
</div>
</div>
<div class="border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/30 px-4 py-4">
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Add rule</h4>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label text-xs">Effect</label>
<select v-model="newEntry.effect" class="input text-sm w-auto"><option value="allow">allow</option><option value="deny">deny</option></select>
</div>
<div class="flex-1 min-w-40"><label class="label text-xs">Path</label><input v-model="newEntry.path" class="input text-sm" placeholder="docs/intro or docs" @keydown.enter="addNewEntry" /></div>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label v-for="perm in ALL_PERMS" :key="perm" class="flex items-center gap-1 text-xs cursor-pointer text-slate-600 dark:text-slate-400">
<input type="checkbox" v-model="newEntry.perms[perm]" class="rounded border-slate-300 text-accent-600" />
<div class="mb-4">
<div class="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">Deny (wins over allow)</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<label v-for="perm in ALL_PERMS" :key="'d'+perm" class="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" v-model="editDeny[perm]" class="rounded border-slate-300 text-red-600" />
{{ PERM_LABELS[perm] }}
</label>
</div>
<button class="btn-primary text-sm py-1.5 px-4" :disabled="addingEntry || !newEntry.path.trim()" @click="addNewEntry">{{ addingEntry ? 'Adding' : 'Add' }}</button>
</div>
<div class="flex justify-end">
<button class="btn-primary text-sm py-1.5 px-4" :disabled="savingNode" @click="saveNode">{{ savingNode ? 'Saving' : 'Save rules for this node' }}</button>
</div>
<p class="text-[11px] text-slate-400 mt-2">Uncheck everything and save to clear a rule. Rules on a folder apply to everything under it.</p>
</div>
<div v-else class="border border-dashed border-slate-300 dark:border-slate-700 rounded-lg p-4 flex items-center justify-center text-sm text-slate-400 text-center">
Click a folder or file in the tree to set its rules for <strong class="mx-1">{{ selectedSubject.name }}</strong>.
</div>
</div>
<p v-else class="text-sm text-slate-400 italic text-center mt-2">Select a user or group to manage their access.</p>

View File

@@ -2,6 +2,8 @@
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { gql } from '@/lib/gql'
import { fetchMyAccess, type PathAccess } from '@/lib/access'
import { useAppStore } from '@/stores/app'
import VisualEditor from '@/components/editor/VisualEditor.vue'
import SourceEditor from '@/components/editor/SourceEditor.vue'
import HistoryPanel from '@/components/history/HistoryPanel.vue'
@@ -10,11 +12,33 @@ import type { CommitEntry } from '@/components/history/HistoryPanel.vue'
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const slug = computed(() => {
const s = route.params.slug
return Array.isArray(s) ? s.join('/') : (s as string)
})
// ── Permission gating ──────────────────────────────────────────────────────
const isAdmin = computed(() => app.role === 'admin')
const docAccess = ref<PathAccess | null>(null)
// New docs are reached via a create-gated action, so editing is allowed there.
const canEditDoc = computed(() => isAdmin.value || slug.value === 'new' || !!docAccess.value?.canEdit)
const canDeleteDoc = computed(() => slug.value !== 'new' && (isAdmin.value || !!docAccess.value?.canDelete))
const deleting = ref(false)
async function deleteDoc() {
if (!confirm(`Delete "${slug.value}"? This cannot be undone.`)) return
deleting.value = true
try {
await gql(`mutation Del($slug: String!) { deleteDocument(slug: $slug) }`, { slug: slug.value })
router.push('/')
} catch (err: any) {
alert(err?.message || 'Failed to delete')
} finally {
deleting.value = false
}
}
// ── Normal editing state ──────────────────────────────────────────────────────
const docPath = ref('')
@@ -30,6 +54,7 @@ async function loadDocument(s: string) {
exitVersionView()
exitDiffView()
docAccess.value = null
if (s === 'new') {
content.value = ''
docPath.value = ''
@@ -47,6 +72,12 @@ async function loadDocument(s: string) {
{ s },
)
content.value = data.document?.content ?? ''
if (!isAdmin.value) {
try {
const acc = await fetchMyAccess([s])
docAccess.value = acc[s] ?? null
} catch { /* leave null → buttons hidden */ }
}
} finally {
loading.value = false
}
@@ -230,8 +261,8 @@ function formatDate(dateStr: string) {
placeholder="New filename (e.g. folder/doc)"
/>
<!-- Save controls (normal editing only) -->
<template v-if="!viewingVersion && !diffActive">
<!-- Save controls (normal editing only, when the user may edit) -->
<template v-if="!viewingVersion && !diffActive && canEditDoc">
<input
v-model="commitMsg"
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
@@ -244,6 +275,20 @@ function formatDate(dateStr: string) {
>{{ saving ? 'Saving…' : 'Save' }}</button>
</template>
<!-- Read-only hint when editing is not permitted -->
<span
v-else-if="!viewingVersion && !diffActive && !canEditDoc"
class="text-xs text-slate-400 italic px-2"
>Read-only</span>
<!-- Delete (only when permitted) -->
<button
v-if="!viewingVersion && !diffActive && canDeleteDoc"
class="px-3 py-1 bg-red-700 hover:bg-red-600 text-white rounded text-sm disabled:opacity-50"
:disabled="deleting"
@click="deleteDoc"
>{{ deleting ? 'Deleting…' : 'Delete' }}</button>
<!-- History toggle button (not shown for new docs or in diff view) -->
<button
v-if="slug !== 'new'"

View File

@@ -52,6 +52,7 @@ export default defineConfig({
proxy: {
'/graphql': { target: 'http://localhost:4000', changeOrigin: true },
'/media': { target: 'http://localhost:4000', changeOrigin: true },
'/api': { target: 'http://localhost:4000', changeOrigin: true },
},
},
})