package api import ( "encoding/json" "net/http" "strings" "sync" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" ) // RateLimiter tracks login attempts per IP to prevent brute-force attacks type RateLimiter struct { mu sync.Mutex attempts map[string][]time.Time limit int window time.Duration } func NewRateLimiter(limit int, window time.Duration) *RateLimiter { return &RateLimiter{ attempts: make(map[string][]time.Time), limit: limit, window: window, } } func (rl *RateLimiter) Allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() cutoff := now.Add(-rl.window) // Filter out expired timestamps valid := make([]time.Time, 0) for _, t := range rl.attempts[key] { if t.After(cutoff) { valid = append(valid, t) } } if len(valid) >= rl.limit { rl.attempts[key] = valid return false } valid = append(valid, now) rl.attempts[key] = valid return true } func getClientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") return strings.TrimSpace(parts[0]) } if xrip := r.Header.Get("X-Real-IP"); xrip != "" { return strings.TrimSpace(xrip) } return r.RemoteAddr } // Auth Handlers func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { ip := getClientIP(r) if !h.loginLimiter.Allow(ip) { h.writeError(w, http.StatusTooManyRequests, "too many login attempts, please try again in a minute") return } var req models.LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request body") return } token, err := h.auth.Authenticate(req.Username, req.Password) if err != nil { h.writeError(w, http.StatusUnauthorized, "invalid username or password") return } http.SetCookie(w, &http.Cookie{ Name: "rsspipe_token", Value: token, Path: "/", Expires: time.Now().Add(30 * 24 * time.Hour), HttpOnly: true, SameSite: http.SameSiteLaxMode, }) user, _ := h.db.GetUserByUsername(req.Username) h.writeJSON(w, http.StatusOK, models.LoginResponse{ Token: token, User: *user, }) } func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "rsspipe_token", Value: "", Path: "/", Expires: time.Unix(0, 0), HttpOnly: true, }) h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) } func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims) if !ok { h.writeError(w, http.StatusUnauthorized, "unauthorized") return } user, err := h.db.GetUserByUsername(claims.Username) if err != nil { h.writeError(w, http.StatusNotFound, "user not found") return } h.writeJSON(w, http.StatusOK, user) } func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims) if !ok { h.writeError(w, http.StatusUnauthorized, "unauthorized") return } var req models.ChangePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request payload") return } if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil { h.writeError(w, http.StatusBadRequest, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"}) }