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 } // 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)) } 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 }