package api import ( "context" "errors" "log/slog" "net/http" "strings" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" "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 tokenHandler struct { logger *slog.Logger guests *storage.GuestRepo tokens *storage.TokenRepo events *storage.EventRepo accessLogs *storage.AccessLogRepo gen *auth.Generator ttl time.Duration pub accessPublisher } type issueTokenResponse struct { Token string `json:"token"` TokenID uuid.UUID `json:"token_id"` Meta *domain.Token `json:"meta"` } // POST /events/{id}/guests/{guest_id}/tokens — issue a token for the guest. func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) { eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") 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 } writeJSON(w, http.StatusCreated, issueTokenResponse{ Token: raw, TokenID: tk.ID, Meta: tk, }) } 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"` } // 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) } 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(), }) writeJSON(w, http.StatusOK, accessResponse{ Guest: guest, Event: event, Token: tk, AccessLog: accessLogID, }) } 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 }