| 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' |