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

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

759 lines
21 KiB
Go

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
}