| 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 | applyParentToChild, getNodeName, getDefaultFile, hasPermission, masksCouldGivePermission, nodeIsFolder, nodeStats, |
| 5 | statusCodeForMissingPerm, urlToNode, VfsNode, walkNode |
| 6 | } from './vfs' |
| 7 | import { ApiError, ApiHandler } from './apiMiddleware' |
| 8 | import { mapPlugins } from './plugins' |
| 9 | import { apiAssertTypes, asyncGeneratorToArray, pattern2filter, WHO_NO_ONE } from './misc' |
| 10 | import { HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const' |
| 11 | import Koa from 'koa' |
| 12 | import { getCommentFor, areCommentsEnabled } from './comments' |
| 13 | import { basename } from 'path' |
| 14 | import { updateConnectionForCtx } from './connections' |
| 15 | import { ctxAdminAccess } from './adminApis' |
| 16 | import { dontOverwriteUploading } from './upload' |
| 17 | import { SendListReadable } from './SendList' |
| 18 | import events from './events' |
| 19 | |
| 20 | export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string, icon?: string | true, order?: number } |
| 21 | |
| 22 | export function paramsToFilter({ search, wild, searchComment, fileMask }: any) { |
| 23 | search = String(search || '').toLocaleLowerCase() |
| 24 | searchComment = String(searchComment || '').toLocaleLowerCase() |
| 25 | return { |
| 26 | depth: search || searchComment ? Infinity : 0, |
| 27 | filterName: search > '' && (wild === 'no' ? (s: string) => s.includes(search) : pattern2filter(search)), |
| 28 | fileMask: fileMask > '' && pattern2filter(fileMask), |
| 29 | filterComment: searchComment > '' && (wild === 'no' ? (s: string) => s.includes(searchComment) : pattern2filter(searchComment)) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onlyFolders, onlyFiles, admin, ...rest }, ctx) => { |
| 34 | apiAssertTypes({ string: { uri }}) |
| 35 | const node = await urlToNode(uri, ctx) |
| 36 | const list = ctx.get('accept') === 'text/event-stream' ? new SendListReadable() : undefined |
| 37 | if (!node) |
| 38 | return fail(HTTP_NOT_FOUND) |
| 39 | admin &&= ctxAdminAccess(ctx) // validate 'admin' flag |
| 40 | if (await getDefaultFile(node, ctx) || !nodeIsFolder(node)) // for files without permission, the frontend is sent, and the location is the file itself |
| 41 | // so, we first check if you have a permission problem, to tell frontend to show login, otherwise we fall back to method_not_allowed, as it's proper for files. |
| 42 | return fail(!admin && statusCodeForMissingPerm(node, 'can_read', ctx) ? undefined : HTTP_METHOD_NOT_ALLOWED) |
| 43 | if (!admin && statusCodeForMissingPerm(node, 'can_list', ctx)) |
| 44 | return fail() |
| 45 | offset = Number(offset) |
| 46 | limit = Number(limit) |
| 47 | const { filterName, filterComment, fileMask, depth } = paramsToFilter(rest) |
| 48 | const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, onlyFiles, depth }) |
| 49 | const onDirEntryHandlers = mapPlugins((plug, id) => plug.onDirEntry && { id, cb: plug.onDirEntry }) |
| 50 | const can_upload = admin || hasPermission(node, 'can_upload', ctx) |
| 51 | const can_delete = admin || hasPermission(node, 'can_delete', ctx) |
| 52 | const fakeChild = await applyParentToChild({ source: 'dummy-file', original: undefined }, node) // used to check permission; simple but can produce false results; 'original' to simulate a non-vfs node |
| 53 | const can_delete_children = admin || hasPermission(fakeChild, 'can_delete', ctx) |
| 54 | const can_archive = admin || hasPermission(node, 'can_archive', ctx) |
| 55 | const can_comment = can_upload && areCommentsEnabled() |
| 56 | const can_overwrite = can_upload && (can_delete_children || !dontOverwriteUploading.get()) |
| 57 | const comment = node.comment ?? await getCommentFor(node.source) |
| 58 | const props = { can_archive, can_upload, can_delete, can_delete_children, can_overwrite, can_comment, comment, accept: node.accept, icon: getNodeIcon(node) } |
| 59 | ctx.state.browsing = uri.replace(/\/{2,}/g, '/') |
| 60 | updateConnectionForCtx(ctx) |
| 61 | if (!list) |
| 62 | return { ...props, list: await asyncGeneratorToArray(produceEntries()) } |
| 63 | setTimeout(async () => { |
| 64 | list.props(props) |
| 65 | for await (const entry of produceEntries()) |
| 66 | list.add(entry) |
| 67 | list.close() |
| 68 | }) |
| 69 | return list |
| 70 | |
| 71 | function fail(code=ctx.status) { |
| 72 | if (!list) |
| 73 | return new ApiError(code) |
| 74 | list.error(code, true) |
| 75 | return list |
| 76 | } |
| 77 | |
| 78 | async function* produceEntries() { |
| 79 | for await (const sub of walker) { |
| 80 | let name = getNodeName(sub) |
| 81 | name = basename(name) || name // on Windows, basename('C:') === '' |
| 82 | if (filterName && !filterName(name) || fileMask && !nodeIsFolder(sub) && !fileMask(name) |
| 83 | || filterComment && !filterComment(await getCommentFor(sub.source) || '')) |
| 84 | continue |
| 85 | const entry = await nodeToDirEntry(ctx, sub) |
| 86 | if (!entry) |
| 87 | continue |
| 88 | const cbParams = { entry, ctx, listUri: uri, node: sub } |
| 89 | try { |
| 90 | const res = await Promise.all(onDirEntryHandlers.map(({ id, cb }) => |
| 91 | Promise.resolve().then(() => cb(cbParams)).catch(error => { throw { id, error } }))) |
| 92 | if (res.some(x => x === false)) |
| 93 | continue |
| 94 | } |
| 95 | catch(e: any) { |
| 96 | console.warn(`Plugin ${e?.id || '?'} is causing problems on onDirEntry:`, e?.error ?? e) |
| 97 | } |
| 98 | if ((await events.emitAsync('dirEntry', cbParams))?.isDefaultPrevented()) |
| 99 | continue |
| 100 | if (offset) { |
| 101 | --offset |
| 102 | continue |
| 103 | } |
| 104 | if (c === 'no' && entry.c) // allow excluding c for smaller payload |
| 105 | entry.c = undefined |
| 106 | yield entry |
| 107 | if (limit && !--limit) |
| 108 | break |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | function getNodeIcon(node: VfsNode) { |
| 113 | return node.icon?.includes('.') || node.icon // send just true when the icon is specific and must be retrieved by the frontend with ?get=icon, otherwise is a SYS_ICONS |
| 114 | } |
| 115 | |
| 116 | async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise<DirEntry | null> { |
| 117 | const { source, url } = node |
| 118 | const name = getNodeName(node) |
| 119 | const isFolder = nodeIsFolder(node) |
| 120 | try { |
| 121 | const [web, comment, st] = await Promise.all([ |
| 122 | getDefaultFile(node, ctx).then(x => x ? true : undefined), |
| 123 | node.comment ?? getCommentFor(source), |
| 124 | nodeStats(node).catch(e => { |
| 125 | if (!isFolder || !node.children?.length) // folders with virtual children, keep them |
| 126 | throw e |
| 127 | }) |
| 128 | ]) |
| 129 | // permissions of entries are sent as a difference with permissions of parent |
| 130 | const pl = node.can_list === WHO_NO_ONE ? 'l' |
| 131 | : !hasPermission(node, 'can_list', ctx) ? 'L' |
| 132 | : '' |
| 133 | // no download here, but maybe inside? |
| 134 | const pr = node.can_read === WHO_NO_ONE && !(isFolder && filesInsideCould()) ? 'r' |
| 135 | : !hasPermission(node, 'can_read', ctx) ? 'R' |
| 136 | : '' |
| 137 | // for delete, the diff is based on can_delete_children instead of can_delete, because it will produce less data |
| 138 | const pd = Boolean(can_delete_children) === hasPermission(node, 'can_delete', ctx) ? '' : can_delete_children ? 'd' : 'D' |
| 139 | const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A' |
| 140 | const pu = !isFolder || Boolean(can_upload) === hasPermission(node, 'can_upload', ctx) ? '' : can_upload ? 'u' : 'U' |
| 141 | return { |
| 142 | n: name + (isFolder ? '/' : ''), |
| 143 | c: st?.birthtime, |
| 144 | m: !st || Math.abs(st.mtimeMs - st.birthtimeMs) < 1000 ? undefined : st.mtime, |
| 145 | s: isFolder ? undefined : st?.size, |
| 146 | p: (pr + pl + pd + pa + pu) || undefined, |
| 147 | url, |
| 148 | target: node.target, |
| 149 | order: node.order, |
| 150 | comment, |
| 151 | icon: getNodeIcon(node), |
| 152 | web, |
| 153 | } |
| 154 | } |
| 155 | catch { |
| 156 | return null |
| 157 | } |
| 158 | |
| 159 | function filesInsideCould(n: VfsNode=node): boolean | undefined { |
| 160 | return masksCouldGivePermission(n.masks, 'can_read') |
| 161 | || n.children?.some(c => c.can_read || filesInsideCould(c)) // we count on the boolean-compliant nature of the permission type here |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | declare module "koa" { |
| 166 | interface DefaultState { |
| 167 | browsing?: string // for admin/monitoring |
| 168 | } |
| 169 | } |