feat: scaffold initial RSS reader project with Go backend and React frontend

This commit is contained in:
2026-08-24 08:08:31 -07:00
commit 3742dbdce4
44 changed files with 9901 additions and 0 deletions
+530
View File
@@ -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
}
+91
View File
@@ -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
}
+140
View File
@@ -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))
})
}
+67
View File
@@ -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,
}
}
+192
View File
@@ -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
}
+679
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+118
View File
@@ -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"`
}
+157
View File
@@ -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
}
+66
View File
@@ -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))
}
}
+129
View File
@@ -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
}