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