From 9adc13e722ceae93b36968a809857339db656f1b Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Wed, 6 Nov 2024 23:55:14 +0200 Subject: [PATCH] Add User Rate Limits tab (#125) * Add User Rate Limits tab * update readme --- README.md | 1 + src/components/ExperimentalFeatures.tsx | 144 ++++++++++++------------ src/components/UserRateLimits.tsx | 90 +++++++++++++++ src/i18n/de.ts | 8 +- src/i18n/en.ts | 12 +- src/i18n/fa.ts | 8 +- src/i18n/fr.ts | 8 +- src/i18n/index.d.ts | 8 +- src/i18n/it.ts | 8 +- src/i18n/ru.ts | 10 +- src/i18n/zh.ts | 8 +- src/resources/users.tsx | 10 +- src/synapse/dataProvider.ts | 24 ++++ 13 files changed, 256 insertions(+), 83 deletions(-) create mode 100644 src/components/UserRateLimits.tsx diff --git a/README.md b/README.md index 2adf6d7..b848dfe 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ with a proper manifest.json generation on build) * Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available) * [Add option to control user's experimental features](https://github.com/etkecc/synapse-admin/pull/111) * [Add random password generation on user create/edit form](https://github.com/etkecc/synapse-admin/pull/123) +* [Add option to set user's rate limits](https://github.com/etkecc/synapse-admin/pull/125) _the list will be updated as new changes are added_ diff --git a/src/components/ExperimentalFeatures.tsx b/src/components/ExperimentalFeatures.tsx index 49cd384..19d9d7a 100644 --- a/src/components/ExperimentalFeatures.tsx +++ b/src/components/ExperimentalFeatures.tsx @@ -10,86 +10,86 @@ const experimentalFeaturesMap = { msc3575: "enable experimental sliding sync support", }; const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => { - const featureKey = props.featureKey; - const featureValue = props.featureValue; - const featureDescription = experimentalFeaturesMap[featureKey] ?? ""; - const [checked, setChecked] = useState(featureValue); + const featureKey = props.featureKey; + const featureValue = props.featureValue; + const featureDescription = experimentalFeaturesMap[featureKey] ?? ""; + const [checked, setChecked] = useState(featureValue); - const handleChange = (event: React.ChangeEvent) => { - setChecked(event.target.checked); - props.updateFeature(featureKey, event.target.checked); - }; + const handleChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + props.updateFeature(featureKey, event.target.checked); + }; - return - - - - {featureKey} - - - {featureDescription} - - + return + + + + {featureKey} + + + {featureDescription} + + } export const ExperimentalFeaturesList = () => { - const record = useRecordContext(); - const notify = useNotify(); - const dataProvider = useDataProvider() as SynapseDataProvider; - const [features, setFeatures] = useState({}); - if (!record) { - return null; + const record = useRecordContext(); + const notify = useNotify(); + const dataProvider = useDataProvider() as SynapseDataProvider; + const [features, setFeatures] = useState({}); + if (!record) { + return null; + } + + useEffect(() => { + const fetchFeatures = async () => { + const features = await dataProvider.getFeatures(record.id); + setFeatures(features); } - useEffect(() => { - const fetchFeatures = async () => { - const features = await dataProvider.getFeatures(record.id); - setFeatures(features); - } + fetchFeatures(); + }, []); - fetchFeatures(); - }, []); + const updateFeature = async (feature_name: string, feature_value: boolean) => { + const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel; + setFeatures(updatedFeatures); + const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures); + notify("ra.notification.updated", { + messageArgs: { smart_count: 1 }, + type: "success", + }); + }; - const updateFeature = async (feature_name: string, feature_value: boolean) => { - const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel; - setFeatures(updatedFeatures); - const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures); - notify("ra.notification.updated", { - messageArgs: { smart_count: 1 }, - type: "success", - }); - }; - - return <> - - {Object.keys(features).map((featureKey: string) => - - )} - - + return <> + + {Object.keys(features).map((featureKey: string) => + + )} + + } diff --git a/src/components/UserRateLimits.tsx b/src/components/UserRateLimits.tsx new file mode 100644 index 0000000..30925df --- /dev/null +++ b/src/components/UserRateLimits.tsx @@ -0,0 +1,90 @@ +import { Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; +import { TextField } from "@mui/material"; +import { useFormContext } from "react-hook-form"; + +const RateLimitRow = ({ limit, value, updateRateLimit }: { limit: string, value: number, updateRateLimit: (limit: string, value: number) => void }) => { + const translate = useTranslate(); + + const handleChange = (event: React.ChangeEvent) => { + updateRateLimit(limit, parseInt(event.target.value)); + }; + + return + + + + {translate(`resources.users.limits.${limit}_text`)} + + + +} + +export const UserRateLimits = () => { + const translate = useTranslate(); + const notify = useNotify(); + const record = useRecordContext(); + const form = useFormContext(); + const dataProvider = useDataProvider(); + const [rateLimits, setRateLimits] = useState({ + messages_per_second: 0, + burst_count: 0, + }); + + if (!record) { + return null; + } + + useEffect(() => { + const fetchRateLimits = async () => { + const rateLimits = await dataProvider.getRateLimits(record.id); + if (Object.keys(rateLimits).length > 0) { + setRateLimits(rateLimits); + } + } + + fetchRateLimits(); + }, []); + + const updateRateLimit = async (limit: string, value: number) => { + let updatedRateLimits = { ...rateLimits, [limit]: value }; + setRateLimits(updatedRateLimits); + form.setValue(`rates.${limit}`, value, { shouldDirty: true }); + }; + + return <> + + {Object.keys(rateLimits).map((limit: string) => + + )} + + +}; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 71c991f..3020f82 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -55,7 +55,7 @@ const de: SynapseTranslationMessages = { }, users: { invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.", - tabs: { sso: "SSO" }, + tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits" }, }, rooms: { details: "Raumdetails", @@ -192,6 +192,12 @@ const de: SynapseTranslationMessages = { redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)", generate_password: "Passwort generieren", }, + limits: { + messages_per_second: "Nachrichten pro Sekunde", + messages_per_second_text: "Die Anzahl der Aktionen, die in einer Sekunde durchgeführt werden können. 0 bedeutet, dass die Rate-Limitierung für diesen Benutzer deaktiviert ist.", + burst_count: "Burst-Anzahl", + burst_count_text: "Die Anzahl der Aktionen, die vor der Begrenzung durchgeführt werden können.", + } }, rooms: { name: "Raum |||| Räume", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0224e52..f5185b5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -25,7 +25,11 @@ const en: SynapseTranslationMessages = { }, users: { invalid_user_id: "Localpart of a Matrix user-id without homeserver.", - tabs: { sso: "SSO" }, + tabs: { + sso: "SSO", + experimental: "Experimental", + limits: "Rate Limits", + }, }, rooms: { details: "Room details", @@ -161,6 +165,12 @@ const en: SynapseTranslationMessages = { redact_events: "Redact all events sent by the user(-s)", generate_password: "Generate password", }, + limits: { + messages_per_second: "Messages per second", + messages_per_second_text: "The number of actions that can be performed in a second. 0 mean that ratelimiting is disabled for this user", + burst_count: "Burst count", + burst_count_text: "How many actions that can be performed before being limited.", + } }, rooms: { name: "Room |||| Rooms", diff --git a/src/i18n/fa.ts b/src/i18n/fa.ts index e4e4faf..db98252 100644 --- a/src/i18n/fa.ts +++ b/src/i18n/fa.ts @@ -24,7 +24,7 @@ const fa: SynapseTranslationMessages = { }, users: { invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.", - tabs: { sso: "SSO" }, + tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها" }, }, rooms: { tabs: { @@ -157,6 +157,12 @@ const fa: SynapseTranslationMessages = { redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)", generate_password: "توليد رمز عبور", }, + limits: { + messages_per_second: "پیام در ثانیه", + messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود. 0 به معنای غیرفعال کردن محدودیت برای این کاربر است.", + burst_count: "تعداد پیچیدگی", + burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.", + } }, rooms: { name: "اتاق |||| اتاق ها", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 1493f1d..919f649 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -24,7 +24,7 @@ const fr: SynapseTranslationMessages = { }, users: { invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.", - tabs: { sso: "Authentification unique" }, + tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites" }, }, rooms: { tabs: { @@ -159,6 +159,12 @@ const fr: SynapseTranslationMessages = { redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)", generate_password: "Générer un mot de passe", }, + limits: { + messages_per_second: "Messages par seconde", + messages_per_second_text: "Le nombre d'actions que l'utilisateur peut effectuer par seconde. 0 signifie que la limitation est désactivée pour cet utilisateur.", + burst_count: "Compteur de pics", + burst_count_text: "Le nombre d'actions que l'utilisateur peut effectuer avant d'être limité.", + } }, rooms: { name: "Salon |||| Salons", diff --git a/src/i18n/index.d.ts b/src/i18n/index.d.ts index a95ca27..f41e560 100644 --- a/src/i18n/index.d.ts +++ b/src/i18n/index.d.ts @@ -22,7 +22,7 @@ interface SynapseTranslationMessages extends TranslationMessages { }; users: { invalid_user_id: string; - tabs: { sso: string }; + tabs: { sso: string; experimental: string; limits: string; }; }; rooms: { details?: string; // TODO: fa, fr, it, zh @@ -157,6 +157,12 @@ interface SynapseTranslationMessages extends TranslationMessages { redact_events: string; generate_password: string; }; + limits: { + messages_per_second: string; + messages_per_second_text: string; + burst_count: string; + burst_count_text: string; + }; }; rooms: { name: string; diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 8014cbc..14609ae 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -24,7 +24,7 @@ const it: SynapseTranslationMessages = { }, users: { invalid_user_id: "ID utente non valido su questo homeserver.", - tabs: { sso: "SSO" }, + tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti" }, }, rooms: { tabs: { @@ -158,6 +158,12 @@ const it: SynapseTranslationMessages = { redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)", generate_password: "Genera password", }, + limits: { + messages_per_second: "Messaggi al secondo", + messages_per_second_text: "Il numero di azioni che l'utente può eseguire al secondo. 0 significa che la limitazione è disabilitata per questo utente.", + burst_count: "Burst-conteggio", + burst_count_text: "Il numero di azioni che l'utente può eseguire prima di essere limitato.", + } }, rooms: { name: "Stanza |||| Stanze", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 6e476b2..15477ca 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -50,7 +50,7 @@ const ru: SynapseTranslationMessages = { }, users: { invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.", - tabs: { sso: "SSO" }, + tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения" }, }, rooms: { details: "Данные комнаты", @@ -195,7 +195,13 @@ const ru: SynapseTranslationMessages = { redact_events: "Удаление всех событий, отправленных пользователем (-ами)", generate_password: "Сгенерировать пароль", }, - }, + limits: { + messages_per_second: "Сообщений в секунду", + messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду. 0 означает, что ограничение на количество действий отключено для этого пользователя.", + burst_count: "Burst-счётчик", + burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.", + } + }, rooms: { name: "Комната |||| Комнаты", fields: { diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3532861..780ca33 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -52,7 +52,7 @@ const zh: SynapseTranslationMessages = { }, users: { invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver", - tabs: { sso: "SSO" }, + tabs: { sso: "SSO", experimental: "实验性", limits: "限制" }, }, rooms: { tabs: { @@ -182,6 +182,12 @@ const zh: SynapseTranslationMessages = { redact_events: "重新编辑用户(-s)发送的所有事件", generate_password: "生成密码", }, + limits: { + messages_per_second: "每秒消息数", + messages_per_second_text: "每秒可以执行的操作数。0 表示禁用此用户的限制。", + burst_count: "Burst-计数", + burst_count_text: "在限制之前可以执行的操作数。", + } }, rooms: { name: "房间", diff --git a/src/resources/users.tsx b/src/resources/users.tsx index 4ec180d..fdd04d2 100644 --- a/src/resources/users.tsx +++ b/src/resources/users.tsx @@ -8,9 +8,10 @@ import PermMediaIcon from "@mui/icons-material/PermMedia"; import PersonPinIcon from "@mui/icons-material/PersonPin"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import ScienceIcon from "@mui/icons-material/Science"; +import LockClockIcon from '@mui/icons-material/LockClock'; import ViewListIcon from "@mui/icons-material/ViewList"; import { useEffect, useState } from "react"; -import { Alert, Switch, Stack, Typography } from "@mui/material"; +import { Alert } from "@mui/material"; import { ArrayInput, ArrayField, @@ -72,6 +73,7 @@ import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../comp import { generateRandomPassword } from "../synapse/synapse"; import { useFormContext } from "react-hook-form"; import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures"; +import { UserRateLimits } from "../components/UserRateLimits"; const choices_medium = [ { id: "email", name: "resources.users.email" }, @@ -476,9 +478,13 @@ export const UserEdit = (props: EditProps) => { - } path="experimental"> + } path="experimental"> + + } path="limits"> + + ); diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index 7bff99f..afb481e 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -254,10 +254,17 @@ export interface ExperimentalFeaturesModel { }; } +export interface RateLimitsModel { + messages_per_second?: number; + burst_count?: number; +} + export interface SynapseDataProvider extends DataProvider { deleteMedia: (params: DeleteMediaParams) => Promise; uploadMedia: (params: UploadMediaParams) => Promise; updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise; + getRateLimits: (id: Identifier) => Promise; + setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise; } const resourceMap = { @@ -816,6 +823,17 @@ const baseDataProvider: SynapseDataProvider = { const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`; await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) }); }, + getRateLimits: async (id: Identifier) => { + const base_url = storage.getItem("base_url"); + const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`; + const { json } = await jsonClient(endpoint_url); + return json as RateLimitsModel; + }, + setRateLimits: async (id: Identifier, rateLimits: RateLimitsModel) => { + const base_url = storage.getItem("base_url"); + const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`; + await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(rateLimits) }); + }, }; const dataProvider = withLifecycleCallbacks(baseDataProvider, [ @@ -824,6 +842,12 @@ const dataProvider = withLifecycleCallbacks(baseDataProvider, [ beforeUpdate: async (params: UpdateParams, dataProvider: DataProvider) => { const avatarFile = params.data.avatar_file?.rawFile; const avatarErase = params.data.avatar_erase; + const rates = params.data.rates; + + if (rates) { + await dataProvider.setRateLimits(params.id, rates); + delete params.data.rates; + } if (avatarErase) { params.data.avatar_url = "";