main
ts 240 lines 14.2 KB
Raw
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 }