admin/internet: Geo IP section
Massimo Melina committed
Nov 7, 2023 at 18:30 UTC
d2b3bd15a1f2b9b20d996585ce115bbee66a7c0c
15 files changed
+564
-104
README.md
+1
@@ -40,6 +40,7 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2).
40
- real-time monitoring of connections
41
- [show some files](https://github.com/rejetto/hfs/discussions/270)
42
- speed throttler
43
+- geographic firewall
44
- admin web interface
45
- multi-language front-end
46
- virtual hosting (plug-in)
admin/src/DataTable.ts
+3
-3
@@ -10,6 +10,7 @@ const ACTIONS = 'Actions'
10
11
interface DataTableProps<R extends GridValidRowModel=any> extends Omit<DataGridProps<R>, 'columns'> {
12
columns: Array<GridColDef<R> & {
13
+ hidden?: boolean
14
hideUnder?: Breakpoint | number
15
mergeRender?: { other: string, override?: Partial<GridColDef<R>> } & BoxProps
16
}>
@@ -61,9 +62,8 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
62
}, [columns, actions, actionsLength])
63
const hideCols = useMemo(() => {
64
if (!width) return
64
- const fields = onlyTruthy(manipulatedColumns.map(({ field, hideUnder }) =>
65
- hideUnder
66
- && width < (typeof hideUnder === 'number' ? hideUnder : theme.breakpoints.values[hideUnder])
65
+ const fields = onlyTruthy(manipulatedColumns.map(({ field, hideUnder, hidden }) =>
66
+ (hidden || hideUnder && width < (typeof hideUnder === 'number' ? hideUnder : theme.breakpoints.values[hideUnder]))
67
&& field))
68
const o = Object.fromEntries(fields.map(x => [x, false]))
69
_.merge(initialState, { columns: { columnVisibilityModel: o } })
admin/src/InternetPage.ts
+151
-62
@@ -1,15 +1,21 @@
1
-import { createElement as h, useEffect, useState } from 'react'
1
+import { createElement as h, ReactNode, useEffect, useMemo, useState } from 'react'
2
import { Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link } from '@mui/material'
3
-import { CardMembership, HomeWorkTwoTone, Lock, PublicTwoTone, RouterTwoTone, Send } from '@mui/icons-material'
3
+import { CardMembership, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RestartAlt, RouterTwoTone, Send,
4
+ SvgIconComponent } from '@mui/icons-material'
5
import { apiCall, useApiEx } from './api'
6
import { closeDialog, DAY, formatTimestamp, wait, wantArray, with_ } from '@hfs/shared'
6
-import { Flex, LinkBtn, isIP, Btn } from './misc'
7
+import { Flex, LinkBtn, isIP, Btn, modifiedSx, IconBtn, CFG } from './misc'
8
import { alertDialog, confirmDialog, promptDialog, toast, waitDialog } from './dialog'
8
-import { BoolField, Form, NumberField } from '@hfs/mui-grid-form'
9
+import { BoolField, Form, FormProps, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
10
import md from './md'
11
import { isCertError } from './OptionsPage'
12
import { changeBaseUrl } from './FileForm'
13
import { getNatInfo } from '../../src/nat'
14
+import { ALL, WITH_IP } from './countries'
15
+import _ from 'lodash'
16
+import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
17
+
18
+const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code))
19
20
const PORT_FORWARD_URL = 'https://portforward.com/'
21
const HIGHER_PORT = 1080
@@ -21,7 +27,7 @@ export default function InternetPage() {
27
const [mapping, setMapping] = useState(false)
28
const [verifyAgain, setVerifyAgain] = useState(false)
29
const status = useApiEx('get_status')
24
- const { data: config, reload: reloadConfig } = useApiEx('get_config', { only: ['base_url'] })
30
+ const config = useApiEx('get_config', { only: ['base_url'] })
31
const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
32
h && s ? 'error' : h || s ? 'warning' : 'success')
33
type GetNat = Awaited<ReturnType<typeof getNatInfo>>
@@ -39,68 +45,110 @@ export default function InternetPage() {
45
baseUrlBox(),
46
networkBox(),
47
httpsBox(),
48
+ geoBox(),
49
)
50
51
+ function geoBox() {
52
+ const countryOptions = useMemo(() => _.sortBy(COUNTRIES, 'name').map(x => ({
53
+ value: x.code,
54
+ label: h('span', { style: { whiteSpace: 'nowrap' } }, `${x.flag} ${x.name}`)
55
+ })), [COUNTRIES])
56
+ return h(TitleCard, { title: "Geo IP", icon: Public },
57
+ h(ConfigForm<{
58
+ [CFG.geo_enable]: boolean
59
+ [CFG.geo_allow]: null | boolean
60
+ [CFG.geo_list]: string[]
61
+ [CFG.geo_allow_unknown]: boolean
62
+ }>, {
63
+ keys: [ CFG.geo_enable, CFG.geo_allow, CFG.geo_list, CFG.geo_allow_unknown ],
64
+ form: values => ({ fields: [
65
+ { k: CFG.geo_enable, comp: BoolField, label: "Enable", helperText: "Necessary database will be downloaded every month (2MB)" },
66
+ ...!values[CFG.geo_enable] ? [] : [
67
+ {
68
+ k: CFG.geo_allow,
69
+ comp: SelectField,
70
+ label: "Rule",
71
+ options: { "no restriction": null, "block selected countries": false, "allow selected countries": true },
72
+ },
73
+ values[CFG.geo_allow] != null && {
74
+ k: CFG.geo_list,
75
+ comp: MultiSelectField<string>,
76
+ label: `Selected countries (${values[CFG.geo_list]?.length || 0})`,
77
+ placeholder: "none",
78
+ options: countryOptions,
79
+ clearable: true,
80
+ },
81
+ values[CFG.geo_allow] != null && {
82
+ k: CFG.geo_allow_unknown,
83
+ comp: SelectField,
84
+ label: "When country cannot be determined",
85
+ options: { Allow: true, Block: false },
86
+ },
87
+ ]
88
+ ] })
89
+ })
90
+ )
91
+ }
92
+
93
function httpsBox() {
94
const { error, listening } = status.data?.https ||{}
95
const [values, setValues] = useState<any>()
96
const cert = useApiEx('get_cert')
97
useEffect(() => { apiCall('get_config', { only: ['acme_domain', 'acme_email', 'acme_renew'] }).then(setValues) } , [])
98
if (!status || !values) return h(CircularProgress)
50
- return element || status.element || h(Card, {}, h(CardContent, {},
51
- h(Flex, { vert: true },
52
- h(Box, { fontSize: 'x-large' }, "HTTPS",
53
- h(Lock, { color: listening && !error ? 'success' : 'warning', sx: { ml: 1, verticalAlign: 'text-top' } }) ),
54
- isCertError(error) && h(Alert, { severity: 'warning' }, error),
55
- !listening && h(LinkBtn, { onClick: notEnabled }, "Not enabled")
56
- || error && "For HTTPS to work, you need a valid certificate",
57
- cert.element || with_(cert.data, c => h(Box, {}, h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate", h('ul', {},
99
+ return element || status.element || h(TitleCard, { title: "HTTPS", icon: Lock, color: listening && !error ? 'success' : 'warning' },
100
+ isCertError(error) && h(Alert, { severity: 'warning' }, error),
101
+ !listening && h(LinkBtn, { onClick: notEnabled }, "Not enabled")
102
+ || error && "For HTTPS to work, you need a valid certificate",
103
+ cert.element || with_(cert.data, c => h(Box, {},
104
+ h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate",
105
+ h('ul', {},
106
h('li', {}, "Domain: ", c.subject?.CN || '-'),
107
h('li', {}, "Issuer: ", c.issuer?.O || h('i', {}, 'self-signed')),
108
h('li', {}, "Validity: ", ['validFrom', 'validTo'].map(k => formatTimestamp(c[k])).join(' – ')),
61
- ))),
62
- h(Divider),
63
- h(Form, {
64
- gap: 1,
65
- gridProps: {rowSpacing:1},
66
- values,
67
- set(v, k) {
68
- setValues((was: any) => {
69
- const values = { ...was, [k]: v }
70
- apiCall('set_config', { values })
71
- return values
72
- })
73
- },
74
- fields: [
75
- md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"),
76
- { k: 'acme_domain', label: "Domain for certificate", sm: 6, required: true, helperText: "example: your.domain.com" },
77
- { k: 'acme_email', label: "E-mail for certificate", sm: 6 },
78
- { k: 'acme_renew', label: "Automatic renew one month before expiration", comp: BoolField, disabled: !values.acme_domain },
79
- ],
80
- save: {
81
- children: "Request",
82
- startIcon: h(Send),
83
- async onClick() {
84
- const domain = values.acme_domain
85
- const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY
86
- if (fresh && !await confirmDialog("Your certificate is still good", { confirmText: "Make a new one anyway" }))
87
- return
88
- if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return
89
- const res = await apiCall('check_domain', { domain }).catch(e =>
90
- confirmDialog(String(e), { confirmText: "Continue anyway" }) )
91
- if (res === false) return
92
- await apiCall('make_cert', { domain, email: values.acme_email }, { timeout: 20_000 })
93
- .then(async () => {
94
- await alertDialog("Certificate created", 'success')
95
- if (!listening)
96
- await notEnabled()
97
- cert.reload()
98
- }, alertDialog)
99
- }
100
- },
101
- })
102
- )
103
- ))
109
+ )
110
+ )),
111
+ h(Divider),
112
+ h(Form, {
113
+ gap: 1,
114
+ gridProps: {rowSpacing:1},
115
+ values,
116
+ set(v, k) {
117
+ setValues((was: any) => {
118
+ const values = { ...was, [k]: v }
119
+ apiCall('set_config', { values })
120
+ return values
121
+ })
122
+ },
123
+ fields: [
124
+ md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"),
125
+ { k: 'acme_domain', label: "Domain for certificate", sm: 6, required: true, helperText: "example: your.domain.com" },
126
+ { k: 'acme_email', label: "E-mail for certificate", sm: 6 },
127
+ { k: 'acme_renew', label: "Automatic renew one month before expiration", comp: BoolField, disabled: !values.acme_domain },
128
+ ],
129
+ save: {
130
+ children: "Request",
131
+ startIcon: h(Send),
132
+ async onClick() {
133
+ const domain = values.acme_domain
134
+ const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY
135
+ if (fresh && !await confirmDialog("Your certificate is still good", { confirmText: "Make a new one anyway" }))
136
+ return
137
+ if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return
138
+ const res = await apiCall('check_domain', { domain }).catch(e =>
139
+ confirmDialog(String(e), { confirmText: "Continue anyway" }) )
140
+ if (res === false) return
141
+ await apiCall('make_cert', { domain, email: values.acme_email }, { timeout: 20_000 })
142
+ .then(async () => {
143
+ await alertDialog("Certificate created", 'success')
144
+ if (!listening)
145
+ await notEnabled()
146
+ cert.reload()
147
+ }, alertDialog)
148
+ }
149
+ },
150
+ })
151
+ )
152
}
153
154
async function notEnabled() {
@@ -115,16 +163,15 @@ export default function InternetPage() {
163
}
164
165
function baseUrlBox() {
118
- const url = config?.base_url
166
+ const url = config.data?.base_url
167
const hostname = url && new URL(url).hostname
168
const domain = !isIP(hostname) && hostname
121
- return config && h(Card, {}, h(CardContent, {},
122
- h(Box, { fontSize: 'x-large', mb: 2 }, "Address / Domain"),
169
+ return config.element || h(TitleCard, { title: "Address / Domain" },
170
h(Flex, { flexWrap: 'wrap' },
171
url || "Automatic, not configured",
172
h(Button, {
173
size: 'small',
127
- onClick() { changeBaseUrl().then(reloadConfig) }
174
+ onClick() { changeBaseUrl().then(config.reload) }
175
}, "Change"),
176
domain && h(Btn, {
177
size: 'small',
@@ -133,7 +180,7 @@ export default function InternetPage() {
180
.then(() => alertDialog("Domain seems ok", 'success'))
181
}, "Check"),
182
)
136
- ))
183
+ )
184
}
185
186
function networkBox() {
@@ -170,7 +217,7 @@ export default function InternetPage() {
217
if (!verifyAgain && !await confirmDialog("This test will check if your server is working properly on the Internet")) return
218
setChecking(true)
219
try {
173
- const url = config.base_url
220
+ const url = config.data?.base_url
221
const urlResult = url && await apiCall('self_check', { url }).catch(() =>
222
alertDialog(md(`Sorry, we couldn't verify your configured address ${url} 😰\nstill, we are going to test your IP address 🤞`), 'warning'))
223
if (urlResult?.success) {
@@ -201,7 +248,9 @@ export default function InternetPage() {
248
toast("Port forwarded, now verify again", 'success')
249
retry()
250
})
251
+ const cfg = await apiCall('get_config', { only: [CFG.geo_enable, CFG.geo_allow] })
252
const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {},
253
+ cfg[CFG.geo_enable] && cfg[CFG.geo_allow] != null && h('li', {}, "You may be blocking a country from where the test is performed"),
254
!nat.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")),
255
h('li', {}, "There could be a firewall, try configuring or disabling it."),
256
(nat.externalPort || nat.internalPort!) <= 1024 && h('li', {},
@@ -284,3 +333,43 @@ function Device({ name, icon, color, ip, below }: any) {
333
below,
334
)
335
}
336
+
337
+function TitleCard({ title, icon, color, children }: { title: ReactNode, icon?: SvgIconComponent, color?: SvgIconProps['color'], children?: ReactNode }) {
338
+ return h(Card, {}, h(CardContent, {}, h(Flex, { vert: true },
339
+ h(Box, { fontSize: 'x-large' }, icon && h(icon, { color, sx: { mr: 1, verticalAlign: 'bottom', mb: '2px' } }), title),
340
+ children
341
+ )))
342
+}
343
+
344
+type FormRest<T> = Omit<FormProps<T>, 'values' | 'set' | 'save'>
345
+function ConfigForm<T=any>({ keys, form, ...rest }: Partial<FormRest<T>> & { keys: (keyof T)[], form: ((values: T) => FormRest<T>) }) {
346
+ const config = useApiEx('get_config', { only: keys })
347
+ const [values, setValues] = useState<any>(config.data)
348
+ useEffect(() => setValues((v: any) => config.data || v), [config.data])
349
+ if (!values)
350
+ return config.element
351
+ const formProps = form(values)
352
+ const modified = !_.isEqual(values, config.data)
353
+ return h(Form, {
354
+ values,
355
+ set(v, k) {
356
+ setValues((was: any) => ({ ...was, [k]: v }))
357
+ },
358
+ save: {
359
+ onClick: () => apiCall('set_config', { values }).then(config.reload),
360
+ sx: modifiedSx(modified),
361
+ },
362
+ ...Array.isArray(formProps) ? { fields: formProps } : formProps,
363
+ ...rest,
364
+ barSx: { gap: 1, ...rest.barSx },
365
+ addToBar: [
366
+ h(IconBtn, {
367
+ icon: RestartAlt,
368
+ disabled: !modified,
369
+ title: "Reset",
370
+ onClick(){ setValues(config.data) }
371
+ }),
372
+ ...rest.addToBar||[],
373
+ ],
374
+ })
375
+}
\ No newline at end of file
admin/src/MonitorPage.ts
+26
-4
@@ -1,15 +1,16 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import _ from "lodash"
4
-import { createElement as h, useMemo, Fragment, useState } from "react"
4
+import { createElement as h, useMemo, Fragment, useState, ReactNode } from "react"
5
import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6
import { PauseCircle, PlayCircle, LinkOff, Lock, Block, FolderZip, Upload, Download } from '@mui/icons-material'
7
-import { Box, Chip, ChipProps } from '@mui/material'
7
+import { Box, Chip, ChipProps, Tooltip } from '@mui/material'
8
import { DataTable } from './DataTable'
9
-import { formatBytes, IconBtn, IconProgress, iconTooltip, ipForUrl, manipulateConfig, useBreakpoint } from "./misc"
9
+import { formatBytes, IconBtn, IconProgress, iconTooltip, ipForUrl, manipulateConfig, useBreakpoint, useBatch, CFG } from "./misc"
10
import { Field, SelectField } from '@hfs/mui-grid-form'
11
import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
12
import { toast } from "./dialog"
13
+import { ALL as COUNTRIES } from './countries'
14
15
export default function MonitorPage() {
16
return h(Fragment, {},
@@ -80,8 +81,22 @@ function MoreInfo() {
81
82
}
83
84
+function Country({ code, ip, def }: { code: string, ip?: string, def: ReactNode }) {
85
+ const { data } = useBatch(code === undefined && ip && ip2countryBatch, ip, { delay: 100 }) // query if necessary
86
+ code ||= data || ''
87
+ const country = code && _.find(COUNTRIES, { code })
88
+ return country ? h(Tooltip, { title: country.name, children: h('span', {}, country.flag, ' ', code ) })
89
+ : h(Fragment, {}, def)
90
+}
91
+
92
+async function ip2countryBatch(ips: string[]) {
93
+ const res = await apiCall('ip_country', { ips })
94
+ return res.codes as string[]
95
+}
96
+
97
function Connections() {
98
const { list, error, props } = useApiList('get_connections')
99
+ const config = useApiEx('get_config', { only: [CFG.geo_enable] })
100
const [filtered, setFiltered] = useState(true)
101
const [paused, setPaused] = useState(false)
102
const rows = useMemo(() =>
@@ -119,6 +134,13 @@ function Connections() {
134
renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
135
mergeRender: { other: 'user', fontSize: 'small' },
136
},
137
+ {
138
+ field: 'country',
139
+ hidden: config.data?.[CFG.geo_enable] !== true,
140
+ headerName: "Country",
141
+ hideUnder: 'md',
142
+ renderCell: ({ value, row }) => h(Country, { code: value, ip: row.ip, def: '-' }),
143
+ },
144
{
145
field: 'user',
146
headerName: "User",
@@ -182,7 +204,7 @@ function Connections() {
204
headerName: "Protocol",
205
align: 'center',
206
hideUnder: Infinity,
185
- renderCell: ({ value, row }) => h(Fragment, {},
207
+ renderCell: ({ value }) => h(Fragment, {},
208
"IPv" + value,
209
iconTooltip(Lock, "HTTPS", { opacity: .5 })
210
)
admin/src/countries.ts
new
+269
@@ -0,0 +1,269 @@
1
+export const ALL = `🇦🇨|AC|Ascension Island
2
+🇦🇩|AD|Andorra
3
+🇦🇪|AE|United Arab Emirates
4
+🇦🇫|AF|Afghanistan
5
+🇦🇬|AG|Antigua & Barbuda
6
+🇦🇮|AI|Anguilla
7
+🇦🇱|AL|Albania
8
+🇦🇲|AM|Armenia
9
+🇦🇴|AO|Angola
10
+🇦🇶|AQ|Antarctica
11
+🇦🇷|AR|Argentina
12
+🇦🇸|AS|American Samoa
13
+🇦🇹|AT|Austria
14
+🇦🇺|AU|Australia
15
+🇦🇼|AW|Aruba
16
+🇦🇽|AX|Åland Islands
17
+🇦🇿|AZ|Azerbaijan
18
+🇧🇦|BA|Bosnia & Herzegovina
19
+🇧🇧|BB|Barbados
20
+🇧🇩|BD|Bangladesh
21
+🇧🇪|BE|Belgium
22
+🇧🇫|BF|Burkina Faso
23
+🇧🇬|BG|Bulgaria
24
+🇧🇭|BH|Bahrain
25
+🇧🇮|BI|Burundi
26
+🇧🇯|BJ|Benin
27
+🇧🇱|BL|St. Barthélemy
28
+🇧🇲|BM|Bermuda
29
+🇧🇳|BN|Brunei
30
+🇧🇴|BO|Bolivia
31
+🇧🇶|BQ|Caribbean Netherlands
32
+🇧🇷|BR|Brazil
33
+🇧🇸|BS|Bahamas
34
+🇧🇹|BT|Bhutan
35
+🇧🇻|BV|Bouvet Island
36
+🇧🇼|BW|Botswana
37
+🇧🇾|BY|Belarus
38
+🇧🇿|BZ|Belize
39
+🇨🇦|CA|Canada
40
+🇨🇨|CC|Cocos (Keeling) Islands
41
+🇨🇩|CD|Congo - Kinshasa
42
+🇨🇫|CF|Central African Republic
43
+🇨🇬|CG|Congo - Brazzaville
44
+🇨🇭|CH|Switzerland
45
+🇨🇮|CI|Côte d'Ivoire
46
+🇨🇰|CK|Cook Islands
47
+🇨🇱|CL|Chile
48
+🇨🇲|CM|Cameroon
49
+🇨🇳|CN|China
50
+🇨🇴|CO|Colombia
51
+🇨🇵|CP|Clipperton Island
52
+🇨🇷|CR|Costa Rica
53
+🇨🇺|CU|Cuba
54
+🇨🇻|CV|Cape Verde
55
+🇨🇼|CW|Curaçao
56
+🇨🇽|CX|Christmas Island
57
+🇨🇾|CY|Cyprus
58
+🇨🇿|CZ|Czechia
59
+🇩🇪|DE|Germany
60
+🇩🇬|DG|Diego Garcia
61
+🇩🇯|DJ|Djibouti
62
+🇩🇰|DK|Denmark
63
+🇩🇲|DM|Dominica
64
+🇩🇴|DO|Dominican Republic
65
+🇩🇿|DZ|Algeria
66
+🇪🇦|EA|Ceuta & Melilla
67
+🇪🇨|EC|Ecuador
68
+🇪🇪|EE|Estonia
69
+🇪🇬|EG|Egypt
70
+🇪🇭|EH|Western Sahara
71
+🇪🇷|ER|Eritrea
72
+🇪🇸|ES|Spain
73
+🇪🇹|ET|Ethiopia
74
+🇫🇮|FI|Finland
75
+🇫🇯|FJ|Fiji
76
+🇫🇰|FK|Falkland Islands
77
+🇫🇲|FM|Micronesia
78
+🇫🇴|FO|Faroe Islands
79
+🇫🇷|FR|France
80
+🇬🇦|GA|Gabon
81
+🇬🇧|GB|United Kingdom
82
+🇬🇩|GD|Grenada
83
+🇬🇪|GE|Georgia
84
+🇬🇫|GF|French Guiana
85
+🇬🇬|GG|Guernsey
86
+🇬🇭|GH|Ghana
87
+🇬🇮|GI|Gibraltar
88
+🇬🇱|GL|Greenland
89
+🇬🇲|GM|Gambia
90
+🇬🇳|GN|Guinea
91
+🇬🇵|GP|Guadeloupe
92
+🇬🇶|GQ|Equatorial Guinea
93
+🇬🇷|GR|Greece
94
+🇬🇸|GS|South Georgia & South Sandwich Islands
95
+🇬🇹|GT|Guatemala
96
+🇬🇺|GU|Guam
97
+🇬🇼|GW|Guinea-Bissau
98
+🇬🇾|GY|Guyana
99
+🇭🇰|HK|Hong Kong SAR China
100
+🇭🇲|HM|Heard & McDonald Islands
101
+🇭🇳|HN|Honduras
102
+🇭🇷|HR|Croatia
103
+🇭🇹|HT|Haiti
104
+🇭🇺|HU|Hungary
105
+🇮🇨|IC|Canary Islands
106
+🇮🇩|ID|Indonesia
107
+🇮🇪|IE|Ireland
108
+🇮🇱|IL|Israel
109
+🇮🇲|IM|Isle of Man
110
+🇮🇳|IN|India
111
+🇮🇴|IO|British Indian Ocean Territory
112
+🇮🇶|IQ|Iraq
113
+🇮🇷|IR|Iran
114
+🇮🇸|IS|Iceland
115
+🇮🇹|IT|Italy
116
+🇯🇪|JE|Jersey
117
+🇯🇲|JM|Jamaica
118
+🇯🇴|JO|Jordan
119
+🇯🇵|JP|Japan
120
+🇰🇪|KE|Kenya
121
+🇰🇬|KG|Kyrgyzstan
122
+🇰🇭|KH|Cambodia
123
+🇰🇮|KI|Kiribati
124
+🇰🇲|KM|Comoros
125
+🇰🇳|KN|St. Kitts & Nevis
126
+🇰🇵|KP|North Korea
127
+🇰🇷|KR|South Korea
128
+🇰🇼|KW|Kuwait
129
+🇰🇾|KY|Cayman Islands
130
+🇰🇿|KZ|Kazakhstan
131
+🇱🇦|LA|Laos
132
+🇱🇧|LB|Lebanon
133
+🇱🇨|LC|St. Lucia
134
+🇱🇮|LI|Liechtenstein
135
+🇱🇰|LK|Sri Lanka
136
+🇱🇷|LR|Liberia
137
+🇱🇸|LS|Lesotho
138
+🇱🇹|LT|Lithuania
139
+🇱🇺|LU|Luxembourg
140
+🇱🇻|LV|Latvia
141
+🇱🇾|LY|Libya
142
+🇲🇦|MA|Morocco
143
+🇲🇨|MC|Monaco
144
+🇲🇩|MD|Moldova
145
+🇲🇪|ME|Montenegro
146
+🇲🇫|MF|St. Martin
147
+🇲🇬|MG|Madagascar
148
+🇲🇭|MH|Marshall Islands
149
+🇲🇰|MK|North Macedonia
150
+🇲🇱|ML|Mali
151
+🇲🇲|MM|Myanmar (Burma)
152
+🇲🇳|MN|Mongolia
153
+🇲🇴|MO|Macao SAR China
154
+🇲🇵|MP|Northern Mariana Islands
155
+🇲🇶|MQ|Martinique
156
+🇲🇷|MR|Mauritania
157
+🇲🇸|MS|Montserrat
158
+🇲🇹|MT|Malta
159
+🇲🇺|MU|Mauritius
160
+🇲🇻|MV|Maldives
161
+🇲🇼|MW|Malawi
162
+🇲🇽|MX|Mexico
163
+🇲🇾|MY|Malaysia
164
+🇲🇿|MZ|Mozambique
165
+🇳🇦|NA|Namibia
166
+🇳🇨|NC|New Caledonia
167
+🇳🇪|NE|Niger
168
+🇳🇫|NF|Norfolk Island
169
+🇳🇬|NG|Nigeria
170
+🇳🇮|NI|Nicaragua
171
+🇳🇱|NL|Netherlands
172
+🇳🇴|NO|Norway
173
+🇳🇵|NP|Nepal
174
+🇳🇷|NR|Nauru
175
+🇳🇺|NU|Niue
176
+🇳🇿|NZ|New Zealand
177
+🇴🇲|OM|Oman
178
+🇵🇦|PA|Panama
179
+🇵🇪|PE|Peru
180
+🇵🇫|PF|French Polynesia
181
+🇵🇬|PG|Papua New Guinea
182
+🇵🇭|PH|Philippines
183
+🇵🇰|PK|Pakistan
184
+🇵🇱|PL|Poland
185
+🇵🇲|PM|St. Pierre & Miquelon
186
+🇵🇳|PN|Pitcairn Islands
187
+🇵🇷|PR|Puerto Rico
188
+🇵🇸|PS|Palestinian Territories
189
+🇵🇹|PT|Portugal
190
+🇵🇼|PW|Palau
191
+🇵🇾|PY|Paraguay
192
+🇶🇦|QA|Qatar
193
+🇷🇪|RE|Réunion
194
+🇷🇴|RO|Romania
195
+🇷🇸|RS|Serbia
196
+🇷🇺|RU|Russia
197
+🇷🇼|RW|Rwanda
198
+🇸🇦|SA|Saudi Arabia
199
+🇸🇧|SB|Solomon Islands
200
+🇸🇨|SC|Seychelles
201
+🇸🇩|SD|Sudan
202
+🇸🇪|SE|Sweden
203
+🇸🇬|SG|Singapore
204
+🇸🇭|SH|St. Helena
205
+🇸🇮|SI|Slovenia
206
+🇸🇯|SJ|Svalbard & Jan Mayen
207
+🇸🇰|SK|Slovakia
208
+🇸🇱|SL|Sierra Leone
209
+🇸🇲|SM|San Marino
210
+🇸🇳|SN|Senegal
211
+🇸🇴|SO|Somalia
212
+🇸🇷|SR|Suriname
213
+🇸🇸|SS|South Sudan
214
+🇸🇹|ST|São Tomé & Príncipe
215
+🇸🇻|SV|El Salvador
216
+🇸🇽|SX|Sint Maarten
217
+🇸🇾|SY|Syria
218
+🇸🇿|SZ|Eswatini
219
+🇹🇦|TA|Tristan da Cunha
220
+🇹🇨|TC|Turks & Caicos Islands
221
+🇹🇩|TD|Chad
222
+🇹🇫|TF|French Southern Territories
223
+🇹🇬|TG|Togo
224
+🇹🇭|TH|Thailand
225
+🇹🇯|TJ|Tajikistan
226
+🇹🇰|TK|Tokelau
227
+🇹🇱|TL|Timor-Leste
228
+🇹🇲|TM|Turkmenistan
229
+🇹🇳|TN|Tunisia
230
+🇹🇴|TO|Tonga
231
+🇹🇷|TR|Türkiye
232
+🇹🇹|TT|Trinidad & Tobago
233
+🇹🇻|TV|Tuvalu
234
+🇹🇼|TW|Taiwan
235
+🇹🇿|TZ|Tanzania
236
+🇺🇦|UA|Ukraine
237
+🇺🇬|UG|Uganda
238
+🇺🇲|UM|U.S. Outlying Islands
239
+🇺🇳|UN|United Nations
240
+🇺🇸|US|United States
241
+🇺🇾|UY|Uruguay
242
+🇺🇿|UZ|Uzbekistan
243
+🇻🇦|VA|Vatican City
244
+🇻🇨|VC|St. Vincent & Grenadines
245
+🇻🇪|VE|Venezuela
246
+🇻🇬|VG|British Virgin Islands
247
+🇻🇮|VI|U.S. Virgin Islands
248
+🇻🇳|VN|Vietnam
249
+🇻🇺|VU|Vanuatu
250
+🇼🇫|WF|Wallis & Futuna
251
+🇼🇸|WS|Samoa
252
+🇽🇰|XK|Kosovo
253
+🇾🇪|YE|Yemen
254
+🇾🇹|YT|Mayotte
255
+🇿🇦|ZA|South Africa
256
+🇿🇲|ZM|Zambia
257
+🇿🇼|ZW|Zimbabwe`.split('\n').map(x => {
258
+ const [flag,code,name] = x.split('|')
259
+ return { flag, code, name }
260
+})
261
+
262
+export const WITH_IP = 'AD,AE,AF,AG,AI,AL,AM,AO,AQ,AR,AS,AT,AU,AW,AX,AZ,BA,BB,BD,BE,BF,BG,BH,BI,BJ,BL,BM,BN,BO,BQ,BR,' +
263
+ 'BS,BT,BV,BW,BY,BZ,CA,CD,CF,CG,CH,CI,CK,CL,CM,CN,CO,CR,CU,CV,CW,CY,CZ,DE,DJ,DK,DM,DO,DZ,EC,EE,EG,ER,ES,ET,FI,FJ,FK,' +
264
+ 'FM,FO,FR,GA,GB,GD,GE,GF,GG,GH,GI,GL,GM,GN,GP,GQ,GR,GT,GU,GW,GY,HK,HN,HR,HT,HU,ID,IE,IL,IM,IN,IO,IQ,IR,IS,IT,JE,JM,' +
265
+ 'JO,JP,KE,KG,KH,KI,KM,KN,KP,KR,KW,KY,KZ,LA,LB,LC,LI,LK,LR,LS,LT,LU,LV,LY,MA,MC,MD,ME,MF,MG,MH,MK,ML,MM,MN,MO,MP,MQ,' +
266
+ 'MR,MS,MT,MU,MV,MW,MX,MY,MZ,NA,NC,NE,NF,NG,NI,NL,NO,NP,NR,NU,NZ,OM,PA,PE,PF,PG,PH,PK,PL,PM,PR,PS,PT,PW,PY,QA,RE,RO,' +
267
+ 'RS,RU,RW,SA,SB,SC,SD,SE,SG,SI,SK,SL,SM,SN,SO,SR,SS,ST,SV,SX,SY,SZ,TC,TD,TG,TH,TJ,TK,TL,TM,TN,TO,TR,TT,TV,TW,TZ,UA,' +
268
+ 'UG,UM,US,UY,UZ,VA,VC,VE,VG,VI,VN,VU,WF,WS,YE,YT,ZA,ZM,ZW'.split(',')
269
+
mui-grid-form/SelectField.ts
+22
-20
@@ -1,19 +1,12 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, ReactNode } from 'react'
3
+import { createElement as h, Fragment, ReactNode } from 'react'
4
import { FieldProps } from '.'
5
-import {
6
- FormControl,
7
- FormControlLabel,
8
- FormLabel,
9
- InputAdornment,
10
- MenuItem,
11
- Radio,
12
- RadioGroup,
13
- StandardTextFieldProps,
14
- TextField
5
+import { FormControl, FormControlLabel, FormLabel, IconButton, InputAdornment, MenuItem, Radio, RadioGroup,
6
+ StandardTextFieldProps, TextField, Tooltip
7
} from '@mui/material'
8
import { SxProps } from '@mui/system'
9
+import { Clear } from '@mui/icons-material'
10
11
type SelectOptions<T> = { [label:string]:T } | SelectOption<T>[]
12
type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
@@ -36,11 +29,12 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
29
}
30
31
export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T>) {
39
- const { value, onChange, setApi, options, sx, ...rest } = props
32
+ const { value, onChange, setApi, options, sx, clearable, clearValue, ...rest } = props
33
return h(TextField, {
41
- ...commonSelectProps({ ...props, value: undefined }),
34
+ ...commonSelectProps({ clearValue: [], ...props }),
35
...rest,
36
SelectProps: { multiple: true },
37
+ sx: { ...rest.sx, '& div[role=button]': { whiteSpace: 'unset' } },
38
value: !Array.isArray(value) ? [] : value.map(x => JSON.stringify(x)),
39
onChange(event) {
40
try {
@@ -53,21 +47,23 @@ export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T
47
})
48
}
49
56
-interface CommonSelectProps<T> extends Partial<Omit<StandardTextFieldProps, 'label' | 'onChange' | 'value'>> {
50
+type HelperCommon<T> = Partial<Omit<StandardTextFieldProps, 'label' | 'value' | 'onChange'>> & Pick<FieldProps<T>, 'value' | 'onChange' | 'label'>
51
+interface CommonSelectProps<T> extends HelperCommon<T> {
52
sx?: SxProps
58
- label?: FieldProps<T>['label']
59
- value?: T
53
disabled?: boolean
54
+ clearable?: boolean
55
+ clearValue?: T | []
56
options: SelectOptions<T>
57
start?: ReactNode
58
end?: ReactNode
59
}
60
function commonSelectProps<T>(props: CommonSelectProps<T>) {
66
- const { options, disabled, start, end } = props
61
+ const { options, disabled, start, end, clearable, clearValue, value } = props
62
const normalizedOptions = !Array.isArray(options) ? Object.entries(options).map(([label,value]) => ({ value, label }))
63
: options.map(o => typeof o === 'string' || typeof o === 'number' ? { value: o, label: String(o) } : o as SelectPair<T>)
69
- const jsonValue = JSON.stringify(props.value)
64
+ const jsonValue = JSON.stringify(value)
65
const currentOption = normalizedOptions.find(x => JSON.stringify(x.value) === jsonValue)
66
+ const showClear = clearable && (Array.isArray(value) ? value.length > 0 : value)
67
return {
68
select: true,
69
fullWidth: true,
@@ -76,14 +72,20 @@ function commonSelectProps<T>(props: CommonSelectProps<T>) {
72
value: currentOption ? jsonValue : '',
73
disabled: !normalizedOptions?.length || disabled,
74
InputProps: {
79
- startAdornment: start && h(InputAdornment, { position: 'start' }, start),
75
+ startAdornment: (start || showClear) && h(InputAdornment, { position: 'start' },
76
+ showClear && h(Tooltip, { title: "Clear", children: h(IconButton, {
77
+ onClick(event) {
78
+ props.onChange(clearValue as any, { was: value, event })
79
+ }
80
+ }, h(Clear)) }),
81
+ start),
82
endAdornment: end && h(InputAdornment, { position: 'end' }, end),
83
...props.InputProps,
84
},
85
children: normalizedOptions.map((o, i) => h(MenuItem, {
86
key: i,
87
value: JSON.stringify(o?.value),
86
- children: o?.label
88
+ children: h(Fragment, { key: i }, o?.label) // without this fragment/key, a label as h(span) will produce warnings
89
}))
90
}
91
}
mui-grid-form/index.ts
+5
-3
@@ -31,7 +31,7 @@ export interface FieldDescriptor<T=any> extends FieldApi<T> {
31
label?: ReactNode
32
error?: ReactNode
33
toField?: (v: T) => any
34
- fromField?: (v: any) => T
34
+ fromField?: (v: any, { originalValue }: { originalValue: T }) => T
35
before?: ReactNode
36
after?: ReactNode
37
getError?: GetError
@@ -108,6 +108,7 @@ export function Form<Values extends Dict>({
108
const submitAfterValidation = useRef(false)
109
const validateUpTo = useRef('')
110
useEffect(() => void(phaseChange()), [phase]) //eslint-disable-line
111
+ const keyMet: Dict<number> = {}
112
113
const apis: Dict<FieldApi<unknown>> = {} // consider { [K in keyof Values]?: FieldApi<Values[K]> }
114
return h('form', {
@@ -150,7 +151,7 @@ export function Form<Values extends Dict>({
151
},
152
onChange(v: unknown) {
153
try {
153
- v = fromField(v)
154
+ v = fromField(v, { originalValue })
155
setFieldExceptions(x => ({ ...x, [k]: false }))
156
if ((apis[k]?.isEqual || _.isEqual)(v, originalValue)) return
157
set(v, k)
@@ -179,7 +180,8 @@ export function Form<Values extends Dict>({
180
fromField, toField, // don't propagate
181
...rest } = field
182
Object.assign(rest, { name: k })
182
- return h(Grid, { key: k || idx, item: true, xs, sm, md, lg, xl },
183
+ const n = (keyMet[k] = (keyMet[k] || 0) + 1)
184
+ return h(Grid, { key: k ? k + n : idx, item: true, xs, sm, md, lg, xl },
185
before,
186
isValidElement(comp) ? comp : h(comp, rest),
187
after
package.json
+1
@@ -72,6 +72,7 @@
72
"formidable": "^3.5.1",
73
"fs-x-attributes": "^1.0.2",
74
"iconv-lite": "^0.6.3",
75
+ "ip2location-nodejs": "^9.6.0",
76
"koa": "^2.13.4",
77
"koa-compress": "^5.1.0",
78
"koa-mount": "^4.0.0",
shared/react.ts
+10
-9
@@ -3,6 +3,7 @@
3
import { createElement as h, Fragment, KeyboardEvent, ReactElement, ReactNode,
4
useCallback, useEffect, useRef, useState } from 'react'
5
import { useIsMounted, useWindowSize } from 'usehooks-ts'
6
+import { Falsy } from '.'
7
8
export function useStateMounted<T>(init: T) {
9
const isMounted = useIsMounted()
@@ -44,7 +45,7 @@ export function useRequestRender() {
45
As an additional feature, results are cached. You can clear the cache by calling cache.clear()
46
*/
47
export function useBatch<Job=unknown,Result=unknown>(
47
- worker: ((jobs: Job[]) => Promise<Result[]>),
48
+ worker: Falsy | ((jobs: Job[]) => Promise<Result[]>),
49
job: undefined | Job,
50
{ delay=0 }={}
51
) {
@@ -54,28 +55,28 @@ export function useBatch<Job=unknown,Result=unknown>(
55
waiter?: Promise<void>
56
}
57
const worker2env = (useBatch as any).worker2env ||= worker && new Map<typeof worker, Env>()
57
- const env = (worker2env.get(worker) || (() => {
58
+ const env = worker2env && (worker2env.get(worker) || (() => {
59
const ret = { batch: new Set<Job>(), cache: new Map<Job, Result>() } as Env
60
worker2env.set(worker, ret)
61
return ret
62
})())
63
const requestRender = useRequestRender()
64
useEffect(() => {
64
- (env.waiter ||= new Promise<void>(resolve => {
65
+ worker && (env.waiter ||= new Promise<void>(resolve => {
66
setTimeout(async () => {
66
- env.timeout = undefined
67
+ if (!env.batch.size)
68
+ return resolve()
69
const jobs = [...env.batch.values()]
70
env.batch.clear()
71
const res = await worker(jobs)
70
- let i = 0
71
- for (const job of jobs)
72
- env.cache.set(job, res[i++] ?? null)
72
+ jobs.forEach((job, i) =>
73
+ env.cache.set(job, res[i] ?? null) )
74
env.waiter = undefined
75
resolve()
76
}, delay)
77
})).then(requestRender)
77
- }, [])
78
- const cached = env?.cache.get(job)
78
+ }, [worker])
79
+ const cached = env && env.cache.get(job) // don't use ?. as env can be falsy
80
if (env && cached === undefined)
81
env.batch.add(job)
82
return { data: cached, ...env } as Env & { data: Result | undefined | null } // so you can cache.clear
src/adminApis.ts
+8
@@ -36,6 +36,7 @@ import { getUpdates, localUpdateAvailable, update, updateSupported } from './upd
36
import { consoleLog } from './consoleLog'
37
import { resolve } from 'path'
38
import { getErrorSections } from './errorPages'
39
+import { ip2country } from './geo'
40
41
export const adminApis: ApiHandlers = {
42
@@ -75,6 +76,13 @@ export const adminApis: ApiHandlers = {
76
return { options: await getUpdates() }
77
},
78
79
+ async ip_country({ ips }) {
80
+ const res = await Promise.allSettled(ips.map(ip2country))
81
+ return {
82
+ codes: res.map(x => x.status === 'rejected' || x.value === '-' ? '' : x.value)
83
+ }
84
+ },
85
+
86
get_custom_html() {
87
return {
88
sections: Object.fromEntries([
src/api.monitor.ts
+1
-1
@@ -72,7 +72,7 @@ const apis: ApiHandlers = {
72
v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
73
got: socket.bytesRead,
74
sent: socket.bytesWritten,
75
- ..._.pick(conn, ['op', 'opTotal', 'opOffset', 'opProgress']),
75
+ ..._.pick(conn, ['op', 'opTotal', 'opOffset', 'opProgress', 'country']),
76
started,
77
secure: (secure || undefined) as boolean|undefined, // undefined will save some space once json-ed
78
...fromCtx(conn.ctx),
src/connections.ts
+1
@@ -16,6 +16,7 @@ export class Connection {
16
opProgress?: number
17
opOffset?: number
18
ctx?: Context
19
+ country?: string
20
private _cachedIp?: string
21
[rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
22
src/cross.ts
+5
-1
@@ -21,7 +21,7 @@ export const FRONTEND_OPTIONS = {
21
}
22
export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
23
export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
24
-
24
+export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown'])
25
export type Dict<T=any> = Record<string, T>
26
export type Falsy = false | null | undefined | '' | 0
27
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
@@ -81,6 +81,10 @@ export type VfsNodeAdminSend = {
81
82
export const PERM_KEYS = typedKeys(defaultPerms)
83
84
+function constMap<T extends string>(a: T[]): { [K in T]: K } {
85
+ return Object.fromEntries(a.map(x => [x, x])) as { [K in T]: K };
86
+}
87
+
88
export function isWhoObject(v: undefined | Who): v is WhoObject {
89
return v !== null && typeof v === 'object' && !Array.isArray(v)
90
}
src/geo.ts
new
+57
@@ -0,0 +1,57 @@
1
+import { defineConfig } from './config'
2
+import { CFG, DAY, httpStream, isLocalHost, unzip } from './misc'
3
+import { stat, rename, unlink } from 'node:fs/promises'
4
+import { IP2Location } from 'ip2location-nodejs'
5
+import _ from 'lodash'
6
+import { Middleware } from 'koa'
7
+import { updateConnection } from './connections'
8
+
9
+const ip2location = new IP2Location()
10
+const enabled = defineConfig(CFG.geo_enable, false)
11
+const allow = defineConfig<boolean | null>(CFG.geo_allow, null)
12
+const list = defineConfig(CFG.geo_list, [] as string[])
13
+const allowUnknown = defineConfig(CFG.geo_allow_unknown, false)
14
+enabled.sub(checkFiles)
15
+setInterval(checkFiles, DAY) // keep updated at run-time
16
+
17
+export const ip2country = _.memoize((ip: string) => ip2location.getCountryShortAsync(ip).then(v => v === '-' ? '' : v, () => ''))
18
+
19
+export const geoFilter: Middleware = async (ctx, next) => {
20
+ if (allow.get() !== null && !isLocalHost(ctx)) {
21
+ const { connection } = ctx.state
22
+ const country = connection.country ??= await ip2country(ctx.ip)
23
+ updateConnection(connection, { country })
24
+ if (country ? list.get().includes(country) !== allow.get() : !allowUnknown.get())
25
+ return ctx.socket.destroy()
26
+ }
27
+ return next()
28
+}
29
+
30
+function isOpen() {
31
+ return Boolean(ip2location.getPackageVersion())
32
+}
33
+
34
+async function checkFiles() {
35
+ if (!enabled.get()) return
36
+ const ZIP_FILE = 'IP2LOCATION-LITE-DB1.IPV6.BIN'
37
+ const URL = `https://download.ip2location.com/lite/${ZIP_FILE}.ZIP`
38
+ const LOCAL_FILE = 'geo_ip.bin'
39
+ const TEMP = LOCAL_FILE + '.downloading'
40
+ const { mtime=0 } = await stat(LOCAL_FILE).catch(() => ({ mtime: 0 }))
41
+ const now = Date.now()
42
+ if (mtime < now - 31 * DAY) { // month-old or non-existing
43
+ console.log('downloading geo-ip db')
44
+ await unzip(await httpStream(URL), path =>
45
+ path.toUpperCase().endsWith(ZIP_FILE) && TEMP)
46
+ if (await stat(TEMP))
47
+ if (isOpen())
48
+ ip2location.close()
49
+ await unlink(LOCAL_FILE).catch(() => {})
50
+ await rename(TEMP, LOCAL_FILE)
51
+ ip2country.cache.clear?.()
52
+ console.log('download geo-ip db completed')
53
+ }
54
+ else if (isOpen()) return
55
+ console.debug('loading geo-ip db')
56
+ ip2location.open(LOCAL_FILE) // using openAsync causes a DEP0137 error within 10 seconds
57
+}
src/index.ts
+4
-1
@@ -21,6 +21,8 @@ import { randomId } from './misc'
21
import session from 'koa-session'
22
import { selfCheckMiddleware } from './selfCheck'
23
import { acmeMiddleware } from './acme'
24
+import './geo'
25
+import { geoFilter } from './geo'
26
27
ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
28
@@ -29,10 +31,11 @@ const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
31
|| [randomId(30)] // randomness at start gives some extra security, btu also invalidates existing sessions
32
export const app = new Koa({ keys })
33
app.use(someSecurity)
32
- .use(selfCheckMiddleware)
34
.use(acmeMiddleware)
35
.use(session({ key: 'hfs_$id', signed: true, rolling: true, sameSite: 'lax' }, app))
36
.use(prepareState)
37
+ .use(geoFilter)
38
+ .use(selfCheckMiddleware)
39
.use(gzipper)
40
.use(paramsDecoder) // must be done before plugins, so they can manipulate params
41
.use(pluginsMiddleware)