feat(gitsync): webhook-triggad synk + helt manuellt läge
All checks were successful
build-and-push / build (push) Successful in 1m7s

- POST /api/git-hook: HMAC-SHA256-verifierad (X-Gitea-Signature),
  reagerar bara på pushar till live-grenen, svarar 202 och synkar async
- webhook-hemlighet genereras server-side (headless via config.json),
  roteras med gitRegenerateWebhookSecret
- auto_sync_minutes=0 + webhook av ⇒ ingen automatisk hämtning alls;
  webhook på ⇒ catch-up-synk vid uppstart (missade event)
- admin-UI: webhook-toggle, target-URL + secret med copy/rotate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:52:01 +02:00
parent 9854d85237
commit 5819a6bdce
7 changed files with 236 additions and 7 deletions

View File

@@ -279,6 +279,14 @@ ticker). Admin-UI: `frontend/src/components/GitSyncSection.vue` med
- **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.
@@ -293,7 +301,7 @@ ticker). Admin-UI: `frontend/src/components/GitSyncSection.vue` med
- `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`
`read_only`, `auto_sync_minutes`, `webhook_enabled`, `webhook_secret`
- `oidc` — valfri OIDC/SSO-konfiguration (Authentik):
- `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url`
- `groups_claim` (default `groups`), `username_claim` (default `preferred_username`)

View File

@@ -37,8 +37,15 @@ type GitSyncConfig struct {
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.
// 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.

View File

@@ -3,10 +3,15 @@ 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"
@@ -107,6 +112,22 @@ func (s *Server) configureGitSync(cfg *config.Config) {
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)
@@ -120,15 +141,21 @@ func (s *Server) configureGitSync(cfg *config.Config) {
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, then on the
// configured interval. Manual-only mode still gets the eager
// run so status is populated after a restart.
// 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 {
@@ -153,6 +180,69 @@ func (s *Server) configureGitSync(cfg *config.Config) {
}()
}
// ── 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.
@@ -257,6 +347,10 @@ func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session
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{}{
@@ -267,6 +361,9 @@ func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session
"autoSyncMinutes": gs.AutoSyncMinutes,
"publicKey": publicKey,
"keySet": publicKey != "",
"webhookEnabled": gs.WebhookEnabled,
"webhookSecret": gs.WebhookSecret,
"webhookUrl": webhookURL,
},
},
})
@@ -299,6 +396,16 @@ func (s *Server) handleUpdateGitSync(w http.ResponseWriter, req gqlRequest, sess
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
@@ -342,6 +449,39 @@ func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Sessio
})
}
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

View File

@@ -176,6 +176,9 @@ type Mutation {
# 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!
@@ -296,9 +299,16 @@ type GitSyncSettings {
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 {
@@ -347,6 +357,13 @@ input GitSyncInput {
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 {

View File

@@ -53,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)
@@ -268,6 +269,9 @@ func (s *Server) dispatchAuthenticated(
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)

View File

@@ -10,6 +10,7 @@ const emit = defineEmits<{
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
@@ -21,6 +22,7 @@ interface GitStatus {
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: [] })
@@ -29,6 +31,7 @@ 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
@@ -41,7 +44,7 @@ const allBranches = computed(() => {
async function loadSettings() {
const d = await gql<{ gitSyncSettings: GitSettings }>(
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet } }`)
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet webhookEnabled webhookSecret webhookUrl } }`)
settings.value = d.gitSyncSettings
}
async function loadStatus() {
@@ -75,6 +78,7 @@ async function saveSettings() {
liveBranch: settings.value.liveBranch,
readOnly: settings.value.readOnly,
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
webhookEnabled: settings.value.webhookEnabled,
} },
)
emit('status', 'Git sync settings saved.')
@@ -100,6 +104,22 @@ async function regenerateKey() {
} 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
@@ -223,8 +243,9 @@ onUnmounted(() => { if (poll) clearInterval(poll) })
<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, 0 = manual)</label>
<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">
@@ -232,6 +253,37 @@ onUnmounted(() => { if (poll) clearInterval(poll) })
<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 -->

View File

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