diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e5d36e..e99dc3c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -254,6 +254,35 @@ resolveDBPath(configPath): └─ Annars → /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 `/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/`). Används + också för bootstrap när historikerna är orelaterade (`UNRELATED_HISTORIES`). +- **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 +292,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` - `oidc` — valfri OIDC/SSO-konfiguration (Authentik): - `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url` - `groups_claim` (default `groups`), `username_claim` (default `preferred_username`) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6604e74..8307e84 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -19,6 +19,36 @@ 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. + AutoSyncMinutes int `json:"auto_sync_minutes"` +} + +// 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 +135,7 @@ func Load(path string) (*Config, error) { } cfg.OIDC.Normalize() + cfg.GitSync.Normalize() return &cfg, nil } diff --git a/backend/internal/git/git.go b/backend/internal/git/git.go index 54c0362..986400c 100644 --- a/backend/internal/git/git.go +++ b/backend/internal/git/git.go @@ -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 diff --git a/backend/internal/git/sync.go b/backend/internal/git/sync.go new file mode 100644 index 0000000..0b4e45e --- /dev/null +++ b/backend/internal/git/sync.go @@ -0,0 +1,280 @@ +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/"). +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 +} + +// 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 +} diff --git a/backend/internal/graph/gitsync.go b/backend/internal/graph/gitsync.go new file mode 100644 index 0000000..a7af047 --- /dev/null +++ b/backend/internal/graph/gitsync.go @@ -0,0 +1,604 @@ +package graph + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "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 + } + + 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) + + stop := make(chan struct{}) + s.syncMu.Lock() + s.syncStop = stop + s.syncMu.Unlock() + + go func() { + // One eager sync shortly after (re)configuration, then on the + // configured interval. Manual-only mode still gets the eager + // run so status is populated after a restart. + 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() + } + } + }() +} + +// 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 + } + 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)) + } + 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 != "", + }, + }, + }) +} + +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"), + } + 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) 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 +} diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index 17fe3d1..e6acb28 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -68,6 +68,15 @@ type Query { # 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 { @@ -135,6 +144,38 @@ 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/ 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! + # Delete an image file. deleteImage(slug: String!, filename: String!): Boolean! @@ -250,6 +291,64 @@ type RepoStatus { hasUncommitted: Boolean! } +type GitSyncSettings { + enabled: Boolean! + remoteUrl: String! + liveBranch: String! + readOnly: Boolean! + autoSyncMinutes: Int! + publicKey: String! + keySet: Boolean! +} + +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! +} + type ImageFile { name: String! url: String! diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 56e078c..d07e0a4 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -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 { @@ -112,6 +119,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 +233,44 @@ 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, "gitRegenerateKey"): + s.handleGitRegenerateKey(w, sess) + // ── Effective-access queries (matched before generic acl/subject cases) ──── case strings.Contains(q, "myAccess"): s.handleMyAccess(w, req, sess) diff --git a/docker/Dockerfile b/docker/Dockerfile index 52d49c1..921d96f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,7 +38,8 @@ 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; diff --git a/frontend/src/components/GitSyncSection.vue b/frontend/src/components/GitSyncSection.vue new file mode 100644 index 0000000..e8e69d5 --- /dev/null +++ b/frontend/src/components/GitSyncSection.vue @@ -0,0 +1,325 @@ + + + diff --git a/frontend/src/components/MergeConflictDialog.vue b/frontend/src/components/MergeConflictDialog.vue new file mode 100644 index 0000000..d65b6e0 --- /dev/null +++ b/frontend/src/components/MergeConflictDialog.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 1dc7ba7..734de0a 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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) @@ -443,6 +444,9 @@ onMounted(async () => { + + +

Your Password