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()); } }