Files
Antigravity AgentandClaude Opus 5.5 03e57e4308 fix(backend): harden security and speed up feeds and article API
Security:
- WebSocket events are routed to their owner only (no cross-user leak);
  hub close is idempotent (fixes double-close panic), adds ping/pong and
  write deadlines.
- Session tokens stored as SHA-256 (migration 008 keeps sessions valid);
  single-query auth middleware puts the user in the request context.
- Client IP only trusts X-Forwarded-For from TRUSTED_PROXIES; rate limiter
  map is bounded; per-user limit on AI summaries.
- Argon2id at OWASP minimum with a concurrency cap; constant-time login
  for unknown emails; atomic first-admin bootstrap; REGISTRATION_ENABLED.
- CSP/HSTS/COOP headers, same-origin guard on mutations, body size limits,
  wider SSRF denylist, bounded feed/page/AI response reads, generic errors.
- Upgrade chi, pgx, x/net, x/text, x/crypto (known CVEs); commit go.sum.

Performance:
- List endpoints return a plain-text excerpt and reading time instead of
  full HTML; content is sanitized once at ingest (legacy rows backfilled).
- Keyset pagination on (sort_at, id) with matching partial indexes;
  redundant indexes dropped (migration 007).
- Fetcher: bounded worker pool, conditional GET (ETag/Last-Modified),
  exponential backoff, dedupe before insert, column-safe truncation,
  retention-aware ingest, per-user refresh coalescing.
- Read/favorite/read-all are single ownership-scoped statements.
- gzip compression, immutable caching for hashed assets, path-safe SPA
  handler, server timeouts; expired sessions purged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:34:08 +02:00

243 lines
6.0 KiB
Go

package service
import (
"errors"
"fmt"
"log"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/michael/flowreader/internal/domain"
)
// Feed service errors
var (
ErrInvalidURL = errors.New("invalid feed URL")
ErrFeedExists = errors.New("feed already exists")
ErrFeedNotFound = errors.New("feed not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrTooManyFeeds = errors.New("too many feeds in OPML file (max 500)")
)
// maxOPMLFeeds bounds a single OPML import.
const maxOPMLFeeds = 500
// validFeedURL accepts only absolute http(s) URLs.
func validFeedURL(raw string) (string, bool) {
u, err := url.ParseRequestURI(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(raw) > 2048 {
return "", false
}
return u.String(), true
}
// FeedService handles feed-related business logic.
type FeedService struct {
feedRepo domain.FeedRepository
}
// NewFeedService creates a new feed service.
func NewFeedService(feedRepo domain.FeedRepository) *FeedService {
return &FeedService{feedRepo: feedRepo}
}
// AddFeedRequest contains the data needed to add a new feed.
type AddFeedRequest struct {
URL string `json:"url"`
UserID uuid.UUID `json:"-"`
}
// AddFeedResponse contains the created feed data.
type AddFeedResponse struct {
ID uuid.UUID `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
}
// AddFeed creates a new feed subscription.
func (s *FeedService) AddFeed(req AddFeedRequest) (*AddFeedResponse, error) {
// Validate and normalize URL
normalizedURL, ok := validFeedURL(strings.TrimSpace(req.URL))
if !ok {
return nil, ErrInvalidURL
}
// Check if feed already exists for this user
existing, err := s.feedRepo.GetByURL(req.UserID, normalizedURL)
if err != nil {
return nil, fmt.Errorf("checking existing feed: %w", err)
}
if existing != nil {
return nil, ErrFeedExists
}
// Create feed (title will be updated after first fetch)
now := time.Now()
feed := &domain.Feed{
ID: uuid.New(),
UserID: req.UserID,
URL: normalizedURL,
Title: normalizedURL, // Temporary title until fetched
CreatedAt: now,
UpdatedAt: now,
}
if err := s.feedRepo.Create(feed); err != nil {
return nil, fmt.Errorf("creating feed: %w", err)
}
return &AddFeedResponse{
ID: feed.ID,
URL: feed.URL,
Title: feed.Title,
CreatedAt: feed.CreatedAt,
}, nil
}
// GetUserFeeds returns all feeds for a user.
func (s *FeedService) GetUserFeeds(userID uuid.UUID) ([]*domain.Feed, error) {
feeds, err := s.feedRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("getting user feeds: %w", err)
}
return feeds, nil
}
// GetFeed returns a single feed by ID, verifying ownership.
func (s *FeedService) GetFeed(feedID, userID uuid.UUID) (*domain.Feed, error) {
feed, err := s.feedRepo.GetByID(feedID)
if err != nil {
return nil, fmt.Errorf("getting feed: %w", err)
}
if feed == nil {
return nil, ErrFeedNotFound
}
if feed.UserID != userID {
return nil, ErrUnauthorized
}
return feed, nil
}
// DeleteFeed removes a feed subscription.
func (s *FeedService) DeleteFeed(feedID, userID uuid.UUID) error {
feed, err := s.feedRepo.GetByID(feedID)
if err != nil {
return fmt.Errorf("getting feed: %w", err)
}
if feed == nil {
return ErrFeedNotFound
}
if feed.UserID != userID {
return ErrUnauthorized
}
if err := s.feedRepo.Delete(feedID); err != nil {
return fmt.Errorf("deleting feed: %w", err)
}
return nil
}
// UpdateFeed updates a feed's title.
func (s *FeedService) UpdateFeed(feedID, userID uuid.UUID, title string) (*domain.Feed, error) {
feed, err := s.feedRepo.GetByID(feedID)
if err != nil {
return nil, fmt.Errorf("getting feed: %w", err)
}
if feed == nil {
return nil, ErrFeedNotFound
}
if feed.UserID != userID {
return nil, ErrUnauthorized
}
feed.Title = title
// UpdatedAt is handled by repo or we can set it here if we strictly follow domain logic,
// but repo.Update sets it to time.Now().
if err := s.feedRepo.Update(feed); err != nil {
return nil, fmt.Errorf("updating feed: %w", err)
}
return feed, nil
}
// ImportOPMLResult contains the result of an OPML import.
type ImportOPMLResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
// ImportOPML imports feeds from an OPML file.
func (s *FeedService) ImportOPML(userID uuid.UUID, opmlFeeds []OPMLFeedInfo) (*ImportOPMLResult, error) {
result := &ImportOPMLResult{}
if len(opmlFeeds) > maxOPMLFeeds {
return nil, ErrTooManyFeeds
}
for _, opmlFeed := range opmlFeeds {
// Validate URL (http/https only)
feedURL, ok := validFeedURL(strings.TrimSpace(opmlFeed.URL))
if !ok {
result.Errors = append(result.Errors, fmt.Sprintf("Invalid URL: %.200s", opmlFeed.URL))
result.Skipped++
continue
}
opmlFeed.URL = feedURL
if _, ok := validFeedURL(opmlFeed.SiteURL); !ok {
opmlFeed.SiteURL = ""
}
// Check if already exists
existing, err := s.feedRepo.GetByURL(userID, opmlFeed.URL)
if err != nil {
log.Printf("OPML import: checking %s: %v", opmlFeed.URL, err)
result.Errors = append(result.Errors, fmt.Sprintf("Could not import %.200s", opmlFeed.URL))
result.Skipped++
continue
}
if existing != nil {
result.Skipped++
continue
}
// Create feed with title from OPML
now := time.Now()
feed := &domain.Feed{
ID: uuid.New(),
UserID: userID,
URL: opmlFeed.URL,
Title: opmlFeed.Title,
SiteURL: opmlFeed.SiteURL,
CreatedAt: now,
UpdatedAt: now,
}
if feed.Title == "" {
feed.Title = opmlFeed.URL
}
if err := s.feedRepo.Create(feed); err != nil {
log.Printf("OPML import: creating %s: %v", opmlFeed.URL, err)
result.Errors = append(result.Errors, fmt.Sprintf("Could not import %.200s", opmlFeed.URL))
result.Skipped++
continue
}
result.Imported++
}
return result, nil
}
// OPMLFeedInfo contains feed info from OPML parsing.
type OPMLFeedInfo struct {
URL string
Title string
SiteURL string
}