From a5a2cb91d059cb82753d2a4a7f7d1f38b4c881e2 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:00:28 +0100 Subject: [PATCH] =?UTF-8?q?ui:=20P2R-12=20hook=20editor=20=E2=80=94=20sour?= =?UTF-8?q?ce-group=20form=20+=20host-default=20Repo=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-group edit form gains pre/post hook textareas with a service- user warning banner; bodies AEAD-encrypted on save (per-group AD). Repo page adds a 'Host-default hooks' panel above the danger zone with the same shape; saved via POST /hosts/{id}/repo/hooks. --- internal/server/http/server.go | 1 + internal/server/http/ui_repo.go | 9 ++++ internal/server/http/ui_repo_hooks.go | 50 ++++++++++++++++++++++ internal/server/http/ui_sources.go | 27 +++++++++++- web/templates/pages/host_repo.html | 26 +++++++++++ web/templates/pages/source_group_edit.html | 21 +++++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 internal/server/http/ui_repo_hooks.go diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 02adfad..c232407 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -229,6 +229,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit) + r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave) // Admin credentials form (separate slot for prune-capable user). r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index 3420cbd..ac42cc9 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -79,11 +79,16 @@ type hostRepoPage struct { UntaggedSnapshots int GroupNames []string // ordered, for stable rendering + // Host-default hooks (decrypted plaintext for round-trip in form). + HostPreHook string + HostPostHook string + // Inline form-error banners. Empty when no error for that section. CredentialsError string AdminCredsError string BandwidthError string MaintenanceError string + HooksError string // Highlight which form was just submitted, for the success-state // border (subtle UX nicety; empty = no recent save). @@ -179,6 +184,10 @@ func (s *Server) loadHostRepoPage(r *stdhttp.Request, host store.Host) (*hostRep p.BandwidthDown = strconv.Itoa(*host.BandwidthDownKBps) } + // Host-default hooks (decrypt for round-trip in the form). + p.HostPreHook = s.decryptHookOrFallback("", host.PreHookDefault, host.ID, "pre") + p.HostPostHook = s.decryptHookOrFallback("", host.PostHookDefault, host.ID, "post") + // Maintenance — auto-seed defaults if missing. m, err := s.deps.Store.GetRepoMaintenance(r.Context(), host.ID) if err != nil && errors.Is(err, store.ErrNotFound) { diff --git a/internal/server/http/ui_repo_hooks.go b/internal/server/http/ui_repo_hooks.go new file mode 100644 index 0000000..e737cc5 --- /dev/null +++ b/internal/server/http/ui_repo_hooks.go @@ -0,0 +1,50 @@ +// ui_repo_hooks.go — host-default pre/post hook editor on the Repo +// page (P2R-12). Per-source-group hooks live on the source group +// edit form; this surface lets the operator set defaults that apply +// to every group that doesn't override them. +// +// POST /hosts/{id}/repo/hooks takes pre_hook + post_hook form +// fields; encrypts each with the AEAD key (per-host AD bytes); and +// persists the (possibly empty) blobs via store.SetHostHooks. +package http + +import ( + "log/slog" + stdhttp "net/http" +) + +func (s *Server) handleUIRepoHooksSave(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + pre := r.PostForm.Get("pre_hook") + post := r.PostForm.Get("post_hook") + + preEnc, err := s.EncryptHookForHost(host.ID, "pre", pre) + if err != nil { + slog.Error("ui repo hooks: encrypt pre", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + postEnc, err := s.EncryptHookForHost(host.ID, "post", post) + if err != nil { + slog.Error("ui repo hooks: encrypt post", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetHostHooks(r.Context(), host.ID, preEnc, postEnc); err != nil { + slog.Error("ui repo hooks: persist", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/repo?saved=hooks", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_sources.go b/internal/server/http/ui_sources.go index c8ed59f..c4581a5 100644 --- a/internal/server/http/ui_sources.go +++ b/internal/server/http/ui_sources.go @@ -56,6 +56,8 @@ type sourceFormData struct { RetryMax int RetryBackoffSeconds int ConflictDimension string + PreHook string // plaintext; encrypted on save + PostHook string } // sourceGroupEditPage backs both the new and edit form views. @@ -173,11 +175,14 @@ func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp } view := s.baseView(u) view.Title = g.Name + " · " + host.Name + " · restic-manager" + form := formFromGroup(*g) + form.PreHook = s.decryptHookOrFallback(g.PreHook, "", host.ID, "pre") + form.PostHook = s.decryptHookOrFallback(g.PostHook, "", host.ID, "post") view.Page = sourceGroupEditPage{ hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name), IsNew: false, GroupID: gid, - Form: formFromGroup(*g), + Form: form, SaveAction: "/hosts/" + host.ID + "/sources/" + gid + "/edit", } if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil { @@ -253,6 +258,20 @@ func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Re return } + // Encrypt hook bodies (empty → empty stored, clearing the column). + preEnc, err := s.EncryptHookForGroup(host.ID, "pre", form.PreHook) + if err != nil { + slog.Error("ui sources: encrypt pre_hook", "err", err) + s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt pre-hook — see the server log.") + return + } + postEnc, err := s.EncryptHookForGroup(host.ID, "post", form.PostHook) + if err != nil { + slog.Error("ui sources: encrypt post_hook", "err", err) + s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt post-hook — see the server log.") + return + } + g := store.SourceGroup{ ID: gid, HostID: host.ID, @@ -265,6 +284,8 @@ func (s *Server) handleUISourceGroupSave(w stdhttp.ResponseWriter, r *stdhttp.Re }, RetryMax: form.RetryMax, RetryBackoffSeconds: form.RetryBackoffSeconds, + PreHook: preEnc, + PostHook: postEnc, } if isNew { @@ -381,6 +402,8 @@ func parseSourceForm(v map[string][]string) sourceFormData { KeepYearly: get("keep_yearly"), RetryMax: rmax, RetryBackoffSeconds: rback, + PreHook: firstVal(v, "pre_hook"), + PostHook: firstVal(v, "post_hook"), } } @@ -435,5 +458,7 @@ func formFromGroup(g store.SourceGroup) sourceFormData { RetryMax: g.RetryMax, RetryBackoffSeconds: g.RetryBackoffSeconds, ConflictDimension: g.ConflictDimension, + // PreHook/PostHook are decrypted on render (handler-side, not + // here) since formFromGroup has no AEAD reference. } } diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index fda3489..88bfa13 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -220,6 +220,32 @@ + {{/* ---------- Host-default hooks ---------- */}} +

Host-default hooks

+
+

+ Defaults applied to every backup that doesn't set its own. Per-source-group hooks (on the + Sources tab) override these. +

+
+ Hooks run as the agent service user — root on Linux, LocalSystem on Windows. +
+
+ + +
+
+ + +
+
+ +
+
+ {{/* ---------- Danger zone ---------- */}}

Danger zone

Manual run-now ignores this — it just fails immediately if the agent is offline.
+

+ Hooks + backup jobs only +

+
+ Hooks run as the agent service user — root on Linux, LocalSystem on Windows. Treat them like any other root cron entry. +
+
+ + +
Non-zero exit aborts the backup. Stored AEAD-encrypted.
+
+
+ + +
Always runs. RM_JOB_STATUS is set to the backup's outcome. Stored AEAD-encrypted.
+
+
Cancel