admin/fs: use picker field for root's source

Massimo Melina committed May 15, 2022 at 18:58 UTC 90a35de3426603e72323fec1a85b84e27f16bac3
9 files changed +57 -43
admin/src/FileField.ts
+3 -2
@@ -7,7 +7,7 @@ import { newDialog } from '@hfs/shared/lib/dialogs'
7 import FilePicker from './FilePicker'
8 import { apiCall } from './api'
9
10 -export default function FileField({ value, onChange, ...props }: FieldProps<string>) {
10 +export default function FileField({ value, onChange, files, title, ...props }: FieldProps<string>) {
11 return h(StringField, {
12 ...props,
13 value,
@@ -20,7 +20,7 @@ export default function FileField({ value, onChange, ...props }: FieldProps<stri
20 edge: 'end',
21 onClick() {
22 const close = newDialog({
23 - title: "Pick a file",
23 + title: title ?? (files ? "Pick a file" : "Pick a folder"),
24 dialogProps: { sx:{ minWidth:'min(90vw, 40em)', minHeight: 'calc(100vh - 9em)' } },
25 Content,
26 })
@@ -29,6 +29,7 @@ export default function FileField({ value, onChange, ...props }: FieldProps<stri
29 return h(FilePicker, {
30 multiple: false,
31 folders: false,
32 + files,
33 from: value,
34 async onSelect(sel) {
35 let one = sel?.[0]
admin/src/FileForm.ts
+2 -1
@@ -9,6 +9,7 @@ import { formatBytes, isEqualLax, modifiedSx, onlyTruthy } from './misc'
9 import { reloadVfs, VfsNode, Who } from './VfsPage'
10 import md from './md'
11 import _ from 'lodash'
12 +import FileField from './FileField'
13
14 interface Account { username: string }
15
@@ -80,7 +81,7 @@ export default function FileForm({ file }: { file: VfsNode }) {
81 k: 'name', validate: x => x>'' || `Required`,
82 helperText: source && "You can decide a name that's different from the one on your disk",
83 },
83 - isRoot ? { k: 'source', helperText: "If you specify a folder here, its files will be listed in the home" }
84 + isRoot ? { k: 'source', comp: FileField, files: false, helperText: "If you specify a folder here, its files will be listed in the home" }
85 : (hasSource && { k: 'source', comp: DisplayField, multiline: true }),
86 { k: 'can_read', label:"Who can download", xl: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
87 helperText: "Note: who can't download won't see it in the list"
admin/src/FilePicker.ts
+12 -15
@@ -14,7 +14,7 @@ import {
14 TextField,
15 Typography
16 } from '@mui/material'
17 -import { enforceFinal, formatBytes, isWindowsDrive, spinner, pathJoin, dirname, isAbsolutePath } from './misc'
17 +import { enforceFinal, formatBytes, isWindowsDrive, spinner, Center } from './misc'
18 import { ArrowUpward, Home } from '@mui/icons-material'
19 import { StringField } from './Form'
20 import { FileIcon, FolderIcon } from './VfsTree'
@@ -28,21 +28,19 @@ interface FilePickerProps {
28 multiple?: boolean
29 from?: string
30 folders?: boolean
31 + files?: boolean
32 }
32 -export default function FilePicker({ onSelect, multiple=true, folders=true, from }: FilePickerProps) {
33 - const passedDir = useMemo(() => from && dirname(from), [from])
34 - const [cwd, setCwd] = useState(from && passedDir || '')
33 +export default function FilePicker({ onSelect, multiple=true, files=true, folders=true, from='' }: FilePickerProps) {
34 + const [cwd, setCwd] = useState(from)
35 const [ready, setReady] = useState(false)
36 useEffect(() => {
37 - if (passedDir && isAbsolutePath(passedDir))
38 - return setReady(true)
39 - apiCall('get_cwd').then(res => {
37 + apiCall('resolve_path', { path: from, closestFolder: true }).then(res => {
38 if (typeof res.path === 'string')
41 - setCwd(pathJoin(res.path, passedDir))
39 + setCwd(res.path)
40 setReady(true)
41 })
44 - }, [passedDir])
45 - const { list, error, loading } = useApiList<DirEntry>(ready && 'ls', { path: cwd })
42 + }, [from])
43 + const { list, error, loading } = useApiList<DirEntry>(ready && 'ls', { path: cwd, files })
44 useEffect(() => {
45 setSel([])
46 setFilter('')
@@ -81,10 +79,9 @@ export default function FilePicker({ onSelect, multiple=true, folders=true, from
79 }),
80 ),
81 error ? h(Alert, { severity:'error' }, String(error))
84 - : !list.length ? h(Typography, { p:1 }, 'No elements in this folder')
82 : h(Fragment, {},
83 h(Box, { sx: { flex: 1 } },
87 - h(AutoSizer, {
84 + !list.length ? h(Center, { flex: 1, mt: '4em' }, "No elements in this folder") : h(AutoSizer, {
85 children: size =>
86 h(FixedSizeList, {
87 ...size, itemSize: 46, itemCount: filteredList.length, overscanCount: 5,
@@ -124,14 +121,14 @@ export default function FilePicker({ onSelect, multiple=true, folders=true, from
121 }),
122 ),
123 h(Box, { display:'flex', gap: 1 },
127 - (multiple || folders) && h(Button, {
124 + (multiple || folders || !files) && h(Button, {
125 variant: 'contained',
129 - disabled: !folders && !sel.length,
126 + disabled: !folders && !sel.length && files,
127 sx: { minWidth: 'max-content' },
128 onClick() {
129 onSelect(sel.length ? sel.map(x => cwdPostfixed + x) : [cwd])
130 }
134 - }, sel.length || !folders ? `Select (${sel.length})` : `Select this folder`),
131 + }, files && (sel.length || !folders) ? `Select (${sel.length})` : `Select this folder`),
132 h(TextField, {
133 value: filter,
134 label: `Filter results (${filteredList.length}${filteredList.length < list.length ? '/'+list.length : ''})`,
admin/src/api.ts
+10 -8
@@ -21,7 +21,9 @@ export function useApiComp<T=any>(...args: Parameters<typeof useApi>): [T | Reac
21 const PREFIX = '/~/api/'
22
23 export function apiCall(cmd: string, params?: Dict) : Promise<any> {
24 - params = addCsrf(params)
24 + const csrf = getCsrf()
25 + if (csrf)
26 + params = { csrf, ...params }
27
28 const controller = new AbortController()
29 setTimeout(() => controller.abort(), 10_000)
@@ -75,14 +77,15 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
77 type EventHandler = (type:string, data?:any) => void
78
79 export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
78 - const processed: Record<string,string> = {}
80 + console.debug('API EVENTS', cmd, params)
81 + const csrf = getCsrf()
82 + const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
83 for (const k in params) {
84 const v = params[k]
85 if (v === undefined) continue
82 - processed[k] = v === true ? '1' : v
86 + processed[k] = JSON.stringify(v)
87 }
84 - console.debug('API EVENTS', cmd, params)
85 - const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(addCsrf(processed)))
88 + const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
89 source.onopen = () => cb('connected')
90 source.onerror = err => cb('error', err)
91 source.onmessage = ({ data }) => {
@@ -100,9 +103,8 @@ export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
103 return source
104 }
105
103 -function addCsrf(params?: Dict) {
104 - const csrf = getCookie('csrf')
105 - return csrf ? { csrf, ...params } : params
106 +function getCsrf() {
107 + return getCookie('csrf')
108 }
109
110 export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
frontend/src/api.ts
+9 -6
@@ -8,7 +8,9 @@ const PREFIX = '/~/api/'
8 interface ApiCallOptions { noModal?:true }
9 export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={}) : Promise<any> {
10 const stop = options.noModal ? undefined : working()
11 - params = addCsrf(params)
11 + const csrf = getCsrf()
12 + if (csrf)
13 + params = { csrf, ...params }
14 return fetch(PREFIX+cmd, {
15 method: 'POST',
16 headers: { 'content-type': 'application/json' },
@@ -47,13 +49,14 @@ export function useApi(cmd: string | Falsy, params?: object) : any {
49 type EventHandler = (type:string, data?:any) => void
50
51 export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
50 - const processed: Record<string,string> = {}
52 + const csrf = getCsrf()
53 + const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
54 for (const k in params) {
55 const v = params[k]
56 if (v === undefined) continue
54 - processed[k] = v === true ? '1' : v
57 + processed[k] = JSON.stringify(v)
58 }
56 - const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(addCsrf(processed)))
59 + const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
60 source.onopen = () => cb('connected')
61 source.onerror = err => cb('error', err)
62 source.onmessage = ({ data }) => {
@@ -70,6 +73,6 @@ export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
73 return source
74 }
75
73 -function addCsrf(params?: Dict) {
74 - return { csrf: getCookie('csrf'), ...params }
76 +function getCsrf() {
77 + return getCookie('csrf')
78 }
server/src/api.vfs.ts
+12 -3
@@ -4,7 +4,7 @@ import { getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from '
4 import _ from 'lodash'
5 import { stat } from 'fs/promises'
6 import { ApiError, ApiHandlers } from './apiMiddleware'
7 -import { dirname, join } from 'path'
7 +import { dirname, join, resolve } from 'path'
8 import { dirStream, enforceFinal, isWindowsDrive, objSameKeys } from './misc'
9 import { exec } from 'child_process'
10 import { promisify } from 'util'
@@ -109,11 +109,18 @@ const apis: ApiHandlers = {
109 }
110 },
111
112 - async get_cwd() {
112 + get_cwd() {
113 return { path: process.cwd() }
114 },
115
116 - async *ls({ path }, ctx) {
116 + async resolve_path({ path, closestFolder }) {
117 + path = resolve(path)
118 + if (closestFolder && !(await stat(path)).isDirectory())
119 + path = dirname(path)
120 + return { path }
121 + },
122 +
123 + async *ls({ path, files=true }, ctx) {
124 if (!path && IS_WINDOWS) {
125 try {
126 for (const n of await getDrives())
@@ -133,6 +140,8 @@ const apis: ApiHandlers = {
140 try {
141 const full = join(path, name)
142 const stats = await stat(full)
143 + if (!files && stats.isFile())
144 + continue
145 yield {
146 add: {
147 n: name,
server/src/apiMiddleware.ts
+3 -2
@@ -4,7 +4,7 @@ import { IncomingMessage } from 'http'
4 import Koa from 'koa'
5 import createSSE from './sse'
6 import { Readable } from 'stream'
7 -import { asyncGeneratorToReadable, onOff } from './misc'
7 +import { asyncGeneratorToReadable, objSameKeys, onOff, tryJson } from './misc'
8 import events from './events'
9
10 export class ApiError extends Error {
@@ -18,7 +18,8 @@ export type ApiHandlers = Record<string, ApiHandler>
18
19 export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
20 return async (ctx) => {
21 - const params = ctx.method === 'POST' ? await getJsonFromReq(ctx.req) : ctx.request.query
21 + const params = ctx.method === 'POST' ? await getJsonFromReq(ctx.req)
22 + : objSameKeys(ctx.request.query, x => Array.isArray(x) ? x : tryJson(x))
23 console.debug('API', ctx.method, ctx.path, { ...params })
24 if (!apis.hasOwnProperty(ctx.path)) {
25 ctx.body = 'invalid api'
server/src/misc.ts
+5
@@ -323,3 +323,8 @@ export function httpsStream(url: string, options:RequestOptions={}): Promise<Inc
323 }).on('error', reject).end()
324 })
325 }
326 +
327 +export function tryJson(s?: string) {
328 + try { return s && JSON.parse(s) }
329 + catch {}
330 +}
server/src/plugins.ts
+1 -6
@@ -7,7 +7,7 @@ import pathLib from 'path'
7 import { API_VERSION, COMPATIBLE_API_VERSION, PLUGINS_PUB_URI } from './const'
8 import * as Const from './const'
9 import Koa from 'koa'
10 -import { debounceAsync, getOrSet, onProcessExit, same, wantArray, watchDir } from './misc'
10 +import { debounceAsync, getOrSet, onProcessExit, same, tryJson, wantArray, watchDir } from './misc'
11 import { defineConfig } from './config'
12 import { DirEntry } from './api.file_list'
13 import { VfsNode } from './vfs'
@@ -288,11 +288,6 @@ export function parsePluginSource(id: string, source: string) {
288 return pl
289 }
290
291 -function tryJson(s?: string) {
292 - try { return s && JSON.parse(s) }
293 - catch {}
294 -}
295 -
291 function calculateBadApi(data: AvailablePlugin) {
292 const r = data.apiRequired
293 const [min, max] = Array.isArray(r) ? r : [r, r] // normalize data type