All checks were successful
check / check (push) Successful in 2m48s
internal/handlers/source_management.go read the target destination
with r.FormValue, which falls back to the URL query string when the
field is absent from the body. So
POST /source/{id}/targets?url=https://hooks.slack.com/services/T/B/S
created a working target from a value carried on the request line,
where logs, proxies, Referer headers and error trackers record it.
That is the remaining ingress path of the credential-exposure class
the render, delivery-error and log-line paths were each closed for.
Every form read in these handlers is now r.PostFormValue, so no
query-string value can populate stored configuration or be taken as a
credential. The one deliberate query read, `page` on the authenticated
pagination links, is untouched: it uses r.URL.Query().Get already.
The access log no longer carries the query on any branch, so the log
half of the report is already mitigated; the Sentry half is not, and
making the body the only place these fields are read from aims every
credential at Sentry's request context. The SDK attaches the request
to every captured event, and SendDefaultPII=false does not cover all
of what it copies: Scope.SetRequest tees the first 10 KiB of the body
into a buffer that ParseForm then fills, and Scope.ApplyToEvent copies
both that buffer and r.URL.RawQuery into the event with no guard,
before BeforeSend runs.
So the BeforeSend hook replaces the query string and the body with a
marker, drops cookies and the remote-address environment, and reduces
the headers to an allowlist. The body is replaced on every route
rather than filtered by route, and that is a choice rather than a
limitation: sentryhttp's recover path puts the request on the context
it hands to RecoverWithContext, the SDK carries that context through
to BeforeSend as hint.Context, and chi's RoutePattern is reachable
from it. Redacting unconditionally is still the right call. Every
handler reads its fields with PostFormValue, so the body is exactly
where the credentials are; the one route whose body is genuine signal
is the receiver, and that body is already stored on the event and
served from the UI, so a tracker is not where anyone reads it; and an
unconditional rule cannot leak on a route somebody forgets to add to
it, which a route-conditional one can.
The headers need an allowlist because the SDK's own filter removes
four names and passes everything else, including X-Csrf-Token and the
shared secrets senders put on the receiver route. Scheme, host, path,
method and X-Request-Id stay, which is what names the failing route
and ties it to the access log line. Nothing dropped is needed to debug
a CSRF rejection: Origin and Referer are kept, and the TLS decision is
already in the retained URL, whose scheme sentry-go derives from the
same r.TLS-or-X-Forwarded-Proto predicate the CSRF middleware uses to
pick its handler.
Second barrier, for the JSON path that does not exist yet: the fields
that hold a credential are tagged json:"-" so the first handler to
marshal a model cannot serialise one. Target.Config holds the
incoming-webhook URL, APIKey.Key is a bearer token, and Setting.Value
holds the session encryption key. delivery.TargetView remains the
masking barrier for the HTML path, which is unaffected.
1433 lines
33 KiB
Go
1433 lines
33 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/google/uuid"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// WebhookListItem holds data for the webhook list view.
|
|
type WebhookListItem struct {
|
|
database.Webhook
|
|
|
|
EntrypointCount int64
|
|
TargetCount int64
|
|
EventCount int64
|
|
}
|
|
|
|
// errMissingURL signals that a required URL was not provided.
|
|
var errMissingURL = errors.New("missing URL")
|
|
|
|
// errInvalidRetention signals a retention_days form value that is not
|
|
// a non-negative whole number.
|
|
var errInvalidRetention = errors.New("invalid retention days")
|
|
|
|
// errRetentionTooLarge signals a retention_days form value that is a
|
|
// whole number but larger than the reaper's cutoff arithmetic can
|
|
// represent. It is distinguished from errInvalidRetention so the form
|
|
// can tell the user the actual ceiling instead of implying their input
|
|
// was not a number.
|
|
var errRetentionTooLarge = errors.New("retention days out of range")
|
|
|
|
// retentionErrorMessage returns the message the create and edit forms
|
|
// show the user for a rejected retention_days value. Any error other
|
|
// than errRetentionTooLarge falls back to the generic wording, so an
|
|
// unrecognised parse failure still produces a sensible 400 rather than
|
|
// an empty alert.
|
|
func retentionErrorMessage(err error) string {
|
|
if errors.Is(err, errRetentionTooLarge) {
|
|
return "Retention must be at most " +
|
|
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
|
" days, or 0 to retain events forever."
|
|
}
|
|
|
|
return "Retention must be a whole number of days, or 0 to " +
|
|
"retain events forever."
|
|
}
|
|
|
|
// parseRetentionDays interprets a retention_days form value.
|
|
//
|
|
// 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 an error rather than a silently substituted 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 a 400.
|
|
//
|
|
// 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, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil || v < 0 {
|
|
return 0, errInvalidRetention
|
|
}
|
|
|
|
if v >= database.RetentionForeverDays {
|
|
return database.RetentionForeverDays, nil
|
|
}
|
|
|
|
if v > database.MaxFiniteRetentionDays {
|
|
return 0, errRetentionTooLarge
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// DeliveryView is the display-safe projection of a delivery
|
|
// for the event log page. Its target is a TargetView, so the
|
|
// stored configuration blob — which holds the target's
|
|
// credential — has no path to the template.
|
|
type DeliveryView struct {
|
|
ID string
|
|
Status database.DeliveryStatus
|
|
Target delivery.TargetView
|
|
}
|
|
|
|
// HandleSourceList shows a list of user's webhooks.
|
|
func (h *Handlers) HandleSourceList() 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
|
|
}
|
|
|
|
var webhooks []database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"user_id = ?", userID,
|
|
).Order("created_at DESC").Find(&webhooks).Error
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to list webhooks", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
items := h.buildWebhookListItems(webhooks)
|
|
|
|
data := map[string]any{
|
|
"Webhooks": items,
|
|
}
|
|
|
|
h.renderTemplate(w, r, "sources_list.html", data)
|
|
}
|
|
}
|
|
|
|
// buildWebhookListItems builds list items with counts.
|
|
func (h *Handlers) buildWebhookListItems(
|
|
webhooks []database.Webhook,
|
|
) []WebhookListItem {
|
|
items := make([]WebhookListItem, len(webhooks))
|
|
|
|
for i := range webhooks {
|
|
items[i].Webhook = webhooks[i]
|
|
|
|
h.db.DB().Model(&database.Entrypoint{}).Where(
|
|
"webhook_id = ?", webhooks[i].ID,
|
|
).Count(&items[i].EntrypointCount)
|
|
|
|
h.db.DB().Model(&database.Target{}).Where(
|
|
"webhook_id = ?", webhooks[i].ID,
|
|
).Count(&items[i].TargetCount)
|
|
|
|
if h.dbMgr.DBExists(webhooks[i].ID) {
|
|
webhookDB, err := h.dbMgr.GetDB(
|
|
webhooks[i].ID,
|
|
)
|
|
if err == nil {
|
|
webhookDB.Model(
|
|
&database.Event{},
|
|
).Count(&items[i].EventCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// 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("", "", ""),
|
|
)
|
|
}
|
|
}
|
|
|
|
// newSourceFormData builds the template data for the webhook creation
|
|
// form.
|
|
//
|
|
// It carries the retention default so the pre-filled value comes from
|
|
// database.DefaultRetentionDays rather than being a third hardcoded
|
|
// copy of the same policy, and it carries the submitted name and
|
|
// description so that re-rendering the form after a validation failure
|
|
// gives the user their input back instead of a blank form. The edit
|
|
// form already behaves that way; create now matches it.
|
|
func newSourceFormData(
|
|
errMsg, name, description string,
|
|
) map[string]any {
|
|
return map[string]any{
|
|
tmplKeyError: errMsg,
|
|
"Name": name,
|
|
"Description": description,
|
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
name := r.PostFormValue("name")
|
|
description := r.PostFormValue("description")
|
|
retentionStr := r.PostFormValue("retention_days")
|
|
|
|
if name == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
h.renderTemplate(
|
|
w, r, "sources_new.html",
|
|
newSourceFormData(
|
|
"Name is required", name, description,
|
|
),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
retentionDays, retErr := parseRetentionDays(
|
|
retentionStr, database.DefaultRetentionDays,
|
|
)
|
|
if retErr != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
h.renderTemplate(
|
|
w, r, "sources_new.html",
|
|
newSourceFormData(
|
|
retentionErrorMessage(retErr),
|
|
name, description,
|
|
),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.createWebhookWithEntrypoint(
|
|
w, r, userID, name, description, retentionDays,
|
|
)
|
|
}
|
|
}
|
|
|
|
// createWebhookWithEntrypoint creates a webhook and its default
|
|
// entrypoint in a transaction.
|
|
func (h *Handlers) createWebhookWithEntrypoint(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
userID, name, description string,
|
|
retentionDays int,
|
|
) {
|
|
webhook := &database.Webhook{
|
|
UserID: userID,
|
|
Name: name,
|
|
Description: description,
|
|
RetentionDays: retentionDays,
|
|
}
|
|
|
|
err := h.commitWebhook(webhook)
|
|
if err != nil {
|
|
h.serverError(w, "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", name, "user_id", userID,
|
|
)
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// commitWebhook creates a webhook and default entrypoint in
|
|
// a transaction. Returns an error on failure (rolls back).
|
|
func (h *Handlers) commitWebhook(
|
|
webhook *database.Webhook,
|
|
) 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
|
|
}
|
|
|
|
return tx.Commit().Error
|
|
}
|
|
|
|
// HandleSourceDetail shows details for a specific webhook.
|
|
func (h *Handlers) HandleSourceDetail() 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
h.renderSourceDetail(w, r, webhook)
|
|
}
|
|
}
|
|
|
|
// renderSourceDetail loads and renders a source detail page.
|
|
func (h *Handlers) renderSourceDetail(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
) {
|
|
var entrypoints []database.Entrypoint
|
|
|
|
h.db.DB().Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Find(&entrypoints)
|
|
|
|
var targets []database.Target
|
|
|
|
h.db.DB().Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Find(&targets)
|
|
|
|
var events []database.Event
|
|
|
|
if h.dbMgr.DBExists(webhook.ID) {
|
|
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
|
if dbErr == nil {
|
|
webhookDB.Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Order("created_at DESC").Limit(
|
|
recentEventLimit,
|
|
).Find(&events)
|
|
}
|
|
}
|
|
|
|
host := r.Host
|
|
scheme := "https"
|
|
|
|
if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
|
|
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
|
|
scheme = fwdProto
|
|
}
|
|
|
|
// The template calls Webhook methods, which take pointer
|
|
// receivers; html/template cannot address a value stored in a map.
|
|
data := map[string]any{
|
|
tmplKeyWebhook: &webhook,
|
|
"Entrypoints": entrypoints,
|
|
// Targets are projected to a display-safe view: the
|
|
// stored config blob holds credentials and must never
|
|
// reach a template.
|
|
"Targets": delivery.NewTargetViews(targets),
|
|
"Events": events,
|
|
"BaseURL": scheme + "://" + host,
|
|
}
|
|
|
|
h.renderTemplate(w, r, "source_detail.html", data)
|
|
}
|
|
|
|
// 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
data := map[string]any{
|
|
tmplKeyWebhook: &webhook,
|
|
tmplKeyError: "",
|
|
}
|
|
|
|
h.renderTemplate(w, r, "source_edit.html", data)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// The body size cap is enforced by the MaxBodySize
|
|
// middleware, which runs before CSRF parses the form.
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.applyWebhookEdit(w, r, &webhook)
|
|
}
|
|
}
|
|
|
|
// applyWebhookEdit validates and saves webhook edits.
|
|
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")
|
|
if name == "" {
|
|
data := map[string]any{
|
|
tmplKeyWebhook: webhook,
|
|
tmplKeyError: "Name is required",
|
|
}
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
h.renderTemplate(w, r, "source_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
webhook.Name = name
|
|
webhook.Description = r.PostFormValue("description")
|
|
|
|
// An empty field falls back to the stored value, so submitting the
|
|
// form without touching retention leaves the policy alone.
|
|
retentionDays, retErr := parseRetentionDays(
|
|
r.PostFormValue("retention_days"), webhook.RetentionDays,
|
|
)
|
|
if retErr != nil {
|
|
data := map[string]any{
|
|
tmplKeyWebhook: webhook,
|
|
tmplKeyError: retentionErrorMessage(retErr),
|
|
}
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
h.renderTemplate(w, r, "source_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
webhook.RetentionDays = retentionDays
|
|
|
|
err := h.db.DB().Save(webhook).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to update webhook", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// HandleSourceDelete handles webhook deletion.
|
|
func (h *Handlers) HandleSourceDelete() 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
h.deleteWebhookResources(w, r, webhook, userID)
|
|
}
|
|
}
|
|
|
|
// deleteWebhookResources soft-deletes config and hard-deletes
|
|
// the per-webhook event database.
|
|
func (h *Handlers) deleteWebhookResources(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
userID string,
|
|
) {
|
|
tx := h.db.DB().Begin()
|
|
if tx.Error != nil {
|
|
h.log.Error(
|
|
"failed to begin transaction",
|
|
"error", tx.Error,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
tx.Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Delete(&database.Entrypoint{})
|
|
|
|
tx.Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Delete(&database.Target{})
|
|
|
|
tx.Delete(&webhook)
|
|
|
|
err := tx.Commit().Error
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to commit deletion", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Release the delivery engine's per-webhook archiving state
|
|
// so a deleted webhook's archive writer (and any handle open
|
|
// within its debounce window) does not linger for the
|
|
// process lifetime. The archive file itself is deliberately
|
|
// left on disk; see evictArchiveWriter.
|
|
h.evictArchiveWriter(webhook.ID)
|
|
|
|
err = h.dbMgr.DeleteDB(webhook.ID)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to delete webhook event database",
|
|
"webhook_id", webhook.ID,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
h.log.Info(
|
|
"webhook deleted",
|
|
"webhook_id", webhook.ID,
|
|
"user_id", userID,
|
|
)
|
|
|
|
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
|
}
|
|
|
|
// evictArchiveWriter asks the delivery engine to drop its
|
|
// cached archive writer for a webhook, closing the archive file
|
|
// handle.
|
|
//
|
|
// The archive database file is NOT deleted. Unlike the event
|
|
// database — which is per-webhook working storage and is
|
|
// hard-deleted with the webhook — an archive is explicitly
|
|
// long-term storage that an operator may want to keep or move
|
|
// away for offline retention. Destroying it as a side effect of
|
|
// deleting a webhook would be a surprising and unrecoverable
|
|
// data loss, so the file is left for the operator to handle.
|
|
func (h *Handlers) evictArchiveWriter(webhookID string) {
|
|
if h.evictor == nil {
|
|
return
|
|
}
|
|
|
|
h.evictor.EvictWebhook(webhookID)
|
|
}
|
|
|
|
// evictArchiveWriterIfUnused releases a webhook's archive
|
|
// writer once the webhook has no database target left to feed
|
|
// it.
|
|
//
|
|
// It is called after any child resource of a webhook is
|
|
// deleted, and is correct without knowing which kind was: it
|
|
// evicts only when no database target remains, so deleting one
|
|
// of several database targets — or deleting an unrelated
|
|
// target type — leaves a still-needed writer alone. When no
|
|
// database target ever existed there is no writer and eviction
|
|
// is a no-op. Soft-deleted targets are excluded by GORM's
|
|
// default scope, so the row just deleted is not counted.
|
|
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
|
|
var remaining int64
|
|
|
|
err := h.db.DB().
|
|
Model(&database.Target{}).
|
|
Where(
|
|
"webhook_id = ? AND type = ?",
|
|
webhookID, database.TargetTypeDatabase,
|
|
).
|
|
Count(&remaining).Error
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to count remaining database targets",
|
|
"webhook_id", webhookID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if remaining > 0 {
|
|
return
|
|
}
|
|
|
|
h.evictArchiveWriter(webhookID)
|
|
}
|
|
|
|
// 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return database.Webhook{}, false
|
|
}
|
|
|
|
return webhook, true
|
|
}
|
|
|
|
// HandleSourceLogs shows the request/response logs for a
|
|
// webhook.
|
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
webhook, ok := h.ownedWebhook(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
targets := h.loadTargetMap(webhook.ID)
|
|
page := h.parsePage(r)
|
|
|
|
evts, total := h.loadEventsWithDeliveries(
|
|
w, webhook, targets, page,
|
|
)
|
|
|
|
totalPages := int(total) / paginationPerPage
|
|
if int(total)%paginationPerPage != 0 {
|
|
totalPages++
|
|
}
|
|
|
|
data := map[string]any{
|
|
tmplKeyWebhook: &webhook,
|
|
"Events": evts,
|
|
"Page": page,
|
|
"TotalPages": totalPages,
|
|
"TotalEvents": total,
|
|
"HasPrev": page > 1,
|
|
"HasNext": page < totalPages,
|
|
"PrevPage": page - 1,
|
|
"NextPage": page + 1,
|
|
}
|
|
|
|
h.renderTemplate(w, r, "source_logs.html", data)
|
|
}
|
|
}
|
|
|
|
// loadTargetMap loads targets into a map of display-safe
|
|
// views keyed by target ID. The projection happens here so
|
|
// that no caller can hand a raw target, configuration blob
|
|
// and all, to a template.
|
|
func (h *Handlers) loadTargetMap(
|
|
webhookID string,
|
|
) map[string]delivery.TargetView {
|
|
var targets []database.Target
|
|
|
|
h.db.DB().Where(
|
|
"webhook_id = ?", webhookID,
|
|
).Find(&targets)
|
|
|
|
views := delivery.NewTargetViews(targets)
|
|
|
|
targetMap := make(
|
|
map[string]delivery.TargetView, len(views),
|
|
)
|
|
|
|
for _, v := range views {
|
|
targetMap[v.ID] = v
|
|
}
|
|
|
|
return targetMap
|
|
}
|
|
|
|
// parsePage extracts a page number from the query string.
|
|
func (h *Handlers) parsePage(r *http.Request) int {
|
|
page := 1
|
|
|
|
if p := r.URL.Query().Get("page"); p != "" {
|
|
v, err := strconv.Atoi(p)
|
|
if err == nil && v > 0 {
|
|
page = v
|
|
}
|
|
}
|
|
|
|
return page
|
|
}
|
|
|
|
// loadEventsWithDeliveries loads paginated events and their
|
|
// deliveries from the per-webhook database. Events come back
|
|
// as capped projections rather than database.Event rows: see
|
|
// eventLogColumns for why the cut happens in SQL.
|
|
func (h *Handlers) loadEventsWithDeliveries(
|
|
w http.ResponseWriter,
|
|
webhook database.Webhook,
|
|
targetMap map[string]delivery.TargetView,
|
|
page int,
|
|
) ([]EventLogView, int64) {
|
|
var totalEvents int64
|
|
|
|
var result []EventLogView
|
|
|
|
if !h.dbMgr.DBExists(webhook.ID) {
|
|
return result, totalEvents
|
|
}
|
|
|
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
|
if err != nil {
|
|
h.serverError(
|
|
w, "failed to get webhook database", err,
|
|
)
|
|
|
|
return nil, 0
|
|
}
|
|
|
|
webhookDB.Model(&database.Event{}).Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Count(&totalEvents)
|
|
|
|
offset := (page - 1) * paginationPerPage
|
|
|
|
var rows []eventLogRow
|
|
|
|
webhookDB.Model(&database.Event{}).Select(
|
|
eventLogColumns, maxRenderedBodyBytes,
|
|
).Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Order("created_at DESC").Offset(offset).Limit(
|
|
paginationPerPage,
|
|
).Find(&rows)
|
|
|
|
result = make([]EventLogView, len(rows))
|
|
|
|
for i := range rows {
|
|
result[i] = rows[i].view()
|
|
|
|
var deliveries []database.Delivery
|
|
|
|
webhookDB.Where(
|
|
"event_id = ?", rows[i].ID,
|
|
).Find(&deliveries)
|
|
|
|
result[i].Deliveries = newDeliveryViews(
|
|
deliveries, targetMap,
|
|
)
|
|
}
|
|
|
|
return result, totalEvents
|
|
}
|
|
|
|
// newDeliveryViews projects deliveries for rendering,
|
|
// resolving each one's target to its display-safe view.
|
|
func newDeliveryViews(
|
|
deliveries []database.Delivery,
|
|
targetMap map[string]delivery.TargetView,
|
|
) []DeliveryView {
|
|
views := make([]DeliveryView, len(deliveries))
|
|
|
|
for i := range deliveries {
|
|
views[i] = DeliveryView{
|
|
ID: deliveries[i].ID,
|
|
Status: deliveries[i].Status,
|
|
Target: targetMap[deliveries[i].TargetID],
|
|
}
|
|
}
|
|
|
|
return views
|
|
}
|
|
|
|
// HandleEntrypointCreate handles adding a new entrypoint.
|
|
func (h *Handlers) HandleEntrypointCreate() 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// The body size cap is enforced by the MaxBodySize
|
|
// middleware, which runs before CSRF parses the form.
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
description := r.PostFormValue("description")
|
|
|
|
entrypoint := &database.Entrypoint{
|
|
WebhookID: webhook.ID,
|
|
Path: uuid.New().String(),
|
|
Description: description,
|
|
Active: true,
|
|
}
|
|
|
|
err = h.db.DB().Create(entrypoint).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to create entrypoint", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleTargetCreate handles adding a new target to a webhook.
|
|
func (h *Handlers) HandleTargetCreate() 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// The body size cap is enforced by the MaxBodySize
|
|
// middleware, which runs before CSRF parses the form.
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.processTargetCreate(w, r, webhook)
|
|
}
|
|
}
|
|
|
|
// processTargetCreate validates and creates a new target.
|
|
func (h *Handlers) processTargetCreate(
|
|
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.
|
|
//
|
|
// Every field here is read with PostFormValue, not FormValue.
|
|
// FormValue falls back to the query string, which would let
|
|
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
|
// configure a target from a value the request line carries — and
|
|
// the request line, unlike the body, is what logs, proxies,
|
|
// Referer headers and error trackers record.
|
|
name := r.PostFormValue("name")
|
|
targetType := database.TargetType(r.PostFormValue("type"))
|
|
targetURL := r.PostFormValue("url")
|
|
maxRetriesStr := r.PostFormValue("max_retries")
|
|
expiry := r.PostFormValue("expiry")
|
|
|
|
if name == "" {
|
|
http.Error(
|
|
w, "Name is required", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if !isValidTargetType(targetType) {
|
|
http.Error(
|
|
w, "Invalid target type",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
configJSON, err := h.buildTargetConfig(
|
|
w, r, targetType, targetURL, expiry,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
maxRetries := parseNonNegativeInt(maxRetriesStr)
|
|
|
|
target := &database.Target{
|
|
WebhookID: webhook.ID,
|
|
Name: name,
|
|
Type: targetType,
|
|
Active: true,
|
|
Config: configJSON,
|
|
MaxRetries: maxRetries,
|
|
}
|
|
|
|
err = h.db.DB().Create(target).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to create target", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// isValidTargetType checks whether the target type is supported.
|
|
func isValidTargetType(tt database.TargetType) bool {
|
|
switch tt {
|
|
case database.TargetTypeHTTP,
|
|
database.TargetTypeDatabase,
|
|
database.TargetTypeLog,
|
|
database.TargetTypeSlack:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// parseNonNegativeInt parses s as a non-negative integer,
|
|
// returning 0 if s is empty or invalid.
|
|
func parseNonNegativeInt(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
|
|
v, err := strconv.Atoi(s)
|
|
if err == nil && v >= 0 {
|
|
return v
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// buildTargetConfig builds the JSON config string for a target.
|
|
// The expiry form value is read by the caller (which bounds the
|
|
// request body) and applies to database targets only.
|
|
func (h *Handlers) buildTargetConfig(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
targetType database.TargetType,
|
|
targetURL, expiry string,
|
|
) (string, error) {
|
|
switch targetType {
|
|
case database.TargetTypeHTTP:
|
|
return h.buildURLTargetConfig(
|
|
w, r, targetURL, "url",
|
|
"URL is required for HTTP targets",
|
|
)
|
|
case database.TargetTypeSlack:
|
|
return h.buildURLTargetConfig(
|
|
w, r, targetURL, "webhookUrl",
|
|
"Webhook URL is required for Slack targets",
|
|
)
|
|
case database.TargetTypeDatabase:
|
|
return h.buildDatabaseTargetConfig(w, expiry)
|
|
case database.TargetTypeLog:
|
|
return "", nil
|
|
default:
|
|
http.Error(
|
|
w, "Invalid target type",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return "", errMissingURL
|
|
}
|
|
}
|
|
|
|
// buildURLTargetConfig builds config JSON for a target whose
|
|
// configuration is a single SSRF-validated URL stored under
|
|
// configKey. missingMsg is the error shown when no URL is given.
|
|
func (h *Handlers) buildURLTargetConfig(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
targetURL, configKey, missingMsg string,
|
|
) (string, error) {
|
|
if targetURL == "" {
|
|
http.Error(
|
|
w,
|
|
missingMsg,
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return "", errMissingURL
|
|
}
|
|
|
|
err := delivery.ValidateTargetURL(
|
|
r.Context(), targetURL,
|
|
)
|
|
if err != nil {
|
|
// The submitted URL can be a credential (a Slack
|
|
// incoming webhook URL is a bearer token), so the log
|
|
// records only its scheme and host.
|
|
h.log.Warn(
|
|
"target URL blocked by SSRF protection",
|
|
"url", delivery.MaskURL(targetURL),
|
|
"error", err,
|
|
)
|
|
http.Error(
|
|
w,
|
|
"Invalid target URL: "+err.Error(),
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return "", err
|
|
}
|
|
|
|
cfg := map[string]any{configKey: targetURL}
|
|
|
|
configBytes, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return "", err
|
|
}
|
|
|
|
return string(configBytes), nil
|
|
}
|
|
|
|
// buildDatabaseTargetConfig builds config JSON for a database
|
|
// (archive) target. The optional expiry (a form value read by
|
|
// the caller, which bounds the request body) is validated here,
|
|
// at creation time, so an unparseable value is rejected with a
|
|
// 400 instead of failing every subsequent delivery. An empty
|
|
// expiry yields an empty config (the keep-forever default).
|
|
func (h *Handlers) buildDatabaseTargetConfig(
|
|
w http.ResponseWriter,
|
|
expiry string,
|
|
) (string, error) {
|
|
expiry = strings.TrimSpace(expiry)
|
|
if expiry == "" {
|
|
return "", nil
|
|
}
|
|
|
|
err := delivery.ValidateArchiveExpiry(expiry)
|
|
if err != nil {
|
|
http.Error(
|
|
w,
|
|
"Invalid archive expiry: "+err.Error(),
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return "", err
|
|
}
|
|
|
|
cfg := map[string]any{"expiry": expiry}
|
|
|
|
configBytes, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return "", err
|
|
}
|
|
|
|
return string(configBytes), nil
|
|
}
|
|
|
|
// HandleEntrypointDelete handles deleting an entrypoint.
|
|
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|
return h.deleteChildResource(
|
|
"entrypointID", &database.Entrypoint{},
|
|
"failed to delete entrypoint",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// HandleTargetDelete handles deleting a target. Deleting the
|
|
// last database target of a webhook leaves its archive writer
|
|
// with nothing to write, so the writer is evicted and its
|
|
// handle closed; the archive file is left on disk.
|
|
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
|
return h.deleteChildResource(
|
|
"targetID", &database.Target{},
|
|
"failed to delete target",
|
|
h.evictArchiveWriterIfUnused,
|
|
)
|
|
}
|
|
|
|
// deleteChildResource returns a handler that deletes a child
|
|
// resource (entrypoint or target) belonging to a webhook. The
|
|
// optional afterDelete hook runs with the webhook's id once the
|
|
// delete has succeeded, before the redirect.
|
|
func (h *Handlers) deleteChildResource(
|
|
idParam string,
|
|
model any,
|
|
errMsg string,
|
|
afterDelete func(webhookID string),
|
|
) 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
result := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhook.ID,
|
|
).Delete(model)
|
|
if result.Error != nil {
|
|
h.log.Error(errMsg, "error", result.Error)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if afterDelete != nil {
|
|
afterDelete(webhook.ID)
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r,
|
|
"/source/"+webhook.ID,
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleEntrypointToggle handles toggling an entrypoint's
|
|
// active state.
|
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|
return h.toggleChildResource(
|
|
"entrypointID",
|
|
func(webhookID, childID string) error {
|
|
var ep database.Entrypoint
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhookID,
|
|
).First(&ep).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ep.Active = !ep.Active
|
|
|
|
return h.db.DB().Save(&ep).Error
|
|
},
|
|
"failed to toggle entrypoint",
|
|
)
|
|
}
|
|
|
|
// HandleTargetToggle handles toggling a target's active state.
|
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|
return h.toggleChildResource(
|
|
"targetID",
|
|
func(webhookID, childID string) error {
|
|
var tgt database.Target
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhookID,
|
|
).First(&tgt).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tgt.Active = !tgt.Active
|
|
|
|
return h.db.DB().Save(&tgt).Error
|
|
},
|
|
"failed to toggle target",
|
|
)
|
|
}
|
|
|
|
// toggleChildResource returns a handler that toggles the active
|
|
// state of a child resource belonging to a webhook.
|
|
func (h *Handlers) toggleChildResource(
|
|
idParam string,
|
|
toggleFn func(webhookID, childID string) error,
|
|
errMsg string,
|
|
) 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
err = toggleFn(webhook.ID, childID)
|
|
if err != nil {
|
|
h.log.Error(errMsg, "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r,
|
|
"/source/"+webhook.ID,
|
|
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)
|
|
}
|