package handler import ( "context" "encoding/json" "errors" "net" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/shorturl" "go.uber.org/zap" ) type ShortURLHandler struct { repo *repository.ShortURLRepo queue *shorturl.Queue logger *zap.Logger } func NewShortURLHandler(repo *repository.ShortURLRepo, queue *shorturl.Queue, logger *zap.Logger) *ShortURLHandler { return &ShortURLHandler{repo: repo, queue: queue, logger: logger} } type createRequest struct { URL string `json:"url"` } type createResponse struct { Code string `json:"code"` } type resolveResponse struct { URL string `json:"url"` } // Create handles POST /api/v1/share. Validates the path, allocates a code in // Redis, enqueues the row for background insertion into Postgres, and returns // the code immediately. The path persisted is exactly what the client sent, // preserving the `?hl=` fragment. func (h *ShortURLHandler) Create(w http.ResponseWriter, r *http.Request) { var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid request body") return } if err := shorturl.ValidatePath(req.URL); err != nil { response.Error(w, http.StatusBadRequest, err.Error()) return } if h.queue == nil { response.Error(w, http.StatusServiceUnavailable, "url shortener unavailable") return } ctx := r.Context() code, err := h.queue.AllocateCode(ctx) if err != nil { h.logger.Warn("allocate code", zap.Error(err)) response.Error(w, http.StatusServiceUnavailable, "failed to allocate short code") return } payload := shorturl.PendingPayload{ URL: req.URL, CreatorIP: clientIP(r), CreatorUA: truncateTo(r.UserAgent(), 1024), CreatorReferer: truncateTo(r.Referer(), 1024), CreatedAt: time.Now().UTC(), } if err := h.queue.MarkPending(ctx, code, payload); err != nil { h.logger.Warn("mark pending", zap.String("code", code), zap.Error(err)) response.Error(w, http.StatusServiceUnavailable, "failed to queue request") return } if err := h.queue.EnqueueCreate(ctx, code); err != nil { h.logger.Warn("enqueue create", zap.String("code", code), zap.Error(err)) response.Error(w, http.StatusServiceUnavailable, "failed to queue request") return } response.JSON(w, http.StatusOK, createResponse{Code: code}) } // Resolve handles GET /api/v1/share/{code}. Resolves through the cache chain // (hot cache → pending → Postgres) and enqueues a click event. Click events // are flushed asynchronously by the click-worker. func (h *ShortURLHandler) Resolve(w http.ResponseWriter, r *http.Request) { code := strings.ToLower(chi.URLParam(r, "code")) if !shorturl.ValidCode(code) { response.Error(w, http.StatusBadRequest, "invalid code") return } if h.queue == nil { response.Error(w, http.StatusServiceUnavailable, "url shortener unavailable") return } ctx := r.Context() now := time.Now().UTC() // 1. Hot cache. if longURL, ok := h.queue.LookupCached(ctx, code); ok { h.enqueueClick(ctx, code, r, now) response.JSON(w, http.StatusOK, resolveResponse{URL: longURL}) return } // 2. Pending (still in flight to Postgres). if p, ok := h.queue.LookupPending(ctx, code); ok { h.enqueueClick(ctx, code, r, now) response.JSON(w, http.StatusOK, resolveResponse{URL: p.URL}) return } // 3. Postgres. row, err := h.repo.GetByCode(ctx, code) if err != nil { if errors.Is(err, pgx.ErrNoRows) { response.Error(w, http.StatusNotFound, "short link not found") return } h.logger.Error("resolve postgres", zap.String("code", code), zap.Error(err)) response.Error(w, http.StatusInternalServerError, "failed to resolve short link") return } if row.Disabled { response.Error(w, http.StatusGone, "short link disabled") return } if row.ExpiresAt != nil && row.ExpiresAt.Before(now) { response.Error(w, http.StatusGone, "short link expired") return } // Repopulate hot cache and enqueue the click. _ = h.queue.MarkCached(ctx, code, row.LongURL) h.enqueueClick(ctx, code, r, now) response.JSON(w, http.StatusOK, resolveResponse{URL: row.LongURL}) } func (h *ShortURLHandler) enqueueClick(ctx context.Context, code string, r *http.Request, ts time.Time) { ua := r.UserAgent() ev := shorturl.ClickEvent{ Code: code, IP: clientIP(r), UA: truncateTo(ua, 1024), Referer: truncateTo(r.Referer(), 1024), IsBot: shorturl.IsBot(ua), ClickedAt: ts, } if err := h.queue.EnqueueClick(ctx, ev); err != nil { h.logger.Warn("enqueue click", zap.String("code", code), zap.Error(err)) } } // clientIP extracts the best-effort client IP. RemoteAddr is "host:port"; we // strip the port. The chi RealIP middleware already overwrites RemoteAddr // from X-Forwarded-For / X-Real-IP when those are present. func clientIP(r *http.Request) string { addr := r.RemoteAddr if addr == "" { return "" } host, _, err := net.SplitHostPort(addr) if err != nil { return addr } return host } func truncateTo(s string, n int) string { if len(s) <= n { return s } return s[:n] }