package handlers import ( "context" "net/http" "strconv" "github.com/google/uuid" "sneak.berlin/go/webhooker/internal/database" ) // HandleSourceCreate shows the form to create a new webhook. func (h *Handlers) HandleSourceCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.renderTemplate( w, r, "sources_new.html", newSourceFormData("", sourceFormInput{ RetentionDays: strconv.Itoa( database.DefaultRetentionDays, ), }), ) } } // sourceFormInput carries the raw values of the new webhook form. A // refused submission is shown again from it, so every value entered // comes back, retention included. type sourceFormInput struct { Name string Description string RetentionDays string // HTTPURL, when not empty, asks for an HTTP target with this // destination. HTTPURL string // Archive asks for a database (archive) target, whose rows expire // after ArchiveExpiry and whose files rotate by ArchiveRotation. Archive bool ArchiveExpiry string ArchiveRotation string } // newSourceFormData builds the template data for the webhook creation // form. It carries the retention default, which the form's help text // names, from database.DefaultRetentionDays rather than a hardcoded // copy of the same policy. func newSourceFormData( errMsg string, in sourceFormInput, ) map[string]any { return map[string]any{ tmplKeyError: errMsg, "Form": in, "DefaultRetentionDays": database.DefaultRetentionDays, tmplKeyArchiveExpiryChoices: archiveExpiryOptions( in.ArchiveExpiry, ), tmplKeyArchiveRotationChoices: archiveRotationOptions( in.ArchiveRotation, ), } } // HandleSourceCreateSubmit handles the webhook creation form // submission. func (h *Handlers) HandleSourceCreateSubmit() 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 } // 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 } in := sourceFormInput{ Name: r.PostFormValue("name"), Description: r.PostFormValue("description"), RetentionDays: r.PostFormValue("retention_days"), HTTPURL: r.PostFormValue("http_url"), Archive: r.PostFormValue("archive") != "", ArchiveExpiry: r.PostFormValue("archive_expiry"), ArchiveRotation: r.PostFormValue("archive_rotation"), } refuse := func(errMsg string) { h.renderTemplateStatus( w, r, "sources_new.html", newSourceFormData(errMsg, in), http.StatusBadRequest, ) } if in.Name == "" { refuse("Name is required") return } retentionDays, errMsg := parseRetentionDays( in.RetentionDays, database.DefaultRetentionDays, ) if errMsg != "" { refuse(errMsg) return } targets, errMsg, err := h.newWebhookTargets(r.Context(), in) if err != nil { h.serverError(w, r, "failed to encode target config", err) return } if errMsg != "" { refuse(errMsg) return } h.createWebhookWithEntrypoint(w, r, &database.Webhook{ UserID: userID, Name: in.Name, Description: in.Description, RetentionDays: retentionDays, }, targets) } } // newWebhookTargets validates the targets the new webhook form asks // for and returns the rows to create with the webhook, or the message // the form shows for the first one it refuses. A filled-in HTTP URL // asks for an HTTP target named "HTTP", and the archive checkbox for a // database target named "Archive". Each goes through newTarget, as on // the webhook page's add target form. The rows have no WebhookID yet: // the webhook has no ID until it is created. func (h *Handlers) newWebhookTargets( ctx context.Context, in sourceFormInput, ) ([]*database.Target, string, error) { var requested []targetFormInput if in.HTTPURL != "" { requested = append(requested, targetFormInput{ Name: "HTTP", Type: database.TargetTypeHTTP, URL: in.HTTPURL, }) } if in.Archive { requested = append(requested, targetFormInput{ Name: "Archive", Type: database.TargetTypeDatabase, Expiry: in.ArchiveExpiry, Rotation: in.ArchiveRotation, }) } targets := make([]*database.Target, 0, len(requested)) for _, form := range requested { target, errMsg, err := h.newTarget(ctx, "", form) if err != nil || errMsg != "" { return nil, errMsg, err } targets = append(targets, target) } return targets, "", nil } // createWebhookWithEntrypoint creates a webhook, its default // entrypoint and the given targets in a transaction. func (h *Handlers) createWebhookWithEntrypoint( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, targets []*database.Target, ) { err := h.commitWebhook(webhook, targets) if err != nil { h.serverError(w, r, "failed to create webhook", err) return } err = h.dbMgr.CreateDB(webhook.ID) if err != nil { h.log.Error( "failed to create webhook event database", "webhook_id", webhook.ID, "error", err, ) } h.log.Info("webhook created", "webhook_id", webhook.ID, "name", webhook.Name, "user_id", webhook.UserID, ) http.Redirect( w, r, withNotice("/hook/"+webhook.ID, webhookCreated), http.StatusSeeOther, ) } // commitWebhook creates a webhook, its default entrypoint and the // given targets in a transaction. Returns an error on failure (rolls // back). func (h *Handlers) commitWebhook( webhook *database.Webhook, targets []*database.Target, ) error { tx := h.db.DB().Begin() if tx.Error != nil { return tx.Error } err := tx.Create(webhook).Error if err != nil { tx.Rollback() return err } entrypoint := &database.Entrypoint{ WebhookID: webhook.ID, Path: uuid.New().String(), Description: "Default entrypoint", Active: true, } err = tx.Create(entrypoint).Error if err != nil { tx.Rollback() return err } for _, target := range targets { target.WebhookID = webhook.ID err = tx.Create(target).Error if err != nil { tx.Rollback() return err } } return tx.Commit().Error }