@samitouri / QOSami-HFS / commits / d2b3bd15

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)