@blocklet/uploader-server 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2018-2020 ArcBlock
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @blocklet/uploader-server-server
2
+
3
+ **@blocklet/uploader-server** is a package that integrates the **uppy** service to provide universal upload capability for blocklets. For more information about uppy, refer to the [official documentation](https://uppy.io/docs/quick-start/).
4
+
5
+ ## Package Structure
6
+
7
+ The package is composed of both frontend and backend components. The backend code can be found in the `middlewares` folder.
8
+
9
+ ## Development
10
+
11
+ ### Install In Blocklet
12
+
13
+ ```
14
+ # You can use npm / yarn
15
+ pnpm add @blocklet/uploader-server
16
+ ```
17
+
18
+ ### Install Dependencies
19
+
20
+ To install the required dependencies, run the following command:
21
+
22
+ ```
23
+ pnpm i
24
+ ```
25
+
26
+ ### Build Packages
27
+
28
+ To build the packages, execute the following command:
29
+
30
+ ```
31
+ pnpm build
32
+ ```
33
+
34
+ ### Build, Watch, and Run Development Server
35
+
36
+ For building, watching changes, and running the development server, use the following command:
37
+
38
+ ```
39
+ pnpm run dev
40
+ ```
41
+
42
+ ## Backend Example
43
+
44
+ ```javascript
45
+ const { initLocalStorageServer, initCompanion } = require('@blocklet/uploader-server');
46
+
47
+ // init uploader server
48
+ const localStorageServer = initLocalStorageServer({
49
+ path: env.uploadDir,
50
+ express,
51
+ onUploadFinish: async (req, res, uploadMetadata) => {
52
+ const {
53
+ id: filename,
54
+ size,
55
+ metadata: { filename: originalname, filetype: mimetype },
56
+ } = uploadMetadata;
57
+
58
+ const obj = new URL(env.appUrl);
59
+ obj.protocol = req.get('x-forwarded-proto') || req.protocol;
60
+ obj.pathname = joinUrl(req.headers['x-path-prefix'] || '/', '/uploads', filename);
61
+
62
+ const doc = await Upload.insert({
63
+ mimetype,
64
+ originalname,
65
+ filename,
66
+ size,
67
+ remark: req.body.remark || '',
68
+ tags: (req.body.tags || '')
69
+ .split(',')
70
+ .map((x) => x.trim())
71
+ .filter(Boolean),
72
+ folderId: req.componentDid,
73
+ createdAt: new Date().toISOString(),
74
+ updatedAt: new Date().toISOString(),
75
+ createdBy: req.user.did,
76
+ updatedBy: req.user.did,
77
+ });
78
+
79
+ const resData = { url: obj.href, ...doc };
80
+
81
+ return resData;
82
+ },
83
+ });
84
+
85
+ router.use('/uploads', user, auth, ensureComponentDid, localStorageServer.handle);
86
+
87
+ // if you need to load file from remote
88
+ // companion
89
+ const companion = initCompanion({
90
+ path: env.uploadDir,
91
+ express,
92
+ providerOptions: env.providerOptions,
93
+ uploadUrls: [env.appUrl],
94
+ });
95
+
96
+ router.use('/companion', user, auth, ensureComponentDid, companion.handle);
97
+ ```
98
+
99
+ ## License
100
+
101
+ This package is licensed under the MIT license.
package/es/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './middlewares';
package/es/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./middlewares.js";
@@ -0,0 +1,6 @@
1
+ export declare function initCompanion({ path, express, providerOptions, ...restProps }: {
2
+ path: string;
3
+ express: Function;
4
+ providerOptions?: Object;
5
+ }): any;
6
+ export declare function proxyImageDownload(req: any, res: any, next?: Function): Promise<void>;
@@ -0,0 +1,92 @@
1
+ const companion = require("@uppy/companion");
2
+ const bodyParser = require("body-parser");
3
+ const session = require("express-session");
4
+ const axios = require("axios");
5
+ const crypto = require("crypto");
6
+ const secret = crypto.randomBytes(32).toString("hex");
7
+ export function initCompanion({
8
+ path,
9
+ express,
10
+ providerOptions,
11
+ ...restProps
12
+ }) {
13
+ const app = express();
14
+ app.use(bodyParser.json());
15
+ app.use(session({ secret }));
16
+ app.use("/proxy", proxyImageDownload);
17
+ let dynamicProviderOptions = providerOptions;
18
+ const companionOptions = {
19
+ secret,
20
+ providerOptions,
21
+ // unused
22
+ server: {
23
+ protocol: "https",
24
+ host: "UNUSED_HOST",
25
+ // unused
26
+ path: "UNUSED_PATH"
27
+ // unused
28
+ },
29
+ filePath: path,
30
+ streamingUpload: true,
31
+ metrics: false,
32
+ ...restProps
33
+ };
34
+ const newCompanion = companion.app(companionOptions);
35
+ const { app: companionApp } = newCompanion;
36
+ app.all(
37
+ "*",
38
+ (req, res, next) => {
39
+ let hackerCompanion = {};
40
+ Object.defineProperty(req, "companion", {
41
+ get() {
42
+ return hackerCompanion;
43
+ },
44
+ set(value) {
45
+ hackerCompanion = value;
46
+ hackerCompanion.options.providerOptions = dynamicProviderOptions;
47
+ }
48
+ });
49
+ next();
50
+ },
51
+ companionApp
52
+ );
53
+ newCompanion.handle = app;
54
+ newCompanion.setProviderOptions = (options) => {
55
+ dynamicProviderOptions = options;
56
+ };
57
+ return newCompanion;
58
+ }
59
+ export async function proxyImageDownload(req, res, next) {
60
+ let { url, responseType = "stream" } = {
61
+ ...req.query,
62
+ ...req.body
63
+ };
64
+ if (url) {
65
+ url = encodeURI(url);
66
+ try {
67
+ const { headers, data, status } = await axios.get(url, {
68
+ responseType
69
+ });
70
+ if (data && status >= 200 && status < 302) {
71
+ res.setHeader("Content-Type", headers["content-type"]);
72
+ try {
73
+ } catch (error) {
74
+ }
75
+ if (responseType === "stream") {
76
+ data.pipe(res);
77
+ } else if (responseType === "arraybuffer") {
78
+ res.end(data?.toString?.("binary"), "binary");
79
+ } else {
80
+ res.send(data);
81
+ }
82
+ } else {
83
+ throw new Error("download image error");
84
+ }
85
+ } catch (err) {
86
+ console.error("Proxy url failed: ", err);
87
+ res.status(500).send("Proxy url failed");
88
+ }
89
+ } else {
90
+ res.status(500).send('Parameter "url" is required');
91
+ }
92
+ }
@@ -0,0 +1,18 @@
1
+ import { type ServerOptions } from '@tus/server';
2
+ export declare function initLocalStorageServer({ path: _path, onUploadFinish: _onUploadFinish, onUploadCreate: _onUploadCreate, express, expiredUploadTime, // default 3 days expire
3
+ ...restProps }: ServerOptions & {
4
+ path: string;
5
+ onUploadFinish?: Function;
6
+ onUploadCreate?: Function;
7
+ express: Function;
8
+ expiredUploadTime?: Number;
9
+ }): any;
10
+ export declare const getFileName: (req: any) => any;
11
+ export declare function getFileNameParam(req: any, res: any, { isRequired }?: {
12
+ isRequired: boolean;
13
+ }): any;
14
+ export declare function getLocalStorageFile({ server }: any): (req: any, res: any, next: any) => Promise<void>;
15
+ export declare function setHeaders(req: any, res: any, next?: Function): void;
16
+ export declare function fileExistBeforeUpload(req: any, res: any, next?: Function): Promise<void>;
17
+ export declare function getMetaDataByFilePath(filePath: string): Promise<any>;
18
+ export declare function joinUrl(...args: string[]): any;
@@ -0,0 +1,374 @@
1
+ const { Server, EVENTS } = require("@tus/server");
2
+ const { FileStore } = require("@tus/file-store");
3
+ const cron = require("@abtnode/cron");
4
+ const fs = require("fs").promises;
5
+ const path = require("path");
6
+ const crypto = require("crypto");
7
+ const mime = require("mime-types");
8
+ const joinUrlLib = require("url-join");
9
+ const { default: queue } = require("p-queue");
10
+ const validFilePathInDirPath = (dirPath, filePath) => {
11
+ const fileName = path.basename(filePath);
12
+ if (!filePath.startsWith(dirPath) || path.join(dirPath, fileName) !== filePath) {
13
+ console.error("Invalid file path: ", filePath);
14
+ throw new Error("Invalid file path");
15
+ }
16
+ return true;
17
+ };
18
+ export function initLocalStorageServer({
19
+ path: _path,
20
+ onUploadFinish: _onUploadFinish,
21
+ onUploadCreate: _onUploadCreate,
22
+ express,
23
+ expiredUploadTime = 1e3 * 60 * 60 * 24 * 3,
24
+ // default 3 days expire
25
+ ...restProps
26
+ }) {
27
+ const app = express();
28
+ const configstore = new RewriteFileConfigstore(_path);
29
+ const datastore = new RewriteFileStore({
30
+ directory: _path,
31
+ expirationPeriodInMilliseconds: expiredUploadTime,
32
+ configstore
33
+ });
34
+ const formatMetadata = (uploadMetadata) => {
35
+ const cloneUploadMetadata = {
36
+ ...uploadMetadata
37
+ };
38
+ if (cloneUploadMetadata.metadata?.name?.indexOf("/") > -1 && cloneUploadMetadata.metadata?.relativePath?.indexOf("/") > -1) {
39
+ cloneUploadMetadata.metadata.name = cloneUploadMetadata.metadata.name.split("/").pop();
40
+ cloneUploadMetadata.metadata.filename = cloneUploadMetadata.metadata.name;
41
+ }
42
+ if (cloneUploadMetadata.id && !cloneUploadMetadata.runtime) {
43
+ const { id, metadata, size } = cloneUploadMetadata;
44
+ cloneUploadMetadata.runtime = {
45
+ relativePath: metadata?.relativePath,
46
+ absolutePath: path.join(_path, id),
47
+ size,
48
+ hashFileName: id,
49
+ originFileName: metadata?.filename,
50
+ type: metadata?.type,
51
+ fileType: metadata?.filetype
52
+ };
53
+ }
54
+ return cloneUploadMetadata;
55
+ };
56
+ const rewriteMetaDataFile = async (uploadMetadata) => {
57
+ uploadMetadata = formatMetadata(uploadMetadata);
58
+ const { id } = uploadMetadata;
59
+ if (!id) {
60
+ return;
61
+ }
62
+ const oldMetadata = formatMetadata(await configstore.get(id));
63
+ if (JSON.stringify(oldMetadata) !== JSON.stringify(uploadMetadata)) {
64
+ await configstore.set(id, uploadMetadata);
65
+ }
66
+ };
67
+ const onUploadCreate = async (req, res, uploadMetadata) => {
68
+ uploadMetadata = formatMetadata(uploadMetadata);
69
+ await rewriteMetaDataFile(uploadMetadata);
70
+ if (uploadMetadata.offset === 0 && uploadMetadata.size === 0) {
71
+ res.status(200);
72
+ res.setHeader("Location", joinUrl(req.headers["x-uploader-base-url"], uploadMetadata.id));
73
+ res.setHeader("Upload-Offset", 0);
74
+ res.setHeader("Upload-Length", 0);
75
+ res.setHeader("x-uploader-file-exist", true);
76
+ }
77
+ if (_onUploadCreate) {
78
+ const result = await _onUploadCreate(req, res, uploadMetadata);
79
+ return result;
80
+ }
81
+ return res;
82
+ };
83
+ const onUploadFinish = async (req, res, uploadMetadata) => {
84
+ res.setHeader("x-uploader-file-exist", true);
85
+ uploadMetadata = formatMetadata(uploadMetadata);
86
+ await rewriteMetaDataFile(uploadMetadata);
87
+ if (_onUploadFinish) {
88
+ try {
89
+ const result = await _onUploadFinish(req, res, uploadMetadata);
90
+ return result;
91
+ } catch (err) {
92
+ console.error("@blocklet/uploader: onUploadFinish error: ", err);
93
+ newServer.delete(uploadMetadata.id);
94
+ res.setHeader("x-uploader-file-exist", false);
95
+ throw err;
96
+ }
97
+ }
98
+ return res;
99
+ };
100
+ const newServer = new Server({
101
+ path: "/",
102
+ // UNUSED
103
+ relativeLocation: true,
104
+ // respectForwardedHeaders: true,
105
+ namingFunction: (req) => {
106
+ const fileName = getFileName(req);
107
+ const filePath = path.join(_path, fileName);
108
+ validFilePathInDirPath(_path, filePath);
109
+ return fileName;
110
+ },
111
+ datastore,
112
+ onUploadFinish: async (req, res, uploadMetadata) => {
113
+ uploadMetadata = formatMetadata(uploadMetadata);
114
+ const result = await onUploadFinish(req, res, uploadMetadata);
115
+ if (result && !result.send) {
116
+ const body = typeof result === "string" ? result : JSON.stringify(result);
117
+ throw { body, status_code: 200 };
118
+ } else {
119
+ return result;
120
+ }
121
+ },
122
+ onUploadCreate,
123
+ ...restProps
124
+ });
125
+ app.use((req, res, next) => {
126
+ req.uploaderProps = {
127
+ server: newServer,
128
+ onUploadFinish,
129
+ onUploadCreate
130
+ };
131
+ next();
132
+ });
133
+ cron.init({
134
+ context: {},
135
+ jobs: [
136
+ {
137
+ name: "auto-cleanup-expired-uploads",
138
+ time: "0 0 * * * *",
139
+ // each hour
140
+ fn: () => {
141
+ try {
142
+ newServer.cleanUpExpiredUploads().then((count) => {
143
+ console.info(`@blocklet/uploader: cleanup expired uploads done: ${count}`);
144
+ }).catch((err) => {
145
+ console.error(`@blocklet/uploader: cleanup expired uploads error`, err);
146
+ });
147
+ } catch (err) {
148
+ console.error(`@blocklet/uploader: cleanup expired uploads error`, err);
149
+ }
150
+ },
151
+ options: { runOnInit: false }
152
+ }
153
+ ],
154
+ onError: (err) => {
155
+ console.error("@blocklet/uploader: cleanup job failed", err);
156
+ }
157
+ });
158
+ newServer.delete = async (key) => {
159
+ try {
160
+ await configstore.delete(key);
161
+ await configstore.delete(key, false);
162
+ } catch (err) {
163
+ console.error("@blocklet/uploader: delete error: ", err);
164
+ }
165
+ };
166
+ newServer.on(EVENTS.POST_RECEIVE, async (req, res, uploadMetadata) => {
167
+ uploadMetadata = formatMetadata(uploadMetadata);
168
+ await rewriteMetaDataFile(uploadMetadata);
169
+ });
170
+ app.all("*", setHeaders, fileExistBeforeUpload, newServer.handle.bind(newServer));
171
+ newServer.handle = app;
172
+ return newServer;
173
+ }
174
+ export const getFileName = (req) => {
175
+ const ext = req.headers["x-uploader-file-ext"];
176
+ const randomName = `${crypto.randomBytes(16).toString("hex")}${ext ? `.${ext}` : ""}`;
177
+ return req.headers["x-uploader-file-name"] || randomName;
178
+ };
179
+ export function getFileNameParam(req, res, { isRequired = true } = {}) {
180
+ let { fileName } = req.params;
181
+ if (!fileName) {
182
+ fileName = req.originalUrl.replace(req.baseUrl, "");
183
+ }
184
+ if (!fileName && isRequired) {
185
+ res.status(400).json({ error: 'Parameter "fileName" is required' });
186
+ return;
187
+ }
188
+ return fileName;
189
+ }
190
+ export function getLocalStorageFile({ server }) {
191
+ return async (req, res, next) => {
192
+ const fileName = getFileNameParam(req, res);
193
+ const filePath = path.join(server.datastore.directory, fileName);
194
+ validFilePathInDirPath(server.datastore.directory, filePath);
195
+ const fileExists = await fs.stat(filePath).catch(() => false);
196
+ if (!fileExists) {
197
+ res.status(404).json({ error: "file not found" });
198
+ return;
199
+ }
200
+ setHeaders(req, res);
201
+ const file = await fs.readFile(filePath);
202
+ res.send(file);
203
+ next?.();
204
+ };
205
+ }
206
+ export function setHeaders(req, res, next) {
207
+ let { method } = req;
208
+ method = method.toUpperCase();
209
+ const fileName = getFileNameParam(req, res, {
210
+ isRequired: false
211
+ });
212
+ if (req.headers["x-uploader-endpoint-url"]) {
213
+ const query = new URL(req.headers["x-uploader-endpoint-url"]).searchParams;
214
+ req.query = {
215
+ ...Object.fromEntries(query),
216
+ // query params convert to object
217
+ ...req.query
218
+ };
219
+ }
220
+ if (method === "POST" && req.headers["x-uploader-base-url"]) {
221
+ req.baseUrl = req.headers["x-uploader-base-url"];
222
+ }
223
+ if (method === "GET" && fileName) {
224
+ const contentType = mime.lookup(fileName);
225
+ if (contentType) {
226
+ res.setHeader("Content-Type", contentType);
227
+ }
228
+ }
229
+ next?.();
230
+ }
231
+ export async function fileExistBeforeUpload(req, res, next) {
232
+ let { method, uploaderProps } = req;
233
+ method = method.toUpperCase();
234
+ if (["PATCH", "POST"].includes(method)) {
235
+ const _path = uploaderProps.server.datastore.directory;
236
+ const fileName = getFileName(req);
237
+ const filePath = path.join(_path, fileName);
238
+ validFilePathInDirPath(_path, filePath);
239
+ const isExist = await fs.stat(filePath).catch(() => false);
240
+ if (isExist) {
241
+ const metaData = await getMetaDataByFilePath(filePath);
242
+ if (isExist?.size >= 0 && isExist?.size === metaData?.size) {
243
+ const prepareUpload = method === "POST";
244
+ if (prepareUpload) {
245
+ res.status(200);
246
+ res.setHeader("Location", joinUrl(req.headers["x-uploader-base-url"], fileName));
247
+ res.setHeader("Upload-Offset", +metaData.offset);
248
+ res.setHeader("Upload-Length", +metaData.size);
249
+ }
250
+ if (req.headers["x-uploader-metadata"]) {
251
+ try {
252
+ const realMetaData = JSON.parse(req.headers["x-uploader-metadata"], (key, value) => {
253
+ if (typeof value === "string") {
254
+ return decodeURIComponent(value);
255
+ }
256
+ return value;
257
+ });
258
+ metaData.metadata = {
259
+ ...metaData.metadata,
260
+ ...realMetaData
261
+ };
262
+ } catch (err) {
263
+ console.error("@blocklet/uploader: parse metadata error: ", err);
264
+ }
265
+ }
266
+ const uploadResult = await uploaderProps.onUploadFinish(req, res, metaData);
267
+ res.json(uploadResult);
268
+ return;
269
+ }
270
+ }
271
+ }
272
+ next?.();
273
+ }
274
+ export async function getMetaDataByFilePath(filePath) {
275
+ const metaDataPath = `${filePath}.json`;
276
+ const isExist = await fs.stat(filePath).catch(() => false);
277
+ if (isExist) {
278
+ try {
279
+ const metaData = await fs.readFile(metaDataPath, "utf-8");
280
+ const metaDataJson = JSON.parse(metaData);
281
+ return metaDataJson;
282
+ } catch (err) {
283
+ console.error("@blocklet/uploader: getMetaDataByPath error: ", err);
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+ export function joinUrl(...args) {
289
+ const realArgs = args.filter(Boolean).map((item) => {
290
+ if (item === "/") {
291
+ return "";
292
+ }
293
+ return item;
294
+ });
295
+ return joinUrlLib(...realArgs);
296
+ }
297
+ class RewriteFileConfigstore {
298
+ directory;
299
+ queue;
300
+ constructor(path2) {
301
+ this.directory = path2;
302
+ this.queue = new queue({ concurrency: 1 });
303
+ }
304
+ async get(key) {
305
+ try {
306
+ const buffer = await this.queue.add(() => fs.readFile(this.resolve(key), "utf8"));
307
+ const metadata = JSON.parse(buffer);
308
+ if (metadata.offset !== metadata.size) {
309
+ const info = await fs.stat(this.resolve(key, false)).catch(() => false);
310
+ if (info?.size !== metadata?.offset) {
311
+ metadata.offset = info.size;
312
+ this.set(key, metadata);
313
+ }
314
+ }
315
+ return metadata;
316
+ } catch {
317
+ return void 0;
318
+ }
319
+ }
320
+ async set(key, value) {
321
+ if (value?.runtime) {
322
+ delete value.runtime;
323
+ }
324
+ if (value?.metadata?.runtime) {
325
+ delete value.metadata.runtime;
326
+ }
327
+ await this.queue.add(() => fs.writeFile(this.resolve(key), JSON.stringify(value)));
328
+ }
329
+ async safeDeleteFile(filePath) {
330
+ validFilePathInDirPath(this.directory, filePath);
331
+ try {
332
+ const isExist = await fs.stat(filePath).catch(() => false);
333
+ if (isExist) {
334
+ await fs.rm(filePath);
335
+ } else {
336
+ console.log("Can not remove file, the file not exist: ", filePath);
337
+ }
338
+ } catch (err) {
339
+ console.error("@blocklet/uploader: safeDeleteFile error: ", err);
340
+ }
341
+ }
342
+ async delete(key, isMetadata = true) {
343
+ try {
344
+ await this.queue.add(() => this.safeDeleteFile(this.resolve(key, isMetadata)));
345
+ } catch (err) {
346
+ console.error("@blocklet/uploader: delete error: ", err);
347
+ }
348
+ }
349
+ async list() {
350
+ return this.queue.add(async () => {
351
+ const files = await fs.readdir(this.directory, { withFileTypes: true });
352
+ const promises = files.filter((file) => file.isFile() && file.name.endsWith(".json")).map((file) => {
353
+ return file.name.replace(".json", "");
354
+ });
355
+ return Promise.all(promises);
356
+ });
357
+ }
358
+ resolve(key, isMetadata = true) {
359
+ let fileKey = key;
360
+ if (isMetadata) {
361
+ fileKey = `${key}.json`;
362
+ }
363
+ return path.join(this.directory, fileKey);
364
+ }
365
+ }
366
+ class RewriteFileStore extends FileStore {
367
+ constructor(options) {
368
+ super(options);
369
+ }
370
+ async remove(key) {
371
+ this.configstore.delete(key);
372
+ this.configstore.delete(key, false);
373
+ }
374
+ }
@@ -0,0 +1,11 @@
1
+ export declare const mappingResource: () => Promise<any>;
2
+ type initStaticResourceMiddlewareOptions = {
3
+ options?: any;
4
+ resourceTypes?: string[] | Object[];
5
+ express: any;
6
+ skipRunningCheck?: boolean;
7
+ };
8
+ export declare const initStaticResourceMiddleware: ({ options, resourceTypes: _resourceTypes, express, skipRunningCheck: _skipRunningCheck, }?: initStaticResourceMiddlewareOptions) => (req: any, res: any, next: Function) => void;
9
+ export declare const getCanUseResources: () => any;
10
+ export declare const initProxyToMediaKitUploadsMiddleware: ({ options, express }?: any) => (req: any, res: any, next: Function) => any;
11
+ export {};