Files
Pete/internal/poster/tracker.go
prosolis 9e5ba5aafc Fix SSRF, XSS, dedup, force-post, and DB hot-path issues
- Add internal/safehttp: hardened HTTP client (DNS-resolved dial guard
  blocking loopback/RFC1918/CGNAT/link-local, redirect re-validation,
  body-size cap) and rewire article/feed/thumb clients through it
- Cap goquery body at 5 MiB so a hostile origin can't OOM the process
- search.js: reject non-http(s) hrefs to block stored XSS via javascript:
- dedup: tracking-param key "CMP" was unreachable (lookup lowercases);
  fixed to "cmp" so CMP= is actually stripped from canonical URLs
- ForcePost: postItem now returns bool; on dedup-skip ForcePost returns
  false so !post falls back to DB lookup instead of silently consuming
- Bound reaction callbacks behind an 8-slot semaphore; drop overflow
- Add stories indexes on (channel, classified, seen_at DESC),
  (classified, seen_at DESC), and partial image_url to kill full scans
  in IsKnownImageURL and ORDER BY seen_at hot paths
- Surface FTS5 probe Scan error instead of swallowing it
2026-05-25 12:23:37 -07:00

76 lines
2.3 KiB
Go

package poster
import (
"log/slog"
"sync"
"time"
"pete/internal/storage"
"maunium.net/go/mautrix/id"
)
// ReactionCallback is an optional hook fired after a reaction is recorded.
// Used to wire reaction-driven features (e.g. ❓ → summary) without coupling
// poster to those packages.
type ReactionCallback func(roomID id.RoomID, targetEventID id.EventID, guid, channel, emoji string)
var (
reactionCallbackMu sync.RWMutex
reactionCallback ReactionCallback
// reactionSem bounds concurrent in-flight reaction callbacks. Each callback
// may do DB / Matrix / LLM I/O, so an unbounded fan-out (`go cb(...)` per
// reaction) lets a reaction flood — legitimate burst or an abusive room
// member spamming emoji — exhaust goroutines, connections, and memory.
// Tries to acquire non-blocking: if the cap is saturated we drop the
// callback with a warn rather than queueing, on the theory that the room
// is already busy enough.
reactionSem = make(chan struct{}, 8)
)
// SetReactionCallback installs a hook called after every recorded reaction.
// Pass nil to clear. Safe to call before or after the matrix client starts.
func SetReactionCallback(fn ReactionCallback) {
reactionCallbackMu.Lock()
reactionCallback = fn
reactionCallbackMu.Unlock()
}
// HandleReaction processes a reaction event by mapping it back to the story GUID.
func HandleReaction(roomID id.RoomID, eventID id.EventID, targetEventID id.EventID, emoji string, userID id.UserID) {
guid, channel, found := storage.LookupPostGUID(string(targetEventID))
if !found {
slog.Info("reaction on event not in post_log, ignoring",
"target_event", targetEventID, "emoji", emoji, "user", userID)
return
}
storage.InsertReaction(guid, channel, string(eventID), emoji, string(userID), time.Now().Unix())
slog.Debug("reaction recorded",
"guid", guid,
"channel", channel,
"emoji", emoji,
"user", userID,
)
reactionCallbackMu.RLock()
cb := reactionCallback
reactionCallbackMu.RUnlock()
if cb == nil {
return
}
select {
case reactionSem <- struct{}{}:
default:
slog.Warn("reaction callback dropped: worker pool saturated",
"guid", guid, "channel", channel, "emoji", emoji)
return
}
go func() {
defer func() { <-reactionSem }()
cb(roomID, targetEventID, guid, channel, emoji)
}()
}