feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/haopengzhan/rsspipe/internal/auth"
|
||||
"github.com/haopengzhan/rsspipe/internal/crawler"
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/haopengzhan/rsspipe/internal/opml"
|
||||
"github.com/haopengzhan/rsspipe/internal/parser"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/haopengzhan/rsspipe/internal/auth"
|
||||
)
|
||||
|
||||
func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// CORS configuration for local development & PWA
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080", "*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Public share route (/share/<uuid>.html)
|
||||
r.Get("/share/{uuid}", h.ViewSharedEntry)
|
||||
|
||||
// API Routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Public auth endpoints
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
|
||||
// Protected endpoints
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authService.Middleware)
|
||||
|
||||
r.Get("/auth/me", h.GetMe)
|
||||
r.Get("/stats", h.GetStats)
|
||||
|
||||
// Categories
|
||||
r.Get("/categories", h.ListCategories)
|
||||
r.Post("/categories", h.CreateCategory)
|
||||
r.Delete("/categories/{id}", h.DeleteCategory)
|
||||
|
||||
// Feeds
|
||||
r.Get("/feeds", h.ListFeeds)
|
||||
r.Post("/feeds", h.CreateFeed)
|
||||
r.Put("/feeds/{id}", h.UpdateFeed)
|
||||
r.Delete("/feeds/{id}", h.DeleteFeed)
|
||||
r.Post("/feeds/{id}/refresh", h.RefreshFeed)
|
||||
r.Post("/feeds/refresh-all", h.RefreshAllFeeds)
|
||||
|
||||
// Entries
|
||||
r.Get("/entries", h.ListEntries)
|
||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
|
||||
// OPML
|
||||
r.Post("/opml/import", h.ImportOPML)
|
||||
r.Get("/opml/export", h.ExportOPML)
|
||||
})
|
||||
})
|
||||
|
||||
// Static Files / Single Page Application fallback
|
||||
if staticDir != "" {
|
||||
if _, err := os.Stat(staticDir); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
path := filepath.Join(staticDir, req.URL.Path)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) && !strings.HasPrefix(req.URL.Path, "/api") && !strings.HasPrefix(req.URL.Path, "/share") {
|
||||
http.ServeFile(w, req, filepath.Join(staticDir, "index.html"))
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/haopengzhan/rsspipe/internal/config"
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserContextKey contextKey = "user"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
db *db.DB
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewAuthService(database *db.DB, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
db: database,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) BootstrapAdmin() error {
|
||||
count, err := s.db.CountUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count users: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(s.config.AdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash admin password: %w", err)
|
||||
}
|
||||
_, err = s.db.CreateUser(s.config.AdminUsername, string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bootstrap admin user: %w", err)
|
||||
}
|
||||
fmt.Printf("==> Initialized Admin user: '%s'\n", s.config.AdminUsername)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Authenticate(username, password string) (string, error) {
|
||||
u, err := s.db.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "rsspipe",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
func (s *AuthService) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenStr string
|
||||
|
||||
// 1. Check Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenStr = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Cookie
|
||||
if tokenStr == "" {
|
||||
if cookie, err := r.Cookie("rsspipe_token"); err == nil {
|
||||
tokenStr = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
if tokenStr == "" {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.ValidateToken(tokenStr)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
JWTSecret string
|
||||
CrawlerIntervalMinutes int
|
||||
CrawlerWorkers int
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./data/rsspipe.db"
|
||||
}
|
||||
|
||||
adminUser := os.Getenv("ADMIN_USERNAME")
|
||||
if adminUser == "" {
|
||||
adminUser = "admin"
|
||||
}
|
||||
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPass == "" {
|
||||
adminPass = "admin123456"
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "rsspipe-super-secret-jwt-key-2026"
|
||||
}
|
||||
|
||||
intervalMin := 15
|
||||
if intervalStr := os.Getenv("CRAWLER_INTERVAL_MINUTES"); intervalStr != "" {
|
||||
if v, err := strconv.Atoi(intervalStr); err == nil && v > 0 {
|
||||
intervalMin = v
|
||||
}
|
||||
}
|
||||
|
||||
workers := 10
|
||||
if workerStr := os.Getenv("CRAWLER_WORKERS"); workerStr != "" {
|
||||
if v, err := strconv.Atoi(workerStr); err == nil && v > 0 {
|
||||
workers = v
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DBPath: dbPath,
|
||||
AdminUsername: adminUser,
|
||||
AdminPassword: adminPass,
|
||||
JWTSecret: jwtSecret,
|
||||
CrawlerIntervalMinutes: intervalMin,
|
||||
CrawlerWorkers: workers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package crawler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/haopengzhan/rsspipe/internal/parser"
|
||||
)
|
||||
|
||||
type Crawler struct {
|
||||
db *db.DB
|
||||
parser *parser.Parser
|
||||
intervalMinutes int
|
||||
workerCount int
|
||||
isRefreshing bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCrawler(database *db.DB, intervalMinutes, workers int) *Crawler {
|
||||
return &Crawler{
|
||||
db: database,
|
||||
parser: parser.NewParser(),
|
||||
intervalMinutes: intervalMinutes,
|
||||
workerCount: workers,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Crawler) Start(ctx context.Context) {
|
||||
log.Printf("==> Starting RSS background crawler scheduler (Workers: %d)\n", c.workerCount)
|
||||
|
||||
// Run initial fetch asynchronously after 2 seconds startup
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
c.RefreshAll(context.Background())
|
||||
}()
|
||||
|
||||
// Check due feeds every 45 seconds
|
||||
ticker := time.NewTicker(45 * time.Second)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.RefreshDue(context.Background())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RefreshDue checks all feeds and refreshes only those whose refresh interval has elapsed
|
||||
func (c *Crawler) RefreshDue(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
if c.isRefreshing {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.isRefreshing = true
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.isRefreshing = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
feeds, err := c.db.ListFeeds()
|
||||
if err != nil {
|
||||
log.Printf("Crawler error listing feeds: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var dueFeeds []models.Feed
|
||||
|
||||
for _, f := range feeds {
|
||||
interval := f.RefreshIntervalMinutes
|
||||
if interval <= 0 {
|
||||
interval = 30
|
||||
}
|
||||
if f.LastFetchedAt == nil || now.Sub(*f.LastFetchedAt) >= time.Duration(interval)*time.Minute {
|
||||
dueFeeds = append(dueFeeds, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dueFeeds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Crawler: Refreshing %d due feeds (out of %d total)...\n", len(dueFeeds), len(feeds))
|
||||
c.runWorkerPool(ctx, dueFeeds)
|
||||
}
|
||||
|
||||
// RefreshAll refreshes all feeds unconditionally (e.g. on manual trigger)
|
||||
func (c *Crawler) RefreshAll(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
if c.isRefreshing {
|
||||
c.mu.Unlock()
|
||||
log.Println("Crawler: RefreshAll already in progress, skipping")
|
||||
return
|
||||
}
|
||||
c.isRefreshing = true
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.isRefreshing = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
feeds, err := c.db.ListFeeds()
|
||||
if err != nil {
|
||||
log.Printf("Crawler error listing feeds: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(feeds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Crawler: Refreshing all %d feeds...\n", len(feeds))
|
||||
c.runWorkerPool(ctx, feeds)
|
||||
}
|
||||
|
||||
func (c *Crawler) runWorkerPool(ctx context.Context, feeds []models.Feed) {
|
||||
jobs := make(chan models.Feed, len(feeds))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < c.workerCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for f := range jobs {
|
||||
c.fetchSingleFeed(ctx, f)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, f := range feeds {
|
||||
jobs <- f
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
log.Printf("Crawler: Completed refresh of %d feeds.\n", len(feeds))
|
||||
}
|
||||
|
||||
func (c *Crawler) RefreshSingle(ctx context.Context, feedID string) error {
|
||||
f, err := c.db.GetFeed(feedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feed not found: %w", err)
|
||||
}
|
||||
return c.fetchSingleFeed(ctx, *f)
|
||||
}
|
||||
|
||||
func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
|
||||
timeout := time.Duration(f.TimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
feedCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := c.parser.FetchFeed(feedCtx, f.FeedURL, f.ETag, f.LastModified)
|
||||
if err != nil {
|
||||
log.Printf("Crawler error fetching [%s]: %v\n", f.Title, err)
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, f.ETag, f.LastModified, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if res.NotModified {
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, f.ETag, f.LastModified, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert entries
|
||||
inserted, err := c.db.UpsertFeedEntries(f.ID, res.Entries)
|
||||
if err != nil {
|
||||
log.Printf("Crawler error upserting entries for [%s]: %v\n", f.Title, err)
|
||||
} else if inserted > 0 {
|
||||
log.Printf("Crawler [%s]: inserted %d new entries\n", f.Title, inserted)
|
||||
}
|
||||
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, res.ETag, res.LastModified, "")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func InitDB(dbPath string) (*DB, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA busy_timeout = 5000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
return nil, fmt.Errorf("failed to set pragma %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
database := &DB{DB: db}
|
||||
if err := database.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
site_url TEXT,
|
||||
category_id TEXT,
|
||||
is_virtual_digest BOOLEAN DEFAULT 0,
|
||||
refresh_interval_minutes INTEGER DEFAULT 30,
|
||||
timeout_seconds INTEGER DEFAULT 10,
|
||||
etag TEXT DEFAULT '',
|
||||
last_modified TEXT DEFAULT '',
|
||||
last_fetched_at DATETIME,
|
||||
fetch_error TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feed_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
feed_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT DEFAULT '',
|
||||
content TEXT NOT NULL,
|
||||
raw_content TEXT DEFAULT '',
|
||||
published_at DATETIME NOT NULL,
|
||||
is_read BOOLEAN DEFAULT 0,
|
||||
is_starred BOOLEAN DEFAULT 0,
|
||||
share_uuid TEXT UNIQUE,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
|
||||
UNIQUE(feed_id, guid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_feed_read ON feed_entries(feed_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_published ON feed_entries(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_is_read ON feed_entries(is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON feed_entries(is_starred);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_share_uuid ON feed_entries(share_uuid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_config TEXT DEFAULT '{}',
|
||||
conditions TEXT DEFAULT '[]',
|
||||
actions TEXT DEFAULT '[]',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := d.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
// Migration for timeout_seconds column if older DB exists
|
||||
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
|
||||
return nil
|
||||
}
|
||||
|
||||
// User operations
|
||||
func (d *DB) CountUsers() (int, error) {
|
||||
var count int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
|
||||
var u models.User
|
||||
err := d.QueryRow(
|
||||
"SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?",
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
|
||||
now := time.Now().UTC()
|
||||
u := &models.User{
|
||||
ID: uuid.New().String(),
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err := d.Exec(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
u.ID, u.Username, u.PasswordHash, u.CreatedAt, u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Category operations
|
||||
func (d *DB) ListCategories() ([]models.Category, error) {
|
||||
query := `
|
||||
SELECT c.id, c.title, c.parent_id, c.sort_order, c.created_at, COUNT(f.id) as feed_count
|
||||
FROM categories c
|
||||
LEFT JOIN feeds f ON f.category_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sort_order ASC, c.title ASC
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []models.Category
|
||||
for rows.Next() {
|
||||
var c models.Category
|
||||
var parentID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt, &c.FeedCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
p := parentID.String
|
||||
c.ParentID = &p
|
||||
}
|
||||
categories = append(categories, c)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetCategoryByTitle(title string) (*models.Category, error) {
|
||||
var c models.Category
|
||||
var parentID sql.NullString
|
||||
err := d.QueryRow("SELECT id, title, parent_id, sort_order, created_at FROM categories WHERE title = ?", title).
|
||||
Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
p := parentID.String
|
||||
c.ParentID = &p
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateCategory(title string, parentID *string) (*models.Category, error) {
|
||||
c := &models.Category{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
ParentID: parentID,
|
||||
SortOrder: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
var pID interface{}
|
||||
if parentID != nil {
|
||||
pID = *parentID
|
||||
}
|
||||
_, err := d.Exec("INSERT INTO categories (id, title, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
c.ID, c.Title, pID, c.SortOrder, c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteCategory(id string) error {
|
||||
_, err := d.Exec("DELETE FROM categories WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed operations
|
||||
func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
||||
COALESCE(c.title, '') as category_title,
|
||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
|
||||
COUNT(e.id) as total_count
|
||||
FROM feeds f
|
||||
LEFT JOIN categories c ON c.id = f.category_id
|
||||
LEFT JOIN feed_entries e ON e.feed_id = f.id
|
||||
GROUP BY f.id
|
||||
ORDER BY f.title ASC
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var feeds []models.Feed
|
||||
for rows.Next() {
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
if lastMod.Valid {
|
||||
f.LastModified = lastMod.String
|
||||
}
|
||||
if lastFetched.Valid {
|
||||
t := lastFetched.Time
|
||||
f.LastFetchedAt = &t
|
||||
}
|
||||
if fetchErr.Valid {
|
||||
f.FetchError = fetchErr.String
|
||||
}
|
||||
feeds = append(feeds, f)
|
||||
}
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
||||
COALESCE(c.title, '') as category_title,
|
||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
|
||||
COUNT(e.id) as total_count
|
||||
FROM feeds f
|
||||
LEFT JOIN categories c ON c.id = f.category_id
|
||||
LEFT JOIN feed_entries e ON e.feed_id = f.id
|
||||
WHERE f.id = ?
|
||||
GROUP BY f.id
|
||||
`
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
err := d.QueryRow(query, id).Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
if lastMod.Valid {
|
||||
f.LastModified = lastMod.String
|
||||
}
|
||||
if lastFetched.Valid {
|
||||
t := lastFetched.Time
|
||||
f.LastFetchedAt = &t
|
||||
}
|
||||
if fetchErr.Valid {
|
||||
f.FetchError = fetchErr.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
err := d.QueryRow(
|
||||
"SELECT id, title, feed_url, site_url, category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
|
||||
feedURL,
|
||||
).Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
|
||||
&lastFetched, &fetchErr, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) {
|
||||
now := time.Now().UTC()
|
||||
f := &models.Feed{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
FeedURL: feedURL,
|
||||
SiteURL: siteURL,
|
||||
CategoryID: categoryID,
|
||||
RefreshIntervalMinutes: 30,
|
||||
TimeoutSeconds: 10,
|
||||
CreatedAt: now,
|
||||
}
|
||||
var catID interface{}
|
||||
if categoryID != nil {
|
||||
catID = *categoryID
|
||||
}
|
||||
_, err := d.Exec(
|
||||
"INSERT INTO feeds (id, title, feed_url, site_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
f.ID, f.Title, f.FeedURL, f.SiteURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
||||
feed, err := d.GetFeed(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := feed.Title
|
||||
if req.Title != nil && *req.Title != "" {
|
||||
title = *req.Title
|
||||
}
|
||||
|
||||
catID := feed.CategoryID
|
||||
if req.CategoryID != nil {
|
||||
if *req.CategoryID == "" {
|
||||
catID = nil
|
||||
} else {
|
||||
catID = req.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
interval := feed.RefreshIntervalMinutes
|
||||
if req.RefreshIntervalMinutes != nil && *req.RefreshIntervalMinutes > 0 {
|
||||
interval = *req.RefreshIntervalMinutes
|
||||
}
|
||||
|
||||
timeout := feed.TimeoutSeconds
|
||||
if req.TimeoutSeconds != nil && *req.TimeoutSeconds > 0 {
|
||||
timeout = *req.TimeoutSeconds
|
||||
}
|
||||
|
||||
var cID interface{}
|
||||
if catID != nil {
|
||||
cID = *catID
|
||||
}
|
||||
|
||||
_, err = d.Exec(`
|
||||
UPDATE feeds
|
||||
SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
||||
WHERE id = ?
|
||||
`, title, cID, interval, timeout, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateFeedFetchStatus(id string, etag, lastModified, fetchErr string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := d.Exec(
|
||||
"UPDATE feeds SET etag = ?, last_modified = ?, fetch_error = ?, last_fetched_at = ? WHERE id = ?",
|
||||
etag, lastModified, fetchErr, now, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteFeed(id string) error {
|
||||
_, err := d.Exec("DELETE FROM feeds WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed Entries operations
|
||||
func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) (int, error) {
|
||||
if len(entries) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
stmt, err := d.Prepare(`
|
||||
INSERT INTO feed_entries (
|
||||
id, feed_id, guid, url, title, author, content, raw_content, published_at, is_read, is_starred, metadata, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
|
||||
ON CONFLICT(feed_id, guid) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
content = CASE WHEN feed_entries.content = '' THEN excluded.content ELSE feed_entries.content END,
|
||||
url = excluded.url,
|
||||
author = excluded.author
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
insertedCount := 0
|
||||
for _, e := range entries {
|
||||
id := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
pubAt := e.PublishedAt
|
||||
if pubAt.IsZero() {
|
||||
pubAt = now
|
||||
}
|
||||
metaBytes, _ := json.Marshal(e.Metadata)
|
||||
if len(metaBytes) == 0 {
|
||||
metaBytes = []byte("{}")
|
||||
}
|
||||
|
||||
res, err := stmt.Exec(
|
||||
id, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now,
|
||||
)
|
||||
if err == nil {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
insertedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return insertedCount, nil
|
||||
}
|
||||
|
||||
type EntryFilter struct {
|
||||
View string // "inbox" (unread), "starred", "all"
|
||||
FeedID string
|
||||
CategoryID string
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (d *DB) ListEntries(f EntryFilter) ([]models.FeedEntry, int, error) {
|
||||
where := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
|
||||
if f.View == "inbox" {
|
||||
where = append(where, "e.is_read = 0")
|
||||
} else if f.View == "starred" {
|
||||
where = append(where, "e.is_starred = 1")
|
||||
}
|
||||
|
||||
if f.FeedID != "" {
|
||||
where = append(where, "e.feed_id = ?")
|
||||
args = append(args, f.FeedID)
|
||||
}
|
||||
|
||||
if f.CategoryID != "" {
|
||||
where = append(where, "f.category_id = ?")
|
||||
args = append(args, f.CategoryID)
|
||||
}
|
||||
|
||||
if f.Search != "" {
|
||||
searchTerms := strings.Fields(f.Search)
|
||||
for _, term := range searchTerms {
|
||||
lower := strings.ToLower(term)
|
||||
if strings.HasPrefix(lower, "is:unread") {
|
||||
where = append(where, "e.is_read = 0")
|
||||
} else if strings.HasPrefix(lower, "is:read") {
|
||||
where = append(where, "e.is_read = 1")
|
||||
} else if strings.HasPrefix(lower, "is:starred") {
|
||||
where = append(where, "e.is_starred = 1")
|
||||
} else if strings.HasPrefix(lower, "feed:") {
|
||||
feedVal := strings.TrimPrefix(term, "feed:")
|
||||
where = append(where, "(f.title LIKE ? OR f.feed_url LIKE ?)")
|
||||
args = append(args, "%"+feedVal+"%", "%"+feedVal+"%")
|
||||
} else {
|
||||
where = append(where, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)")
|
||||
args = append(args, "%"+term+"%", "%"+term+"%", "%"+term+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
whereClause := strings.Join(where, " AND ")
|
||||
|
||||
// Count total
|
||||
countQuery := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM feed_entries e
|
||||
JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE %s
|
||||
`, whereClause)
|
||||
var total int
|
||||
if err := d.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
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 %s
|
||||
ORDER BY e.published_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, whereClause)
|
||||
|
||||
queryArgs := append(args, limit, offset)
|
||||
rows, err := d.Query(query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []models.FeedEntry
|
||||
for rows.Next() {
|
||||
var e models.FeedEntry
|
||||
var author, rawContent, metaStr sql.NullString
|
||||
var shareUUID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
|
||||
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
|
||||
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if author.Valid {
|
||||
e.Author = author.String
|
||||
}
|
||||
if rawContent.Valid {
|
||||
e.RawContent = rawContent.String
|
||||
}
|
||||
if shareUUID.Valid {
|
||||
s := shareUUID.String
|
||||
e.ShareUUID = &s
|
||||
}
|
||||
e.Metadata = make(map[string]interface{})
|
||||
if metaStr.Valid && metaStr.String != "" {
|
||||
_ = json.Unmarshal([]byte(metaStr.String), &e.Metadata)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
func (d *DB) UpdateEntryRead(id string, isRead bool) error {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = ? WHERE id = ?", isRead, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_starred = ? WHERE id = ?", isStarred, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
|
||||
if feedID != nil && *feedID != "" {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
|
||||
return err
|
||||
}
|
||||
if categoryID != nil && *categoryID != "" {
|
||||
_, err := d.Exec(`
|
||||
UPDATE feed_entries
|
||||
SET is_read = 1
|
||||
WHERE feed_id IN (SELECT id FROM feeds WHERE category_id = ?)
|
||||
`, *categoryID)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = 1")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetStats() (*models.Stats, error) {
|
||||
var s models.Stats
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&s.TotalFeeds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_read = 0").Scan(&s.TotalUnread)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_starred = 1").Scan(&s.TotalStarred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestDatabaseOperations(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "rsspipe-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
database, err := InitDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 1. Test User
|
||||
u, err := database.CreateUser("admin", "$2a$10$dummyhashvalue")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
if u.Username != "admin" {
|
||||
t.Errorf("expected username admin, got %s", u.Username)
|
||||
}
|
||||
|
||||
// 2. Test Category
|
||||
cat, err := database.CreateCategory("Tech News", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCategory failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Test Feed
|
||||
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", &cat.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFeed failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. Test Upsert Feed Entries
|
||||
entries := []models.FeedEntry{
|
||||
{
|
||||
GUID: "item-1",
|
||||
URL: "https://example.com/1",
|
||||
Title: "Post One",
|
||||
Author: "Alice",
|
||||
Content: "<p>Content 1</p>",
|
||||
PublishedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
GUID: "item-2",
|
||||
URL: "https://example.com/2",
|
||||
Title: "Post Two",
|
||||
Author: "Bob",
|
||||
Content: "<p>Content 2</p>",
|
||||
PublishedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
inserted, err := database.UpsertFeedEntries(feed.ID, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertFeedEntries failed: %v", err)
|
||||
}
|
||||
if inserted != 2 {
|
||||
t.Fatalf("expected 2 inserted entries, got %d", inserted)
|
||||
}
|
||||
|
||||
// 5. List entries
|
||||
list, total, err := database.ListEntries(EntryFilter{View: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntries failed: %v", err)
|
||||
}
|
||||
if total != 2 || len(list) != 2 {
|
||||
t.Errorf("expected 2 entries, got total %d, len %d", total, len(list))
|
||||
}
|
||||
|
||||
// 6. Update Read & Star
|
||||
firstID := list[0].ID
|
||||
if err := database.UpdateEntryRead(firstID, true); err != nil {
|
||||
t.Fatalf("UpdateEntryRead failed: %v", err)
|
||||
}
|
||||
if err := database.UpdateEntryStarred(firstID, true); err != nil {
|
||||
t.Fatalf("UpdateEntryStarred failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Verify Stats
|
||||
stats, err := database.GetStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats failed: %v", err)
|
||||
}
|
||||
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
|
||||
t.Errorf("unexpected stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents the single admin user
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Category represents a folder/tag grouping for feeds
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
FeedCount int `json:"feed_count,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Feed represents a subscribed RSS/Atom/JSON feed
|
||||
type Feed struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
CategoryTitle string `json:"category_title,omitempty"`
|
||||
IsVirtualDigest bool `json:"is_virtual_digest"`
|
||||
RefreshIntervalMinutes int `json:"refresh_interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"last_modified,omitempty"`
|
||||
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
|
||||
FetchError string `json:"fetch_error,omitempty"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FeedEntry represents an individual article or digest entry
|
||||
type FeedEntry struct {
|
||||
ID string `json:"id"`
|
||||
FeedID string `json:"feed_id"`
|
||||
FeedTitle string `json:"feed_title"`
|
||||
FeedSiteURL string `json:"feed_site_url,omitempty"`
|
||||
GUID string `json:"guid"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Content string `json:"content"`
|
||||
RawContent string `json:"raw_content,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
IsRead bool `json:"is_read"`
|
||||
IsStarred bool `json:"is_starred"`
|
||||
ShareUUID *string `json:"share_uuid,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Stats represents dashboard aggregate counts
|
||||
type Stats struct {
|
||||
TotalFeeds int `json:"total_feeds"`
|
||||
TotalUnread int `json:"total_unread"`
|
||||
TotalStarred int `json:"total_starred"`
|
||||
TotalCategories int `json:"total_categories"`
|
||||
}
|
||||
|
||||
// LoginRequest is the body for POST /api/auth/login
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response returned on successful auth
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
// CreateFeedRequest is the body for POST /api/feeds
|
||||
type CreateFeedRequest struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateFeedRequest is the body for PUT /api/feeds/{id}
|
||||
type UpdateFeedRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateEntryReadRequest
|
||||
type UpdateEntryReadRequest struct {
|
||||
IsRead bool `json:"is_read"`
|
||||
}
|
||||
|
||||
// UpdateEntryStarRequest
|
||||
type UpdateEntryStarRequest struct {
|
||||
IsStarred bool `json:"is_starred"`
|
||||
}
|
||||
|
||||
// MarkAllReadRequest
|
||||
type MarkAllReadRequest struct {
|
||||
FeedID *string `json:"feed_id,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Before *string `json:"before,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
type OPML struct {
|
||||
XMLName xml.Name `xml:"opml"`
|
||||
Version string `xml:"version,attr"`
|
||||
Head Head `xml:"head"`
|
||||
Body Body `xml:"body"`
|
||||
}
|
||||
|
||||
type Head struct {
|
||||
Title string `xml:"title"`
|
||||
DateCreated string `xml:"dateCreated,omitempty"`
|
||||
DateModified string `xml:"dateModified,omitempty"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Outlines []Outline `xml:"outline"`
|
||||
}
|
||||
|
||||
type Outline struct {
|
||||
Text string `xml:"text,attr"`
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
XMLURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
HTMLURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines []Outline `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
type ImportedFeed struct {
|
||||
Title string
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
CategoryTitle string
|
||||
}
|
||||
|
||||
func ParseOPML(data []byte) ([]ImportedFeed, error) {
|
||||
var doc OPML
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OPML XML: %w", err)
|
||||
}
|
||||
|
||||
var feeds []ImportedFeed
|
||||
for _, topOutline := range doc.Body.Outlines {
|
||||
if topOutline.XMLURL != "" {
|
||||
// Top-level feed without category
|
||||
title := topOutline.Title
|
||||
if title == "" {
|
||||
title = topOutline.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(topOutline.XMLURL),
|
||||
SiteURL: strings.TrimSpace(topOutline.HTMLURL),
|
||||
CategoryTitle: "",
|
||||
})
|
||||
} else if len(topOutline.Outlines) > 0 {
|
||||
// Category folder containing feeds
|
||||
categoryTitle := topOutline.Title
|
||||
if categoryTitle == "" {
|
||||
categoryTitle = topOutline.Text
|
||||
}
|
||||
for _, child := range topOutline.Outlines {
|
||||
if child.XMLURL != "" {
|
||||
title := child.Title
|
||||
if title == "" {
|
||||
title = child.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(child.XMLURL),
|
||||
SiteURL: strings.TrimSpace(child.HTMLURL),
|
||||
CategoryTitle: strings.TrimSpace(categoryTitle),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func GenerateOPML(feeds []models.Feed, categories []models.Category) ([]byte, error) {
|
||||
doc := OPML{
|
||||
Version: "2.0",
|
||||
Head: Head{
|
||||
Title: "RSSPipe Subscriptions",
|
||||
DateCreated: time.Now().UTC().Format(time.RFC1123),
|
||||
DateModified: time.Now().UTC().Format(time.RFC1123),
|
||||
},
|
||||
Body: Body{
|
||||
Outlines: make([]Outline, 0),
|
||||
},
|
||||
}
|
||||
|
||||
// Map category ID to slice of feeds
|
||||
catFeedMap := make(map[string][]models.Feed)
|
||||
var uncategorized []models.Feed
|
||||
|
||||
for _, f := range feeds {
|
||||
if f.CategoryID != nil && *f.CategoryID != "" {
|
||||
catFeedMap[*f.CategoryID] = append(catFeedMap[*f.CategoryID], f)
|
||||
} else {
|
||||
uncategorized = append(uncategorized, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Add categorized feeds
|
||||
for _, c := range categories {
|
||||
feedList, exists := catFeedMap[c.ID]
|
||||
if !exists || len(feedList) == 0 {
|
||||
continue
|
||||
}
|
||||
catOutline := Outline{
|
||||
Text: c.Title,
|
||||
Title: c.Title,
|
||||
Outlines: make([]Outline, 0, len(feedList)),
|
||||
}
|
||||
for _, f := range feedList {
|
||||
catOutline.Outlines = append(catOutline.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, catOutline)
|
||||
}
|
||||
|
||||
// Add uncategorized feeds
|
||||
for _, f := range uncategorized {
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
|
||||
xmlHeader := []byte(xml.Header)
|
||||
data, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(xmlHeader, data...), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestParseAndGenerateOPML(t *testing.T) {
|
||||
sampleOPML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>Miniflux Subscriptions</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline text="Technology" title="Technology">
|
||||
<outline type="rss" text="Hacker News" title="Hacker News" xmlUrl="https://news.ycombinator.com/rss" htmlUrl="https://news.ycombinator.com/" />
|
||||
<outline type="rss" text="Go Blog" title="The Go Programming Language" xmlUrl="https://go.dev/blog/feed.atom" htmlUrl="https://go.dev/blog" />
|
||||
</outline>
|
||||
<outline type="rss" text="Ars Technica" title="Ars Technica" xmlUrl="https://feeds.arstechnica.com/arstechnica/index" htmlUrl="https://arstechnica.com" />
|
||||
</body>
|
||||
</opml>`
|
||||
|
||||
feeds, err := ParseOPML([]byte(sampleOPML))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOPML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(feeds) != 3 {
|
||||
t.Fatalf("expected 3 feeds, got %d", len(feeds))
|
||||
}
|
||||
|
||||
// Verify first feed with category
|
||||
if feeds[0].Title != "Hacker News" || feeds[0].FeedURL != "https://news.ycombinator.com/rss" || feeds[0].CategoryTitle != "Technology" {
|
||||
t.Errorf("unexpected feed 0: %+v", feeds[0])
|
||||
}
|
||||
|
||||
// Verify uncategorized feed
|
||||
if feeds[2].Title != "Ars Technica" || feeds[2].FeedURL != "https://feeds.arstechnica.com/arstechnica/index" || feeds[2].CategoryTitle != "" {
|
||||
t.Errorf("unexpected feed 2: %+v", feeds[2])
|
||||
}
|
||||
|
||||
// Test Export Generation
|
||||
catID := "cat-1"
|
||||
categories := []models.Category{
|
||||
{ID: catID, Title: "Technology"},
|
||||
}
|
||||
modelFeeds := []models.Feed{
|
||||
{ID: "f-1", Title: "Hacker News", FeedURL: "https://news.ycombinator.com/rss", SiteURL: "https://news.ycombinator.com/", CategoryID: &catID},
|
||||
{ID: "f-2", Title: "Ars Technica", FeedURL: "https://feeds.arstechnica.com/arstechnica/index", SiteURL: "https://arstechnica.com"},
|
||||
}
|
||||
|
||||
exportedXML, err := GenerateOPML(modelFeeds, categories)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateOPML failed: %v", err)
|
||||
}
|
||||
|
||||
reParsed, err := ParseOPML(exportedXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-parse exported OPML: %v", err)
|
||||
}
|
||||
|
||||
if len(reParsed) != 2 {
|
||||
t.Fatalf("expected 2 re-parsed feeds, got %d", len(reParsed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
type FetchResult struct {
|
||||
NotModified bool
|
||||
ETag string
|
||||
LastModified string
|
||||
Title string
|
||||
SiteURL string
|
||||
Entries []models.FeedEntry
|
||||
}
|
||||
|
||||
type Parser struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
client: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) FetchFeed(ctx context.Context, feedURL, etag, lastModified string) (*FetchResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", feedURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "RSSPipe/1.0 (+https://github.com/haopengzhan/rsspipe)")
|
||||
req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, application/json;q=0.9, */*;q=0.8")
|
||||
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if lastModified != "" {
|
||||
req.Header.Set("If-Modified-Since", lastModified)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return &FetchResult{NotModified: true}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("server returned HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
newETag := resp.Header.Get("ETag")
|
||||
newLastModified := resp.Header.Get("Last-Modified")
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
fp := gofeed.NewParser()
|
||||
feed, err := fp.ParseString(string(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse feed content: %w", err)
|
||||
}
|
||||
|
||||
result := &FetchResult{
|
||||
NotModified: false,
|
||||
ETag: newETag,
|
||||
LastModified: newLastModified,
|
||||
Title: feed.Title,
|
||||
SiteURL: feed.Link,
|
||||
Entries: make([]models.FeedEntry, 0, len(feed.Items)),
|
||||
}
|
||||
|
||||
for _, item := range feed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
guid = item.Title
|
||||
}
|
||||
|
||||
pubDate := time.Now().UTC()
|
||||
if item.PublishedParsed != nil {
|
||||
pubDate = item.PublishedParsed.UTC()
|
||||
} else if item.UpdatedParsed != nil {
|
||||
pubDate = item.UpdatedParsed.UTC()
|
||||
}
|
||||
|
||||
content := item.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = item.Description
|
||||
}
|
||||
|
||||
author := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
}
|
||||
|
||||
entry := models.FeedEntry{
|
||||
GUID: guid,
|
||||
URL: item.Link,
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
Content: content,
|
||||
RawContent: item.Description,
|
||||
PublishedAt: pubDate,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user