refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user