@quark-fw/plugin-uploads 0.0.0-stage → 0.1.22
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/package.json +39 -5
- package/src/global.d.ts +38 -0
- package/src/handleUpload.test.ts +256 -0
- package/src/handleUpload.ts +148 -0
- package/src/helpers.ts +67 -0
- package/src/images.ts +170 -0
- package/src/index.ts +68 -0
- package/src/serve.ts +60 -0
- package/src/storage.ts +193 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@quark-fw/plugin-uploads",
|
|
3
|
+
"version": "0.1.22",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "echo raw-src package, no build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"quark",
|
|
16
|
+
"uploads",
|
|
17
|
+
"images",
|
|
18
|
+
"s3"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"description": "File uploads for the Quark framework: local/S3 storage, image variants via sharp, cached image serving route, authorize/onSaved hooks (published as raw TS sources; consumers run tsx)",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@quark-fw/plugin": "0.1.22",
|
|
28
|
+
"@types/busboy": "^1.5.4",
|
|
29
|
+
"busboy": "^1.6.0",
|
|
30
|
+
"express": "^5.2.1",
|
|
31
|
+
"sharp": "^0.34.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@quark-fw/core": "0.1.22"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/express": "^5.0.6",
|
|
38
|
+
"vitest": "^5.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import "@quark-fw/core/src/global.d.ts";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import type {
|
|
4
|
+
UploadStorage,
|
|
5
|
+
StoredFile,
|
|
6
|
+
} from "@quark-fw/plugin-uploads/src/storage.js";
|
|
7
|
+
import type {
|
|
8
|
+
StoredImage,
|
|
9
|
+
VariantsConfig,
|
|
10
|
+
} from "@quark-fw/plugin-uploads/src/images.js";
|
|
11
|
+
|
|
12
|
+
export {};
|
|
13
|
+
|
|
14
|
+
declare global {
|
|
15
|
+
interface Quark {
|
|
16
|
+
uploads: {
|
|
17
|
+
/** выбранное хранилище (local по умолчанию, s3 по UPLOAD_DRIVER) */
|
|
18
|
+
storage: UploadStorage;
|
|
19
|
+
/** максимальный размер файла в байтах */
|
|
20
|
+
maxFileSize: number;
|
|
21
|
+
/** варианты картинок (UPLOAD_VARIANTS); без них сохраняется только оригинал */
|
|
22
|
+
variants?: VariantsConfig;
|
|
23
|
+
/** префикс ключей картинок, которые отдаёт маршрут /img (по умолчанию "img/") */
|
|
24
|
+
imageKeyPrefix: string;
|
|
25
|
+
/** кто может загружать; по умолчанию — любой залогиненный (req.user) */
|
|
26
|
+
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
27
|
+
/** ключ для нового файла; по умолчанию YYYY-MM/<uuid><ext> */
|
|
28
|
+
keyFor?: (file: {
|
|
29
|
+
mimeType: string;
|
|
30
|
+
originalName: string;
|
|
31
|
+
}) => string;
|
|
32
|
+
/** замена элемента ответа (например запись таблицы Image) */
|
|
33
|
+
onSaved?: (
|
|
34
|
+
file: StoredFile | StoredImage,
|
|
35
|
+
) => unknown | Promise<unknown>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, expect, test, vi } from "vitest";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import sharp from "sharp";
|
|
5
|
+
import { createImageServer } from "./serve.js";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import http, { type Server } from "node:http";
|
|
8
|
+
import type { AddressInfo } from "node:net";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { handleUpload } from "./handleUpload.js";
|
|
12
|
+
import { setUploadHeaders } from "./helpers.js";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import {
|
|
15
|
+
createLocalStorage,
|
|
16
|
+
type StoredFile,
|
|
17
|
+
type UploadStorage,
|
|
18
|
+
} from "./storage.js";
|
|
19
|
+
|
|
20
|
+
let server: Server;
|
|
21
|
+
let base: string;
|
|
22
|
+
let saved: string[];
|
|
23
|
+
let deleted: string[];
|
|
24
|
+
// save(), которые начались, но ещё не завершились (ни успехом, ни ошибкой)
|
|
25
|
+
let inFlight = 0;
|
|
26
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "quark-uploads-"));
|
|
27
|
+
|
|
28
|
+
// хранилище в памяти: сохраняет, дочитав поток, и запоминает удаления
|
|
29
|
+
const memoryStorage: UploadStorage = {
|
|
30
|
+
name: "memory",
|
|
31
|
+
async save({ key, stream, mimeType, originalName }): Promise<StoredFile> {
|
|
32
|
+
inFlight += 1;
|
|
33
|
+
try {
|
|
34
|
+
const chunks: Buffer[] = [];
|
|
35
|
+
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
|
36
|
+
const body = Buffer.concat(chunks);
|
|
37
|
+
blobs.set(key, body);
|
|
38
|
+
saved.push(key);
|
|
39
|
+
return {
|
|
40
|
+
key,
|
|
41
|
+
url: `/m/${key}`,
|
|
42
|
+
size: body.length,
|
|
43
|
+
mimeType,
|
|
44
|
+
originalName,
|
|
45
|
+
};
|
|
46
|
+
} finally {
|
|
47
|
+
inFlight -= 1;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async delete(key) {
|
|
51
|
+
deleted.push(key);
|
|
52
|
+
},
|
|
53
|
+
async read(key) {
|
|
54
|
+
const body = blobs.get(key);
|
|
55
|
+
if (!body) throw new Error("not found");
|
|
56
|
+
return Readable.from(body);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
// содержимое сохранённых файлов — для теста раздачи вариантов
|
|
60
|
+
const blobs = new Map<string, Buffer>();
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
(globalThis as { quark?: unknown }).quark = {
|
|
64
|
+
uploads: {
|
|
65
|
+
storage: memoryStorage,
|
|
66
|
+
maxFileSize: 1024,
|
|
67
|
+
imageKeyPrefix: "img/",
|
|
68
|
+
variants: { "32": { width: 32, format: "webp" } },
|
|
69
|
+
keyFor: ({ mimeType }: { mimeType: string }) =>
|
|
70
|
+
mimeType.startsWith("image/")
|
|
71
|
+
? `img/${crypto.randomUUID().slice(0, 8)}`
|
|
72
|
+
: `files/${crypto.randomUUID().slice(0, 8)}.txt`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
fs.writeFileSync(path.join(dir, "evil.html"), "<script>1</script>");
|
|
76
|
+
fs.writeFileSync(path.join(dir, "pic.png"), "png");
|
|
77
|
+
|
|
78
|
+
const app = express();
|
|
79
|
+
app.use((req, _res, next) => {
|
|
80
|
+
(req as { user?: unknown }).user = { id: "u1" };
|
|
81
|
+
next();
|
|
82
|
+
});
|
|
83
|
+
app.post("/api/files", handleUpload);
|
|
84
|
+
app.use(
|
|
85
|
+
"/img",
|
|
86
|
+
createImageServer({ storage: memoryStorage, keyPrefix: "img/" }),
|
|
87
|
+
);
|
|
88
|
+
app.use(
|
|
89
|
+
"/uploads",
|
|
90
|
+
express.static(dir, { index: false, setHeaders: setUploadHeaders }),
|
|
91
|
+
);
|
|
92
|
+
server = app.listen(0);
|
|
93
|
+
await new Promise((resolve) => server.once("listening", resolve));
|
|
94
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
saved = [];
|
|
99
|
+
deleted = [];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
server.close();
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
delete (globalThis as { quark?: unknown }).quark;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const multipart = (body: string) =>
|
|
109
|
+
fetch(`${base}/api/files`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "multipart/form-data; boundary=X" },
|
|
112
|
+
body,
|
|
113
|
+
signal: AbortSignal.timeout(3000),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const part = (name: string, content: string) =>
|
|
117
|
+
`--X\r\nContent-Disposition: form-data; name="f"; filename="${name}"\r\n` +
|
|
118
|
+
`Content-Type: text/plain\r\n\r\n${content}\r\n`;
|
|
119
|
+
|
|
120
|
+
test("корректная загрузка отвечает списком файлов", async () => {
|
|
121
|
+
const res = await multipart(part("a.txt", "hello") + "--X--\r\n");
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
const { files } = (await res.json()) as { files: StoredFile[] };
|
|
124
|
+
expect(files).toHaveLength(1);
|
|
125
|
+
expect(files[0]!.size).toBe(5);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("имя файла в UTF-8 не искажается", async () => {
|
|
129
|
+
const res = await multipart(part("фото.txt", "hi") + "--X--\r\n");
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
const { files } = (await res.json()) as { files: StoredFile[] };
|
|
132
|
+
expect(files[0]!.originalName).toBe("фото.txt");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("оборванный multipart — 400, процесс жив", async () => {
|
|
136
|
+
// нет закрывающей границы: busboy эмитит error «Unexpected end of form»
|
|
137
|
+
const res = await multipart(part("a.txt", "hello"));
|
|
138
|
+
expect(res.status).toBe(400);
|
|
139
|
+
// сервер продолжает отвечать
|
|
140
|
+
const again = await multipart(part("b.txt", "ok") + "--X--\r\n");
|
|
141
|
+
expect(again.status).toBe(200);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("обрыв соединения посреди файла не оставляет висящий save()", async () => {
|
|
145
|
+
const { port } = new URL(base);
|
|
146
|
+
const req = http.request({
|
|
147
|
+
host: "127.0.0.1",
|
|
148
|
+
port,
|
|
149
|
+
path: "/api/files",
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
"content-type": "multipart/form-data; boundary=X",
|
|
153
|
+
"content-length": "100000",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
req.on("error", () => {});
|
|
157
|
+
req.write(part("big.bin", "x".repeat(100)));
|
|
158
|
+
await vi.waitFor(() => expect(inFlight).toBe(1));
|
|
159
|
+
req.destroy();
|
|
160
|
+
// поток файла закрыт вместе с запросом — save() завершился
|
|
161
|
+
await vi.waitFor(() => expect(inFlight).toBe(0), { timeout: 2000 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("html отдаётся скачиванием и с nosniff", async () => {
|
|
165
|
+
const res = await fetch(`${base}/uploads/evil.html`);
|
|
166
|
+
expect(res.headers.get("content-disposition")).toBe("attachment");
|
|
167
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("картинка отдаётся inline", async () => {
|
|
171
|
+
const res = await fetch(`${base}/uploads/pic.png`);
|
|
172
|
+
expect(res.headers.get("content-disposition")).toBeNull();
|
|
173
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("локальное хранилище удаляет недописанный файл при ошибке потока", async () => {
|
|
177
|
+
const storage = createLocalStorage({ dir });
|
|
178
|
+
const stream = new Readable({ read() {} });
|
|
179
|
+
stream.push("partial");
|
|
180
|
+
setTimeout(() => stream.destroy(new Error("aborted")), 10);
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
storage.save({
|
|
184
|
+
key: "2026-09/partial.bin",
|
|
185
|
+
stream,
|
|
186
|
+
mimeType: "application/octet-stream",
|
|
187
|
+
originalName: "partial.bin",
|
|
188
|
+
}),
|
|
189
|
+
).rejects.toThrow("aborted");
|
|
190
|
+
expect(fs.existsSync(path.join(dir, "2026-09/partial.bin"))).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("картинка с вариантами: размеры, превью рядом с оригиналом, раздача через /img", async () => {
|
|
194
|
+
const png = await sharp({
|
|
195
|
+
create: { width: 64, height: 48, channels: 3, background: "#3366cc" },
|
|
196
|
+
})
|
|
197
|
+
.png()
|
|
198
|
+
.toBuffer();
|
|
199
|
+
const body = Buffer.concat([
|
|
200
|
+
Buffer.from(
|
|
201
|
+
'--X\r\nContent-Disposition: form-data; name="f"; filename="pic.png"\r\nContent-Type: image/png\r\n\r\n',
|
|
202
|
+
),
|
|
203
|
+
png,
|
|
204
|
+
Buffer.from("\r\n--X--\r\n"),
|
|
205
|
+
]);
|
|
206
|
+
const res = await fetch(`${base}/api/files`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "multipart/form-data; boundary=X" },
|
|
209
|
+
body,
|
|
210
|
+
});
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
const { files } = (await res.json()) as {
|
|
213
|
+
files: {
|
|
214
|
+
key: string;
|
|
215
|
+
width: number;
|
|
216
|
+
height: number;
|
|
217
|
+
variants: Record<string, { key: string }>;
|
|
218
|
+
}[];
|
|
219
|
+
};
|
|
220
|
+
expect(files[0]!.width).toBe(64);
|
|
221
|
+
expect(files[0]!.height).toBe(48);
|
|
222
|
+
const variant = files[0]!.variants["32"]!;
|
|
223
|
+
expect(variant.key).toBe(`${files[0]!.key}/32.webp`);
|
|
224
|
+
|
|
225
|
+
const served = await fetch(
|
|
226
|
+
`${base}/img/${files[0]!.key.replace("img/", "")}/32.webp`,
|
|
227
|
+
);
|
|
228
|
+
expect(served.status).toBe(200);
|
|
229
|
+
expect(served.headers.get("content-type")).toBe("image/webp");
|
|
230
|
+
expect(served.headers.get("cache-control")).toContain("immutable");
|
|
231
|
+
const meta = await sharp(
|
|
232
|
+
Buffer.from(await served.arrayBuffer()),
|
|
233
|
+
).metadata();
|
|
234
|
+
expect(meta.width).toBe(32);
|
|
235
|
+
expect(meta.height).toBe(24);
|
|
236
|
+
|
|
237
|
+
// оригинал без расширения маршрут не отдаёт, мусорный путь — 404
|
|
238
|
+
expect(
|
|
239
|
+
(await fetch(`${base}/img/${files[0]!.key.replace("img/", "")}`))
|
|
240
|
+
.status,
|
|
241
|
+
).toBe(404);
|
|
242
|
+
expect((await fetch(`${base}/img/../etc/passwd`)).status).toBe(404);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("authorize запрещает загрузку", async () => {
|
|
246
|
+
const uploads = (
|
|
247
|
+
globalThis as { quark: { uploads: { authorize?: () => boolean } } }
|
|
248
|
+
).quark.uploads;
|
|
249
|
+
uploads.authorize = () => false;
|
|
250
|
+
try {
|
|
251
|
+
const res = await multipart(part("a.txt", "hello") + "--X--\r\n");
|
|
252
|
+
expect(res.status).toBe(403);
|
|
253
|
+
} finally {
|
|
254
|
+
delete uploads.authorize;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import busboy from "busboy";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { safeExtension } from "./helpers.js";
|
|
5
|
+
import { isImageMime, saveImage, type StoredImage } from "./images.js";
|
|
6
|
+
import type { StoredFile } from "./storage.js";
|
|
7
|
+
import {} from "@quark-fw/plugin-uploads/src/global.js";
|
|
8
|
+
|
|
9
|
+
export const handleUpload = async (req: Request, res: Response) => {
|
|
10
|
+
const uploads = quark.uploads;
|
|
11
|
+
// по умолчанию загрузка только для залогиненных: файлы стоят денег и
|
|
12
|
+
// места; приложение сужает правило через quark.uploads.authorize
|
|
13
|
+
const allowed = uploads.authorize
|
|
14
|
+
? await uploads.authorize(req)
|
|
15
|
+
: !!(req as { user?: unknown }).user;
|
|
16
|
+
if (!allowed) {
|
|
17
|
+
res.status(403).json({ error: "Загрузка запрещена" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
22
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
23
|
+
res.status(400).json({ error: "Ожидается multipart/form-data" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parser = busboy({
|
|
28
|
+
headers: req.headers,
|
|
29
|
+
limits: { fileSize: uploads.maxFileSize, files: 20 },
|
|
30
|
+
// браузеры шлют имя файла в UTF-8, а busboy по умолчанию читает его
|
|
31
|
+
// как latin1 — кириллица превращалась в «Ð¤Ð¾Ñ‚Ð¾»
|
|
32
|
+
defParamCharset: "utf8",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const saved: (StoredFile | StoredImage)[] = [];
|
|
36
|
+
const pending: Promise<void>[] = [];
|
|
37
|
+
let tooLarge = false;
|
|
38
|
+
let failure: unknown;
|
|
39
|
+
|
|
40
|
+
parser.on("file", (_field, stream, info) => {
|
|
41
|
+
if (tooLarge) {
|
|
42
|
+
stream.resume();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
stream.on("limit", () => {
|
|
46
|
+
tooLarge = true;
|
|
47
|
+
stream.resume();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const mimeType = info.mimeType || "application/octet-stream";
|
|
51
|
+
const originalName = info.filename ?? "file";
|
|
52
|
+
const key = uploads.keyFor
|
|
53
|
+
? uploads.keyFor({ mimeType, originalName })
|
|
54
|
+
: `${new Date().toISOString().slice(0, 7)}/${crypto.randomUUID()}${safeExtension(originalName)}`;
|
|
55
|
+
|
|
56
|
+
// Картинка с вариантами: файл буферизуется — размеры и превью считаются
|
|
57
|
+
// из целого; остальное стримится в хранилище как раньше.
|
|
58
|
+
const task =
|
|
59
|
+
uploads.variants && isImageMime(mimeType)
|
|
60
|
+
? (async () => {
|
|
61
|
+
const chunks: Buffer[] = [];
|
|
62
|
+
for await (const chunk of stream)
|
|
63
|
+
chunks.push(chunk as Buffer);
|
|
64
|
+
if (tooLarge) return;
|
|
65
|
+
saved.push(
|
|
66
|
+
await saveImage(uploads.storage, {
|
|
67
|
+
key,
|
|
68
|
+
body: Buffer.concat(chunks),
|
|
69
|
+
mimeType,
|
|
70
|
+
originalName,
|
|
71
|
+
variants: uploads.variants,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
})()
|
|
75
|
+
: uploads.storage
|
|
76
|
+
.save({ key, stream, mimeType, originalName })
|
|
77
|
+
.then((file) => {
|
|
78
|
+
saved.push(file);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pending.push(
|
|
82
|
+
task.catch((e) => {
|
|
83
|
+
failure = e;
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Ответ ровно один: error и close у busboy могут прийти оба, а без
|
|
89
|
+
// обработчика error оборванный клиентом multipart роняет процесс.
|
|
90
|
+
let finished = false;
|
|
91
|
+
const finish = async (parseError?: unknown) => {
|
|
92
|
+
if (finished) return;
|
|
93
|
+
finished = true;
|
|
94
|
+
try {
|
|
95
|
+
await Promise.all(pending);
|
|
96
|
+
|
|
97
|
+
if (parseError || tooLarge) {
|
|
98
|
+
await Promise.all(
|
|
99
|
+
saved.map((f) => uploads.storage.delete(f.key)),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (parseError) {
|
|
103
|
+
res.status(400).json({
|
|
104
|
+
error: "Некорректный multipart-запрос",
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (tooLarge) {
|
|
109
|
+
res.status(413).json({
|
|
110
|
+
error: `Файл больше ${uploads.maxFileSize} байт`,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (failure) {
|
|
115
|
+
console.error("[uploads]", failure);
|
|
116
|
+
res.status(500).json({ error: "Не удалось сохранить файл" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// приложение может заменить ответ своей записью (например строкой
|
|
120
|
+
// таблицы Image) — клиент получает то, что вернул onSaved
|
|
121
|
+
const files = uploads.onSaved
|
|
122
|
+
? await Promise.all(saved.map((f) => uploads.onSaved!(f)))
|
|
123
|
+
: saved;
|
|
124
|
+
res.json({ files });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// сбой удаления хранилища — не необработанный reject в close
|
|
127
|
+
console.error("[uploads]", e);
|
|
128
|
+
if (!res.headersSent) {
|
|
129
|
+
res.status(500).json({ error: "Не удалось сохранить файл" });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
parser.on("error", (e) => {
|
|
135
|
+
req.unpipe(parser);
|
|
136
|
+
req.resume();
|
|
137
|
+
void finish(e);
|
|
138
|
+
});
|
|
139
|
+
parser.on("close", () => void finish());
|
|
140
|
+
|
|
141
|
+
// Обрыв соединения pipe парсеру не передаёт: поток текущего файла ждал бы
|
|
142
|
+
// данных вечно — save() висит, а у локального диска открыт дескриптор и
|
|
143
|
+
// лежит недописанный файл. Уничтожаем парсер — он закрывает поток файла.
|
|
144
|
+
req.on("close", () => {
|
|
145
|
+
if (!req.complete) parser.destroy(new Error("Request aborted"));
|
|
146
|
+
});
|
|
147
|
+
req.pipe(parser);
|
|
148
|
+
};
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Хелперы uploads-плагина: выбор хранилища и лимиты. Без quark — модуль
|
|
2
|
+
// пригоден для скриптов приложений, работающих отдельным процессом.
|
|
3
|
+
import type { ServerResponse } from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createLocalStorage,
|
|
7
|
+
createS3Storage,
|
|
8
|
+
type UploadStorage,
|
|
9
|
+
} from "./storage.js";
|
|
10
|
+
|
|
11
|
+
export const MAX_FILE_SIZE =
|
|
12
|
+
Number(process.env.UPLOAD_MAX_SIZE) || 10 * 1024 * 1024;
|
|
13
|
+
export const uploadsDir = path.join(process.cwd(), "uploads");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Хранилище из окружения: UPLOAD_DRIVER=s3 → UPLOAD_S3_BUCKET, UPLOAD_S3_REGION,
|
|
17
|
+
* UPLOAD_S3_ENDPOINT, UPLOAD_S3_ACCESS_KEY, UPLOAD_S3_SECRET_KEY,
|
|
18
|
+
* UPLOAD_S3_PUBLIC_URL, UPLOAD_S3_ACL; иначе локальный диск uploads/.
|
|
19
|
+
*/
|
|
20
|
+
export function createStorage(): UploadStorage {
|
|
21
|
+
if (process.env.UPLOAD_DRIVER === "s3") {
|
|
22
|
+
return createS3Storage({
|
|
23
|
+
bucket: process.env.UPLOAD_S3_BUCKET!,
|
|
24
|
+
region: process.env.UPLOAD_S3_REGION,
|
|
25
|
+
endpoint: process.env.UPLOAD_S3_ENDPOINT,
|
|
26
|
+
accessKeyId: process.env.UPLOAD_S3_ACCESS_KEY!,
|
|
27
|
+
secretAccessKey: process.env.UPLOAD_S3_SECRET_KEY!,
|
|
28
|
+
publicBaseUrl: process.env.UPLOAD_S3_PUBLIC_URL,
|
|
29
|
+
acl: process.env.UPLOAD_S3_ACL,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return createLocalStorage({ dir: uploadsDir });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// расширение берём из имени файла, но чистим — имя приходит от клиента
|
|
36
|
+
export const safeExtension = (name: string) => {
|
|
37
|
+
const ext = path.extname(name).toLowerCase();
|
|
38
|
+
return /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : "";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Типы, которые браузер безопасно показывает inline. html/svg/xml и прочее,
|
|
42
|
+
// отданное с домена приложения, исполнилось бы как его страница (stored
|
|
43
|
+
// XSS через загрузку), поэтому всё вне списка отдаётся только скачиванием.
|
|
44
|
+
const INLINE_TYPES: Record<string, string> = {
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".gif": "image/gif",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".avif": "image/avif",
|
|
51
|
+
".pdf": "application/pdf",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* MIME для inline-отдачи по расширению ключа или undefined — тогда файл
|
|
56
|
+
* только скачивается. Тип берётся из расширения, а не из присланного
|
|
57
|
+
* клиентом mimeType: тот подделывается («картинка» с text/html).
|
|
58
|
+
*/
|
|
59
|
+
export const inlineType = (key: string): string | undefined =>
|
|
60
|
+
INLINE_TYPES[path.extname(key).toLowerCase()];
|
|
61
|
+
|
|
62
|
+
/** заголовки отдачи локального файла (setHeaders у express.static) */
|
|
63
|
+
export const setUploadHeaders = (res: ServerResponse, filePath: string) => {
|
|
64
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
65
|
+
if (!inlineType(filePath))
|
|
66
|
+
res.setHeader("Content-Disposition", "attachment");
|
|
67
|
+
};
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Варианты изображений (превью) через sharp. Модуль без side-effects и без
|
|
2
|
+
// quark: его используют и HTTP-обработчик плагина, и скрипты приложений
|
|
3
|
+
// (парсеры, миграции), работающие отдельным процессом.
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import type { StoredFile, UploadStorage } from "./storage.js";
|
|
6
|
+
|
|
7
|
+
export type ImageFormat = "webp" | "jpeg" | "avif" | "png";
|
|
8
|
+
|
|
9
|
+
export type VariantSpec = {
|
|
10
|
+
/** ширина, высота подбирается по пропорции; картинка не растягивается */
|
|
11
|
+
width: number;
|
|
12
|
+
/** формат (по умолчанию webp) */
|
|
13
|
+
format?: ImageFormat;
|
|
14
|
+
quality?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type VariantsConfig = Record<string, VariantSpec>;
|
|
18
|
+
|
|
19
|
+
export type ProcessedVariant = {
|
|
20
|
+
name: string;
|
|
21
|
+
buffer: Buffer;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
/** расширение файла варианта, например "webp" */
|
|
26
|
+
ext: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ProcessedImage = {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
variants: ProcessedVariant[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type StoredImage = StoredFile & {
|
|
36
|
+
width: number | null;
|
|
37
|
+
height: number | null;
|
|
38
|
+
/** сохранённые варианты: имя → файл */
|
|
39
|
+
variants: Record<string, StoredFile>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const MIME: Record<ImageFormat, string> = {
|
|
43
|
+
webp: "image/webp",
|
|
44
|
+
jpeg: "image/jpeg",
|
|
45
|
+
avif: "image/avif",
|
|
46
|
+
png: "image/png",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const isImageMime = (mimeType: string) =>
|
|
50
|
+
/^image\/(png|jpe?g|gif|webp|avif|heic|heif|tiff)$/i.test(mimeType);
|
|
51
|
+
|
|
52
|
+
/** Ключ варианта рядом с оригиналом: img/<id> → img/<id>/<name>.<ext> */
|
|
53
|
+
export const variantKey = (baseKey: string, name: string, ext: string) =>
|
|
54
|
+
`${baseKey}/${name}.${ext}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Разбирает UPLOAD_VARIANTS: JSON `{"400":{"width":400},"800":{"width":800,"format":"webp"}}`
|
|
58
|
+
* или короткая форма "400,800" (webp). Пусто — вариантов нет.
|
|
59
|
+
*/
|
|
60
|
+
export function parseVariants(
|
|
61
|
+
raw: string | undefined,
|
|
62
|
+
): VariantsConfig | undefined {
|
|
63
|
+
if (!raw?.trim()) return undefined;
|
|
64
|
+
if (raw.trim().startsWith("{")) return JSON.parse(raw) as VariantsConfig;
|
|
65
|
+
const config: VariantsConfig = {};
|
|
66
|
+
for (const part of raw.split(",")) {
|
|
67
|
+
const width = Number(part.trim());
|
|
68
|
+
if (Number.isInteger(width) && width > 0)
|
|
69
|
+
config[String(width)] = { width, format: "webp" };
|
|
70
|
+
}
|
|
71
|
+
return Object.keys(config).length ? config : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// sharp — нативный модуль; грузим лениво, чтобы приложения без картинок
|
|
75
|
+
// его не инициализировали
|
|
76
|
+
const loadSharp = async () => (await import("sharp")).default;
|
|
77
|
+
|
|
78
|
+
/** Размеры оригинала и варианты заданных ширин. */
|
|
79
|
+
export async function processImage(
|
|
80
|
+
buffer: Buffer,
|
|
81
|
+
variants: VariantsConfig,
|
|
82
|
+
): Promise<ProcessedImage> {
|
|
83
|
+
const sharp = await loadSharp();
|
|
84
|
+
const source = sharp(buffer, { failOn: "none" }).rotate(); // EXIF-поворот → реальная ориентация
|
|
85
|
+
const meta = await source.metadata();
|
|
86
|
+
const width = meta.width ?? 0;
|
|
87
|
+
const height = meta.height ?? 0;
|
|
88
|
+
const out: ProcessedVariant[] = [];
|
|
89
|
+
for (const [name, spec] of Object.entries(variants)) {
|
|
90
|
+
const format = spec.format ?? "webp";
|
|
91
|
+
// по одному варианту за раз: несколько параллельных пайплайнов на
|
|
92
|
+
// больших оригиналах выедают память в контейнере
|
|
93
|
+
const { data, info } = await source
|
|
94
|
+
.clone()
|
|
95
|
+
.resize({ width: spec.width, withoutEnlargement: true })
|
|
96
|
+
.toFormat(format, { quality: spec.quality ?? 80 })
|
|
97
|
+
.toBuffer({ resolveWithObject: true });
|
|
98
|
+
out.push({
|
|
99
|
+
name,
|
|
100
|
+
buffer: data,
|
|
101
|
+
mimeType: MIME[format],
|
|
102
|
+
width: info.width,
|
|
103
|
+
height: info.height,
|
|
104
|
+
ext: format,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { width, height, variants: out };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Сохраняет оригинал под `key` и варианты под `key/<name>.<ext>`; возвращает
|
|
112
|
+
* оригинал с размерами и картой вариантов. Не картинка или без вариантов —
|
|
113
|
+
* сохраняется только оригинал.
|
|
114
|
+
*/
|
|
115
|
+
export async function saveImage(
|
|
116
|
+
storage: UploadStorage,
|
|
117
|
+
{
|
|
118
|
+
key,
|
|
119
|
+
body,
|
|
120
|
+
mimeType,
|
|
121
|
+
originalName,
|
|
122
|
+
variants,
|
|
123
|
+
}: {
|
|
124
|
+
key: string;
|
|
125
|
+
body: Buffer;
|
|
126
|
+
mimeType: string;
|
|
127
|
+
originalName: string;
|
|
128
|
+
variants?: VariantsConfig;
|
|
129
|
+
},
|
|
130
|
+
): Promise<StoredImage> {
|
|
131
|
+
const original = await storage.save({
|
|
132
|
+
key,
|
|
133
|
+
stream: Readable.from(body),
|
|
134
|
+
mimeType,
|
|
135
|
+
originalName,
|
|
136
|
+
});
|
|
137
|
+
if (!variants || !isImageMime(mimeType)) {
|
|
138
|
+
return { ...original, width: null, height: null, variants: {} };
|
|
139
|
+
}
|
|
140
|
+
const processed = await processImage(body, variants);
|
|
141
|
+
const saved: Record<string, StoredFile> = {};
|
|
142
|
+
for (const variant of processed.variants) {
|
|
143
|
+
saved[variant.name] = await storage.save({
|
|
144
|
+
key: variantKey(key, variant.name, variant.ext),
|
|
145
|
+
stream: Readable.from(variant.buffer),
|
|
146
|
+
mimeType: variant.mimeType,
|
|
147
|
+
originalName: `${originalName}.${variant.name}.${variant.ext}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...original,
|
|
152
|
+
width: processed.width,
|
|
153
|
+
height: processed.height,
|
|
154
|
+
variants: saved,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Удаляет оригинал и все варианты из конфигурации. */
|
|
159
|
+
export async function deleteImage(
|
|
160
|
+
storage: UploadStorage,
|
|
161
|
+
key: string,
|
|
162
|
+
variants?: VariantsConfig,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
await storage.delete(key);
|
|
165
|
+
for (const [name, spec] of Object.entries(variants ?? {})) {
|
|
166
|
+
await storage
|
|
167
|
+
.delete(variantKey(key, name, spec.format ?? "webp"))
|
|
168
|
+
.catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { QuarkPlugin } from "@quark-fw/plugin";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { registerHttpMiddleware } from "@quark-fw/plugin/http.js";
|
|
4
|
+
import {
|
|
5
|
+
createStorage,
|
|
6
|
+
MAX_FILE_SIZE,
|
|
7
|
+
setUploadHeaders,
|
|
8
|
+
uploadsDir,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
import { handleUpload } from "./handleUpload.js";
|
|
11
|
+
import { parseVariants } from "./images.js";
|
|
12
|
+
import { createImageServer } from "./serve.js";
|
|
13
|
+
import {} from "@quark-fw/plugin-uploads/src/global.js";
|
|
14
|
+
|
|
15
|
+
export { createLocalStorage, createS3Storage } from "./storage.js";
|
|
16
|
+
export type { StoredFile, UploadStorage } from "./storage.js";
|
|
17
|
+
export { createStorage } from "./helpers.js";
|
|
18
|
+
export {
|
|
19
|
+
deleteImage,
|
|
20
|
+
isImageMime,
|
|
21
|
+
parseVariants,
|
|
22
|
+
processImage,
|
|
23
|
+
saveImage,
|
|
24
|
+
variantKey,
|
|
25
|
+
} from "./images.js";
|
|
26
|
+
export type {
|
|
27
|
+
ProcessedImage,
|
|
28
|
+
StoredImage,
|
|
29
|
+
VariantSpec,
|
|
30
|
+
VariantsConfig,
|
|
31
|
+
} from "./images.js";
|
|
32
|
+
export { createImageServer } from "./serve.js";
|
|
33
|
+
|
|
34
|
+
// Плагин: quark.uploads (хранилище, лимиты, варианты) + маршруты
|
|
35
|
+
// POST /api/files (загрузка), GET /img/<id>/<variant> (раздача превью с
|
|
36
|
+
// неизменяемым кэшем), /uploads (статика локального диска).
|
|
37
|
+
// Приложение дополняет: authorize, keyFor, onSaved — до onInit.
|
|
38
|
+
await quark.registerPlugin({
|
|
39
|
+
name: "uploads",
|
|
40
|
+
extend(quark) {
|
|
41
|
+
quark.uploads = {
|
|
42
|
+
storage: createStorage(),
|
|
43
|
+
maxFileSize: MAX_FILE_SIZE,
|
|
44
|
+
variants: parseVariants(process.env.UPLOAD_VARIANTS),
|
|
45
|
+
imageKeyPrefix: process.env.UPLOAD_IMAGE_PREFIX ?? "img/",
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
onInit(quark) {
|
|
49
|
+
registerHttpMiddleware("/api/files", handleUpload);
|
|
50
|
+
registerHttpMiddleware(
|
|
51
|
+
"/img",
|
|
52
|
+
createImageServer({
|
|
53
|
+
storage: quark.uploads.storage,
|
|
54
|
+
keyPrefix: quark.uploads.imageKeyPrefix,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
// локальные файлы отдаём статикой (для s3 URL ведёт напрямую в бакет)
|
|
58
|
+
if (quark.uploads.storage.name === "local") {
|
|
59
|
+
registerHttpMiddleware(
|
|
60
|
+
"/uploads",
|
|
61
|
+
express.static(uploadsDir, {
|
|
62
|
+
index: false,
|
|
63
|
+
setHeaders: setUploadHeaders,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
} satisfies QuarkPlugin);
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Request, Response } from "express";
|
|
2
|
+
import type { UploadStorage } from "./storage.js";
|
|
3
|
+
import { inlineType } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Раздача вариантов изображений с домена приложения: GET /img/<id>/<name>.<ext>
|
|
7
|
+
* → ключ `<prefix><id>/<name>.<ext>` из хранилища. Хеш в адресе не нужен —
|
|
8
|
+
* вариант неизменяем, поэтому кэшируется навсегда (и браузером, и CDN перед
|
|
9
|
+
* приложением). Оригиналы без расширения этим маршрутом не отдаются: их тип
|
|
10
|
+
* неизвестен, а отдавать с домена приложения что попало нельзя.
|
|
11
|
+
*/
|
|
12
|
+
export function createImageServer({
|
|
13
|
+
storage,
|
|
14
|
+
keyPrefix,
|
|
15
|
+
}: {
|
|
16
|
+
storage: UploadStorage;
|
|
17
|
+
keyPrefix: string;
|
|
18
|
+
}) {
|
|
19
|
+
return async (req: Request, res: Response) => {
|
|
20
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
21
|
+
res.status(405).end();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// /<id>/<variant>.<ext>; id и имя варианта — только безопасные символы
|
|
25
|
+
const match =
|
|
26
|
+
/^\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+\.[a-z0-9]{1,8})$/.exec(
|
|
27
|
+
req.path,
|
|
28
|
+
);
|
|
29
|
+
if (!match) {
|
|
30
|
+
res.status(404).end();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const key = `${keyPrefix}${match[1]}/${match[2]}`;
|
|
34
|
+
const mimeType = inlineType(key);
|
|
35
|
+
if (!mimeType) {
|
|
36
|
+
res.status(404).end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let stream;
|
|
40
|
+
try {
|
|
41
|
+
stream = await storage.read(key);
|
|
42
|
+
} catch {
|
|
43
|
+
res.status(404).end();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
res.setHeader("Content-Type", mimeType);
|
|
47
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
48
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
49
|
+
if (req.method === "HEAD") {
|
|
50
|
+
stream.destroy();
|
|
51
|
+
res.end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
stream.on("error", () => {
|
|
55
|
+
if (!res.headersSent) res.status(404);
|
|
56
|
+
res.end();
|
|
57
|
+
});
|
|
58
|
+
stream.pipe(res);
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
// цикл с helpers.ts безопасен: inlineType вызывается только внутри save
|
|
6
|
+
import { inlineType } from "./helpers.js";
|
|
7
|
+
|
|
8
|
+
export type StoredFile = {
|
|
9
|
+
/** ключ в хранилище (например "2026/08/<uuid>.png") */
|
|
10
|
+
key: string;
|
|
11
|
+
/** публичный URL файла */
|
|
12
|
+
url: string;
|
|
13
|
+
size: number;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
originalName: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface UploadStorage {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
save(args: {
|
|
21
|
+
key: string;
|
|
22
|
+
stream: Readable;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
originalName: string;
|
|
25
|
+
}): Promise<StoredFile>;
|
|
26
|
+
delete(key: string): Promise<void>;
|
|
27
|
+
/** поток содержимого по ключу; отсутствующий ключ — reject */
|
|
28
|
+
read(key: string): Promise<Readable>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Локальный диск: файлы кладутся в uploads/ и отдаются как /uploads/<key>. */
|
|
32
|
+
export function createLocalStorage({
|
|
33
|
+
dir = path.join(process.cwd(), "uploads"),
|
|
34
|
+
publicPath = "/uploads",
|
|
35
|
+
}: { dir?: string; publicPath?: string } = {}): UploadStorage {
|
|
36
|
+
return {
|
|
37
|
+
name: "local",
|
|
38
|
+
async save({ key, stream, mimeType, originalName }) {
|
|
39
|
+
const target = path.join(dir, key);
|
|
40
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
41
|
+
try {
|
|
42
|
+
await pipeline(stream, fs.createWriteStream(target));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// недописанный файл (обрыв клиента, лимит) никому не
|
|
45
|
+
// принадлежит — без удаления он копится на диске
|
|
46
|
+
fs.rmSync(target, { force: true });
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
key,
|
|
51
|
+
url: `${publicPath}/${key.replace(/\\/g, "/")}`,
|
|
52
|
+
size: fs.statSync(target).size,
|
|
53
|
+
mimeType,
|
|
54
|
+
originalName,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
async delete(key) {
|
|
58
|
+
fs.rmSync(path.join(dir, key), { force: true });
|
|
59
|
+
},
|
|
60
|
+
async read(key) {
|
|
61
|
+
const target = path.join(dir, key);
|
|
62
|
+
// существование проверяем заранее: ошибка открытия потока приходила
|
|
63
|
+
// бы асинхронно уже после начала ответа
|
|
64
|
+
await fs.promises.access(target, fs.constants.R_OK);
|
|
65
|
+
return fs.createReadStream(target);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* S3-совместимое хранилище (AWS, Yandex Object Storage, Cloudflare R2, MinIO).
|
|
72
|
+
* SDK импортируется динамически, чтобы пакет не тянул aws-зависимости, когда
|
|
73
|
+
* драйвер не используется.
|
|
74
|
+
*/
|
|
75
|
+
export function createS3Storage(config: {
|
|
76
|
+
bucket: string;
|
|
77
|
+
region?: string;
|
|
78
|
+
endpoint?: string;
|
|
79
|
+
accessKeyId: string;
|
|
80
|
+
secretAccessKey: string;
|
|
81
|
+
publicBaseUrl?: string;
|
|
82
|
+
/** например public-read — для бакетов, которые читают напрямую, минуя приложение */
|
|
83
|
+
acl?: string;
|
|
84
|
+
}): UploadStorage {
|
|
85
|
+
/*
|
|
86
|
+
* @aws-sdk/client-s3 — ОПЦИОНАЛЬНАЯ зависимость: её типов может не быть на
|
|
87
|
+
* этапе проверки, поэтому здесь описано ровно то, чем пользуется этот
|
|
88
|
+
* файл, а не полный интерфейс SDK.
|
|
89
|
+
*/
|
|
90
|
+
type S3Command = object;
|
|
91
|
+
type S3Client = { send(command: S3Command): Promise<unknown> };
|
|
92
|
+
type S3Sdk = {
|
|
93
|
+
S3Client: new (config: Record<string, unknown>) => S3Client;
|
|
94
|
+
PutObjectCommand: new (input: Record<string, unknown>) => S3Command;
|
|
95
|
+
DeleteObjectCommand: new (input: Record<string, unknown>) => S3Command;
|
|
96
|
+
GetObjectCommand: new (input: Record<string, unknown>) => S3Command;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let sdkPromise: Promise<S3Sdk> | undefined;
|
|
100
|
+
let clientPromise: Promise<S3Client> | undefined;
|
|
101
|
+
|
|
102
|
+
// спецификатор в переменной: TypeScript не резолвит модуль на этапе
|
|
103
|
+
// проверки, поэтому @aws-sdk/client-s3 остаётся ОПЦИОНАЛЬНОЙ зависимостью
|
|
104
|
+
const sdkName = "@aws-sdk/client-s3";
|
|
105
|
+
|
|
106
|
+
const getSdk = async () => {
|
|
107
|
+
sdkPromise ??= import(sdkName).catch(() => {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"[uploads] драйвер s3 требует пакет @aws-sdk/client-s3 (npm i @aws-sdk/client-s3)",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
return sdkPromise;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const getClient = async () => {
|
|
116
|
+
clientPromise ??= (async () => {
|
|
117
|
+
const sdk = await getSdk();
|
|
118
|
+
return new sdk.S3Client({
|
|
119
|
+
region: config.region ?? "us-east-1",
|
|
120
|
+
endpoint: config.endpoint,
|
|
121
|
+
forcePathStyle: !!config.endpoint,
|
|
122
|
+
credentials: {
|
|
123
|
+
accessKeyId: config.accessKeyId,
|
|
124
|
+
secretAccessKey: config.secretAccessKey,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
})();
|
|
128
|
+
return clientPromise;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const base =
|
|
132
|
+
config.publicBaseUrl ??
|
|
133
|
+
(config.endpoint
|
|
134
|
+
? `${config.endpoint.replace(/\/$/, "")}/${config.bucket}`
|
|
135
|
+
: `https://${config.bucket}.s3.amazonaws.com`);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
name: "s3",
|
|
139
|
+
async save({ key, stream, mimeType, originalName }) {
|
|
140
|
+
const client = await getClient();
|
|
141
|
+
const { PutObjectCommand } = await getSdk();
|
|
142
|
+
const chunks: Buffer[] = [];
|
|
143
|
+
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
|
144
|
+
const body = Buffer.concat(chunks);
|
|
145
|
+
|
|
146
|
+
// бакет может стоять за доменом приложения (publicBaseUrl), поэтому
|
|
147
|
+
// правила отдачи те же, что у локального диска. Ключ без расширения
|
|
148
|
+
// (img/<id>) доверяет присланному типу, если он картиночный.
|
|
149
|
+
const inline =
|
|
150
|
+
inlineType(key) ??
|
|
151
|
+
(/^image\/(png|jpeg|gif|webp|avif)$/.test(mimeType)
|
|
152
|
+
? mimeType
|
|
153
|
+
: undefined);
|
|
154
|
+
await client.send(
|
|
155
|
+
new PutObjectCommand({
|
|
156
|
+
Bucket: config.bucket,
|
|
157
|
+
Key: key,
|
|
158
|
+
Body: body,
|
|
159
|
+
ContentType: inline ?? "application/octet-stream",
|
|
160
|
+
...(inline ? {} : { ContentDisposition: "attachment" }),
|
|
161
|
+
...(config.acl ? { ACL: config.acl } : {}),
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
key,
|
|
167
|
+
url: `${base}/${key}`,
|
|
168
|
+
size: body.length,
|
|
169
|
+
mimeType,
|
|
170
|
+
originalName,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
async delete(key) {
|
|
174
|
+
const client = await getClient();
|
|
175
|
+
const { DeleteObjectCommand } = await getSdk();
|
|
176
|
+
await client.send(
|
|
177
|
+
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
async read(key) {
|
|
181
|
+
const client = await getClient();
|
|
182
|
+
const { GetObjectCommand } = await getSdk();
|
|
183
|
+
const result = (await client.send(
|
|
184
|
+
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
185
|
+
)) as {
|
|
186
|
+
Body?: Readable;
|
|
187
|
+
};
|
|
188
|
+
if (!result.Body)
|
|
189
|
+
throw new Error(`[uploads] empty body for ${key}`);
|
|
190
|
+
return result.Body;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
package/README.md
DELETED