package api import ( "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth" "git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler" "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" "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/rules" "git.pengzhan.dev/haopengzhan/rsspipe/internal/worker" "github.com/go-chi/chi/v5" ) type Handler struct { db *db.DB auth *auth.AuthService crawler *crawler.Crawler parser *parser.Parser pool *worker.Pool } 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, } } func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) { h.writeJSON(w, status, map[string]string{"error": msg}) } // Auth Handlers func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { 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"}) } // Stats Handler func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { stats, err := h.db.GetStats() if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, stats) } // Category Handlers func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { cats, err := h.db.ListCategories() 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.db.CreateCategory(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.db.DeleteCategory(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.db.ListFeeds() 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 } req.FeedURL = strings.TrimSpace(req.FeedURL) // Check if already subscribed if existing, _ := h.db.GetFeedByURL(req.FeedURL); existing != nil { h.writeError(w, http.StatusConflict, "feed is already subscribed") return } // Fetch to test and discover title/site URL res, err := h.parser.FetchFeed(r.Context(), req.FeedURL, "", "") if err != nil { h.writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to fetch feed: %v", err)) return } title := res.Title if req.Title != nil && strings.TrimSpace(*req.Title) != "" { title = strings.TrimSpace(*req.Title) } if title == "" { title = req.FeedURL } feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, req.CategoryID) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } // Insert parsed entries if len(res.Entries) > 0 { _, _ = h.db.UpsertFeedEntries(feed.ID, res.Entries) _ = h.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "") } // Retrieve updated feed with counts fullFeed, err := h.db.GetFeed(feed.ID) if err != nil { h.writeJSON(w, http.StatusCreated, feed) return } h.writeJSON(w, http.StatusCreated, fullFeed) } 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 } if err := h.db.UpdateFeed(id, req); err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } fullFeed, err := h.db.GetFeed(id) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, fullFeed) } func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := h.db.DeleteFeed(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") if err := h.crawler.RefreshSingle(r.Context(), id); err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } f, _ := h.db.GetFeed(id) h.writeJSON(w, http.StatusOK, f) } func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) { go h.crawler.RefreshAll(context.Background()) h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"}) } // 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.db.ListEntries(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) 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 } if err := h.db.UpdateEntryRead(id, body.IsRead); err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": body.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 } if err := h.db.UpdateEntryStarred(id, body.IsStarred); err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": body.IsStarred}) } func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) { var body models.MarkAllReadRequest _ = json.NewDecoder(r.Body).Decode(&body) if err := h.db.MarkAllRead(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"}) } // OPML Handlers func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { // 10MB max 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 } importedFeeds, err := opml.ParseOPML(data) if err != nil { h.writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid OPML XML: %v", err)) return } categoryCache := make(map[string]string) // title -> id cats, _ := h.db.ListCategories() for _, c := range cats { categoryCache[c.Title] = c.ID } createdCount := 0 for _, item := range importedFeeds { if item.FeedURL == "" { continue } if existing, _ := h.db.GetFeedByURL(item.FeedURL); existing != nil { continue } var catID *string if item.CategoryTitle != "" { if cid, ok := categoryCache[item.CategoryTitle]; ok { catID = &cid } else { newCat, err := h.db.CreateCategory(item.CategoryTitle, nil) if err == nil { categoryCache[item.CategoryTitle] = newCat.ID catID = &newCat.ID } } } title := item.Title if title == "" { title = item.FeedURL } _, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, catID) if err == nil { createdCount++ } } // Trigger background crawler for newly imported feeds go h.crawler.RefreshAll(context.Background()) h.writeJSON(w, http.StatusOK, map[string]interface{}{ "imported_count": createdCount, "total_parsed": len(importedFeeds), }) } func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) { feeds, err := h.db.ListFeeds() if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } cats, err := h.db.ListCategories() if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } xmlData, err := opml.GenerateOPML(feeds, cats) 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", `attachment; filename="rsspipe-subscriptions.opml"`) w.WriteHeader(http.StatusOK) _, _ = w.Write(xmlData) } // Public Share handler (/share/.html) func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) { uuidStr := chi.URLParam(r, "uuid") uuidStr = strings.TrimSuffix(uuidStr, ".html") var e models.FeedEntry var fTitle, fSiteURL, author, rawContent, metaStr, shareUUID sql.NullString query := ` SELECT e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url, e.guid, e.url, e.title, e.author, e.content, e.raw_content, e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at FROM feed_entries e JOIN feeds f ON f.id = e.feed_id WHERE e.share_uuid = ? OR e.id = ? ` err := h.db.QueryRow(query, uuidStr, uuidStr).Scan( &e.ID, &e.FeedID, &fTitle, &fSiteURL, &e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent, &e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt, ) if err != nil { http.Error(w, "Shared article not found", http.StatusNotFound) return } if fTitle.Valid { e.FeedTitle = fTitle.String } if fSiteURL.Valid { e.FeedSiteURL = fSiteURL.String } if author.Valid { e.Author = author.String } // Render standalone styled HTML html := fmt.Sprintf(` %s - RSSPipe
%s • %s %s

%s ↗

%s
`, e.Title, e.FeedTitle, e.PublishedAt.Format("Jan 02, 2006 15:04"), func() string { if e.Author != "" { return "•By " + e.Author + "" } return "" }(), e.URL, e.Title, e.Content, ) 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 err := rules.ValidateRuleSafety(models.Rule{ Trigger: body.Trigger, Conditions: body.Conditions, Actions: body.Actions, }); 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) } 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 } if err := rules.ValidateRuleSafety(models.Rule{ Trigger: body.Trigger, Conditions: body.Conditions, Actions: body.Actions, }); 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"}) }