package api import ( "context" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/calendar" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/storage" ) type accessPublisher interface { PublishAccessAttempted(ctx context.Context, evt natspub.AccessAttempted) error } type invitationPublisher interface { PublishInvitationSend(ctx context.Context, evt natspub.InvitationSend) error } type tokenHandler struct { logger *slog.Logger guests *storage.GuestRepo tokens *storage.TokenRepo events *storage.EventRepo users *storage.UserRepo accessLogs *storage.AccessLogRepo rsvps *storage.RSVPRepo collabs *storage.CollaboratorRepo branding *storage.BrandingRepo editNonces *editNonceStore emails auth.EmailSender checkInQR *auth.CheckInQRSigner gen *auth.Generator ttl time.Duration pub accessPublisher invitations invitationPublisher publicBaseURL string } type issueTokenResponse struct { Token string `json:"token"` TokenID uuid.UUID `json:"token_id"` Meta *domain.Token `json:"meta"` InvitationQueued bool `json:"invitation_queued"` InvitationLink string `json:"invitation_link"` } // POST /events/{id}/guests/{guest_id}/tokens — issue a token for the guest. func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor) if !ok { return } guest, err := h.guests.Get(r.Context(), guestID) if err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to load guest") return } if guest.EventID != eventID { writeError(w, http.StatusNotFound, "guest not found in event") return } raw, hash, err := h.gen.Generate() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate token") return } tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{ GuestID: guestID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { writeError(w, http.StatusConflict, err.Error()) return } link := h.invitationLink(raw) invitationQueued := h.queueInvitation(r.Context(), event, guest, tk, hostID, raw) writeJSON(w, http.StatusCreated, issueTokenResponse{ Token: raw, TokenID: tk.ID, Meta: tk, InvitationQueued: invitationQueued, InvitationLink: link, }) } // queueInvitation publishes an invitation.send event so the notifier can // dispatch a branded email. Best-effort: if any step fails we log and // return false rather than failing the whole token-issue request — the // host still has the raw URL in the response and can re-trigger sending. func (h *tokenHandler) queueInvitation( ctx context.Context, event *domain.Event, guest *domain.Guest, tk *domain.Token, hostID uuid.UUID, rawToken string, ) bool { if h.invitations == nil { return false } if guest.Email == nil || *guest.Email == "" { // Phone-only / nameless guests get no email — host shares the link // manually. Show that on the UI so it's not a silent surprise. return false } hostName := "" if h.users != nil { if host, err := h.users.GetByID(ctx, hostID); err == nil && host != nil { hostName = host.Name } } evt := natspub.InvitationSend{ EventID: event.ID, GuestID: guest.ID, TokenID: tk.ID, GuestName: guest.Name, GuestEmail: *guest.Email, HostName: hostName, EventName: event.Name, Venue: event.Venue, EventDate: event.EventDate, Link: h.invitationLink(rawToken), IssuedAt: time.Now().UTC(), } if err := h.invitations.PublishInvitationSend(ctx, evt); err != nil { h.logger.Warn("publish invitation.send (continuing)", "err", err, "guest_id", guest.ID) return false } return true } // invitationLink renders the public RSVP URL the guest clicks from their // inbox. publicBaseURL is the externally-reachable host (set via // GG_PUBLIC_BASE_URL); access via /rsvp/ is intentional — the // frontend page rsvp/[token].vue catches the raw token. func (h *tokenHandler) invitationLink(raw string) string { base := h.publicBaseURL if base == "" { base = "http://localhost:3000" } return base + "/rsvp/" + raw } // bulkIssueRequest is the optional JSON body for the bulk-invite call. // An empty body (or missing GuestIDs) means "every guest on the event // who doesn't already have a token". type bulkIssueRequest struct { GuestIDs []string `json:"guest_ids"` } type bulkIssueItemError struct { GuestID string `json:"guest_id"` Reason string `json:"reason"` } // bulkIssueToken is one minted invitation. The raw token is returned so // the host's UI can offer a "copy link" affordance after a bulk send // (especially for guests with no email on file) without making another // round-trip. Same data the per-guest issue endpoint already exposes, // scoped to the host who owns the event. type bulkIssueToken struct { GuestID string `json:"guest_id"` Token string `json:"token"` InvitationQueued bool `json:"invitation_queued"` InvitationLink string `json:"invitation_link"` } type bulkIssueResponse struct { Issued int `json:"issued"` Queued int `json:"queued"` SkippedExisting int `json:"skipped_existing"` SkippedNoEmail int `json:"skipped_no_email"` Tokens []bulkIssueToken `json:"tokens,omitempty"` Errors []bulkIssueItemError `json:"errors,omitempty"` } // POST /events/{id}/guests/invitations/bulk — generate tokens for every // eligible guest (or the explicit subset) on the event and queue an // invitation email for those with an address. Best-effort: any per-guest // error is reported in the response and doesn't abort the rest. func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor) if !ok { return } var req bulkIssueRequest if r.ContentLength > 0 { // Body is optional; only decode when something was actually sent. if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } } var onlyIDs []uuid.UUID if len(req.GuestIDs) > 0 { onlyIDs = make([]uuid.UUID, 0, len(req.GuestIDs)) for _, raw := range req.GuestIDs { id, err := uuid.Parse(raw) if err != nil { writeError(w, http.StatusBadRequest, "invalid guest id: "+raw) return } onlyIDs = append(onlyIDs, id) } } guests, err := h.guests.ListGuestsForInvitation(r.Context(), eventID, onlyIDs) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guests") return } hostName := "" if h.users != nil { if host, err := h.users.GetByID(r.Context(), hostID); err == nil && host != nil { hostName = host.Name } } resp := bulkIssueResponse{} for _, g := range guests { if g.HasToken { resp.SkippedExisting++ continue } raw, hash, err := h.gen.Generate() if err != nil { resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: "mint token failed"}) continue } tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{ GuestID: g.ID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { // Likely a race against the unique constraint (someone else // issued in parallel) — surface but don't fail the batch. resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: err.Error()}) continue } resp.Issued++ link := h.invitationLink(raw) tokenInfo := bulkIssueToken{ GuestID: g.ID.String(), Token: raw, InvitationLink: link, } if g.Email == "" { resp.SkippedNoEmail++ resp.Tokens = append(resp.Tokens, tokenInfo) continue } evt := natspub.InvitationSend{ EventID: event.ID, GuestID: g.ID, TokenID: tk.ID, GuestName: g.Name, GuestEmail: g.Email, HostName: hostName, EventName: event.Name, Venue: event.Venue, EventDate: event.EventDate, Link: h.invitationLink(raw), IssuedAt: time.Now().UTC(), } if h.invitations == nil { resp.Tokens = append(resp.Tokens, tokenInfo) continue } if err := h.invitations.PublishInvitationSend(r.Context(), evt); err != nil { h.logger.Warn("publish invitation.send (bulk, continuing)", "err", err, "guest_id", g.ID) resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: "publish failed"}) resp.Tokens = append(resp.Tokens, tokenInfo) continue } resp.Queued++ tokenInfo.InvitationQueued = true resp.Tokens = append(resp.Tokens, tokenInfo) } writeJSON(w, http.StatusOK, resp) } type rotateTokenRequest struct { // SendEmail asks the notifier to re-deliver the invitation. False // means "just give me a fresh link" — typical for phone-only guests // where the host shares the new URL via SMS. SendEmail bool `json:"send_email"` } // POST /events/{id}/guests/{guest_id}/tokens/rotate — invalidate the // guest's existing invitation link and mint a fresh one. Optionally // re-publishes invitation.send so the notifier re-delivers via email. // The old URL stops working immediately. func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor) if !ok { return } guest, err := h.guests.Get(r.Context(), guestID) if err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to load guest") return } if guest.EventID != eventID { writeError(w, http.StatusNotFound, "guest not found in event") return } var req rotateTokenRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } } raw, hash, err := h.gen.Generate() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate token") return } tk, err := h.tokens.RotateForGuest(r.Context(), storage.CreateTokenParams{ GuestID: guestID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { h.logger.Error("rotate token", "err", err, "guest_id", guestID) writeError(w, http.StatusInternalServerError, "failed to rotate token") return } link := h.invitationLink(raw) invitationQueued := false if req.SendEmail { invitationQueued = h.queueInvitation(r.Context(), event, guest, tk, hostID, raw) } writeJSON(w, http.StatusOK, issueTokenResponse{ Token: raw, TokenID: tk.ID, Meta: tk, InvitationQueued: invitationQueued, InvitationLink: link, }) } type accessResponse struct { Guest *domain.Guest `json:"guest"` Event *domain.Event `json:"event"` Token *domain.Token `json:"token"` AccessLog uuid.UUID `json:"access_log_id"` // RSVP is the guest's current submission, if any. Populated so the RSVP // page can show an edit form instead of a fresh submit form when the // guest revisits their invitation link (Tier 2 Block A). // // As of the forwarded-link defence (Tier 2 Block G follow-up): this is // only populated when the current device looks like the device that // originally submitted, OR when the caller presented a valid edit nonce. // A forwarded-link recipient sees a nil RSVP + RSVPSubmittedElsewhere=true // instead, so the original guest's response stays private and unmodifiable. RSVP *domain.RSVP `json:"rsvp,omitempty"` // RSVPSubmittedElsewhere signals "there's an RSVP on file but we're // hiding it because this looks like a different device". The frontend // renders a "this invitation has already been responded to" view + // (when CanRequestEditLink) a "send me an edit link" CTA. RSVPSubmittedElsewhere bool `json:"rsvp_submitted_elsewhere,omitempty"` // CanRequestEditLink reports whether we have a way to deliver an edit // link to the guest (email or phone on file). When false the only // path is for the guest to contact the host directly. CanRequestEditLink bool `json:"can_request_edit_link,omitempty"` // Calendar holds the add-to-calendar deep-links and the .ics download // path so the frontend renders four ready-to-click buttons after a // successful RSVP. (Tier 2 Block B.) Calendar calendar.ProviderLinks `json:"calendar"` // Branding lets the RSVP page render in the host's colour scheme / // logo / cover image. Nil when the host hasn't customised yet — the // frontend falls back to defaults. (Tier 2 Block D.) Branding *domain.Branding `json:"branding,omitempty"` // CheckIn carries the per-guest QR code data when the RSVP is // "attending". The frontend renders the PNG straight into an // ; the guest screenshots it or saves the confirmation // email for door scanning on the day. (Tier 2 Block H.) CheckIn *checkInQRPayload `json:"check_in,omitempty"` } // checkInQRPayload bundles the QR JWT + the rendered PNG so the // frontend doesn't need a QR library of its own. type checkInQRPayload struct { QR string `json:"qr"` // raw JWT — what the scanner POSTs back QRImage string `json:"qr_image"` // data:image/png;base64,... ready for } // GET /access/{token} — validate token, log the access attempt, publish to NATS. func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("token") if err := auth.ValidateFormat(raw); err != nil { writeError(w, http.StatusBadRequest, "malformed token") return } tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) if err != nil { if errors.Is(err, domain.ErrTokenNotFound) { writeError(w, http.StatusNotFound, "token not found") return } writeError(w, http.StatusInternalServerError, "failed to load token") return } if err := tk.IsValid(time.Now().UTC()); err != nil { writeError(w, http.StatusGone, err.Error()) return } guest, err := h.guests.Get(r.Context(), tk.GuestID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guest") return } event, err := h.events.Get(r.Context(), guest.EventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load event") return } fingerprint := collectFingerprint(r) ip := clientIP(r) accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{ GuestID: guest.ID, TokenID: tk.ID, Fingerprint: fingerprint, IPAddress: ip, }) if err != nil { h.logger.Error("create access log", "err", err) } if h.pub != nil { go func(evt natspub.AccessAttempted) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil { h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID) } }(natspub.AccessAttempted{ EventID: event.ID, GuestID: guest.ID, TokenID: tk.ID, AccessLogID: accessLogID, Fingerprint: fingerprint, IPAddress: ip, UserAgent: r.UserAgent(), Referrer: r.Referer(), OccurredAt: time.Now().UTC(), }) } var brandingPayload *domain.Branding if h.branding != nil { // Same best-effort treatment as the RSVP lookup below — missing // branding row is the common case, not a failure. br, err := h.branding.Get(r.Context(), event.ID) switch { case err == nil: brandingPayload = br case errors.Is(err, domain.ErrBrandingNotFound): // expected when the host hasn't customised yet default: h.logger.Warn("load branding for access", "err", err, "event_id", event.ID) } } var existingRSVP *domain.RSVP if h.rsvps != nil { // Best-effort: a missing RSVP just means the guest hasn't submitted // yet. Any other error is logged but doesn't fail the access call — // we'd rather the guest see the form than a 500. rs, err := h.rsvps.GetByGuest(r.Context(), guest.ID) switch { case err == nil: existingRSVP = rs case errors.Is(err, domain.ErrRSVPNotFound): // expected first-visit case default: h.logger.Warn("load rsvp for access", "err", err, "guest_id", guest.ID) } } // Forwarded-link defence (Tier 2 Block G follow-up). If a previous // submission exists, only surface its details when the current // device looks like the one that submitted, OR when the caller // presented a valid edit nonce in ?edit=. Anything else gets // the "responded elsewhere" view — no leak, no edit. rsvpPayload := existingRSVP var rsvpSubmittedElsewhere, canRequestEditLink bool if existingRSVP != nil && !fingerprintsSimilar(existingRSVP.DeviceFingerprint, fingerprint) { bypassed := false if nonce := r.URL.Query().Get("edit"); nonce != "" && h.editNonces != nil { if ok, _ := h.editNonces.Verify(r.Context(), nonce, guest.ID); ok { bypassed = true } } if !bypassed { rsvpPayload = nil rsvpSubmittedElsewhere = true canRequestEditLink = h.editNonces != nil && ((guest.Email != nil && *guest.Email != "") || (guest.Phone != nil && *guest.Phone != "")) } } // QR code for the door (Tier 2 Block H). We only mint the JWT when // the visible RSVP is "attending" — there's no point handing a // check-in code to someone who replied no. The QR is bound to // (event_id, guest_id) and only valid through the event window. var checkInPayload *checkInQRPayload if rsvpPayload != nil && rsvpPayload.Response == domain.RSVPAttending && h.checkInQR != nil { now := time.Now().UTC() qrJWT, _, err := h.checkInQR.Issue(event.ID, guest.ID, event.EventDate, now) if err == nil { if png, err2 := renderQRPNG(qrJWT); err2 == nil { checkInPayload = &checkInQRPayload{QR: qrJWT, QRImage: png} } else { h.logger.Warn("render qr png", "err", err2, "guest_id", guest.ID) } } else { h.logger.Warn("issue qr jwt", "err", err, "guest_id", guest.ID) } } writeJSON(w, http.StatusOK, accessResponse{ Guest: guest, Event: event, Token: tk, AccessLog: accessLogID, RSVP: rsvpPayload, RSVPSubmittedElsewhere: rsvpSubmittedElsewhere, CanRequestEditLink: canRequestEditLink, Calendar: h.calendarLinks(event, raw), Branding: brandingPayload, CheckIn: checkInPayload, }) } // requestEditLinkResponse is the wire shape of POST /access/{token}/request-edit-link. type requestEditLinkResponse struct { // Channel hints at where the link went so the frontend can render // "Sent to your email" vs. "Sent by SMS" feedback. Empty when the // store/sender wasn't configured (dev environments without email // wired up — the frontend should still treat that as success). Channel string `json:"channel,omitempty"` } // POST /access/{token}/request-edit-link — public, token-scoped. When a // guest opens their invitation from an unfamiliar device the regular // access response hides their RSVP. This endpoint lets them prove email // or phone ownership instead: we mint a short-lived edit nonce and // deliver it to the address on file. // // Rate limit lives on the route registration (3 per hour per token). // The endpoint itself stays generous about the response — we never // reveal whether a token has an RSVP attached, just whether the request // itself was acceptable. func (h *tokenHandler) requestEditLink(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("token") if err := auth.ValidateFormat(raw); err != nil { writeError(w, http.StatusBadRequest, "malformed token") return } tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) if err != nil { if errors.Is(err, domain.ErrTokenNotFound) { writeError(w, http.StatusNotFound, "token not found") return } writeError(w, http.StatusInternalServerError, "failed to load token") return } if err := tk.IsValid(time.Now().UTC()); err != nil { writeError(w, http.StatusGone, err.Error()) return } guest, err := h.guests.Get(r.Context(), tk.GuestID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guest") return } if h.editNonces == nil { // Redis isn't wired up; the feature is disabled. Tell the caller // honestly rather than pretending we sent something. writeError(w, http.StatusServiceUnavailable, "edit-link delivery isn't configured for this environment") return } // Only attempt delivery when there's somewhere to deliver to. Without // email or phone on file the request is a 404 — we don't have a // secure channel for the nonce. hasEmail := guest.Email != nil && *guest.Email != "" if !hasEmail { // Phone-only delivery is a future enhancement (Twilio path is // wired for the broader notifier; not for synchronous edit links // yet). For now treat as no-channel. writeError(w, http.StatusNotFound, "no email on file for this guest") return } nonce, err := h.editNonces.Mint(r.Context(), guest.ID) if err != nil { h.logger.Error("mint edit nonce", "err", err, "guest_id", guest.ID) writeError(w, http.StatusInternalServerError, "failed to issue edit link") return } // Resolve the event name for the email. Best-effort: if the lookup // fails we still send a link, just with a generic fallback. eventName := "your event" if event, err := h.events.Get(r.Context(), guest.EventID); err == nil { eventName = event.Name } link := h.editLink(raw, nonce) if h.emails != nil { if err := h.emails.SendRSVPEditLink(r.Context(), *guest.Email, guest.Name, eventName, link); err != nil { h.logger.Warn("send rsvp edit link", "err", err, "guest_id", guest.ID) // Don't 500 — the nonce already exists in Redis, and we've // logged the link in the dev stub. A 202-ish behaviour: // "accepted; delivery might be best-effort". } } writeJSON(w, http.StatusAccepted, requestEditLinkResponse{Channel: "email"}) } func (h *tokenHandler) editLink(rawToken, nonce string) string { base := h.publicBaseURL if base == "" { base = "http://localhost:3000" } return base + "/rsvp/" + rawToken + "?edit=" + nonce } // calendarLinks renders the three provider URLs + .ics path for the event. // The raw access token is embedded in the .ics path so the download endpoint // stays public (no auth) while still scoped to a single invitation. func (h *tokenHandler) calendarLinks(event *domain.Event, rawToken string) calendar.ProviderLinks { return calendar.BuildLinks(eventForCalendar(event), h.calendarICSURL(rawToken)) } func (h *tokenHandler) calendarICSURL(rawToken string) string { base := h.publicBaseURL if base == "" { base = "http://localhost:8080" } return base + "/access/" + rawToken + "/calendar.ics" } func eventForCalendar(e *domain.Event) calendar.Event { // We deliberately don't surface event Settings into the calendar entry — // settings holds host-private config; the guest only needs the // human-facing fields. return calendar.Event{ ID: e.ID, Name: e.Name, Venue: e.Venue, StartsAt: e.EventDate, } } // GET /access/{token}/calendar.ics — public, token-scoped download. Same // token-validity rules as /access/{token}, but the response is the .ics // file rather than JSON. func (h *tokenHandler) calendar(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("token") if err := auth.ValidateFormat(raw); err != nil { writeError(w, http.StatusBadRequest, "malformed token") return } tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) if err != nil { if errors.Is(err, domain.ErrTokenNotFound) { writeError(w, http.StatusNotFound, "token not found") return } writeError(w, http.StatusInternalServerError, "failed to load token") return } if err := tk.IsValid(time.Now().UTC()); err != nil { writeError(w, http.StatusGone, err.Error()) return } guest, err := h.guests.Get(r.Context(), tk.GuestID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guest") return } event, err := h.events.Get(r.Context(), guest.EventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load event") return } ics := calendar.BuildICS(eventForCalendar(event), time.Now().UTC()) w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+calendar.FileName(event.Name)+`"`) w.Header().Set("Cache-Control", "private, no-store") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(ics)) } func collectFingerprint(r *http.Request) map[string]any { fp := map[string]any{ "user_agent": r.UserAgent(), "accept_language": r.Header.Get("Accept-Language"), "accept_encoding": r.Header.Get("Accept-Encoding"), } if v := r.Header.Get("Sec-CH-UA-Platform"); v != "" { fp["platform"] = v } if v := r.Header.Get("X-Device-Fingerprint"); v != "" { fp["client_fingerprint"] = v } return fp } func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i > 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } host := r.RemoteAddr if i := strings.LastIndexByte(host, ':'); i > 0 { host = host[:i] } return host }