feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.
This commit is contained in:
+161
-1
@@ -17,6 +17,7 @@ import (
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -25,14 +26,16 @@ type Handler struct {
|
||||
auth *auth.AuthService
|
||||
crawler *crawler.Crawler
|
||||
parser *parser.Parser
|
||||
pool *worker.Pool
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler) *Handler {
|
||||
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
|
||||
return &Handler{
|
||||
db: database,
|
||||
auth: authService,
|
||||
crawler: cr,
|
||||
parser: parser.NewParser(),
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,3 +550,160 @@ func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// GetEntry returns a single entry by ID with its loaded variants
|
||||
func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.db.GetEntry(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)
|
||||
}
|
||||
|
||||
// ListEntryVariants returns all variants for a given entry ID
|
||||
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)
|
||||
}
|
||||
|
||||
// GetVariantBlobStream streams the audio/binary file for a variant with HTTP 206 Partial Content support
|
||||
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)
|
||||
}
|
||||
|
||||
// ScrapeEntry manually triggers readability full text scraping and saves to entry_variants
|
||||
func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.db.GetEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
}
|
||||
|
||||
if h.pool == nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "worker pool not configured")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.pool.ExecuteDirect(r.Context(), "readability_scrape", *entry, nil)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if res != nil && res.ResultVariant != nil {
|
||||
_ = h.db.SetVariant(*res.ResultVariant)
|
||||
}
|
||||
|
||||
// Reload entry with newly saved variant
|
||||
updatedEntry, err := h.db.GetEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, updatedEntry)
|
||||
}
|
||||
|
||||
// Rules API Handlers
|
||||
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := h.db.ListRules()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if rules == nil {
|
||||
rules = []models.Rule{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, rules)
|
||||
}
|
||||
|
||||
type CreateRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Trigger string `json:"trigger"`
|
||||
MatchMode string `json:"match_mode"`
|
||||
Conditions []models.RuleCondition `json:"conditions"`
|
||||
Actions []models.RuleAction `json:"actions"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body CreateRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Name) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Trigger) == "" {
|
||||
body.Trigger = "on_post_created"
|
||||
}
|
||||
if strings.TrimSpace(body.MatchMode) == "" {
|
||||
body.MatchMode = "all"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type UpdateRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Trigger string `json:"trigger"`
|
||||
MatchMode string `json:"match_mode"`
|
||||
Conditions []models.RuleCondition `json:"conditions"`
|
||||
Actions []models.RuleAction `json:"actions"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body UpdateRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -62,9 +62,19 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
|
||||
// Entries
|
||||
r.Get("/entries", h.ListEntries)
|
||||
r.Get("/entries/{id}", h.GetEntry)
|
||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
|
||||
r.Get("/entries/{id}/variants", h.ListEntryVariants)
|
||||
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
|
||||
|
||||
// Rules
|
||||
r.Get("/rules", h.ListRules)
|
||||
r.Post("/rules", h.CreateRule)
|
||||
r.Put("/rules/{id}", h.UpdateRule)
|
||||
r.Delete("/rules/{id}", h.DeleteRule)
|
||||
|
||||
// OPML
|
||||
r.Post("/opml/import", h.ImportOPML)
|
||||
|
||||
Reference in New Issue
Block a user