check / check (push) Canceled after 0s
The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the lock renames run under, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the lock, rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5
246 lines
5.9 KiB
Go
246 lines
5.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi"
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// HandleSourceEdit shows the form to edit a webhook.
|
|
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
h.renderError(w, r, http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.renderWebhookEdit(
|
|
w, r, &webhook,
|
|
webhook.Name, webhook.Description,
|
|
strconv.Itoa(webhook.RetentionDays),
|
|
"", http.StatusOK,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleSourceEditSubmit handles the webhook edit form
|
|
// submission.
|
|
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
h.archives.Lock()
|
|
defer h.archives.Unlock()
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
h.renderError(w, r, http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
// The body size cap is enforced by the MaxBodySize
|
|
// middleware, which runs before CSRF parses the form.
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
h.renderError(w, r, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
h.applyWebhookEdit(w, r, &webhook)
|
|
}
|
|
}
|
|
|
|
// applyWebhookEdit validates and saves webhook edits. A refused save
|
|
// shows the edit form again with the values submitted and the reason.
|
|
func (h *Handlers) applyWebhookEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook *database.Webhook,
|
|
) {
|
|
// The body size cap is enforced by the MaxBodySize middleware,
|
|
// which runs before CSRF parses the form.
|
|
name := r.PostFormValue("name")
|
|
description := r.PostFormValue("description")
|
|
retention := r.PostFormValue("retention_days")
|
|
|
|
if name == "" {
|
|
h.renderWebhookEdit(
|
|
w, r, webhook, name, description, retention,
|
|
"Name is required", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// An empty field falls back to the stored value, so submitting the
|
|
// form without touching retention leaves the policy alone.
|
|
retentionDays, errMsg := parseRetentionDays(
|
|
retention, webhook.RetentionDays,
|
|
)
|
|
if errMsg != "" {
|
|
h.renderWebhookEdit(
|
|
w, r, webhook, name, description, retention,
|
|
errMsg, http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// edited is the webhook as the submission leaves it; webhook stays
|
|
// as stored, for the page shown again when the save is refused.
|
|
edited := *webhook
|
|
edited.Name = name
|
|
edited.Description = description
|
|
edited.RetentionDays = retentionDays
|
|
|
|
// A new name renames the archive files before it is saved (see
|
|
// archive.Writers.Rename). If either step fails, the same targets'
|
|
// archives go back to the name that is still stored, without
|
|
// reading the main database again.
|
|
targets, err := h.renameWebhookArchives(
|
|
webhook.ID, webhook.Name, edited.Name,
|
|
)
|
|
if err == nil {
|
|
err = h.db.DB().Save(&edited).Error
|
|
}
|
|
|
|
if err != nil {
|
|
restoreErr := h.renameArchives(targets, webhook.Name)
|
|
if restoreErr != nil {
|
|
h.log.Error(
|
|
"failed to rename archives back",
|
|
"webhook_id", webhook.ID,
|
|
"error", restoreErr,
|
|
)
|
|
}
|
|
|
|
if errors.Is(err, archive.ErrNameTaken) {
|
|
h.renderWebhookEdit(
|
|
w, r, webhook, name, description, retention,
|
|
"Not saved: "+err.Error()+
|
|
". Move that archive out of the data directory, "+
|
|
"its .db together with any -wal and -shm beside "+
|
|
"it, then save again.",
|
|
http.StatusConflict,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.serverError(w, r, "failed to update webhook", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// renderWebhookEdit renders the webhook edit page for the webhook as
|
|
// stored, its form showing name, description and retentionDays, with
|
|
// an optional error message above it.
|
|
func (h *Handlers) renderWebhookEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook *database.Webhook,
|
|
name, description, retentionDays, errMsg string,
|
|
status int,
|
|
) {
|
|
data := map[string]any{
|
|
tmplKeyWebhook: webhook,
|
|
tmplKeyError: errMsg,
|
|
"Name": name,
|
|
"Description": description,
|
|
"RetentionDays": retentionDays,
|
|
}
|
|
|
|
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
|
|
}
|
|
|
|
// renameWebhookArchives renames the archive file of every database
|
|
// target of a webhook from the webhook name oldName to newName,
|
|
// keeping each target's own name. It does nothing when the name is
|
|
// unchanged. It returns the targets it read, so that a failed edit can
|
|
// move those same archives back with renameArchives.
|
|
func (h *Handlers) renameWebhookArchives(
|
|
webhookID, oldName, newName string,
|
|
) ([]database.Target, error) {
|
|
if h.archives == nil || oldName == newName {
|
|
return nil, nil
|
|
}
|
|
|
|
var targets []database.Target
|
|
|
|
err := h.db.DB().
|
|
Where(
|
|
"webhook_id = ? AND type = ?",
|
|
webhookID, database.TargetTypeDatabase,
|
|
).
|
|
Find(&targets).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return targets, h.renameArchives(targets, newName)
|
|
}
|
|
|
|
// renameArchives renames the archive file of each of the given
|
|
// database targets to the webhook name webhookName, keeping each
|
|
// target's own name. It tries every target even after one fails, so
|
|
// that moving the archives back after a failed edit leaves none under
|
|
// the new name, and returns every failure joined.
|
|
func (h *Handlers) renameArchives(
|
|
targets []database.Target, webhookName string,
|
|
) error {
|
|
var errs []error
|
|
|
|
for i := range targets {
|
|
err := h.archives.Rename(
|
|
targets[i].ID, webhookName, targets[i].Name,
|
|
)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|