feat(gitsync): webhook-triggad synk + helt manuellt läge
All checks were successful
build-and-push / build (push) Successful in 1m7s
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:
@@ -279,6 +279,14 @@ ticker). Admin-UI: `frontend/src/components/GitSyncSection.vue` med
|
|||||||
- **Reset från remote:** tvingar lokala grenen att matcha remoten; lokalt
|
- **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
|
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`).
|
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
|
- **Headless-konfiguration:** allt kan sättas via `config.json`-sektionen
|
||||||
`git_sync` + omstart, eller via GraphQL-mutationen `updateGitSync` — inget
|
`git_sync` + omstart, eller via GraphQL-mutationen `updateGitSync` — inget
|
||||||
webbgui krävs.
|
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
|
- `public_url` — extern bas-URL (t.ex. `https://archivum.brasse-pc.eu`), används
|
||||||
för att härleda OIDC-redirect-URI
|
för att härleda OIDC-redirect-URI
|
||||||
- `git_sync` — se avsnittet ovan: `enabled`, `remote_url`, `live_branch`,
|
- `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):
|
- `oidc` — valfri OIDC/SSO-konfiguration (Authentik):
|
||||||
- `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url`
|
- `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url`
|
||||||
- `groups_claim` (default `groups`), `username_claim` (default `preferred_username`)
|
- `groups_claim` (default `groups`), `username_claim` (default `preferred_username`)
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ type GitSyncConfig struct {
|
|||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
// AutoSyncMinutes > 0 enables background sync on that interval.
|
// AutoSyncMinutes > 0 enables background sync on that interval.
|
||||||
// Background pulls are fast-forward only; anything needing a real
|
// 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"`
|
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.
|
// Normalize fills in defaults for optional git-sync fields.
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package graph
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -107,6 +112,22 @@ func (s *Server) configureGitSync(cfg *config.Config) {
|
|||||||
return
|
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()
|
keyPath, khPath, _, err := s.ensureSSHKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[gitsync] ssh key setup failed: %v", err)
|
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",
|
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
|
||||||
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
|
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{})
|
stop := make(chan struct{})
|
||||||
s.syncMu.Lock()
|
s.syncMu.Lock()
|
||||||
s.syncStop = stop
|
s.syncStop = stop
|
||||||
s.syncMu.Unlock()
|
s.syncMu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// One eager sync shortly after (re)configuration, then on the
|
// One eager sync shortly after (re)configuration: catches up on
|
||||||
// configured interval. Manual-only mode still gets the eager
|
// webhook events missed while the server was down, and seeds
|
||||||
// run so status is populated after a restart.
|
// status. Then the configured interval, when one is set.
|
||||||
timer := time.NewTimer(10 * time.Second)
|
timer := time.NewTimer(10 * time.Second)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
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,
|
// backgroundSync fetches and, when the live branch is checked out,
|
||||||
// fast-forwards it and pushes local commits. It never merges: anything
|
// fast-forwards it and pushes local commits. It never merges: anything
|
||||||
// that needs a real merge raises the attention flag for an admin.
|
// 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 {
|
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
|
||||||
publicKey = strings.TrimSpace(string(pub))
|
publicKey = strings.TrimSpace(string(pub))
|
||||||
}
|
}
|
||||||
|
webhookURL := ""
|
||||||
|
if cfg.PublicURL != "" {
|
||||||
|
webhookURL = strings.TrimRight(cfg.PublicURL, "/") + "/api/git-hook"
|
||||||
|
}
|
||||||
writeJSONObj(w, map[string]interface{}{
|
writeJSONObj(w, map[string]interface{}{
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"gitSyncSettings": map[string]interface{}{
|
"gitSyncSettings": map[string]interface{}{
|
||||||
@@ -267,6 +361,9 @@ func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session
|
|||||||
"autoSyncMinutes": gs.AutoSyncMinutes,
|
"autoSyncMinutes": gs.AutoSyncMinutes,
|
||||||
"publicKey": publicKey,
|
"publicKey": publicKey,
|
||||||
"keySet": 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")),
|
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
|
||||||
ReadOnly: boolVal(input, "readOnly"),
|
ReadOnly: boolVal(input, "readOnly"),
|
||||||
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
|
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()
|
gs.Normalize()
|
||||||
newCfg.GitSync = gs
|
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) {
|
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
|
||||||
if !s.requireAdmin(w, sess) {
|
if !s.requireAdmin(w, sess) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ type Mutation {
|
|||||||
# Replace the sync keypair (register the new public key as deploy key).
|
# Replace the sync keypair (register the new public key as deploy key).
|
||||||
gitRegenerateKey: GitKeyResult!
|
gitRegenerateKey: GitKeyResult!
|
||||||
|
|
||||||
|
# Rotate the webhook secret (update the webhook config on the git host).
|
||||||
|
gitRegenerateWebhookSecret: GitWebhookSecret!
|
||||||
|
|
||||||
# Delete an image file.
|
# Delete an image file.
|
||||||
deleteImage(slug: String!, filename: String!): Boolean!
|
deleteImage(slug: String!, filename: String!): Boolean!
|
||||||
|
|
||||||
@@ -296,9 +299,16 @@ type GitSyncSettings {
|
|||||||
remoteUrl: String!
|
remoteUrl: String!
|
||||||
liveBranch: String!
|
liveBranch: String!
|
||||||
readOnly: Boolean!
|
readOnly: Boolean!
|
||||||
|
# 0 = no interval polling. With webhook off too, nothing is fetched
|
||||||
|
# automatically at all (fully manual).
|
||||||
autoSyncMinutes: Int!
|
autoSyncMinutes: Int!
|
||||||
publicKey: String!
|
publicKey: String!
|
||||||
keySet: Boolean!
|
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 {
|
type GitSyncStatus {
|
||||||
@@ -347,6 +357,13 @@ input GitSyncInput {
|
|||||||
liveBranch: String!
|
liveBranch: String!
|
||||||
readOnly: Boolean!
|
readOnly: Boolean!
|
||||||
autoSyncMinutes: Int!
|
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 {
|
type ImageFile {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func NewServer(cfg *config.Config, configPath string) http.Handler {
|
|||||||
mux.HandleFunc("/graphql", s.handleGraphQL)
|
mux.HandleFunc("/graphql", s.handleGraphQL)
|
||||||
mux.HandleFunc("/health", s.handleHealth)
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
mux.HandleFunc("/api/upload", s.handleUpload)
|
mux.HandleFunc("/api/upload", s.handleUpload)
|
||||||
|
mux.HandleFunc("/api/git-hook", s.handleGitWebhook)
|
||||||
mux.HandleFunc("/media/", s.handleMedia)
|
mux.HandleFunc("/media/", s.handleMedia)
|
||||||
mux.HandleFunc("/auth/oidc/login", s.handleOIDCLogin)
|
mux.HandleFunc("/auth/oidc/login", s.handleOIDCLogin)
|
||||||
mux.HandleFunc("/auth/oidc/callback", s.handleOIDCCallback)
|
mux.HandleFunc("/auth/oidc/callback", s.handleOIDCCallback)
|
||||||
@@ -268,6 +269,9 @@ func (s *Server) dispatchAuthenticated(
|
|||||||
case strings.Contains(q, "gitResetFromRemote"):
|
case strings.Contains(q, "gitResetFromRemote"):
|
||||||
s.handleGitResetFromRemote(w, sess)
|
s.handleGitResetFromRemote(w, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "gitRegenerateWebhookSecret"):
|
||||||
|
s.handleGitRegenerateWebhookSecret(w, sess)
|
||||||
|
|
||||||
case strings.Contains(q, "gitRegenerateKey"):
|
case strings.Contains(q, "gitRegenerateKey"):
|
||||||
s.handleGitRegenerateKey(w, sess)
|
s.handleGitRegenerateKey(w, sess)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const emit = defineEmits<{
|
|||||||
interface GitSettings {
|
interface GitSettings {
|
||||||
enabled: boolean; remoteUrl: string; liveBranch: string
|
enabled: boolean; remoteUrl: string; liveBranch: string
|
||||||
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
|
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
|
||||||
|
webhookEnabled: boolean; webhookSecret: string; webhookUrl: string
|
||||||
}
|
}
|
||||||
interface GitStatus {
|
interface GitStatus {
|
||||||
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
|
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
|
||||||
@@ -21,6 +22,7 @@ interface GitStatus {
|
|||||||
const settings = ref<GitSettings>({
|
const settings = ref<GitSettings>({
|
||||||
enabled: false, remoteUrl: '', liveBranch: 'main',
|
enabled: false, remoteUrl: '', liveBranch: 'main',
|
||||||
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
|
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
|
||||||
|
webhookEnabled: false, webhookSecret: '', webhookUrl: '',
|
||||||
})
|
})
|
||||||
const status = ref<GitStatus | null>(null)
|
const status = ref<GitStatus | null>(null)
|
||||||
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
|
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
|
||||||
@@ -29,6 +31,7 @@ const switchTarget = ref('')
|
|||||||
const newBranchName = ref('')
|
const newBranchName = ref('')
|
||||||
const confirmReset = ref(false)
|
const confirmReset = ref(false)
|
||||||
const confirmRegen = ref(false)
|
const confirmRegen = ref(false)
|
||||||
|
const confirmRegenHook = ref(false)
|
||||||
const showMergeDialog = ref(false)
|
const showMergeDialog = ref(false)
|
||||||
const suggestReset = ref(false)
|
const suggestReset = ref(false)
|
||||||
let poll: ReturnType<typeof setInterval> | undefined
|
let poll: ReturnType<typeof setInterval> | undefined
|
||||||
@@ -41,7 +44,7 @@ const allBranches = computed(() => {
|
|||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const d = await gql<{ gitSyncSettings: GitSettings }>(
|
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
|
settings.value = d.gitSyncSettings
|
||||||
}
|
}
|
||||||
async function loadStatus() {
|
async function loadStatus() {
|
||||||
@@ -75,6 +78,7 @@ async function saveSettings() {
|
|||||||
liveBranch: settings.value.liveBranch,
|
liveBranch: settings.value.liveBranch,
|
||||||
readOnly: settings.value.readOnly,
|
readOnly: settings.value.readOnly,
|
||||||
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
|
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
|
||||||
|
webhookEnabled: settings.value.webhookEnabled,
|
||||||
} },
|
} },
|
||||||
)
|
)
|
||||||
emit('status', 'Git sync settings saved.')
|
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 }
|
} 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() {
|
async function pull() {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
suggestReset.value = false
|
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>
|
<p class="text-xs text-slate-500 mt-1">The branch auto-sync keeps in sync</p>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<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>
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<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>
|
<span class="text-sm font-medium">Read-only (pull from remote, never push)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Deploy key -->
|
<!-- Deploy key -->
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/graphql': { target: 'http://localhost:4000', changeOrigin: true },
|
'/graphql': { target: 'http://localhost:4000', changeOrigin: true },
|
||||||
'/media': { target: 'http://localhost:4000', changeOrigin: true },
|
'/media': { target: 'http://localhost:4000', changeOrigin: true },
|
||||||
|
'/api': { target: 'http://localhost:4000', changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user