mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +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
+40
-16
@@ -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)
|
||||
}
|
||||
+24
-2
@@ -20,8 +20,29 @@ type Feed struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Fetch state (HTTP conditional GET + error backoff); not exposed.
|
||||
ETag string `json:"-"`
|
||||
LastModified string `json:"-"`
|
||||
ErrorCount int `json:"-"`
|
||||
NextFetchAt *time.Time `json:"-"`
|
||||
|
||||
// Virtual fields (not in DB)
|
||||
UnreadCount int `json:"unread_count,omitempty"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
|
||||
// FetchResult is the outcome of one fetch attempt, persisted in one statement.
|
||||
type FetchResult struct {
|
||||
FetchedAt time.Time
|
||||
NextFetchAt time.Time
|
||||
Error string // empty on success
|
||||
ErrorCount int
|
||||
ETag string
|
||||
LastModified string
|
||||
// Metadata from the feed document; nil when the feed wasn't (re)parsed.
|
||||
Title *string
|
||||
Description *string
|
||||
SiteURL *string
|
||||
ImageURL *string
|
||||
}
|
||||
|
||||
// FeedRepository defines the interface for feed data access.
|
||||
@@ -32,6 +53,7 @@ type FeedRepository interface {
|
||||
GetByURL(userID uuid.UUID, url string) (*Feed, error)
|
||||
Update(feed *Feed) error
|
||||
Delete(id uuid.UUID) error
|
||||
// GetFeedsToFetch returns feeds whose next fetch is due.
|
||||
GetFeedsToFetch(limit int) ([]*Feed, error)
|
||||
UpdateFetchStatus(id uuid.UUID, fetchedAt time.Time, fetchError string) error
|
||||
SaveFetchResult(id uuid.UUID, res FetchResult) error
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -21,6 +22,8 @@ type Session struct {
|
||||
type SessionRepository interface {
|
||||
Create(session *Session) error
|
||||
GetByToken(token string) (*Session, error)
|
||||
// GetUserByToken resolves a session token to its user in one query.
|
||||
GetUserByToken(ctx context.Context, token string) (*User, error)
|
||||
Delete(token string) error
|
||||
DeleteByUserID(userID uuid.UUID) error
|
||||
DeleteExpired() (int64, error)
|
||||
|
||||
Reference in new issue
Block a user