fix: admin/logs: block button not supporting all netmasks

Massimo Melina committed Feb 21, 2025 at 15:08 UTC 2a92a595b724ea0bac55d817e4142a096f3f15e1
7 files changed +69 -46
admin/src/LogsPage.ts
+2 -3
@@ -18,7 +18,7 @@ import { AutoDelete, ClearAll, Delete, Download, Settings, SmartToy } from '@mui
18 import { ConfigForm } from './ConfigForm'
19 import { BoolField, SelectField } from '@hfs/mui-grid-form'
20 import { toast, useDialogBarColors } from './dialog'
21 -import { useBlockIp } from './useBlockIp'
21 +import { BlockIpBtn } from './blockIp';
22 import { ALL as COUNTRIES } from './countries'
23
24 const logLabels = {
@@ -154,7 +154,6 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
154 : showApi || list?.[0]?.uri === undefined ? list
155 : list.filter(x => !x.uri.startsWith(API_URL)),
156 [list, showApi, filter])
157 - const blockIp = useBlockIp()
157 const isConsole = file === 'console'
158 return hidden ? null : h(DataTable, {
159 persist: 'log_' + file,
@@ -164,7 +163,7 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
163 compact: true,
164 actionsProps: { hideUnder: 'md' },
165 actions: isConsole ? undefined : (({ row }) => onlyTruthy([
167 - blockIp.iconBtn(row.ip, "From log"),
166 + h(BlockIpBtn, { ip: row.ip, comment: "From log" }),
167 isIps && h(Btn, {
168 icon: Delete,
169 confirm: true,
admin/src/MonitorPage.ts
+2 -3
@@ -17,7 +17,7 @@ import { Field, SelectField } from '@hfs/mui-grid-form'
17 import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
18 import { agentIcons, LogFile } from './LogsPage'
19 import { state, useSnapState } from './state'
20 -import { useBlockIp } from './useBlockIp'
20 +import { BlockIpBtn } from './blockIp'
21 import { alertDialog, confirmDialog, toast } from './dialog'
22 import { useInterval } from 'usehooks-ts'
23 import { PageProps } from './App'
@@ -119,7 +119,6 @@ function Connections() {
119 const rows = useMemo(() =>
120 list?.filter((x: any) => !monitorOnlyFiles || x.op).map((x: any, id: number) => ({ id, ...x })),
121 [!pause && list, monitorOnlyFiles]) //eslint-disable-line
122 - const blockIp = useBlockIp()
122 const logAble = useBreakpoint('md')
123 const [wantLog, wantLogButton] = useToggleButton("Show log", "Hide log", v => ({
124 icon: History,
@@ -264,7 +263,7 @@ function Connections() {
263 doneMessage: true,
264 onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])).then(x => x.result > 0)
265 }),
267 - blockIp.iconBtn(row.ip, "From monitoring", { disabled: row.ip === props?.you }),
266 + h(BlockIpBtn, { ip: row.ip, comment: "From monitoring", disabled: row.ip === props?.you }),
267 ]
268 }),
269 ),
admin/src/blockIp.ts new
+26
@@ -0,0 +1,26 @@
1 +import { apiCall } from '@hfs/shared/api'
2 +import { createElement as h } from 'react'
3 +import { toast } from './dialog'
4 +import { IconBtn, IconBtnProps } from './mui'
5 +import { Block } from '@mui/icons-material'
6 +import { useBatch } from '@hfs/shared'
7 +
8 +export function BlockIpBtn({ ip, comment, ...rest }: { ip: string, comment: string } & Partial<IconBtnProps>) {
9 + const { data, refresh } = useBatch(isIpBlocked, ip, { delay: 100 })
10 + return h(IconBtn, {
11 + icon: Block,
12 + title: "Block IP",
13 + confirm: "Block address " + ip,
14 + ...data && { disabled: true, title: "Blocked" },
15 + ...rest,
16 + async onClick() {
17 + await apiCall('add_block', { ip, merge: { comment } })
18 + refresh()
19 + toast("Blocked", 'success')
20 + }
21 + })
22 +}
23 +
24 +async function isIpBlocked(ips: string[]) {
25 + return apiCall('is_ip_blocked', { ips }).then(x => x.blocked)
26 +}
admin/src/useBlockIp.ts deleted
-23
@@ -1,23 +0,0 @@
1 -import { apiCall, useApi } from '@hfs/shared/api'
2 -import { createElement as h, useCallback } from 'react'
3 -import { toast } from './dialog'
4 -import { IconBtn, IconBtnProps } from './mui'
5 -import { Block } from '@mui/icons-material'
6 -
7 -export function useBlockIp() {
8 - const { data, reload } = useApi('get_config', { only: ['block'] })
9 - const isBlocked = useCallback((ip: string) => data?.block?.find((x: any) => x.ip.includes(ip) && !x.ip.startsWith('!')), [data]) //TODO have a gui version of netMatches, and use that
10 - return {
11 - iconBtn: (ip: string, comment: string, options: Partial<IconBtnProps>={}) => h(IconBtn, {
12 - icon: Block,
13 - title: "Block IP",
14 - confirm: "Block address " + ip,
15 - ...isBlocked(ip) && { disabled: true, title: "Blocked" },
16 - ...options,
17 - onClick() {
18 - return apiCall('add_block', { ip, merge: { comment } })
19 - .then(reload).then(() => toast("Blocked", 'success'))
20 - },
21 - }),
22 - }
23 -}
shared/react.ts
+30 -15
@@ -47,7 +47,7 @@ export function useRequestRender() {
47 /* the idea is that you need a job done by a worker, but the worker will execute only after it collected jobs for some time
48 by other "users" of the same worker, like other instances of the same component, but potentially also different components.
49 User of this hook will just be returned with the single result of its own job.
50 - As an additional feature, results are cached. You can clear the cache by calling cache.clear()
50 + As an additional feature, results are cached, but you can refresh()
51 */
52 export function useBatch<Job=unknown,Result=unknown>(
53 worker: Falsy | ((jobs: Job[]) => Promise<Result[]>),
@@ -69,22 +69,37 @@ export function useBatch<Job=unknown,Result=unknown>(
69 useEffect(() => {
70 worker && (env.waiter ||= new Promise<void>(resolve => {
71 setTimeout(async () => {
72 - if (!env.batch.size)
73 - return resolve()
74 - const jobs = [...env.batch.values()]
75 - env.batch.clear()
76 - const res = await worker(jobs)
77 - jobs.forEach((job, i) =>
78 - env.cache.set(job, res[i] ?? null) )
79 - env.waiter = undefined
80 - resolve()
72 + try {
73 + if (!env.batch.size)
74 + return
75 + const jobs = [...env.batch.values()]
76 + env.batch.clear()
77 + worker(jobs).then(res => {
78 + jobs.forEach((job, i) =>
79 + env.cache.set(job, res[i] ?? null) )
80 + }).finally(resolve)
81 + }
82 + finally {
83 + env.waiter = undefined
84 + }
85 }, delay)
82 - })).then(requestRender)
83 - }, [worker])
86 + })).then(requestRender) // all instances share the same 'waiter', but each instance will call its own 'requestRender'
87 + }, [worker, requestRender.state])
88 const cached = env && env.cache.get(job) // don't use ?. as env can be falsy
85 - if (env && cached === undefined)
86 - env.batch.add(job)
87 - return { data: cached, ...env } as Env & { data: Result | undefined | null } // so you can cache.clear
89 + useEffect(() => {
90 + if (env && cached === undefined) {
91 + requestRender()
92 + env.batch.add(job)
93 + }
94 + }, [job, cached])
95 + return {
96 + data: cached,
97 + refresh() {
98 + if (!env) return
99 + env.batch.add(job)
100 + requestRender()
101 + }
102 + }
103 }
104
105 export function KeepInScreen({ margin, ...props }: any) {
src/adminApis.ts
+8 -1
@@ -33,7 +33,7 @@ import { ip2country } from './geo'
33 import { roots } from './roots'
34 import { SendListReadable } from './SendList'
35 import { get_dynamic_dns_error } from './ddns'
36 -import { addBlock, BlockingRule } from './block'
36 +import { addBlock, BlockingRule, isBlocked } from './block'
37 import { alerts, blacklistedInstalledPlugins, getProjectInfo } from './github'
38 import { acmeRenewError } from './acme'
39
@@ -96,6 +96,13 @@ export const adminApis = {
96 codes: res.map(x => x.status === 'rejected' || x.value === '-' ? '' : x.value)
97 }
98 },
99 + is_ip_blocked({ ips }) {
100 + apiAssertTypes({
101 + array: { ips },
102 + string: { ips0: ips[0] }
103 + })
104 + return { blocked: ips.map((x: string) => isBlocked(x) ? 1 : 0) }
105 + },
106
107 get_custom_html() {
108 return {
src/block.ts
+1 -1
@@ -26,7 +26,7 @@ export function applyBlock(socket: Socket, ip=normalizeIp(socket.remoteAddress||
26 return disconnect(socket, 'block-ip')
27 }
28
29 -function isBlocked(ip: string) {
29 +export function isBlocked(ip: string) {
30 return block.compiled().find(rule => rule(ip))
31 }
32