fix: admin/fs: were suggesting URLs that couldn't work because of force_address

Massimo Melina committed Dec 7, 2024 at 00:18 UTC 2dccaa227c66011098769f281b8d4b9d565cc20f
6 files changed +35 -27
admin/src/FileForm.ts
+4 -4
@@ -9,7 +9,7 @@ import { apiCall, UseApi } from './api'
9 import {
10 basename, defaultPerms, formatBytes, formatTimestamp, isWhoObject, newDialog, objSameKeys,
11 onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback,
12 - useRequestRender, splitAt, IMAGE_FILEMASK, copyTextToClipboard
12 + useRequestRender, splitAt, IMAGE_FILEMASK, copyTextToClipboard, normalizeHost, CFG
13 } from './misc'
14 import { isModifiedConfig } from './AccountForm'
15 import { Btn, Flex, IconBtn, LinkBtn, propsForModifiedValues, useBreakpoint, wikiLink } from './mui'
@@ -329,7 +329,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
329 const data = statusApi.getData()
330
331 const urls: string[] = data?.urls.https || data?.urls.http
332 - const baseHost = data?.baseUrl && new URL(data.baseUrl).host
332 + const baseHost = data?.baseUrl && normalizeHost(new URL(data.baseUrl).host)
333 const root = useMemo(() => baseHost && _.find(data.roots, (root, host) => matches(baseHost, host)),
334 [data])
335 if (root)
@@ -397,7 +397,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
397 export async function changeBaseUrl() {
398 return new Promise(async resolve => {
399 const res = await apiCall('get_status')
400 - const { base_url, roots } = await apiCall('get_config', { only: ['base_url', 'roots'] })
400 + const { base_url, roots } = await apiCall('get_config', { only: [CFG.base_url, CFG.roots] })
401 const urls: string[] = res.urls.https || res.urls.http
402 const domainsFromRoots = Object.keys(roots).map(x => x.split('|')).flat().filter(x => !/[*?]/.test(x))
403 const proto = splitAt('//', urls[0])[0] + '//'
@@ -443,7 +443,7 @@ export async function changeBaseUrl() {
443 children: "Save",
444 async onClick() {
445 if (v !== base_url)
446 - await apiCall('set_config', { values: { base_url: v.replace(/\/$/, '') } })
446 + await apiCall('set_config', { values: { [CFG.base_url]: v.replace(/\/$/, '') } })
447 resolve(v)
448 close()
449 },
admin/src/InternetPage.ts
+6 -6
@@ -33,8 +33,8 @@ export default function InternetPage({ setTitleSide }: PageProps) {
33 const [checking, setChecking] = useState(false)
34 const [mapping, setMapping] = useState(false)
35 const status = useApiEx('get_status')
36 - const config = useApiEx('get_config', { only: ['base_url'] })
37 - const base_url = config.data?.base_url
36 + const config = useApiEx('get_config', { only: [CFG.base_url] })
37 + const baseUrl = config.data?.[CFG.base_url]
38 const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
39 h && s ? 'error' : h || s ? 'warning' : 'success')
40 type GetNat = Awaited<ReturnType<typeof getNatInfo>>
@@ -249,7 +249,7 @@ export default function InternetPage({ setTitleSide }: PageProps) {
249 return config.element || h(TitleCard, { icon: Public, title: "Address" },
250 h(Flex, { flexWrap: 'wrap' },
251 "Main address: ",
252 - base_url ? h('tt', {}, base_url) : "automatic, not configured",
252 + baseUrl ? h('tt', {}, baseUrl) : "automatic, not configured",
253 h(Btn, {
254 size: 'small',
255 variant: 'outlined',
@@ -311,7 +311,7 @@ export default function InternetPage({ setTitleSide }: PageProps) {
311 doubleNat && h(LinkBtn, { display: 'block', onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
312 checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
313 ' ',
314 - (base_url > '' || publicIps.length > 0) && data.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify")
314 + (baseUrl > '' || publicIps.length > 0) && data.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify")
315 )
316 }),
317 )
@@ -330,8 +330,8 @@ export default function InternetPage({ setTitleSide }: PageProps) {
330 if (!again && !await confirmDialog("This test will check if your server is working properly on the Internet")) return
331 setChecking(true)
332 try {
333 - const hostname = base_url && new URL(base_url).hostname
334 - const checkUrl = !isIpLan(hostname) && base_url
333 + const hostname = baseUrl && new URL(baseUrl).hostname
334 + const checkUrl = !isIpLan(hostname) && baseUrl
335 if (!isIP(hostname) && await stopOnCheckDomain(hostname)) return
336 const urlResult = checkUrl && await apiCall('self_check', { url: checkUrl }).catch(e =>
337 alertDialog(!e.code ? e : "Sorry, this function is not available at the moment. Retry later.", 'error'))
admin/src/VfsPage.ts
+14 -7
@@ -5,7 +5,7 @@ import { apiCall, useApiEx } from './api'
5 import { Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
6 import { state, useSnapState } from './state'
7 import VfsTree, { vfsNodeIcon } from './VfsTree'
8 -import { newDialog, onlyTruthy, prefix, VfsNodeAdminSend } from './misc'
8 +import { CFG, matches, newDialog, normalizeHost, onlyTruthy, prefix, VfsNodeAdminSend } from './misc'
9 import { Flex, useBreakpoint } from './mui'
10 import { reactJoin } from '@hfs/shared'
11 import _ from 'lodash'
@@ -22,15 +22,23 @@ export default function VfsPage({ setTitleSide }: PageProps) {
22 const { vfs, selectedFiles, movingFile } = useSnapState()
23 const { data, reload, element } = useApiEx('get_vfs')
24 useMemo(() => vfs || reload(), [vfs, reload])
25 + const { data: config } = useApiEx('get_config', { only: [CFG.force_address, CFG.base_url] })
26 const sideBreakpoint = 'md'
27 const isSideBreakpoint = useBreakpoint(sideBreakpoint)
28 const statusApi = useApiEx('get_status')
29 const { data: status } = statusApi
30 const urls = useMemo<string[]>(() => {
30 - const b = status?.baseUrl
31 - const ret = status?.urls.https || status?.urls.http
32 - return b && !ret.includes(b) ? [b, ...ret] : ret
33 - }, [status])
31 + const force = config?.[CFG.force_address] // when force_address, we'll only suggest urls that will be accepted
32 + const ret = (status?.urls.https || status?.urls.http)?.filter((url: string) => {
33 + if (!force) return true
34 + const host = normalizeHost(new URL(url).host)
35 + return Object.keys(status.roots).some(mask => matches(host, mask))
36 + })
37 + const b = force ? config?.[CFG.base_url] : status?.baseUrl // when force_address, we should only consider user-inputted urls, otherwise 'automatic' is also good
38 + if (b && ret && !ret.includes(b))
39 + ret.unshift(b)
40 + return ret
41 + }, [status, config])
42 const single = selectedFiles?.length < 2 && (selectedFiles[0] as VfsNode || vfs)
43 const accountsApi = useApiEx<{ list: Account[] }>('get_accounts') // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
44
@@ -45,7 +53,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
53 const alert: AlertProps | false = useMemo(() => anythingShared ? {
54 severity: 'warning',
55 children: "Add something to your shared files — click Add"
48 - } : urls && {
56 + } : urls?.length > 0 && {
57 severity: 'info',
58 children: [
59 "Your shared files can be browsed from ",
@@ -53,7 +61,6 @@ export default function VfsPage({ setTitleSide }: PageProps) {
61 ]
62 }, [anythingShared, urls])
63
56 -
64 setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
65 h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
66 alert && h(Alert, alert),
src/cross.ts
+5 -1
@@ -28,7 +28,7 @@ export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
28 export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
29 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
30 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
31 - 'allow_session_ip_change', 'force_lang', 'suspend_plugins'])
31 + 'allow_session_ip_change', 'force_lang', 'suspend_plugins', 'base_url'])
32 export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
33 export type Dict<T=any> = Record<string, T>
34 export type Falsy = false | null | undefined | '' | 0
@@ -403,6 +403,10 @@ export function xlate(input: any, table: Record<string, any>) {
403 return table[input] ?? input
404 }
405
406 +export function normalizeHost(host: string) {
407 + return host[0] === '[' ? host.slice(1, host.indexOf(']')) : host?.split(':')[0]
408 +}
409 +
410 export function isIpLocalHost(ip: string) {
411 return ip === '::1' || ip.endsWith('127.0.0.1')
412 }
src/listen.ts
+4 -2
@@ -8,7 +8,9 @@ import { watchLoad } from './watchLoad'
8 import { networkInterfaces } from 'os';
9 import { newConnection } from './connections'
10 import open from 'open'
11 -import { debounceAsync, ipForUrl, makeNetMatcher, MINUTE, objSameKeys, onlyTruthy, prefix, runAt, wait, xlate } from './misc'
11 +import {
12 + CFG, debounceAsync, ipForUrl, makeNetMatcher, MINUTE, objSameKeys, onlyTruthy, prefix, runAt, wait, xlate
13 +} from './misc'
14 import { PORT_DISABLED, ADMIN_URI, argv, DEV, IS_WINDOWS } from './const'
15 import findProcess from 'find-process'
16 import { anyAccountCanLoginAdmin } from './adminApis'
@@ -25,7 +27,7 @@ let httpsSrv: undefined | http.Server & ServerExtra
27
28 const openBrowserAtStart = defineConfig('open_browser_at_start', !DEV)
29
28 -export const baseUrl = defineConfig('base_url', '',
30 +export const baseUrl = defineConfig(CFG.base_url, '',
31 x => /(?<=\/\/)[^\/]+/.exec(x)?.[0]) // compiled is host only
32
33 export async function getBaseUrlOrDefault() {
src/serveFile.ts
+2 -7
@@ -7,7 +7,7 @@ import { HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONT
7 import { getNodeName, VfsNode } from './vfs'
8 import mimetypes from 'mime-types'
9 import { defineConfig } from './config'
10 -import { CFG, Dict, makeMatcher, matches, with_ } from './misc'
10 +import { CFG, Dict, makeMatcher, matches, normalizeHost, with_ } from './misc'
11 import _ from 'lodash'
12 import { basename } from 'path'
13 import { promisify } from 'util'
@@ -40,7 +40,7 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
40 : _.find(mime, (val,mask) => matches(name, mask))
41 if (allowedReferer.get()) {
42 const ref = /\/\/([^:/]+)/.exec(ctx.get('referer'))?.[1] // extract host from url
43 - if (ref && ref !== host() // automatically accept if referer is basically the hosting domain
43 + if (ref && ref !== normalizeHost(ctx.host) // automatically accept if referer is basically the hosting domain
44 && !matches(ref, allowedReferer.get()))
45 return ctx.status = HTTP_FORBIDDEN
46 }
@@ -55,11 +55,6 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
55
56 if (await maxDownloadsPerAccount(ctx) === undefined) // returning false will not execute other limits
57 await maxDownloads(ctx) || await maxDownloadsPerIp(ctx)
58 -
59 - function host() {
60 - const s = ctx.host
61 - return s[0] === '[' ? s.slice(1, s.indexOf(']')) : s?.split(':')[0]
62 - }
58 }
59
60 const mimeCfg = defineConfig<Dict<string>, (name: string) => string | undefined>('mime', {}, obj => {