Fix push SSRF, cross-user unsub, and personalization edge cases

Code review of the personalization/feeds/PWA/push work surfaced ten
confirmed issues, now fixed:

- Web Push delivery bypassed the SSRF guard (unguarded default client);
  now routes through safehttp.NewClient with a hard timeout, and the
  subscribe handler validates the endpoint URL.
- Push unsubscribe deleted by endpoint with no owner check; added
  RemovePushSubscriptionForUser scoped to the signed-in user.
- Byte-slice body/content truncation could split a UTF-8 rune and break
  the RSS content:encoded XML; added a rune-safe truncateUTF8 helper.
- Digest sender could permanently starve a user who hid a high-volume
  source; step the watermark past a full hidden-source scan window.
- Service worker cached personalized HTML navigations into a shared
  cache (identity leak across PWA users); navigations are now
  network-only, CACHE_VERSION bumped to v2 to purge stale pages.
- Public /api/article leaked discarded/unclassified bodies; filter to
  classified, non-sentinel stories.
- runLocal never started the push sender; digests now fire in -local.
- Push client had no timeout, so one hung endpoint stalled all sends.
- Reader migration resurrected cross-device-cleared reads; gate it
  behind a one-time flag so the server stays authoritative.
- Bookmarks count didn't match the classified list filter.
This commit is contained in:
prosolis
2026-07-07 01:08:42 -07:00
parent 71f7050f41
commit 8863b75916
12 changed files with 145 additions and 37 deletions

View File

@@ -46,11 +46,16 @@ func InsertStory(s *Story) error {
}
// GetStoryReaderText returns the stored full article text and lede for a single
// story, for reader mode. found is false when no story has that id.
// story, for reader mode. found is false when no story has that id. The query
// mirrors the visible-story filter (classified, non-sentinel channel) so the
// public /api/article endpoint can't be enumerated to pull the captured bodies
// of discarded or not-yet-classified stories that never surface in the UI.
func GetStoryReaderText(id int64) (content, lede string, found bool, err error) {
var c sql.NullString
var l sql.NullString
row := Get().QueryRow(`SELECT content, lede FROM stories WHERE id = ?`, id)
row := Get().QueryRow(
`SELECT content, lede FROM stories
WHERE id = ? AND classified = 1 AND channel NOT IN ('_discarded', '_duplicate')`, id)
switch err = row.Scan(&c, &l); {
case errors.Is(err, sql.ErrNoRows):
return "", "", false, nil