Files
Pete/internal/ingestion/parser.go
prosolis d5d0656dba Harden security: SSRF guard on all outbound fetches, auth fixes
- Route image-validation, Matrix image-download, and wayback/archive.today
  fetches through safehttp so feed-controlled URLs can't reach loopback/
  RFC1918/cloud-metadata IPs (incl. via redirects).
- Cap feed body size with safehttp.LimitedBody (16 MiB) to prevent OOM.
- Fail closed if matrix.pickle_key is unset/<16 chars; drop the hardcoded
  "pete_pickle_key" default that silently weakened E2EE-at-rest.
- Gate !post behind a matrix.admins allowlist; empty = disabled (channel
  rooms are public, so empty must not mean anyone).
- Reject /\host open-redirect bypass in post-login safeNext.
- Allowlist http(s) schemes for the Matrix link href; escape JSON embedded
  in inline <script> (prefs/sources blobs).
2026-06-21 16:21:03 -07:00

229 lines
6.1 KiB
Go

package ingestion
import (
"context"
"fmt"
"html"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/mmcdole/gofeed"
ext "github.com/mmcdole/gofeed/extensions"
"pete/internal/safehttp"
)
const userAgent = "Pete/1.0 (newsbot; +https://github.com/reala-misaki/pete)"
// maxFeedBytes caps how much of a feed body we'll buffer into the parser, so a
// hostile feed streaming an endless body within the request timeout can't OOM
// the process. Generous headroom — real RSS/Atom feeds are well under this.
const maxFeedBytes = 16 << 20
var feedClient = safehttp.NewClient(30 * time.Second)
// FeedItem represents a parsed RSS item ready for routing.
type FeedItem struct {
GUID string
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
DirectRoute string
Language string // per-item <language> (or dc:language) when present; "" otherwise
PublishedAt int64 // unix seconds from RSS pubDate / Atom published (or updated as fallback); 0 if absent or unparseable
}
var (
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
wsRe = regexp.MustCompile(`\s+`)
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
)
// FetchFeed fetches and parses an RSS/Atom feed, returning items.
func FetchFeed(feedURL string) ([]FeedItem, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := feedClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed fetch %s: status %d", feedURL, resp.StatusCode)
}
feed, err := gofeed.NewParser().Parse(safehttp.LimitedBody(resp.Body, maxFeedBytes))
if err != nil {
return nil, err
}
items := make([]FeedItem, 0, len(feed.Items))
for _, item := range feed.Items {
fi := FeedItem{
GUID: itemGUID(item),
Headline: strings.TrimSpace(item.Title),
Lede: extractLede(item.Description),
ImageURL: NormalizeImageURL(extractImageURL(item)),
ArticleURL: strings.TrimSpace(item.Link),
Language: itemLanguage(item),
PublishedAt: itemPublished(item),
}
if fi.GUID == "" || fi.ArticleURL == "" {
continue
}
items = append(items, fi)
}
slog.Debug("fetched feed", "url", feedURL, "items", len(items))
return items, nil
}
// itemLanguage returns the per-item language tag if the feed provides one.
// Politico Europe puts a raw <language> inside each <item>; gofeed parks
// unknown item-level elements in item.Custom. We also check the standard
// dc:language extension for feeds that use Dublin Core.
func itemLanguage(item *gofeed.Item) string {
if item.Custom != nil {
if v, ok := item.Custom["language"]; ok {
return strings.ToLower(strings.TrimSpace(v))
}
}
if item.DublinCoreExt != nil && len(item.DublinCoreExt.Language) > 0 {
return strings.ToLower(strings.TrimSpace(item.DublinCoreExt.Language[0]))
}
return ""
}
func itemGUID(item *gofeed.Item) string {
if item.GUID != "" {
return item.GUID
}
return item.Link
}
// itemPublished returns the item's publish time as unix seconds, falling back
// to the updated time if pubDate was absent. Returns 0 when neither parses;
// the poller clamps future timestamps to ingest time.
func itemPublished(item *gofeed.Item) int64 {
if item.PublishedParsed != nil {
return item.PublishedParsed.Unix()
}
if item.UpdatedParsed != nil {
return item.UpdatedParsed.Unix()
}
return 0
}
// extractLede returns the feed description verbatim, with HTML stripped.
func extractLede(desc string) string {
if desc == "" {
return ""
}
// Replace tags with a space so adjacent blocks like </p><p> don't fuse words.
text := htmlTagRe.ReplaceAllString(desc, " ")
text = html.UnescapeString(text)
text = wsRe.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
// extractImageURL tries to find an image URL from feed item metadata.
// Order: media:content/thumbnail, enclosures, item.Image, then <img> tags
// scraped from content:encoded / description (catches feeds like Ars that
// embed the lead image in the article body but not as a media:* element).
func extractImageURL(item *gofeed.Item) string {
// Check media content (common in RSS 2.0 with media namespace).
// When multiple media:content variants are advertised (Guardian RSS lists
// 140/460/700/1200…) we want the widest signed URL we can find.
if item.Extensions != nil {
if media, ok := item.Extensions["media"]; ok {
if contents, ok := media["content"]; ok {
if url := widestURL(contents); url != "" {
return url
}
}
if thumbs, ok := media["thumbnail"]; ok {
if url := widestURL(thumbs); url != "" {
return url
}
}
// media:group wraps nested media:content / media:thumbnail in some feeds
if groups, ok := media["group"]; ok {
for _, g := range groups {
for _, child := range g.Children {
for _, n := range child {
if url := n.Attrs["url"]; url != "" {
return url
}
}
}
}
}
}
}
// Check enclosures
for _, enc := range item.Enclosures {
if strings.HasPrefix(enc.Type, "image/") && enc.URL != "" {
return enc.URL
}
}
// Check item image
if item.Image != nil && item.Image.URL != "" {
return item.Image.URL
}
// Scrape first <img src> from content:encoded or description
if url := firstImgSrc(item.Content); url != "" {
return url
}
if url := firstImgSrc(item.Description); url != "" {
return url
}
return ""
}
// widestURL picks the entry with the largest declared width attribute, falling
// back to the first URL if none have a parsable width.
func widestURL(entries []ext.Extension) string {
best := ""
bestW := -1
for _, e := range entries {
url := e.Attrs["url"]
if url == "" {
continue
}
w, _ := strconv.Atoi(e.Attrs["width"])
if w > bestW {
best = url
bestW = w
}
}
return best
}
func firstImgSrc(htmlBody string) string {
if htmlBody == "" {
return ""
}
m := imgSrcRe.FindStringSubmatch(htmlBody)
if len(m) < 2 {
return ""
}
return html.UnescapeString(m[1])
}