diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e99dc3c..8aa5aed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/`). 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`) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8307e84..b0109e8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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. diff --git a/backend/internal/graph/gitsync.go b/backend/internal/graph/gitsync.go index 5a13d7a..795f50a 100644 --- a/backend/internal/graph/gitsync.go +++ b/backend/internal/graph/gitsync.go @@ -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 diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index e6acb28..b0374fc 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -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 { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index d07e0a4..979f6b1 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -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) diff --git a/frontend/src/components/GitSyncSection.vue b/frontend/src/components/GitSyncSection.vue index e8e69d5..b5342d1 100644 --- a/frontend/src/components/GitSyncSection.vue +++ b/frontend/src/components/GitSyncSection.vue @@ -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({ enabled: false, remoteUrl: '', liveBranch: 'main', readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false, + webhookEnabled: false, webhookSecret: '', webhookUrl: '', }) const status = ref(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 | 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) })

The branch auto-sync keeps in sync

- + +

0 = no polling. With the webhook off too, nothing is fetched automatically.

+
+ +
+ + + +
+

+ 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. +

+
+ Target URL: + {{ settings.webhookUrl || '/api/git-hook' }} +
+
+ Secret: + {{ settings.webhookSecret }} + + +
+
+ The old secret stops working immediately. Continue? + + + + +
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0bfaaf5..3044ae4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 }, }, }, })