| 1 | import { createElement as h, ReactNode, useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import { |
| 3 | Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link, Typography, Skeleton, |
| 4 | } from '@mui/material' |
| 5 | import { CardMembership, Check, Dns, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RouterTwoTone, Send, Storage, |
| 6 | Error as ErrorIcon, SvgIconComponent, Search } from '@mui/icons-material' |
| 7 | import { apiCall, useApiEvents, useApiEx } from './api' |
| 8 | import { |
| 9 | closeDialog, DAY, formatTimestamp, wait, wantArray, with_, PORT_DISABLED, isIP, CFG, md, |
| 10 | useRequestRender, replace, restartAnimation, prefix, isIpLan, HIDE_IN_TESTS |
| 11 | } from './misc' |
| 12 | import { Flex, LinkBtn, Btn, Country, wikiLink } from './mui' |
| 13 | import { alertDialog, confirmDialog, formDialog, promptDialog, toast, waitDialog } from './dialog' |
| 14 | import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form' |
| 15 | import { suggestMakingCert } from './OptionsPage' |
| 16 | import { changeBaseUrl } from './FileForm' |
| 17 | import { adminApis } from '../../src/adminApis' |
| 18 | import { ALL, WITH_IP } from './countries' |
| 19 | import _ from 'lodash' |
| 20 | import { SvgIconProps } from '@mui/material/SvgIcon' |
| 21 | import { ConfigForm } from './ConfigForm' |
| 22 | import { DynamicDnsResult } from '../../src/ddns' |
| 23 | import { ArrayField } from './ArrayField' |
| 24 | import VfsPathField from './VfsPathField' |
| 25 | import { PageProps } from './App' |
| 26 | |
| 27 | const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code)) |
| 28 | |
| 29 | const PORT_FORWARD_URL = 'https://portforward.com/' |
| 30 | const HIGHER_PORT = 1080 |
| 31 | const MSG_ISP = h('div', {}, "HFS will probably not be reachable on the Internet. ", wikiLink('Work-on-the-internet#double-nat', "Read more")) |
| 32 | |
| 33 | export default function InternetPage({ setTitleSide }: PageProps) { |
| 34 | const [checkResult, setCheckResult] = useState<boolean | undefined>() |
| 35 | const [checking, setChecking] = useState(false) |
| 36 | const [mapping, setMapping] = useState(false) |
| 37 | const status = useApiEx('get_status') |
| 38 | const config = useApiEx('get_config', { only: [CFG.base_url] }) |
| 39 | const baseUrl = config.data?.[CFG.base_url] |
| 40 | const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) => |
| 41 | h && s ? 'error' : h || s ? 'warning' : 'success') |
| 42 | const nat = useApiEx<typeof adminApis.get_nat>('get_nat', {}, { timeout: 20 }) |
| 43 | const { data: publicIps } = useApiEx<typeof adminApis.get_public_ips>('get_public_ips', { timeout: 20 }) |
| 44 | const { data } = nat |
| 45 | const port = data?.internalPort |
| 46 | const wrongMap = data?.mapped && data.mapped.private.port !== port && data.mapped.private.port |
| 47 | const doubleNat = data?.externalIp && publicIps && !publicIps.includes(data.externalIp) |
| 48 | const verifyAgain = useRequestRender() |
| 49 | useEffect(() => { |
| 50 | if (verifyAgain.state) // skip first |
| 51 | void verify(true) |
| 52 | }, [verifyAgain.state]) |
| 53 | setTitleSide(useMemo(() => |
| 54 | h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "This page makes sure your site is working correctly on the Internet"), |
| 55 | [])) |
| 56 | return h(Flex, { vert: true, gap: '2em' }, |
| 57 | h(Box, { sx: { maxWidth: '40em' } }, networkBox()), |
| 58 | h(Flex, { gap: '2em', flexWrap: 'wrap', maxWidth: '84em', '&>*': { maxWidth: '40em', width: { md: '40em' } }, alignItems: 'flex-start', justifyContent: 'space-between' }, |
| 59 | baseUrlBox(), |
| 60 | httpsBox(), |
| 61 | geoBox(), |
| 62 | ddnsBox(), |
| 63 | )) |
| 64 | |
| 65 | function stripTags(html: string) { |
| 66 | return html.replace(/.+<body>(.+)<\/body>.+/is, (all,x) => x || all) // extract body, if any |
| 67 | .replace(/<[^>]+>/g, ' ') |
| 68 | } |
| 69 | |
| 70 | function ddnsBox() { |
| 71 | const { data } = useApiEvents<DynamicDnsResult>('get_dynamic_dns_error') |
| 72 | const ref = useRef<any>() |
| 73 | useEffect(() => ref.current && restartAnimation(ref.current, '1s blink'), [data]); |
| 74 | return h(TitleCard, { icon: Dns, title: "Dynamic DNS updater" }, |
| 75 | data && h(Flex, {}, |
| 76 | data.error ? h(ErrorIcon, { color: 'error', ref }) : h(Check, { color: 'success', ref }), |
| 77 | formatTimestamp(data.ts), ' – ', |
| 78 | prefix("Error: ", stripTags(data.error)).slice(0, 500) || "Updated successfully", |
| 79 | ), |
| 80 | "This tool can keep your domain updated with your latest IP address. Not every service is compatible, and most of them have their own software for the job, which is superior, but we offer this lightweight solution if you prefer it.", |
| 81 | h(ConfigForm<{ |
| 82 | [CFG.dynamic_dns_url]: string, |
| 83 | }>, { |
| 84 | form: (v, { setValues }) => ({ |
| 85 | fields: [ |
| 86 | h(Flex, {}, |
| 87 | _.map({ |
| 88 | NoIP: { |
| 89 | url: 'https://$username:$password@dynupdate.no-ip.com/nic/update?hostname=$domain', |
| 90 | fields: ['username', 'password', 'domain'], |
| 91 | }, |
| 92 | DuckDNS: { |
| 93 | url: 'https://www.duckdns.org/update/$domain/$token>OK', |
| 94 | fields: [{ k: 'domain', helperText: "do NOT include the .duckdns.org part" }, 'token'], |
| 95 | } |
| 96 | }, ({ url, fields }, label) => |
| 97 | h(Btn, { |
| 98 | key: url, |
| 99 | onClick: () => formDialog({ |
| 100 | title: label + " wizard", |
| 101 | form: { |
| 102 | sx: { maxWidth: '20em' }, |
| 103 | before: h(Box, { sx: { mb: 1 } }, "The following information is stored unencrypted"), |
| 104 | fields: fields.map(k => _.isString(k) ? { k } : k) |
| 105 | } |
| 106 | }).then(symbols => symbols && setValues({ [CFG.dynamic_dns_url]: replace(url, symbols as any, '$') })) |
| 107 | }, label + " wizard") |
| 108 | ) |
| 109 | ), |
| 110 | { k: CFG.dynamic_dns_url, label: "Updater URL", multiline: true, |
| 111 | helperText: "Refer to your DNS service provider to know what URL can automatically keep your domain updated. Supported symbols are $IP4, $IP6, $IPX. Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used." |
| 112 | }, |
| 113 | ] |
| 114 | }) |
| 115 | }) |
| 116 | ) |
| 117 | } |
| 118 | |
| 119 | function geoBox() { |
| 120 | const countryOptions = useMemo(() => COUNTRIES.map(x => ({ value: x.code, label: x.name })), [COUNTRIES]) |
| 121 | return h(TitleCard, { title: "Geo IP", icon: Public }, |
| 122 | h(ConfigForm<{ |
| 123 | [CFG.geo_enable]: boolean |
| 124 | [CFG.geo_allow]: null | boolean |
| 125 | [CFG.geo_list]: string[] |
| 126 | [CFG.geo_allow_unknown]: boolean |
| 127 | }>, { |
| 128 | keys: [ CFG.geo_enable, CFG.geo_allow, CFG.geo_list, CFG.geo_allow_unknown ], |
| 129 | form: values => ({ fields: [ |
| 130 | { k: CFG.geo_enable, comp: BoolField, label: "Enable", helperText: md("Necessary database will be downloaded every month (2MB). Service is made possible thanks to [IP2Location](https://www.ip2location.com).") }, |
| 131 | ...!values?.[CFG.geo_enable] ? [] : [ |
| 132 | { |
| 133 | k: CFG.geo_allow, |
| 134 | comp: SelectField, |
| 135 | label: "Rule", |
| 136 | options: { "no restriction": null, "block selected countries": false, "allow selected countries": true }, |
| 137 | }, |
| 138 | values[CFG.geo_allow] != null && { |
| 139 | k: CFG.geo_list, |
| 140 | comp: MultiSelectField<string>, |
| 141 | label: `Selected countries (${values[CFG.geo_list]?.length || 0})`, |
| 142 | valueSeparator: false, |
| 143 | placeholder: "none", |
| 144 | options: countryOptions, |
| 145 | renderOption: (v: any) => h(Country, { code: v.value, long: true }), |
| 146 | clearable: true, |
| 147 | getError: (v: any) => values[CFG.geo_allow] && !v?.length && "Cannot be empty", |
| 148 | }, |
| 149 | values[CFG.geo_allow] != null && { |
| 150 | k: CFG.geo_allow_unknown, |
| 151 | comp: SelectField, |
| 152 | label: "When country cannot be determined", |
| 153 | helperText: "Local IPs are ignored", |
| 154 | options: { Allow: true, Block: false }, |
| 155 | }, |
| 156 | ] |
| 157 | ] }), |
| 158 | addToBar: [ |
| 159 | h(Box, { sx: { flex: 1 } }), |
| 160 | h(Btn, { icon: Search, onClick: lookup }, "Lookup IP") |
| 161 | ], |
| 162 | }) |
| 163 | ) |
| 164 | } |
| 165 | |
| 166 | async function lookup() { |
| 167 | const ip = await promptDialog("Lookup IP") |
| 168 | if (!ip) return |
| 169 | const { country } = await apiCall('geo_ip', { ip }) |
| 170 | if (!country) |
| 171 | return alertDialog("IP not found", 'error') |
| 172 | return alertDialog(h(Country, { code: country, long: true }), 'success') |
| 173 | } |
| 174 | |
| 175 | function httpsBox() { |
| 176 | const [values, setValues] = useState<any>() |
| 177 | const cert = useApiEx('get_cert') |
| 178 | useEffect(() => { apiCall('get_config', { only: ['acme_domain', 'acme_renew'] }).then(setValues) } , []) |
| 179 | const [saving, setSaving] = useState(false) |
| 180 | if (!values) return h(CircularProgress) |
| 181 | const { https } = status.data ||{} |
| 182 | const disabled = https?.port === PORT_DISABLED |
| 183 | const error = https?.error |
| 184 | return status.element || h(TitleCard, { title: "HTTPS", icon: Lock, color: https?.listening && !error ? 'success' : 'warning' }, |
| 185 | error ? h(Alert, { severity: 'warning' }, error) : |
| 186 | (disabled && h(LinkBtn, { onClick: notEnabled }, "Not enabled")), |
| 187 | cert.element || with_(cert.data, c => c.none ? h(LinkBtn, { onClick: noCertClick }, "No certificate configured") : h(Box, {}, |
| 188 | h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate", |
| 189 | h('ul', {}, |
| 190 | h('li', {}, "Domain: ", c.altNames?.join(' + ') ||'-'), |
| 191 | h('li', {}, "Issuer: ", c.issuer?.O || h('i', {}, 'self-signed')), |
| 192 | h('li', {}, "Validity: ", ['validFrom', 'validTo'].map(k => formatTimestamp(c[k])).join(' – ')), |
| 193 | ) |
| 194 | )), |
| 195 | h(Divider), |
| 196 | h(Form, { |
| 197 | sx: { gap: 1 }, |
| 198 | gridProps: {rowSpacing:1}, |
| 199 | values, |
| 200 | set(v, k) { |
| 201 | setValues((was: any) => { |
| 202 | const values = { ...was, [k]: v } |
| 203 | setSaving(true) |
| 204 | apiCall('set_config', { values }).finally(() => setSaving(false)) |
| 205 | return values |
| 206 | }) |
| 207 | }, |
| 208 | fields: [ |
| 209 | md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"), |
| 210 | { |
| 211 | k: 'acme_domain', |
| 212 | label: "Domain for certificate", |
| 213 | sm: values.acme_domain?.length > 30 ? 12 : 6, |
| 214 | required: true, |
| 215 | multiline: true, |
| 216 | fromField: x => x.replaceAll('\n', ','), |
| 217 | toField: x => x.replaceAll(',', '\n'), |
| 218 | helperText: md("Example: your.domain.com\nMultiple domains on separate lines") |
| 219 | }, |
| 220 | { |
| 221 | k: 'acme_renew', |
| 222 | label: "Automatic renew one month before expiration", |
| 223 | comp: BoolField, |
| 224 | disabled: !values.acme_domain |
| 225 | }, |
| 226 | with_(status.data.acmeRenewError, x => x && h(Alert, { severity: 'error' }, x)), |
| 227 | ], |
| 228 | save: { |
| 229 | children: "Request", |
| 230 | startIcon: h(Send), |
| 231 | ...saving && { loading: true }, |
| 232 | async onClick() { |
| 233 | const [domain, ...altNames] = values.acme_domain.split(',') |
| 234 | const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY |
| 235 | if (fresh && !await confirmDialog("Your certificate is still good", { trueText: "Make a new one anyway" })) |
| 236 | return |
| 237 | if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return |
| 238 | if (await stopOnCheckDomain(domain)) return |
| 239 | await apiCall('make_cert', { domain, altNames }, { timeout: 20_000 }) |
| 240 | .then(async () => { |
| 241 | await alertDialog("Certificate created", 'success') |
| 242 | if (disabled) |
| 243 | await notEnabled() |
| 244 | cert.reload() |
| 245 | }, alertDialog) |
| 246 | .finally(status.reload) |
| 247 | } |
| 248 | }, |
| 249 | }) |
| 250 | ) |
| 251 | |
| 252 | async function noCertClick() { |
| 253 | await suggestMakingCert() |
| 254 | cert.reload() |
| 255 | status.reload() |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | async function notEnabled() { |
| 260 | if (!await confirmDialog("HTTPS is currently disabled.\nFull configuration is available in the Options page.", { trueText: "Enable it"})) return |
| 261 | const stop = waitDialog() |
| 262 | try { |
| 263 | await apiCall('set_config', { values: { https_port: 443 } }) |
| 264 | await wait(1000) |
| 265 | status.reload() |
| 266 | } |
| 267 | finally { stop() } |
| 268 | } |
| 269 | |
| 270 | function baseUrlBox() { |
| 271 | return config.element || h(TitleCard, { icon: Public, title: "Address" }, |
| 272 | h(Flex, { flexWrap: 'wrap' }, |
| 273 | "Main address: ", |
| 274 | baseUrl ? h('tt', {}, baseUrl) : "automatic, not configured", |
| 275 | h(Btn, { |
| 276 | size: 'small', |
| 277 | variant: 'outlined', |
| 278 | 'aria-label': "Change address", |
| 279 | onClick: () => void changeBaseUrl().then(config.reload) |
| 280 | }, "Change"), |
| 281 | ), |
| 282 | h(Divider), |
| 283 | h(ConfigForm<{ roots: any, force_address: boolean }>, { |
| 284 | saveOnChange: true, |
| 285 | onSave() { |
| 286 | status.reload() // this config is affecting status data |
| 287 | }, |
| 288 | form: { |
| 289 | fields: [ |
| 290 | { |
| 291 | k: CFG.roots, |
| 292 | label: "Domain roots", |
| 293 | helperText: "You can decide different home-folders (in the VFS) for different domains, a bit like virtual hosts. If none is matched, the default home will be used.", |
| 294 | comp: ArrayField, |
| 295 | fields: [ |
| 296 | { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: *.domain.com|other.com", |
| 297 | getError: (v?: string) => v?.includes('/') && "No URLs or paths here!" }, |
| 298 | { k: 'root', label: "Home/Root", comp: VfsPathField, files: false, placeholder: "default", helperText: "Root path in VFS", |
| 299 | $column: { renderCell({ value }: any) { return value || h('i', {}, 'default') } } }, |
| 300 | ], |
| 301 | toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })), |
| 302 | fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])), |
| 303 | }, |
| 304 | { |
| 305 | k: CFG.force_address, |
| 306 | label: "Accept requests only using domains above (and localhost)", |
| 307 | comp: BoolField, |
| 308 | } |
| 309 | ] |
| 310 | }, |
| 311 | }) |
| 312 | ) |
| 313 | } |
| 314 | |
| 315 | function networkBox() { |
| 316 | if (nat.error) return nat.element |
| 317 | const direct = publicIps?.includes(data?.localIp!) |
| 318 | return h(Flex, { justifyContent: 'space-around' }, |
| 319 | h(Device, { name: "Server", icon: direct ? Storage : HomeWorkTwoTone, color: localColor, ip: data?.localIp, |
| 320 | below: port && h(Box, { className: 'port ' + HIDE_IN_TESTS }, "port ", port), |
| 321 | }), |
| 322 | !direct && h(DataLine), |
| 323 | !direct && h(Device, { |
| 324 | name: "Router", icon: RouterTwoTone, ip: data?.gatewayIp, |
| 325 | color: checkResult ? 'success' : data?.mapped && (wrongMap ? 'warning' : 'success'), |
| 326 | below: mapping ? h(LinearProgress, { sx: { height: '1em' } }) |
| 327 | : data && ( |
| 328 | checkResult && !data.mapped ? `port ${data.externalPort || data.internalPort}` |
| 329 | : h(LinkBtn, { sx: { display: 'block' }, onClick: configure }, |
| 330 | "port ", wrongMap ? "is wrong" : data?.externalPort || (checkResult ? "verified" : "unknown")) |
| 331 | ), |
| 332 | }), |
| 333 | h(DataLine), |
| 334 | h(Device, { name: "Internet", icon: PublicTwoTone, ip: publicIps, |
| 335 | color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined, |
| 336 | below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : publicIps && h(Box, { className: HIDE_IN_TESTS }, |
| 337 | doubleNat && h(LinkBtn, { sx: { display: 'block' }, onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"), |
| 338 | checkResult ? "Working!" : checkResult === false ? "Failed!" : '', |
| 339 | ' ', |
| 340 | (baseUrl > '' || publicIps?.length > 0) && data?.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify") |
| 341 | || ' ' // steadier layout, mainly for testing |
| 342 | ) |
| 343 | }), |
| 344 | ) |
| 345 | } |
| 346 | |
| 347 | async function stopOnCheckDomain(domain: string) { |
| 348 | return domain && false === await apiCall('check_domain', { domain }).catch(e => |
| 349 | confirmDialog(String(e), { trueText: "Continue anyway", falseText: "Stop" })) |
| 350 | } |
| 351 | |
| 352 | async function verify(again=false): Promise<any> { |
| 353 | await nat.loading |
| 354 | const data = nat.getData() // fresh data |
| 355 | if (!data) return |
| 356 | setCheckResult(undefined) |
| 357 | if (!again && !await confirmDialog("This test will check if your server is working properly on the Internet")) return |
| 358 | setChecking(true) |
| 359 | try { |
| 360 | const hostname = baseUrl && new URL(baseUrl).hostname |
| 361 | const checkUrl = !isIpLan(hostname) && baseUrl |
| 362 | if (!isIP(hostname) && await stopOnCheckDomain(hostname)) return |
| 363 | const urlResult = checkUrl && await apiCall('self_check', { url: checkUrl }).catch(e => |
| 364 | alertDialog(!e.code ? e : "Sorry, this function is not available at the moment. Retry later.", 'error')) |
| 365 | if (checkUrl && !urlResult) |
| 366 | return |
| 367 | if (urlResult?.success) { |
| 368 | setCheckResult(true) |
| 369 | return alertDialog(h(Box, {}, "Your server is responding correctly over the Internet:", |
| 370 | h('ul', {}, h('li', {}, urlResult.url))), 'success') |
| 371 | } |
| 372 | if (urlResult?.success === false) |
| 373 | await alertDialog(md(`Your configured address ${checkUrl} doesn't seem to work 😰\nstill, we are going to test your IP address 🤞`), 'warning') |
| 374 | const res = await apiCall('self_check', {}) |
| 375 | if (res.some((x: any) => x.success)) { |
| 376 | setCheckResult(true) |
| 377 | const mild = urlResult.success === false && md(`Your server is responding over the Internet 👍\nbut not with configured address ${checkUrl} 👎\njust on your IP:`) |
| 378 | return alertDialog(h(Box, {}, mild || "Your server is responding correctly over the Internet:", |
| 379 | h('ul', {}, ...res.map((x: any) => h('li', {}, x.url)))), mild ? 'warning' : 'success') |
| 380 | } |
| 381 | setCheckResult(false) |
| 382 | if (wrongMap) |
| 383 | return fixPort().then(verifyAgain) |
| 384 | if (doubleNat) |
| 385 | return alertDialog(MSG_ISP, 'warning') |
| 386 | const msg = "We couldn't reach your server from the Internet. " |
| 387 | if (data.upnp && !data!.mapped) |
| 388 | return confirmDialog(msg + "Try port-forwarding on your router", { trueText: "Fix it" }).then(async go => { |
| 389 | if (!go) return |
| 390 | try { await mapPort(data!.internalPort!, '', '') } |
| 391 | catch { await mapPort(HIGHER_PORT, '') } |
| 392 | toast("Port forwarded, now we verify again", 'success') |
| 393 | verifyAgain() |
| 394 | }) |
| 395 | const cfg = await apiCall('get_config', { only: [CFG.geo_enable, CFG.geo_allow] }) |
| 396 | const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {}, |
| 397 | cfg[CFG.geo_enable] && cfg[CFG.geo_allow] != null && h('li', {}, "You may be blocking a country from where the test is performed"), |
| 398 | !data.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")), |
| 399 | h('li', {}, "There could be a firewall, try configuring or disabling it."), |
| 400 | (data.externalPort || data.internalPort!) <= 1024 && h('li', {}, |
| 401 | "Your Internet Provider may be blocking ports under 1024. ", |
| 402 | data.upnp && h(Button, { |
| 403 | size: 'small', |
| 404 | onClick() { |
| 405 | close() |
| 406 | mapPort(HIGHER_PORT).then(verifyAgain) |
| 407 | } |
| 408 | }, "Try " + HIGHER_PORT)), |
| 409 | data.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."), |
| 410 | h('li', {}, h('div', {}, "Your Internet Provider may not assign you a public IP address. ", wikiLink('Work-on-the-internet#double-nat', "Read more"))), |
| 411 | )), 'warning') |
| 412 | } |
| 413 | catch(e: any) { |
| 414 | alertDialog(e) |
| 415 | } |
| 416 | finally { |
| 417 | setChecking(false) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | async function configure() { |
| 422 | if (!data) return // shut up ts |
| 423 | if (wrongMap) |
| 424 | return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { trueText: "Fix it" }) |
| 425 | && fixPort() |
| 426 | if (!data.upnp) |
| 427 | return alertDialog(h(Box, { sx: { lineHeight: 1.5 } }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info') |
| 428 | const msg = `For HFS to work over the Internet, you need a port on your modem/router forwarded to this computer's port ${port}.\n\n` |
| 429 | + (data?.mapped ? '' : `You may want to check if that's already the case before trying the following.\n\n`) |
| 430 | + `This will ask the router to forward a port.\nYou can use the same number as the local network port (${port}), or a different one.` |
| 431 | const res = await promptDialog(md(msg), { |
| 432 | value: data.externalPort || port, |
| 433 | field: { label: "Port seen from the Internet", comp: NumberField }, |
| 434 | addToBar: data.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")], |
| 435 | dialogProps: { sx: { maxWidth: '20em' } }, |
| 436 | }) |
| 437 | if (res) |
| 438 | await mapPort(Number(res), "Port forwarded").catch(() => {}) |
| 439 | |
| 440 | function remove() { |
| 441 | closeDialog() |
| 442 | mapPort(0, "Port removed") |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | function fixPort() { |
| 447 | if (!data?.externalPort) return alertDialog("externalPort not found", 'error') |
| 448 | return mapPort(data.externalPort, "Forwarding corrected") |
| 449 | } |
| 450 | |
| 451 | async function mapPort(external: number, msg='', errMsg="Operation failed") { |
| 452 | setMapping(true) |
| 453 | try { |
| 454 | await apiCall('map_port', { external }) |
| 455 | nat.reload() |
| 456 | if (msg) toast(msg, 'success') |
| 457 | setCheckResult(undefined) // things have changed, invalidate check result |
| 458 | } |
| 459 | catch(e: any) { |
| 460 | if (errMsg) { |
| 461 | const low = (external || data!.internalPort!) < 1024 |
| 462 | const msg = errMsg + prefix(': ', e?.message) + (low ? ". Some routers refuse to work with ports under 1024." : '') |
| 463 | await alertDialog(msg, 'error') |
| 464 | } |
| 465 | throw e |
| 466 | } |
| 467 | finally { |
| 468 | setMapping(false) |
| 469 | } |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | function DataLine() { |
| 474 | return h(Box, { sx: { flex: 1 }, className: 'animated-dashed-line' }) |
| 475 | } |
| 476 | |
| 477 | function Device({ name, icon, color, ip, below }: any) { |
| 478 | const fontSize = 'min(20vw, 10vh)' |
| 479 | return h(Box, { sx: { display: 'inline-block', textAlign: 'center' } }, |
| 480 | h(icon, { color, sx: { fontSize, mb: '-0.1em' } }), |
| 481 | h(Box, { sx: { fontSize: 'larger' } }, name), |
| 482 | ip === undefined ? h(Skeleton) : h(Box, { sx: { fontSize: 'smaller', whiteSpace: 'pre-wrap' }, className: 'ip ' + HIDE_IN_TESTS }, wantArray(ip).join('\n') || "unknown"), |
| 483 | below ? h(Box, { sx: { fontSize: 'smaller' } }, below) : h(Skeleton), |
| 484 | ) |
| 485 | } |
| 486 | |
| 487 | function TitleCard({ title, icon, color, children }: { title: ReactNode, icon?: SvgIconComponent, color?: SvgIconProps['color'], children?: ReactNode }) { |
| 488 | return h(Card, {}, h(CardContent, {}, h(Flex, { vert: true }, |
| 489 | h(Typography, { variant: 'h3', sx: { fontSize: 'x-large' } }, icon && h(icon, { color, sx: { mr: 1, mb: '2px' } }), title), |
| 490 | children |
| 491 | ))) |
| 492 | } |