diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-08-23 12:44:53 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-08-23 12:44:53 -0700 |
| commit | 68f615cc6e6ac177109db5b3be4f9767c9cfd1a8 (patch) | |
| tree | 37380267c1356e67783e9273495bfa099048486c /internal | |
| parent | 4df6b97311e321c1ef178efce905307b10ce6fa7 (diff) | |
Add feed-backed threaded news imports
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/mobius/config.go | 37 | ||||
| -rw-r--r-- | internal/mobius/config_test.go | 91 | ||||
| -rw-r--r-- | internal/mobius/feed_news.go | 217 | ||||
| -rw-r--r-- | internal/mobius/feed_news_fetch.go | 478 | ||||
| -rw-r--r-- | internal/mobius/feed_news_import.go | 238 | ||||
| -rw-r--r-- | internal/mobius/feed_news_test.go | 462 | ||||
| -rw-r--r-- | internal/mobius/handlers_news.go | 2 | ||||
| -rw-r--r-- | internal/mobius/testdata/afterglow-appcast.xml | 22 | ||||
| -rw-r--r-- | internal/mobius/testdata/mobius-releases.atom | 16 |
9 files changed, 1562 insertions, 1 deletions
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"><h2>Changelog</h2><ul><li>Fix data races</li><li>Improve graceful shutdown</li></ul></content> + <author><name>jhalter</name></author> + </entry> +</feed> |