Compare commits
11 Commits
feat/oidc-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa8f951ae | |||
| cdc9652842 | |||
| 5819a6bdce | |||
| 9854d85237 | |||
| 19cf285d80 | |||
| 0798b76c3a | |||
| e181e8e68e | |||
| 952ff5f951 | |||
| 9180033543 | |||
| d4cea04941 | |||
| 95e0d8fce1 |
@@ -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: 3 (run #8 hit a transient TLS timeout pulling alpine:3.20)
|
||||
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}" \
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
298
backend/internal/git/sync.go
Normal file
298
backend/internal/git/sync.go
Normal 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
|
||||
}
|
||||
758
backend/internal/graph/gitsync.go
Normal file
758
backend/internal/graph/gitsync.go
Normal 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
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
@@ -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}})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
377
frontend/src/components/GitSyncSection.vue
Normal file
377
frontend/src/components/GitSyncSection.vue
Normal 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>
|
||||
58
frontend/src/components/MergeConflictDialog.vue
Normal file
58
frontend/src/components/MergeConflictDialog.vue
Normal 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>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = withDefaults(defineProps<{ readonly?: boolean }>(), { readonly: false })
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
let view: EditorView | null = null
|
||||
|
||||
const readonlyCompartment = new Compartment()
|
||||
const readonlyExtensions = (ro: boolean) => [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)]
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
|
||||
@@ -20,6 +24,7 @@ onMounted(() => {
|
||||
basicSetup,
|
||||
markdown(),
|
||||
oneDark,
|
||||
readonlyCompartment.of(readonlyExtensions(props.readonly)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
model.value = update.state.doc.toString()
|
||||
@@ -30,6 +35,10 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.readonly, (ro) => {
|
||||
view?.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtensions(ro)) })
|
||||
})
|
||||
|
||||
// Sync external model changes into CodeMirror.
|
||||
watch(model, (adoc) => {
|
||||
if (!view) return
|
||||
|
||||
@@ -14,9 +14,15 @@ import ImageEditDialog from './ImageEditDialog.vue'
|
||||
import LinkDialog from './LinkDialog.vue'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ slug?: string }>()
|
||||
const props = withDefaults(defineProps<{ slug?: string; editable?: boolean }>(), { editable: true })
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
// Force read mode if edit permission disappears (e.g. navigating to a doc
|
||||
// the user may not edit while this component instance is reused).
|
||||
watch(() => props.editable, (editable) => {
|
||||
if (!editable) isEditing.value = false
|
||||
})
|
||||
const showImagePicker = ref(false)
|
||||
const showImageEditor = ref(false)
|
||||
const showLinkDialog = ref({ open: false, url: '', text: '' })
|
||||
@@ -473,9 +479,9 @@ function handleEditorClick(e: MouseEvent) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Banner when not editing -->
|
||||
<!-- Banner when not editing (hidden entirely without edit permission) -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
v-if="!isEditing && props.editable"
|
||||
class="bg-indigo-50 dark:bg-slate-800/80 border-b border-indigo-100 dark:border-slate-700/60 flex items-center justify-between p-2 flex-shrink-0"
|
||||
>
|
||||
<span class="text-sm text-indigo-700 dark:text-indigo-300 ml-2">Läsläge</span>
|
||||
|
||||
@@ -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', '')"
|
||||
>
|
||||
|
||||
35
frontend/src/lib/access.ts
Normal file
35
frontend/src/lib/access.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
<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.
|
||||
</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" />
|
||||
{{ 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 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>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
<!-- 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="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>
|
||||
<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="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>
|
||||
</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>
|
||||
|
||||
@@ -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'"
|
||||
@@ -312,11 +357,13 @@ function formatDate(dateStr: string) {
|
||||
<VisualEditor
|
||||
v-else-if="versionEditorMode === 'visual'"
|
||||
:slug="slug"
|
||||
:editable="false"
|
||||
:model-value="versionContent"
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
<SourceEditor
|
||||
v-else
|
||||
readonly
|
||||
:model-value="versionContent"
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
@@ -325,8 +372,8 @@ function formatDate(dateStr: string) {
|
||||
<!-- Normal editor -->
|
||||
<template v-else>
|
||||
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
|
||||
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" :slug="slug" />
|
||||
<SourceEditor v-else v-model="content" />
|
||||
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" :slug="slug" :editable="canEditDoc" />
|
||||
<SourceEditor v-else v-model="content" :readonly="!canEditDoc" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user