@samitouri / QOSami-HFS / commits / d40bef77

admin/home: random plugin

Massimo Melina committed Sep 3, 2025 at 01:33 UTC d40bef77d06dcaf77761fde98debd1a36b2ab379
5 files changed +192 -125
admin/src/HomePage.ts
+90 -86
@@ -23,6 +23,7 @@ import { CheckboxField } from '@hfs/mui-grid-form'
23 import { ConfigForm } from './ConfigForm'
24 import { Release } from '../../src/update'
25 import { adminApis } from '../../src/adminApis'
26 +import { RandomPlugin } from './RandomPlugin'
27
28 export default function HomePage() {
29 const SOLUTION_SEP = " — "
@@ -64,94 +65,97 @@ export default function HomePage() {
65 },
66 title: status.updatePossible && "Right-click if you want to install a zip",
67 }
67 - return h(Box, { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' },
68 - account?.adminActualAccess ? entry('', "Welcome, "+username)
69 - : entry('', md("On <u>localhost</u> you don't need to login"),
70 - SOLUTION_SEP, "to access Admin-panel from another computer ", h(InLink, { to:'accounts' }, md("create an account with *admin* permission")) ),
71 - dontBotherWithKeys(status.alerts?.map(x => entry('warning', md(x, { html: false })))),
72 - errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg))))
73 - : entry('success', "Server is working"),
74 - !href && entry('warning', "Frontend unreachable: ",
75 - _.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '),
76 - !errors.length && [ SOLUTION_SEP, cfgLink("switch http or https on") ]
77 - ),
78 - with_(status.acmeRenewError, x => x && entry('warning', x)),
79 - with_(status.blacklistedInstalledPlugins, x => x?.length > 0
80 - && entry('warning', "Found blacklisted plugin(s): ", x.join(', ')) ),
81 - with_(plugins?.filter(x => x.error || x.badApi).length, x => x > 0
82 - && entry('warning', `${x} plugin(s) failing`, SOLUTION_SEP, h(InLink, { to:'plugins' }, "check now"))),
83 - !cfg.data?.split_uploads && (Date.now() - Number(status.cloudflareDetected || 0)) < DAY
84 - && entry('', wikiLink('Reverse-proxy#cloudflare', "Cloudflare detected, read our guide")),
85 - !vfs ? h(LinearProgress)
86 - : !vfs.root?.children?.length && !vfs.root?.source ? entry('warning', "You have no files shared", SOLUTION_SEP, fsLink("add some"))
87 - : entry('', md("This is the Admin-panel, where you manage your server. Access your files on "),
88 - h(Link, { target:'frontend', href: '../..' }, "Front-end", h(Launch, { sx: { verticalAlign: 'sub', ml: '.2em' } }))),
68 + return h(Box, {},
69 + h(RandomPlugin),
70 + h(Box, { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' },
71 + account?.adminActualAccess ? entry('', "Welcome, "+username)
72 + : entry('', md("On <u>localhost</u> you don't need to login"),
73 + SOLUTION_SEP, "to access Admin-panel from another computer ", h(InLink, { to:'accounts' }, md("create an account with *admin* permission")) ),
74 + dontBotherWithKeys(status.alerts?.map(x => entry('warning', md(x, { html: false })))),
75 + errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg))))
76 + : entry('success', "Server is working"),
77 + !href && entry('warning', "Frontend unreachable: ",
78 + _.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '),
79 + !errors.length && [ SOLUTION_SEP, cfgLink("switch http or https on") ]
80 + ),
81 + with_(status.acmeRenewError, x => x && entry('warning', x)),
82 + with_(status.blacklistedInstalledPlugins, x => x?.length > 0
83 + && entry('warning', "Found blacklisted plugin(s): ", x.join(', ')) ),
84 + with_(plugins?.filter(x => x.error || x.badApi).length, x => x > 0
85 + && entry('warning', `${x} plugin(s) failing`, SOLUTION_SEP, h(InLink, { to:'plugins' }, "check now"))),
86 + !cfg.data?.split_uploads && (Date.now() - Number(status.cloudflareDetected || 0)) < DAY
87 + && entry('', wikiLink('Reverse-proxy#cloudflare', "Cloudflare detected, read our guide")),
88 + !vfs ? h(LinearProgress)
89 + : !vfs.root?.children?.length && !vfs.root?.source ? entry('warning', "You have no files shared", SOLUTION_SEP, fsLink("add some"))
90 + : entry('', md("This is the Admin-panel, where you manage your server. Access your files on "),
91 + h(Link, { target:'frontend', href: '../..' }, "Front-end", h(Launch, { sx: { verticalAlign: 'sub', ml: '.2em' } }))),
92
90 - with_(proxyWarning(cfg.data, status), x => x && entry('warning', x,
91 - SOLUTION_SEP, cfgLink("set the number of proxies"),
92 - SOLUTION_SEP, "unless you are sure and you can ", h(Btn, {
93 - variant: 'outlined',
94 - size: 'small',
95 - sx: { lineHeight: 'unset' }, // fit in the line, avoiding bad layout
96 - confirm: "Go on only if you know what you are doing",
97 - onClick: () => apiCall('set_config', { values: { ignore_proxies: true } }).then(cfg.reload)
98 - }, "ignore this warning"),
99 - SOLUTION_SEP, wikiLink('Proxy-warning', "Explanation")
100 - )),
101 - (cfg.data?.proxies > 0 || status?.proxyDetected) && entry('', wikiLink('Reverse-proxy', "Read our guide on proxies")),
102 - status.frpDetected && entry('warning', `FRP is detected. It should not be used with "type = tcp" with HFS. Possible solutions are`,
103 - h('ol',{},
104 - h('li',{}, `configure FRP with type=http (best solution)`),
105 - h('li',{}, md(`configure FRP to connect to HFS <u>not</u> with localhost (safe, but you won't see users' IPs)`)),
106 - h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`),
93 + with_(proxyWarning(cfg.data, status), x => x && entry('warning', x,
94 + SOLUTION_SEP, cfgLink("set the number of proxies"),
95 + SOLUTION_SEP, "unless you are sure and you can ", h(Btn, {
96 + variant: 'outlined',
97 + size: 'small',
98 + sx: { lineHeight: 'unset' }, // fit in the line, avoiding bad layout
99 + confirm: "Go on only if you know what you are doing",
100 + onClick: () => apiCall('set_config', { values: { ignore_proxies: true } }).then(cfg.reload)
101 + }, "ignore this warning"),
102 + SOLUTION_SEP, wikiLink('Proxy-warning', "Explanation")
103 )),
108 - entry('', wikiLink('', "See the documentation"), " and ", h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "get support")),
109 - !updates && with_(status.autoCheckUpdateResult, x => x?.isNewer && h(Update, { info: x, bodyCollapsed: true, title: "An update has been found" })),
110 - pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')),
111 - h(ConfigForm, {
112 - gridProps: { sx: { mt: 1, display: 'flex', columnGap: 1, alignitems: 'center', '&>div.MuiGrid2-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 - }, '❤️')
104 + (cfg.data?.proxies > 0 || status?.proxyDetected) && entry('', wikiLink('Reverse-proxy', "Read our guide on proxies")),
105 + status.frpDetected && entry('warning', `FRP is detected. It should not be used with "type = tcp" with HFS. Possible solutions are`,
106 + h('ol',{},
107 + h('li',{}, `configure FRP with type=http (best solution)`),
108 + h('li',{}, md(`configure FRP to connect to HFS <u>not</u> with localhost (safe, but you won't see users' IPs)`)),
109 + h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`),
110 + )),
111 + entry('', wikiLink('', "See the documentation"), " and ", h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "get support")),
112 + !updates && with_(status.autoCheckUpdateResult, x => x?.isNewer && h(Update, { info: x, bodyCollapsed: true, title: "An update has been found" })),
113 + pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')),
114 + h(ConfigForm, {
115 + gridProps: { sx: { mt: 1, display: 'flex', columnGap: 1, alignitems: 'center', '&>div.MuiGrid2-root': { width: 'auto', px: .5, py: 0 }, '.MuiCheckbox-root': { pl: '2px' } } },
116 + saveOnChange: true,
117 + form: {
118 + fields: [
119 + status.updatePossible === 'local' ? h(Btn, { icon: UpdateIcon, onClick: () => update() }, "Update from local file")
120 + : !updates && h(Btn, {
121 + icon: UpdateIcon,
122 + onClick() {
123 + apiCall('wait_project_info').then(reloadStatus)
124 + setCheckPlugins(true) // this only happens once, actually (until you change page)
125 + return apiCall<typeof adminApis.check_update>('check_update').then(x => setUpdates(x.options), alertDialog)
126 + },
127 + ...rightClickToInstallFromUrl
128 + }, "Check for updates"),
129 + { k: 'auto_check_update', comp: CheckboxField, label: "Auto check updates daily" },
130 + { k: 'update_to_beta', comp: CheckboxField, label: "Include beta versions" },
131 + ]
132 + }
133 + }),
134 + updates && with_(_.find(updates, 'isNewer'), newer =>
135 + !updates.length || !status.updatePossible && !newer ? entry('', "No update available")
136 + : newer && !status.updatePossible ? entry('success', `Version ${newer.name} available`)
137 + : h(Flex, { vert: true },
138 + updates.map((x: any) => h(Update, { info: x, key: x.name })) ),
139 + ),
140 + h(Flex, { flexWrap: 'wrap' },
141 + !otherVersions && status.updatePossible && status.previousVersionAvailable
142 + && h(Btn, { icon: Restore, onClick: () => update(PREVIOUS_TAG) }, "Reinstall previous version"),
143 + !status.updatePossible ? entry('', h(Link, { href: REPO_URL + 'releases/', target: 'repo' }, "All releases"))
144 + : !otherVersions ? h(Btn, { icon: Colorize, onClick: getOtherVersions, ...rightClickToInstallFromUrl }, "Get another version")
145 + : h(Flex, { vert: true }, otherVersions.map((x: any) => h(Update, {
146 + info: x,
147 + key: x.name,
148 + bodyCollapsed: true
149 + }))),
150 + ),
151 + h(SwitchThemeBtn),
152 + Date.now() - Number(new Date(status.started)) > HOUR && h(Link, {
153 + title: "Donate",
154 + target: 'donate',
155 + style: { textDecoration: 'none', position: 'fixed', bottom: 0, right: 4, fontSize: 'large' },
156 + href: 'https://www.paypal.com/donate/?hosted_button_id=HC8MB4GRVU5T2'
157 + }, '❤️')
158 + )
159 )
160
161 async function getOtherVersions() {
admin/src/InstalledPlugins.ts
+1 -1
@@ -225,7 +225,7 @@ function getSingleConfig(k: string) {
225 }
226
227 // hide the hfs- prefix, as one may want to use it for its repository, because github is the context, but in the hfs context the prefix it's not only redundant, but also ruins the sorting
228 -function treatPluginName(name: string) {
228 +export function treatPluginName(name: string) {
229 return name.replace(/hfs-/, '')
230 }
231
admin/src/OnlinePlugins.ts
+41 -37
@@ -80,22 +80,7 @@ export default function OnlinePlugins() {
80 progress: row.downloading,
81 disabled: row.installed && "Already installed",
82 tooltipProps: { placement:'bottom-end' }, // workaround problem with horizontal scrolling by moving the tooltip leftward
83 - confirm: !id.startsWith(HFS_GITHUB_ACCOUNT) && h(Flex, { vert: true, alignItems: 'center' },
84 - h(Warning, { color: 'warning', fontSize: 'large' }),
85 - "Proceed only if you trust this plugin",
86 - h(Box, { fontSize: '60%' }, "A plugin has the same power of any other software"),
87 - ),
88 - async onClick() {
89 - if (row.missing && !await confirmDialog("This will also install: " + _.map(row.missing, 'repo').join(', '))) return
90 - const branch = row.branch || row.default_branch
91 - return installPlugin(id, branch).catch((e: any) => {
92 - if (e.code !== HTTP_FAILED_DEPENDENCY)
93 - return alertDialog(e)
94 - const msg = h(Fragment, {}, "This plugin has some dependencies unmet:",
95 - e.data.map((x: any) => h('li', { key: x.repo }, x.repo + ': ' + x.error)) )
96 - return alertDialog(msg, 'error')
97 - })
98 - }
83 + onClick: () => installPluginFromResult(row)
84 }),
85 h(IconBtn, {
86 icon: RemoveRedEye,
@@ -109,27 +94,6 @@ export default function OnlinePlugins() {
94 ]
95 })
96 )
112 -
113 - async function installPlugin(id: string, branch?: string): Promise<any> {
114 - try {
115 - const res = await apiCall('download_plugin', { id, branch, stop: true }, { timeout: false })
116 - if (await confirmDialog(`Plugin ${id} downloaded`, { trueText: "Start" }))
117 - await startPlugin(res.id)
118 - }
119 - catch(e:any) {
120 - let done = false
121 - if (e.code === HTTP_FAILED_DEPENDENCY) // try to install automatically
122 - for (const x of e.cause)
123 - if (x.error === 'missing') {
124 - toast("Installing dependency: " + x.repo)
125 - await installPlugin(x.repo)
126 - done = true
127 - }
128 - if (done) // try again
129 - return installPlugin(id, branch)
130 - throw e
131 - }
132 - }
97 }
98
99 function ShowImages({ imgs }: { imgs: string[] }) {
@@ -143,4 +107,44 @@ function ShowImages({ imgs }: { imgs: string[] }) {
107 h(IconBtn, { icon: ArrowForward, disabled: cur >= imgs.length - 1, onClick: () => setCur(cur + 1) }),
108 ),
109 )
110 +}
111 +
112 +export async function installPluginFromResult(row: any) {
113 + if (!row.id.startsWith(HFS_GITHUB_ACCOUNT))
114 + if (!await confirmDialog(
115 + h(Flex, { vert: true, alignItems: 'center' },
116 + h(Warning, { color: 'warning', fontSize: 'large' }),
117 + "Proceed only if you trust this plugin",
118 + h(Box, { fontSize: '60%' }, "A plugin has the same power of any other software"),
119 + ))) return
120 + if (row.missing && !await confirmDialog("This will also install: " + _.map(row.missing, 'repo').join(', '))) return
121 + const branch = row.branch || row.default_branch
122 + return installPlugin(row.id, branch).catch((e: any) => {
123 + if (e.code !== HTTP_FAILED_DEPENDENCY)
124 + return alertDialog(e)
125 + const msg = h(Fragment, {}, "This plugin has some dependencies unmet:",
126 + e.data.map((x: any) => h('li', { key: x.repo }, x.repo + ': ' + x.error)) )
127 + return alertDialog(msg, 'error')
128 + })
129 +}
130 +
131 +async function installPlugin(id: string, branch?: string): Promise<any> {
132 + try {
133 + const res = await apiCall('download_plugin', { id, branch, stop: true }, { timeout: false })
134 + if (await confirmDialog(`Plugin ${id} downloaded`, { trueText: "Start" }))
135 + await startPlugin(res.id)
136 + }
137 + catch(e:any) {
138 + let done = false
139 + if (e.code === HTTP_FAILED_DEPENDENCY) // try to install automatically
140 + for (const x of e.cause)
141 + if (x.error === 'missing') {
142 + toast("Installing dependency: " + x.repo)
143 + await installPlugin(x.repo)
144 + done = true
145 + }
146 + if (done) // try again
147 + return installPlugin(id, branch)
148 + throw e
149 + }
150 }
\ No newline at end of file
admin/src/RandomPlugin.ts new
+56
@@ -0,0 +1,56 @@
1 +import { createElement as h, useEffect, useMemo, useState } from 'react'
2 +import { Box, Card, CardActions, CardContent } from '@mui/material'
3 +import { Btn } from './mui'
4 +import { state, useSnapState } from './state'
5 +import { useApiList } from './api'
6 +import { DAY, tryJson } from '../../src/cross'
7 +import _ from 'lodash'
8 +import { renderName } from './InstalledPlugins'
9 +import { installPluginFromResult } from './OnlinePlugins'
10 +import { toast } from './dialog'
11 +
12 +const cacheKey = 'onlinePluginsCache'
13 +
14 +export function RandomPlugin() {
15 + const { hideRandomPlugin } = useSnapState()
16 + const serializedCache = localStorage.getItem(cacheKey)
17 + const cached = useMemo(() => {
18 + const obj = tryJson(serializedCache || '')
19 + return obj?.ts && Date.now() - obj.ts < DAY && _.shuffle(obj.list)
20 + }, [serializedCache])
21 + const { list, initializing } = useApiList(!hideRandomPlugin && !cached && 'get_online_plugins')
22 + const [idx, setIdx] = useState(-1)
23 + const one = cached?.[idx]
24 + useEffect(() => {
25 + if (!cached && !initializing && list.length)
26 + localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), list }))
27 + setIdx(0)
28 + }, [list, initializing])
29 + useEffect(() => {
30 + if (idx > 0 && !one)
31 + toast("No more plugins!")
32 + }, [idx])
33 + if (hideRandomPlugin || !one) return
34 + return h(Card, { sx: { display: { xs: 'none', md: 'block' }, float: 'right', width: 'min(50%, 30em)', ml: '1em' } },
35 + h(CardContent, {},
36 + h(Box, { fontWeight: 'bold', fontSize: '1.4em' }, h(Box, { color: 'warning.main', mr: 1, display: 'inline' }, '★'), "Random plugin:"),
37 + h(Box, { fontWeight: 'bold', fontSize: '1.8em', my: 1 }, renderName({ row: one })),
38 + h(Box, {}, one.description),
39 + one.preview && h('img', {
40 + src: one.preview[0],
41 + style: {
42 + maxWidth: '100%',
43 + maxHeight: '50vh',
44 + marginTop: '1em',
45 + border: '1px solid',
46 + maskImage: 'radial-gradient(circle at center, black 50%, transparent 100%)'
47 + }
48 + }),
49 + ),
50 + h(CardActions, {},
51 + h(Btn, { variant: 'outlined', onClick: () => installPluginFromResult(one) }, "Install"),
52 + h(Btn, { variant: 'outlined', onClick: () => setIdx(x => x + 1) }, "Another"),
53 + h(Btn, { variant: 'outlined', onClick() { state.hideRandomPlugin = true } }, "Hide this box"),
54 + )
55 + )
56 +}
\ No newline at end of file
admin/src/state.ts
+4 -1
@@ -22,6 +22,7 @@ const INIT = {
22 customHtmlSection: '',
23 darkTheme: undefined as undefined | boolean,
24 dataTablePersistence: {} as any,
25 + hideRandomPlugin: false,
26 onlinePluginsColumns: {
27 version: false,
28 pushed_at: false,
@@ -30,8 +31,10 @@ const INIT = {
31 }
32 Object.assign(INIT, JSON.parse(localStorage[STORAGE_KEY]||null))
33 export const state = proxy(INIT)
34 +Object.assign(window, { state })
35
34 -const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'monitorWithLog', 'customHtmlSection', 'darkTheme', 'dataTablePersistence', 'accountsAsTree']
36 +const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'monitorWithLog',
37 + 'customHtmlSection', 'darkTheme', 'dataTablePersistence', 'accountsAsTree', 'hideRandomPlugin']
38 const storeSettings = _.debounce(() =>
39 localStorage[STORAGE_KEY] = JSON.stringify(_.pick(state, SETTINGS_TO_STORE)), 500, { maxWait: 1000 })
40 for (const k of SETTINGS_TO_STORE)