main
ts 351 lines 15.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 {
4 getNodeName, isSameFilenameAs, nodeIsFolder, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5 permsFromParent, isRoot, nodeStats
6 } from './vfs'
7 import _ from 'lodash'
8 import { mkdir } from 'fs/promises'
9 import { ApiError, ApiHandlers } from './apiMiddleware'
10 import { dirname, extname, join, resolve } from 'path'
11 import {
12 enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, pathDecode, pathEncode, PERM_KEYS,
13 VFS_STORED_KEYS, statWithTimeout, VfsNodeAdminSend
14 } from './misc'
15 import {
16 IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE,
17 IS_BINARY, APP_PATH
18 } from './const'
19 import { getDiskSpace, getDiskSpaces, getDrives, reg } from './util-os'
20 import { getBaseUrlOrDefault, getServerStatus } from './listen'
21 import { SendListReadable } from './SendList'
22 import { walkDir } from './walkDir'
23 import { roots } from './roots'
24
25 // to manipulate the tree we need the original node
26 async function urlToNodeOriginal(uri: string) {
27 const n = await urlToNode(uri)
28 return n?.isTemp ? n.original : n
29 }
30
31 export interface LsEntry { n:string, s?:number, m?:string, c?:string, k?:'d' }
32
33 export default {
34
35 async get_vfs() {
36 return { root: await recur() }
37
38 async function recur(node=vfs): Promise<VfsNodeAdminSend> {
39 const { source } = node
40 const stats = await nodeStats(node)
41 const isFolder = nodeIsFolder(node)
42 const copyStats: Pick<VfsNodeAdminSend, 'size' | 'birthtime' | 'mtime'> = stats ? _.pick(stats, ['size', 'birthtime', 'mtime'])
43 : { size: source ? -1 : undefined }
44 if (copyStats.mtime && (stats?.mtimeMs! - stats?.birthtimeMs!) < 1000)
45 delete copyStats.mtime
46 const inherited = node.parent && permsFromParent(node.parent, {})
47 const byMasks = node.original && _.pickBy(node, (v,k) =>
48 v !== (node.original as any)[k] // something is changing me...
49 && !(inherited && k in inherited) // ...and it's not inheritance...
50 && PERM_KEYS.includes(k as any)) // ...must be masks. Please limit this to perms
51 return {
52 ...copyStats,
53 ...node.original || node,
54 inherited,
55 byMasks: _.isEmpty(byMasks) ? undefined : byMasks,
56 website: node.children?.some(isSameFilenameAs('index.html'))
57 || isFolder && source && await statWithTimeout(join(source, 'index.html')).then(() => true, () => undefined)
58 || undefined,
59 name: getNodeName(node),
60 type: isFolder ? 'folder' : undefined,
61 children: node.children && await Promise.all(node.children.map(async child =>
62 recur(await applyParentToChild(child, node)) ))
63 }
64 }
65 },
66
67 async set_vfs({ uri, props, uriRemaps={} }) {
68 const n = uri && await urlToNodeOriginal(uri)
69 if (!n)
70 return new ApiError(HTTP_NOT_FOUND, 'path not found')
71 if (props.name && props.name !== getNodeName(n)) {
72 if (!isValidFileName(props.name))
73 return new ApiError(HTTP_BAD_REQUEST, 'bad name')
74 // check for siblings with the same name
75 const parent = await urlToNodeOriginal(dirname(uri))
76 if (parent?.children?.find(x => getNodeName(x) === props.name))
77 return new ApiError(HTTP_CONFLICT, 'name already present')
78 }
79 Object.assign(n, sanitizeVfsProps(props))
80 simplifyName(n)
81 n.isFolder = undefined // reset field, it will be set by saveVfs
82 await saveVfs()
83 if (!isRoot(n)) // not actually used by admin-panel but still
84 uriRemaps[uri] = uriForNode(uri, n) // just in case the current node was modified
85 await updateRootsForVfsUriRemaps(uriRemaps)
86 return n
87
88 function uriForNode(uri: string, n: VfsNode) {
89 return enforceFinal('/', dirname(uri).replace(/\\/g, '/')) + pathEncode(getNodeName(n)) + (nodeIsFolder(n) ? '/' : '')
90 }
91 },
92
93 // legacy – not currently used by the UI
94 async move_vfs({ from, parent }) {
95 if (!from || !parent)
96 return new ApiError(HTTP_BAD_REQUEST)
97 const fromNode = await urlToNodeOriginal(from)
98 if (!fromNode)
99 return new ApiError(HTTP_NOT_FOUND, 'from not found')
100 if (isRoot(fromNode))
101 return new ApiError(HTTP_BAD_REQUEST, 'from is root')
102 if (parent.startsWith(from))
103 return new ApiError(HTTP_BAD_REQUEST, 'incompatible parent')
104 const parentNode = await urlToNodeOriginal(parent)
105 if (!parentNode)
106 return new ApiError(HTTP_NOT_FOUND, 'parent not found')
107 const name = getNodeName(fromNode)
108 if (parentNode.children?.find(x => name === getNodeName(x)))
109 return new ApiError(HTTP_CONFLICT, 'item with same name already present in destination')
110 const oldParent = await urlToNodeOriginal(dirname(from))
111 _.pull(oldParent!.children!, fromNode)
112 if (_.isEmpty(oldParent!.children)) {
113 delete oldParent!.children
114 }
115 ;(parentNode.children ||= []).push(fromNode)
116 await saveVfs()
117 await updateRootsForVfsUriRemaps({
118 [from]: enforceFinal('/', parent) + pathEncode(name) + (nodeIsFolder(fromNode) ? '/' : '')
119 })
120 return {}
121 },
122
123 // legacy – not currently used by the UI
124 async add_vfs({ parent, source, name, ...rest }) {
125 if (!source && !name)
126 return new ApiError(HTTP_BAD_REQUEST, 'name or source required')
127 if (name && !isValidFileName(name))
128 return new ApiError(HTTP_BAD_REQUEST, 'bad name')
129 const parentNode = parent ? await urlToNodeOriginal(parent) : vfs
130 if (!parentNode)
131 return new ApiError(HTTP_NOT_FOUND, 'parent not found')
132 if (!nodeIsFolder(parentNode))
133 return new ApiError(HTTP_NOT_ACCEPTABLE, 'parent not a folder')
134 if (isWindowsDrive(source))
135 source += '\\' // slash must be included, otherwise it will refer to the cwd of that drive
136 const isFolder = source && await isDirectory(source)
137 if (source && isFolder === undefined)
138 return new ApiError(HTTP_NOT_FOUND, 'source not found')
139 const child = { source, name, ...sanitizeVfsProps(rest) }
140 name = getNodeName(child) // could be not given as input
141 const ext = extname(name)
142 const noExt = ext ? name.slice(0, -ext.length) : name
143 let idx = 2
144 while (parentNode.children?.find(isSameFilenameAs(name)))
145 name = `${noExt} ${idx++}${ext}`
146 child.name = name
147 simplifyName(child)
148 ;(parentNode.children ||= []).unshift(child)
149 await saveVfs()
150 const link = rest.url ? undefined : await getBaseUrlOrDefault()
151 + (parent ? enforceStarting('/', enforceFinal('/', parent)) : '/')
152 + encodeURIComponent(getNodeName(child))
153 + (isFolder ? '/' : '')
154 return { name, link }
155 },
156
157 // legacy – not currently used by the UI
158 async del_vfs({ uris }) {
159 if (!uris || !Array.isArray(uris))
160 return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
161 return {
162 errors: await Promise.all(uris.map(async uri => {
163 if (typeof uri !== 'string')
164 return HTTP_BAD_REQUEST
165 const node = await urlToNodeOriginal(uri)
166 if (!node)
167 return HTTP_NOT_FOUND
168 if (isRoot(node))
169 return HTTP_NOT_ACCEPTABLE
170 const parentNode = await urlToNodeOriginal(dirname(uri))
171 const c = parentNode?.children // since node is not root, parentNode must exist and have children
172 if (!c) // inconsistent state
173 return HTTP_SERVER_ERROR
174 const idx = c.indexOf(node)
175 if (idx < 0) // inconsistent state
176 return HTTP_SERVER_ERROR
177 c.splice(idx, 1)
178 if (!c.length)
179 parentNode.children = undefined
180 return 0 // error code 0 is OK
181 })).finally(saveVfs)
182 }
183 },
184
185 get_cwd() {
186 return { path: process.cwd() }
187 },
188
189 async resolve_path({ path, closestFolder }) {
190 path = resolve(path)
191 if (closestFolder)
192 while (path && !await isDirectory(path))
193 path = dirname(path)
194 return { path, isFolder: await isDirectory(path) }
195 },
196
197 async mkdir({ path }) {
198 await mkdir(path, { recursive: true })
199 return {}
200 },
201
202 get_disk_spaces: getDiskSpaces,
203
204 get_ls({ path, files=true, fileMask }, ctx) {
205 return new SendListReadable<LsEntry>({
206 async doAtStart(list) {
207 if (!path && IS_WINDOWS) {
208 try {
209 for (const n of await getDrives())
210 list.add({ n, k: 'd' })
211 } catch (error) {
212 console.debug(error)
213 }
214 return
215 }
216 const sendPropsAsap = getDiskSpace(path).then(x => x && list.props(x))
217 try {
218 const matching = makeMatcher(fileMask)
219 path = isWindowsDrive(path) ? path + '\\' : resolve(path || '/')
220 await walkDir(path, { ctx }, async entry => {
221 if (ctx.isAborted())
222 return null
223 const {path:name} = entry
224 const isDir = entry.isDirectory()
225 if (!isDir)
226 if (!files || fileMask && !matching(name))
227 return
228 try {
229 const stats = entry.stats || await statWithTimeout(join(path, name))
230 list.add({
231 n: name,
232 s: stats.size,
233 c: stats.birthtime.toJSON(),
234 m: stats.mtime.toJSON(),
235 k: isDir ? 'd' : undefined,
236 })
237 } catch {} // just ignore entries we can't stat
238 })
239 await sendPropsAsap.catch(() => {})
240 list.close()
241 } catch (e: any) {
242 list.error(e.code || e.message || String(e), true)
243 }
244 }
245 })
246 },
247
248 async windows_integration({ parent }) {
249 const status = await getServerStatus(true)
250 const useHttp = status.http.listening
251 const h = useHttp ? status.http : status.https // prefer http on localhost
252 const url = h.srv!.name + '://localhost:' + h.port
253 for (const k of ['*', 'Directory']) {
254 await reg('add', WINDOWS_REG_KEY.replace('*', k), '/ve', '/f', '/d', 'Add to HFS (new)')
255 await reg('add', WINDOWS_REG_KEY.replace('*', k), '/v', 'icon', '/f', '/d', IS_BINARY ? process.execPath : APP_PATH + '\\hfs.ico')
256 await reg('add', WINDOWS_REG_KEY.replace('*', k) + '\\command', '/ve', '/f', '/d', `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -Command "
257 [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12;
258 $wsh = New-Object -ComObject Wscript.Shell;
259 $j = @{parent=@'\n${parent}\n'@; source=@'\n%1\n'@} | ConvertTo-Json -Compress
260 $j = [System.Text.Encoding]::UTF8.GetBytes($j);
261 ${useHttp ? '' : '[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}'}
262 try {
263 $res = Invoke-WebRequest -Uri '${url}/~/api/add_vfs' -UseBasicParsing -Method POST -Headers @{ 'x-hfs-anti-csrf' = '1' } -ContentType 'application/json' -TimeoutSec 3 -Body $j;
264 $json = $res.Content | ConvertFrom-Json; $link = $json.link; $link | Set-Clipboard;
265 $wsh.Popup('The link is ready to be pasted');
266 } catch { $wsh.Popup($_.Exception.Message + ' – ' + '${url}', 0, 'Error', 16); }"`)
267 }
268 return {}
269 },
270
271 async windows_integrated() {
272 return {
273 is: await reg('query', WINDOWS_REG_KEY)
274 .then(x => x.includes('REG_SZ'), () => false)
275 }
276 },
277
278 async windows_remove() {
279 for (const k of ['*', 'Directory'])
280 await reg('delete', WINDOWS_REG_KEY.replace('*',k), '/f')
281 return {}
282 },
283
284 } satisfies ApiHandlers
285
286 // pick only selected props, and consider null and empty string as undefined, as it's the default value and we don't want to store it
287 export function pickProps(o: any, keys: string[]) {
288 const ret: any = {}
289 if (o && typeof o === 'object')
290 for (const k in o)
291 if (keys.includes(k))
292 ret[k] = o[k] === null || o[k] === '' ? undefined : o[k]
293 return ret
294 }
295
296 function sanitizeVfsProps(props: any) {
297 const ret = pickProps(props, VFS_STORED_KEYS)
298 if (ret.masks && typeof ret.masks !== 'object')
299 ret.masks = undefined
300 if (props?.children === null)
301 delete ret.children
302 else if (Array.isArray(props?.children))
303 ret.children = !props.children.length ? undefined
304 : props.children.map(sanitizeVfsProps)
305 return ret
306 }
307
308 function updateRootsForVfsUriRemaps(uriRemaps={}) {
309 const remaps = Object.entries(uriRemaps)
310 .map(([from, to]) => [normalizeVfsId(from), normalizeVfsId(to)] as const)
311 .filter(x => x[0] !== x[1])
312 .sort(([a], [b]) => b.length - a.length) // longest first because, in case of both parent and child, it's more specific
313 let changed = false
314 const updatedRoots = _.mapValues(roots.get(), root => {
315 if (typeof root !== 'string' || !root) return root
316 const normalizedRoot = normalize(root)
317 for (const [from, to] of remaps) {
318 // roots are stored outside the VFS tree, so rename/move edits need an explicit path remap
319 const remappedRoot = replaceUriPrefix(normalizedRoot, from, to)
320 if (!remappedRoot) continue
321 if (remappedRoot !== root)
322 changed = true
323 return remappedRoot
324 }
325 return root
326 })
327 if (changed)
328 roots.set(updatedRoots)
329
330 function normalize(uri: unknown) {
331 return String(uri).replace(/^\/+|^(?!\/)|\/{2,}|\/+$|(?<!\/)$/g, '/')
332 }
333
334 function normalizeVfsId(uri: unknown) {
335 return normalize(pathDecode(String(uri))) // admin remaps come from tree ids, while roots are stored as readable VFS paths
336 }
337
338 function replaceUriPrefix(uri: string, oldPrefix: string, newPrefix: string) {
339 return uri === oldPrefix ? newPrefix
340 : uri.startsWith(oldPrefix) ? newPrefix + uri.slice(oldPrefix.length)
341 : ''
342 }
343 }
344
345 export function simplifyName(node: VfsNode) {
346 const { name, ...noName } = node
347 if (getNodeName(noName) === name)
348 delete node.name
349 }
350
351 const WINDOWS_REG_KEY = 'HKCU\\Software\\Classes\\*\\shell\\AddToHFS3'