diff --git a/commafeed-client/src/app/types.ts b/commafeed-client/src/app/types.ts
index 86c44789..96c70ed5 100644
--- a/commafeed-client/src/app/types.ts
+++ b/commafeed-client/src/app/types.ts
@@ -220,6 +220,7 @@ export interface ServerInfo {
websocketEnabled: boolean
websocketPingInterval: number
treeReloadInterval: number
+ forceRefreshCooldownDuration: number
}
export interface SharingSettings {
@@ -287,6 +288,7 @@ export interface UserModel {
created: number
lastLogin?: number
admin: boolean
+ lastForceRefresh?: number
}
export interface AdminSaveUserRequest {
diff --git a/commafeed-client/src/components/RelativeDate.tsx b/commafeed-client/src/components/RelativeDate.tsx
index 769d8e74..a08236e0 100644
--- a/commafeed-client/src/components/RelativeDate.tsx
+++ b/commafeed-client/src/components/RelativeDate.tsx
@@ -2,14 +2,10 @@ import { Trans } from "@lingui/macro"
import { Tooltip } from "@mantine/core"
import { Constants } from "app/constants"
import dayjs from "dayjs"
-import { useEffect, useState } from "react"
+import { useNow } from "hooks/useNow"
export function RelativeDate(props: { date: Date | number | undefined }) {
- const [now, setNow] = useState(new Date())
- useEffect(() => {
- const interval = setInterval(() => setNow(new Date()), 60 * 1000)
- return () => clearInterval(interval)
- }, [])
+ const now = useNow(60 * 1000)
if (!props.date) return N/A
const date = dayjs(props.date)
diff --git a/commafeed-client/src/components/header/ProfileMenu.tsx b/commafeed-client/src/components/header/ProfileMenu.tsx
index a3305d17..7c71ef24 100644
--- a/commafeed-client/src/components/header/ProfileMenu.tsx
+++ b/commafeed-client/src/components/header/ProfileMenu.tsx
@@ -15,6 +15,9 @@ import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetr
import { useAppDispatch, useAppSelector } from "app/store"
import type { ViewMode } from "app/types"
import { setViewMode } from "app/user/slice"
+import { reloadProfile } from "app/user/thunks"
+import dayjs from "dayjs"
+import { useNow } from "hooks/useNow"
import { type ReactNode, useState } from "react"
import {
TbChartLine,
@@ -92,12 +95,19 @@ const viewModeData: ViewModeControlItem[] = [
export function ProfileMenu(props: ProfileMenuProps) {
const [opened, setOpened] = useState(false)
+ const now = useNow()
const profile = useAppSelector(state => state.user.profile)
const admin = useAppSelector(state => state.user.profile?.admin)
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
+ const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
const dispatch = useAppDispatch()
const { colorScheme, setColorScheme } = useMantineColorScheme()
+ const nextAvailableForceRefresh = profile?.lastForceRefresh
+ ? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
+ : now.getTime()
+ const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
+
const logout = () => {
window.location.href = "logout"
}
@@ -118,18 +128,24 @@ export function ProfileMenu(props: ProfileMenuProps) {
}
- onClick={async () =>
- await client.feed.refreshAll().then(() => {
- showNotification({
- message: Your feeds have been queued for refresh.,
- color: "green",
- autoClose: 1000,
- })
- setOpened(false)
+ disabled={!forceRefreshEnabled}
+ onClick={async () => {
+ setOpened(false)
+
+ await client.feed.refreshAll()
+
+ // reload profile to update last force refresh timestamp
+ await dispatch(reloadProfile())
+
+ showNotification({
+ message: Your feeds have been queued for refresh.,
+ color: "green",
+ autoClose: 1000,
})
- }
+ }}
>
Fetch all my feeds now
+ {!forceRefreshEnabled && ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})}
diff --git a/commafeed-client/src/hooks/useNow.ts b/commafeed-client/src/hooks/useNow.ts
new file mode 100644
index 00000000..087feaeb
--- /dev/null
+++ b/commafeed-client/src/hooks/useNow.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from "react"
+
+export const useNow = (interval = 1000): Date => {
+ const [time, setTime] = useState(new Date())
+ useEffect(() => {
+ const t = setInterval(() => setTime(new Date()), interval)
+ return () => clearInterval(t)
+ }, [interval])
+ return time
+}
diff --git a/commafeed-client/src/main.tsx b/commafeed-client/src/main.tsx
index 0f64f603..0e107484 100644
--- a/commafeed-client/src/main.tsx
+++ b/commafeed-client/src/main.tsx
@@ -6,11 +6,13 @@ import "react-contexify/ReactContexify.css"
import { App } from "App"
import { store } from "app/store"
import dayjs from "dayjs"
+import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import ReactDOM from "react-dom/client"
import { Provider } from "react-redux"
dayjs.extend(relativeTime)
+dayjs.extend(duration)
const root = document.getElementById("root")
root &&
diff --git a/commafeed-server/doc/commafeed.adoc b/commafeed-server/doc/commafeed.adoc
index f5759ae0..8427d484 100644
--- a/commafeed-server/doc/commafeed.adoc
+++ b/commafeed-server/doc/commafeed.adoc
@@ -413,6 +413,23 @@ endif::add-copy-button-to-env-var[]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`500MS`
+a| [[commafeed-server_commafeed-feed-refresh-force-refresh-cooldown-duration]] [.property-path]##`commafeed.feed-refresh.force-refresh-cooldown-duration`##
+
+[.description]
+--
+Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds. 0 to disable.
+
+
+ifdef::add-copy-button-to-env-var[]
+Environment variable: env_var_with_copy_button:+++COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION+++[]
+endif::add-copy-button-to-env-var[]
+ifndef::add-copy-button-to-env-var[]
+Environment variable: `+++COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION+++`
+endif::add-copy-button-to-env-var[]
+--
+|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
+|`1M`
+
h|[[commafeed-server_section_commafeed-database]] [.section-name.section-level0]##Database settings##
h|Type
diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java
index 20a49e9e..ee3115d4 100644
--- a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java
+++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java
@@ -209,6 +209,14 @@ public interface CommaFeedConfiguration {
*/
@WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout();
+
+ /**
+ * Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds.
+ *
+ * 0 to disable.
+ */
+ @WithDefault("1m")
+ Duration forceRefreshCooldownDuration();
}
interface Database {
diff --git a/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java b/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java
index 0bc56613..9e6c1a76 100644
--- a/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java
+++ b/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java
@@ -18,7 +18,8 @@ public class JacksonCustomizer implements ObjectMapperCustomizer {
objectMapper.registerModule(new JavaTimeModule());
// read and write instants as milliseconds instead of nanoseconds
- objectMapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
+ objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
+ .configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
// add support for serializing metrics
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/User.java b/commafeed-server/src/main/java/com/commafeed/backend/model/User.java
index 6efc8cd4..73cc5313 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/model/User.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/model/User.java
@@ -52,4 +52,7 @@ public class User extends AbstractModel {
@Column
private Instant recoverPasswordTokenDate;
+
+ @Column
+ private Instant lastForceRefresh;
}
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java
index d554500c..06d58211 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java
@@ -98,12 +98,19 @@ public class FeedSubscriptionService {
}
}
- public void refreshAll(User user) {
+ public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
+ Instant lastForceRefresh = user.getLastForceRefresh();
+ if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
+ throw new ForceFeedRefreshTooSoonException();
+ }
+
List subs = feedSubscriptionDAO.findAll(user);
for (FeedSubscription sub : subs) {
Feed feed = sub.getFeed();
feedRefreshEngine.refreshImmediately(feed);
}
+
+ user.setLastForceRefresh(Instant.now());
}
public void refreshAllUpForRefresh(User user) {
@@ -130,4 +137,11 @@ public class FeedSubscriptionService {
}
}
+ @SuppressWarnings("serial")
+ public static class ForceFeedRefreshTooSoonException extends Exception {
+ private ForceFeedRefreshTooSoonException() {
+ super();
+ }
+ }
+
}
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java
index f7ffd7cf..c0198047 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java
@@ -43,4 +43,7 @@ public class ServerInfo implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private long treeReloadInterval;
+ @Schema(requiredMode = RequiredMode.REQUIRED)
+ private long forceRefreshCooldownDuration;
+
}
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java
index 43d5faea..1b1bb7c9 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java
@@ -41,4 +41,7 @@ public class UserModel implements Serializable {
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin;
+ @Schema(description = "user last force refresh", type = "number")
+ private Instant lastForceRefresh;
+
}
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java
index febfa8ad..a2012944 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java
@@ -10,6 +10,7 @@ import java.util.Objects;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
+import org.apache.hc.core5.http.HttpStatus;
import org.jboss.resteasy.reactive.Cache;
import org.jboss.resteasy.reactive.RestForm;
@@ -40,6 +41,7 @@ import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterEx
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.FeedSubscriptionService;
+import com.commafeed.backend.service.FeedSubscriptionService.ForceFeedRefreshTooSoonException;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo;
@@ -276,8 +278,13 @@ public class FeedREST {
@Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue")
public Response queueAllForRefresh() {
User user = authenticationContext.getCurrentUser();
- feedSubscriptionService.refreshAll(user);
- return Response.ok().build();
+ try {
+ feedSubscriptionService.refreshAll(user);
+ return Response.ok().build();
+ } catch (ForceFeedRefreshTooSoonException e) {
+ return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build();
+ }
+
}
@Path("/refresh")
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java
index 3145ba87..5a82f125 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java
@@ -61,6 +61,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());
return Response.ok(infos).build();
}
diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
index 5c11d22a..e12c7102 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
@@ -212,6 +212,7 @@ public class UserREST {
userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey());
+ userModel.setLastForceRefresh(user.getLastForceRefresh());
for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml
new file mode 100644
index 00000000..04b5a299
--- /dev/null
+++ b/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/commafeed-server/src/main/resources/migrations.xml b/commafeed-server/src/main/resources/migrations.xml
index 9c74cf79..b0fd9d4c 100644
--- a/commafeed-server/src/main/resources/migrations.xml
+++ b/commafeed-server/src/main/resources/migrations.xml
@@ -33,5 +33,6 @@
+
\ No newline at end of file
diff --git a/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java b/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java
index 00ebe118..592d8cce 100644
--- a/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java
+++ b/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java
@@ -125,7 +125,7 @@ public abstract class BaseIT {
.as(Entries.class);
}
- protected void forceRefreshAllFeeds() {
- RestAssured.given().get("rest/feed/refreshAll").then().statusCode(HttpStatus.SC_OK);
+ protected int forceRefreshAllFeeds() {
+ return RestAssured.given().get("rest/feed/refreshAll").then().extract().statusCode();
}
}
diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java
index 271b3556..074fa33b 100644
--- a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java
+++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java
@@ -183,11 +183,13 @@ class FeedIT extends BaseIT {
// mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().minus(Duration.ofSeconds(1));
- forceRefreshAllFeeds();
+ Assertions.assertEquals(HttpStatus.SC_OK, forceRefreshAllFeeds());
Awaitility.await()
.atMost(Duration.ofSeconds(15))
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
+
+ Assertions.assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, forceRefreshAllFeeds());
}
}
diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java
index 3977d967..de172808 100644
--- a/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java
+++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java
@@ -21,6 +21,7 @@ class ServerIT extends BaseIT {
Assertions.assertTrue(serverInfos.isWebsocketEnabled());
Assertions.assertEquals(900000, serverInfos.getWebsocketPingInterval());
Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval());
+ Assertions.assertEquals(60000, serverInfos.getForceRefreshCooldownDuration());
}
}