package handlers import ( "net/http" "strconv" "strings" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/database" ) // parseRetentionDays interprets a retention_days form value. It // returns the number of days, or, for a value it refuses, the message // the create and edit forms show; the message is empty when the value // is accepted. // // An empty value yields fallback, which lets the create path apply the // default and the edit path leave the stored value unchanged. A value // of 0 is returned as 0 and is rewritten to the retain-forever // sentinel by database.Webhook's BeforeSave hook. Anything unparseable // or negative is refused rather than silently given a default. // // The upper bound is not cosmetic. The reaper computes its cutoff as a // time.Duration, an int64 nanosecond count, so a day count above // database.MaxFiniteRetentionDays overflows, puts the cutoff in the // future, and deletes every event the webhook has. A finite value // above that ceiling is therefore refused, and the message names the // ceiling rather than implying the input was not a number. // // A value at or above the retain-forever sentinel is not out of range: // it is what the edit form pre-fills for a retain-forever webhook, so // submitting the form back unchanged has to keep meaning "forever" // rather than being rejected. func parseRetentionDays(raw string, fallback int) (int, string) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, "" } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return 0, "Retention must be a whole number of days, or 0 to " + "retain events forever." } if v >= database.RetentionForeverDays { return database.RetentionForeverDays, "" } if v > database.MaxFiniteRetentionDays { return 0, "Retention must be at most " + strconv.Itoa(database.MaxFiniteRetentionDays) + " days, or 0 to retain events forever." } return v, "" } // ownedWebhook resolves the request's sourceID parameter to a // webhook the session's user owns. // // Ownership and existence are decided by one query, so a // webhook belonging to another user is indistinguishable from // one that does not exist: both are a 404, and neither confirms // the id. Callers that reach further into a webhook's data — // the event log page and the event body download — share this // one check rather than restating it, so the download cannot // come to authorize differently from the page that links to it. // // It reports false once it has written the response, which is a // redirect to the login page for an unauthenticated request and // a 404 otherwise. The caller returns without writing more. func (h *Handlers) ownedWebhook( w http.ResponseWriter, r *http.Request, ) (database.Webhook, bool) { var webhook database.Webhook userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return database.Webhook{}, false } sourceID := chi.URLParam(r, "sourceID") err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return database.Webhook{}, false } return webhook, true } // deleteChildResource returns a handler that deletes a child // resource (entrypoint or target) belonging to a webhook. The // optional afterDelete hook runs with the child's id once the // delete has removed it, before the redirect, which carries done as // its notice. func (h *Handlers) deleteChildResource( idParam string, model any, errMsg string, afterDelete func(childID string), done noticeCode, ) 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") childID := chi.URLParam(r, idParam) 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 } result := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhook.ID, ).Delete(model) if result.Error != nil { h.serverError(w, r, errMsg, result.Error) return } // Only for a row this webhook really had: the id came from // the URL and may name another webhook's child. if afterDelete != nil && result.RowsAffected > 0 { afterDelete(childID) } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, done), http.StatusSeeOther, ) } } // toggleChildResource returns a handler that toggles the active // state of a child resource belonging to a webhook. toggleFn returns // the new state, and the redirect carries activated or deactivated as // its notice to match. func (h *Handlers) toggleChildResource( idParam string, toggleFn func(webhookID, childID string) (bool, error), errMsg string, activated, deactivated noticeCode, ) 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") childID := chi.URLParam(r, idParam) 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 } active, err := toggleFn(webhook.ID, childID) if err != nil { h.serverError(w, r, errMsg, err) return } done := deactivated if active { done = activated } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, done), http.StatusSeeOther, ) } } // getUserID extracts the user ID from the session. func (h *Handlers) getUserID( r *http.Request, ) (string, bool) { sess, err := h.session.Get(r) if err != nil { return "", false } if !h.session.IsAuthenticated(sess) { return "", false } return h.session.GetUserID(sess) }