add a cooldown on the force refresh action (#1556)

This commit is contained in:
Athou
2024-09-20 14:03:20 +02:00
parent 0d75688ec8
commit 19c8db8b31
20 changed files with 121 additions and 22 deletions

View File

@@ -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 {

View File

@@ -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 <Trans>N/A</Trans>
const date = dayjs(props.date)

View File

@@ -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) {
</Menu.Item>
<Menu.Item
leftSection={<TbWorldDownload size={iconSize} />}
onClick={async () =>
await client.feed.refreshAll().then(() => {
showNotification({
message: <Trans>Your feeds have been queued for refresh.</Trans>,
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: <Trans>Your feeds have been queued for refresh.</Trans>,
color: "green",
autoClose: 1000,
})
}
}}
>
<Trans>Fetch all my feeds now</Trans>
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
</Menu.Item>
<Divider />

View File

@@ -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
}

View File

@@ -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 &&

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -52,4 +52,7 @@ public class User extends AbstractModel {
@Column
private Instant recoverPasswordTokenDate;
@Column
private Instant lastForceRefresh;
}

View File

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

View File

@@ -43,4 +43,7 @@ public class ServerInfo implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private long treeReloadInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long forceRefreshCooldownDuration;
}

View File

@@ -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;
}

View File

@@ -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")

View File

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

View File

@@ -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);

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="lastForceRefresh" author="athou">
<addColumn tableName="USERS">
<column name="lastForceRefresh" type="${timestamp_type}" />
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -33,5 +33,6 @@
<include file="changelogs/db.changelog-4.4.xml" />
<include file="changelogs/db.changelog-5.1.xml" />
<include file="changelogs/db.changelog-5.2.xml" />
<include file="changelogs/db.changelog-5.3.xml" />
</databaseChangeLog>

View File

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

View File

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

View File

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