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