diff --git a/CHANGES.md b/CHANGES.md index 5ea137a463..d30f61c9c7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -158,6 +158,7 @@ that added its own commons-logging bridge should remove it. - 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. - The admin UI now uses jQuery 4.0.0 and jQuery Validation 1.22.1. ## 6.1.7 diff --git a/app/pom.xml b/app/pom.xml index 09a41462c4..49574a3e66 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.10 7.1.2 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..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 @@ -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,28 @@ 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, 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)) { + String type = link.getType(); + if (StringUtils.isBlank(type) || "text/html".equalsIgnoreCase(type)) { + return link.getHref(); + } + if (firstAlternate == null) { + firstAlternate = link.getHref(); + } + } + } + return firstAlternate != null ? firstAlternate : 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..bca3e046ba --- /dev/null +++ b/app/src/test/java/org/apache/roller/planet/business/fetcher/RomeFeedFetcherLocalTest.java @@ -0,0 +1,298 @@ +/* + * 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 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); + + 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)); + } +}