Add User Rate Limits tab (#125)

* Add User Rate Limits tab

* update readme
This commit is contained in:
Borislav Pantaleev 2024-11-06 23:55:14 +02:00 committed by GitHub
parent a04b24a5d5
commit 9adc13e722
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 256 additions and 83 deletions

View file

@ -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) * 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 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 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_ _the list will be updated as new changes are added_

View file

@ -10,86 +10,86 @@ const experimentalFeaturesMap = {
msc3575: "enable experimental sliding sync support", msc3575: "enable experimental sliding sync support",
}; };
const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => { const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => {
const featureKey = props.featureKey; const featureKey = props.featureKey;
const featureValue = props.featureValue; const featureValue = props.featureValue;
const featureDescription = experimentalFeaturesMap[featureKey] ?? ""; const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
const [checked, setChecked] = useState(featureValue); const [checked, setChecked] = useState(featureValue);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked); setChecked(event.target.checked);
props.updateFeature(featureKey, event.target.checked); props.updateFeature(featureKey, event.target.checked);
}; };
return <Stack return <Stack
direction="row" direction="row"
spacing={2} spacing={2}
alignItems="start" alignItems="start"
sx={{ sx={{
padding: 2, padding: 2,
}} }}
> >
<Switch checked={checked} onChange={handleChange} /> <Switch checked={checked} onChange={handleChange} />
<Stack> <Stack>
<Typography <Typography
variant="subtitle1" variant="subtitle1"
sx={{ sx={{
fontWeight: "medium", fontWeight: "medium",
color: "text.primary" color: "text.primary"
}} }}
> >
{featureKey} {featureKey}
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
> >
{featureDescription} {featureDescription}
</Typography> </Typography>
</Stack>
</Stack> </Stack>
</Stack>
} }
export const ExperimentalFeaturesList = () => { export const ExperimentalFeaturesList = () => {
const record = useRecordContext(); const record = useRecordContext();
const notify = useNotify(); const notify = useNotify();
const dataProvider = useDataProvider() as SynapseDataProvider; const dataProvider = useDataProvider() as SynapseDataProvider;
const [features, setFeatures] = useState({}); const [features, setFeatures] = useState({});
if (!record) { if (!record) {
return null; return null;
}
useEffect(() => {
const fetchFeatures = async () => {
const features = await dataProvider.getFeatures(record.id);
setFeatures(features);
} }
useEffect(() => { fetchFeatures();
const fetchFeatures = async () => { }, []);
const features = await dataProvider.getFeatures(record.id);
setFeatures(features);
}
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) => { return <>
const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel; <Stack
setFeatures(updatedFeatures); direction="column"
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures); spacing={1}
notify("ra.notification.updated", { >
messageArgs: { smart_count: 1 }, {Object.keys(features).map((featureKey: string) =>
type: "success", <ExperimentalFeatureRow
}); key={featureKey}
}; featureKey={featureKey}
featureValue={features[featureKey]}
return <> updateFeature={updateFeature}
<Stack />
direction="column" )}
spacing={1} </Stack>
> </>
{Object.keys(features).map((featureKey: string) =>
<ExperimentalFeatureRow
key={featureKey}
featureKey={featureKey}
featureValue={features[featureKey]}
updateFeature={updateFeature}
/>
)}
</Stack>
</>
} }

View file

@ -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<HTMLInputElement>) => {
updateRateLimit(limit, parseInt(event.target.value));
};
return <Stack
spacing={1}
alignItems="start"
sx={{
padding: 2,
}}
>
<TextField
id="outlined-number"
type="number"
value={value}
onChange={handleChange}
slotProps={{
inputLabel: {
shrink: true,
},
}}
label={translate(`resources.users.limits.${limit}`)}
/>
<Stack>
<Typography
variant="body2"
color="text.secondary"
>
{translate(`resources.users.limits.${limit}_text`)}
</Typography>
</Stack>
</Stack>
}
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 <>
<Stack
direction="column"
>
{Object.keys(rateLimits).map((limit: string) =>
<RateLimitRow
key={limit}
limit={limit}
value={rateLimits[limit]}
updateRateLimit={updateRateLimit}
/>
)}
</Stack>
</>
};

View file

@ -55,7 +55,7 @@ const de: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.", invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
tabs: { sso: "SSO" }, tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits" },
}, },
rooms: { rooms: {
details: "Raumdetails", details: "Raumdetails",
@ -192,6 +192,12 @@ const de: SynapseTranslationMessages = {
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)", redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
generate_password: "Passwort generieren", 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: { rooms: {
name: "Raum |||| Räume", name: "Raum |||| Räume",

View file

@ -25,7 +25,11 @@ const en: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "Localpart of a Matrix user-id without homeserver.", invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
tabs: { sso: "SSO" }, tabs: {
sso: "SSO",
experimental: "Experimental",
limits: "Rate Limits",
},
}, },
rooms: { rooms: {
details: "Room details", details: "Room details",
@ -161,6 +165,12 @@ const en: SynapseTranslationMessages = {
redact_events: "Redact all events sent by the user(-s)", redact_events: "Redact all events sent by the user(-s)",
generate_password: "Generate password", 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: { rooms: {
name: "Room |||| Rooms", name: "Room |||| Rooms",

View file

@ -24,7 +24,7 @@ const fa: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.", invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
tabs: { sso: "SSO" }, tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها" },
}, },
rooms: { rooms: {
tabs: { tabs: {
@ -157,6 +157,12 @@ const fa: SynapseTranslationMessages = {
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)", redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
generate_password: "توليد رمز عبور", generate_password: "توليد رمز عبور",
}, },
limits: {
messages_per_second: "پیام در ثانیه",
messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود. 0 به معنای غیرفعال کردن محدودیت برای این کاربر است.",
burst_count: "تعداد پیچیدگی",
burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.",
}
}, },
rooms: { rooms: {
name: "اتاق |||| اتاق ها", name: "اتاق |||| اتاق ها",

View file

@ -24,7 +24,7 @@ const fr: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.", invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
tabs: { sso: "Authentification unique" }, tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites" },
}, },
rooms: { rooms: {
tabs: { tabs: {
@ -159,6 +159,12 @@ const fr: SynapseTranslationMessages = {
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)", redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
generate_password: "Générer un mot de passe", 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: { rooms: {
name: "Salon |||| Salons", name: "Salon |||| Salons",

8
src/i18n/index.d.ts vendored
View file

@ -22,7 +22,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
}; };
users: { users: {
invalid_user_id: string; invalid_user_id: string;
tabs: { sso: string }; tabs: { sso: string; experimental: string; limits: string; };
}; };
rooms: { rooms: {
details?: string; // TODO: fa, fr, it, zh details?: string; // TODO: fa, fr, it, zh
@ -157,6 +157,12 @@ interface SynapseTranslationMessages extends TranslationMessages {
redact_events: string; redact_events: string;
generate_password: string; generate_password: string;
}; };
limits: {
messages_per_second: string;
messages_per_second_text: string;
burst_count: string;
burst_count_text: string;
};
}; };
rooms: { rooms: {
name: string; name: string;

View file

@ -24,7 +24,7 @@ const it: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "ID utente non valido su questo homeserver.", invalid_user_id: "ID utente non valido su questo homeserver.",
tabs: { sso: "SSO" }, tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti" },
}, },
rooms: { rooms: {
tabs: { tabs: {
@ -158,6 +158,12 @@ const it: SynapseTranslationMessages = {
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)", redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
generate_password: "Genera password", 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: { rooms: {
name: "Stanza |||| Stanze", name: "Stanza |||| Stanze",

View file

@ -50,7 +50,7 @@ const ru: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.", invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
tabs: { sso: "SSO" }, tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения" },
}, },
rooms: { rooms: {
details: "Данные комнаты", details: "Данные комнаты",
@ -195,7 +195,13 @@ const ru: SynapseTranslationMessages = {
redact_events: "Удаление всех событий, отправленных пользователем (-ами)", redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
generate_password: "Сгенерировать пароль", generate_password: "Сгенерировать пароль",
}, },
}, limits: {
messages_per_second: "Сообщений в секунду",
messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду. 0 означает, что ограничение на количество действий отключено для этого пользователя.",
burst_count: "Burst-счётчик",
burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.",
}
},
rooms: { rooms: {
name: "Комната |||| Комнаты", name: "Комната |||| Комнаты",
fields: { fields: {

View file

@ -52,7 +52,7 @@ const zh: SynapseTranslationMessages = {
}, },
users: { users: {
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver", invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
tabs: { sso: "SSO" }, tabs: { sso: "SSO", experimental: "实验性", limits: "限制" },
}, },
rooms: { rooms: {
tabs: { tabs: {
@ -182,6 +182,12 @@ const zh: SynapseTranslationMessages = {
redact_events: "重新编辑用户(-s发送的所有事件", redact_events: "重新编辑用户(-s发送的所有事件",
generate_password: "生成密码", generate_password: "生成密码",
}, },
limits: {
messages_per_second: "每秒消息数",
messages_per_second_text: "每秒可以执行的操作数。0 表示禁用此用户的限制。",
burst_count: "Burst-计数",
burst_count_text: "在限制之前可以执行的操作数。",
}
}, },
rooms: { rooms: {
name: "房间", name: "房间",

View file

@ -8,9 +8,10 @@ import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin"; import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ScienceIcon from "@mui/icons-material/Science"; import ScienceIcon from "@mui/icons-material/Science";
import LockClockIcon from '@mui/icons-material/LockClock';
import ViewListIcon from "@mui/icons-material/ViewList"; import ViewListIcon from "@mui/icons-material/ViewList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Alert, Switch, Stack, Typography } from "@mui/material"; import { Alert } from "@mui/material";
import { import {
ArrayInput, ArrayInput,
ArrayField, ArrayField,
@ -72,6 +73,7 @@ import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../comp
import { generateRandomPassword } from "../synapse/synapse"; import { generateRandomPassword } from "../synapse/synapse";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures"; import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures";
import { UserRateLimits } from "../components/UserRateLimits";
const choices_medium = [ const choices_medium = [
{ id: "email", name: "resources.users.email" }, { id: "email", name: "resources.users.email" },
@ -476,9 +478,13 @@ export const UserEdit = (props: EditProps) => {
</ReferenceManyField> </ReferenceManyField>
</FormTab> </FormTab>
<FormTab label="Experimental" icon={<ScienceIcon />} path="experimental"> <FormTab label="synapseadmin.users.tabs.experimental" icon={<ScienceIcon />} path="experimental">
<ExperimentalFeaturesList /> <ExperimentalFeaturesList />
</FormTab> </FormTab>
<FormTab label="synapseadmin.users.tabs.limits" icon={<LockClockIcon />} path="limits">
<UserRateLimits />
</FormTab>
</TabbedForm> </TabbedForm>
</Edit> </Edit>
); );

View file

@ -254,10 +254,17 @@ export interface ExperimentalFeaturesModel {
}; };
} }
export interface RateLimitsModel {
messages_per_second?: number;
burst_count?: number;
}
export interface SynapseDataProvider extends DataProvider { export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>; uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>; updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
getRateLimits: (id: Identifier) => Promise<RateLimitsModel>;
setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>;
} }
const resourceMap = { const resourceMap = {
@ -816,6 +823,17 @@ const baseDataProvider: SynapseDataProvider = {
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`; const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) }); 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, [ const dataProvider = withLifecycleCallbacks(baseDataProvider, [
@ -824,6 +842,12 @@ const dataProvider = withLifecycleCallbacks(baseDataProvider, [
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => { beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
const avatarFile = params.data.avatar_file?.rawFile; const avatarFile = params.data.avatar_file?.rawFile;
const avatarErase = params.data.avatar_erase; 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) { if (avatarErase) {
params.data.avatar_url = ""; params.data.avatar_url = "";