main
ts 232 lines 9.65 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 { ApiError, ApiHandlers } from './apiMiddleware'
4 import { get_file_list } from './api.get_file_list'
5 import { authApis } from './api.auth'
6 import events from './events'
7 import Koa from 'koa'
8 import { isValidFileName } from './util-files'
9 import {
10 HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FAILED_DEPENDENCY, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED,
11 HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED
12 } from './const'
13 import {
14 hasPermission, isRoot, nodeIsFolder, nodeStats,
15 simpleWhoToError, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
16 } from './vfs'
17 import fs from 'fs'
18 import { mkdir, rename, copyFile, unlink } from 'fs/promises'
19 import { basename, dirname, join } from 'path'
20 import { getUploadMeta } from './upload'
21 import { apiAssertTypes, CFG, moveStoredFileAttrs, pathDecode, pathEncode, popKey, Who, WHO_ADMIN } from './misc'
22 import { defineConfig } from './config'
23 import { getCommentFor, setCommentFor } from './comments'
24 import { SendListReadable } from './SendList'
25 import { ctxAdminAccess } from './adminApis'
26 import _ from 'lodash'
27
28 const partialFolderSize: any = {}
29 const showUploader = defineConfig<Who>(CFG.show_uploader, WHO_ADMIN)
30
31 export const frontEndApis: ApiHandlers = {
32 get_file_list,
33 ...authApis,
34
35 get_notifications({ channel }, ctx) {
36 apiAssertTypes({ string: { channel } })
37 const list = new SendListReadable()
38 list.ready() // on chrome109 EventSource doesn't emit 'open' until something is sent
39 return list.events(ctx, {
40 [NOTIFICATION_PREFIX + channel](name, data) {
41 list.custom(name, data)
42 }
43 }, { warnAfter: 10_000 }) // we may have many clients on the same channel (eg: chat), and we don't want to be spammed with console warnings
44 },
45
46 async get_file_details({ uris }, ctx) {
47 if (!Array.isArray(uris) || typeof uris[0] !== 'string')
48 return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
49 const isAdmin = ctxAdminAccess(ctx)
50 return {
51 details: simpleWhoToError(showUploader.get(), ctx) ? [] // return early because at the moment we only have the uploader
52 : await Promise.all(uris.map(async (uri: any) => {
53 if (typeof uri !== 'string')
54 return false // false means error
55 const node = await urlToNode(uri, ctx)
56 if (!node || !hasPermission(node, 'can_see', ctx))
57 return false
58 let upload = node.source && await getUploadMeta(node.source).catch(() => undefined)
59 if (!upload) return
60 if (!isAdmin)
61 upload = _.omit(upload, 'ip')
62 return { upload }
63 }))
64 }
65 },
66
67 async create_folder({ uri, name }, ctx) {
68 apiAssertTypes({ string: { uri, name } })
69 try { ctx.logExtra(null, { name, target: pathDecode(uri) }) }
70 catch { return new ApiError(HTTP_BAD_REQUEST) }
71 if (!name || !isValidFileName(name))
72 return new ApiError(HTTP_BAD_REQUEST, 'bad name')
73 const parentNode = await urlToNode(uri, ctx)
74 if (!parentNode)
75 return new ApiError(HTTP_NOT_FOUND, 'parent not found')
76 const err = statusCodeForMissingPerm(parentNode, 'can_upload', ctx)
77 if (err)
78 return new ApiError(err)
79 try {
80 await mkdir(join(parentNode.source!, name))
81 return {}
82 }
83 catch(e:any) {
84 return new ApiError(e.code === 'EEXIST' ? HTTP_CONFLICT : HTTP_BAD_REQUEST, e)
85 }
86 },
87
88 // dest is not encoded
89 async rename({ uri, dest }, ctx) {
90 apiAssertTypes({ string: { uri, dest } })
91 try { ctx.logExtra(null, { target: pathDecode(uri), destination: dest }) }
92 catch { return new ApiError(HTTP_BAD_REQUEST) }
93 const node = await urlToNode(uri, ctx)
94 if (!node)
95 throw new ApiError(HTTP_NOT_FOUND)
96 if (isRoot(node) || !isValidFileName(dest))
97 return new ApiError(HTTP_FORBIDDEN)
98 await requestedRename(node, dest, ctx)
99 return {}
100 },
101
102 async move_files({ uri_from, uri_to }, ctx) {
103 return moveFiles(uri_from, uri_to, ctx)
104 },
105
106 async copy_files({ uri_from, uri_to }, ctx) {
107 return moveFiles(uri_from, uri_to, ctx, (srcNode: VfsNode, dest: string) => // override behavior
108 statusCodeForMissingPerm(srcNode, 'can_read', ctx)
109 || copyFile(srcNode.source!, dest, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE) // .source is checked by moveFiles
110 .catch(e => e.code || String(e))
111 )
112 },
113
114 async comment({ uri, comment }, ctx) {
115 apiAssertTypes({ string: { uri, comment } })
116 try { ctx.logExtra(null, { target: pathDecode(uri) }) }
117 catch { return new ApiError(HTTP_BAD_REQUEST) }
118 const node = await urlToNode(uri, ctx)
119 if (!node)
120 return new ApiError(HTTP_NOT_FOUND)
121 if (!hasPermission(node, 'can_upload', ctx))
122 return new ApiError(HTTP_UNAUTHORIZED)
123 if (!node.source)
124 return new ApiError(HTTP_FAILED_DEPENDENCY)
125 await setCommentFor(node.source, comment)
126 return {}
127 },
128
129 async get_folder_size_partial({ id }) {
130 apiAssertTypes({ string: { id } })
131 return partialFolderSize[id] || new ApiError(HTTP_NOT_FOUND)
132 },
133
134 async get_folder_size({ uri, id }, ctx) {
135 apiAssertTypes({ string: { uri } })
136 const folder = await urlToNode(uri, ctx)
137 if (!folder)
138 return new ApiError(HTTP_NOT_FOUND)
139 if (!nodeIsFolder(folder))
140 return new ApiError(HTTP_METHOD_NOT_ALLOWED)
141 if (statusCodeForMissingPerm(folder, 'can_list', ctx))
142 return new ApiError(ctx.status)
143 let bytes = 0
144 let files = 0
145 let folders = 0
146 for await (const n of walkNode(folder, { ctx, depth: Infinity })) {
147 if (n.isFolder)
148 folders++
149 else {
150 bytes += await nodeStats(n).then(x => x?.size || 0, () => 0)
151 files++
152 }
153 partialFolderSize[id] = { bytes, files, folders }
154 }
155 return popKey(partialFolderSize, id) || { bytes, files, folders }
156 },
157 }
158
159 export function notifyClient(channel: string | Koa.Context, name: string, data: any) {
160 if (typeof channel !== 'string')
161 channel = String(channel.query.notifications)
162 events.emit(NOTIFICATION_PREFIX + channel, name, data)
163 }
164
165 const NOTIFICATION_PREFIX = 'notificationChannel:'
166
167 export async function moveFiles(uri_from: any, uri_to: any, ctx: Koa.Context, override?: Function) {
168 apiAssertTypes({ array: { uri_from }, string: { uri_to } })
169 try { ctx.logExtra(null, { target: uri_from.map(pathDecode), destination: pathDecode(uri_to) }) }
170 catch { return new ApiError(HTTP_BAD_REQUEST) }
171 const destNode = await urlToNode(uri_to, ctx)
172 const err = !destNode ? HTTP_NOT_FOUND
173 : !nodeIsFolder(destNode) ? HTTP_METHOD_NOT_ALLOWED
174 : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
175 if (err)
176 return new ApiError(err)
177 return {
178 errors: await Promise.all(uri_from.map(async (from1: any) => {
179 if (typeof from1 !== 'string') return HTTP_BAD_REQUEST
180 const srcNode = await urlToNode(from1, ctx)
181 const src = srcNode?.source
182 if (!src) return HTTP_NOT_FOUND
183 const destName = basename(src)
184 const destChild = await urlToNode(destName, ctx, destNode!)
185 if (destChild && statusCodeForMissingPerm(destChild, 'can_delete', ctx))
186 return ctx.status
187 const dest = join(destNode!.source!, destName)
188 if (_.isFunction(override))
189 return override?.(srcNode, dest)
190 return statusCodeForMissingPerm(srcNode, 'can_delete', ctx)
191 || rename(src, dest).catch(async e => {
192 if (e.code !== 'EXDEV') throw e // exdev = different drive
193 await copyFile(src, dest)
194 await unlink(src)
195 }).then(() => moveStoredFileAttrs(src, dest))
196 .catch(e => e.code || String(e))
197 }))
198 }
199 }
200
201 export async function requestedRename(node: VfsNode | undefined, newName: string, ctx: Koa.Context) {
202 if (!node)
203 throw new ApiError(HTTP_NOT_FOUND)
204 // requestedRename is exported, so keep disk rename confinement here even when callers pre-validate
205 if (!isValidFileName(newName))
206 throw new ApiError(HTTP_BAD_REQUEST)
207 if (statusCodeForMissingPerm(node, 'can_delete', ctx))
208 throw new ApiError(ctx.status)
209 if (node.name) // virtual name = virtual rename
210 node.name = newName
211 else {
212 if (!node.source)
213 throw new ApiError(HTTP_FAILED_DEPENDENCY)
214 const destNode = await urlToNode(pathEncode(newName), ctx, node.parent)
215 if (destNode && statusCodeForMissingPerm(destNode, 'can_delete', ctx)) // if destination exists, you need delete permission
216 throw new ApiError(ctx.status)
217 try {
218 const destSource = join(dirname(node.source), newName)
219 await rename(node.source, destSource)
220 await moveStoredFileAttrs(node.source, destSource)
221 getCommentFor(node.source).then(c => {
222 if (!c) return
223 void setCommentFor(node.source!, '')
224 void setCommentFor(destSource, c)
225 })
226 return {}
227 }
228 catch (e: any) {
229 throw new ApiError(HTTP_SERVER_ERROR, e)
230 }
231 }
232 }