Files
rsspipe/internal/api/handlers.go
T

531 lines
15 KiB
Go

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"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *db.DB
auth *auth.AuthService
crawler *crawler.Crawler
parser *parser.Parser
}
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler) *Handler {
return &Handler{
db: database,
auth: authService,
crawler: cr,
parser: parser.NewParser(),
}
}
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)
}
// 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 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/<uuid>.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(`<!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>`,
e.Title,
e.FeedTitle,
e.PublishedAt.Format("Jan 02, 2006 15:04"),
func() string {
if e.Author != "" {
return "<span>•</span><span>By " + e.Author + "</span>"
}
return ""
}(),
e.URL,
e.Title,
e.Content,
)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
type sqlNullHelper struct {
String string
}