Compare commits

...

4 Commits
5.0.1 ... 5.0.2

10 changed files with 162 additions and 97 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## [5.0.2]
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
- Fix an error that appears in the logs when fetching some favicons
## [5.0.1]
- Configure native compilation to support older CPU architectures (#1524)

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.0.1</version>
<version>5.0.2</version>
</parent>
<artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name>

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.0.1</version>
<version>5.0.2</version>
</parent>
<artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name>
@@ -228,7 +228,7 @@
<dependency>
<groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId>
<version>5.0.1</version>
<version>5.0.2</version>
</dependency>
<dependency>
@@ -248,10 +248,6 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-reactive-routes</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
@@ -331,6 +327,11 @@
<version>${querydsl.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.3.0-jre</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
@@ -422,12 +423,6 @@
<version>8.3.6</version>
</dependency>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-youtube</artifactId>
<version>v3-rev20240814-2.0.0</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>

View File

@@ -1,28 +1,25 @@
package com.commafeed.backend.favicon;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.net.URIBuilder;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.model.Feed;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.services.youtube.YouTube;
import com.google.api.services.youtube.YouTube.Channels;
import com.google.api.services.youtube.YouTube.Playlists;
import com.google.api.services.youtube.model.Channel;
import com.google.api.services.youtube.model.ChannelListResponse;
import com.google.api.services.youtube.model.PlaylistListResponse;
import com.google.api.services.youtube.model.Thumbnail;
import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Singleton;
import jakarta.ws.rs.core.UriBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,13 +28,16 @@ import lombok.extern.slf4j.Slf4j;
@Singleton
public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
private static final JsonPointer CHANNEL_THUMBNAIL_URL = JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
private final HttpGetter getter;
private final CommaFeedConfiguration config;
private final ObjectMapper objectMapper;
@Override
public Favicon fetch(Feed feed) {
String url = feed.getUrl();
if (!url.toLowerCase().contains("youtube.com/feeds/videos.xml")) {
return null;
}
@@ -56,35 +56,33 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
Optional<NameValuePair> channelId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
Optional<NameValuePair> playlistId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
YouTube youtube = new YouTube.Builder(new NetHttpTransport(), GsonFactory.getDefaultInstance(), request -> {
}).setApplicationName("CommaFeed").build();
ChannelListResponse response = null;
byte[] response = null;
if (userId.isPresent()) {
log.debug("contacting youtube api for user {}", userId.get().getValue());
response = fetchForUser(youtube, googleAuthKey.get(), userId.get().getValue());
response = fetchForUser(googleAuthKey.get(), userId.get().getValue());
} else if (channelId.isPresent()) {
log.debug("contacting youtube api for channel {}", channelId.get().getValue());
response = fetchForChannel(youtube, googleAuthKey.get(), channelId.get().getValue());
response = fetchForChannel(googleAuthKey.get(), channelId.get().getValue());
} else if (playlistId.isPresent()) {
log.debug("contacting youtube api for playlist {}", playlistId.get().getValue());
response = fetchForPlaylist(youtube, googleAuthKey.get(), playlistId.get().getValue());
response = fetchForPlaylist(googleAuthKey.get(), playlistId.get().getValue());
}
if (response == null || response.isEmpty() || CollectionUtils.isEmpty(response.getItems())) {
log.debug("youtube api returned no items");
if (ArrayUtils.isEmpty(response)) {
log.debug("youtube api returned empty response");
return null;
}
Channel channel = response.getItems().get(0);
Thumbnail thumbnail = channel.getSnippet().getThumbnails().getDefault();
JsonNode thumbnailUrl = objectMapper.readTree(response).at(CHANNEL_THUMBNAIL_URL);
if (thumbnailUrl.isMissingNode()) {
log.debug("youtube api returned invalid response");
return null;
}
log.debug("fetching favicon");
HttpResult iconResult = getter.getBinary(thumbnail.getUrl());
HttpResult iconResult = getter.getBinary(thumbnailUrl.asText());
bytes = iconResult.getContent();
contentType = iconResult.getContentType();
} catch (Exception e) {
log.debug("Failed to retrieve YouTube icon", e);
log.error("Failed to retrieve YouTube icon", e);
}
if (!isValidIconResponse(bytes, contentType)) {
@@ -93,32 +91,38 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
return new Favicon(bytes, contentType);
}
private ChannelListResponse fetchForUser(YouTube youtube, String googleAuthKey, String userId) throws IOException {
Channels.List list = youtube.channels().list(List.of("snippet"));
list.setKey(googleAuthKey);
list.setForUsername(userId);
return list.execute();
private byte[] fetchForUser(String googleAuthKey, String userId) throws IOException, NotModifiedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", "snippet")
.queryParam("key", googleAuthKey)
.queryParam("forUsername", userId)
.build();
return getter.getBinary(uri.toString()).getContent();
}
private ChannelListResponse fetchForChannel(YouTube youtube, String googleAuthKey, String channelId) throws IOException {
Channels.List list = youtube.channels().list(List.of("snippet"));
list.setKey(googleAuthKey);
list.setId(List.of(channelId));
return list.execute();
private byte[] fetchForChannel(String googleAuthKey, String channelId) throws IOException, NotModifiedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", "snippet")
.queryParam("key", googleAuthKey)
.queryParam("id", channelId)
.build();
return getter.getBinary(uri.toString()).getContent();
}
private ChannelListResponse fetchForPlaylist(YouTube youtube, String googleAuthKey, String playlistId) throws IOException {
Playlists.List list = youtube.playlists().list(List.of("snippet"));
list.setKey(googleAuthKey);
list.setId(List.of(playlistId));
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) throws IOException, NotModifiedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", "snippet")
.queryParam("key", googleAuthKey)
.queryParam("id", playlistId)
.build();
byte[] playlistBytes = getter.getBinary(uri.toString()).getContent();
PlaylistListResponse response = list.execute();
if (response.getItems().isEmpty()) {
JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID);
if (channelId.isMissingNode()) {
return null;
}
String channelId = response.getItems().get(0).getSnippet().getChannelId();
return fetchForChannel(youtube, googleAuthKey, channelId);
return fetchForChannel(googleAuthKey, channelId.asText());
}
}

View File

@@ -1,10 +1,11 @@
package com.commafeed.backend.feed;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import org.apache.commons.codec.binary.StringUtils;
import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.Digests;
import com.commafeed.backend.HttpGetter;
@@ -48,7 +49,7 @@ public class FeedFetcher {
parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} catch (FeedException e) {
if (extractFeedUrlFromHtml) {
String extractedUrl = extractFeedUrl(urlProviders, feedUrl, StringUtils.newStringUtf8(result.getContent()));
String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8));
if (org.apache.commons.lang3.StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl;

View File

@@ -6,8 +6,8 @@ import java.util.Iterator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.utils.Base64;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

View File

@@ -1,34 +0,0 @@
package com.commafeed.security;
import io.quarkus.vertx.http.runtime.HttpConfiguration;
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
import io.quarkus.vertx.web.RouteFilter;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.impl.ServerCookie;
import io.vertx.ext.web.RoutingContext;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
/**
* Intercepts responses and sets a Max-Age on the cookie created by {@link FormAuthenticationMechanism} because it has no value by default.
*
* This is a workaround for https://github.com/quarkusio/quarkus/issues/42463
*/
@RequiredArgsConstructor
@Singleton
public class FormAuthenticationCookieInterceptor {
private final HttpConfiguration config;
@RouteFilter(Integer.MAX_VALUE)
public void cookieInterceptor(RoutingContext context) {
context.addHeadersEndHandler(v -> {
Cookie cookie = context.request().getCookie(config.auth.form.cookieName);
if (cookie instanceof ServerCookie sc && sc.isChanged()) {
cookie.setMaxAge(config.auth.form.timeout.toSeconds());
}
});
context.next();
}
}

View File

@@ -0,0 +1,90 @@
package com.commafeed.security.mechanism;
import java.security.SecureRandom;
import java.util.Base64;
import io.quarkus.vertx.http.runtime.FormAuthConfig;
import io.quarkus.vertx.http.runtime.FormAuthRuntimeConfig;
import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig;
import io.quarkus.vertx.http.runtime.HttpConfiguration;
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.quarkus.vertx.http.runtime.security.PersistentLoginManager;
import io.vertx.core.http.Cookie;
import io.vertx.core.http.impl.ServerCookie;
import io.vertx.ext.web.RoutingContext;
import jakarta.annotation.Priority;
import jakarta.inject.Singleton;
import lombok.experimental.Delegate;
import lombok.extern.slf4j.Slf4j;
/**
* HttpAuthenticationMechanism that wraps FormAuthenticationMechanism and sets a Max-Age on the cookie because it has no value by default.
*
* This is a workaround for https://github.com/quarkusio/quarkus/issues/42463
*/
@Priority(1)
@Singleton
@Slf4j
public class CookieMaxAgeFormAuthenticationMechanism implements HttpAuthenticationMechanism {
// the temp encryption key, persistent across dev mode restarts
static volatile String encryptionKey;
@Delegate
private final FormAuthenticationMechanism delegate;
public CookieMaxAgeFormAuthenticationMechanism(HttpConfiguration httpConfiguration, HttpBuildTimeConfig buildTimeConfig) {
String key;
if (httpConfiguration.encryptionKey.isEmpty()) {
if (encryptionKey != null) {
// persist across dev mode restarts
key = encryptionKey;
} else {
byte[] data = new byte[32];
new SecureRandom().nextBytes(data);
key = encryptionKey = Base64.getEncoder().encodeToString(data);
log.warn("Encryption key was not specified for persistent FORM auth, using temporary key {}", key);
}
} else {
key = httpConfiguration.encryptionKey.get();
}
FormAuthConfig form = buildTimeConfig.auth.form;
FormAuthRuntimeConfig runtimeForm = httpConfiguration.auth.form;
String loginPage = startWithSlash(runtimeForm.loginPage.orElse(null));
String errorPage = startWithSlash(runtimeForm.errorPage.orElse(null));
String landingPage = startWithSlash(runtimeForm.landingPage.orElse(null));
String postLocation = startWithSlash(form.postLocation);
String usernameParameter = runtimeForm.usernameParameter;
String passwordParameter = runtimeForm.passwordParameter;
String locationCookie = runtimeForm.locationCookie;
String cookiePath = runtimeForm.cookiePath.orElse(null);
boolean redirectAfterLogin = landingPage != null;
String cookieSameSite = runtimeForm.cookieSameSite.name();
PersistentLoginManager loginManager = new PersistentLoginManager(key, runtimeForm.cookieName, runtimeForm.timeout.toMillis(),
runtimeForm.newCookieInterval.toMillis(), runtimeForm.httpOnlyCookie, cookieSameSite, cookiePath) {
@Override
public void save(String value, RoutingContext context, String cookieName, RestoreResult restoreResult, boolean secureCookie) {
super.save(value, context, cookieName, restoreResult, secureCookie);
// add max age to the cookie
Cookie cookie = context.request().getCookie(cookieName);
if (cookie instanceof ServerCookie sc && sc.isChanged()) {
cookie.setMaxAge(runtimeForm.timeout.toSeconds());
}
}
};
this.delegate = new FormAuthenticationMechanism(loginPage, postLocation, usernameParameter, passwordParameter, errorPage,
landingPage, redirectAfterLogin, locationCookie, cookieSameSite, cookiePath, loginManager);
}
private static String startWithSlash(String page) {
if (page == null) {
return null;
}
return page.startsWith("/") ? page : "/" + page;
}
}

View File

@@ -13,6 +13,7 @@ import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException;
class FeedEntryFilteringServiceTest {
private CommaFeedConfiguration config;
private FeedEntryFilteringService service;
@@ -20,8 +21,8 @@ class FeedEntryFilteringServiceTest {
@BeforeEach
public void init() {
CommaFeedConfiguration config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS);
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(2));
config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS);
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(30));
service = new FeedEntryFilteringService(config);
@@ -69,6 +70,9 @@ class FeedEntryFilteringServiceTest {
@Test
void cannotLoopForever() {
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofMillis(200));
service = new FeedEntryFilteringService(config);
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("while(true) {}", entry));
}

View File

@@ -5,7 +5,7 @@
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.0.1</version>
<version>5.0.2</version>
<name>CommaFeed</name>
<packaging>pom</packaging>