mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-23 07:08:06 +02:00
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>
This commit is contained in:
1 parent
d037e2be34
commit
03e57e4308
40 files changed
+2231
-1898
No files matched your search
+86
-191
@@ -22,6 +22,34 @@ func NewFeedRepository(pool *pgxpool.Pool) *FeedRepository {
|
||||
return &FeedRepository{pool: pool}
|
||||
}
|
||||
|
||||
const feedColumns = `
|
||||
f.id, f.user_id, f.url, f.title, f.description, f.site_url, f.image_url,
|
||||
f.last_fetched_at, f.fetch_error, f.created_at, f.updated_at,
|
||||
f.etag, f.last_modified, f.error_count, f.next_fetch_at`
|
||||
|
||||
// scanFeed scans feedColumns (plus optional extra destinations).
|
||||
func scanFeed(row pgx.Row, extra ...any) (*domain.Feed, error) {
|
||||
var feed domain.Feed
|
||||
var title, description, siteURL, imageURL, fetchError, etag, lastModified *string
|
||||
|
||||
dest := []any{
|
||||
&feed.ID, &feed.UserID, &feed.URL, &title, &description, &siteURL, &imageURL,
|
||||
&feed.LastFetchedAt, &fetchError, &feed.CreatedAt, &feed.UpdatedAt,
|
||||
&etag, &lastModified, &feed.ErrorCount, &feed.NextFetchAt,
|
||||
}
|
||||
if err := row.Scan(append(dest, extra...)...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feed.Title = deref(title)
|
||||
feed.Description = deref(description)
|
||||
feed.SiteURL = deref(siteURL)
|
||||
feed.ImageURL = deref(imageURL)
|
||||
feed.FetchError = deref(fetchError)
|
||||
feed.ETag = deref(etag)
|
||||
feed.LastModified = deref(lastModified)
|
||||
return &feed, nil
|
||||
}
|
||||
|
||||
// Create inserts a new feed into the database.
|
||||
func (r *FeedRepository) Create(feed *domain.Feed) error {
|
||||
ctx := context.Background()
|
||||
@@ -52,69 +80,36 @@ func (r *FeedRepository) Create(feed *domain.Feed) error {
|
||||
|
||||
// GetByID retrieves a feed by its ID.
|
||||
func (r *FeedRepository) GetByID(id uuid.UUID) (*domain.Feed, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||
last_fetched_at, fetch_error, created_at, updated_at
|
||||
FROM feeds
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var feed domain.Feed
|
||||
var description, siteURL, imageURL, fetchError *string
|
||||
var lastFetchedAt *time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, query, id).Scan(
|
||||
&feed.ID,
|
||||
&feed.UserID,
|
||||
&feed.URL,
|
||||
&feed.Title,
|
||||
&description,
|
||||
&siteURL,
|
||||
&imageURL,
|
||||
&lastFetchedAt,
|
||||
&fetchError,
|
||||
&feed.CreatedAt,
|
||||
&feed.UpdatedAt,
|
||||
)
|
||||
query := `SELECT ` + feedColumns + ` FROM feeds f WHERE f.id = $1`
|
||||
|
||||
feed, err := scanFeed(r.pool.QueryRow(context.Background(), query, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("getting feed by ID: %w", err)
|
||||
}
|
||||
|
||||
if description != nil {
|
||||
feed.Description = *description
|
||||
}
|
||||
if siteURL != nil {
|
||||
feed.SiteURL = *siteURL
|
||||
}
|
||||
if imageURL != nil {
|
||||
feed.ImageURL = *imageURL
|
||||
}
|
||||
if fetchError != nil {
|
||||
feed.FetchError = *fetchError
|
||||
}
|
||||
feed.LastFetchedAt = lastFetchedAt
|
||||
|
||||
return &feed, nil
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
// GetByUserID retrieves all feeds for a user.
|
||||
// GetByUserID retrieves all feeds for a user with their unread counts.
|
||||
func (r *FeedRepository) GetByUserID(userID uuid.UUID) ([]*domain.Feed, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// One aggregate over the partial unread index instead of a correlated
|
||||
// COUNT(*) per feed.
|
||||
query := `
|
||||
SELECT f.id, f.user_id, f.url, f.title, f.description, f.site_url, f.image_url,
|
||||
f.last_fetched_at, f.fetch_error, f.created_at, f.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM articles a WHERE a.feed_id = f.id AND a.is_read = false), 0) as unread_count
|
||||
SELECT ` + feedColumns + `, COALESCE(u.unread, 0)
|
||||
FROM feeds f
|
||||
LEFT JOIN (
|
||||
SELECT a.feed_id, COUNT(*) AS unread
|
||||
FROM articles a
|
||||
JOIN feeds uf ON uf.id = a.feed_id AND uf.user_id = $1
|
||||
WHERE NOT a.is_read
|
||||
GROUP BY a.feed_id
|
||||
) u ON u.feed_id = f.id
|
||||
WHERE f.user_id = $1
|
||||
ORDER BY f.title ASC
|
||||
`
|
||||
ORDER BY lower(f.title) ASC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
@@ -122,101 +117,31 @@ func (r *FeedRepository) GetByUserID(userID uuid.UUID) ([]*domain.Feed, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var feeds []*domain.Feed
|
||||
feeds := make([]*domain.Feed, 0, 16)
|
||||
for rows.Next() {
|
||||
var feed domain.Feed
|
||||
var description, siteURL, imageURL, fetchError *string
|
||||
var lastFetchedAt *time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&feed.ID,
|
||||
&feed.UserID,
|
||||
&feed.URL,
|
||||
&feed.Title,
|
||||
&description,
|
||||
&siteURL,
|
||||
&imageURL,
|
||||
&lastFetchedAt,
|
||||
&fetchError,
|
||||
&feed.CreatedAt,
|
||||
&feed.UpdatedAt,
|
||||
&feed.UnreadCount,
|
||||
)
|
||||
var unread int
|
||||
feed, err := scanFeed(rows, &unread)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning feed: %w", err)
|
||||
}
|
||||
|
||||
if description != nil {
|
||||
feed.Description = *description
|
||||
}
|
||||
if siteURL != nil {
|
||||
feed.SiteURL = *siteURL
|
||||
}
|
||||
if imageURL != nil {
|
||||
feed.ImageURL = *imageURL
|
||||
}
|
||||
if fetchError != nil {
|
||||
feed.FetchError = *fetchError
|
||||
}
|
||||
feed.LastFetchedAt = lastFetchedAt
|
||||
|
||||
feeds = append(feeds, &feed)
|
||||
feed.UnreadCount = unread
|
||||
feeds = append(feeds, feed)
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
return feeds, rows.Err()
|
||||
}
|
||||
|
||||
// GetByURL retrieves a feed by its URL for a specific user.
|
||||
func (r *FeedRepository) GetByURL(userID uuid.UUID, url string) (*domain.Feed, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||
last_fetched_at, fetch_error, created_at, updated_at
|
||||
FROM feeds
|
||||
WHERE user_id = $1 AND url = $2
|
||||
`
|
||||
|
||||
var feed domain.Feed
|
||||
var description, siteURL, imageURL, fetchError *string
|
||||
var lastFetchedAt *time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, query, userID, url).Scan(
|
||||
&feed.ID,
|
||||
&feed.UserID,
|
||||
&feed.URL,
|
||||
&feed.Title,
|
||||
&description,
|
||||
&siteURL,
|
||||
&imageURL,
|
||||
&lastFetchedAt,
|
||||
&fetchError,
|
||||
&feed.CreatedAt,
|
||||
&feed.UpdatedAt,
|
||||
)
|
||||
query := `SELECT ` + feedColumns + ` FROM feeds f WHERE f.user_id = $1 AND f.url = $2`
|
||||
|
||||
feed, err := scanFeed(r.pool.QueryRow(context.Background(), query, userID, url))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("getting feed by URL: %w", err)
|
||||
}
|
||||
|
||||
if description != nil {
|
||||
feed.Description = *description
|
||||
}
|
||||
if siteURL != nil {
|
||||
feed.SiteURL = *siteURL
|
||||
}
|
||||
if imageURL != nil {
|
||||
feed.ImageURL = *imageURL
|
||||
}
|
||||
if fetchError != nil {
|
||||
feed.FetchError = *fetchError
|
||||
}
|
||||
feed.LastFetchedAt = lastFetchedAt
|
||||
|
||||
return &feed, nil
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
// Update updates a feed in the database.
|
||||
@@ -258,21 +183,16 @@ func (r *FeedRepository) Delete(id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFeedsToFetch returns feeds that need to be fetched.
|
||||
// GetFeedsToFetch returns feeds whose next fetch is due, never-fetched first.
|
||||
func (r *FeedRepository) GetFeedsToFetch(limit int) ([]*domain.Feed, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||
last_fetched_at, fetch_error, created_at, updated_at
|
||||
FROM feeds
|
||||
WHERE last_fetched_at IS NULL
|
||||
OR last_fetched_at < NOW() - INTERVAL '15 minutes'
|
||||
ORDER BY last_fetched_at ASC NULLS FIRST
|
||||
LIMIT $1
|
||||
`
|
||||
SELECT ` + feedColumns + `
|
||||
FROM feeds f
|
||||
WHERE f.next_fetch_at IS NULL OR f.next_fetch_at <= NOW()
|
||||
ORDER BY f.next_fetch_at ASC NULLS FIRST
|
||||
LIMIT $1`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, limit)
|
||||
rows, err := r.pool.Query(context.Background(), query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying feeds to fetch: %w", err)
|
||||
}
|
||||
@@ -280,66 +200,41 @@ func (r *FeedRepository) GetFeedsToFetch(limit int) ([]*domain.Feed, error) {
|
||||
|
||||
var feeds []*domain.Feed
|
||||
for rows.Next() {
|
||||
var feed domain.Feed
|
||||
var description, siteURL, imageURL, fetchError *string
|
||||
var lastFetchedAt *time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&feed.ID,
|
||||
&feed.UserID,
|
||||
&feed.URL,
|
||||
&feed.Title,
|
||||
&description,
|
||||
&siteURL,
|
||||
&imageURL,
|
||||
&lastFetchedAt,
|
||||
&fetchError,
|
||||
&feed.CreatedAt,
|
||||
&feed.UpdatedAt,
|
||||
)
|
||||
feed, err := scanFeed(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning feed: %w", err)
|
||||
}
|
||||
|
||||
if description != nil {
|
||||
feed.Description = *description
|
||||
}
|
||||
if siteURL != nil {
|
||||
feed.SiteURL = *siteURL
|
||||
}
|
||||
if imageURL != nil {
|
||||
feed.ImageURL = *imageURL
|
||||
}
|
||||
if fetchError != nil {
|
||||
feed.FetchError = *fetchError
|
||||
}
|
||||
feed.LastFetchedAt = lastFetchedAt
|
||||
|
||||
feeds = append(feeds, &feed)
|
||||
feeds = append(feeds, feed)
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
return feeds, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateFetchStatus updates the fetch status of a feed.
|
||||
func (r *FeedRepository) UpdateFetchStatus(id uuid.UUID, fetchedAt time.Time, fetchError string) error {
|
||||
ctx := context.Background()
|
||||
// SaveFetchResult records the outcome of a fetch (status, schedule, HTTP
|
||||
// validators and, when parsed, feed metadata) in a single statement.
|
||||
// The title is only replaced while it is still the placeholder URL.
|
||||
func (r *FeedRepository) SaveFetchResult(id uuid.UUID, res domain.FetchResult) error {
|
||||
const query = `
|
||||
UPDATE feeds SET
|
||||
last_fetched_at = $2,
|
||||
next_fetch_at = $3,
|
||||
fetch_error = NULLIF($4, ''),
|
||||
error_count = $5,
|
||||
etag = COALESCE(NULLIF($6, ''), etag),
|
||||
last_modified = COALESCE(NULLIF($7, ''), last_modified),
|
||||
title = CASE WHEN $8::text IS NOT NULL AND $8 <> '' AND (title IS NULL OR title = '' OR title = url)
|
||||
THEN $8 ELSE title END,
|
||||
description = COALESCE($9, description),
|
||||
site_url = COALESCE($10, site_url),
|
||||
image_url = COALESCE($11, image_url)
|
||||
WHERE id = $1`
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if fetchError == "" {
|
||||
query = `UPDATE feeds SET last_fetched_at = $2, fetch_error = NULL WHERE id = $1`
|
||||
args = []interface{}{id, fetchedAt}
|
||||
} else {
|
||||
query = `UPDATE feeds SET last_fetched_at = $2, fetch_error = $3 WHERE id = $1`
|
||||
args = []interface{}{id, fetchedAt, fetchError}
|
||||
}
|
||||
|
||||
_, err := r.pool.Exec(ctx, query, args...)
|
||||
_, err := r.pool.Exec(context.Background(), query, id,
|
||||
res.FetchedAt, res.NextFetchAt, res.Error, res.ErrorCount,
|
||||
res.ETag, res.LastModified,
|
||||
res.Title, res.Description, res.SiteURL, res.ImageURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating fetch status: %w", err)
|
||||
return fmt.Errorf("saving fetch result: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user