main
ts 169 lines 8.44 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 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 }