From c7440bb29056a3a4f127fe918e633773c99c1630 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 13:38:57 -0700 Subject: [PATCH 1/2] Upgrade ROME to 2.1.0 ROME 2.1.0 is the latest release on Maven Central. Its classes target Java 8 (class file version 52), so it runs on Roller's Java 17 baseline. Roller depends only on the core rome artifact, with rome-utils 2.1.0 coming in transitively; the modules ROME 2 removed (rome-fetcher, rome-propono, rome-certiorem) were already gone from Roller. ROME 2 makes an Atom feed's rel="self" link the SyndFeed link, where 1.x used the first alternate link. Planet stores that link as the subscription's site URL, so Atom subscriptions would have linked to the feed instead of the blog. RomeFeedFetcher now takes the first alternate link from the feed's links and falls back to the feed link, which is still the channel link for RSS. Nothing else Planet relies on changed: dates are still java.util.Date, rome.properties keeps the same keys and the custom parsers, converters and content module still load, and SyndFeedInput still refuses DOCTYPEs and external entities by default. RomeFeedFetcherLocalTest serves Atom, RSS 2.0 and RSS 0.91 feeds from a loopback server and pins the site URL, entry dates and time zones, authors, content, categories, the conditional fetch, the Planet plugins registered in rome.properties, and the refusal of a feed with a DOCTYPE. All six tests also pass on ROME 1.19.0 with the previous fetcher, and the site URL check fails on 2.1.0 without the fix. --- CHANGES.md | 1 + app/pom.xml | 2 +- .../business/fetcher/RomeFeedFetcher.java | 17 +- .../fetcher/RomeFeedFetcherLocalTest.java | 270 ++++++++++++++++++ 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java diff --git a/CHANGES.md b/CHANGES.md index 631a2498db..947f582966 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -134,6 +134,7 @@ - The source distribution now extracts to `apache-roller--source/`, matching its file name, instead of `roller-weblogger--source/`. +- ROME, the feed library Planet uses, is upgraded from 1.19.0 to 2.1.0. ## 6.1.7 diff --git a/app/pom.xml b/app/pom.xml index 43c8b51ef8..d8df727321 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -52,7 +52,7 @@ limitations under the License. 3.5.1 3.6.0 1.0b3 - 1.19.0 + 2.1.0 2.0.20 7.0.9 7.1.1 diff --git a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java index 1f38660044..1f43579578 100644 --- a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java +++ b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java @@ -23,6 +23,7 @@ import com.rometools.rome.feed.synd.SyndContent; import com.rometools.rome.feed.synd.SyndEntry; import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.feed.synd.SyndLink; import com.rometools.rome.io.FeedException; import com.rometools.rome.io.SyndFeedInput; import com.rometools.rome.io.XmlReader; @@ -100,7 +101,7 @@ public Subscription fetchSubscription(String feedURL, Date lastModified) throws // build planet subscription from fetched feed Subscription newSub = new Subscription(); newSub.setFeedURL(feedURL); - newSub.setSiteURL(feed.getLink()); + newSub.setSiteURL(siteURL(feed)); newSub.setTitle(feed.getTitle()); newSub.setAuthor(feed.getAuthor()); newSub.setLastUpdated(feed.getPublishedDate()); @@ -159,6 +160,20 @@ public Subscription fetchSubscription(String feedURL, Date lastModified) throws } + // ROME 2 makes an Atom feed's rel="self" link (the feed itself) its link, + // so look for the alternate link to the site first. RSS feeds have no + // links list and keep using the channel link. + private static String siteURL(SyndFeed feed) { + for (SyndLink link : feed.getLinks()) { + String rel = link.getRel(); + if (StringUtils.isBlank(rel) || "alternate".equals(rel)) { + return link.getHref(); + } + } + return feed.getLink(); + } + + // build a SubscriptionEntry from Rome SyndEntry and SyndFeed private SubscriptionEntry buildEntry(SyndEntry romeEntry) { diff --git a/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java new file mode 100644 index 0000000000..282486c876 --- /dev/null +++ b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.planet.business.fetcher; + +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.impl.Converters; +import com.rometools.rome.io.ParsingFeedException; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.impl.FeedParsers; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.roller.planet.pojos.Subscription; +import org.apache.roller.planet.pojos.SubscriptionEntry; +import org.apache.roller.planet.util.rome.ContentModule; +import org.apache.roller.planet.util.rome.PlanetConverterForRSS091N; +import org.apache.roller.planet.util.rome.PlanetConverterForRSS091U; +import org.apache.roller.planet.util.rome.PlanetConverterForRSS20; +import org.apache.roller.planet.util.rome.PlanetRSS091UParser; +import org.apache.roller.weblogger.util.SafeSAXBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Fetches feeds from a loopback HTTP server, so the mapping from ROME's model + * to Planet's is checked without reaching the network. + */ +class RomeFeedFetcherLocalTest { + + private static final String ATOM = """ + + + Atom Blog + urn:example:atom + + + 2026-10-12T08:30:00Z + + urn:example:atom:1 + Atom entry + Entry Author + + 2026-10-10T20:00:00-04:00 + 2026-10-11T09:15:00+02:00 + + + <p>Atom body</p> + + + """; + + private static final String RSS_20 = """ + + + + RSS Blog + https://rss.example.org/ + + RSS 2.0 feed + + With pubDate + https://rss.example.org/1 + Sat, 10 Oct 2026 20:00:00 -0400 + Dee Creator + planet + Summary one + Full text one

]]>
+
+ + With dc:date + https://rss.example.org/2 + 2026-10-09T12:00:00+02:00 + Summary two + +
+
+ """; + + private static final String RSS_091 = """ + + + + Old Blog + https://old.example.org/ + RSS 0.91 feed + en-us + + Old item + https://old.example.org/1 + Old text + Fri, 09 Oct 2026 08:00:00 GMT + + + + """; + + private static final Map FEEDS = new ConcurrentHashMap<>(); + + private static HttpServer server; + + private final RomeFeedFetcher fetcher = new RomeFeedFetcher(); + + @BeforeAll + static void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + String body = FEEDS.get(exchange.getRequestURI().getPath()); + byte[] bytes = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/xml; charset=utf-8"); + exchange.sendResponseHeaders(body == null ? 404 : 200, bytes.length == 0 ? -1 : bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + }); + server.start(); + } + + @AfterAll + static void stopServer() { + server.stop(0); + } + + @Test + void atomFeedUsesAlternateLinkAsSiteUrl() throws Exception { + String url = serve("/atom", ATOM); + + Subscription sub = fetcher.fetchSubscription(url); + + assertEquals(url, sub.getFeedURL()); + // ROME 2 reports the rel="self" link as the feed link; Planet wants the site + assertEquals("https://atom.example.org/", sub.getSiteURL()); + assertEquals("Atom Blog", sub.getTitle()); + assertEquals(Instant.parse("2026-10-12T08:30:00Z"), sub.getLastUpdated().toInstant()); + + SubscriptionEntry entry = entry(sub, "https://atom.example.org/entry/1"); + assertEquals("Atom entry", entry.getTitle()); + assertEquals("Entry Author", entry.getAuthor()); + assertEquals(timestamp("2026-10-11T00:00:00Z"), entry.getPubTime()); + assertEquals(timestamp("2026-10-11T07:15:00Z"), entry.getUpdateTime()); + assertEquals("

Atom body

", entry.getText()); + assertEquals("java,feeds", entry.getCategoriesString()); + } + + @Test + void unchangedFeedIsSkipped() throws Exception { + String url = serve("/atom-unchanged", ATOM); + + Date updated = Date.from(Instant.parse("2026-10-12T08:30:00Z")); + assertNull(fetcher.fetchSubscription(url, updated)); + assertNotNull(fetcher.fetchSubscription(url, new Date(updated.getTime() - 1))); + } + + @Test + void rss20FeedMapsDatesAuthorsAndContent() throws Exception { + String url = serve("/rss20", RSS_20); + + Subscription sub = fetcher.fetchSubscription(url); + + assertEquals("https://rss.example.org/", sub.getSiteURL()); + assertEquals("RSS Blog", sub.getTitle()); + + SubscriptionEntry first = entry(sub, "https://rss.example.org/1"); + assertEquals("Dee Creator", first.getAuthor()); + assertEquals(timestamp("2026-10-11T00:00:00Z"), first.getPubTime()); + assertEquals("

Full text one

", first.getText()); + assertEquals("planet", first.getCategoriesString()); + + SubscriptionEntry second = entry(sub, "https://rss.example.org/2"); + assertEquals(timestamp("2026-10-09T10:00:00Z"), second.getPubTime()); + assertEquals("Summary two", second.getText()); + } + + @Test + void rss091ItemPubDateIsUsed() throws Exception { + String url = serve("/rss091", RSS_091); + + Subscription sub = fetcher.fetchSubscription(url); + + assertEquals("https://old.example.org/", sub.getSiteURL()); + SubscriptionEntry entry = entry(sub, "https://old.example.org/1"); + assertEquals(timestamp("2026-10-09T08:00:00Z"), entry.getPubTime()); + assertEquals("Old text", entry.getText()); + } + + @Test + void romePropertiesRegistersPlanetPlugins() throws Exception { + Converters converters = new Converters(); + assertInstanceOf(PlanetConverterForRSS20.class, converters.getConverter("rss_2.0")); + assertInstanceOf(PlanetConverterForRSS091U.class, converters.getConverter("rss_0.91U")); + assertInstanceOf(PlanetConverterForRSS091N.class, converters.getConverter("rss_0.91N")); + + assertInstanceOf(PlanetRSS091UParser.class, new FeedParsers() + .getParserFor(new SafeSAXBuilder().build(new StringReader(RSS_091)))); + + SyndEntry item = new SyndFeedInput().build(new StringReader(RSS_20)).getEntries().get(0); + ContentModule content = assertInstanceOf(ContentModule.class, item.getModule(ContentModule.URI)); + assertEquals("

Full text one

", content.getEncoded()); + } + + @Test + void feedWithDoctypeIsRefused(@TempDir Path dir) throws Exception { + Path secret = dir.resolve("secret.txt"); + Files.writeString(secret, "SECRET-CONTENT"); + String url = serve("/doctype", "" + + "]>" + + "&secret;" + + ""); + + FetcherException ex = assertThrows(FetcherException.class, () -> fetcher.fetchSubscription(url)); + ParsingFeedException refused = assertInstanceOf(ParsingFeedException.class, ex.getRootCause()); + assertTrue(refused.getMessage().contains("DOCTYPE"), refused.getMessage()); + } + + private static String serve(String path, String feed) { + FEEDS.put(path, feed); + return "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort() + path; + } + + private static SubscriptionEntry entry(Subscription sub, String permalink) { + return sub.getEntries().stream() + .filter(e -> permalink.equals(e.getPermalink())) + .findFirst() + .orElseThrow(() -> new AssertionError("No entry " + permalink + " in " + sub.getEntries())); + } + + private static Timestamp timestamp(String instant) { + return Timestamp.from(Instant.parse(instant)); + } +} From fc2351c34f6ff520f07a61d5a5af269160fbf31c Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Sat, 14 Nov 2026 20:00:03 -0700 Subject: [PATCH 2/2] Prefer an HTML alternate link for a feed's site URL A feed that listed an Atom, RSS or JSON alternate before its text/html one got that feed as its site URL. siteURL() now takes the first alternate whose type is text/html or blank, and falls back to the first alternate of any type, then the feed link. Suggested in review. --- .../business/fetcher/RomeFeedFetcher.java | 16 ++++++++--- .../fetcher/RomeFeedFetcherLocalTest.java | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java index 1f43579578..eb2d275279 100644 --- a/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java +++ b/app/src/main/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcher.java @@ -161,16 +161,24 @@ public Subscription fetchSubscription(String feedURL, Date lastModified) throws // ROME 2 makes an Atom feed's rel="self" link (the feed itself) its link, - // so look for the alternate link to the site first. RSS feeds have no - // links list and keep using the channel link. + // so look for the alternate link to the site first, preferring an HTML + // one over alternates in other formats (another feed, JSON). RSS feeds + // have no links list and keep using the channel link. private static String siteURL(SyndFeed feed) { + String firstAlternate = null; for (SyndLink link : feed.getLinks()) { String rel = link.getRel(); if (StringUtils.isBlank(rel) || "alternate".equals(rel)) { - return link.getHref(); + String type = link.getType(); + if (StringUtils.isBlank(type) || "text/html".equalsIgnoreCase(type)) { + return link.getHref(); + } + if (firstAlternate == null) { + firstAlternate = link.getHref(); + } } } - return feed.getLink(); + return firstAlternate != null ? firstAlternate : feed.getLink(); } diff --git a/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java index 282486c876..bca3e046ba 100644 --- a/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java +++ b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java @@ -182,6 +182,34 @@ void atomFeedUsesAlternateLinkAsSiteUrl() throws Exception { assertEquals("java,feeds", entry.getCategoriesString()); } + @Test + void atomFeedPrefersHtmlAlternateOverOtherFormats() throws Exception { + String url = serve("/atom-alternates", """ + + + Many Links + urn:example:alternates + + + + 2026-10-12T08:30:00Z + + """); + assertEquals("https://alt.example.org/", fetcher.fetchSubscription(url).getSiteURL()); + + String onlyFeeds = serve("/atom-feed-alternate", """ + + + Feed Links Only + urn:example:feed-alternate + + + 2026-10-12T08:30:00Z + + """); + assertEquals("https://only.example.org/rss", fetcher.fetchSubscription(onlyFeeds).getSiteURL()); + } + @Test void unchangedFeedIsSkipped() throws Exception { String url = serve("/atom-unchanged", ATOM);