aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md25
-rw-r--r--cmd/mobius-hotline-server/main.go17
-rw-r--r--cmd/mobius-hotline-server/mobius/config/config.yaml21
-rw-r--r--docs/feed-backed-news.md129
-rw-r--r--docs/text-encoding.md40
-rw-r--r--go.mod16
-rw-r--r--go.sum32
-rw-r--r--hotline/config.go55
-rw-r--r--hotline/news.go25
-rw-r--r--hotline/time.go25
-rw-r--r--hotline/time_test.go13
-rw-r--r--internal/mobius/config.go37
-rw-r--r--internal/mobius/config_test.go91
-rw-r--r--internal/mobius/feed_news.go217
-rw-r--r--internal/mobius/feed_news_fetch.go478
-rw-r--r--internal/mobius/feed_news_import.go238
-rw-r--r--internal/mobius/feed_news_test.go462
-rw-r--r--internal/mobius/handlers_news.go2
-rw-r--r--internal/mobius/testdata/afterglow-appcast.xml22
-rw-r--r--internal/mobius/testdata/mobius-releases.atom16
20 files changed, 1898 insertions, 63 deletions
diff --git a/README.md b/README.md
index 3a25a5b..d09719b 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,31 @@ Within this directory some files are intended to be edited to customize the serv
🛠️ `config.yaml` - Edit to set your server name, description, and enable tracker registration.
+### Feed-backed threaded news
+
+An advanced `NewsFeeds` configuration can import RSS or Atom entries into an
+existing ordinary news category, including Sparkle appcasts and GitHub release
+feeds:
+
+See [Feed-backed threaded news](docs/feed-backed-news.md) for the complete
+administrator guide, including configuration rules, retention behavior,
+durable state, operational limits, and troubleshooting.
+
+```yaml
+NewsFeeds:
+ - CategoryPath: ["Software Updates", "Afterglow"]
+ URL: "https://morphing.cloud/afterglow/appcast.xml"
+ - CategoryPath: ["Software Updates", "Mobius"]
+ URL: "https://github.com/jhalter/mobius/releases.atom"
+```
+
+The full target path must already exist as an ordinary category. Whenever a
+client opens it, Mobius conditionally checks the source and saves new entries
+as ordinary root articles in `ThreadedNews.yaml`. Existing entries are never
+rewritten, and deleted imports stay deleted. Feed failures are logged while the
+current local articles remain available. RSS and Atom are detected
+automatically; changes to `NewsFeeds` require restarting the server.
+
### User accounts
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go
index 45b9a08..2511227 100644
--- a/cmd/mobius-hotline-server/main.go
+++ b/cmd/mobius-hotline-server/main.go
@@ -205,9 +205,24 @@ func main() {
slogger.Error("Error loading news", "err", err)
os.Exit(1)
}
- srv.ThreadedNewsMgr = threadedNews
reloaders = append(reloaders, namedReloader{"threaded news", threadedNews})
+ srv.ThreadedNewsMgr = threadedNews
+ if len(config.NewsFeeds) > 0 {
+ feedNews, err := mobius.NewFeedNewsManager(
+ threadedNews,
+ config.NewsFeeds,
+ slogger,
+ "mobius-hotline-server/"+version,
+ config.Encoding,
+ )
+ if err != nil {
+ slogger.Error("Error configuring feed-backed news", "err", err)
+ os.Exit(1)
+ }
+ srv.ThreadedNewsMgr = feedNews
+ }
+
srv.AccountManager, err = mobius.NewYAMLAccountManager(path.Join(*configDir, "Users/"))
if err != nil {
slogger.Error("Error loading accounts", "err", err)
diff --git a/cmd/mobius-hotline-server/mobius/config/config.yaml b/cmd/mobius-hotline-server/mobius/config/config.yaml
index d7ded22..2fda24c 100644
--- a/cmd/mobius-hotline-server/mobius/config/config.yaml
+++ b/cmd/mobius-hotline-server/mobius/config/config.yaml
@@ -25,10 +25,11 @@ Trackers:
- hotline.kicks-ass.net:5499
# - example-tracker-with-password.com:5499:mypassword
-# Text encoding used for file and folder names sent to and from clients.
+# Text encoding used for file and folder names and server-generated feed news.
+# User-authored chat, news, replies, and usernames remain raw protocol bytes.
# Supported values:
-# "macintosh" - Translates between Mac Roman and UTF-8 (default, compatible with classic Hotline clients)
-# "utf8" - No conversion; passes file names through as-is (for servers with only modern UTF-8 clients)
+# "macintosh" - Converts filesystem names and feed news for classic Hotline clients (default)
+# "utf8" - Leaves UTF-8 filesystem names and feed news unchanged (modern clients only)
# If omitted, defaults to "macintosh".
Encoding: macintosh
@@ -52,6 +53,18 @@ NewsDateFormat: ""
# Optional custom delimiter between flat news postings
NewsDelimiter: ""
+# Optional mappings from existing ordinary news categories to public RSS or
+# Atom feeds. Opening a mapped category checks its source and imports unseen
+# entries as ordinary root articles in ThreadedNews.yaml. The complete category
+# path must already exist. Feed formats are detected automatically.
+#
+# NewsFeeds:
+# - CategoryPath: ["Software Updates", "Afterglow"]
+# URL: "https://morphing.cloud/afterglow/appcast.xml"
+# - CategoryPath: ["Software Updates", "Mobius"]
+# URL: "https://github.com/jhalter/mobius/releases.atom"
+NewsFeeds: []
+
# Maximum simultaneous downloads; currently unimplemented
MaxDownloads: 0
@@ -67,4 +80,4 @@ IgnoreFiles:
- '^@' # Ignore all files starting with "@"
# Enable service announcement on local network with Bonjour
-EnableBonjour: false \ No newline at end of file
+EnableBonjour: false
diff --git a/docs/feed-backed-news.md b/docs/feed-backed-news.md
new file mode 100644
index 0000000..8edcf79
--- /dev/null
+++ b/docs/feed-backed-news.md
@@ -0,0 +1,129 @@
+# Feed-backed threaded news
+
+Mobius can import a public RSS or Atom feed into an existing Hotline news
+category. This is useful for project announcements, Sparkle appcasts, and
+GitHub release feeds.
+
+When a user opens a mapped category, Mobius checks its source and adds entries
+it has not seen before as ordinary root articles. Imported articles live in
+`ThreadedNews.yaml`, alongside locally posted articles and replies. The category
+continues to work as ordinary Hotline news: authorized users can post roots,
+reply, and delete any article.
+
+## Configure a category
+
+First, create the target as an ordinary news category with a Hotline client.
+Every component in `CategoryPath`, including the final category, must already
+exist before the server starts.
+
+Then add a mapping to `config.yaml` and restart Mobius:
+
+```yaml
+NewsFeeds:
+ - CategoryPath: ["Software Updates", "Afterglow"]
+ URL: "https://morphing.cloud/afterglow/appcast.xml"
+
+ - CategoryPath: ["Software Updates", "Mobius"]
+ URL: "https://github.com/jhalter/mobius/releases.atom"
+```
+
+`CategoryPath` is the complete path from the root of threaded news to the
+existing category. `URL` must be an absolute public `http` or `https` URL and
+cannot contain embedded credentials. Only one feed may map to a category.
+
+Mobius detects RSS and Atom automatically. Sparkle appcasts are RSS; GitHub's
+`releases.atom` endpoints are Atom. JSON Feed and provider-specific APIs are not
+supported.
+
+## Import behavior
+
+Every article-list request for the mapped category performs a source check.
+Mobius sends the saved `ETag` and `Last-Modified` values when the source
+provides them, allowing an unchanged source to answer with a small `304 Not
+Modified` response. Concurrent requests for the same category share one
+in-flight check.
+
+On a successful response, Mobius:
+
+1. Finds entries not previously observed from that URL.
+2. Converts HTML descriptions to plain text and retains useful source, release
+ notes, and enclosure links.
+3. Imports new entries oldest-first as ordinary root articles.
+4. Saves the articles and import metadata together in one atomic update to
+ `ThreadedNews.yaml`.
+
+An entry needs a stable identity: a GUID or Atom ID, an HTTP(S) article link,
+or an HTTP(S) enclosure URL, in that order. Entries without any of these are
+skipped and logged. Mobius intentionally does not derive identities from titles
+or bodies because an edited entry could then be mistaken for a new article.
+
+Once an identity has been imported, later changes to that entry's title,
+author, date, or body are ignored. If an imported article is deleted locally,
+its identity remains in the seen set, so the feed does not recreate it. Items
+also remain in Hotline when they disappear from the source. This prevents a
+feed's rolling window from rolling articles out of the Hotline category.
+
+The initial import can only include entries returned by the source. Mobius does
+not paginate GitHub or reconstruct older releases that are already absent from
+its Atom feed.
+
+## Failures and limits
+
+A network error, timeout, non-success HTTP response, invalid feed, or failed
+disk write is logged. Mobius still returns the category's current local article
+list, including an empty list before the first successful import. A later
+category load tries the source again. Requests time out after 10 seconds and a
+response body may be at most 2 MiB.
+
+The Hotline protocol limits the complete encoded article-list payload for one
+category to 65,535 bytes. This listing contains article metadata—not article
+bodies—so the practical capacity depends mostly on the number and encoded
+length of titles and authors. Mobius checks the full list after each candidate
+article. It saves the oldest prefix that fits and stops before an addition
+would exceed the limit. It does not save new HTTP validators while unseen
+entries remain, so the next category load fetches and retries them. Mobius does
+not prune local or imported articles automatically; split a large source across
+categories when a category approaches this protocol limit.
+
+Individual imported titles and authors are limited to 255 encoded bytes, and
+article bodies to 65,535 encoded bytes, matching Hotline's field sizes.
+
+## Durable state and backups
+
+Feed history is part of each category in `ThreadedNews.yaml` under a YAML-only
+`FeedState` key. It records the current source URL, HTTP validators, and hashes
+of identities that have already been imported. This metadata is not sent to
+Hotline clients. Back up `ThreadedNews.yaml` as usual; there are no additional
+feed state or cache files.
+
+Changing a category's URL clears its HTTP validators but retains its seen
+history. Identity hashes include the source URL, so the new source can import
+its entries even if it uses the same GUID values. Switching back to a previous
+URL does not duplicate entries that URL imported earlier.
+
+The earlier experimental `FeedNewsState.yaml` and `FeedNewsCache.json` formats
+are not migrated or read. If they exist from a development build, Mobius
+ignores them; their contents do not participate in this feature.
+
+## Text encoding
+
+Feed parsers produce UTF-8 text. Mobius converts each entry once, when it is
+imported, according to the server-wide `Encoding` setting. The default
+`macintosh` setting converts to Mac Roman and replaces unsupported characters;
+`utf8` stores UTF-8 unchanged. Existing imported articles are ordinary raw-byte
+news data, so changing `Encoding` affects only future imports and does not
+rewrite history.
+
+## Troubleshooting
+
+- If startup fails, verify that every mapped path already exists and ends at a
+ news category rather than a bundle. Also check for duplicate paths and
+ invalid or credential-bearing URLs.
+- If new entries do not appear, open the category and inspect the server log.
+ Confirm that the URL is public RSS or Atom and that entries have stable IDs,
+ links, or enclosures.
+- If old releases are missing on the first import, inspect the source feed. Its
+ published window is the initial cutoff; Mobius does not use pagination or a
+ provider API.
+- If a source is unavailable, existing local news remains usable and the next
+ category load retries it.
diff --git a/docs/text-encoding.md b/docs/text-encoding.md
index 71bdada..f299b51 100644
--- a/docs/text-encoding.md
+++ b/docs/text-encoding.md
@@ -2,7 +2,7 @@
## Background
-The Hotline protocol was designed for classic Mac OS, which used an encoding called Mac Roman for text. Modern operating systems use UTF-8 instead. Mobius automatically translates between these two encodings so that classic Hotline clients and modern filesystems can work together.
+The Hotline protocol was designed for classic Mac OS, which used an encoding called Mac Roman for text. Modern operating systems use UTF-8 instead. Mobius translates filesystem names and newly imported feed news so that classic Hotline clients and modern UTF-8 sources can work together. Most other protocol text remains raw bytes.
By default, Mobius assumes all clients use Mac Roman encoding. If your server exclusively serves modern UTF-8 clients, you can disable the Mac Roman conversion. This document explains how encoding works and how to configure it.
@@ -17,9 +17,20 @@ When a Hotline client uploads, downloads, browses, creates, or renames files and
This means files on disk always use UTF-8 names, regardless of what encoding the client uses. You can place files with Unicode names in the server's file directory and clients will see them — as long as the characters have Mac Roman equivalents.
-### Chat, news, and usernames are NOT translated
+### Server-generated feed news is translated
-Text in chat messages, news articles, usernames, and private messages is passed through as raw bytes with no encoding conversion. This means:
+RSS and Atom sources are UTF-8 server data rather than client-authored Hotline
+text. Mobius encodes each imported article's title, author, and body when it is
+first added to `ThreadedNews.yaml`. With the default `macintosh` setting,
+characters that Mac Roman cannot represent are replaced. With `utf8`, feed text
+is stored unchanged.
+
+Imported entries become ordinary raw-byte news articles. Changing `Encoding`
+therefore affects future imports only; it does not rewrite existing articles.
+
+### Chat, user-authored news, and usernames are NOT translated
+
+Text in chat messages, existing news articles, usernames, and private messages is passed through as raw bytes with no encoding conversion. This means:
- If all your users are on classic Mac clients, they'll see each other's text correctly (all Mac Roman).
- If all your users are on modern UTF-8 clients, they'll also see each other's text correctly.
@@ -29,13 +40,13 @@ There is no way to configure this behavior — the Hotline protocol has no mecha
## Configuration
-The `Encoding` field in `config.yaml` controls how file and folder names are translated:
+The `Encoding` field in `config.yaml` controls how file and folder names and server-generated feed news are translated:
```yaml
# Default — translates between Mac Roman and UTF-8 (compatible with classic Hotline clients)
Encoding: macintosh
-# No-op — passes file names through without conversion (for servers with only modern UTF-8 clients)
+# No-op — passes file names and feed news through unchanged (modern UTF-8 clients only)
Encoding: utf8
```
@@ -59,12 +70,13 @@ Modern Hotline clients that send UTF-8 for file operations may produce unexpecte
## Summary
-| What | Encoding translation? | Notes |
-|---------------------------|----------------------|-----------------------------------------------|
-| File and folder names | Yes | Mac Roman <-> UTF-8 at the filesystem boundary |
-| Chat messages | No | Raw bytes, passed through as-is |
-| News articles and titles | No | Raw bytes, passed through as-is |
-| Usernames | No | Raw bytes, passed through as-is |
-| Private messages | No | Raw bytes, passed through as-is |
-| File comments | No | Stored and retrieved as raw bytes |
-| Login credentials | No | Obfuscated, no charset conversion |
+| What | Encoding translation? | Notes |
+| --- | --- | --- |
+| File and folder names | Yes | Mac Roman <-> UTF-8 at the filesystem boundary |
+| Newly imported feed articles | Yes | UTF-8 source text -> configured encoding at import time |
+| Chat messages | No | Raw bytes, passed through as-is |
+| Existing and user-authored news articles | No | Raw bytes, passed through as-is |
+| Usernames | No | Raw bytes, passed through as-is |
+| Private messages | No | Raw bytes, passed through as-is |
+| File comments | No | Stored and retrieved as raw bytes |
+| Login credentials | No | Obfuscated, no charset conversion |
diff --git a/go.mod b/go.mod
index b44350f..2905fa4 100644
--- a/go.mod
+++ b/go.mod
@@ -11,11 +11,14 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/smithy-go v1.27.3
github.com/go-playground/validator/v10 v10.28.0
+ github.com/mmcdole/gofeed v1.4.0
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915
github.com/redis/go-redis/v9 v9.17.0
github.com/stretchr/testify v1.11.1
- golang.org/x/crypto v0.45.0
- golang.org/x/text v0.31.0
+ golang.org/x/crypto v0.53.0
+ golang.org/x/net v0.56.0
+ golang.org/x/sync v0.21.0
+ golang.org/x/text v0.38.0
golang.org/x/time v0.14.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -43,12 +46,11 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/miekg/dns v1.1.68 // indirect
+ github.com/mmcdole/goxpp/v2 v2.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
- golang.org/x/mod v0.30.0 // indirect
- golang.org/x/net v0.47.0 // indirect
- golang.org/x/sync v0.18.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/tools v0.39.0 // indirect
+ golang.org/x/mod v0.36.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/tools v0.45.0 // indirect
)
diff --git a/go.sum b/go.sum
index 57ca4a9..2db280f 100644
--- a/go.sum
+++ b/go.sum
@@ -64,6 +64,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
+github.com/mmcdole/gofeed v1.4.0 h1:+efDmI/yJXJgTfa8we5zg9GAKsU+2d7tnpt9QZwvjLQ=
+github.com/mmcdole/gofeed v1.4.0/go.mod h1:ngV5MTB7UJko6fH3/fG5AkB/ABUGK1ZTePF9iRhzu/c=
+github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
+github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915 h1:d291KOLbN1GthTPA1fLKyWdclX3k1ZP+CzYtun+a5Es=
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915/go.mod h1:MGuVJ1+5TX1SCoO2Sx0eAnjpdRytYla2uC1YIZfkC9c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -76,22 +80,22 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
-golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
-golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
-golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
diff --git a/hotline/config.go b/hotline/config.go
index 8b6adff..b3fdcb8 100644
--- a/hotline/config.go
+++ b/hotline/config.go
@@ -1,19 +1,44 @@
package hotline
+import (
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+// NewsFeedConfig maps an existing threaded-news category to an RSS or Atom
+// feed. Feed entries are imported as ordinary articles in ThreadedNews.yaml.
+type NewsFeedConfig struct {
+ CategoryPath []string `yaml:"CategoryPath" validate:"required,min=1,dive,required"`
+ URL string `yaml:"URL" validate:"required"`
+}
+
+func (c *NewsFeedConfig) UnmarshalYAML(value *yaml.Node) error {
+ for i := 0; i+1 < len(value.Content); i += 2 {
+ key := value.Content[i].Value
+ if key != "CategoryPath" && key != "URL" {
+ return fmt.Errorf("unknown NewsFeeds setting %q", key)
+ }
+ }
+ type plainNewsFeedConfig NewsFeedConfig
+ return value.Decode((*plainNewsFeedConfig)(c))
+}
+
type Config struct {
- Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
- Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
- BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif
- FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
- EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
- Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
- NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts
- NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format
- MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit
- MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit
- MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP
- PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files
- IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list
- EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour
- Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Text encoding for client communication
+ Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
+ Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
+ BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif
+ FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
+ EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
+ Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
+ NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts
+ NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format
+ MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit
+ MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit
+ MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP
+ PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files
+ IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list
+ EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour
+ Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Encoding for filesystem names and server-generated feed news
+ NewsFeeds []NewsFeedConfig `yaml:"NewsFeeds" validate:"dive"` // Existing threaded-news categories populated from remote feeds
}
diff --git a/hotline/news.go b/hotline/news.go
index 033badb..b0e511b 100644
--- a/hotline/news.go
+++ b/hotline/news.go
@@ -28,14 +28,25 @@ type ThreadedNews struct {
Categories map[string]NewsCategoryListData15 `yaml:"Categories"`
}
+// NewsFeedCategoryState is durable import metadata for a feed-mapped category.
+// It is YAML-only: NewsCategoryListData15.Read explicitly serializes the
+// Hotline protocol fields and therefore never exposes this state to clients.
+type NewsFeedCategoryState struct {
+ SourceURL string `yaml:"SourceURL"`
+ ETag string `yaml:"ETag,omitempty"`
+ LastModified string `yaml:"LastModified,omitempty"`
+ Imported map[string]uint32 `yaml:"Imported"`
+}
+
type NewsCategoryListData15 struct {
- Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3)
- Name string `yaml:"Name"`
- Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category
- SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"`
- GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
- AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
- DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3)
+ Name string `yaml:"Name"`
+ Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category
+ SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"`
+ FeedState *NewsFeedCategoryState `yaml:"FeedState,omitempty"`
+ GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
readOffset int // Internal offset to track read progress
writeOffset int // Internal offset to track write progress
diff --git a/hotline/time.go b/hotline/time.go
index b6d2d9c..47972d4 100644
--- a/hotline/time.go
+++ b/hotline/time.go
@@ -36,6 +36,22 @@ func NewTime(t time.Time) (b Time) {
))
}
+// NewNewsTime converts a time.Time to the year-relative timestamp encoding
+// used by threaded-news article lists. Classic Hotline clients combine the
+// leading year field with seconds elapsed since January 1 of that year.
+//
+// This intentionally differs from NewTime's Mac-epoch encoding, which is
+// needed for compatibility with clients such as Pitbull Pro in other protocol
+// fields.
+func NewNewsTime(t time.Time) (b Time) {
+ localTime := t.In(time.Local)
+ startOfYear := time.Date(localTime.Year(), time.January, 1, 0, 0, 0, 0, time.Local)
+
+ binary.BigEndian.PutUint16(b[0:2], uint16(localTime.Year()))
+ binary.BigEndian.PutUint32(b[4:8], uint32(localTime.Sub(startOfYear).Seconds()))
+ return b
+}
+
// Time converts the Hotline Time format to a Go time.Time.
// The Hotline format stores: Year (2 bytes) + milliseconds (2 bytes, unused) +
// seconds since the Mac OS epoch (4 bytes).
@@ -45,6 +61,15 @@ func (t Time) Time() time.Time {
return macEpoch.Add(time.Duration(seconds) * time.Second)
}
+// NewsTime decodes the year-relative timestamp format produced by
+// NewNewsTime.
+func (t Time) NewsTime() time.Time {
+ year := binary.BigEndian.Uint16(t[0:2])
+ seconds := binary.BigEndian.Uint32(t[4:8])
+ startOfYear := time.Date(int(year), time.January, 1, 0, 0, 0, 0, time.Local)
+ return startOfYear.Add(time.Duration(seconds) * time.Second)
+}
+
// Format returns the time formatted according to the layout string.
// This is a convenience wrapper around Time().Format().
func (t Time) Format(layout string) string {
diff --git a/hotline/time_test.go b/hotline/time_test.go
index b52f3ef..f9ee5c4 100644
--- a/hotline/time_test.go
+++ b/hotline/time_test.go
@@ -47,3 +47,16 @@ func TestNewTime_SecondsSinceMacEpoch(t *testing.T) {
t.Errorf("Mac-epoch decode = %v, want %v", decoded, tm)
}
}
+
+func TestNewNewsTime_UsesSecondsSinceStartOfYear(t *testing.T) {
+ want := time.Date(2026, time.August, 22, 18, 9, 55, 0, time.Local)
+
+ encoded := NewNewsTime(want)
+ seconds := binary.BigEndian.Uint32(encoded[4:8])
+ if seconds >= 366*24*60*60 {
+ t.Fatalf("news seconds field = %d, want a year-relative value", seconds)
+ }
+ if got := encoded.NewsTime(); !got.Equal(want) {
+ t.Errorf("news time round trip = %v, want %v", got, want)
+ }
+}
diff --git a/internal/mobius/config.go b/internal/mobius/config.go
index 171f6c7..a0924df 100644
--- a/internal/mobius/config.go
+++ b/internal/mobius/config.go
@@ -2,9 +2,11 @@ package mobius
import (
"fmt"
+ "net/url"
"os"
"path/filepath"
"strings"
+ "unicode/utf8"
"github.com/go-playground/validator/v10"
"github.com/jhalter/mobius/hotline"
@@ -29,6 +31,10 @@ func LoadConfig(path string) (*hotline.Config, error) {
return nil, fmt.Errorf("unmarshal YAML: %v", err)
}
+ if err := normalizeNewsFeedConfig(&config); err != nil {
+ return nil, err
+ }
+
validate := validator.New()
if err = validate.RegisterValidation("bannerext", func(fl validator.FieldLevel) bool {
filename := fl.Field().String()
@@ -56,3 +62,34 @@ func LoadConfig(path string) (*hotline.Config, error) {
// only the caller knows how to resolve it (e.g. against the config dir for the OS backend).
return &config, nil
}
+
+func normalizeNewsFeedConfig(config *hotline.Config) error {
+ seenPaths := make(map[string]struct{}, len(config.NewsFeeds))
+ for i := range config.NewsFeeds {
+ feed := &config.NewsFeeds[i]
+ if len(feed.CategoryPath) == 0 {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath is required", i)
+ }
+ for j, segment := range feed.CategoryPath {
+ if segment == "" || !utf8.ValidString(segment) || strings.ContainsRune(segment, '\x00') || len(segment) > 255 {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath[%d] must be non-empty valid UTF-8 without NUL bytes and at most 255 bytes", i, j)
+ }
+ }
+
+ pathKey := strings.Join(feed.CategoryPath, "\x00")
+ if _, exists := seenPaths[pathKey]; exists {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath duplicates another news feed", i)
+ }
+ seenPaths[pathKey] = struct{}{}
+
+ parsedURL, err := url.Parse(feed.URL)
+ if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return fmt.Errorf("NewsFeeds[%d].URL must be an absolute HTTP or HTTPS URL", i)
+ }
+ if parsedURL.User != nil {
+ return fmt.Errorf("NewsFeeds[%d].URL must not contain credentials", i)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/mobius/config_test.go b/internal/mobius/config_test.go
index f090b5c..9985745 100644
--- a/internal/mobius/config_test.go
+++ b/internal/mobius/config_test.go
@@ -5,6 +5,9 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestLoadConfig_InvalidBannerFileExtension(t *testing.T) {
@@ -124,3 +127,91 @@ FileRoot: "files"
})
}
}
+
+func TestLoadConfig_NewsFeeds(t *testing.T) {
+ t.Run("accepts an auto-detected RSS or Atom feed", func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:
+ - CategoryPath: [Afterglow Releases]
+ URL: https://morphing.cloud/afterglow/appcast.xml
+`
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ config, err := LoadConfig(configPath)
+ require.NoError(t, err)
+ require.Len(t, config.NewsFeeds, 1)
+ assert.Equal(t, []string{"Afterglow Releases"}, config.NewsFeeds[0].CategoryPath)
+ assert.Equal(t, "https://morphing.cloud/afterglow/appcast.xml", config.NewsFeeds[0].URL)
+ })
+
+ tests := []struct {
+ name string
+ feedYAML string
+ wantErr string
+ }{
+ {
+ name: "rejects non HTTP URL",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: file:///tmp/feed.xml`,
+ wantErr: "absolute HTTP or HTTPS URL",
+ },
+ {
+ name: "rejects credentials",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: https://user:password@example.com/feed.xml`,
+ wantErr: "must not contain credentials",
+ },
+ {
+ name: "rejects duplicate category",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: https://example.com/one.xml
+ - CategoryPath: [News]
+ URL: https://example.com/two.xml`,
+ wantErr: "duplicates another news feed",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:` + tt.feedYAML + "\n"
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ _, err := LoadConfig(configPath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+
+ for _, removed := range []string{"Format: rss", "RefreshInterval: 1h", "MaxItems: 25"} {
+ t.Run("rejects removed setting "+removed, func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:
+ - CategoryPath: [Releases]
+ URL: http://example.com/releases.xml
+ ` + removed + "\n"
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ _, err := LoadConfig(configPath)
+ require.ErrorContains(t, err, "unknown NewsFeeds setting")
+ })
+ }
+}
diff --git a/internal/mobius/feed_news.go b/internal/mobius/feed_news.go
new file mode 100644
index 0000000..fcc0664
--- /dev/null
+++ b/internal/mobius/feed_news.go
@@ -0,0 +1,217 @@
+package mobius
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+
+ "github.com/jhalter/mobius/hotline"
+ "golang.org/x/sync/singleflight"
+ textencoding "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/charmap"
+)
+
+// FeedNewsManager refreshes configured feeds when their existing category is
+// opened. Everything except ListArticles remains ordinary threaded-news
+// behavior because imported entries are ordinary ThreadedNews.yaml articles.
+type FeedNewsManager struct {
+ base *ThreadedNewsYAML
+ feeds map[string]configuredNewsFeed
+ logger *slog.Logger
+ fetcher *feedFetcher
+ encoding string
+ refresh singleflight.Group
+}
+
+type configuredNewsFeed struct {
+ config hotline.NewsFeedConfig
+ wirePath []string
+}
+
+var _ hotline.ThreadedNewsMgr = (*FeedNewsManager)(nil)
+
+func NewFeedNewsManager(
+ base *ThreadedNewsYAML,
+ feeds []hotline.NewsFeedConfig,
+ logger *slog.Logger,
+ userAgent string,
+ clientEncoding string,
+) (*FeedNewsManager, error) {
+ if base == nil {
+ return nil, errors.New("feed news manager requires a threaded news YAML manager")
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ normalized := &hotline.Config{NewsFeeds: slices.Clone(feeds)}
+ if err := normalizeNewsFeedConfig(normalized); err != nil {
+ return nil, err
+ }
+
+ manager := &FeedNewsManager{
+ base: base,
+ feeds: make(map[string]configuredNewsFeed, len(feeds)),
+ logger: logger,
+ fetcher: newFeedFetcher(userAgent),
+ encoding: normalizedFeedEncodingName(clientEncoding),
+ }
+ for _, config := range normalized.NewsFeeds {
+ wirePath := encodeFeedPath(config.CategoryPath, manager.encoding)
+ key := newsPathKey(wirePath)
+ if _, exists := manager.feeds[key]; exists {
+ return nil, fmt.Errorf("NewsFeeds categories collide after %s encoding", manager.encoding)
+ }
+ if err := base.validateFeedCategory(wirePath); err != nil {
+ return nil, fmt.Errorf("NewsFeeds category %q: %w", strings.Join(config.CategoryPath, "/"), err)
+ }
+ manager.feeds[key] = configuredNewsFeed{config: config, wirePath: wirePath}
+ }
+ return manager, nil
+}
+
+func (m *FeedNewsManager) ListArticles(newsPath []string) (hotline.NewsArtListData, error) {
+ feed, mapped := m.feeds[newsPathKey(newsPath)]
+ if !mapped {
+ return m.base.ListArticles(newsPath)
+ }
+
+ _, err, _ := m.refresh.Do(newsPathKey(newsPath), func() (any, error) {
+ return nil, m.refreshFeed(feed)
+ })
+ if err != nil {
+ m.logger.Warn("Unable to refresh feed-backed news; serving local articles",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "err", err,
+ )
+ }
+ return m.base.ListArticles(newsPath)
+}
+
+func (m *FeedNewsManager) refreshFeed(feed configuredNewsFeed) error {
+ validators, err := m.base.feedValidators(feed.wirePath, feed.config.URL)
+ if err != nil {
+ return err
+ }
+ result, err := m.fetcher.fetch(context.Background(), feed.config.URL, validators)
+ if err != nil {
+ return err
+ }
+ if result.notModified {
+ return nil
+ }
+ if result.skippedNoIdentity > 0 {
+ m.logger.Warn("Skipped feed entries without a stable identity",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "count", result.skippedNoIdentity,
+ )
+ }
+
+ items := make([]normalizedFeedItem, len(result.items))
+ for i, item := range result.items {
+ items[i] = item
+ items[i].article = encodeFeedArticle(item.article, m.encoding)
+ }
+ imported, err := m.base.importFeedArticles(
+ feed.wirePath,
+ feed.config.URL,
+ result.etag,
+ result.lastModified,
+ items,
+ )
+ if err != nil {
+ return err
+ }
+ if imported.limitReached {
+ m.logger.Warn("Stopped importing feed entries at the Hotline article-list size limit",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "imported", imported.count,
+ )
+ }
+ return nil
+}
+
+func (m *FeedNewsManager) GetArticle(newsPath []string, articleID uint32) *hotline.NewsArtData {
+ return m.base.GetArticle(newsPath, articleID)
+}
+
+func (m *FeedNewsManager) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+ return m.base.DeleteArticle(newsPath, articleID, recursive)
+}
+
+func (m *FeedNewsManager) PostArticle(newsPath []string, parentArticleID uint32, article hotline.NewsArtData) error {
+ return m.base.PostArticle(newsPath, parentArticleID, article)
+}
+
+func (m *FeedNewsManager) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
+ return m.base.CreateGrouping(newsPath, name, itemType)
+}
+
+func (m *FeedNewsManager) GetCategories(newsPath []string) []hotline.NewsCategoryListData15 {
+ return m.base.GetCategories(newsPath)
+}
+
+func (m *FeedNewsManager) NewsItem(newsPath []string) hotline.NewsCategoryListData15 {
+ return m.base.NewsItem(newsPath)
+}
+
+func (m *FeedNewsManager) DeleteNewsItem(newsPath []string) error {
+ return m.base.DeleteNewsItem(newsPath)
+}
+
+func newsPathKey(path []string) string {
+ return strings.Join(path, "\x00")
+}
+
+func normalizedFeedEncodingName(value string) string {
+ if value == "utf8" {
+ return "utf8"
+ }
+ return "macintosh"
+}
+
+func encodeFeedPath(path []string, clientEncoding string) []string {
+ encoded := slices.Clone(path)
+ for i := range encoded {
+ encoded[i] = encodeFeedText(encoded[i], clientEncoding)
+ }
+ return encoded
+}
+
+func encodeFeedArticle(article hotline.NewsArtData, clientEncoding string) hotline.NewsArtData {
+ article.Title = truncateEncodedFeedText(article.Title, maxNewsStringBytes, clientEncoding)
+ article.Poster = truncateEncodedFeedText(article.Poster, maxNewsStringBytes, clientEncoding)
+ article.Data = truncateEncodedFeedText(article.Data, maxNewsBodyBytes, clientEncoding)
+ article.DataFlav = slices.Clone(hotline.NewsFlavor)
+ return article
+}
+
+func truncateEncodedFeedText(value string, maxBytes int, clientEncoding string) string {
+ encoded := encodeFeedText(value, clientEncoding)
+ if len(encoded) <= maxBytes {
+ return encoded
+ }
+ if normalizedFeedEncodingName(clientEncoding) == "utf8" {
+ return truncateUTF8(encoded, maxBytes)
+ }
+ return encoded[:maxBytes]
+}
+
+func encodeFeedText(value, clientEncoding string) string {
+ if normalizedFeedEncodingName(clientEncoding) == "utf8" {
+ return value
+ }
+ encoded, err := textencoding.ReplaceUnsupported(charmap.Macintosh.NewEncoder()).String(value)
+ if err == nil {
+ return encoded
+ }
+ return strings.Map(func(r rune) rune {
+ if r <= 0x7f {
+ return r
+ }
+ return '?'
+ }, value)
+}
diff --git a/internal/mobius/feed_news_fetch.go b/internal/mobius/feed_news_fetch.go
new file mode 100644
index 0000000..18d25ee
--- /dev/null
+++ b/internal/mobius/feed_news_fetch.go
@@ -0,0 +1,478 @@
+package mobius
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ stdhtml "html"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/mmcdole/gofeed"
+ xhtml "golang.org/x/net/html"
+)
+
+const (
+ feedRequestTimeout = 10 * time.Second
+ maxFeedBodyBytes = 2 << 20
+ maxNewsStringBytes = 255
+ maxNewsBodyBytes = 65535
+)
+
+var errFeedResponseTooLarge = errors.New("feed response exceeds 2 MiB")
+
+type normalizedFeedItem struct {
+ identity string
+ article hotline.NewsArtData
+ publishedAt time.Time
+ sourceIndex int
+}
+
+type feedHTTPValidators struct {
+ etag string
+ lastModified string
+}
+
+type feedFetchResult struct {
+ notModified bool
+ etag string
+ lastModified string
+ items []normalizedFeedItem
+ skippedNoIdentity int
+}
+
+type feedFetcher struct {
+ client *http.Client
+ parser *gofeed.Parser
+ now func() time.Time
+ userAgent string
+}
+
+func newFeedFetcher(userAgent string) *feedFetcher {
+ if userAgent == "" {
+ userAgent = "mobius-hotline-server"
+ }
+ return &feedFetcher{
+ client: &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("stopped after 10 redirects")
+ }
+ if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
+ return fmt.Errorf("redirected to unsupported URL scheme %q", req.URL.Scheme)
+ }
+ if req.URL.User != nil {
+ return errors.New("redirected to a URL containing credentials")
+ }
+ return nil
+ },
+ },
+ parser: gofeed.NewParser(),
+ now: time.Now,
+ userAgent: userAgent,
+ }
+}
+
+func (f *feedFetcher) fetch(ctx context.Context, sourceURL string, validators feedHTTPValidators) (feedFetchResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, feedRequestTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
+ if err != nil {
+ return feedFetchResult{}, errors.New("create request from configured feed URL")
+ }
+ req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8")
+ req.Header.Set("User-Agent", f.userAgent)
+ if validators.etag != "" {
+ req.Header.Set("If-None-Match", validators.etag)
+ }
+ if validators.lastModified != "" {
+ req.Header.Set("If-Modified-Since", validators.lastModified)
+ }
+
+ response, err := f.client.Do(req)
+ if err != nil {
+ return feedFetchResult{}, fmt.Errorf("fetch feed: %w", redactHTTPError(err))
+ }
+ defer func() { _ = response.Body.Close() }()
+
+ if response.StatusCode == http.StatusNotModified {
+ if validators.etag == "" && validators.lastModified == "" {
+ return feedFetchResult{}, errors.New("feed returned 304 without a conditional request")
+ }
+ return feedFetchResult{notModified: true}, nil
+ }
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ return feedFetchResult{}, fmt.Errorf("feed returned %s", response.Status)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxFeedBodyBytes+1))
+ if err != nil {
+ return feedFetchResult{}, fmt.Errorf("read feed response: %w", err)
+ }
+ if len(body) > maxFeedBodyBytes {
+ return feedFetchResult{}, errFeedResponseTooLarge
+ }
+ items, skipped, err := f.parseFeedResponse(body, f.now())
+ if err != nil {
+ return feedFetchResult{}, err
+ }
+
+ return feedFetchResult{
+ etag: response.Header.Get("ETag"),
+ lastModified: response.Header.Get("Last-Modified"),
+ items: items,
+ skippedNoIdentity: skipped,
+ }, nil
+}
+
+func (f *feedFetcher) parseFeedResponse(body []byte, fetchedAt time.Time) ([]normalizedFeedItem, int, error) {
+ detected := gofeed.DetectFeedType(bytes.NewReader(body))
+ if detected != gofeed.FeedTypeRSS && detected != gofeed.FeedTypeAtom {
+ return nil, 0, fmt.Errorf("unsupported feed format %q; expected RSS or Atom", detected)
+ }
+ feed, err := f.parser.Parse(bytes.NewReader(body))
+ if err != nil {
+ return nil, 0, fmt.Errorf("parse feed type %v: %w", detected, err)
+ }
+ items, skipped := normalizeSyndicationFeed(feed, fetchedAt)
+ return items, skipped, nil
+}
+
+func redactHTTPError(err error) error {
+ for {
+ var urlError *url.Error
+ if !errors.As(err, &urlError) || urlError.Err == nil || urlError.Err == err {
+ return err
+ }
+ err = urlError.Err
+ }
+}
+
+func normalizeSyndicationFeed(feed *gofeed.Feed, fetchedAt time.Time) ([]normalizedFeedItem, int) {
+ items := make([]normalizedFeedItem, 0, len(feed.Items))
+ seen := make(map[string]struct{}, len(feed.Items))
+ skipped := 0
+ for sourceIndex, item := range feed.Items {
+ if item == nil {
+ continue
+ }
+ identity, ok := feedItemIdentity(item)
+ if !ok {
+ skipped++
+ continue
+ }
+ if _, exists := seen[identity]; exists {
+ continue
+ }
+ seen[identity] = struct{}{}
+
+ publishedAt := fetchedAt
+ if item.PublishedParsed != nil {
+ publishedAt = *item.PublishedParsed
+ } else if item.UpdatedParsed != nil {
+ publishedAt = *item.UpdatedParsed
+ }
+ rawBody := firstNonEmpty(strings.TrimSpace(item.Content), strings.TrimSpace(item.Description))
+ title := strings.TrimSpace(item.Title)
+ if title == "" {
+ title = "(untitled)"
+ }
+ poster := feedItemPoster(item, feed, "News Feed")
+ items = append(items, normalizedFeedItem{
+ identity: identity,
+ article: hotline.NewsArtData{
+ Title: title,
+ Poster: poster,
+ Date: hotline.NewNewsTime(publishedAt),
+ DataFlav: slices.Clone(hotline.NewsFlavor),
+ Data: feedArticleBody(item, rawBody),
+ },
+ publishedAt: publishedAt,
+ sourceIndex: sourceIndex,
+ })
+ }
+
+ // Most feeds are newest-first. Dates establish the real order; reversing
+ // source order resolves equal or missing dates so newer items receive newer
+ // ordinary Hotline article IDs.
+ sort.SliceStable(items, func(i, j int) bool {
+ if items[i].publishedAt.Equal(items[j].publishedAt) {
+ return items[i].sourceIndex > items[j].sourceIndex
+ }
+ return items[i].publishedAt.Before(items[j].publishedAt)
+ })
+ return items, skipped
+}
+
+func feedItemIdentity(item *gofeed.Item) (string, bool) {
+ if value := strings.TrimSpace(item.GUID); value != "" {
+ return "id:" + value, true
+ }
+ if value := firstHTTPURL(append([]string{item.Link}, item.Links...)...); value != "" {
+ return "link:" + value, true
+ }
+ for _, enclosure := range item.Enclosures {
+ if enclosure != nil {
+ if value := safeHTTPURL(enclosure.URL); value != "" {
+ return "enclosure:" + value, true
+ }
+ }
+ }
+ return "", false
+}
+
+func feedItemPoster(item *gofeed.Item, feed *gofeed.Feed, fallback string) string {
+ for _, author := range item.Authors {
+ if author != nil && strings.TrimSpace(author.Name) != "" {
+ return strings.TrimSpace(author.Name)
+ }
+ }
+ if item.Author != nil && strings.TrimSpace(item.Author.Name) != "" {
+ return strings.TrimSpace(item.Author.Name)
+ }
+ for _, author := range feed.Authors {
+ if author != nil && strings.TrimSpace(author.Name) != "" {
+ return strings.TrimSpace(author.Name)
+ }
+ }
+ if feed.Author != nil && strings.TrimSpace(feed.Author.Name) != "" {
+ return strings.TrimSpace(feed.Author.Name)
+ }
+ if strings.TrimSpace(feed.Title) != "" {
+ return strings.TrimSpace(feed.Title)
+ }
+ return fallback
+}
+
+func feedArticleBody(item *gofeed.Item, rawBody string) string {
+ body := markupToPlainText(rawBody)
+ links := make([]string, 0, len(item.Enclosures)+2)
+ if source := firstHTTPURL(append([]string{item.Link}, item.Links...)...); source != "" {
+ links = append(links, "Source: "+source)
+ }
+ if releaseNotes := extensionValue(item, "sparkle", "releaseNotesLink"); releaseNotes != "" {
+ if releaseNotes = safeHTTPURL(releaseNotes); releaseNotes != "" {
+ links = appendUnique(links, "Release notes: "+releaseNotes)
+ }
+ }
+ for _, enclosure := range item.Enclosures {
+ if enclosure == nil {
+ continue
+ }
+ if download := safeHTTPURL(enclosure.URL); download != "" {
+ links = appendUnique(links, "Download: "+download)
+ }
+ }
+ if len(links) > 0 {
+ if body != "" {
+ body += "\r\r"
+ }
+ body += strings.Join(links, "\r")
+ }
+ return body
+}
+
+func extensionValue(item *gofeed.Item, namespace, name string) string {
+ if item.Extensions == nil || item.Extensions[namespace] == nil {
+ return ""
+ }
+ values := item.Extensions[namespace][name]
+ if len(values) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(values[0].Value)
+}
+
+func markupToPlainText(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ if !strings.Contains(value, "<") {
+ return normalizePlainText(value)
+ }
+
+ tokenizer := xhtml.NewTokenizer(strings.NewReader(value))
+ var out strings.Builder
+ var anchors []string
+ skipDepth := 0
+ for {
+ tokenType := tokenizer.Next()
+ switch tokenType {
+ case xhtml.ErrorToken:
+ return normalizePlainText(out.String())
+ case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
+ token := tokenizer.Token()
+ tag := strings.ToLower(token.Data)
+ if tag == "script" || tag == "style" {
+ if tokenType == xhtml.StartTagToken {
+ skipDepth++
+ }
+ continue
+ }
+ if skipDepth > 0 {
+ continue
+ }
+ switch tag {
+ case "br":
+ out.WriteByte('\n')
+ case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre":
+ writeLineBreak(&out, 2)
+ case "li":
+ writeLineBreak(&out, 1)
+ out.WriteString("- ")
+ case "a":
+ anchors = append(anchors, safeHTTPURL(attributeValue(token.Attr, "href")))
+ }
+ case xhtml.EndTagToken:
+ token := tokenizer.Token()
+ tag := strings.ToLower(token.Data)
+ if tag == "script" || tag == "style" {
+ if skipDepth > 0 {
+ skipDepth--
+ }
+ continue
+ }
+ if skipDepth > 0 {
+ continue
+ }
+ if tag == "a" && len(anchors) > 0 {
+ href := anchors[len(anchors)-1]
+ anchors = anchors[:len(anchors)-1]
+ if href != "" {
+ out.WriteString(" (")
+ out.WriteString(href)
+ out.WriteByte(')')
+ }
+ }
+ switch tag {
+ case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre":
+ writeLineBreak(&out, 2)
+ case "li":
+ writeLineBreak(&out, 1)
+ }
+ case xhtml.TextToken:
+ if skipDepth > 0 {
+ continue
+ }
+ text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
+ if text == "" {
+ continue
+ }
+ if needsSpace(out.String()) {
+ out.WriteByte(' ')
+ }
+ out.WriteString(text)
+ }
+ }
+}
+
+func normalizePlainText(value string) string {
+ value = strings.ReplaceAll(value, "\r\n", "\n")
+ value = strings.ReplaceAll(value, "\r", "\n")
+ lines := strings.Split(value, "\n")
+ out := make([]string, 0, len(lines))
+ blank := false
+ for _, line := range lines {
+ line = strings.Join(strings.Fields(line), " ")
+ if line == "" {
+ if len(out) > 0 && !blank {
+ out = append(out, "")
+ blank = true
+ }
+ continue
+ }
+ out = append(out, line)
+ blank = false
+ }
+ for len(out) > 0 && out[len(out)-1] == "" {
+ out = out[:len(out)-1]
+ }
+ return strings.Join(out, "\r")
+}
+
+func writeLineBreak(out *strings.Builder, count int) {
+ value := out.String()
+ newlines := 0
+ for i := len(value) - 1; i >= 0 && value[i] == '\n'; i-- {
+ newlines++
+ }
+ for newlines < count {
+ out.WriteByte('\n')
+ newlines++
+ }
+}
+
+func needsSpace(value string) bool {
+ if value == "" {
+ return false
+ }
+ last := value[len(value)-1]
+ return last != ' ' && last != '\n' && last != '\t' && last != '-'
+}
+
+func attributeValue(attrs []xhtml.Attribute, name string) string {
+ for _, attr := range attrs {
+ if strings.EqualFold(attr.Key, name) {
+ return strings.TrimSpace(attr.Val)
+ }
+ }
+ return ""
+}
+
+func safeHTTPURL(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ parsed, err := url.Parse(value)
+ if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+ return ""
+ }
+ return parsed.String()
+}
+
+func firstHTTPURL(values ...string) string {
+ for _, value := range values {
+ if parsed := safeHTTPURL(value); parsed != "" {
+ return parsed
+ }
+ }
+ return ""
+}
+
+func appendUnique(values []string, value string) []string {
+ if !slices.Contains(values, value) {
+ return append(values, value)
+ }
+ return values
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func truncateUTF8(value string, maxBytes int) string {
+ if len(value) <= maxBytes {
+ return value
+ }
+ value = value[:maxBytes]
+ for !utf8.ValidString(value) {
+ value = value[:len(value)-1]
+ }
+ return value
+}
diff --git a/internal/mobius/feed_news_import.go b/internal/mobius/feed_news_import.go
new file mode 100644
index 0000000..4baacd9
--- /dev/null
+++ b/internal/mobius/feed_news_import.go
@@ -0,0 +1,238 @@
+package mobius
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "slices"
+
+ "github.com/jhalter/mobius/hotline"
+)
+
+var errNewsArticleListTooLarge = errors.New("Hotline news article list exceeds 65,535 bytes")
+
+type feedImportResult struct {
+ count int
+ limitReached bool
+}
+
+func (n *ThreadedNewsYAML) validateFeedCategory(newsPath []string) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ _, _, category, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return errors.New("target category does not exist")
+ }
+ if category.Type != hotline.NewsCategory {
+ return errors.New("target must be an ordinary news category")
+ }
+ return nil
+}
+
+func (n *ThreadedNewsYAML) feedValidators(newsPath []string, sourceURL string) (feedHTTPValidators, error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ _, _, category, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return feedHTTPValidators{}, errors.New("feed target category no longer exists")
+ }
+ if category.Type != hotline.NewsCategory {
+ return feedHTTPValidators{}, errors.New("feed target is no longer an ordinary news category")
+ }
+ if category.FeedState == nil || category.FeedState.SourceURL != sourceURL {
+ return feedHTTPValidators{}, nil
+ }
+ return feedHTTPValidators{
+ etag: category.FeedState.ETag,
+ lastModified: category.FeedState.LastModified,
+ }, nil
+}
+
+func (n *ThreadedNewsYAML) importFeedArticles(
+ newsPath []string,
+ sourceURL string,
+ etag string,
+ lastModified string,
+ items []normalizedFeedItem,
+) (feedImportResult, error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ parent, name, original, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return feedImportResult{}, errors.New("feed target category no longer exists")
+ }
+ if original.Type != hotline.NewsCategory {
+ return feedImportResult{}, errors.New("feed target is no longer an ordinary news category")
+ }
+
+ working := cloneNewsCategory(original)
+ if working.Articles == nil {
+ working.Articles = make(map[uint32]*hotline.NewsArtData)
+ }
+ if working.FeedState == nil {
+ working.FeedState = &hotline.NewsFeedCategoryState{Imported: make(map[string]uint32)}
+ }
+ if working.FeedState.Imported == nil {
+ working.FeedState.Imported = make(map[string]uint32)
+ }
+
+ result := feedImportResult{}
+ for _, item := range items {
+ identityHash := feedIdentityHash(sourceURL, item.identity)
+ if _, seen := working.FeedState.Imported[identityHash]; seen {
+ continue
+ }
+
+ articleID, previousID, err := nextNewsArticleID(working.Articles)
+ if err != nil {
+ return feedImportResult{}, err
+ }
+ article := item.article
+ article.PrevArt = [4]byte{}
+ article.NextArt = [4]byte{}
+ article.ParentArt = [4]byte{}
+ article.FirstChildArt = [4]byte{}
+ article.DataFlav = slices.Clone(hotline.NewsFlavor)
+
+ var oldPreviousNext [4]byte
+ if previousID != 0 {
+ oldPreviousNext = working.Articles[previousID].NextArt
+ binary.BigEndian.PutUint32(article.PrevArt[:], previousID)
+ binary.BigEndian.PutUint32(working.Articles[previousID].NextArt[:], articleID)
+ }
+ working.Articles[articleID] = &article
+
+ if err := validateNewsArticleListSize(working.Articles); err != nil {
+ delete(working.Articles, articleID)
+ if previousID != 0 {
+ working.Articles[previousID].NextArt = oldPreviousNext
+ }
+ if errors.Is(err, errNewsArticleListTooLarge) {
+ result.limitReached = true
+ break
+ }
+ return feedImportResult{}, err
+ }
+
+ working.FeedState.Imported[identityHash] = articleID
+ result.count++
+ }
+
+ if !result.limitReached {
+ working.FeedState.SourceURL = sourceURL
+ working.FeedState.ETag = etag
+ working.FeedState.LastModified = lastModified
+ }
+
+ if result.count == 0 && feedStateEqual(original.FeedState, working.FeedState) {
+ return result, nil
+ }
+ parent[name] = working
+ if err := n.writeFile(); err != nil {
+ parent[name] = original
+ return feedImportResult{}, fmt.Errorf("persist imported feed articles: %w", err)
+ }
+ return result, nil
+}
+
+func (n *ThreadedNewsYAML) feedCategoryLocked(newsPath []string) (map[string]hotline.NewsCategoryListData15, string, hotline.NewsCategoryListData15, bool) {
+ if len(newsPath) == 0 {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ categories := n.ThreadedNews.Categories
+ for i, segment := range newsPath {
+ category, ok := categories[segment]
+ if !ok {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ if i == len(newsPath)-1 {
+ return categories, segment, category, true
+ }
+ categories = category.SubCats
+ if categories == nil {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ }
+ return nil, "", hotline.NewsCategoryListData15{}, false
+}
+
+func cloneNewsCategory(category hotline.NewsCategoryListData15) hotline.NewsCategoryListData15 {
+ clone := category
+ clone.Articles = make(map[uint32]*hotline.NewsArtData, len(category.Articles))
+ for id, article := range category.Articles {
+ if article == nil {
+ clone.Articles[id] = nil
+ continue
+ }
+ articleClone := *article
+ articleClone.DataFlav = slices.Clone(article.DataFlav)
+ clone.Articles[id] = &articleClone
+ }
+ clone.SubCats = make(map[string]hotline.NewsCategoryListData15, len(category.SubCats))
+ for name, subcategory := range category.SubCats {
+ clone.SubCats[name] = cloneNewsCategory(subcategory)
+ }
+ if category.FeedState != nil {
+ state := *category.FeedState
+ state.Imported = make(map[string]uint32, len(category.FeedState.Imported))
+ for identity, id := range category.FeedState.Imported {
+ state.Imported[identity] = id
+ }
+ clone.FeedState = &state
+ }
+ return clone
+}
+
+func nextNewsArticleID(articles map[uint32]*hotline.NewsArtData) (uint32, uint32, error) {
+ var previousID uint32
+ for id := range articles {
+ if id > previousID {
+ previousID = id
+ }
+ }
+ if previousID == math.MaxUint32 {
+ return 0, 0, errors.New("news category exhausted article IDs")
+ }
+ return previousID + 1, previousID, nil
+}
+
+func validateNewsArticleListSize(articles map[uint32]*hotline.NewsArtData) error {
+ category := hotline.NewsCategoryListData15{Articles: articles}
+ list, err := category.GetNewsArtListData()
+ if err != nil {
+ return err
+ }
+ encoded, err := io.ReadAll(&list)
+ if err != nil {
+ return err
+ }
+ if len(encoded) > math.MaxUint16 {
+ return errNewsArticleListTooLarge
+ }
+ return nil
+}
+
+func feedIdentityHash(sourceURL, identity string) string {
+ hash := sha256.Sum256([]byte(sourceURL + "\x00" + identity))
+ return hex.EncodeToString(hash[:])
+}
+
+func feedStateEqual(a, b *hotline.NewsFeedCategoryState) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+ if a.SourceURL != b.SourceURL || a.ETag != b.ETag || a.LastModified != b.LastModified || len(a.Imported) != len(b.Imported) {
+ return false
+ }
+ for identity, id := range a.Imported {
+ if b.Imported[identity] != id {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/mobius/feed_news_test.go b/internal/mobius/feed_news_test.go
new file mode 100644
index 0000000..037d1a0
--- /dev/null
+++ b/internal/mobius/feed_news_test.go
@@ -0,0 +1,462 @@
+package mobius
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ textencoding "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/charmap"
+)
+
+const testFeedURL = "https://example.com/releases.xml"
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return f(request)
+}
+
+func testFeedResponse(status int, body string, headers map[string]string) *http.Response {
+ response := &http.Response{
+ StatusCode: status,
+ Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+ for key, value := range headers {
+ response.Header.Set(key, value)
+ }
+ return response
+}
+
+func testNewsFeedConfig(url string, path ...string) hotline.NewsFeedConfig {
+ return hotline.NewsFeedConfig{CategoryPath: path, URL: url}
+}
+
+func addTestNewsCategory(t *testing.T, base *ThreadedNewsYAML, path []string, name string) {
+ t.Helper()
+ require.NoError(t, base.CreateGrouping(path, name, hotline.NewsCategory))
+}
+
+func newTestFeedNewsManager(
+ t *testing.T,
+ base *ThreadedNewsYAML,
+ config hotline.NewsFeedConfig,
+ transport http.RoundTripper,
+ encoding string,
+) *FeedNewsManager {
+ t.Helper()
+ manager, err := NewFeedNewsManager(
+ base,
+ []hotline.NewsFeedConfig{config},
+ slog.New(slog.NewTextHandler(io.Discard, nil)),
+ "mobius-test",
+ encoding,
+ )
+ require.NoError(t, err)
+ if transport != nil {
+ manager.fetcher.client = &http.Client{Transport: transport}
+ }
+ return manager
+}
+
+func feedState(t *testing.T, base *ThreadedNewsYAML, path ...string) *hotline.NewsFeedCategoryState {
+ t.Helper()
+ state := base.NewsItem(path).FeedState
+ require.NotNil(t, state)
+ return state
+}
+
+func TestFeedNewsManager_ImportsAfterglowAsOrdinaryNews(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Afterglow Releases")
+ requests := 0
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Afterglow Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ assert.Equal(t, "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8", request.Header.Get("Accept"))
+ assert.Equal(t, "mobius-test", request.Header.Get("User-Agent"))
+ return testFeedResponse(http.StatusOK, feedFixture(t, "afterglow-appcast.xml"), map[string]string{
+ "ETag": `"afterglow-23"`,
+ "Last-Modified": "Sat, 22 Aug 2026 18:09:55 GMT",
+ }), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Afterglow Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, 1, requests)
+
+ state := feedState(t, base, "Afterglow Releases")
+ assert.Equal(t, testFeedURL, state.SourceURL)
+ assert.Equal(t, `"afterglow-23"`, state.ETag)
+ require.Len(t, state.Imported, 1)
+ var rootID uint32
+ for _, id := range state.Imported {
+ rootID = id
+ }
+ root := manager.GetArticle([]string{"Afterglow Releases"}, rootID)
+ require.NotNil(t, root)
+ assert.Equal(t, "Afterglow 1.0rc4", root.Title)
+ assert.Equal(t, "Afterglow", root.Poster)
+ assert.Contains(t, root.Data, "Fixed main-window resizing.")
+ assert.Contains(t, root.Data, "Release notes: https://morphing.cloud/afterglow/1.0rc4-notes.html")
+ assert.Contains(t, root.Data, "Download: https://morphing.cloud/afterglow/Afterglow-v1.0rc4.zip")
+ assert.NotContains(t, root.Data, "<h3>")
+ expectedTime := time.Date(2026, time.August, 22, 18, 9, 55, 0, time.FixedZone("appcast", -7*60*60))
+ assert.True(t, hotline.Time(root.Date).NewsTime().Equal(expectedTime))
+
+ // Imported roots are completely ordinary: local roots, replies, and deletion
+ // all use the conventional manager.
+ require.NoError(t, manager.PostArticle([]string{"Afterglow Releases"}, 0, hotline.NewsArtData{Title: "Local root"}))
+ require.NoError(t, manager.PostArticle([]string{"Afterglow Releases"}, rootID, hotline.NewsArtData{Title: "Reply"}))
+ require.NoError(t, manager.DeleteArticle([]string{"Afterglow Releases"}, rootID, true))
+ assert.Nil(t, manager.GetArticle([]string{"Afterglow Releases"}, rootID))
+
+ // The durable seen marker survives deletion, so the same feed entry stays
+ // deleted even though the source still returns it.
+ list, err = manager.ListArticles([]string{"Afterglow Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count)
+ assert.Nil(t, manager.GetArticle([]string{"Afterglow Releases"}, rootID))
+ assert.Equal(t, 2, requests)
+
+ yamlData, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(yamlData), "FeedState:")
+ assert.Contains(t, string(yamlData), "Imported:")
+ assert.NoFileExists(t, filepath.Join(filepath.Dir(base.filePath), "FeedNewsState.yaml"))
+ assert.NoFileExists(t, filepath.Join(filepath.Dir(base.filePath), "FeedNewsCache.json"))
+}
+
+func TestFeedNewsManager_AutoDetectsAtomAndIgnoresUpdates(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Mobius Releases")
+ var requests int
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Mobius Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ if requests == 1 {
+ return testFeedResponse(http.StatusOK, feedFixture(t, "mobius-releases.atom"), map[string]string{"ETag": `"v1"`}), nil
+ }
+ assert.Equal(t, `"v1"`, request.Header.Get("If-None-Match"))
+ return testFeedResponse(http.StatusOK, atomBatchFixture(
+ atomEntry("v0.22.0 renamed", "tag:github.com,2008:Repository/272052223/v0.22.0", "2026-06-12T16:03:33Z"),
+ atomEntry("v0.23.0", "tag:github.com,2008:Repository/272052223/v0.23.0", "2026-07-01T12:00:00Z"),
+ ), map[string]string{"ETag": `"v2"`}), nil
+ }), "utf8")
+
+ _, err := manager.ListArticles([]string{"Mobius Releases"})
+ require.NoError(t, err)
+ first := manager.GetArticle([]string{"Mobius Releases"}, 1)
+ require.NotNil(t, first)
+ assert.Equal(t, "v0.22.0", first.Title)
+
+ list, err := manager.ListArticles([]string{"Mobius Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count)
+ assert.Equal(t, "v0.22.0", manager.GetArticle([]string{"Mobius Releases"}, 1).Title, "same-identity changes must be ignored")
+ assert.Equal(t, "v0.23.0", manager.GetArticle([]string{"Mobius Releases"}, 2).Title)
+ assert.Equal(t, `"v2"`, feedState(t, base, "Mobius Releases").ETag)
+}
+
+func TestFeedNewsManager_RestartUsesConditionalGETWithoutDuplicates(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ config := testNewsFeedConfig(testFeedURL, "Releases")
+ manager := newTestFeedNewsManager(t, base, config, roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), map[string]string{
+ "ETag": `"one"`,
+ "Last-Modified": "Sat, 22 Aug 2026 18:09:55 GMT",
+ }), nil
+ }), "utf8")
+ _, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+
+ reloaded, err := NewThreadedNewsYAML(base.filePath)
+ require.NoError(t, err)
+ restarted := newTestFeedNewsManager(t, reloaded, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ assert.Equal(t, `"one"`, request.Header.Get("If-None-Match"))
+ assert.Equal(t, "Sat, 22 Aug 2026 18:09:55 GMT", request.Header.Get("If-Modified-Since"))
+ return testFeedResponse(http.StatusNotModified, "", nil), nil
+ }), "utf8")
+ before, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ list, err := restarted.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ after, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, before, after, "304 responses must not rewrite ThreadedNews.yaml")
+}
+
+func TestFeedNewsManager_SourceURLChangesKeepSeenHistory(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ path := []string{"Releases"}
+ urlA := "https://example.com/a.xml"
+ urlB := "https://example.com/b.xml"
+ fixture := rssFixture("Release", "shared-id", "Sat, 22 Aug 2026 18:09:55 -0700")
+
+ for _, sourceURL := range []string{urlA, urlB, urlA} {
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(sourceURL, path...), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ assert.Empty(t, request.Header.Get("If-None-Match"), "a URL switch must clear validators")
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"etag"`}), nil
+ }), "utf8")
+ _, err := manager.ListArticles(path)
+ require.NoError(t, err)
+ }
+
+ list, err := base.ListArticles(path)
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count, "the same ID is distinct across URLs but not reimported after switching back")
+ state := feedState(t, base, path...)
+ assert.Equal(t, urlA, state.SourceURL)
+ assert.Len(t, state.Imported, 2)
+}
+
+func TestFeedNewsManager_FailuresServeLocalAndEachLoadRetries(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ var requests atomic.Int32
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ if requests.Add(1) == 1 {
+ return nil, errors.New("offline")
+ }
+ return testFeedResponse(http.StatusOK, rssFixture("Recovered", "recovered", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ list, err = manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, int32(2), requests.Load())
+}
+
+func TestFeedNewsManager_CoalescesConcurrentLoads(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ var requests atomic.Int32
+ requestStarted := make(chan struct{})
+ releaseRequest := make(chan struct{})
+ var startedOnce sync.Once
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ requests.Add(1)
+ startedOnce.Do(func() { close(requestStarted) })
+ <-releaseRequest
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+
+ const callers = 12
+ start := make(chan struct{})
+ var ready sync.WaitGroup
+ var done sync.WaitGroup
+ ready.Add(callers)
+ done.Add(callers)
+ for range callers {
+ go func() {
+ defer done.Done()
+ ready.Done()
+ <-start
+ _, err := manager.ListArticles([]string{"Releases"})
+ assert.NoError(t, err)
+ }()
+ }
+ ready.Wait()
+ close(start)
+ <-requestStarted
+ time.Sleep(25 * time.Millisecond)
+ close(releaseRequest)
+ done.Wait()
+ assert.Equal(t, int32(1), requests.Load())
+}
+
+func TestFeedNewsManager_SkipsEntriesWithoutIdentityAndRejectsJSON(t *testing.T) {
+ t.Run("missing identity", func(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ fixture := `<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title><item><title>No identity</title><description>Notes</description></item></channel></rss>`
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"empty"`}), nil
+ }), "utf8")
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ assert.Empty(t, feedState(t, base, "Releases").Imported)
+ })
+
+ t.Run("JSON", func(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, `{"version":"https://jsonfeed.org/version/1.1","items":[]}`, nil), nil
+ }), "utf8")
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ assert.Nil(t, base.NewsItem([]string{"Releases"}).FeedState)
+ })
+}
+
+func TestFeedNewsManager_EncodingIsAppliedOnceAtImport(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ title := "Café — Snowman ☃"
+ fixture := rssFixture(title, "unicode", "Sat, 22 Aug 2026 18:09:55 -0700")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, fixture, nil), nil
+ }), "macintosh")
+ _, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+
+ wantTitle, err := textencoding.ReplaceUnsupported(charmap.Macintosh.NewEncoder()).String(title)
+ require.NoError(t, err)
+ article := base.GetArticle([]string{"Releases"}, 1)
+ require.NotNil(t, article)
+ assert.Equal(t, wantTitle, article.Title)
+ yamlData, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(yamlData), "!!binary", "MacRoman bytes that are invalid UTF-8 must round-trip through YAML")
+
+ reloaded, err := NewThreadedNewsYAML(base.filePath)
+ require.NoError(t, err)
+ assert.Equal(t, wantTitle, reloaded.GetArticle([]string{"Releases"}, 1).Title)
+
+ // Changing server encoding later affects future imports only.
+ utf8Manager := newTestFeedNewsManager(t, reloaded, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("UTF-8 ☃", "unicode-2", "Sun, 23 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+ _, err = utf8Manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, wantTitle, reloaded.GetArticle([]string{"Releases"}, 1).Title)
+ assert.Equal(t, "UTF-8 ☃", reloaded.GetArticle([]string{"Releases"}, 2).Title)
+}
+
+func TestFeedNewsManager_ArticleListLimitPersistsFittingPrefixAndRetries(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ fixture := rssBatchFixture("large", 300, 255)
+ var requests atomic.Int32
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests.Add(1)
+ assert.Empty(t, request.Header.Get("If-None-Match"), "validators must not advance while unseen entries remain")
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"too-large"`}), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Greater(t, list.Count, 0)
+ assert.Less(t, list.Count, 300)
+ encoded, err := io.ReadAll(&list)
+ require.NoError(t, err)
+ assert.LessOrEqual(t, len(encoded), 65535)
+ state := feedState(t, base, "Releases")
+ assert.Empty(t, state.ETag)
+ assert.Len(t, state.Imported, list.Count)
+
+ second, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, list.Count, second.Count)
+ assert.Equal(t, int32(2), requests.Load(), "the first unseen item must be retried on the next load")
+}
+
+func TestFeedNewsManager_ValidatesExistingOrdinaryTarget(t *testing.T) {
+ base := newTestThreadedNews(t)
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ _, err := NewFeedNewsManager(base, []hotline.NewsFeedConfig{testNewsFeedConfig(testFeedURL, "Missing")}, logger, "test", "utf8")
+ require.ErrorContains(t, err, "does not exist")
+ _, err = NewFeedNewsManager(base, []hotline.NewsFeedConfig{testNewsFeedConfig(testFeedURL, "Archive")}, logger, "test", "utf8")
+ require.ErrorContains(t, err, "ordinary news category")
+ _, err = NewFeedNewsManager(base, []hotline.NewsFeedConfig{
+ testNewsFeedConfig("https://example.com/one.xml", "General"),
+ testNewsFeedConfig("https://example.com/two.xml", "General"),
+ }, logger, "test", "utf8")
+ require.ErrorContains(t, err, "duplicates")
+}
+
+func TestFeedNewsManager_WriteFailureRollsBackImport(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+ base.filePath = filepath.Join(t.TempDir(), "missing", "ThreadedNews.yaml")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err, "import failures serve local news")
+ assert.Zero(t, list.Count)
+ item := base.NewsItem([]string{"Releases"})
+ assert.Empty(t, item.Articles)
+ assert.Nil(t, item.FeedState)
+}
+
+func TestFeedFetcherLimitsAndConditional304(t *testing.T) {
+ t.Run("oversized response", func(t *testing.T) {
+ fetcher := newFeedFetcher("test")
+ fetcher.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, strings.Repeat("x", maxFeedBodyBytes+1), nil), nil
+ })}
+ _, err := fetcher.fetch(t.Context(), testFeedURL, feedHTTPValidators{})
+ assert.ErrorIs(t, err, errFeedResponseTooLarge)
+ })
+
+ t.Run("unprompted 304", func(t *testing.T) {
+ fetcher := newFeedFetcher("test")
+ fetcher.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusNotModified, "", nil), nil
+ })}
+ _, err := fetcher.fetch(t.Context(), testFeedURL, feedHTTPValidators{})
+ require.ErrorContains(t, err, "without a conditional request")
+ })
+}
+
+func feedFixture(t *testing.T, name string) string {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("testdata", name))
+ require.NoError(t, err)
+ return string(data)
+}
+
+func rssFixture(title, guid, published string) string {
+ return fmt.Sprintf(`<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title><item><title>%s</title><guid>%s</guid><pubDate>%s</pubDate><description><![CDATA[<p>Notes for %s.</p>]]></description></item></channel></rss>`, title, guid, published, title)
+}
+
+func atomEntry(title, id, updated string) string {
+ return fmt.Sprintf(`<entry><id>%s</id><updated>%s</updated><title>%s</title><content type="html">Notes</content><author><name>Mobius</name></author></entry>`, id, updated, title)
+}
+
+func atomBatchFixture(entries ...string) string {
+ return `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Releases</title>` + strings.Join(entries, "") + `</feed>`
+}
+
+func rssBatchFixture(prefix string, count, titleSize int) string {
+ var builder strings.Builder
+ builder.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title>`)
+ for i := 0; i < count; i++ {
+ title := strings.Repeat("x", titleSize)
+ fmt.Fprintf(&builder, `<item><title>%s</title><guid>%s-%d</guid><description>Notes</description></item>`, title, prefix, i)
+ }
+ builder.WriteString(`</channel></rss>`)
+ return builder.String()
+}
+
+func TestEncodeFeedTextDoesNotSplitUTF8(t *testing.T) {
+ value := strings.Repeat("é", 200)
+ encoded := truncateEncodedFeedText(value, 255, "utf8")
+ assert.True(t, bytes.Equal([]byte(encoded), []byte(strings.Repeat("é", 127))))
+}
diff --git a/internal/mobius/handlers_news.go b/internal/mobius/handlers_news.go
index 29b0ae2..368375e 100644
--- a/internal/mobius/handlers_news.go
+++ b/internal/mobius/handlers_news.go
@@ -342,7 +342,7 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
hotline.NewsArtData{
Title: string(t.GetField(hotline.FieldNewsArtTitle).Data),
Poster: string(cc.GetUserName()),
- Date: hotline.NewTime(time.Now()),
+ Date: hotline.NewNewsTime(time.Now()),
DataFlav: hotline.NewsFlavor,
Data: string(t.GetField(hotline.FieldNewsArtData).Data),
},
diff --git a/internal/mobius/testdata/afterglow-appcast.xml b/internal/mobius/testdata/afterglow-appcast.xml
new file mode 100644
index 0000000..b31831e
--- /dev/null
+++ b/internal/mobius/testdata/afterglow-appcast.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
+ <channel>
+ <title>Afterglow</title>
+ <link>https://morphing.cloud/afterglow/appcast.xml</link>
+ <description>Most recent updates to Afterglow</description>
+ <item>
+ <title>Afterglow 1.0rc4</title>
+ <pubDate>Sat, 22 Aug 2026 18:09:55 -0700</pubDate>
+ <sparkle:version>23</sparkle:version>
+ <sparkle:releaseNotesLink>https://morphing.cloud/afterglow/1.0rc4-notes.html</sparkle:releaseNotesLink>
+ <description><![CDATA[
+ <h3>Fixes</h3>
+ <ul>
+ <li>Fixed main-window resizing.</li>
+ <li>Restored the missing <strong>Quit</strong> command.</li>
+ </ul>
+ ]]></description>
+ <enclosure url="https://morphing.cloud/afterglow/Afterglow-v1.0rc4.zip" length="33560698" type="application/octet-stream"/>
+ </item>
+ </channel>
+</rss>
diff --git a/internal/mobius/testdata/mobius-releases.atom b/internal/mobius/testdata/mobius-releases.atom
new file mode 100644
index 0000000..f928f60
--- /dev/null
+++ b/internal/mobius/testdata/mobius-releases.atom
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
+ <id>tag:github.com,2008:https://github.com/jhalter/mobius/releases</id>
+ <link type="text/html" rel="alternate" href="https://github.com/jhalter/mobius/releases"/>
+ <link type="application/atom+xml" rel="self" href="https://github.com/jhalter/mobius/releases.atom"/>
+ <title>Release notes from mobius</title>
+ <updated>2026-06-12T15:55:05Z</updated>
+ <entry>
+ <id>tag:github.com,2008:Repository/272052223/v0.22.0</id>
+ <updated>2026-06-12T16:03:33Z</updated>
+ <link rel="alternate" type="text/html" href="https://github.com/jhalter/mobius/releases/tag/v0.22.0"/>
+ <title>v0.22.0</title>
+ <content type="html">&lt;h2&gt;Changelog&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;Fix data races&lt;/li&gt;&lt;li&gt;Improve graceful shutdown&lt;/li&gt;&lt;/ul&gt;</content>
+ <author><name>jhalter</name></author>
+ </entry>
+</feed>