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"})
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/services"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Entry Handlers
|
||||
func (h *Handler) ListEntries(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
|
||||
filter := db.EntryFilter{
|
||||
View: q.Get("view"),
|
||||
FeedID: q.Get("feed_id"),
|
||||
CategoryID: q.Get("category_id"),
|
||||
Search: q.Get("search"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
entries, total, err := h.entryService.ListEntries(r.Context(), filter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []models.FeedEntry{}
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
}
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
// PatchEntry handles atomic partial updates to an entry and returns the updated entry + transition diff
|
||||
func (h *Handler) PatchEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req models.PatchEntryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, diff, err := h.entryService.PatchEntry(r.Context(), id, req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"entry": updated,
|
||||
"diff": diff,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEntryRead(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body models.UpdateEntryReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{
|
||||
IsRead: &body.IsRead,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": updated.IsRead})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEntryStar(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body models.UpdateEntryStarRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{
|
||||
IsStarred: &body.IsStarred,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": updated.IsStarred})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
var body models.MarkAllReadRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
if err := h.entryService.MarkAllRead(r.Context(), body.FeedID, body.CategoryID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "marked all as read"})
|
||||
}
|
||||
|
||||
// ScrapeEntry triggers readability full text extraction (immediate response for UI, async if ?async=true)
|
||||
func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
}
|
||||
|
||||
idempotencyKey := services.GenerateIdempotencyKey("readability_scrape", id, "manual", entry.CreatedAt.Format("20060102150405"))
|
||||
job, err := h.jobService.Enqueue(r.Context(), "readability_scrape", map[string]interface{}{
|
||||
"entry_id": id,
|
||||
"url": entry.URL,
|
||||
}, idempotencyKey)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("async") == "true" {
|
||||
h.writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
"status": job.Status,
|
||||
"message": "Scrape task queued asynchronously",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if h.jobEngine == nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "job engine not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "readability_scrape", job.Payload)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if patch != nil {
|
||||
updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch)
|
||||
if patchErr != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, patchErr.Error())
|
||||
return
|
||||
}
|
||||
_ = h.db.UpdateJob(models.Job{
|
||||
ID: job.ID,
|
||||
Status: "completed",
|
||||
Progress: 100,
|
||||
Result: result,
|
||||
})
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
return
|
||||
}
|
||||
|
||||
updated, _ := h.entryService.GetEntry(r.Context(), id)
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// GenerateAISummary generates an AI summary (immediate response for UI, async if ?async=true)
|
||||
func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
idempotencyKey := services.GenerateIdempotencyKey("ai_summary", id, "manual", entry.CreatedAt.Format("20060102150405"))
|
||||
job, err := h.jobService.Enqueue(r.Context(), "ai_summary", map[string]interface{}{
|
||||
"entry_id": id,
|
||||
"prompt": body.Prompt,
|
||||
}, idempotencyKey)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("async") == "true" {
|
||||
h.writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
"status": job.Status,
|
||||
"message": "AI summary task queued asynchronously",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if h.jobEngine == nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "job engine not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "ai_summary", job.Payload)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("AI summary generation failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if patch != nil {
|
||||
updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch)
|
||||
if patchErr != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, patchErr.Error())
|
||||
return
|
||||
}
|
||||
_ = h.db.UpdateJob(models.Job{
|
||||
ID: job.ID,
|
||||
Status: "completed",
|
||||
Progress: 100,
|
||||
Result: result,
|
||||
})
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
return
|
||||
}
|
||||
|
||||
updated, _ := h.entryService.GetEntry(r.Context(), id)
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
shareUUID, err := h.entryService.ToggleShare(r.Context(), id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
entry, _ := h.entryService.GetEntry(r.Context(), id)
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"shared": shareUUID != nil,
|
||||
"share_uuid": shareUUID,
|
||||
"entry": entry,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ListEntryVariants(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
variants, err := h.db.ListVariantsForEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, variants)
|
||||
}
|
||||
|
||||
func (h *Handler) GetVariantBlobStream(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
variant, err := h.db.GetVariant(id, key)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "variant not found")
|
||||
return
|
||||
}
|
||||
if variant.BlobPath == "" {
|
||||
h.writeError(w, http.StatusNotFound, "variant has no binary media blob")
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, variant.BlobPath)
|
||||
}
|
||||
|
||||
// ViewSharedEntry renders a styled standalone HTML page for an explicitly shared entry (strictly checked)
|
||||
func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
uuidStr := chi.URLParam(r, "uuid")
|
||||
entry, err := h.entryService.GetSharedEntry(r.Context(), uuidStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Shared article not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Render standalone styled HTML
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - RSSPipe</title>
|
||||
<style>
|
||||
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --muted: #94a3b8; --accent: #38bdf8; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #f8fafc; --card: #ffffff; --text: #0f172a; --muted: #64748b; --accent: #0284c7; }
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; margin: 0; padding: 2rem 1rem; display: flex; justify-content: center; }
|
||||
.container { max-width: 760px; width: 100%%; background: var(--card); border-radius: 12px; padding: 2.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
||||
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; border-bottom: 1px solid rgba(148, 163, 184, 0.2); padding-bottom: 1rem; }
|
||||
h1 { margin-top: 0; font-size: 2rem; line-height: 1.3; }
|
||||
h1 a { color: inherit; text-decoration: none; }
|
||||
h1 a:hover { color: var(--accent); }
|
||||
.content img { max-width: 100%%; height: auto; border-radius: 8px; }
|
||||
.content pre { background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
||||
.footer { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(148, 163, 184, 0.2); font-size: 0.85rem; color: var(--muted); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="meta">
|
||||
<span><strong>%s</strong></span>
|
||||
<span>•</span>
|
||||
<span>%s</span>
|
||||
%s
|
||||
</div>
|
||||
<h1><a href="%s" target="_blank" rel="noopener noreferrer">%s ↗</a></h1>
|
||||
<div class="content">
|
||||
%s
|
||||
</div>
|
||||
<div class="footer">
|
||||
Shared via <a href="/" style="color: var(--accent); text-decoration: none;">RSSPipe Information Gateway</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
entry.Title,
|
||||
entry.FeedTitle,
|
||||
entry.PublishedAt.Format("Jan 02, 2006 15:04"),
|
||||
func() string {
|
||||
if entry.Author != "" {
|
||||
return "<span>•</span><span>By " + entry.Author + "</span>"
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
entry.URL,
|
||||
entry.Title,
|
||||
entry.Content,
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Category Handlers
|
||||
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := h.feedService.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if cats == nil {
|
||||
cats = []models.Category{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, cats)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "valid category title is required")
|
||||
return
|
||||
}
|
||||
|
||||
cat, err := h.feedService.CreateCategory(r.Context(), strings.TrimSpace(body.Title), body.ParentID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, cat)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.feedService.DeleteCategory(r.Context(), id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
|
||||
}
|
||||
|
||||
// Feed Handlers
|
||||
func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.feedService.ListFeeds(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if feeds == nil {
|
||||
feeds = []models.Feed{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, feeds)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateFeedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.feedService.CreateFeed(r.Context(), req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, feed)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req models.UpdateFeedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.feedService.UpdateFeed(r.Context(), id, req)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, feed)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.feedService.DeleteFeed(r.Context(), id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
f, err := h.feedService.RefreshFeed(r.Context(), id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, f)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
h.feedService.RefreshAllFeeds(r.Context())
|
||||
h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.feedService.GetStats(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// OPML Handlers
|
||||
func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "file field is required")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
createdCount, totalParsed, err := h.feedService.ImportOPML(r.Context(), data)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"imported_count": createdCount,
|
||||
"total_parsed": totalParsed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
|
||||
xmlData, err := h.feedService.ExportOPML(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="rsspipe-subscriptions.opml"`))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(xmlData)
|
||||
}
|
||||
+75
-1109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
|
||||
filter := models.JobFilter{
|
||||
Type: q.Get("type"),
|
||||
Status: q.Get("status"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
jobs, err := h.jobService.ListJobs(r.Context(), filter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if jobs == nil {
|
||||
jobs = []models.Job{}
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"jobs": jobs,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
job, err := h.jobService.GetJob(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
h.writeError(w, http.StatusNotFound, "job not found")
|
||||
return
|
||||
}
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (h *Handler) CancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.jobService.CancelJob(r.Context(), id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "job cancelled"})
|
||||
}
|
||||
|
||||
func (h *Handler) RetryJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.jobService.RetryJob(r.Context(), id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "job queued for retry"})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Rules API Handlers
|
||||
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
rulesList, err := h.db.ListRules()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if rulesList == nil {
|
||||
rulesList = []models.Rule{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, rulesList)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid rule payload")
|
||||
return
|
||||
}
|
||||
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
||||
return
|
||||
}
|
||||
if body.Trigger == "" {
|
||||
body.Trigger = "on_update"
|
||||
}
|
||||
if body.MatchMode == "" {
|
||||
body.MatchMode = "all"
|
||||
}
|
||||
|
||||
if err := rules.ValidateRuleSafety(body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body models.Rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid rule payload")
|
||||
return
|
||||
}
|
||||
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := rules.ValidateRuleSafety(body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, rule)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.db.DeleteRule(id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
rule, err := h.db.GetRule(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
|
||||
entries, _, err := h.db.ListEntries(db.EntryFilter{Limit: 200})
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
eval := rules.NewEvaluator()
|
||||
matchedCount := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if eval.Evaluate(*rule, entry) {
|
||||
matchedCount++
|
||||
if h.bus != nil {
|
||||
h.bus.Publish(r.Context(), events.Event{
|
||||
Type: events.EventManualTrigger,
|
||||
Entry: entry,
|
||||
Depth: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "rule applied to matching articles",
|
||||
"matched_count": matchedCount,
|
||||
"queued_jobs": matchedCount * len(rule.Actions),
|
||||
})
|
||||
}
|
||||
+15
-4
@@ -20,10 +20,10 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// CORS configuration for local development & PWA
|
||||
// CORS configuration: Restricted to localhost dev ports and same-origin
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080", "*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
@@ -43,6 +43,10 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authService.Middleware)
|
||||
|
||||
// Real-time Event Stream (K8s Watch pattern via SSE)
|
||||
r.Get("/events/stream", h.StreamEvents)
|
||||
|
||||
// Auth & Profile
|
||||
r.Get("/auth/me", h.GetMe)
|
||||
r.Put("/auth/password", h.UpdatePassword)
|
||||
r.Get("/user/preferences", h.GetUserPreferences)
|
||||
@@ -65,15 +69,22 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
// Entries
|
||||
r.Get("/entries", h.ListEntries)
|
||||
r.Get("/entries/{id}", h.GetEntry)
|
||||
r.Patch("/entries/{id}", h.PatchEntry)
|
||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||
r.Post("/entries/{id}/share", h.ToggleShareEntry)
|
||||
r.Post("/entries/{id}/ai-summary", h.GenerateAISummary)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
r.Get("/entries/{id}/variants", h.ListEntryVariants)
|
||||
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
|
||||
|
||||
// Task Queue / Background Jobs
|
||||
r.Get("/jobs", h.ListJobs)
|
||||
r.Get("/jobs/{id}", h.GetJob)
|
||||
r.Post("/jobs/{id}/cancel", h.CancelJob)
|
||||
r.Post("/jobs/{id}/retry", h.RetryJob)
|
||||
|
||||
// Rules
|
||||
r.Get("/rules", h.ListRules)
|
||||
r.Post("/rules", h.CreateRule)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
// User Preferences Handlers
|
||||
func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||
if !ok {
|
||||
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
prefs, err := h.settingsService.GetPreferences(r.Context(), claims.Username)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUserPreferences(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 newPrefs map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.settingsService.UpdatePreferences(r.Context(), claims.Username, newPrefs)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// AI Settings Handlers
|
||||
func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := h.settingsService.GetAISettings(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body models.AISettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.settingsService.UpdateAISettings(r.Context(), body)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var body models.AISettings
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
cfg := ai.Config{
|
||||
Enabled: body.Enabled,
|
||||
Provider: body.Provider,
|
||||
BaseURL: body.BaseURL,
|
||||
APIKey: body.APIKey,
|
||||
Model: body.Model,
|
||||
}
|
||||
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = "openai"
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testEntry := models.FeedEntry{
|
||||
Title: "RSSPipe Connection Test",
|
||||
Content: "This is a quick test to verify connection to your AI provider.",
|
||||
}
|
||||
|
||||
res, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, "Respond with 'Connection successful' only.", testEntry)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"latency_ms": latency,
|
||||
"model": cfg.Model,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": strings.TrimSpace(res),
|
||||
"latency_ms": latency,
|
||||
"model": cfg.Model,
|
||||
})
|
||||
}
|
||||
|
||||
// Search History Handlers
|
||||
func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.db.GetSearchHistory(q, limit)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []models.SearchHistoryItem{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Keyword) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "keyword is required")
|
||||
return
|
||||
}
|
||||
if err := h.db.RecordSearch(body.Keyword); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "recorded"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||
kw := r.URL.Query().Get("keyword")
|
||||
if kw != "" {
|
||||
_ = h.db.DeleteSearchHistory(kw)
|
||||
} else {
|
||||
_ = h.db.ClearSearchHistory()
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SSEBroadcaster struct {
|
||||
mu sync.RWMutex
|
||||
clients map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func NewSSEBroadcaster() *SSEBroadcaster {
|
||||
return &SSEBroadcaster{
|
||||
clients: make(map[chan []byte]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SSEBroadcaster) Subscribe() chan []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
ch := make(chan []byte, 32)
|
||||
b.clients[ch] = struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *SSEBroadcaster) Unsubscribe(ch chan []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if _, ok := b.clients[ch]; ok {
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SSEBroadcaster) Broadcast(event string, data interface{}) {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, string(payload))
|
||||
msgBytes := []byte(msg)
|
||||
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
for ch := range b.clients {
|
||||
select {
|
||||
case ch <- msgBytes:
|
||||
default:
|
||||
// Non-blocking drop if client buffer is full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamEvents handles GET /api/events/stream
|
||||
func (h *Handler) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Send initial connection packet
|
||||
initialMsg := fmt.Sprintf("event: connected\ndata: {\"status\":\"connected\",\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
|
||||
_, _ = w.Write([]byte(initialMsg))
|
||||
flusher.Flush()
|
||||
|
||||
clientCh := h.broadcaster.Subscribe()
|
||||
defer h.broadcaster.Unsubscribe(clientCh)
|
||||
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
pingMsg := fmt.Sprintf("event: ping\ndata: {\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
|
||||
if _, err := w.Write([]byte(pingMsg)); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case msg, ok := <-clientCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user