- push digest queries now exclude _duplicate channel like every other visibility query (bookmarks list/count and NewClassifiedSince) - advance push watermark to newest scanned story, not pass-start now, so stories arriving during a long send pass aren't re-counted next pass - replace hand-rolled escapeHTMLText with stdlib html.EscapeString - drop em-dashes from user-facing copy; bump PWA CACHE_VERSION so clients pick up the changed shell assets
116 lines
3.9 KiB
Go
116 lines
3.9 KiB
Go
package storage
|
|
|
|
import "fmt"
|
|
|
|
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
|
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
|
type PushSubscription struct {
|
|
Endpoint string
|
|
UserSub string
|
|
P256dh string
|
|
Auth string
|
|
CreatedAt int64
|
|
LastNotifiedAt int64
|
|
}
|
|
|
|
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
|
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
|
// the keys and resets the digest watermark to now — the user shouldn't be
|
|
// paged for everything published before they opted in.
|
|
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
|
|
now := nowUnix()
|
|
_, err := Get().Exec(`
|
|
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(endpoint) DO UPDATE SET
|
|
user_sub = excluded.user_sub,
|
|
p256dh = excluded.p256dh,
|
|
auth = excluded.auth,
|
|
last_notified_at = excluded.last_notified_at`,
|
|
endpoint, sub, p256dh, auth, now, now)
|
|
if err != nil {
|
|
return fmt.Errorf("add push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
|
// the digest sender's prune path, where a push service has reported the endpoint
|
|
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
|
// opt-outs must use RemovePushSubscriptionForUser.
|
|
func RemovePushSubscription(endpoint string) error {
|
|
_, err := Get().Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("remove push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemovePushSubscriptionForUser drops an endpoint only if it belongs to sub, so
|
|
// a signed-in user can't unsubscribe another account's device by presenting its
|
|
// endpoint string. A no-op (no matching row) is not an error.
|
|
func RemovePushSubscriptionForUser(sub, endpoint string) error {
|
|
_, err := Get().Exec(
|
|
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_sub = ?`, endpoint, sub)
|
|
if err != nil {
|
|
return fmt.Errorf("remove push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
|
func ListPushSubscriptions() ([]PushSubscription, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
|
|
FROM push_subscriptions`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []PushSubscription
|
|
for rows.Next() {
|
|
var p PushSubscription
|
|
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
|
// digest only considers stories seen after ts.
|
|
func TouchPushSubscription(endpoint string, ts int64) error {
|
|
_, err := Get().Exec(
|
|
`UPDATE push_subscriptions SET last_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("touch push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewClassifiedSince returns classified stories first seen after sinceUnix,
|
|
// newest first, capped at limit. The digest sender uses it to count and preview
|
|
// what's new for a subscriber; it carries just the fields a digest needs.
|
|
func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT id, headline, source, channel, seen_at
|
|
FROM stories
|
|
WHERE classified = 1 AND channel NOT IN ('_discarded', '_duplicate') AND seen_at > ?
|
|
ORDER BY seen_at DESC
|
|
LIMIT ?`, sinceUnix, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Story
|
|
for rows.Next() {
|
|
var s Story
|
|
if err := rows.Scan(&s.ID, &s.Headline, &s.Source, &s.Channel, &s.SeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|