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:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 07:34:08 +02:00
1 parent d037e2be34
commit 03e57e4308
40 files changed
+2231 -1898

No files matched your search

+40 -16
View File
@@ -1,6 +1,7 @@
package domain
import (
"context"
"time"
"github.com/google/uuid"
@@ -10,39 +11,62 @@ import (
type Article struct {
ID uuid.UUID `json:"id"`
FeedID uuid.UUID `json:"feed_id"`
GUID string `json:"guid"`
GUID string `json:"guid,omitempty"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
Content string `json:"content,omitempty"`
Summary string `json:"summary,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
AISummary string `json:"ai_summary,omitempty"`
Author string `json:"author,omitempty"`
ImageURL string `json:"image_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
SortAt time.Time `json:"sort_at"`
IsRead bool `json:"is_read"`
IsFavorite bool `json:"is_favorite"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
WordCount int `json:"word_count"`
ReadingTime int `json:"reading_time"`
// Virtual fields (from joins)
FeedTitle string `json:"feed_title,omitempty"`
}
// ArticleCursor is a keyset pagination position: the (sort_at, id) of the
// last article of the previous page.
type ArticleCursor struct {
SortAt time.Time
ID uuid.UUID
}
// ArticleFilter selects a page of a user's articles, newest first.
type ArticleFilter struct {
UserID uuid.UUID
FeedID *uuid.UUID
UnreadOnly bool
FavoritesOnly bool
Cursor *ArticleCursor
Limit int
}
// ArticleRepository defines the interface for article data access.
// Every user-facing operation is scoped by user ID so ownership is enforced
// in SQL rather than by a separate lookup.
type ArticleRepository interface {
Create(article *Article) error
CreateBatch(articles []*Article) error
GetByID(id uuid.UUID) (*Article, error)
GetByFeedID(feedID uuid.UUID, limit, offset int) ([]*Article, error)
GetByUserID(userID uuid.UUID, limit, offset int, unreadOnly bool) ([]*Article, error)
GetByGUID(feedID uuid.UUID, guid string) (*Article, error)
MarkAsRead(id uuid.UUID) error
MarkAsUnread(id uuid.UUID) error
MarkAllAsRead(feedID uuid.UUID) error
MarkAllAsReadGlobal(userID uuid.UUID) error
ToggleFavorite(id uuid.UUID) error
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
CountUnread(feedID uuid.UUID) (int, error)
Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
UpdateAISummary(id uuid.UUID, summary string) error
// InsertNew inserts the articles whose GUID is not already stored for the
// feed and returns how many rows were actually inserted.
InsertNew(ctx context.Context, feedID uuid.UUID, articles []*Article) (int, error)
ExistingGUIDs(ctx context.Context, feedID uuid.UUID, guids []string) (map[string]struct{}, error)
GetForUser(ctx context.Context, id, userID uuid.UUID) (*Article, error)
List(ctx context.Context, f ArticleFilter) ([]*Article, error)
Search(ctx context.Context, userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
// SetRead returns false when the article doesn't exist or isn't owned by the user.
SetRead(ctx context.Context, id, userID uuid.UUID, read bool) (bool, error)
// ToggleFavorite returns the new favorite state and whether the article was found.
ToggleFavorite(ctx context.Context, id, userID uuid.UUID) (isFavorite bool, found bool, err error)
MarkFeedRead(ctx context.Context, feedID, userID uuid.UUID) (int64, error)
MarkAllRead(ctx context.Context, userID uuid.UUID) (int64, error)
UpdateAISummary(ctx context.Context, id uuid.UUID, summary string) error
DeleteOldArticles(ctx context.Context, olderThan time.Duration) (int64, error)
}