use 120 max line length for both server and client code (was 100 and 140)

This commit is contained in:
Athou
2026-11-03 06:48:11 +01:00
parent 29987a460d
commit 69cf674a02
157 changed files with 1228 additions and 1958 deletions

View File

@@ -290,6 +290,7 @@
<googleJavaFormat>
<version>${google-java-format.version}</version>
<style>AOSP</style>
<maxLineLength>120</maxLineLength>
<reorderImports>true</reorderImports>
</googleJavaFormat>
</java>

View File

@@ -34,16 +34,14 @@ public class CommaFeedApplication {
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
PasswordConstraintValidator.setMinimumPasswordLength(
config.users().minimumPasswordLength());
PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
if (config.imageProxyEnabled()) {
ImageProxyUrl.generateKey();
}
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
throw new IllegalStateException(
"password recovery is enabled but no public base url is set");
throw new IllegalStateException("password recovery is enabled but no public base url is set");
}
feedRefreshEngine.start();

View File

@@ -19,24 +19,20 @@ import java.util.Optional;
/**
* CommaFeed configuration
*
* <p>Default values are for production, they can be overridden in application.properties for other
* profiles
* <p>Default values are for production, they can be overridden in application.properties for other profiles
*/
@ConfigMapping(prefix = "commafeed")
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
public interface CommaFeedConfiguration {
/**
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
*/
/** Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. */
@WithDefault("true")
boolean hideFromWebCrawlers();
/**
* If enabled, images in feed entries will be proxied through the server instead of accessed
* directly by the browser.
* If enabled, images in feed entries will be proxied through the server instead of accessed directly by the
* browser.
*
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds
* that are followed.
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
*/
@WithDefault("false")
boolean imageProxyEnabled();
@@ -50,9 +46,8 @@ public interface CommaFeedConfiguration {
boolean passwordRecoveryEnabled();
/**
* The base URL of the application to use in the password recovery link in the email. We can't
* use the URL sent by the browser of the user because of malicious Host header injection
* attacks.
* The base URL of the application to use in the password recovery link in the email. We can't use the URL sent by
* the browser of the user because of malicious Host header injection attacks.
*/
Optional<String> passwordRecoveryPublicBaseUrl();
@@ -87,16 +82,14 @@ public interface CommaFeedConfiguration {
Websocket websocket();
/**
* Duration to wait for the feed refresh engine and the task scheduler to stop when the
* application is shutting down.
* Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting
* down.
*/
@WithDefault("2s")
Duration shutdownTimeout();
interface HttpClient {
/**
* User-Agent string that will be used by the http client, leave empty for the default one.
*/
/** User-Agent string that will be used by the http client, leave empty for the default one. */
Optional<String> userAgent();
/** Time to wait for a connection to be established. */
@@ -123,19 +116,15 @@ public interface CommaFeedConfiguration {
@WithDefault("1m")
Duration idleConnectionsEvictionInterval();
/**
* If a feed is larger than this, it will be discarded to prevent memory issues while
* parsing the feed.
*/
/** If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. */
@WithDefault("5M")
MemorySize maxResponseSize();
/**
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks,
* which could potentially expose internal resources.
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could
* potentially expose internal resources.
*
* <p>You may want to enable this if you host a public instance of CommaFeed with
* registrations open.
* <p>You may want to enable this if you host a public instance of CommaFeed with registrations open.
*/
@WithDefault("true")
boolean blockLocalAddresses();
@@ -147,9 +136,8 @@ public interface CommaFeedConfiguration {
interface HttpClientCache {
/**
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts
* (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds
* now").
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when
* subscribing to a feed for the first time or when clicking "fetch all my feeds now").
*/
@WithDefault("true")
boolean enabled();
@@ -169,8 +157,7 @@ public interface CommaFeedConfiguration {
Duration interval();
/**
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an
* upper bound when:
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
*
* <ul>
* <li>an error occurs while refreshing a feed and we're backing off exponentially
@@ -182,10 +169,10 @@ public interface CommaFeedConfiguration {
Duration maxInterval();
/**
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average
* time between entries and the time since the last entry was published. The interval will
* be sometimes between the default refresh interval (`commafeed.feed-refresh.interval`) and
* the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries
* and the time since the last entry was published. The interval will be sometimes between the default refresh
* interval (`commafeed.feed-refresh.interval`) and the maximum refresh interval
* (`commafeed.feed-refresh.max-interval`).
*
* <p>See {@link FeedRefreshIntervalCalculator} for details.
*/
@@ -207,8 +194,8 @@ public interface CommaFeedConfiguration {
int databaseThreads();
/**
* Duration after which a user is considered inactive. Feeds for inactive users are not
* refreshed until they log in again.
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log
* in again.
*
* <p>0 to disable.
*/
@@ -216,15 +203,15 @@ public interface CommaFeedConfiguration {
Duration userInactivityPeriod();
/**
* Duration after which the evaluation of a filtering expresion to mark an entry as read is
* considered to have timed out.
* Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have
* timed out.
*/
@WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout();
/**
* Duration after which the "Fetch all my feeds now" action is available again after use to
* avoid spamming feeds.
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming
* feeds.
*/
@WithDefault("0")
Duration forceRefreshCooldownDuration();
@@ -240,10 +227,7 @@ public interface CommaFeedConfiguration {
@WithDefault("5")
int threads();
/**
* Maximum amount of notifications that can be queued before new notifications are
* discarded.
*/
/** Maximum amount of notifications that can be queued before new notifications are discarded. */
@Min(1)
@WithDefault("100")
int queueCapacity();
@@ -256,8 +240,8 @@ public interface CommaFeedConfiguration {
int retriesBeforeBackoff();
/**
* Duration to wait before retrying after an error. Will be multiplied by the number of
* errors since the last successful fetch.
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last
* successful fetch.
*/
@WithDefault("1h")
Duration backoffInterval();
@@ -286,8 +270,7 @@ public interface CommaFeedConfiguration {
Duration entriesMaxAge();
/**
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will
* be deleted.
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
*
* <p>0 to disable.
*/
@@ -320,9 +303,7 @@ public interface CommaFeedConfiguration {
boolean keepStarredEntries();
default Instant statusesInstantThreshold() {
return statusesMaxAge().toMillis() > 0
? Instant.now().minus(statusesMaxAge())
: null;
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
}
}
}
@@ -346,23 +327,17 @@ public interface CommaFeedConfiguration {
}
interface Websocket {
/**
* Enable websocket connection so the server can notify web clients that there are new
* entries for feeds.
*/
/** Enable websocket connection so the server can notify web clients that there are new entries for feeds. */
@WithDefault("true")
boolean enabled();
/**
* Interval at which the client will send a ping message on the websocket to keep the
* connection alive.
*/
/** Interval at which the client will send a ping message on the websocket to keep the connection alive. */
@WithDefault("15m")
Duration pingInterval();
/**
* If the websocket connection is disabled or the connection is lost, the client will reload
* the feed tree at this interval.
* If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at
* this interval.
*/
@WithDefault("30s")
Duration treeReloadInterval();

View File

@@ -32,11 +32,9 @@ public class ExceptionMappers {
private final CommaFeedConfiguration config;
@ServerExceptionMapper(CommaFeedApplicationException.class)
public RestResponse<CommaFeedApplicationError> applicationError(
CommaFeedApplicationException e) {
public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
ResponseBuilder<CommaFeedApplicationError> response =
ResponseBuilder.create(
e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
if (e.type().status() == Status.UNAUTHORIZED) {
response.cookie(cookieService.buildLogoutCookie());
}
@@ -46,8 +44,7 @@ public class ExceptionMappers {
@ServerExceptionMapper(UnauthorizedException.class)
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
return RestResponse.status(
Status.UNAUTHORIZED,
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
}
@ServerExceptionMapper(AuthenticationFailedException.class)

View File

@@ -56,29 +56,24 @@ public class HttpClientFactory {
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
private static final IPAddress NAT64_LOCAL_USE_RANGE =
new IPAddressString("64:ff9b:1::/48").getAddress();
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
public CloseableHttpAsyncClient newClient(int poolSize) {
PoolingAsyncClientConnectionManager connectionManager =
newConnectionManager(config, poolSize);
PoolingAsyncClientConnectionManager connectionManager = newConnectionManager(config, poolSize);
String userAgent =
config.httpClient()
.userAgent()
.orElseGet(() ->
String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion()));
"CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
return newClient(config, connectionManager, userAgent);
}
private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config,
PoolingAsyncClientConnectionManager connectionManager,
String userAgent) {
CommaFeedConfiguration config, PoolingAsyncClientConnectionManager connectionManager, String userAgent) {
List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
@@ -86,10 +81,8 @@ public class HttpClientFactory {
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
new LinkedHashMap<>();
contentDecoderMap.put(
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap = new LinkedHashMap<>();
contentDecoderMap.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
RedirectStrategy redirectStrategy =
@@ -107,8 +100,7 @@ public class HttpClientFactory {
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.evictIdleConnections(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
@@ -116,10 +108,8 @@ public class HttpClientFactory {
return client;
}
private PoolingAsyncClientConnectionManager newConnectionManager(
CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
DnsResolver dnsResolver =
config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
@@ -131,14 +121,12 @@ public class HttpClientFactory {
ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
.setTimeToLive(
Timeout.of(config.httpClient().connectionTimeToLive()))
.setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
.build())
.setDefaultTlsConfig(
TlsConfig.custom()
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
.setHandshakeTimeout(
Timeout.of(config.httpClient().sslHandshakeTimeout()))
.setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build())
.setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize)
@@ -191,8 +179,7 @@ public class HttpClientFactory {
InetAddress[] addresses = delegate.resolve(host);
for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) {
throw new UnknownHostException(
"Access to local address blocked: " + addr.getHostAddress());
throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
}
}
return addresses;
@@ -228,8 +215,7 @@ public class HttpClientFactory {
for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) {
throw new HttpException(
"Access to local address blocked: " + addr.getHostAddress());
throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
}
}

View File

@@ -75,32 +75,22 @@ public class HttpGetter {
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
this.cache = newCache(config);
metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "size"),
() -> cache == null ? 0 : cache.size());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() ->
cache == null
? 0
: cache.asMap().values().stream()
.mapToInt(e -> ArrayUtils.getLength(e.content))
.sum());
: cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
}
public HttpResult get(String url)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
return get(HttpRequest.builder(url).build());
}
public HttpResult get(HttpRequest request)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri = URI.create(request.getUrl());
ensureHttpScheme(uri.getScheme());
@@ -178,8 +168,7 @@ public class HttpGetter {
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
new FutureCallback<>() {
@Override
public void completed(
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
public void completed(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
@Override
public void failed(Exception ex) {}
@@ -283,15 +272,13 @@ public class HttpGetter {
}
return CacheBuilder.newBuilder()
.weigher((HttpRequest key, HttpResponse value) ->
value.content() != null ? value.content().length : 0)
.weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration())
.build();
}
private record LimitedContentLengthEntityConsumer(
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
private record LimitedContentLengthEntityConsumer(@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
implements AsyncEntityConsumer<byte[]> {
@Override
@@ -324,9 +311,7 @@ public class HttpGetter {
@Override
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
if (out.size() + src.remaining() > maxBytes) {
throw new IOException(
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
}
byte[] chunk = new byte[src.remaining()];
src.get(chunk);
@@ -366,8 +351,7 @@ public class HttpGetter {
this(message, null, null);
}
public NotModifiedException(
String message, String newLastModifiedHeader, String newEtagHeader) {
public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
super(message);
this.newLastModifiedHeader = newLastModifiedHeader;
this.newEtagHeader = newEtagHeader;

View File

@@ -65,11 +65,7 @@ public class Urls {
try {
return URI.create(baseUrl).resolve(relativeUrl).toString();
} catch (IllegalArgumentException e) {
log.debug(
"Unable to create absolute url from relative url: {} base: {}",
relativeUrl,
baseUrl,
e);
log.debug("Unable to create absolute url from relative url: {} base: {}", relativeUrl, baseUrl, e);
return null;
}
}
@@ -86,8 +82,8 @@ public class Urls {
}
/**
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to
* identify a feed and avoid duplicates
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and
* avoid duplicates
*/
public static String normalize(String url) {
if (url == null) {

View File

@@ -31,9 +31,7 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
}
public FeedCategory findById(User user, Long id) {
return query().selectFrom(CATEGORY)
.where(CATEGORY.user.eq(user), CATEGORY.id.eq(id))
.fetchOne();
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
}
public FeedCategory findByName(User user, String name, FeedCategory parent) {

View File

@@ -36,10 +36,7 @@ public class FeedDAO extends GenericDAO<Feed> {
// join on subscriptions to only refresh feeds that have subscribers
.join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED))
.where(
FEED.disabledUntil
.isNull()
.or(FEED.disabledUntil.lt(Instant.now())));
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) {
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
@@ -53,11 +50,7 @@ public class FeedDAO extends GenericDAO<Feed> {
}
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
return query()
.selectFrom(FEED)
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
.fetch()
.stream()
return query().selectFrom(FEED).where(FEED.normalizedUrlHash.eq(normalizedUrlHash)).fetch().stream()
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
.findFirst()
.orElse(null);

View File

@@ -42,8 +42,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
}
Set<String> result = new HashSet<>();
for (List<String> batch :
Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
for (List<String> batch : Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
result.addAll(
query().select(ENTRY.guidHash)
.from(ENTRY)
@@ -53,8 +52,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
return result;
}
public List<FeedCapacity> findFeedsExceedingCapacity(
long maxCapacity, long max, boolean keepStarredEntries) {
public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max, boolean keepStarredEntries) {
NumberExpression<Long> count = ENTRY.id.count();
JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY);
@@ -68,8 +66,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
}
public int delete(Long feedId, long max) {
List<FeedEntry> list =
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
return delete(list);
}
@@ -91,10 +88,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
/** Delete the oldest entries of a feed */
public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) {
JPAQuery<FeedEntry> query =
query().selectFrom(ENTRY)
.where(ENTRY.feed.id.eq(feedId))
.orderBy(ENTRY.published.asc())
.limit(max);
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max);
if (keepStarredEntries) {
query.where(Predicates.isNotStarred(ENTRY));

View File

@@ -48,9 +48,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private final CommaFeedConfiguration config;
public FeedEntryStatusDAO(
EntityManager entityManager,
FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) {
EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config;
@@ -58,22 +56,16 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
List<FeedEntryStatus> statuses =
query().selectFrom(STATUS)
.where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub))
.fetch();
query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
FeedEntryStatus status = statuses.stream().findFirst().orElse(null);
return handleStatus(user, status, sub, entry);
}
/** creates an artificial "unread" status if status is null */
private FeedEntryStatus handleStatus(
User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) {
Instant statusesInstantThreshold =
config.database().cleanup().statusesInstantThreshold();
boolean read =
statusesInstantThreshold != null
&& entry.getPublished().isBefore(statusesInstantThreshold);
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold);
status = new FeedEntryStatus(user, sub, entry);
status.setRead(read);
status.setMarkable(!read);
@@ -85,8 +77,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private void fetchTags(User user, List<FeedEntryStatus> statuses) {
Map<Long, List<FeedEntryTag>> tagsByEntryIds =
feedEntryTagDAO.findByEntries(
user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
feedEntryTagDAO.findByEntries(user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
for (FeedEntryStatus status : statuses) {
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
status.setTags(tags == null ? List.of() : tags);
@@ -258,9 +249,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max());
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
result.put(
sub.getId(),
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
result.put(sub.getId(), new UnreadCount(sub.getId(), count == null ? 0 : count, published));
}
}
@@ -303,8 +292,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
NumberExpression<Integer> daysDiff =
Expressions.numberTemplate(
Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
Expressions.numberTemplate(Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
where.and(buildUnreadPredicate());
@@ -333,11 +321,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
.toList();
if (!statusIdsToUpdate.isEmpty()) {
updated +=
updateQuery(STATUS)
.where(STATUS.id.in(statusIdsToUpdate))
.set(STATUS.read, true)
.execute();
updated += updateQuery(STATUS).where(STATUS.id.in(statusIdsToUpdate)).set(STATUS.read, true).execute();
}
// Insert new statuses for entries without existing status

View File

@@ -31,11 +31,7 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
}
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
return query()
.selectFrom(TAG)
.where(TAG.user.eq(user), TAG.entry.in(entries))
.fetch()
.stream()
return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.in(entries)).fetch().stream()
.collect(Collectors.groupingBy(t -> t.getEntry().getId()));
}
}

View File

@@ -84,9 +84,7 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
public FeedSubscription findByFeed(User user, Feed feed) {
List<FeedSubscription> subs =
query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
.fetch();
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)).fetch();
FeedSubscription sub = subs.stream().findFirst().orElse(null);
return initRelations(sub);
}
@@ -104,15 +102,11 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
}
public Long count(User user) {
return query().select(SUBSCRIPTION.count())
.from(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user))
.fetchOne();
return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne();
}
public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
JPQLQuery<FeedSubscription> query =
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
if (category == null) {
query.where(SUBSCRIPTION.category.isNull());
} else {
@@ -122,11 +116,9 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
}
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
Set<Long> categoryIds =
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream()
.filter(s ->
s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
.filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
.toList();
}

View File

@@ -37,9 +37,7 @@ public class FacebookFaviconFetcher implements FaviconFetcher {
return null;
}
String iconUrl =
String.format(
"https://graph.facebook.com/%s/picture?type=square&height=16", userName);
String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName);
try {
log.debug("Getting Facebook user's icon, {}", url);

View File

@@ -17,8 +17,7 @@ public record Favicon(byte[] icon, MediaType mediaType) {
try {
return MediaType.valueOf(contentType);
} catch (Exception e) {
log.debug(
"invalid content type '{}' received, returning default value", contentType, e);
log.debug("invalid content type '{}' received, returning default value", contentType, e);
return DEFAULT_MEDIA_TYPE;
}
}

View File

@@ -32,10 +32,7 @@ public class RootFaviconFetcher implements FaviconFetcher {
URI uri = URI.create(url.trim());
String faviconUrl =
"%s://%s%s/favicon.ico"
.formatted(
uri.getScheme(),
uri.getHost(),
uri.getPort() > 0 ? ":" + uri.getPort() : "");
.formatted(uri.getScheme(), uri.getHost(), uri.getPort() > 0 ? ":" + uri.getPort() : "");
log.debug("getting root icon at {}", faviconUrl);
HttpResult result = getter.get(faviconUrl);

View File

@@ -37,8 +37,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
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 static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
private final HttpGetter getter;
private final CommaFeedConfiguration config;
@@ -60,17 +59,11 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
try {
List<NameValuePair> params = new URIBuilder(url).getQueryParams();
Optional<NameValuePair> userId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("user"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst();
Optional<NameValuePair> channelId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
Optional<NameValuePair> playlistId =
params.stream()
.filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id"))
.findFirst();
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
byte[] response = null;
if (userId.isPresent()) {
@@ -103,10 +96,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForUser(String googleAuthKey, String userId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET)
@@ -117,10 +107,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForChannel(String googleAuthKey, String channelId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET)
@@ -131,10 +118,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
}
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
throws IOException,
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", PART_SNIPPET)

View File

@@ -36,8 +36,7 @@ public class FeedFetcher {
private final HttpGetter getter;
private final List<FeedURLProvider> urlProviders;
public FeedFetcher(
FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
this.parser = parser;
this.getter = getter;
this.urlProviders = urlProviders;
@@ -58,9 +57,7 @@ public class FeedFetcher {
NoFeedFoundException {
log.debug("Fetching feed {}", feedUrl);
HttpResult result =
getter.get(
HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
byte[] content = result.content();
FeedParserResult parserResult;
@@ -69,19 +66,12 @@ public class FeedFetcher {
} catch (FeedParsingException e) {
if (extractFeedUrlFromHtml) {
String extractedUrl =
extractFeedUrl(
urlProviders,
feedUrl,
new String(result.content(), StandardCharsets.UTF_8));
extractFeedUrl(urlProviders, feedUrl, new String(result.content(), StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl;
result =
getter.get(
HttpRequest.builder(extractedUrl)
.lastModified(lastModified)
.eTag(eTag)
.build());
getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
content = result.content();
parserResult = parser.parse(result.urlAfterRedirect(), content);
} else {
@@ -96,8 +86,7 @@ public class FeedFetcher {
throw new IOException("Feed content is empty.");
}
boolean lastModifiedHeaderValueChanged =
!Strings.CS.equals(lastModified, result.lastModifiedSince());
boolean lastModifiedHeaderValueChanged = !Strings.CS.equals(lastModified, result.lastModifiedSince());
boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag());
String hash = Digests.sha1Hex(content);
@@ -109,8 +98,7 @@ public class FeedFetcher {
etagHeaderValueChanged ? result.eTag() : null);
}
if (lastPublishedDate != null
&& lastPublishedDate.equals(parserResult.lastPublishedDate())) {
if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) {
log.debug("publishedDate not modified: {}", feedUrl);
throw new NotModifiedException(
"publishedDate not modified",
@@ -127,8 +115,7 @@ public class FeedFetcher {
result.validFor());
}
private static String extractFeedUrl(
List<FeedURLProvider> urlProviders, String url, String urlContent) {
private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
return urlProviders.stream()
.flatMap(provider -> provider.get(url, urlContent).stream())
.filter(StringUtils::isNotBlank)

View File

@@ -67,8 +67,7 @@ public class FeedRefreshEngine {
this.queue = new LinkedBlockingDeque<>();
metrics.register(
MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(
MetricRegistry.name(getClass(), "worker", "active"),
(Gauge<Integer>) () -> workerExecutor.getActiveCount());
@@ -88,12 +87,10 @@ public class FeedRefreshEngine {
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService();
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
this.databaseUpdaterExecutor =
newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.notifierExecutor =
newDiscardingExecutorService(
config.pushNotifications().threads(),
config.pushNotifications().queueCapacity());
config.pushNotifications().threads(), config.pushNotifications().queueCapacity());
}
public void start() {
@@ -169,9 +166,7 @@ public class FeedRefreshEngine {
refill.mark();
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
log.debug(
"found {} feeds that are up for refresh",
nextUpdatableFeeds.size());
log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size());
for (Feed feed : nextUpdatableFeeds) {
// add the feed only if it was not already queued
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
@@ -199,8 +194,7 @@ public class FeedRefreshEngine {
notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync(
() -> notifier.sendPushNotifications(sub, entries),
notifierExecutor);
() -> notifier.sendPushNotifications(sub, entries), notifierExecutor);
})
.toList();
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
@@ -222,8 +216,7 @@ public class FeedRefreshEngine {
// update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
}
return feeds;
});
@@ -234,26 +227,19 @@ public class FeedRefreshEngine {
}
public void stop() {
MoreExecutors.shutdownAndAwaitTermination(
this.feedProcessingLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(
this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.feedProcessingLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(
this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
queue.clear();
}
/**
* returns an ExecutorService with a single thread that discards tasks if a task is already
* running
*/
/** returns an ExecutorService with a single thread that discards tasks if a task is already running */
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool;
}
@@ -262,11 +248,7 @@ public class FeedRefreshEngine {
private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
threads,
threads,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(queueCapacity));
threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(queueCapacity));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool;
}
@@ -274,8 +256,7 @@ public class FeedRefreshEngine {
/** returns an ExecutorService that blocks submissions until a thread is available */
private ThreadPoolExecutor newBlockingExecutorService(int threads) {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler((r, e) -> {
if (e.isShutdown()) {
return;

View File

@@ -22,8 +22,7 @@ public class FeedRefreshIntervalCalculator {
private final FeedRefreshErrorHandling errorHandling;
private final InstantSource instantSource;
public FeedRefreshIntervalCalculator(
CommaFeedConfiguration config, InstantSource instantSource) {
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
this.interval = config.feedRefresh().interval();
this.maxInterval = config.feedRefresh().maxInterval();
this.empirical = config.feedRefresh().intervalEmpirical();
@@ -31,8 +30,7 @@ public class FeedRefreshIntervalCalculator {
this.instantSource = instantSource;
}
public Instant onFetchSuccess(
Instant publishedDate, Long averageEntryInterval, Duration validFor) {
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
Instant instant =
empirical
? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
@@ -54,14 +52,11 @@ public class FeedRefreshIntervalCalculator {
}
Duration retryInterval =
errorHandling
.backoffInterval()
.multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
return constrainToBounds(instantSource.instant().plus(retryInterval));
}
private Instant computeEmpiricalRefreshInterval(
Instant publishedDate, Long averageEntryInterval) {
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
Instant now = instantSource.instant();
if (publishedDate == null) {
@@ -80,9 +75,7 @@ public class FeedRefreshIntervalCalculator {
int factor = 2;
long millis =
Longs.constrainToRange(
averageEntryInterval / factor,
interval.toMillis(),
maxInterval.dividedBy(4).toMillis());
averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
return now.plusMillis(millis);
} else {
// unknown case

View File

@@ -64,8 +64,7 @@ public class FeedRefreshUpdater {
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
}
private AddEntryResult addEntry(
final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
boolean processed = false;
FeedEntry insertedEntry = null;
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
@@ -111,11 +110,7 @@ public class FeedRefreshUpdater {
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
}
} catch (InterruptedException e) {
log.error(
"interrupted while waiting for lock for {} : {}",
feed.getUrl(),
e.getMessage(),
e);
log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
Thread.currentThread().interrupt();
} finally {
if (locked1) {
@@ -131,13 +126,11 @@ public class FeedRefreshUpdater {
public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) {
boolean processed = true;
long inserted = 0;
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription =
new HashMap<>();
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription = new HashMap<>();
if (!entries.isEmpty()) {
List<FeedSubscription> subscriptions = null;
List<Entry> newEntries =
unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
List<Entry> newEntries = unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
for (Entry entry : newEntries) {
if (subscriptions == null) {
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
@@ -176,9 +169,7 @@ public class FeedRefreshUpdater {
}
private record AddEntryResult(
boolean processed,
FeedEntry insertedEntry,
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
boolean processed, FeedEntry insertedEntry, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
public record FeedRefreshUpdaterResult(
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {}

View File

@@ -22,8 +22,8 @@ import java.util.List;
import java.util.Optional;
/**
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link
* FeedRefreshUpdater} does that)
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater}
* does that)
*/
@Slf4j
@Singleton
@@ -68,10 +68,7 @@ public class FeedRefreshWorker {
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge);
entries =
entries.stream()
.filter(entry -> entry.published().isAfter(threshold))
.toList();
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
}
String urlAfterRedirect = result.urlAfterRedirect();
@@ -121,9 +118,7 @@ public class FeedRefreshWorker {
feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Server indicated that we are sending too many requests");
feed.setDisabledUntil(
refreshIntervalCalculator.onTooManyRequests(
e.getRetryAfter(), feed.getErrorCount()));
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (Exception e) {

View File

@@ -30,15 +30,12 @@ public class FeedUpdateNotifier {
public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) {
if (!entries.isEmpty()) {
webSocketSessions.sendMessage(
sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
webSocketSessions.sendMessage(sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
}
}
public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) {
if (!config.pushNotifications().enabled()
|| !sub.isPushNotificationsEnabled()
|| entries.isEmpty()) {
if (!config.pushNotifications().enabled() || !sub.isPushNotificationsEnabled() || entries.isEmpty()) {
return;
}

View File

@@ -40,8 +40,7 @@ public class FeedUtils {
return false;
}
return TextDirectionDetector.detect(stripped)
== TextDirectionDetector.Direction.RIGHT_TO_LEFT;
return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
}
public static String getFaviconUrl(FeedSubscription subscription) {

View File

@@ -14,8 +14,8 @@ import java.nio.charset.Charset;
public class EncodingDetector {
/**
* Detect feed encoding by using the declared encoding in the xml processing instruction and by
* detecting the characters used in the feed
* Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the
* characters used in the feed
*/
public Charset getEncoding(byte[] bytes) {
String extracted = extractDeclaredEncoding(bytes);

View File

@@ -42,14 +42,12 @@ import java.util.stream.Collectors;
@Singleton
public class FeedParser {
private static final Namespace ATOM_10_NS =
Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Instant START = Instant.ofEpochMilli(86400000);
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
private static final Comparator<Entry> ENTRY_COMPARATOR =
Comparator.comparing(Entry::published).reversed();
private static final Comparator<Entry> ENTRY_COMPARATOR = Comparator.comparing(Entry::published).reversed();
private final EncodingDetector encodingDetector;
private final XMLCleaner xmlCleaner;
@@ -78,20 +76,13 @@ public class FeedParser {
List<Entry> entries = buildEntries(feed, feedUrl);
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
if (lastPublishedDate == null
|| lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
lastPublishedDate = lastEntryDate;
}
Long averageEntryInterval = averageTimeBetweenEntries(entries);
return new FeedParserResult(
title,
link,
iconUrl,
lastPublishedDate,
averageEntryInterval,
lastEntryDate,
entries);
title, link, iconUrl, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
} catch (FeedParsingException e) {
throw e;
} catch (Exception e) {
@@ -151,9 +142,7 @@ public class FeedParser {
String author = StringUtils.trimToNull(item.getAuthor());
String categories =
StringUtils.trimToNull(
item.getCategories().stream()
.map(SyndCategory::getName)
.collect(Collectors.joining(", ")));
item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", ")));
Enclosure enclosure = buildEnclosure(item);
Media media = buildMedia(item);
@@ -281,9 +270,7 @@ public class FeedParser {
SummaryStatistics stats = new SummaryStatistics();
for (int i = 0; i < entries.size() - 1; i++) {
long diff =
Math.abs(
entries.get(i).published().toEpochMilli()
- entries.get(i + 1).published().toEpochMilli());
Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli());
stats.addValue(diff);
}
return (long) stats.getMean();

View File

@@ -14,18 +14,9 @@ public record FeedParserResult(
public record Entry(String guid, String url, Instant published, Content content) {}
public record Content(
String title,
String content,
String author,
String categories,
Enclosure enclosure,
Media media) {}
String title, String content, String author, String categories, Enclosure enclosure, Media media) {}
public record Enclosure(String url, String type) {}
public record Media(
String description,
String thumbnailUrl,
Integer thumbnailWidth,
Integer thumbnailHeight) {}
public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) {}
}

View File

@@ -13,11 +13,9 @@ import java.util.regex.Pattern;
@Singleton
public class XMLCleaner {
private static final Pattern DOCTYPE_PATTERN =
Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
private final Trie trie =
Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
private final Trie trie = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
public String clean(String xml) {
xml = removeCharactersBeforeFirstXmlTag(xml);

View File

@@ -155,8 +155,7 @@ public class UserSettings extends AbstractModel {
private boolean instapaper;
private boolean buffer;
@Embedded
private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
@Embedded private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
@Embeddable
@SuppressWarnings("serial")

View File

@@ -49,8 +49,7 @@ public class OPMLExporter {
}
// export root subscriptions
for (FeedSubscription sub :
subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
opml.getOutlines().add(buildSubscriptionOutline(sub));
}
@@ -65,17 +64,14 @@ public class OPMLExporter {
for (FeedCategory child :
categories.stream()
.filter(c ->
c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
}
for (FeedSubscription sub :
subscriptions.stream()
.filter(s ->
s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId()))
.filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub));
}

View File

@@ -79,8 +79,7 @@ public class OPMLImporter {
}
// make sure we continue with the import process even if a feed failed
try {
feedSubscriptionService.subscribe(
user, outline.getXmlUrl(), name, parent, position);
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
} catch (Exception e) {
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
}

View File

@@ -33,10 +33,8 @@ import java.util.List;
public class FeedEntryContentCleaningService {
private static final Safelist HTML_WHITELIST = buildWhiteList();
private static final List<String> ALLOWED_IFRAME_CSS_RULES =
Arrays.asList("height", "width", "border");
private static final List<String> ALLOWED_IMG_CSS_RULES =
Arrays.asList("display", "width", "height");
private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border");
private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height");
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'};
public String clean(String content, String baseUri, boolean keepTextOnly) {
@@ -59,8 +57,7 @@ public class FeedEntryContentCleaningService {
e.attr("style", escaped);
}
clean.outputSettings(
new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
Element body = clean.body();
if (keepTextOnly) {
content = body.text();
@@ -126,24 +123,13 @@ public class FeedEntryContentCleaningService {
whitelist.addAttributes("blockquote", "cite");
whitelist.addAttributes("col", "span", "width");
whitelist.addAttributes("colgroup", "span", "width");
whitelist.addAttributes(
"iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
whitelist.addAttributes("ol", "start", "type");
whitelist.addAttributes("q", "cite");
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
whitelist.addAttributes(
"td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
whitelist.addAttributes(
"th",
"border",
"bordercolor",
"abbr",
"axis",
"colspan",
"rowspan",
"scope",
"width");
whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width");
whitelist.addAttributes("ul", "type");
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
@@ -161,8 +147,7 @@ public class FeedEntryContentCleaningService {
String rule = "";
try {
List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl =
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i);
@@ -187,8 +172,7 @@ public class FeedEntryContentCleaningService {
String rule = "";
try {
List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl =
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i);
@@ -216,8 +200,7 @@ public class FeedEntryContentCleaningService {
new ErrorHandler() {
@Override
public void warning(CSSParseException exception) throws CSSException {
log.debug(
"warning while parsing css: {}", exception.getMessage(), exception);
log.debug("warning while parsing css: {}", exception.getMessage(), exception);
}
@Override
@@ -227,10 +210,7 @@ public class FeedEntryContentCleaningService {
@Override
public void fatalError(CSSParseException exception) throws CSSException {
log.debug(
"fatal error while parsing css: {}",
exception.getMessage(),
exception);
log.debug("fatal error while parsing css: {}", exception.getMessage(), exception);
}
});

View File

@@ -27,9 +27,7 @@ public class FeedEntryContentService {
public FeedEntryContent findOrCreate(Content content, String baseUrl) {
FeedEntryContent entryContent = buildContent(content, baseUrl);
Optional<FeedEntryContent> existing =
feedEntryContentDAO
.findExisting(entryContent.getContentHash(), entryContent.getTitleHash())
.stream()
feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()).stream()
.filter(entryContent::equivalentTo)
.findFirst();
if (existing.isPresent()) {
@@ -44,11 +42,9 @@ public class FeedEntryContentService {
FeedEntryContent entryContent = new FeedEntryContent();
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
entryContent.setTitle(
FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
entryContent.setAuthor(
FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
entryContent.setDirection(
FeedUtils.isRTL(content.title(), content.content())
@@ -63,8 +59,7 @@ public class FeedEntryContentService {
Media media = content.media();
if (media != null) {
entryContent.setMediaDescription(
cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
entryContent.setMediaThumbnailHeight(media.thumbnailHeight());

View File

@@ -50,27 +50,17 @@ public class FeedEntryFilteringService {
private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config;
public boolean filterMatchesEntry(String filter, FeedEntry entry)
throws FeedEntryFilterException {
public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException {
if (StringUtils.isBlank(filter)) {
return true;
}
String title =
entry.getContent().getTitle() == null
? ""
: Jsoup.parse(entry.getContent().getTitle()).text();
String author =
entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
String title = entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text();
String author = entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
String content =
entry.getContent().getContent() == null
? ""
: Jsoup.parse(entry.getContent().getContent()).text();
entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text();
String url = entry.getUrl() == null ? "" : entry.getUrl();
String categories =
entry.getContent().getCategories() == null
? ""
: entry.getContent().getCategories();
String categories = entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories();
Map<String, Object> data = new HashMap<>();
data.put("title", title);
@@ -97,11 +87,9 @@ public class FeedEntryFilteringService {
TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FeedEntryFilterException(
"interrupted while evaluating expression " + filter, e);
throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e);
} catch (ExecutionException e) {
throw new FeedEntryFilterException(
"Exception while evaluating expression " + filter, e);
throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e);
} catch (TimeoutException e) {
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
}

View File

@@ -55,12 +55,9 @@ public class FeedEntryService {
}
public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) {
Set<String> guidHashes =
entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
Set<String> guidHashes = entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed);
return entries.stream()
.filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid())))
.toList();
return entries.stream().filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid()))).toList();
}
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
@@ -122,37 +119,22 @@ public class FeedEntryService {
List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
subscriptions,
true,
keywords,
null,
-1,
-1,
null,
false,
null,
null,
null);
user, subscriptions, true, keywords, null, -1, -1, null, false, null, null, null);
markList(statuses, olderThan, insertedBefore);
}
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
markList(statuses, olderThan, insertedBefore);
}
private void markList(
List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statusesToMark =
statuses.stream()
.filter(FeedEntryStatus::isMarkable)
.filter(s -> {
Instant entryDate = s.getEntry().getPublished();
return olderThan == null
|| entryDate == null
|| entryDate.isBefore(olderThan);
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
})
.filter(s -> {
Instant insertedDate = s.getEntry().getInserted();

View File

@@ -36,16 +36,14 @@ public class FeedEntryTagService {
}
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
Set<String> existingTagNames =
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
List<FeedEntryTag> addList =
tagNames.stream()
.filter(name -> !existingTagNames.contains(name))
.map(name -> new FeedEntryTag(user, entry, name))
.toList();
List<FeedEntryTag> removeList =
existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
addList.forEach(feedEntryTagDAO::persist);
feedEntryTagDAO.delete(removeList);

View File

@@ -36,8 +36,7 @@ public class FeedFaviconService {
this.defaultFavicon =
new Favicon(
Resources.toByteArray(
Objects.requireNonNull(
getClass().getResource("/images/default_favicon.gif"))),
Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))),
"image/gif");
}

View File

@@ -48,15 +48,13 @@ public class FeedSubscriptionService {
// by the feed engine
feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null
|| feed.getDisabledUntil().isBefore(Instant.now())) {
if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed);
}
});
}
public long subscribe(
User user, String url, String title, FeedCategory category, int position) {
public long subscribe(User user, String url, String title, FeedCategory category, int position) {
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
String message =
@@ -98,9 +96,7 @@ public class FeedSubscriptionService {
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
Instant lastForceRefresh = user.getLastForceRefresh();
if (lastForceRefresh != null
&& lastForceRefresh
.plus(config.feedRefresh().forceRefreshCooldownDuration())
.isAfter(Instant.now())) {
&& lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
throw new ForceFeedRefreshTooSoonException();
}

View File

@@ -47,16 +47,13 @@ public class PushNotificationService {
private final CommaFeedConfiguration config;
public PushNotificationService(
HttpClientFactory httpClientFactory,
MetricRegistry metrics,
CommaFeedConfiguration config) {
HttpClientFactory httpClientFactory, MetricRegistry metrics, CommaFeedConfiguration config) {
this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads());
this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify"));
this.config = config;
}
public void notify(
PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
public void notify(PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
if (!config.pushNotifications().enabled() || settings.getType() == null) {
return;
}
@@ -79,9 +76,7 @@ public class PushNotificationService {
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
default ->
throw new IllegalStateException(
"unsupported notification type: " + settings.getType());
default -> throw new IllegalStateException("unsupported notification type: " + settings.getType());
}
} catch (IOException e) {
throw new PushNotificationException("Failed to send external notification", e);
@@ -90,11 +85,7 @@ public class PushNotificationService {
meter.mark();
}
private void sendNtfy(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
private void sendNtfy(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String topic = settings.getTopic();
@@ -119,11 +110,7 @@ public class PushNotificationService {
send("ntfy", request.build());
}
private void sendGotify(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
private void sendGotify(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String token = settings.getUserSecret();
@@ -143,8 +130,7 @@ public class PushNotificationService {
new JsonObject()
.put(
"client::notification",
new JsonObject()
.put("click", new JsonObject().put("url", entryUrl))));
new JsonObject().put("click", new JsonObject().put("url", entryUrl))));
}
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
@@ -155,10 +141,7 @@ public class PushNotificationService {
}
private void sendPushover(
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
throws IOException {
String token = settings.getUserSecret();
String userKey = settings.getUserId();
@@ -177,8 +160,7 @@ public class PushNotificationService {
params.add(new BasicNameValuePair("url", entryUrl));
}
SimpleRequestBuilder request =
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
SimpleRequestBuilder request = SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
request.setBody(
WWWFormCodec.format(params, StandardCharsets.UTF_8),
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
@@ -194,11 +176,9 @@ public class PushNotificationService {
null);
try {
HttpResponse response =
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS)
.getHead();
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS).getHead();
if (response.getCode() >= 400) {
throw new PushNotificationException(
name + " notification failed with status " + response.getCode());
throw new PushNotificationException(name + " notification failed with status " + response.getCode());
}
} catch (InterruptedException e) {
future.cancel(true);

View File

@@ -53,8 +53,7 @@ public class UserService {
user = userDAO.findByEmail(nameOrEmail);
}
if (user != null && !user.isDisabled()) {
boolean authenticated =
encryptionService.authenticate(password, user.getPassword(), user.getSalt());
boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt());
if (authenticated) {
performPostLoginActivities(user);
return Optional.of(user);
@@ -107,16 +106,11 @@ public class UserService {
}
public User register(
String name,
String password,
String email,
Collection<Role> roles,
boolean forceRegistration) {
String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
if (!forceRegistration) {
Preconditions.checkState(
config.users().allowRegistrations(),
"Registrations are closed on this CommaFeed instance");
config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
}
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
@@ -167,9 +161,7 @@ public class UserService {
}
public String generateApiKey(User user) {
byte[] key =
encryptionService.getEncryptedPassword(
UUID.randomUUID().toString(), user.getSalt());
byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt());
return Digests.sha1Hex(key);
}

View File

@@ -71,18 +71,15 @@ public class DatabaseCleaningService {
return;
}
entriesDeleted =
unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
entriesDeletedMeter.mark(entriesDeleted);
entriesTotal += entriesDeleted;
log.debug("removed {} entries for feeds without subscriptions", entriesTotal);
} while (entriesDeleted > 0);
}
deleted =
unitOfWork.call(() ->
feedDAO.delete(
feedDAO.findByIds(
feeds.stream().map(AbstractModel::getId).toList())));
unitOfWork.call(
() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList())));
total += deleted;
log.debug("removed {} feeds without subscriptions", total);
} while (deleted != 0);
@@ -117,8 +114,7 @@ public class DatabaseCleaningService {
List<FeedCapacity> feeds =
unitOfWork.call(() ->
feedEntryDAO.findFeedsExceedingCapacity(
maxFeedCapacity, batchSize, keepStarredEntries));
feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize, keepStarredEntries));
if (feeds.isEmpty()) {
break;
}
@@ -128,8 +124,7 @@ public class DatabaseCleaningService {
int deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info(
"interrupted, stopping cleanup of entries exceeding feed capacity");
log.info("interrupted, stopping cleanup of entries exceeding feed capacity");
return;
}
@@ -137,9 +132,7 @@ public class DatabaseCleaningService {
deleted =
unitOfWork.call(() ->
feedEntryDAO.deleteOldEntries(
feed.id(),
Math.min(batchSize, rem),
keepStarredEntries));
feed.id(), Math.min(batchSize, rem), keepStarredEntries));
entriesDeletedMeter.mark(deleted);
total += deleted;
remaining -= deleted;
@@ -161,9 +154,8 @@ public class DatabaseCleaningService {
}
deleted =
unitOfWork.call(() ->
feedEntryDAO.deleteEntriesOlderThan(
olderThan, batchSize, keepStarredEntries));
unitOfWork.call(
() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize, keepStarredEntries));
entriesDeletedMeter.mark(deleted);
total += deleted;
log.debug("removed {} old entries", total);
@@ -181,9 +173,7 @@ public class DatabaseCleaningService {
return;
}
deleted =
unitOfWork.call(
() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
total += deleted;
log.debug("removed {} old read statuses", total);
} while (deleted != 0);

View File

@@ -27,14 +27,12 @@ public class DatabaseStartupService {
}
/**
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use
* lower case columns
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns
*/
@MetaInfServices(Database.class)
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
@Override
public String escapeObjectName(
String objectName, Class<? extends DatabaseObject> objectType) {
public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
return objectName;
}

View File

@@ -16,8 +16,7 @@ import java.util.List;
public class YoutubeFeedURLProvider implements FeedURLProvider {
private static final String PREFIX = "https://www.youtube.com/channel/";
private static final String REPLACEMENT_PREFIX =
"https://www.youtube.com/feeds/videos.xml?channel_id=";
private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id=";
@Override
public List<String> get(String url, String urlContent) {

View File

@@ -67,10 +67,7 @@ public class Entry implements Serializable {
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
private Instant date;
@Schema(
description = "entry insertion date in the database",
type = SchemaType.INTEGER,
required = true)
@Schema(description = "entry insertion date in the database", type = SchemaType.INTEGER, required = true)
private Instant insertedDate;
@Schema(description = "feed id", required = true)
@@ -97,9 +94,7 @@ public class Entry implements Serializable {
@Schema(description = "starred status", required = true)
private boolean starred;
@Schema(
description = "whether the entry is still markable (old entry statuses are discarded)",
required = true)
@Schema(description = "whether the entry is still markable (old entry statuses are discarded)", required = true)
private boolean markable;
@Schema(description = "tags", required = true)
@@ -130,10 +125,7 @@ public class Entry implements Serializable {
if (content != null) {
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
entry.setTitle(content.getTitle());
entry.setContent(
proxyImages
? FeedUtils.proxyImages(content.getContent())
: content.getContent());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType());

View File

@@ -29,14 +29,10 @@ public class Settings implements Serializable {
@Schema(description = "user reads entries in ascending or descending order", required = true)
private ReadingOrder readingOrder;
@Schema(
description = "user wants category and feeds with no unread entries shown",
required = true)
@Schema(description = "user wants category and feeds with no unread entries shown", required = true)
private boolean showRead;
@Schema(
description = "In expanded view, scroll through entries mark them as read",
required = true)
@Schema(description = "In expanded view, scroll through entries mark them as read", required = true)
private boolean scrollMarks;
@Schema(description = "user's custom css for the website")
@@ -45,42 +41,33 @@ public class Settings implements Serializable {
@Schema(description = "user's custom js for the website")
private String customJs;
@Schema(
description = "user's preferred scroll speed when navigating between entries",
required = true)
@Schema(description = "user's preferred scroll speed when navigating between entries", required = true)
private int scrollSpeed;
@Schema(description = "whether to scroll to the selected entry", required = true)
private ScrollMode scrollMode;
@Schema(
description = "number of entries to keep above the selected entry when scrolling",
required = true)
@Schema(description = "number of entries to keep above the selected entry when scrolling", required = true)
private int entriesToKeepOnTopWhenScrolling;
@Schema(description = "whether to show the star icon in the header of entries", required = true)
private IconDisplayMode starIconDisplayMode;
@Schema(
description = "whether to show the external link icon in the header of entries",
required = true)
@Schema(description = "whether to show the external link icon in the header of entries", required = true)
private IconDisplayMode externalLinkIconDisplayMode;
@Schema(description = "ask for confirmation when marking all entries as read", required = true)
private boolean markAllAsReadConfirmation;
@Schema(
description =
"navigate to the next unread category or feed after marking all entries as read",
description = "navigate to the next unread category or feed after marking all entries as read",
required = true)
private boolean markAllAsReadNavigateToNextUnread;
@Schema(description = "show commafeed's own context menu on right click", required = true)
private boolean customContextMenu;
@Schema(
description = "on mobile, show action buttons at the bottom of the screen",
required = true)
@Schema(description = "on mobile, show action buttons at the bottom of the screen", required = true)
private boolean mobileFooter;
@Schema(description = "show unread count in the title", required = true)

View File

@@ -37,8 +37,7 @@ public class Subscription implements Serializable {
private Instant lastRefresh;
@Schema(
description =
"next time the feed refresh is planned, null if refresh is already queued",
description = "next time the feed refresh is planned, null if refresh is already queued",
type = SchemaType.INTEGER)
private Instant nextRefresh;
@@ -63,22 +62,16 @@ public class Subscription implements Serializable {
@Schema(description = "date of the newest item", type = SchemaType.INTEGER)
private Instant newestItemTime;
@Schema(
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
private String filter;
@Schema(description = "JEXL legacy filter")
private String filterLegacy;
@Schema(
description = "whether to send push notifications for new entries of this feed",
required = true)
@Schema(description = "whether to send push notifications for new entries of this feed", required = true)
private boolean pushNotificationsEnabled;
@Schema(
description =
"automatically mark entries as read after this many days (null to disable)")
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {

View File

@@ -27,17 +27,13 @@ public class FeedModificationRequest implements Serializable {
@Schema(description = "new display position, null if not changed")
private Integer position;
@Schema(
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
@Schema(description = "whether to send push notifications for new entries of this feed")
private boolean pushNotificationsEnabled;
@Schema(
description =
"automatically mark entries as read after this many days (null to disable)")
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
}

View File

@@ -35,8 +35,6 @@ public class MarkRequest implements Serializable {
@Size(max = 128)
private String keywords;
@Schema(
description =
"if marking a category or 'all', exclude those subscriptions from the marking")
@Schema(description = "if marking a category or 'all', exclude those subscriptions from the marking")
private List<Long> excludedSubscriptions;
}

View File

@@ -62,8 +62,7 @@ public class AdminREST {
@Transactional
@Operation(
summary = "Save or update a user",
description =
"Save or update a user. If the id is not specified, a new user will be created")
description = "Save or update a user. If the id is not specified, a new user will be created")
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
@@ -77,24 +76,14 @@ public class AdminREST {
roles.add(Role.ADMIN);
}
try {
id =
userService
.register(
req.getName(),
req.getPassword(),
req.getEmail(),
roles,
true)
.getId();
id = userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true).getId();
} catch (Exception e) {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
}
} else {
User user = authenticationContext.getCurrentUser();
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
return Response.status(Status.FORBIDDEN)
.entity("You cannot disable your own account.")
.build();
return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
}
User u = userDAO.findById(id);
@@ -134,8 +123,7 @@ public class AdminREST {
@GET
@Transactional
@Operation(summary = "Get user information", description = "Get user information")
public UserModel adminGetUser(
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
public UserModel adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User u = userDAO.findById(id);
UserModel userModel = new UserModel();
@@ -143,8 +131,7 @@ public class AdminREST {
userModel.setName(u.getName());
userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled());
userModel.setAdmin(
userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
return userModel;
}
@@ -192,9 +179,7 @@ public class AdminREST {
User user = authenticationContext.getCurrentUser();
if (user.getId().equals(u.getId())) {
return Response.status(Status.FORBIDDEN)
.entity("You cannot delete your own user.")
.build();
return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build();
}
userService.unregister(u);
return Response.ok().build();

View File

@@ -104,30 +104,22 @@ public class CategoryREST {
@Operation(summary = "Get category entries", description = "Get a list of category entries")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Entries.class))
})
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class))})
@APIResponse(responseCode = "404", description = "category not found")
public Response getCategoryEntries(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
@QueryParam("id")
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("unread")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@@ -136,13 +128,11 @@ public class CategoryREST {
@Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
String tag) {
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Preconditions.checkNotNull(readType);
List<FeedEntryKeyword> entryKeywords =
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
@@ -159,8 +149,7 @@ public class CategoryREST {
List<Long> excludedIds = null;
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
excludedIds =
Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
}
User user = authenticationContext.getCurrentUser();
@@ -191,18 +180,15 @@ public class CategoryREST {
} else if (STARRED.equals(id)) {
entries.setName("Starred");
List<FeedEntryStatus> starred =
feedEntryStatusDAO.findStarred(
user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
feedEntryStatusDAO.findStarred(user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
if (parent != null) {
List<FeedCategory> categories =
feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs =
feedSubscriptionDAO.findByCategories(user, categories);
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list =
feedEntryStatusDAO.findBySubscriptions(
@@ -224,9 +210,7 @@ public class CategoryREST {
}
entries.setName(parent.getName());
} else {
return Response.status(Status.NOT_FOUND)
.entity("<message>category not found</message>")
.build();
return Response.status(Status.NOT_FOUND).entity("<message>category not found</message>").build();
}
}
@@ -244,28 +228,22 @@ public class CategoryREST {
@Path("/entriesAsFeed")
@GET
@Transactional
@Operation(
summary = "Get category entries as feed",
description = "Get a feed of category entries")
@Operation(summary = "Get category entries as feed", description = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML)
public Response getCategoryEntriesAsFeed(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
@QueryParam("id")
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("all")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@@ -274,20 +252,11 @@ public class CategoryREST {
@Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
String tag) {
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Response response =
getCategoryEntries(
id,
readType,
newerThan,
offset,
limit,
order,
keywords,
excludedSubscriptionIds,
tag);
id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
@@ -314,21 +283,14 @@ public class CategoryREST {
@Path("/mark")
@POST
@Transactional
@Operation(
summary = "Mark category entries",
description = "Mark feed entries of this category as read")
@Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read")
public Response markCategoryEntries(
@Valid @Parameter(description = "category id, or 'all'", required = true)
MarkRequest req) {
@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Instant olderThan =
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore =
req.getInsertedBefore() == null
? null
: Instant.ofEpochMilli(req.getInsertedBefore());
Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
@@ -336,8 +298,7 @@ public class CategoryREST {
if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(
user, subs, olderThan, insertedBefore, entryKeywords);
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
} else {
@@ -345,8 +306,7 @@ public class CategoryREST {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(
user, subs, olderThan, insertedBefore, entryKeywords);
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
}
return Response.ok().build();
}
@@ -400,8 +360,7 @@ public class CategoryREST {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
for (FeedCategory child : categories) {
if (!child.getId().equals(cat.getId())
&& child.getParent().getId().equals(cat.getId())) {
if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) {
child.setParent(null);
}
}
@@ -417,8 +376,7 @@ public class CategoryREST {
@Path("/modify")
@Transactional
@Operation(summary = "Modify a category", description = "Modify an existing feed category")
public Response modifyCategory(
@Valid @Parameter(required = true) CategoryModificationRequest req) {
public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -437,14 +395,11 @@ public class CategoryREST {
}
if (parent != null) {
Long parentId = parent.getId();
List<FeedCategory> descendants =
feedCategoryDAO.findAllChildrenCategories(user, category);
boolean parentIsDescendant =
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
List<FeedCategory> descendants = feedCategoryDAO.findAllChildrenCategories(user, category);
boolean parentIsDescendant = descendants.stream().anyMatch(c -> c.getId().equals(parentId));
if (parentIsDescendant) {
return Response.status(Status.BAD_REQUEST)
.entity(
"<message>cannot set a category's own descendant as its parent</message>")
.entity("<message>cannot set a category's own descendant as its parent</message>")
.build();
}
}
@@ -476,9 +431,7 @@ public class CategoryREST {
@POST
@Path("/collapse")
@Transactional
@Operation(
summary = "Collapse a category",
description = "Save collapsed or expanded status for a category")
@Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category")
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -506,9 +459,7 @@ public class CategoryREST {
@GET
@Path("/get")
@Transactional
@Operation(
summary = "Get root category",
description = "Get all categories and subscriptions of the user")
@Operation(summary = "Get root category", description = "Get all categories and subscriptions of the user")
public Category getRootCategory() {
User user = authenticationContext.getCurrentUser();
@@ -551,8 +502,7 @@ public class CategoryREST {
for (FeedSubscription subscription : subscriptions) {
if (id == null && subscription.getCategory() == null
|| subscription.getCategory() != null
&& Objects.equals(subscription.getCategory().getId(), id)) {
|| subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) {
UnreadCount uc = unreadCount.get(subscription.getId());
Subscription sub = Subscription.build(subscription, uc);
category.getFeeds().add(sub);

View File

@@ -50,8 +50,7 @@ public class EntryREST {
@POST
@Transactional
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
public Response markEntry(
@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -63,12 +62,9 @@ public class EntryREST {
@Path("/markMultiple")
@POST
@Transactional
@Operation(
summary = "Mark multiple feed entries",
description = "Mark feed entries as read/unread")
@Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread")
public Response markEntries(
@Valid @Parameter(description = "Multiple Mark Request", required = true)
MultipleMarkRequest req) {
@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests());
@@ -85,8 +81,7 @@ public class EntryREST {
@POST
@Transactional
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
public Response starEntry(
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@@ -99,9 +94,7 @@ public class EntryREST {
@Path("/tags")
@GET
@Transactional
@Operation(
summary = "Get list of tags for the user",
description = "Get list of tags for the user")
@Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user")
public Response getTags() {
User user = authenticationContext.getCurrentUser();
List<String> tags = feedEntryTagDAO.findByUser(user);
@@ -112,8 +105,7 @@ public class EntryREST {
@POST
@Transactional
@Operation(summary = "Set feed entry tags")
public Response tagEntry(
@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());

View File

@@ -133,11 +133,7 @@ public class FeedREST {
@Operation(summary = "Get feed entries", description = "Get a list of feed entries")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Entries.class))
})
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class))})
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeedEntries(
@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@@ -145,16 +141,13 @@ public class FeedREST {
@DefaultValue("unread")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@@ -164,8 +157,7 @@ public class FeedREST {
Preconditions.checkNotNull(id);
Preconditions.checkNotNull(readType);
List<FeedEntryKeyword> entryKeywords =
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
@@ -211,9 +203,7 @@ public class FeedREST {
entries.getEntries().removeLast();
}
} else {
return Response.status(Status.NOT_FOUND)
.entity("<message>feed not found</message>")
.build();
return Response.status(Status.NOT_FOUND).entity("<message>feed not found</message>").build();
}
entries.setTimestamp(System.currentTimeMillis());
@@ -232,16 +222,13 @@ public class FeedREST {
@DefaultValue("all")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@@ -277,8 +264,7 @@ public class FeedREST {
url = StringUtils.trimToEmpty(url);
url = prependHttp(url);
try {
FeedFetcherResult feedFetcherResult =
feedFetcher.fetch(url, true, null, null, null, null);
FeedFetcherResult feedFetcherResult = feedFetcher.fetch(url, true, null, null, null, null);
info = new FeedInfo();
info.setUrl(feedFetcherResult.urlAfterRedirect());
info.setTitle(feedFetcherResult.feed().title());
@@ -296,14 +282,9 @@ public class FeedREST {
@Operation(summary = "Fetch a feed", description = "Fetch a feed by its url")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = FeedInfo.class))
})
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = FeedInfo.class))})
@APIResponse(responseCode = "404", description = "feed not found")
public Response fetchFeed(
@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getUrl());
@@ -339,33 +320,21 @@ public class FeedREST {
@Path("/mark")
@POST
@Transactional
@Operation(
summary = "Mark feed entries",
description = "Mark feed entries as read (unread is not supported)")
public Response markFeedEntries(
@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
@Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)")
public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Instant olderThan =
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore =
req.getInsertedBefore() == null
? null
: Instant.ofEpochMilli(req.getInsertedBefore());
Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription =
feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
if (subscription != null) {
feedEntryService.markSubscriptionEntries(
user,
Collections.singletonList(subscription),
olderThan,
insertedBefore,
entryKeywords);
user, Collections.singletonList(subscription), olderThan, insertedBefore, entryKeywords);
}
return Response.ok().build();
}
@@ -376,14 +345,9 @@ public class FeedREST {
@Operation(summary = "get feed")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Subscription.class))
})
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Subscription.class))})
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeed(
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
public Response getFeed(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User user = authenticationContext.getCurrentUser();
@@ -435,14 +399,9 @@ public class FeedREST {
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))
})
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Long.class))})
public Response subscribe(
@Valid @Parameter(description = "subscription request", required = true)
SubscribeRequest req) {
@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getTitle());
Preconditions.checkNotNull(req.getUrl());
@@ -455,9 +414,7 @@ public class FeedREST {
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
User user = authenticationContext.getCurrentUser();
long subscriptionId =
feedSubscriptionService.subscribe(
user, info.getUrl(), req.getTitle(), category, 0);
long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category, 0);
return Response.ok(subscriptionId).build();
} catch (Exception e) {
log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e);
@@ -513,18 +470,14 @@ public class FeedREST {
@Transactional
@Operation(summary = "Modify a subscription", description = "Modify a feed subscription")
public Response modifyFeed(
@Valid @Parameter(description = "subscription id", required = true)
FeedModificationRequest req) {
@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
try {
feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY);
} catch (FeedEntryFilterException e) {
return Response.status(Status.BAD_REQUEST)
.entity(e.getMessage())
.type(MediaType.TEXT_PLAIN)
.build();
return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).type(MediaType.TEXT_PLAIN).build();
}
User user = authenticationContext.getCurrentUser();
@@ -576,22 +529,15 @@ public class FeedREST {
@Path("/import")
@Transactional
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "OPML import",
description = "Import an OPML file, posted as a FORM with the 'file' name")
public Response importOpml(
@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) {
@Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name")
public Response importOpml(@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
.entity("Import is disabled for the demo account")
.build();
return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build();
}
try {
// opml will be encoded in the default JVM encoding, bu we want UTF-8
opmlImporter.importOpml(
user,
new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8));
opmlImporter.importOpml(user, new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8));
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
@@ -602,9 +548,7 @@ public class FeedREST {
@Path("/export")
@Transactional
@Produces(MediaType.APPLICATION_XML)
@Operation(
summary = "OPML export",
description = "Export an OPML file of the user's subscriptions")
@Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions")
public Response exportOpml() throws FeedException {
User user = authenticationContext.getCurrentUser();
Opml opml = opmlExporter.export(user);

View File

@@ -58,8 +58,7 @@ public class ServerREST {
infos.setWebsocketEnabled(config.websocket().enabled());
infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis());
infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis());
infos.setForceRefreshCooldownDuration(
config.feedRefresh().forceRefreshCooldownDuration().toMillis());
infos.setForceRefreshCooldownDuration(config.feedRefresh().forceRefreshCooldownDuration().toMillis());
infos.setInitialSetupRequired(databaseStartupService.isInitialSetupRequired());
infos.setMinimumPasswordLength(config.users().minimumPasswordLength());
infos.setPushNotificationsEnabled(config.pushNotifications().enabled());

View File

@@ -134,14 +134,10 @@ public class UserREST {
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
s.getPushNotificationSettings()
.setServerUrl(settings.getPushNotifications().getServerUrl());
s.getPushNotificationSettings()
.setUserId(settings.getPushNotifications().getUserId());
s.getPushNotificationSettings()
.setUserSecret(settings.getPushNotifications().getUserSecret());
s.getPushNotificationSettings()
.setTopic(settings.getPushNotifications().getTopic());
s.getPushNotificationSettings().setServerUrl(settings.getPushNotifications().getServerUrl());
s.getPushNotificationSettings().setUserId(settings.getPushNotifications().getUserId());
s.getPushNotificationSettings().setUserSecret(settings.getPushNotifications().getUserSecret());
s.getPushNotificationSettings().setTopic(settings.getPushNotifications().getTopic());
}
} else {
s.setReadingMode(ReadingMode.UNREAD);
@@ -191,10 +187,7 @@ public class UserREST {
s.setShowRead(settings.isShowRead());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(
CommaFeedConstants.USERNAME_DEMO.equals(user.getName())
? ""
: settings.getCustomJs());
s.setCustomJs(CommaFeedConstants.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode());
@@ -234,8 +227,7 @@ public class UserREST {
@POST
@Transactional
@Operation(summary = "Send a test push notification")
public Response sendTestPushNotification(
@Parameter(required = true) PushNotificationSettings settings) {
public Response sendTestPushNotification(@Parameter(required = true) PushNotificationSettings settings) {
FeedSubscription sub = new FeedSubscription();
sub.setTitle("CommaFeed Test Feed");
sub.setFeed(new Feed());
@@ -291,8 +283,7 @@ public class UserREST {
@POST
@Transactional
@Operation(summary = "Save user's profile")
public Response saveUserProfile(
@Valid @Parameter(required = true) ProfileModificationRequest request) {
public Response saveUserProfile(@Valid @Parameter(required = true) ProfileModificationRequest request) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
@@ -335,10 +326,7 @@ public class UserREST {
public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) {
try {
userService.register(
req.getName(),
req.getPassword(),
req.getEmail(),
Collections.singletonList(Role.USER));
req.getName(), req.getPassword(), req.getEmail(), Collections.singletonList(Role.USER));
return Response.ok().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
@@ -355,17 +343,10 @@ public class UserREST {
public Response initialSetup(@Valid @Parameter(required = true) InitialSetupRequest req) {
boolean initialSetupRequired = databaseStartupService.isInitialSetupRequired();
if (!initialSetupRequired) {
return Response.status(Status.BAD_REQUEST)
.entity("Initial setup has already been completed")
.build();
return Response.status(Status.BAD_REQUEST).entity("Initial setup has already been completed").build();
}
userService.register(
req.getName(),
req.getPassword(),
req.getEmail(),
List.of(Role.ADMIN, Role.USER),
true);
userService.register(req.getName(), req.getPassword(), req.getEmail(), List.of(Role.ADMIN, Role.USER), true);
if (config.users().createDemoAccount()) {
User demo = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
@@ -384,8 +365,7 @@ public class UserREST {
@Operation(summary = "send a password reset email")
public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) {
if (!config.passwordRecoveryEnabled()) {
throw new IllegalArgumentException(
"Password recovery is not enabled on this CommaFeed instance");
throw new IllegalArgumentException("Password recovery is not enabled on this CommaFeed instance");
}
User user = userDAO.findByEmail(req.getEmail());
@@ -409,8 +389,7 @@ public class UserREST {
}
private String buildEmailContent(User user) throws URISyntaxException {
String publicUrl =
Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
String publicUrl = Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
return String.format(
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
HtmlEscapers.htmlEscaper().escape(user.getName()), callbackUrl(user, publicUrl));
@@ -429,8 +408,7 @@ public class UserREST {
@POST
@Transactional
@Operation(summary = "confirm password reset with new password")
public Response passwordRecoveryCallback(
@Valid @Parameter(required = true) PasswordResetConfirmationRequest req) {
public Response passwordRecoveryCallback(@Valid @Parameter(required = true) PasswordResetConfirmationRequest req) {
String email = req.getEmail();
String token = req.getToken();
String password = req.getPassword();
@@ -440,12 +418,8 @@ public class UserREST {
Preconditions.checkNotNull(password);
User user = userDAO.findByEmail(email);
if (user == null
|| user.getRecoverPasswordToken() == null
|| !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED)
.entity("Email not found or invalid token.")
.build();
if (user == null || user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found or invalid token.").build();
}
if (ChronoUnit.MINUTES.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 30) {
return Response.status(Status.UNAUTHORIZED).entity("Token expired.").build();
@@ -468,16 +442,12 @@ public class UserREST {
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
.entity("the demo account cannot be deleted")
.build();
return Response.status(Status.FORBIDDEN).entity("the demo account cannot be deleted").build();
}
Set<Role> roles = userRoleDAO.findRoles(user);
if (roles.contains(Role.ADMIN) && userRoleDAO.countAdmins() == 1) {
return Response.status(Status.FORBIDDEN)
.entity("The last admin account cannot be deleted")
.build();
return Response.status(Status.FORBIDDEN).entity("The last admin account cannot be deleted").build();
}
userService.unregister(userDAO.findById(user.getId()));

View File

@@ -94,9 +94,7 @@ public class FeverREST {
@Transactional
@Operation(hidden = true)
public FeverResponse formUrlencoded(
@Context UriInfo uri,
@PathParam("userId") Long userId,
MultivaluedMap<String, String> form) {
@Context UriInfo uri, @PathParam("userId") Long userId, MultivaluedMap<String, String> form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.forEach((k, v) -> params.put(k, v.getFirst()));
@@ -135,14 +133,10 @@ public class FeverREST {
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse formData(
@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) {
public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.getValues()
.forEach((k, v) ->
params.put(
k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
return handle(userId, params);
}
@@ -212,8 +206,7 @@ public class FeverREST {
if (params.containsKey("mark") && params.containsKey("id") && params.containsKey("as")) {
long id = Long.parseLong(params.get("id"));
String before = params.get("before");
Instant insertedBefore =
before == null ? null : Instant.ofEpochSecond(Long.parseLong(before));
Instant insertedBefore = before == null ? null : Instant.ofEpochSecond(Long.parseLong(before));
mark(user, params.get("mark"), id, params.get("as"), insertedBefore);
}
@@ -236,9 +229,7 @@ public class FeverREST {
private List<FeverFeedGroup> buildFeedsGroups(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.collect(
Collectors.groupingBy(
s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
.collect(Collectors.groupingBy(s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
.entrySet()
.stream()
.map(e -> {
@@ -272,9 +263,7 @@ public class FeverREST {
f.setSiteUrl(s.getFeed().getLink());
f.setSpark(false);
f.setLastUpdatedOnTime(
s.getFeed().getLastUpdated() == null
? 0
: s.getFeed().getLastUpdated().getEpochSecond());
s.getFeed().getLastUpdated() == null ? 0 : s.getFeed().getLastUpdated().getEpochSecond());
return f;
})
.toList();
@@ -305,8 +294,7 @@ public class FeverREST {
return statuses.stream().map(s -> s.getEntry().getId()).toList();
}
private List<FeverItem> buildItems(
User user, List<FeedSubscription> subscriptions, List<String> entryIds) {
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, List<String> entryIds) {
List<FeverItem> items = new ArrayList<>();
Map<Long, FeedSubscription> subscriptionsByFeedId =
@@ -323,8 +311,7 @@ public class FeverREST {
return items;
}
private List<FeverItem> buildItems(
User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
@@ -366,8 +353,7 @@ public class FeverREST {
f.setData(
String.format(
"data:%s;base64,%s",
favicon.mediaType(),
Base64.getEncoder().encodeToString(favicon.icon())));
favicon.mediaType(), Base64.getEncoder().encodeToString(favicon.icon())));
return f;
})
.toList();
@@ -387,10 +373,8 @@ public class FeverREST {
} else if ("group".equals(source)) {
FeedCategory parent = feedCategoryDAO.findById(user, id);
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subscriptions =
feedSubscriptionDAO.findByCategories(user, categories);
feedEntryService.markSubscriptionEntries(
user, subscriptions, null, insertedBefore, null);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, categories);
feedEntryService.markSubscriptionEntries(user, subscriptions, null, insertedBefore, null);
}
}
}

View File

@@ -157,24 +157,18 @@ public class FeverResponse {
@Data
public static class FeverLink {}
public static class LongListToCommaSeparatedStringSerializer
extends JsonSerializer<List<Long>> {
public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer<List<Long>> {
@Override
public void serialize(
List<Long> input,
JsonGenerator jsonGenerator,
SerializerProvider serializerProvider)
public void serialize(List<Long> input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
throws IOException {
String output = input.stream().map(String::valueOf).collect(Collectors.joining(","));
jsonGenerator.writeObject(output);
}
}
public static class CommaSeparatedStringToLongListDeserializer
extends JsonDeserializer<List<Long>> {
public static class CommaSeparatedStringToLongListDeserializer extends JsonDeserializer<List<Long>> {
@Override
public List<Long> deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
public List<Long> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String value = ctxt.readValue(p, String.class);
return Stream.of(value.split(",")).map(Long::valueOf).toList();
}

View File

@@ -145,8 +145,7 @@ public class GoogleReaderREST {
public GoogleReaderModel.SubscriptionList subscriptionList() {
User user = currentUser();
GoogleReaderModel.SubscriptionList resp = new GoogleReaderModel.SubscriptionList();
resp.setSubscriptions(
feedSubscriptionDAO.findAll(user).stream().map(this::mapSubscription).toList());
resp.setSubscriptions(feedSubscriptionDAO.findAll(user).stream().map(this::mapSubscription).toList());
return resp;
}
@@ -216,10 +215,7 @@ public class GoogleReaderREST {
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
feedCategoryDAO
.findAll(user)
.forEach(c ->
tags.add(
new GoogleReaderModel.Tag(
GoogleReaderStreamId.label(c.getName()))));
.forEach(c -> tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.label(c.getName()))));
tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.STARRED));
GoogleReaderModel.TagList resp = new GoogleReaderModel.TagList();
@@ -256,8 +252,7 @@ public class GoogleReaderREST {
}
Map<Long, FeedCategory> categoriesById =
feedCategoryDAO.findAll(user).stream()
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
feedCategoryDAO.findAll(user).stream().collect(Collectors.toMap(FeedCategory::getId, c -> c));
unreadByCategory.forEach((categoryId, count) -> {
if (categoryId == 0) {
return;
@@ -325,14 +320,12 @@ public class GoogleReaderREST {
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
boolean unreadOnly = isUnreadOnly(excludeTargets);
PagedStatuses paged =
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
PagedStatuses paged = fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
resp.setItemRefs(
paged.statuses().stream()
.map(s ->
new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
.map(s -> new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
.toList());
resp.setContinuation(paged.continuation());
return resp;
@@ -343,8 +336,7 @@ public class GoogleReaderREST {
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Transactional
@Operation(hidden = true)
public GoogleReaderModel.StreamContents streamItemsContentsPost(
@FormParam("i") List<String> ids) {
public GoogleReaderModel.StreamContents streamItemsContentsPost(@FormParam("i") List<String> ids) {
return buildStreamItemsContents(ids);
}
@@ -352,8 +344,7 @@ public class GoogleReaderREST {
@Path("/reader/api/0/stream/items/contents")
@Transactional
@Operation(hidden = true)
public GoogleReaderModel.StreamContents streamItemsContentsGet(
@QueryParam("i") List<String> ids) {
public GoogleReaderModel.StreamContents streamItemsContentsGet(@QueryParam("i") List<String> ids) {
return buildStreamItemsContents(ids);
}
@@ -415,9 +406,7 @@ public class GoogleReaderREST {
@Transactional
@Operation(hidden = true)
public Response markAllAsRead(
@FormParam("s") String streamId,
@FormParam("ts") Long ts,
@FormParam("T") String token) {
@FormParam("s") String streamId, @FormParam("ts") Long ts, @FormParam("T") String token) {
User user = currentUser();
if (!validToken(user, token)) {
return Response.status(Status.UNAUTHORIZED).build();
@@ -430,20 +419,16 @@ public class GoogleReaderREST {
case STARRED -> feedEntryService.markStarredEntries(user, olderThan, null);
case FEED -> {
Long subId = parsed.feedSubscriptionId();
FeedSubscription sub =
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
FeedSubscription sub = subId == null ? null : feedSubscriptionDAO.findById(user, subId);
if (sub != null) {
feedEntryService.markSubscriptionEntries(
user, List.of(sub), olderThan, null, null);
feedEntryService.markSubscriptionEntries(user, List.of(sub), olderThan, null, null);
}
}
case LABEL -> {
FeedCategory category = findCategoryByName(user, parsed.value());
if (category != null) {
List<FeedCategory> categories =
feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subs =
feedSubscriptionDAO.findByCategories(user, categories);
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
feedEntryService.markSubscriptionEntries(user, subs, olderThan, null, null);
}
}
@@ -456,19 +441,14 @@ public class GoogleReaderREST {
}
private GoogleReaderModel.StreamContents buildStreamContents(
String rawStreamId,
Integer n,
String continuation,
String order,
List<String> excludeTargets) {
String rawStreamId, Integer n, String continuation, String order, List<String> excludeTargets) {
User user = currentUser();
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(rawStreamId);
int limit = clampLimit(n);
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
boolean unreadOnly = isUnreadOnly(excludeTargets);
PagedStatuses paged =
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
PagedStatuses paged = fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
GoogleReaderModel.StreamContents resp = new GoogleReaderModel.StreamContents();
resp.setId(streamIdString(parsed));
@@ -483,8 +463,7 @@ public class GoogleReaderREST {
User user = currentUser();
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
Map<Long, FeedSubscription> subsByFeedId =
subs.stream()
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s, (a, b) -> a));
subs.stream().collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s, (a, b) -> a));
List<GoogleReaderModel.StreamItem> items = new ArrayList<>();
if (ids != null) {
@@ -544,16 +523,13 @@ public class GoogleReaderREST {
switch (streamId.type()) {
case STARRED -> {
List<FeedEntryStatus> starred =
feedEntryStatusDAO.findStarred(
user, null, null, offset, limit, order, true);
feedEntryStatusDAO.findStarred(user, null, null, offset, limit, order, true);
fetchedCount = starred.size();
statuses =
unreadOnly ? starred.stream().filter(s -> !s.isRead()).toList() : starred;
statuses = unreadOnly ? starred.stream().filter(s -> !s.isRead()).toList() : starred;
}
case FEED -> {
Long subId = streamId.feedSubscriptionId();
FeedSubscription sub =
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
FeedSubscription sub = subId == null ? null : feedSubscriptionDAO.findById(user, subId);
List<FeedSubscription> subs = sub == null ? List.of() : List.of(sub);
statuses =
feedEntryStatusDAO.findBySubscriptions(
@@ -577,8 +553,7 @@ public class GoogleReaderREST {
category == null
? List.of()
: feedSubscriptionDAO.findByCategories(
user,
feedCategoryDAO.findAllChildrenCategories(user, category));
user, feedCategoryDAO.findAllChildrenCategories(user, category));
statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
@@ -644,15 +619,12 @@ public class GoogleReaderREST {
if (StringUtils.isNotBlank(entry.getUrl())) {
item.setCanonical(List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), null)));
item.setAlternate(
List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), "text/html")));
item.setAlternate(List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), "text/html")));
}
item.setSummary(
new GoogleReaderModel.Content(
Optional.ofNullable(entry.getContent().getContent()).orElse(""),
entry.getContent().getDirection() == FeedEntryContent.Direction.RTL
? "rtl"
: "ltr"));
entry.getContent().getDirection() == FeedEntryContent.Direction.RTL ? "rtl" : "ltr"));
List<String> categories = new ArrayList<>();
categories.add(GoogleReaderStreamId.READING_LIST);
@@ -695,8 +667,7 @@ public class GoogleReaderREST {
return s;
}
private FeedCategory resolveLabelCategory(
User user, String labelParam, boolean createIfMissing) {
private FeedCategory resolveLabelCategory(User user, String labelParam, boolean createIfMissing) {
if (StringUtils.isBlank(labelParam)) {
return null;
}
@@ -717,10 +688,7 @@ public class GoogleReaderREST {
}
private FeedCategory findCategoryByName(User user, String name) {
return feedCategoryDAO.findAll(user).stream()
.filter(c -> c.getName().equals(name))
.findFirst()
.orElse(null);
return feedCategoryDAO.findAll(user).stream().filter(c -> c.getName().equals(name)).findFirst().orElse(null);
}
private String streamIdString(GoogleReaderStreamId parsed) {
@@ -736,8 +704,7 @@ public class GoogleReaderREST {
return switch (parsed.type()) {
case FEED -> {
Long subId = parsed.feedSubscriptionId();
FeedSubscription sub =
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
FeedSubscription sub = subId == null ? null : feedSubscriptionDAO.findById(user, subId);
yield sub == null ? null : sub.getTitle();
}
case LABEL -> parsed.value();
@@ -747,8 +714,7 @@ public class GoogleReaderREST {
}
private boolean isUnreadOnly(List<String> excludeTargets) {
return excludeTargets != null
&& excludeTargets.stream().anyMatch(t -> t.endsWith("/state/com.google/read"));
return excludeTargets != null && excludeTargets.stream().anyMatch(t -> t.endsWith("/state/com.google/read"));
}
private boolean containsTag(List<String> tags, String suffix) {

View File

@@ -18,8 +18,7 @@ public record GoogleReaderStreamId(Type type, String value) {
public static final String FEED_PREFIX = "feed/";
public static final GoogleReaderStreamId ALL_STREAM = new GoogleReaderStreamId(Type.ALL, null);
public static final GoogleReaderStreamId STARRED_STREAM =
new GoogleReaderStreamId(Type.STARRED, null);
public static final GoogleReaderStreamId STARRED_STREAM = new GoogleReaderStreamId(Type.STARRED, null);
public static GoogleReaderStreamId parse(String raw) {
if (StringUtils.isBlank(raw) || "reading-list".equals(raw) || READING_LIST.equals(raw)) {

View File

@@ -45,8 +45,7 @@ public class NextUnreadServlet {
@Transactional
@Operation(hidden = true)
public Response get(
@QueryParam("category") String categoryId,
@QueryParam("order") @DefaultValue("desc") ReadingOrder order) {
@QueryParam("category") String categoryId, @QueryParam("order") @DefaultValue("desc") ReadingOrder order) {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return Response.temporaryRedirect(uri.getBaseUri()).build();
@@ -62,24 +61,11 @@ public class NextUnreadServlet {
} else {
FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children =
feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions =
feedSubscriptionDAO.findByCategories(user, children);
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, children);
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
subscriptions,
true,
null,
null,
0,
1,
order,
true,
null,
null,
null);
user, subscriptions, true, null, null, 0, 1, order, true, null, null, null);
s = statuses.stream().findFirst().orElse(null);
}
}

View File

@@ -40,11 +40,7 @@ public class WebSocketSessions {
public void sendMessage(User user, String text) {
Set<Session> userSessions = sessions.get(user.getId());
if (userSessions != null && !userSessions.isEmpty()) {
log.debug(
"sending '{}' to user {} via websocket ({} sessions)",
text,
user.getId(),
userSessions.size());
log.debug("sending '{}' to user {} via websocket ({} sessions)", text, user.getId(), userSessions.size());
for (Session userSession : userSessions) {
if (userSession.isOpen()) {
userSession.getAsyncRemote().sendText(text);

View File

@@ -18,10 +18,6 @@ public class CookieService {
}
public NewCookie buildLogoutCookie() {
return new NewCookie.Builder(cookieName)
.maxAge(0)
.expiry(Date.from(Instant.EPOCH))
.path("/")
.build();
return new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build();
}
}

View File

@@ -25,8 +25,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
@Singleton
public class DatabaseApiKeyIdentityProvider
implements IdentityProvider<TokenAuthenticationRequest> {
public class DatabaseApiKeyIdentityProvider implements IdentityProvider<TokenAuthenticationRequest> {
private final UnitOfWork unitOfWork;
private final UserService userService;
@@ -40,8 +39,7 @@ public class DatabaseApiKeyIdentityProvider
public Uni<SecurityIdentity> authenticate(
TokenAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking(() -> {
Optional<User> user =
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
Optional<User> user = unitOfWork.call(() -> userService.login(request.getToken().getToken()));
if (user.isEmpty()) {
throw new AuthenticationFailedException("could not find a user with this api key");
}

View File

@@ -43,12 +43,9 @@ public class DatabaseUsernamePasswordIdentityProvider
return context.runBlocking(() -> {
Optional<User> user =
unitOfWork.call(() ->
userService.login(
request.getUsername(),
new String(request.getPassword().getPassword())));
userService.login(request.getUsername(), new String(request.getPassword().getPassword())));
if (user.isEmpty()) {
throw new CommaFeedApplicationException(
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
throw new CommaFeedApplicationException(CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
}
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));

View File

@@ -19,8 +19,7 @@ import java.util.Set;
public class ApiKeyAuthenticationMechanism implements HttpAuthenticationMechanism {
@Override
public Uni<SecurityIdentity> authenticate(
RoutingContext context, IdentityProviderManager identityProviderManager) {
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
// only authorize api key for GET requests
if (!context.request().method().name().equals("GET")) {
return Uni.createFrom().optional(Optional.empty());

View File

@@ -18,12 +18,11 @@ import java.util.Optional;
import java.util.Set;
/**
* Authenticates requests to the Google Reader compatible API, using the "Authorization: GoogleLogin
* auth=&lt;token&gt;" header set by Google Reader API clients.
* Authenticates requests to the Google Reader compatible API, using the "Authorization: GoogleLogin auth=&lt;token&gt;"
* header set by Google Reader API clients.
*
* <p>This mechanism only applies to requests under {@link #PATH_PREFIX}, so that a stray
* "Authorization: GoogleLogin ..." header sent to any other endpoint is not mistakenly accepted as
* an api key authentication.
* <p>This mechanism only applies to requests under {@link #PATH_PREFIX}, so that a stray "Authorization: GoogleLogin
* ..." header sent to any other endpoint is not mistakenly accepted as an api key authentication.
*/
@Singleton
public class GoogleReaderAuthenticationMechanism implements HttpAuthenticationMechanism {
@@ -34,8 +33,7 @@ public class GoogleReaderAuthenticationMechanism implements HttpAuthenticationMe
private static final String AUTH_PREFIX = "GoogleLogin auth=";
@Override
public Uni<SecurityIdentity> authenticate(
RoutingContext context, IdentityProviderManager identityProviderManager) {
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
if (!context.normalizedPath().startsWith(PATH_PREFIX)) {
return Uni.createFrom().optional(Optional.empty());
}

View File

@@ -25,12 +25,10 @@ import java.util.List;
import java.util.Optional;
/**
* This class generates an application.properties file with all the properties from {@link
* CommaFeedConfiguration}.
* This class generates an application.properties file with all the properties from {@link CommaFeedConfiguration}.
*
* <p>This is useful for people who want to be able to configure CommaFeed without having to look at
* the code or the documentation, or for distribution packages that want to provide a default
* configuration file.
* <p>This is useful for people who want to be able to configure CommaFeed without having to look at the code or the
* documentation, or for distribution packages that want to provide a default configuration file.
*/
public class CommaFeedPropertiesGenerator {
@@ -41,8 +39,7 @@ public class CommaFeedPropertiesGenerator {
Path modelPath = targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_MODEL);
Path javadocPath = targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_JAVADOC);
Path outputPath =
targetPath.resolve("quarkus-generated-doc").resolve("application.properties");
Path outputPath = targetPath.resolve("quarkus-generated-doc").resolve("application.properties");
try (InputStream model = Files.newInputStream(modelPath);
InputStream javadoc = Files.newInputStream(javadocPath);
@@ -52,10 +49,8 @@ public class CommaFeedPropertiesGenerator {
}
void generate(InputStream model, InputStream javadoc, OutputStream output) throws IOException {
ResolvedModel resolvedModel =
JacksonMappers.yamlObjectReader().readValue(model, ResolvedModel.class);
JavadocElements javadocElements =
JacksonMappers.yamlObjectReader().readValue(javadoc, JavadocElements.class);
ResolvedModel resolvedModel = JacksonMappers.yamlObjectReader().readValue(model, ResolvedModel.class);
JavadocElements javadocElements = JacksonMappers.yamlObjectReader().readValue(javadoc, JavadocElements.class);
for (ConfigRoot configRoot : resolvedModel.getConfigRoots()) {
for (AbstractConfigItem item : configRoot.getItems()) {
@@ -66,8 +61,7 @@ public class CommaFeedPropertiesGenerator {
IOUtils.write(String.join("\n", lines), output, StandardCharsets.UTF_8);
}
private void handleAbstractConfigItem(
AbstractConfigItem item, JavadocElements javadocElements) {
private void handleAbstractConfigItem(AbstractConfigItem item, JavadocElements javadocElements) {
if (item.isSection()) {
handleSection((ConfigSection) item, javadocElements);
} else {
@@ -89,8 +83,7 @@ public class CommaFeedPropertiesGenerator {
.get(property.getSourceType() + "." + property.getSourceElementName())
.description()
.replace("\n", "\n# ");
String defaultValue =
Optional.ofNullable(property.getDefaultValue()).orElse("").toLowerCase();
String defaultValue = Optional.ofNullable(property.getDefaultValue()).orElse("").toLowerCase();
lines.add("# " + description);
lines.add(key + "=" + defaultValue);

View File

@@ -25,26 +25,14 @@ class NativeImageClassesTest {
void annotationContainsAllRequiredRomeClasses() {
Reflections reflections = new Reflections("com.rometools");
Set<Class<?>> classesInAnnotation =
Set.copyOf(
List.of(
NativeImageClasses.class
.getAnnotation(RegisterForReflection.class)
.targets()));
Set.copyOf(List.of(NativeImageClasses.class.getAnnotation(RegisterForReflection.class).targets()));
List<Class<?>> missingClasses = new ArrayList<>();
for (Class<?> clazz :
List.of(
Module.class,
Cloneable.class,
CopyFrom.class,
WireFeedParser.class,
WireFeedGenerator.class)) {
Set<Class<?>> moduleClasses =
new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
List.of(Module.class, Cloneable.class, CopyFrom.class, WireFeedParser.class, WireFeedGenerator.class)) {
Set<Class<?>> moduleClasses = new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
moduleClasses.removeIf(c ->
c.isInterface()
|| Modifier.isAbstract(c.getModifiers())
|| !Modifier.isPublic(c.getModifiers()));
c.isInterface() || Modifier.isAbstract(c.getModifiers()) || !Modifier.isPublic(c.getModifiers()));
moduleClasses.removeAll(classesInAnnotation);
missingClasses.addAll(moduleClasses);
}

View File

@@ -7,8 +7,7 @@ class DigestsTest {
@Test
void sha1Hex() {
Assertions.assertEquals(
"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", Digests.sha1Hex("hello"));
Assertions.assertEquals("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", Digests.sha1Hex("hello"));
}
@Test

View File

@@ -65,9 +65,7 @@ class HttpGetterTest {
void init() throws IOException {
this.mockServerClient = ClientAndServer.startClientAndServer(0);
this.feedUrl = "http://localhost:" + this.mockServerClient.getPort() + "/";
this.feedContent =
IOUtils.toByteArray(
Objects.requireNonNull(getClass().getResource("/feed/rss.xml")));
this.feedContent = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/feed/rss.xml")));
this.config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS);
Mockito.when(config.httpClient().userAgent()).thenReturn(Optional.of("http-getter-test"));
@@ -78,14 +76,12 @@ class HttpGetterTest {
Mockito.when(config.httpClient().connectionTimeToLive()).thenReturn(Duration.ofSeconds(30));
Mockito.when(config.httpClient().maxResponseSize()).thenReturn(MemorySize.of("10000"));
Mockito.when(config.httpClient().cache().enabled()).thenReturn(true);
Mockito.when(config.httpClient().cache().maximumMemorySize())
.thenReturn(MemorySize.of("100000"));
Mockito.when(config.httpClient().cache().maximumMemorySize()).thenReturn(MemorySize.of("100000"));
Mockito.when(config.httpClient().cache().expiration()).thenReturn(Duration.ofMinutes(1));
Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3);
this.provider = new HttpClientFactory(config, Mockito.mock(CommaFeedVersion.class));
this.getter =
new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
this.getter = new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
}
@AfterEach
@@ -108,9 +104,7 @@ class HttpGetterTest {
.when(HttpRequest.request().withMethod("GET"))
.respond(HttpResponse.response().withStatusCode(code));
HttpResponseException e =
Assertions.assertThrows(
HttpResponseException.class, () -> getter.get(this.feedUrl));
HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(code, e.getCode());
}
@@ -124,8 +118,7 @@ class HttpGetterTest {
.withContentType(MediaType.APPLICATION_ATOM_XML)
.withHeader(HttpHeaders.LAST_MODIFIED, "123456")
.withHeader(HttpHeaders.ETAG, "78910")
.withHeader(
HttpHeaders.CACHE_CONTROL, "max-age=60, must-revalidate")
.withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, must-revalidate")
.withHeader(HttpHeaders.RETRY_AFTER, "120"));
HttpResult result = getter.get(this.feedUrl);
@@ -145,8 +138,7 @@ class HttpGetterTest {
HttpResponse.response()
.withBody(feedContent)
.withContentType(MediaType.APPLICATION_ATOM_XML)
.withHeader(
HttpHeaders.CACHE_CONTROL, "max-age=60; must-revalidate"));
.withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60; must-revalidate"));
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals(Duration.ZERO, result.validFor());
@@ -162,8 +154,7 @@ class HttpGetterTest {
.withHeader(HttpHeaders.RETRY_AFTER, "120"));
TooManyRequestsException e =
Assertions.assertThrows(
TooManyRequestsException.class, () -> getter.get(this.feedUrl));
Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(NOW.plusSeconds(120), e.getRetryAfter());
}
@@ -174,12 +165,10 @@ class HttpGetterTest {
.respond(
HttpResponse.response()
.withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS)
.withHeader(
HttpHeaders.RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT"));
.withHeader(HttpHeaders.RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT"));
TooManyRequestsException e =
Assertions.assertThrows(
TooManyRequestsException.class, () -> getter.get(this.feedUrl));
Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(Instant.parse("2015-10-21T07:28:00Z"), e.getRetryAfter());
}
@@ -200,9 +189,7 @@ class HttpGetterTest {
.withStatusCode(code)
.withHeader(
HttpHeaders.LOCATION,
"http://localhost:"
+ this.mockServerClient.getPort()
+ "/redirected"));
"http://localhost:" + this.mockServerClient.getPort() + "/redirected"));
// second redirect
this.mockServerClient
@@ -212,29 +199,22 @@ class HttpGetterTest {
.withStatusCode(code)
.withHeader(
HttpHeaders.LOCATION,
"http://localhost:"
+ this.mockServerClient.getPort()
+ "/redirected-2"));
"http://localhost:" + this.mockServerClient.getPort() + "/redirected-2"));
// final destination
this.mockServerClient
.when(HttpRequest.request().withMethod("GET").withPath("/redirected-2"))
.respond(
HttpResponse.response()
.withBody(feedContent)
.withContentType(MediaType.APPLICATION_ATOM_XML));
.respond(HttpResponse.response().withBody(feedContent).withContentType(MediaType.APPLICATION_ATOM_XML));
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals(
"http://localhost:" + this.mockServerClient.getPort() + "/redirected-2",
result.urlAfterRedirect());
"http://localhost:" + this.mockServerClient.getPort() + "/redirected-2", result.urlAfterRedirect());
}
@Test
void dataTimeout() {
Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofMillis(500));
this.getter =
new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
this.getter = new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
this.mockServerClient
.when(HttpRequest.request().withMethod("GET"))
@@ -246,29 +226,23 @@ class HttpGetterTest {
@Test
void connectTimeout() {
Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofMillis(500));
this.getter =
new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
this.getter = new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
// try to connect to a non-routable address
// https://stackoverflow.com/a/904609
Exception e =
Assertions.assertThrows(Exception.class, () -> getter.get("http://10.255.255.1"));
Exception e = Assertions.assertThrows(Exception.class, () -> getter.get("http://10.255.255.1"));
Assertions.assertTrue(
e instanceof ConnectTimeoutException
// A NoRouteToHostException can also be thrown in some cases
// depending on the underlying network configuration
// https://github.com/Athou/commafeed/issues/1876
|| e instanceof NoRouteToHostException,
"Expected ConnectTimeoutException or NoRouteToHostException, but got: "
+ e.getClass().getName());
"Expected ConnectTimeoutException or NoRouteToHostException, but got: " + e.getClass().getName());
}
@Test
void userAgent() throws Exception {
this.mockServerClient
.when(
HttpRequest.request()
.withMethod("GET")
.withHeader(HttpHeaders.USER_AGENT, "http-getter-test"))
.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.USER_AGENT, "http-getter-test"))
.respond(HttpResponse.response().withBody("ok"));
HttpResult result = getter.get(this.feedUrl);
@@ -278,37 +252,23 @@ class HttpGetterTest {
@Test
void lastModifiedReturns304() {
this.mockServerClient
.when(
HttpRequest.request()
.withMethod("GET")
.withHeader(HttpHeaders.IF_MODIFIED_SINCE, "123456"))
.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_MODIFIED_SINCE, "123456"))
.respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED));
Assertions.assertThrows(
NotModifiedException.class,
() ->
getter.get(
HttpGetter.HttpRequest.builder(this.feedUrl)
.lastModified("123456")
.build()));
() -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).lastModified("123456").build()));
}
@Test
void eTagReturns304() {
this.mockServerClient
.when(
HttpRequest.request()
.withMethod("GET")
.withHeader(HttpHeaders.IF_NONE_MATCH, "78910"))
.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_NONE_MATCH, "78910"))
.respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED));
Assertions.assertThrows(
NotModifiedException.class,
() ->
getter.get(
HttpGetter.HttpRequest.builder(this.feedUrl)
.eTag("78910")
.build()));
() -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).eTag("78910").build()));
}
@Test
@@ -324,9 +284,7 @@ class HttpGetterTest {
throw new Exception("cookie should not be sent by the client");
}
return HttpResponse.response()
.withBody("ok")
.withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
return HttpResponse.response().withBody("ok").withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
});
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
@@ -360,8 +318,7 @@ class HttpGetterTest {
IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(
"Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)",
e.getMessage());
"Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)", e.getMessage());
}
@Test
@@ -374,12 +331,10 @@ class HttpGetterTest {
HttpResponse.response()
.withBody(bytes)
.withConnectionOptions(
ConnectionOptions.connectionOptions()
.withSuppressContentLengthHeader(true)));
ConnectionOptions.connectionOptions().withSuppressContentLengthHeader(true)));
IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(
"Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage());
Assertions.assertEquals("Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage());
}
@Test
@@ -427,8 +382,7 @@ class HttpGetterTest {
supportsCompression("deflate", DeflaterOutputStream::new);
}
void supportsCompression(String encoding, CompressionFunction compressionFunction)
throws Exception {
void supportsCompression(String encoding, CompressionFunction compressionFunction) throws Exception {
String body = "my body";
HttpGetterTest.this
@@ -436,8 +390,7 @@ class HttpGetterTest {
.when(HttpRequest.request().withMethod("GET"))
.respond(req -> {
try {
String acceptEncodingHeader =
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
String acceptEncodingHeader = req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
throw new Exception(
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
@@ -445,8 +398,7 @@ class HttpGetterTest {
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (OutputStream compressionOutputStream =
compressionFunction.apply(output)) {
try (OutputStream compressionOutputStream = compressionFunction.apply(output)) {
compressionOutputStream.write(body.getBytes());
}
@@ -473,16 +425,12 @@ class HttpGetterTest {
class SchemeNotAllowed {
@Test
void file() {
Assertions.assertThrows(
HttpGetter.SchemeNotAllowedException.class,
() -> getter.get("file://localhost"));
Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("file://localhost"));
}
@Test
void ftp() {
Assertions.assertThrows(
HttpGetter.SchemeNotAllowedException.class,
() -> getter.get("ftp://localhost"));
Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("ftp://localhost"));
}
}
@@ -492,20 +440,15 @@ class HttpGetterTest {
@BeforeEach
void init() {
Mockito.when(config.httpClient().blockLocalAddresses()).thenReturn(true);
getter =
new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
getter = new HttpGetter(config, () -> NOW, provider, Mockito.mock(MetricRegistry.class));
}
@Test
void localhost() {
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://localhost"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://127.0.0.1"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://2130706433"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://localhost"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://127.0.0.1"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://2130706433"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01"));
}
@Test
@@ -515,72 +458,54 @@ class HttpGetterTest {
@Test
void linkLocal() {
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://169.254.12.34"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://169.254.169.254"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://169.254.12.34"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://169.254.169.254"));
}
@Test
void multicast() {
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://224.2.3.4"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://239.255.255.254"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://224.2.3.4"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://239.255.255.254"));
}
@Test
void privateIpv4Ranges() {
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://10.0.0.1"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://172.16.0.1"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://192.168.0.1"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://100.64.0.1"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://10.0.0.1"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://172.16.0.1"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://192.168.0.1"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://100.64.0.1"));
}
@Test
void privateIpv6Ranges() {
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[fe80::215:5dff:fe15:102]"));
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://[fd00:dead:beef::50]"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://[fe80::215:5dff:fe15:102]"));
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://[fd00:dead:beef::50]"));
}
@Test
void nat64() {
// 64:ff9b::/96 embeds the IPv4 address in the lower 32 bits (RFC 6052)
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[64:ff9b::a9fe:a9fe]")); // 169.254.169.254
UnknownHostException.class, () -> getter.get("http://[64:ff9b::a9fe:a9fe]")); // 169.254.169.254
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[64:ff9b::a00:1]")); // 10.0.0.1
UnknownHostException.class, () -> getter.get("http://[64:ff9b::a00:1]")); // 10.0.0.1
}
@Test
void nat64LocalUse() {
// 64:ff9b:1::/48 is reserved for local use (RFC 8215), the whole range is blocked
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[64:ff9b:1::a9fe:a9fe]")); // 169.254.169.254
UnknownHostException.class, () -> getter.get("http://[64:ff9b:1::a9fe:a9fe]")); // 169.254.169.254
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[64:ff9b:1:ffff::a00:1]")); // 10.0.0.1
UnknownHostException.class, () -> getter.get("http://[64:ff9b:1:ffff::a00:1]")); // 10.0.0.1
}
@Test
void sixToFour() {
// 2002::/16 embeds the IPv4 address in bits 16-47 (RFC 3056)
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[2002:a9fe:a9fe::]")); // 169.254.169.254
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[2002:a00:1::]")); // 10.0.0.1
UnknownHostException.class, () -> getter.get("http://[2002:a9fe:a9fe::]")); // 169.254.169.254
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://[2002:a00:1::]")); // 10.0.0.1
}
@Test
@@ -597,10 +522,8 @@ class HttpGetterTest {
// deprecated ::/96 IPv4-compatible addresses embed the IPv4 address in the lower 32
// bits
Assertions.assertThrows(
UnknownHostException.class,
() -> getter.get("http://[::a9fe:a9fe]")); // 169.254.169.254
Assertions.assertThrows(
UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
UnknownHostException.class, () -> getter.get("http://[::a9fe:a9fe]")); // 169.254.169.254
Assertions.assertThrows(UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
}
}

View File

@@ -50,25 +50,17 @@ class UrlsTest {
// usual cases
Assertions.assertEquals(
expected,
Urls.toAbsolute(
"http://a.com/blog/entry/1", "http://a.com/feed/", "http://a.com/feed/"));
expected, Urls.toAbsolute("http://a.com/blog/entry/1", "http://a.com/feed/", "http://a.com/feed/"));
Assertions.assertEquals(
expected,
Urls.toAbsolute(
"http://a.com/blog/entry/1", "http://a.com/feed", "http://a.com/feed"));
expected, Urls.toAbsolute("http://a.com/blog/entry/1", "http://a.com/feed", "http://a.com/feed"));
// relative links
Assertions.assertEquals(
expected,
Urls.toAbsolute("../blog/entry/1", "http://a.com/feed/", "http://a.com/feed/"));
Assertions.assertEquals(
expected,
Urls.toAbsolute("../blog/entry/1", "feed.xml", "http://a.com/feed/feed.xml"));
expected, Urls.toAbsolute("../blog/entry/1", "http://a.com/feed/", "http://a.com/feed/"));
Assertions.assertEquals(expected, Urls.toAbsolute("../blog/entry/1", "feed.xml", "http://a.com/feed/feed.xml"));
// root-relative links
Assertions.assertEquals(
expected, Urls.toAbsolute("/blog/entry/1", "/feed", "http://a.com/feed"));
Assertions.assertEquals(expected, Urls.toAbsolute("/blog/entry/1", "/feed", "http://a.com/feed"));
// real cases
Assertions.assertEquals(
@@ -79,10 +71,7 @@ class UrlsTest {
"https://github.com/erusev/parsedown/tags.atom"));
Assertions.assertEquals(
"http://ergoemacs.org/emacs/elisp_all_about_lines.html",
Urls.toAbsolute(
"elisp_all_about_lines.html",
"blog.xml",
"http://ergoemacs.org/emacs/blog.xml"));
Urls.toAbsolute("elisp_all_about_lines.html", "blog.xml", "http://ergoemacs.org/emacs/blog.xml"));
// invalid relative urls
Assertions.assertEquals(
@@ -97,13 +86,11 @@ class UrlsTest {
void testSanitize() {
// valid http/https urls are kept as is
Assertions.assertEquals("http://example.com/foo", Urls.sanitize("http://example.com/foo"));
Assertions.assertEquals(
"https://example.com/foo", Urls.sanitize("https://example.com/foo"));
Assertions.assertEquals("https://example.com/foo", Urls.sanitize("https://example.com/foo"));
// scheme matching is case-insensitive
Assertions.assertEquals("HTTP://example.com/foo", Urls.sanitize("HTTP://example.com/foo"));
Assertions.assertEquals(
"HTTPS://example.com/foo", Urls.sanitize("HTTPS://example.com/foo"));
Assertions.assertEquals("HTTPS://example.com/foo", Urls.sanitize("HTTPS://example.com/foo"));
// malicious or disallowed schemes are stripped
Assertions.assertNull(Urls.sanitize("javascript:alert(1)"));

View File

@@ -36,11 +36,8 @@ class FacebookFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult httpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://graph.facebook.com/validUserId/picture?type=square&height=16"))
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://graph.facebook.com/validUserId/picture?type=square&height=16"))
.thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -73,9 +70,7 @@ class FacebookFaviconFetcherTest {
Feed feed = new Feed();
feed.setUrl("https://www.facebook.com/something?id=validUserId");
Mockito.when(
httpGetter.get(
"https://graph.facebook.com/validUserId/picture?type=square&height=16"))
Mockito.when(httpGetter.get("https://graph.facebook.com/validUserId/picture?type=square&height=16"))
.thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));

View File

@@ -45,8 +45,7 @@ class FeedFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult httpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -62,8 +61,7 @@ class FeedFaviconFetcherTest {
feed.setUrl("https://example.com/feed");
feed.setIconUrl("https://example.com/icon.png");
Mockito.when(httpGetter.get("https://example.com/icon.png"))
.thenThrow(new RuntimeException("Network error"));
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}

View File

@@ -43,16 +43,13 @@ class HtmlFaviconFetcherTest {
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
String html =
"<html><head><link rel=\"icon\" href=\"/favicon.png\" /></head><body></body></html>";
HttpResult pageResult =
new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
String html = "<html><head><link rel=\"icon\" href=\"/favicon.png\" /></head><body></body></html>";
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult iconResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult iconResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/favicon.png")).thenReturn(iconResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -70,16 +67,13 @@ class HtmlFaviconFetcherTest {
String html =
"<html><head><link rel=\"shortcut icon\" href=\"https://example.com/shortcut-favicon.ico\" /></head><body></body></html>";
HttpResult pageResult =
new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult iconResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/shortcut-favicon.ico"))
.thenReturn(iconResult);
HttpResult iconResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/shortcut-favicon.ico")).thenReturn(iconResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -95,8 +89,7 @@ class HtmlFaviconFetcherTest {
feed.setLink("https://example.com");
String html = "<html><head></head><body></body></html>";
HttpResult pageResult =
new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
Assertions.assertNull(faviconFetcher.fetch(feed));
@@ -108,8 +101,7 @@ class HtmlFaviconFetcherTest {
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
Mockito.when(httpGetter.get("https://example.com"))
.thenThrow(new RuntimeException("Network error"));
Mockito.when(httpGetter.get("https://example.com")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@@ -122,8 +114,7 @@ class HtmlFaviconFetcherTest {
String html =
"<html><head><link rel=\"icon\" href=\"https://example.com/favicon.png\" /></head><body></body></html>";
HttpResult pageResult =
new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
Mockito.when(httpGetter.get("https://example.com/favicon.png"))
.thenThrow(new RuntimeException("Network error"));

View File

@@ -36,8 +36,7 @@ class RootFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult httpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://www.example.com/favicon.ico")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -55,10 +54,8 @@ class RootFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult httpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://www.example.com:8000/favicon.ico"))
.thenReturn(httpResult);
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://www.example.com:8000/favicon.ico")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -74,8 +71,7 @@ class RootFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult httpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/favicon.ico")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);

View File

@@ -71,8 +71,7 @@ class YoutubeFaviconFetcherTest {
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes();
HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
HttpResult apiHttpResult = new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&key=test-api-key&forUsername=testUser"))
@@ -83,8 +82,7 @@ class YoutubeFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult iconHttpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult iconHttpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(iconHttpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -106,8 +104,7 @@ class YoutubeFaviconFetcherTest {
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes();
HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
HttpResult apiHttpResult = new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&key=test-api-key&id=testChannelId"))
@@ -118,8 +115,7 @@ class YoutubeFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult iconHttpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult iconHttpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(iconHttpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -142,8 +138,7 @@ class YoutubeFaviconFetcherTest {
"""
.getBytes();
HttpResult playlistHttpResult =
new HttpResult(
playlistResponse, "application/json", null, null, null, Duration.ZERO);
new HttpResult(playlistResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://www.googleapis.com/youtube/v3/playlists?part=snippet&key=test-api-key&id=testPlaylistId"))
@@ -158,8 +153,7 @@ class YoutubeFaviconFetcherTest {
"""
.getBytes();
HttpResult channelHttpResult =
new HttpResult(
channelResponse, "application/json", null, null, null, Duration.ZERO);
new HttpResult(channelResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&key=test-api-key&id=testChannelId"))
@@ -170,8 +164,7 @@ class YoutubeFaviconFetcherTest {
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult iconHttpResult =
new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
HttpResult iconHttpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(iconHttpResult);
Favicon result = faviconFetcher.fetch(feed);
@@ -204,8 +197,7 @@ class YoutubeFaviconFetcherTest {
Mockito.when(config.googleAuthKey()).thenReturn(Optional.of("test-api-key"));
byte[] apiResponse = "{}".getBytes();
HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
HttpResult apiHttpResult = new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(
httpGetter.get(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&key=test-api-key&forUsername=testUser"))

View File

@@ -44,18 +44,14 @@ class FeedFetcherTest {
byte[] html = "html".getBytes();
Mockito.when(getter.get(HttpGetter.HttpRequest.builder(htmlUrl).build()))
.thenReturn(new HttpResult(html, "text/html", null, null, htmlUrl, Duration.ZERO));
Mockito.when(parser.parse(htmlUrl, html))
.thenThrow(new FeedParsingException("invalid feed"));
Mockito.when(parser.parse(htmlUrl, html)).thenThrow(new FeedParsingException("invalid feed"));
String feedUrl = "https://bbb.com/feed";
byte[] feed = "feed".getBytes();
Mockito.when(getter.get(HttpGetter.HttpRequest.builder(feedUrl).build()))
.thenReturn(
new HttpResult(
feed, "application/atom+xml", null, null, feedUrl, Duration.ZERO));
.thenReturn(new HttpResult(feed, "application/atom+xml", null, null, feedUrl, Duration.ZERO));
Mockito.when(parser.parse(feedUrl, feed))
.thenReturn(
new FeedParserResult("title", "link", "iconUrl", null, null, null, null));
.thenReturn(new FeedParserResult("title", "link", "iconUrl", null, null, null, null));
Mockito.when(urlProvider.get(htmlUrl, new String(html))).thenReturn(List.of(feedUrl));
@@ -71,32 +67,13 @@ class FeedFetcherTest {
byte[] content = "content".getBytes();
String lastContentHash = Digests.sha1Hex(content);
Mockito.when(
getter.get(
HttpGetter.HttpRequest.builder(url)
.lastModified(lastModified)
.eTag(etag)
.build()))
.thenReturn(
new HttpResult(
content,
"content-type",
"last-modified-2",
"etag-2",
null,
Duration.ZERO));
Mockito.when(getter.get(HttpGetter.HttpRequest.builder(url).lastModified(lastModified).eTag(etag).build()))
.thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", null, Duration.ZERO));
NotModifiedException e =
Assertions.assertThrows(
NotModifiedException.class,
() ->
fetcher.fetch(
url,
false,
lastModified,
etag,
Instant.now(),
lastContentHash));
() -> fetcher.fetch(url, false, lastModified, etag, Instant.now(), lastContentHash));
Assertions.assertEquals("last-modified-2", e.getNewLastModifiedHeader());
Assertions.assertEquals("etag-2", e.getNewEtagHeader());

View File

@@ -36,8 +36,7 @@ class FeedRefreshIntervalCalculatorTest {
@BeforeEach
void setUp() {
Mockito.when(instantSource.instant()).thenReturn(NOW);
Mockito.when(config.feedRefresh())
.thenReturn(Mockito.mock(CommaFeedConfiguration.FeedRefresh.class));
Mockito.when(config.feedRefresh()).thenReturn(Mockito.mock(CommaFeedConfiguration.FeedRefresh.class));
Mockito.when(config.feedRefresh().interval()).thenReturn(DEFAULT_INTERVAL);
Mockito.when(config.feedRefresh().maxInterval()).thenReturn(MAX_INTERVAL);
Mockito.when(config.feedRefresh().errors()).thenReturn(errorHandling);
@@ -55,24 +54,20 @@ class FeedRefreshIntervalCalculatorTest {
void withoutValidFor(long averageEntryInterval) {
// averageEntryInterval is ignored when empirical is disabled
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), averageEntryInterval, Duration.ZERO);
calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), averageEntryInterval, Duration.ZERO);
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
}
@Test
void withValidForGreaterThanMaxInterval() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.plusDays(1));
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.plusDays(1));
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
}
@Test
void withValidForLowerThanMaxInterval() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.minusSeconds(1));
calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.minusSeconds(1));
Assertions.assertEquals(NOW.plus(MAX_INTERVAL).minusSeconds(1), result);
}
}
@@ -93,24 +88,19 @@ class FeedRefreshIntervalCalculatorTest {
@Test
void with31DaysOldPublishedDate() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(31)), 1L, Duration.ZERO);
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(31)), 1L, Duration.ZERO);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
}
@Test
void with15DaysOldPublishedDate() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(15)), 1L, Duration.ZERO);
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(15)), 1L, Duration.ZERO);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result);
}
@Test
void with8DaysOldPublishedDate() {
Instant result =
calculator.onFetchSuccess(NOW.minus(Duration.ofDays(8)), 1L, Duration.ZERO);
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(8)), 1L, Duration.ZERO);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
}
@@ -120,33 +110,26 @@ class FeedRefreshIntervalCalculatorTest {
void averageBetweenBounds() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)),
Duration.ofHours(4).toMillis(),
Duration.ZERO);
NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis(), Duration.ZERO);
Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result);
}
@Test
void averageBelowMinimum() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), 10L, Duration.ZERO);
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 10L, Duration.ZERO);
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
}
@Test
void averageAboveMaximum() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE, Duration.ZERO);
calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE, Duration.ZERO);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
}
@Test
void noAverage() {
Instant result =
calculator.onFetchSuccess(
NOW.minus(Duration.ofDays(5)), null, Duration.ZERO);
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), null, Duration.ZERO);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
}
}
@@ -162,9 +145,7 @@ class FeedRefreshIntervalCalculatorTest {
@ValueSource(longs = {0, 1, 300, 86400000L})
void withoutValidFor(long averageEntryInterval) {
// averageEntryInterval is ignored when empirical is disabled
Instant result =
calculator.onFeedNotModified(
NOW.minus(Duration.ofDays(5)), averageEntryInterval);
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), averageEntryInterval);
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
}
}
@@ -206,30 +187,25 @@ class FeedRefreshIntervalCalculatorTest {
@Test
void averageBetweenBounds() {
Instant result =
calculator.onFeedNotModified(
NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis());
calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis());
Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result);
}
@Test
void averageBelowMinimum() {
Instant result =
calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), 10L);
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), 10L);
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
}
@Test
void averageAboveMaximum() {
Instant result =
calculator.onFeedNotModified(
NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE);
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
}
@Test
void noAverage() {
Instant result =
calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), null);
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), null);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
}
}
@@ -251,8 +227,7 @@ class FeedRefreshIntervalCalculatorTest {
@Test
void highErrorCount() {
Mockito.when(config.feedRefresh().errors().backoffInterval())
.thenReturn(Duration.ofHours(1));
Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1));
Instant result = calculator.onFetchError(5);
Assertions.assertEquals(NOW.plus(Duration.ofHours(3)), result);
@@ -260,8 +235,7 @@ class FeedRefreshIntervalCalculatorTest {
@Test
void veryHighErrorCount() {
Mockito.when(config.feedRefresh().errors().backoffInterval())
.thenReturn(Duration.ofHours(1));
Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1));
Instant result = calculator.onFetchError(100000);
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);

View File

@@ -31,11 +31,9 @@ class FeedUtilsTest {
Assertions.assertEquals("Test Entry", syndEntry.getTitle());
Assertions.assertEquals("Author Name", syndEntry.getAuthor());
Assertions.assertEquals(1, syndEntry.getContents().size());
Assertions.assertEquals(
"This is a test entry content.", syndEntry.getContents().getFirst().getValue());
Assertions.assertEquals("This is a test entry content.", syndEntry.getContents().getFirst().getValue());
Assertions.assertEquals(1, syndEntry.getEnclosures().size());
Assertions.assertEquals(
"http://example.com/enclosure.mp3", syndEntry.getEnclosures().getFirst().getUrl());
Assertions.assertEquals("http://example.com/enclosure.mp3", syndEntry.getEnclosures().getFirst().getUrl());
Assertions.assertEquals("audio/mpeg", syndEntry.getEnclosures().getFirst().getType());
Assertions.assertEquals("http://example.com/test-entry", syndEntry.getLink());
Assertions.assertEquals(Date.from(Instant.ofEpochSecond(1)), syndEntry.getPublishedDate());

View File

@@ -12,13 +12,10 @@ class EncodingDetectorTest {
Assertions.assertNull(encodingDetector.extractDeclaredEncoding("<?xml ?>".getBytes()));
Assertions.assertNull(encodingDetector.extractDeclaredEncoding("<feed></feed>".getBytes()));
Assertions.assertEquals(
"UTF-8",
encodingDetector.extractDeclaredEncoding("<?xml encoding=\"UTF-8\" ?>".getBytes()));
"UTF-8", encodingDetector.extractDeclaredEncoding("<?xml encoding=\"UTF-8\" ?>".getBytes()));
Assertions.assertEquals(
"UTF-8",
encodingDetector.extractDeclaredEncoding("<?xml encoding='UTF-8' ?>".getBytes()));
"UTF-8", encodingDetector.extractDeclaredEncoding("<?xml encoding='UTF-8' ?>".getBytes()));
Assertions.assertEquals(
"UTF-8",
encodingDetector.extractDeclaredEncoding("<?xml encoding='UTF-8'?>".getBytes()));
"UTF-8", encodingDetector.extractDeclaredEncoding("<?xml encoding='UTF-8'?>".getBytes()));
}
}

View File

@@ -7,38 +7,25 @@ class TextDirectionDetectorTest {
@Test
void testEstimateDirection() {
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(""));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(" "));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("! (...)"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(""));
TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("Pure Ascii content"));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("-17.0%"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(" "));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect("! (...)"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect("Pure Ascii content"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect("-17.0%"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect("http://foo/bar/"));
TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("http://foo/bar/"));
Assertions.assertEquals(
TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect(
"http://foo/bar/?s=\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0"
+ "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0"
+ "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
"http://foo/bar/ \u05d0 http://foo2/bar2/ http://foo3/bar3/"));
TextDirectionDetector.detect("http://foo/bar/ \u05d0 http://foo2/bar2/ http://foo3/bar3/"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
@@ -69,8 +56,7 @@ class TextDirectionDetectorTest {
+ "\u05d9 \u05d1\u05d0\u05ea\u05e8"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
"CAPTCHA \u05de\u05e9\u05d5\u05db\u05dc\u05dc " + "\u05de\u05d3\u05d9?"));
TextDirectionDetector.detect("CAPTCHA \u05de\u05e9\u05d5\u05db\u05dc\u05dc " + "\u05de\u05d3\u05d9?"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
@@ -86,12 +72,10 @@ class TextDirectionDetectorTest {
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
"5710 5720 5730. \u05d4\u05d3\u05dc\u05ea. "
+ "\u05d4\u05e0\u05e9\u05d9\u05e7\u05d4"));
"5710 5720 5730. \u05d4\u05d3\u05dc\u05ea. " + "\u05d4\u05e0\u05e9\u05d9\u05e7\u05d4"));
Assertions.assertEquals(
TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect(
"\u05d4\u05d3\u05dc\u05ea http://www.google.com "
+ "http://www.gmail.com"));
"\u05d4\u05d3\u05dc\u05ea http://www.google.com " + "http://www.gmail.com"));
}
}

View File

@@ -13,22 +13,19 @@ class XMLCleanerTest {
@Test
void removesWhitespaceBeforeXmlTag() {
String xml = " \n\t<feed>content</feed>";
Assertions.assertEquals(
"<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
Assertions.assertEquals("<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
}
@Test
void removesTextBeforeXmlTag() {
String xml = "some text here<feed>content</feed>";
Assertions.assertEquals(
"<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
Assertions.assertEquals("<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
}
@Test
void returnsUnchangedWhenStartsWithXmlTag() {
String xml = "<feed>content</feed>";
Assertions.assertEquals(
"<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
Assertions.assertEquals("<feed>content</feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
}
@Test
@@ -56,8 +53,7 @@ class XMLCleanerTest {
void preservesMultipleXmlTags() {
String xml = "garbage<feed><item>content</item></feed>";
Assertions.assertEquals(
"<feed><item>content</item></feed>",
xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
"<feed><item>content</item></feed>", xmlCleaner.removeCharactersBeforeFirstXmlTag(xml));
}
}
@@ -66,38 +62,32 @@ class XMLCleanerTest {
@Test
void removesNullCharacter() {
String xml = "<feed>content\u0000here</feed>";
Assertions.assertEquals(
"<feed>contenthere</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
Assertions.assertEquals("<feed>contenthere</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
}
@Test
void removesInvalidControlCharacters() {
String xml = "<feed>content\u0001\u0002\u0003here</feed>";
Assertions.assertEquals(
"<feed>contenthere</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
Assertions.assertEquals("<feed>contenthere</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
}
@Test
void preservesValidXmlCharacters() {
String xml = "<feed>content with\ttab\nand newline</feed>";
Assertions.assertEquals(
"<feed>content with\ttab\nand newline</feed>",
xmlCleaner.removeInvalidXmlCharacters(xml));
"<feed>content with\ttab\nand newline</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
}
@Test
void preservesUnicodeCharacters() {
String xml = "<feed>café résumé 中文 العربية</feed>";
Assertions.assertEquals(
"<feed>café résumé 中文 العربية</feed>",
xmlCleaner.removeInvalidXmlCharacters(xml));
Assertions.assertEquals("<feed>café résumé 中文 العربية</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
}
@Test
void preservesEmojiCharacters() {
String xml = "<feed>🎮💪✅</feed>";
Assertions.assertEquals(
"<feed>🎮💪✅</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
Assertions.assertEquals("<feed>🎮💪✅</feed>", xmlCleaner.removeInvalidXmlCharacters(xml));
}
@Test
@@ -141,31 +131,26 @@ class XMLCleanerTest {
@Test
void replacesMultipleOccurrencesOfSameEntity() {
String source = "&nbsp;&nbsp;&nbsp;";
Assertions.assertEquals(
"&#160;&#160;&#160;",
xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
Assertions.assertEquals("&#160;&#160;&#160;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
void preservesTextWithoutEntities() {
String source = "<feed>regular content</feed>";
Assertions.assertEquals(
"<feed>regular content</feed>",
xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
"<feed>regular content</feed>", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
void preservesNumericEntities() {
String source = "&#180;&#8242;";
Assertions.assertEquals(
"&#180;&#8242;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
Assertions.assertEquals("&#180;&#8242;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
void replacesCommonHtmlEntities() {
String source = "&amp;&quot;";
Assertions.assertEquals(
"&#38;&#34;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
Assertions.assertEquals("&#38;&#34;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
@@ -193,15 +178,13 @@ class XMLCleanerTest {
@Test
void handlesEntityAtStartOfString() {
String source = "&amp;test";
Assertions.assertEquals(
"&#38;test", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
Assertions.assertEquals("&#38;test", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
void handlesEntityAtEndOfString() {
String source = "test&amp;";
Assertions.assertEquals(
"test&#38;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
Assertions.assertEquals("test&#38;", xmlCleaner.replaceHtmlEntitiesWithNumericEntities(source));
}
@Test
@@ -218,8 +201,7 @@ class XMLCleanerTest {
void testRemoveDoctype() {
String source = "<!DOCTYPE html><html><head></head><body></body></html>";
Assertions.assertEquals(
"<html><head></head><body></body></html>",
xmlCleaner.removeDoctypeDeclarations(source));
"<html><head></head><body></body></html>", xmlCleaner.removeDoctypeDeclarations(source));
}
@Test
@@ -241,10 +223,8 @@ class XMLCleanerTest {
@Test
void removesComplexDoctypeWithSystemId() {
String source =
"<!DOCTYPE html SYSTEM \"about:legacy-compat\"><html><body></body></html>";
Assertions.assertEquals(
"<html><body></body></html>", xmlCleaner.removeDoctypeDeclarations(source));
String source = "<!DOCTYPE html SYSTEM \"about:legacy-compat\"><html><body></body></html>";
Assertions.assertEquals("<html><body></body></html>", xmlCleaner.removeDoctypeDeclarations(source));
}
@Test
@@ -276,8 +256,7 @@ class XMLCleanerTest {
void preservesContentWithoutDoctype() {
String source = "<html><body>No doctype here</body></html>";
Assertions.assertEquals(
"<html><body>No doctype here</body></html>",
xmlCleaner.removeDoctypeDeclarations(source));
"<html><body>No doctype here</body></html>", xmlCleaner.removeDoctypeDeclarations(source));
}
@Test

View File

@@ -111,9 +111,7 @@ class OPMLExporterTest {
private boolean containsFeed(List<Outline> outlines, String title, String url) {
for (Outline o : outlines) {
if ("rss".equals(o.getType())
&& title.equals(o.getTitle())
&& o.getAttributeValue("xmlUrl").equals(url)) {
if ("rss".equals(o.getType()) && title.equals(o.getTitle()) && o.getAttributeValue("xmlUrl").equals(url)) {
return true;
}
}

View File

@@ -36,21 +36,16 @@ class OPMLImporterTest {
testOpmlVersion("/opml/opml_noversion.xml");
}
private void testOpmlVersion(String fileName)
throws IOException, IllegalArgumentException, FeedException {
private void testOpmlVersion(String fileName) throws IOException, IllegalArgumentException, FeedException {
XMLCleaner xmlCleaner = Mockito.mock(XMLCleaner.class);
FeedCategoryDAO feedCategoryDAO = Mockito.mock(FeedCategoryDAO.class);
FeedSubscriptionService feedSubscriptionService =
Mockito.mock(FeedSubscriptionService.class);
FeedSubscriptionService feedSubscriptionService = Mockito.mock(FeedSubscriptionService.class);
User user = Mockito.mock(User.class);
Mockito.when(xmlCleaner.clean(Mockito.anyString()))
.thenAnswer(invocation -> invocation.getArgument(0));
String xml =
IOUtils.toString(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8);
Mockito.when(xmlCleaner.clean(Mockito.anyString())).thenAnswer(invocation -> invocation.getArgument(0));
String xml = IOUtils.toString(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8);
OPMLImporter importer =
new OPMLImporter(xmlCleaner, feedCategoryDAO, feedSubscriptionService);
OPMLImporter importer = new OPMLImporter(xmlCleaner, feedCategoryDAO, feedSubscriptionService);
importer.importOpml(user, xml);
Mockito.verify(feedSubscriptionService)

View File

@@ -23,8 +23,7 @@ class FeedEntryFilteringServiceTest {
@BeforeEach
void init() {
config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS);
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout())
.thenReturn(Duration.ofSeconds(30));
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(30));
service = new FeedEntryFilteringService(config);
@@ -75,16 +74,12 @@ class FeedEntryFilteringServiceTest {
@Test
void andExpression() throws FeedEntryFilterException {
Assertions.assertTrue(
service.filterMatchesEntry(
"author == \"Athou\" && url.contains(\"github\")", entry));
Assertions.assertTrue(service.filterMatchesEntry("author == \"Athou\" && url.contains(\"github\")", entry));
}
@Test
void orExpression() throws FeedEntryFilterException {
Assertions.assertTrue(
service.filterMatchesEntry(
"author == \"other\" || url.contains(\"github\")", entry));
Assertions.assertTrue(service.filterMatchesEntry("author == \"other\" || url.contains(\"github\")", entry));
}
@Test
@@ -95,8 +90,7 @@ class FeedEntryFilteringServiceTest {
@Test
void incorrectExpressionThrowsException() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("not valid cel", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("not valid cel", entry));
}
@Test
@@ -124,8 +118,7 @@ class FeedEntryFilteringServiceTest {
FeedEntryContent content = entry.getContent();
content.setCategories("tech, programming, java");
entry.setContent(content);
Assertions.assertTrue(
service.filterMatchesEntry("categories.contains(\"programming\")", entry));
Assertions.assertTrue(service.filterMatchesEntry("categories.contains(\"programming\")", entry));
}
@Test
@@ -145,8 +138,7 @@ class FeedEntryFilteringServiceTest {
@Test
void caseInsensitiveContentMatchUsingLowerVariable() throws FeedEntryFilterException {
Assertions.assertTrue(
service.filterMatchesEntry("contentLower.contains(\"merge\")", entry));
Assertions.assertTrue(service.filterMatchesEntry("contentLower.contains(\"merge\")", entry));
}
@Test
@@ -154,8 +146,7 @@ class FeedEntryFilteringServiceTest {
FeedEntryContent content = entry.getContent();
content.setCategories("Tech, Programming, Java");
entry.setContent(content);
Assertions.assertTrue(
service.filterMatchesEntry("categoriesLower.contains(\"tech\")", entry));
Assertions.assertTrue(service.filterMatchesEntry("categoriesLower.contains(\"tech\")", entry));
}
@Nested
@@ -165,36 +156,28 @@ class FeedEntryFilteringServiceTest {
void sandboxBlocksSystemPropertyAccess() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"java.lang.System.getProperty(\"user.home\")", entry));
() -> service.filterMatchesEntry("java.lang.System.getProperty(\"user.home\")", entry));
}
@Test
void sandboxBlocksRuntimeExec() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"java.lang.Runtime.getRuntime().exec(\"calc\")", entry));
() -> service.filterMatchesEntry("java.lang.Runtime.getRuntime().exec(\"calc\")", entry));
}
@Test
void sandboxBlocksProcessBuilder() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"new java.lang.ProcessBuilder(\"cmd\").start()", entry));
() -> service.filterMatchesEntry("new java.lang.ProcessBuilder(\"cmd\").start()", entry));
}
@Test
void sandboxBlocksClassLoading() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"java.lang.Class.forName(\"java.lang.Runtime\")", entry));
() -> service.filterMatchesEntry("java.lang.Class.forName(\"java.lang.Runtime\")", entry));
}
@Test
@@ -208,9 +191,7 @@ class FeedEntryFilteringServiceTest {
void sandboxBlocksFileAccess() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"new java.io.File(\"/etc/passwd\").exists()", entry));
() -> service.filterMatchesEntry("new java.io.File(\"/etc/passwd\").exists()", entry));
}
@Test
@@ -219,18 +200,14 @@ class FeedEntryFilteringServiceTest {
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"java.nio.file.Files.readString(java.nio.file.Paths.get(\"/etc/passwd\"))",
entry));
"java.nio.file.Files.readString(java.nio.file.Paths.get(\"/etc/passwd\"))", entry));
}
@Test
void sandboxBlocksNetworkAccess() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() ->
service.filterMatchesEntry(
"new java.net.URL(\"http://evil.com\").openConnection()",
entry));
() -> service.filterMatchesEntry("new java.net.URL(\"http://evil.com\").openConnection()", entry));
}
@Test
@@ -274,50 +251,42 @@ class FeedEntryFilteringServiceTest {
@Test
void sandboxBlocksMethodInvocationOnStrings() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("title.toCharArray()", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("title.toCharArray()", entry));
}
@Test
void sandboxBlocksArbitraryJavaMethodCalls() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("title.getBytes()", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("title.getBytes()", entry));
}
@Test
void sandboxOnlyAllowsDeclaredVariables() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("System", entry));
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("System", entry));
}
@Test
void sandboxBlocksConstructorCalls() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("new String(\"test\")", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("new String(\"test\")", entry));
}
@Test
void sandboxBlocksStaticMethodCalls() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("String.valueOf(123)", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("String.valueOf(123)", entry));
}
@Test
void sandboxBlocksLambdaExpressions() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("() -> true", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("() -> true", entry));
}
@Test
void sandboxBlocksObjectInstantiation() {
Assertions.assertThrows(
FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.util.HashMap{}", entry));
FeedEntryFilterException.class, () -> service.filterMatchesEntry("java.util.HashMap{}", entry));
}
}
}

View File

@@ -17,9 +17,7 @@ class PasswordEncryptionServiceTest {
// make sure the encrypted password is always the same for a fixed salt
Assertions.assertEquals(
"8b4660158141d9f4f7865718b9a2b940a3e3cea9",
HexFormat.of().formatHex(encryptedPassword));
Assertions.assertTrue(
passwordEncryptionService.authenticate(password, encryptedPassword, salt));
"8b4660158141d9f4f7865718b9a2b940a3e3cea9", HexFormat.of().formatHex(encryptedPassword));
Assertions.assertTrue(passwordEncryptionService.authenticate(password, encryptedPassword, salt));
}
}

View File

@@ -48,11 +48,9 @@ class PushNotificationServiceTest {
HttpClientFactory httpClientFactory =
new HttpClientFactory(config, Mockito.mock(com.commafeed.CommaFeedVersion.class));
MetricRegistry metricRegistry = Mockito.mock(MetricRegistry.class);
Mockito.when(metricRegistry.meter(Mockito.anyString()))
.thenReturn(Mockito.mock(Meter.class));
Mockito.when(metricRegistry.meter(Mockito.anyString())).thenReturn(Mockito.mock(Meter.class));
this.pushNotificationService =
new PushNotificationService(httpClientFactory, metricRegistry, config);
this.pushNotificationService = new PushNotificationService(httpClientFactory, metricRegistry, config);
this.userSettings = new PushNotificationUserSettings();
@@ -78,8 +76,7 @@ class PushNotificationServiceTest {
.withBody("Test Entry"))
.respond(HttpResponse.response().withStatusCode(200));
Assertions.assertDoesNotThrow(
() -> pushNotificationService.notify(userSettings, subscription, entry));
Assertions.assertDoesNotThrow(() -> pushNotificationService.notify(userSettings, subscription, entry));
}
@Test
@@ -113,8 +110,7 @@ class PushNotificationServiceTest {
""")))
.respond(HttpResponse.response().withStatusCode(200));
Assertions.assertDoesNotThrow(
() -> pushNotificationService.notify(userSettings, subscription, entry));
Assertions.assertDoesNotThrow(() -> pushNotificationService.notify(userSettings, subscription, entry));
}
@Test
@@ -124,8 +120,7 @@ class PushNotificationServiceTest {
userSettings.setServerUrl("http://localhost:" + mockServerClient.getPort());
userSettings.setTopic("test-topic");
Assertions.assertDoesNotThrow(
() -> pushNotificationService.notify(userSettings, subscription, entry));
Assertions.assertDoesNotThrow(() -> pushNotificationService.notify(userSettings, subscription, entry));
mockServerClient.verifyZeroInteractions();
}

View File

@@ -108,15 +108,12 @@ class UserServiceTest {
Mockito.when(userDAO.findByName("test")).thenReturn(normalUser);
Mockito.when(
passwordEncryptionService.authenticate(
Mockito.anyString(),
Mockito.any(byte[].class),
Mockito.any(byte[].class)))
Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class)))
.thenReturn(false);
userService.login("test", "password");
Mockito.verify(passwordEncryptionService)
.authenticate("password", ENCRYPTED_PASSWORD, SALT);
Mockito.verify(passwordEncryptionService).authenticate("password", ENCRYPTED_PASSWORD, SALT);
}
@Test
@@ -124,9 +121,7 @@ class UserServiceTest {
Mockito.when(userDAO.findByName("test")).thenReturn(normalUser);
Mockito.when(
passwordEncryptionService.authenticate(
Mockito.anyString(),
Mockito.any(byte[].class),
Mockito.any(byte[].class)))
Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class)))
.thenReturn(false);
Optional<User> authenticatedUser = userService.login("test", "password");
@@ -139,9 +134,7 @@ class UserServiceTest {
Mockito.when(userDAO.findByName("test")).thenReturn(normalUser);
Mockito.when(
passwordEncryptionService.authenticate(
Mockito.anyString(),
Mockito.any(byte[].class),
Mockito.any(byte[].class)))
Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class)))
.thenReturn(true);
Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class));
@@ -155,9 +148,7 @@ class UserServiceTest {
Mockito.when(userDAO.findByName("test")).thenReturn(normalUser);
Mockito.when(
passwordEncryptionService.authenticate(
Mockito.anyString(),
Mockito.any(byte[].class),
Mockito.any(byte[].class)))
Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class)))
.thenReturn(true);
Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class));

View File

@@ -64,13 +64,7 @@ class DatabaseCleaningServiceTest {
service =
new DatabaseCleaningService(
config,
unitOfWork,
feedDAO,
feedEntryDAO,
feedEntryContentDAO,
feedEntryStatusDAO,
metrics);
config, unitOfWork, feedDAO, feedEntryDAO, feedEntryContentDAO, feedEntryStatusDAO, metrics);
}
@Test
@@ -97,13 +91,11 @@ class DatabaseCleaningServiceTest {
@Test
void cleanContentsWithoutEntriesDeletesContents() {
Mockito.when(feedEntryContentDAO.deleteWithoutEntries(Mockito.anyInt()))
.thenReturn(50L, 30L, 0L);
Mockito.when(feedEntryContentDAO.deleteWithoutEntries(Mockito.anyInt())).thenReturn(50L, 30L, 0L);
service.cleanContentsWithoutEntries();
Mockito.verify(feedEntryContentDAO, Mockito.times(3))
.deleteWithoutEntries(Mockito.anyInt());
Mockito.verify(feedEntryContentDAO, Mockito.times(3)).deleteWithoutEntries(Mockito.anyInt());
}
@Test
@@ -133,13 +125,11 @@ class DatabaseCleaningServiceTest {
void cleanEntriesOlderThanDeletesOldEntries() {
Instant cutoff = LocalDate.now().minusDays(30).atStartOfDay().toInstant(ZoneOffset.UTC);
Mockito.when(feedEntryDAO.deleteEntriesOlderThan(cutoff, BATCH_SIZE, false))
.thenReturn(100, 50, 0);
Mockito.when(feedEntryDAO.deleteEntriesOlderThan(cutoff, BATCH_SIZE, false)).thenReturn(100, 50, 0);
service.cleanEntriesOlderThan(cutoff);
Mockito.verify(feedEntryDAO, Mockito.times(3))
.deleteEntriesOlderThan(cutoff, BATCH_SIZE, false);
Mockito.verify(feedEntryDAO, Mockito.times(3)).deleteEntriesOlderThan(cutoff, BATCH_SIZE, false);
Mockito.verify(entriesDeletedMeter, Mockito.times(3)).mark(Mockito.anyLong());
}
@@ -147,8 +137,7 @@ class DatabaseCleaningServiceTest {
void cleanStatusesOlderThanDeletesOldStatuses() {
Instant cutoff = LocalDate.now().minusDays(60).atStartOfDay().toInstant(ZoneOffset.UTC);
Mockito.when(feedEntryStatusDAO.deleteOldStatuses(cutoff, BATCH_SIZE))
.thenReturn(200L, 100L, 0L);
Mockito.when(feedEntryStatusDAO.deleteOldStatuses(cutoff, BATCH_SIZE)).thenReturn(200L, 100L, 0L);
service.cleanStatusesOlderThan(cutoff);

View File

@@ -25,8 +25,7 @@ class InPageReferenceFeedURLProviderTest {
""";
Assertions.assertIterableEquals(
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
provider.get(url, html));
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"), provider.get(url, html));
}
@Test

View File

@@ -18,8 +18,7 @@ class YoutubeFeedURLProviderTest {
@Test
void doesNotmatchYoutubeChannelURL() {
Assertions.assertTrue(
provider.get("https://www.anothersite.com/channel/abc", null).isEmpty());
Assertions.assertTrue(provider.get("https://www.anothersite.com/channel/abc", null).isEmpty());
Assertions.assertTrue(provider.get("https://www.youtube.com/user/abc", null).isEmpty());
}
}

View File

@@ -35,8 +35,7 @@ class AuthentificationIT {
Page page = context.newPage();
page.navigate(getLoginPageUrl());
PlaywrightTestUtils.login(page, TestConstants.ADMIN_USERNAME, "wrong_password");
PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT))
.containsText("Wrong username or password");
PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT)).containsText("Wrong username or password");
}
@Test

View File

@@ -25,8 +25,7 @@ class InitialSetupIT {
page.getByPlaceholder("Admin User Name").fill(TestConstants.ADMIN_USERNAME);
page.getByPlaceholder("Password").fill(TestConstants.ADMIN_PASSWORD);
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Create Admin Account"))
.click();
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Create Admin Account")).click();
PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all");
}

View File

@@ -46,14 +46,12 @@ class ReadingIT {
HttpResponse.response()
.withBody(
IOUtils.toString(
getClass().getResource("/feed/rss.xml"),
StandardCharsets.UTF_8))
getClass().getResource("/feed/rss.xml"), StandardCharsets.UTF_8))
.withDelay(TimeUnit.MILLISECONDS, 100));
PlaywrightTestUtils.initialSetup();
RestAssured.authentication =
RestAssured.preemptive()
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
}
@AfterEach
@@ -74,18 +72,13 @@ class ReadingIT {
Locator sidebar = page.getByRole(AriaRole.NAVIGATION);
Locator main = page.getByRole(AriaRole.MAIN);
PlaywrightAssertions.assertThat(main.getByText("You don't have any subscriptions yet."))
.hasCount(1);
PlaywrightAssertions.assertThat(main.getByText("You don't have any subscriptions yet.")).hasCount(1);
// subscribe
header.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe"))
.click();
header.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe")).click();
main.getByText("Feed URL *").fill("http://localhost:" + this.mockServerClient.getPort());
main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Next")).click();
main.getByRole(
AriaRole.BUTTON,
new Locator.GetByRoleOptions().setName("Subscribe").setExact(true))
.click();
main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe").setExact(true)).click();
// click on subscription
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click();

View File

@@ -40,8 +40,7 @@ import java.util.Objects;
@Getter
public abstract class BaseIT {
private static final HttpRequest FEED_REQUEST =
HttpRequest.request().withMethod("GET").withPath("/");
private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/");
@Getter private MockServerClient mockServerClient;
private Client client;
@@ -62,14 +61,11 @@ public abstract class BaseIT {
URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss.xml"));
this.mockServerClient
.when(FEED_REQUEST)
.respond(
HttpResponse.response()
.withBody(IOUtils.toString(resource, StandardCharsets.UTF_8)));
.respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8)));
// remove when Quarkus supports Jackson 3
RestAssured.config =
RestAssured.config()
.objectMapperConfig(new ObjectMapperConfig(ObjectMapperType.JACKSON_2));
RestAssured.config().objectMapperConfig(new ObjectMapperConfig(ObjectMapperType.JACKSON_2));
}
@AfterEach
@@ -89,9 +85,7 @@ public abstract class BaseIT {
URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss_2.xml"));
mockServerClient
.when(FEED_REQUEST)
.respond(
HttpResponse.response()
.withBody(IOUtils.toString(resource, StandardCharsets.UTF_8)));
.respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8)));
}
protected void initialSetup(String userName, String password) {
@@ -124,9 +118,7 @@ public abstract class BaseIT {
.extract()
.headers()
.getList(HttpHeaders.SET_COOKIE);
return setCookieHeaders.stream()
.flatMap(h -> HttpCookie.parse(h.getValue()).stream())
.toList();
return setCookieHeaders.stream().flatMap(h -> HttpCookie.parse(h.getValue()).stream()).toList();
}
protected String createCategory(String name) {
@@ -223,10 +215,7 @@ public abstract class BaseIT {
protected Entries getCategoryEntries(String categoryId, String keywords) {
return RestAssured.given()
.get(
"rest/category/entries?id={id}&readType=all&keywords={keywords}",
categoryId,
keywords)
.get("rest/category/entries?id={id}&readType=all&keywords={keywords}", categoryId, keywords)
.then()
.statusCode(HttpStatus.SC_OK)
.extract()

View File

@@ -14,11 +14,6 @@ class CompressionIT {
@ParameterizedTest
@ValueSource(strings = {"/rest/server/get", "/"})
void servedWithCompression(String path) {
RestAssured.given()
.when()
.get(path)
.then()
.statusCode(200)
.header(HttpHeaders.CONTENT_ENCODING, "gzip");
RestAssured.given().when().get(path).then().statusCode(200).header(HttpHeaders.CONTENT_ENCODING, "gzip");
}
}

View File

@@ -29,8 +29,7 @@ class PushNotificationIT extends BaseIT {
void setup() {
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.authentication =
RestAssured.preemptive()
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
}
@AfterEach
@@ -41,13 +40,11 @@ class PushNotificationIT extends BaseIT {
@Test
void receivedPushNotifications() throws IOException {
// mock ntfy server
HttpRequest ntfyPost =
HttpRequest.request().withMethod("POST").withPath("/ntfy/integration-test");
HttpRequest ntfyPost = HttpRequest.request().withMethod("POST").withPath("/ntfy/integration-test");
getMockServerClient().when(ntfyPost).respond(HttpResponse.response().withStatusCode(200));
// enable push notifications
Settings settings =
RestAssured.given().get("rest/user/settings").then().extract().as(Settings.class);
Settings settings = RestAssured.given().get("rest/user/settings").then().extract().as(Settings.class);
settings.getPushNotificationSettings().setType(PushNotificationType.NTFY);
settings.getPushNotificationSettings()
.setServerUrl("http://localhost:" + getMockServerClient().getPort() + "/ntfy");
@@ -84,7 +81,6 @@ class PushNotificationIT extends BaseIT {
// await push notification for the two entries in the feed
Awaitility.await()
.atMost(Duration.ofSeconds(20))
.untilAsserted(
() -> getMockServerClient().verify(ntfyPost, VerificationTimes.exactly(2)));
.untilAsserted(() -> getMockServerClient().verify(ntfyPost, VerificationTimes.exactly(2)));
}
}

View File

@@ -57,9 +57,7 @@ class SecurityIT extends BaseIT {
cookies.forEach(c -> Assertions.assertTrue(c.getMaxAge() > 0));
RestAssured.given()
.header(
HttpHeaders.COOKIE,
cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";")))
.header(HttpHeaders.COOKIE, cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";")))
.get("rest/user/profile")
.then()
.statusCode(HttpStatus.SC_OK);
@@ -71,11 +69,7 @@ class SecurityIT extends BaseIT {
RestAssured.given()
.auth()
.none()
.formParams(
"j_username",
TestConstants.ADMIN_USERNAME,
"j_password",
"wrong-password")
.formParams("j_username", TestConstants.ADMIN_USERNAME, "j_password", "wrong-password")
.post("j_security_check")
.then()
.statusCode(HttpStatus.SC_UNAUTHORIZED)
@@ -396,9 +390,7 @@ class SecurityIT extends BaseIT {
}
private String session(String userName, String password) {
return login(userName, password).stream()
.map(HttpCookie::toString)
.collect(Collectors.joining(";"));
return login(userName, password).stream().map(HttpCookie::toString).collect(Collectors.joining(";"));
}
private UserModel profile(String session) {
@@ -412,10 +404,6 @@ class SecurityIT extends BaseIT {
}
private void assertSessionStatus(String session, int status) {
RestAssured.given()
.header(HttpHeaders.COOKIE, session)
.get("rest/user/profile")
.then()
.statusCode(status);
RestAssured.given().header(HttpHeaders.COOKIE, session).get("rest/user/profile").then().statusCode(status);
}
}

View File

@@ -12,12 +12,7 @@ class StaticFilesIT {
@ParameterizedTest
@ValueSource(strings = {"/", "/openapi"})
void servedWithoutCache(String path) {
RestAssured.given()
.when()
.get(path)
.then()
.statusCode(200)
.header("Cache-Control", "no-cache");
RestAssured.given().when().get(path).then().statusCode(200).header("Cache-Control", "no-cache");
}
@ParameterizedTest

View File

@@ -44,8 +44,7 @@ class WebSocketIT extends BaseIT {
void setup() {
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.authentication =
RestAssured.preemptive()
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
}
@AfterEach
@@ -76,9 +75,7 @@ class WebSocketIT extends BaseIT {
Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected);
log.info("connected to {}", session.getRequestURI());
Awaitility.await()
.atMost(15, TimeUnit.SECONDS)
.until(() -> closeReasonRef.get() != null);
Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> closeReasonRef.get() != null);
}
}

View File

@@ -33,8 +33,7 @@ class DatabaseCleaningIT extends BaseIT {
void setup() {
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.authentication =
RestAssured.preemptive()
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
RestAssured.preemptive().basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
}
@AfterEach
@@ -85,8 +84,7 @@ class DatabaseCleaningIT extends BaseIT {
// Verify the entry is starred
Entries starredEntries = getCategoryEntries(CategoryREST.STARRED);
Assertions.assertEquals(1, starredEntries.getEntries().size());
Assertions.assertEquals(
entryToStar.getId(), starredEntries.getEntries().getFirst().getId());
Assertions.assertEquals(entryToStar.getId(), starredEntries.getEntries().getFirst().getId());
// Run cleanup with capacity of 0 (should delete all non-starred entries)
// With keepStarredEntries=true (default), only non-starred entries are counted for
@@ -99,14 +97,12 @@ class DatabaseCleaningIT extends BaseIT {
// Verify starred entry is still present
Entries starredEntriesAfter = getCategoryEntries(CategoryREST.STARRED);
Assertions.assertEquals(1, starredEntriesAfter.getEntries().size());
Assertions.assertEquals(
entryToStar.getId(), starredEntriesAfter.getEntries().getFirst().getId());
Assertions.assertEquals(entryToStar.getId(), starredEntriesAfter.getEntries().getFirst().getId());
// Verify the non-starred entry was deleted (only starred entry should remain)
Entries entriesAfter = getFeedEntries(subscriptionId);
Assertions.assertEquals(1, entriesAfter.getEntries().size());
Assertions.assertEquals(
entryToStar.getId(), entriesAfter.getEntries().getFirst().getId());
Assertions.assertEquals(entryToStar.getId(), entriesAfter.getEntries().getFirst().getId());
}
@Test
@@ -134,14 +130,12 @@ class DatabaseCleaningIT extends BaseIT {
// Verify starred entry is still present
Entries starredEntriesAfter = getCategoryEntries(CategoryREST.STARRED);
Assertions.assertEquals(1, starredEntriesAfter.getEntries().size());
Assertions.assertEquals(
entryToStar.getId(), starredEntriesAfter.getEntries().getFirst().getId());
Assertions.assertEquals(entryToStar.getId(), starredEntriesAfter.getEntries().getFirst().getId());
// Verify the non-starred entry was deleted
Entries entriesAfter = getFeedEntries(subscriptionId);
Assertions.assertEquals(1, entriesAfter.getEntries().size());
Assertions.assertEquals(
entryToStar.getId(), entriesAfter.getEntries().getFirst().getId());
Assertions.assertEquals(entryToStar.getId(), entriesAfter.getEntries().getFirst().getId());
}
@Test
@@ -183,15 +177,13 @@ class DatabaseCleaningIT extends BaseIT {
starEntry(entry.getId(), subscriptionId);
// Verify entry is starred
Assertions.assertEquals(
1, getCategoryEntries(CategoryREST.STARRED).getEntries().size());
Assertions.assertEquals(1, getCategoryEntries(CategoryREST.STARRED).getEntries().size());
// Unstar the entry
unstarEntry(entry.getId(), subscriptionId);
// Verify entry is no longer starred
Assertions.assertEquals(
0, getCategoryEntries(CategoryREST.STARRED).getEntries().size());
Assertions.assertEquals(0, getCategoryEntries(CategoryREST.STARRED).getEntries().size());
// Run cleanup for entries older than now
Instant olderThan = Instant.now().plus(Duration.ofDays(1));
@@ -212,27 +204,20 @@ class DatabaseCleaningIT extends BaseIT {
// verify we have 2 unread entries
Entries entries = getFeedEntries(subscriptionId);
Assertions.assertEquals(
2, entries.getEntries().stream().filter(e -> !e.isRead()).count());
Assertions.assertEquals(2, entries.getEntries().stream().filter(e -> !e.isRead()).count());
// set auto-mark as read
FeedModificationRequest req = new FeedModificationRequest();
req.setId(subscriptionId);
req.setAutoMarkAsReadAfterDays(1);
RestAssured.given()
.body(req)
.contentType(ContentType.JSON)
.post("rest/feed/modify")
.then()
.statusCode(200);
RestAssured.given().body(req).contentType(ContentType.JSON).post("rest/feed/modify").then().statusCode(200);
// run auto-mark as read
databaseCleaningService.autoMarkAsRead();
// verify all entries are now read
entries = getFeedEntries(subscriptionId);
Assertions.assertEquals(
0, entries.getEntries().stream().filter(e -> !e.isRead()).count());
Assertions.assertEquals(0, entries.getEntries().stream().filter(e -> !e.isRead()).count());
}
}
}

Some files were not shown because too many files have changed in this diff Show More