| 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, useState } from 'react' |
| 4 | import { Box, Card, CardContent, Link } from '@mui/material' |
| 5 | import { apiCall, useApiEx, useApiList } from './api' |
| 6 | import { |
| 7 | dontBotherWithKeys, onlyTruthy, prefix, REPO_URL, md, |
| 8 | replaceStringToReact, wait, with_, DAY, HOUR, PREVIOUS_TAG |
| 9 | } from './misc' |
| 10 | import { Btn, Flex, InLink, LinkBtn, wikiLink, } from './mui' |
| 11 | import { |
| 12 | BrowserUpdated as UpdateIcon, CheckCircle, Colorize, Error, Info, OpenInNew, Restore, Warning |
| 13 | } from '@mui/icons-material' |
| 14 | import { state, useSnapState } from './state' |
| 15 | import { alertDialog, confirmDialog, promptDialog, toast } from './dialog' |
| 16 | import { isCertError, isKeyError, suggestMakingCert } from './OptionsPage' |
| 17 | import _ from 'lodash' |
| 18 | import { subscribeKey } from 'valtio/utils' |
| 19 | import { SwitchThemeBtn } from './theme' |
| 20 | import { CheckboxField } from '@hfs/mui-grid-form' |
| 21 | import { ConfigForm } from './ConfigForm' |
| 22 | import { Release } from '../../src/update' |
| 23 | import { adminApis } from '../../src/adminApis' |
| 24 | import { RandomPlugin } from './RandomPlugin' |
| 25 | |
| 26 | export default function HomePage() { |
| 27 | const SOLUTION_SEP = " — " |
| 28 | const { username } = useSnapState() |
| 29 | const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<typeof adminApis.get_status>('get_status') |
| 30 | const { data: account } = useApiEx<typeof adminApis.get_account>(username && 'get_account') |
| 31 | const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'ignore_proxies', 'vfs'] }) |
| 32 | const { list: plugins } = useApiList('get_plugins') |
| 33 | const [checkPlugins, setCheckPlugins] = useState(false) |
| 34 | const { list: pluginUpdates} = useApiList(checkPlugins && 'get_plugin_updates') |
| 35 | const [updates, setUpdates] = useState<undefined | Release[]>() |
| 36 | const [otherVersions, setOtherVersions] = useState<undefined | Release[]>() |
| 37 | if (statusEl || !status) // !status here to shut up ts |
| 38 | return statusEl |
| 39 | const { http, https } = status |
| 40 | const goSecure = !http?.listening && https?.listening ? 's' : '' |
| 41 | const srv = goSecure ? https : (http?.listening && http) |
| 42 | const href = srv && `http${goSecure}://`+window.location.hostname + (srv.port === (goSecure ? 443 : 80) ? '' : ':'+srv.port) |
| 43 | const serverErrors = _.mapValues({ http, https }, v => |
| 44 | v.busy ? [`port ${v.configuredPort} already used by ${v.busy}${SOLUTION_SEP}choose a `, cfgLink('different port'), ` or stop ${v.busy}`] |
| 45 | : v.error ) |
| 46 | const errors = serverErrors && onlyTruthy(Object.entries(serverErrors).map(([k,v]) => |
| 47 | v && [md(`Protocol <u>${k}</u>: `), v, |
| 48 | (isCertError(v) || isKeyError(v)) && [ |
| 49 | SOLUTION_SEP, h(LinkBtn, { |
| 50 | onClick() { suggestMakingCert().then(() => wait(999)).then(cfg.reload).then(reloadStatus) } }, |
| 51 | "make one" |
| 52 | ), " or ", SOLUTION_SEP, cfgLink("provide adequate files") |
| 53 | ]])) |
| 54 | const rightClickToInstallFromUrl = { |
| 55 | async onContextMenu(ev: any) { |
| 56 | ev.preventDefault() |
| 57 | if (!status.updatePossible) |
| 58 | return alertDialog("Automatic update is not supported for your installation", 'warning') |
| 59 | const res = await promptDialog("Enter a link to the zip to install") |
| 60 | if (res) |
| 61 | await update(res) |
| 62 | }, |
| 63 | title: status.updatePossible && "Right-click if you want to install a zip", |
| 64 | } |
| 65 | const vfs = cfg.data?.vfs |
| 66 | return h(Box, {}, |
| 67 | h(RandomPlugin), |
| 68 | h(Box, { sx: { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' } }, |
| 69 | dontBotherWithKeys(status.alerts?.map(x => entry('warning', md(x, { html: false }))) || []), |
| 70 | errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg)))) |
| 71 | : entry('success', "Server is working"), |
| 72 | vfs && !vfs.children?.length && !vfs.source ? entry('warning', "You have no shared files", SOLUTION_SEP, fsLink("add some")) : null, |
| 73 | account?.adminActualAccess ? entry('', "Welcome, "+username) |
| 74 | : entry('', md("You're accessing the Admin-panel without an account because you are on localhost"), |
| 75 | ...status.anyAccountCanLoginAdmin ? [] : [SOLUTION_SEP, "to access from another computer, you must ", h(InLink, { to:'/accounts' }, md("create an account with *admin* permission"))] ), |
| 76 | !href && entry('warning', "Frontend unreachable: ", |
| 77 | _.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '), |
| 78 | !errors.length && [ SOLUTION_SEP, cfgLink("switch http or https on") ] |
| 79 | ), |
| 80 | with_(status.acmeRenewError, x => x && entry('warning', x)), |
| 81 | with_(status.blacklistedInstalledPlugins, x => x?.length > 0 |
| 82 | && entry('warning', "Found blacklisted plugin(s): ", x.join(', ')) ), |
| 83 | with_(plugins?.filter(x => x.error || x.badApi).length, x => x > 0 |
| 84 | && entry('warning', `${x} plugin(s) failing`, SOLUTION_SEP, h(InLink, { to:'/plugins' }, "check now"))), |
| 85 | !cfg.data?.split_uploads && (Date.now() - Number(status.cloudflareDetected || 0)) < DAY |
| 86 | && entry('', wikiLink('Reverse-proxy#cloudflare', "Cloudflare detected, read our guide")), |
| 87 | with_(proxyWarning(cfg.data, status), x => x && entry('warning', x, |
| 88 | SOLUTION_SEP, cfgLink("set the number of proxies"), |
| 89 | SOLUTION_SEP, "unless you are sure and you can ", h(Btn, { |
| 90 | variant: 'outlined', |
| 91 | size: 'small', |
| 92 | sx: { lineHeight: 'unset' }, // fit in the line, avoiding bad layout |
| 93 | confirm: "Go on only if you know what you are doing", |
| 94 | onClick: () => apiCall('set_config', { values: { ignore_proxies: true } }).then(cfg.reload) |
| 95 | }, "ignore this warning"), |
| 96 | SOLUTION_SEP, wikiLink('Proxy-warning', "Explanation") |
| 97 | )), |
| 98 | (cfg.data?.proxies > 0 || status?.proxyDetected) && entry('', wikiLink('Reverse-proxy', "Read our guide on proxies")), |
| 99 | status.frpDetected && entry('warning', `FRP is detected. It should not be used with "type = tcp" with HFS. Possible solutions are`, |
| 100 | h('ol',{}, |
| 101 | h('li',{}, `configure FRP with type=http (best solution)`), |
| 102 | h('li',{}, md(`configure FRP to connect to HFS <u>not</u> with localhost (safe, but you won't see users' IPs)`)), |
| 103 | h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`), |
| 104 | )), |
| 105 | entry('', md("This is the *Admin-panel*, where you manage your server. Access your files on the [Front-end](../..).")), |
| 106 | entry('', wikiLink('', "See the documentation"), " and ", h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "get support")), |
| 107 | !updates && with_(status.autoCheckUpdateResult, x => |
| 108 | x?.isNewer && h(Update, { info: x, fromAuto: true, bodyCollapsed: true, title: "An update has been found" }) ), |
| 109 | pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')), |
| 110 | h(ConfigForm, { |
| 111 | // MUI 7 folded Grid2 into Grid, so the generated class name changed with the import path. |
| 112 | gridProps: { sx: { mt: 1, display: 'flex', columnGap: 1, alignitems: 'center', '&>div.MuiGrid-root': { width: 'auto', px: .5, py: 0 }, '.MuiCheckbox-root': { pl: '2px' } } }, |
| 113 | saveOnChange: true, |
| 114 | form: { |
| 115 | fields: [ |
| 116 | status.updatePossible === 'local' ? h(Btn, { icon: UpdateIcon, onClick: () => update() }, "Update from local file") |
| 117 | : !updates && h(Btn, { |
| 118 | icon: UpdateIcon, |
| 119 | onClick() { |
| 120 | apiCall('wait_project_info').then(reloadStatus) |
| 121 | setCheckPlugins(true) // this only happens once, actually (until you change page) |
| 122 | return apiCall<typeof adminApis.check_update>('check_update').then(x => setUpdates(x.options), alertDialog) |
| 123 | }, |
| 124 | ...rightClickToInstallFromUrl |
| 125 | }, "Check for updates"), |
| 126 | { k: 'auto_check_update', comp: CheckboxField, label: "Auto check updates daily" }, |
| 127 | { k: 'update_to_beta', comp: CheckboxField, label: "Include beta versions" }, |
| 128 | ] |
| 129 | } |
| 130 | }), |
| 131 | updates && with_(_.find(updates, 'isNewer'), newer => |
| 132 | !updates.length || !status.updatePossible && !newer ? entry('', "No update available") |
| 133 | : newer && !status.updatePossible ? entry('success', `Version ${newer.name} available`) |
| 134 | : h(Flex, { vert: true }, |
| 135 | updates.map((x: any) => h(Update, { info: x, key: x.name })) ), |
| 136 | ), |
| 137 | h(Flex, { flexWrap: 'wrap' }, |
| 138 | !otherVersions && status.updatePossible && status.previousVersionAvailable |
| 139 | && h(Btn, { icon: Restore, onClick: () => update(PREVIOUS_TAG) }, "Reinstall previous version"), |
| 140 | !status.updatePossible ? entry('', h(Link, { href: REPO_URL + 'releases/', target: 'repo' }, "All releases")) |
| 141 | : !otherVersions ? h(Btn, { icon: Colorize, onClick: getOtherVersions, ...rightClickToInstallFromUrl }, "Get another version") |
| 142 | : h(Flex, { vert: true }, otherVersions.map((x: any) => h(Update, { |
| 143 | info: x, |
| 144 | key: x.name, |
| 145 | bodyCollapsed: true |
| 146 | }))), |
| 147 | ), |
| 148 | h(SwitchThemeBtn), |
| 149 | Date.now() - Number(new Date(status.started)) > HOUR && h(Link, { |
| 150 | title: "Donate", |
| 151 | target: 'donate', |
| 152 | style: { textDecoration: 'none', position: 'fixed', bottom: 0, right: 4, fontSize: 'large' }, |
| 153 | href: 'https://www.paypal.com/donate/?hosted_button_id=HC8MB4GRVU5T2' |
| 154 | }, '❤️') |
| 155 | ) |
| 156 | ) |
| 157 | |
| 158 | async function getOtherVersions() { |
| 159 | return apiCall<typeof adminApis.get_other_versions>('get_other_versions') |
| 160 | .then(x => setOtherVersions(x.options), alertDialog) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | function Update({ info, title, bodyCollapsed, fromAuto }: { title?: ReactNode, info: Release, bodyCollapsed?: boolean, fromAuto?: true }) { |
| 165 | const [collapsed, setCollapsed] = useState(bodyCollapsed) |
| 166 | return h(Flex, { alignItems: 'flex-start', flexWrap: 'wrap' }, |
| 167 | h(Card, { className: 'release' }, h(CardContent, {}, |
| 168 | h(Flex, {}, |
| 169 | title && h(Box, { sx: { fontSize: 'larger', mb: 1 } }, title), |
| 170 | h(Btn, { |
| 171 | icon: UpdateIcon, |
| 172 | ...!info.isNewer && info.prerelease && { color: 'warning', variant: 'outlined' }, |
| 173 | onClick: () => update(fromAuto ? undefined : info.tag_name) // in case of autoCheck, don't specify the tag_name, as it may have been retired in the meantime (in favor of a newer one) |
| 174 | }, prefix("Install ", info.name, info.isNewer ? '' : " (older)")), |
| 175 | h(Link, { href: REPO_URL + 'releases/tag/' + info.tag_name, target: 'repo' }, h(OpenInNew)), |
| 176 | ), |
| 177 | collapsed ? h(LinkBtn, { sx: { display: 'block', mt: 1 }, onClick(){ setCollapsed(false) } }, "See details") |
| 178 | : h(Box, { sx: { mt: 1 } }, renderChangelog(info.body)) |
| 179 | )), |
| 180 | ) |
| 181 | } |
| 182 | |
| 183 | function renderChangelog(s: string) { |
| 184 | return md(s, { |
| 185 | onText: s => replaceStringToReact(s, /(?<=^|\W)#(\d+)\b|(https:.*\S+)/g, m => // link issues and urls |
| 186 | m[1] ? h(Link, { href: REPO_URL + 'issues/' + m[1], target: '_blank' }, h(OpenInNew, { fontSize: 'small' }) ) |
| 187 | : h(Link, { href: m[2], target: '_blank' }, m[2] ) |
| 188 | ) |
| 189 | }) |
| 190 | } |
| 191 | |
| 192 | async function update(tag?: string) { |
| 193 | if (!await confirmDialog("Installation may take less than a minute, depending on the speed of your server")) return |
| 194 | toast('Downloading') |
| 195 | const err = await apiCall('update', { tag }, { timeout: 600 /*download can be lengthy*/ }) |
| 196 | .then(() => 0, e => e) |
| 197 | if (err) |
| 198 | return alertDialog(err) |
| 199 | toast("Restarting") |
| 200 | const restarting = Date.now() |
| 201 | let warning: undefined | ReturnType<typeof alertDialog> |
| 202 | while (await apiCall('NONE').then(() => 0, e => !e.code)) { // while we get no response |
| 203 | if (!warning && Date.now() - restarting > 15_000) |
| 204 | warning = alertDialog("This is taking too long, please check your server", 'warning') |
| 205 | await wait(500) |
| 206 | } |
| 207 | warning?.close() |
| 208 | // the server is back on, SSE is restored and login dialog may appear, unwanted because we are just waiting to reload |
| 209 | subscribeKey(state, 'loginRequired', () => state.loginRequired = false) |
| 210 | await alertDialog("Procedure complete", 'success') |
| 211 | window.location.reload() // show new gui |
| 212 | } |
| 213 | |
| 214 | type Color = '' | 'success' | 'warning' | 'error' |
| 215 | |
| 216 | function entry(color: Color, ...content: ReactNode[]) { |
| 217 | return h(Box, { |
| 218 | sx: { fontSize: 'x-large', color: th => color && th.palette[color]?.main }, |
| 219 | }, |
| 220 | h(({ success: CheckCircle, info: Info, '': Info, warning: Warning, error: Error })[color], { |
| 221 | sx: { mr: 1, color: color ? undefined : 'primary.main' } |
| 222 | }), |
| 223 | h('span', { style: ['warning', 'error'].includes(color) ? { animation: '.5s blink 2' } : undefined }, |
| 224 | ...content) |
| 225 | ) |
| 226 | } |
| 227 | |
| 228 | function fsLink(text=`File System page`) { |
| 229 | return h(InLink, { to:'/fs' }, text) |
| 230 | } |
| 231 | |
| 232 | function cfgLink(text=`Options page`) { |
| 233 | return h(InLink, { to: '/options' }, text) |
| 234 | } |
| 235 | |
| 236 | export function proxyWarning(cfg: any, status: any) { |
| 237 | return status && cfg && !cfg.ignore_proxies && (!cfg.proxies && status.proxyDetected ? "A proxy was detected but none is configured" |
| 238 | : cfg.proxies && !status.proxyDetected && (Date.now() - +new Date(status.started) > DAY) ? `Proxy count is set to ${cfg.proxies} but none was detected recently. Consider setting it to zero` |
| 239 | : '') |
| 240 | } |