UNPKG

@blocklet/uploader-server

Version:

blocklet upload server

397 lines (396 loc) 13.5 kB
import { Server, EVENTS } from "@tus/server"; import { FileStore } from "@tus/file-store"; import cron from "@abtnode/cron"; import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; import mime from "mime-types"; import joinUrlLib from "url-join"; import queue from "p-queue"; import { setPDFDownloadHeader, logger, removeExifFromFile } from "../utils.js"; const validFilePathInDirPath = (dirPath, filePath) => { const fileName = path.basename(filePath); if (!filePath.startsWith(dirPath) || path.join(dirPath, fileName) !== filePath) { logger.error("Invalid file path: ", filePath); throw new Error("Invalid file path"); } return true; }; export function initLocalStorageServer({ path: _path, onUploadFinish: _onUploadFinish, onUploadCreate: _onUploadCreate, express, expiredUploadTime = 1e3 * 60 * 60 * 24 * 3, // default 3 days expire ...restProps }) { const app = express(); const configstore = new RewriteFileConfigstore(_path); const datastore = new RewriteFileStore({ directory: _path, expirationPeriodInMilliseconds: expiredUploadTime, configstore }); const formatMetadata = (uploadMetadata) => { const cloneUploadMetadata = { ...uploadMetadata }; if (cloneUploadMetadata.metadata?.name?.indexOf("/") > -1 && cloneUploadMetadata.metadata?.relativePath?.indexOf("/") > -1) { cloneUploadMetadata.metadata.name = cloneUploadMetadata.metadata.name.split("/").pop(); cloneUploadMetadata.metadata.filename = cloneUploadMetadata.metadata.name; } if (cloneUploadMetadata.id) { const { id, metadata, size } = cloneUploadMetadata; if (!cloneUploadMetadata?.runtime?.absolutePath) { cloneUploadMetadata.runtime = { ...cloneUploadMetadata.runtime, relativePath: metadata?.relativePath || cloneUploadMetadata.runtime?.relativePath, absolutePath: path.join(_path, id), size: size || cloneUploadMetadata.runtime?.size, hashFileName: id, originFileName: metadata?.filename || cloneUploadMetadata.runtime?.originFileName, type: metadata?.type || cloneUploadMetadata.runtime?.type, fileType: metadata?.filetype || cloneUploadMetadata.runtime?.fileType }; } else { const expectedPath = path.join(_path, id); if (cloneUploadMetadata.runtime.absolutePath !== expectedPath) { cloneUploadMetadata.runtime.absolutePath = expectedPath; } } } return cloneUploadMetadata; }; const rewriteMetaDataFile = async (uploadMetadata) => { uploadMetadata = formatMetadata(uploadMetadata); const { id } = uploadMetadata; if (!id) { return; } const oldMetadata = formatMetadata(await configstore.get(id)); if (JSON.stringify(oldMetadata) !== JSON.stringify(uploadMetadata)) { await configstore.set(id, uploadMetadata); } }; const onUploadCreate = async (req, res, uploadMetadata) => { uploadMetadata = formatMetadata(uploadMetadata); await rewriteMetaDataFile(uploadMetadata); if (uploadMetadata.offset === 0 && uploadMetadata.size === 0) { res.status(200); res.setHeader("Location", joinUrl(req.headers["x-uploader-base-url"], uploadMetadata.id)); res.setHeader("Upload-Offset", 0); res.setHeader("Upload-Length", 0); res.setHeader("x-uploader-file-exist", true); } if (_onUploadCreate) { const result = await _onUploadCreate(req, res, uploadMetadata); return result; } return res; }; const onUploadFinish = async (req, res, uploadMetadata) => { res.setHeader("x-uploader-file-exist", true); uploadMetadata = formatMetadata(uploadMetadata); await rewriteMetaDataFile(uploadMetadata); const fileType = uploadMetadata.metadata?.filetype || uploadMetadata.metadata?.type || ""; const fileName = uploadMetadata.metadata?.filename || uploadMetadata.metadata?.name || ""; const fileExt = path.extname(fileName).toLowerCase(); const isImageFile = fileType.startsWith("image/"); const isBinaryFile = ["zip", "gz", "tgz", "7z", "dmg", "pkg", "apk"].includes(fileExt.replace(".", "")); if (isImageFile && !isBinaryFile && uploadMetadata.runtime?.absolutePath) { try { await removeExifFromFile(uploadMetadata.runtime.absolutePath); } catch (err) { logger.error("failed to remove EXIF from file", err); } } if (_onUploadFinish) { try { const result = await _onUploadFinish(req, res, uploadMetadata); return result; } catch (err) { logger.error("@blocklet/uploader: onUploadFinish error: ", err); newServer.delete(uploadMetadata.id); res.setHeader("x-uploader-file-exist", false); throw err; } } return res; }; const newServer = new Server({ path: "/", // UNUSED relativeLocation: true, // respectForwardedHeaders: true, namingFunction: (req) => { const fileName = getFileName(req); const filePath = path.join(_path, fileName); validFilePathInDirPath(_path, filePath); return fileName; }, datastore, onUploadFinish: async (req, res, uploadMetadata) => { uploadMetadata = formatMetadata(uploadMetadata); const result = await onUploadFinish(req, res, uploadMetadata); if (result && !result.send) { const body = typeof result === "string" ? result : JSON.stringify(result); throw { body, status_code: 200 }; } else { return result; } }, onUploadCreate, ...restProps }); app.use((req, res, next) => { req.uploaderProps = { server: newServer, onUploadFinish, onUploadCreate }; next(); }); cron.init({ context: {}, jobs: [ { name: "auto-cleanup-expired-uploads", time: "0 0 * * * *", // each hour fn: () => { try { newServer.cleanUpExpiredUploads().then((count) => { logger.info(`@blocklet/uploader: cleanup expired uploads done: ${count}`); }).catch((err) => { logger.error(`@blocklet/uploader: cleanup expired uploads error`, err); }); } catch (err) { logger.error(`@blocklet/uploader: cleanup expired uploads error`, err); } }, options: { runOnInit: false } } ], onError: (err) => { logger.error("@blocklet/uploader: cleanup job failed", err); } }); newServer.delete = async (key) => { try { await configstore.delete(key); await configstore.delete(key, false); } catch (err) { logger.error("@blocklet/uploader: delete error: ", err); } }; newServer.on(EVENTS.POST_RECEIVE, async (req, res, uploadMetadata) => { uploadMetadata = formatMetadata(uploadMetadata); await rewriteMetaDataFile(uploadMetadata); }); app.all("*", setHeaders, fileExistBeforeUpload, newServer.handle.bind(newServer)); newServer.handle = app; return newServer; } export const getFileName = (req) => { const ext = req.headers["x-uploader-file-ext"]; const randomName = `${crypto.randomBytes(16).toString("hex")}${ext ? `.${ext}` : ""}`; return req.headers["x-uploader-file-name"] || randomName; }; export function getFileNameParam(req, res, { isRequired = true } = {}) { let { fileName } = req.params; if (!fileName) { fileName = req.originalUrl.replace(req.baseUrl, ""); } if (!fileName && isRequired) { res.status(400).json({ error: 'Parameter "fileName" is required' }); return; } return fileName; } export function getLocalStorageFile({ server }) { return async (req, res, next) => { const fileName = getFileNameParam(req, res); const filePath = path.join(server.datastore.directory, fileName); validFilePathInDirPath(server.datastore.directory, filePath); const fileExists = await fs.stat(filePath).catch(() => false); if (!fileExists) { res.status(404).json({ error: "file not found" }); return; } setHeaders(req, res); const file = await fs.readFile(filePath); res.send(file); next?.(); }; } export function setHeaders(req, res, next) { let { method } = req; method = method.toUpperCase(); const fileName = getFileNameParam(req, res, { isRequired: false }); if (req.headers["x-uploader-endpoint-url"]) { const query = new URL(req.headers["x-uploader-endpoint-url"]).searchParams; req.query = { ...Object.fromEntries(query), // query params convert to object ...req.query }; } if (method === "POST" && req.headers["x-uploader-base-url"]) { req.baseUrl = req.headers["x-uploader-base-url"]; } if (method === "GET" && fileName) { const contentType = mime.lookup(fileName); if (contentType) { res.setHeader("Content-Type", contentType); } setPDFDownloadHeader(req, res); } next?.(); } export async function fileExistBeforeUpload(req, res, next) { let { method, uploaderProps } = req; method = method.toUpperCase(); if (["PATCH", "POST"].includes(method)) { const _path = uploaderProps.server.datastore.directory; const fileName = getFileName(req); const filePath = path.join(_path, fileName); validFilePathInDirPath(_path, filePath); const isExist = await fs.stat(filePath).catch(() => false); if (isExist) { const metaData = await getMetaDataByFilePath(filePath); if (isExist?.size >= 0 && isExist?.size === metaData?.size) { const prepareUpload = method === "POST"; if (prepareUpload) { res.status(200); res.setHeader("Location", joinUrl(req.headers["x-uploader-base-url"], fileName)); res.setHeader("Upload-Offset", +metaData.offset); res.setHeader("Upload-Length", +metaData.size); } if (req.headers["x-uploader-metadata"]) { try { const realMetaData = JSON.parse(req.headers["x-uploader-metadata"], (key, value) => { if (typeof value === "string") { return decodeURIComponent(value); } return value; }); metaData.metadata = { ...metaData.metadata, ...realMetaData }; } catch (err) { logger.error("@blocklet/uploader: parse metadata error: ", err); } } const uploadResult = await uploaderProps.onUploadFinish(req, res, metaData); res.json(uploadResult); return; } } } next?.(); } export async function getMetaDataByFilePath(filePath) { const metaDataPath = `${filePath}.json`; const isExist = await fs.stat(filePath).catch(() => false); if (isExist) { try { const metaData = await fs.readFile(metaDataPath, "utf-8"); const metaDataJson = JSON.parse(metaData); return metaDataJson; } catch (err) { logger.error("@blocklet/uploader: getMetaDataByPath error: ", err); } } return null; } export function joinUrl(...args) { const realArgs = args.filter(Boolean).map((item) => { if (item === "/") { return ""; } return item; }); return joinUrlLib(...realArgs); } class RewriteFileConfigstore { directory; queue; constructor(path2) { this.directory = path2; this.queue = new queue({ concurrency: 1 }); } async get(key) { try { const buffer = await this.queue.add(() => fs.readFile(this.resolve(key), "utf8")); const metadata = JSON.parse(buffer); if (metadata.offset !== metadata.size) { const info = await fs.stat(this.resolve(key, false)).catch(() => false); if (info?.size !== metadata?.offset) { metadata.offset = info.size; this.set(key, metadata); } } return metadata; } catch { return void 0; } } async set(key, value) { if (value?.runtime) { delete value.runtime; } if (value?.metadata?.runtime) { delete value.metadata.runtime; } await this.queue.add(() => fs.writeFile(this.resolve(key), JSON.stringify(value))); } async safeDeleteFile(filePath) { validFilePathInDirPath(this.directory, filePath); try { const isExist = await fs.stat(filePath).catch(() => false); if (isExist) { await fs.rm(filePath); } else { logger.log("Can not remove file, the file not exist: ", filePath); } } catch (err) { logger.error("@blocklet/uploader: safeDeleteFile error: ", err); } } async delete(key, isMetadata = true) { try { await this.queue.add(() => this.safeDeleteFile(this.resolve(key, isMetadata))); } catch (err) { logger.error("@blocklet/uploader: delete error: ", err); } } async list() { return this.queue.add(async () => { const files = await fs.readdir(this.directory, { withFileTypes: true }); const promises = files.filter((file) => file.isFile() && file.name.endsWith(".json")).map((file) => { return file.name.replace(".json", ""); }); return Promise.all(promises); }); } resolve(key, isMetadata = true) { let fileKey = key; if (isMetadata) { fileKey = `${key}.json`; } return path.join(this.directory, fileKey); } } class RewriteFileStore extends FileStore { constructor(options) { super(options); } async remove(key) { this.configstore.delete(key); this.configstore.delete(key, false); } }