koishi-plugin-chatluna-storage-service 0.0.11 → 1.0.1
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/lib/backends/base.d.ts +74 -0
- package/lib/backends/index.d.ts +7 -0
- package/lib/backends/local.d.ts +13 -0
- package/lib/backends/r2.d.ts +19 -0
- package/lib/backends/s3.d.ts +15 -0
- package/lib/backends/webdav.d.ts +18 -0
- package/lib/index.cjs +687 -21
- package/lib/index.d.ts +22 -3
- package/lib/index.mjs +687 -21
- package/lib/service/storage.d.ts +4 -0
- package/lib/types.d.ts +4 -0
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -18,6 +18,10 @@ function apply(ctx, config) {
|
|
|
18
18
|
koa.status = 404;
|
|
19
19
|
return koa.body = "File not found";
|
|
20
20
|
}
|
|
21
|
+
if (fileInfo.publicUrl && fileInfo.storageType !== "local") {
|
|
22
|
+
koa.redirect(fileInfo.publicUrl);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
21
25
|
koa.set(
|
|
22
26
|
"Content-Type",
|
|
23
27
|
fileInfo.type || "application/octet-stream"
|
|
@@ -84,9 +88,532 @@ function randomFileName(fileName) {
|
|
|
84
88
|
}
|
|
85
89
|
__name(randomFileName, "randomFileName");
|
|
86
90
|
|
|
87
|
-
// src/
|
|
91
|
+
// src/backends/local.ts
|
|
88
92
|
import { join } from "path";
|
|
89
93
|
import fs from "fs/promises";
|
|
94
|
+
var LocalStorageBackend = class {
|
|
95
|
+
constructor(config) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
this.basePath = join(config.storagePath, "temp");
|
|
98
|
+
}
|
|
99
|
+
static {
|
|
100
|
+
__name(this, "LocalStorageBackend");
|
|
101
|
+
}
|
|
102
|
+
type = "local";
|
|
103
|
+
hasPublicUrl = false;
|
|
104
|
+
basePath;
|
|
105
|
+
async init() {
|
|
106
|
+
await fs.mkdir(this.basePath, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
async upload(buffer, filename) {
|
|
109
|
+
const filePath = join(this.basePath, filename);
|
|
110
|
+
await fs.writeFile(filePath, buffer);
|
|
111
|
+
return {
|
|
112
|
+
key: filePath
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async download(key) {
|
|
116
|
+
return fs.readFile(key);
|
|
117
|
+
}
|
|
118
|
+
async delete(key) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.unlink(key);
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async exists(key) {
|
|
125
|
+
try {
|
|
126
|
+
await fs.access(key);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// src/backends/s3.ts
|
|
135
|
+
import { createHmac, createHash } from "crypto";
|
|
136
|
+
var S3StorageBackend = class {
|
|
137
|
+
constructor(config) {
|
|
138
|
+
this.config = config;
|
|
139
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
140
|
+
}
|
|
141
|
+
static {
|
|
142
|
+
__name(this, "S3StorageBackend");
|
|
143
|
+
}
|
|
144
|
+
type = "s3";
|
|
145
|
+
hasPublicUrl;
|
|
146
|
+
async init() {
|
|
147
|
+
}
|
|
148
|
+
async upload(buffer, filename) {
|
|
149
|
+
const key = `temp/${filename}`;
|
|
150
|
+
const url = this.buildS3Url(key);
|
|
151
|
+
const date = /* @__PURE__ */ new Date();
|
|
152
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
153
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
154
|
+
const payloadHash = createHash("sha256").update(buffer).digest("hex");
|
|
155
|
+
const headers = {
|
|
156
|
+
"Host": new URL(url).host,
|
|
157
|
+
"x-amz-date": amzDate,
|
|
158
|
+
"x-amz-content-sha256": payloadHash,
|
|
159
|
+
"Content-Type": "application/octet-stream",
|
|
160
|
+
"Content-Length": buffer.length.toString()
|
|
161
|
+
};
|
|
162
|
+
const authorization = this.generateAuthorizationHeader(
|
|
163
|
+
"PUT",
|
|
164
|
+
key,
|
|
165
|
+
headers,
|
|
166
|
+
payloadHash,
|
|
167
|
+
dateStamp,
|
|
168
|
+
amzDate
|
|
169
|
+
);
|
|
170
|
+
headers["Authorization"] = authorization;
|
|
171
|
+
const response = await fetch(url, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers,
|
|
174
|
+
body: new Uint8Array(buffer)
|
|
175
|
+
});
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
throw new Error(`S3 upload failed: ${response.status} ${text}`);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
key,
|
|
182
|
+
publicUrl: this.hasPublicUrl ? `${this.config.publicUrl}/${key}` : void 0
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async download(key) {
|
|
186
|
+
const url = this.buildS3Url(key);
|
|
187
|
+
const date = /* @__PURE__ */ new Date();
|
|
188
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
189
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
190
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
191
|
+
const headers = {
|
|
192
|
+
"Host": new URL(url).host,
|
|
193
|
+
"x-amz-date": amzDate,
|
|
194
|
+
"x-amz-content-sha256": payloadHash
|
|
195
|
+
};
|
|
196
|
+
const authorization = this.generateAuthorizationHeader(
|
|
197
|
+
"GET",
|
|
198
|
+
key,
|
|
199
|
+
headers,
|
|
200
|
+
payloadHash,
|
|
201
|
+
dateStamp,
|
|
202
|
+
amzDate
|
|
203
|
+
);
|
|
204
|
+
headers["Authorization"] = authorization;
|
|
205
|
+
const response = await fetch(url, {
|
|
206
|
+
method: "GET",
|
|
207
|
+
headers
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new Error(`S3 download failed: ${response.status}`);
|
|
211
|
+
}
|
|
212
|
+
return Buffer.from(await response.arrayBuffer());
|
|
213
|
+
}
|
|
214
|
+
async delete(key) {
|
|
215
|
+
const url = this.buildS3Url(key);
|
|
216
|
+
const date = /* @__PURE__ */ new Date();
|
|
217
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
218
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
219
|
+
const payloadHash = createHash("sha256").update("").digest("hex");
|
|
220
|
+
const headers = {
|
|
221
|
+
"Host": new URL(url).host,
|
|
222
|
+
"x-amz-date": amzDate,
|
|
223
|
+
"x-amz-content-sha256": payloadHash
|
|
224
|
+
};
|
|
225
|
+
const authorization = this.generateAuthorizationHeader(
|
|
226
|
+
"DELETE",
|
|
227
|
+
key,
|
|
228
|
+
headers,
|
|
229
|
+
payloadHash,
|
|
230
|
+
dateStamp,
|
|
231
|
+
amzDate
|
|
232
|
+
);
|
|
233
|
+
headers["Authorization"] = authorization;
|
|
234
|
+
const response = await fetch(url, {
|
|
235
|
+
method: "DELETE",
|
|
236
|
+
headers
|
|
237
|
+
});
|
|
238
|
+
if (!response.ok && response.status !== 404) {
|
|
239
|
+
throw new Error(`S3 delete failed: ${response.status}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async exists(key) {
|
|
243
|
+
const url = this.buildS3Url(key);
|
|
244
|
+
const date = /* @__PURE__ */ new Date();
|
|
245
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
246
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
247
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
248
|
+
const headers = {
|
|
249
|
+
"Host": new URL(url).host,
|
|
250
|
+
"x-amz-date": amzDate,
|
|
251
|
+
"x-amz-content-sha256": payloadHash
|
|
252
|
+
};
|
|
253
|
+
const authorization = this.generateAuthorizationHeader(
|
|
254
|
+
"HEAD",
|
|
255
|
+
key,
|
|
256
|
+
headers,
|
|
257
|
+
payloadHash,
|
|
258
|
+
dateStamp,
|
|
259
|
+
amzDate
|
|
260
|
+
);
|
|
261
|
+
headers["Authorization"] = authorization;
|
|
262
|
+
const response = await fetch(url, {
|
|
263
|
+
method: "HEAD",
|
|
264
|
+
headers
|
|
265
|
+
});
|
|
266
|
+
return response.ok;
|
|
267
|
+
}
|
|
268
|
+
buildS3Url(key) {
|
|
269
|
+
const endpoint = new URL(this.config.endpoint);
|
|
270
|
+
const shouldUsePathStyle = this.config.pathStyle || endpoint.hostname === "localhost" || endpoint.hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/);
|
|
271
|
+
if (shouldUsePathStyle) {
|
|
272
|
+
return `${endpoint.origin}/${this.config.bucket}/${key}`;
|
|
273
|
+
}
|
|
274
|
+
return `${endpoint.protocol}//${this.config.bucket}.${endpoint.host}/${key}`;
|
|
275
|
+
}
|
|
276
|
+
generateAuthorizationHeader(method, key, headers, payloadHash, dateStamp, amzDate) {
|
|
277
|
+
const region = this.config.region;
|
|
278
|
+
const service = "s3";
|
|
279
|
+
const canonicalUri = `/${this.config.bucket}/${key}`;
|
|
280
|
+
const canonicalQueryString = "";
|
|
281
|
+
const signedHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().join(";");
|
|
282
|
+
const canonicalHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().map((h) => `${h}:${headers[Object.keys(headers).find((k) => k.toLowerCase() === h)].trim()}`).join("\n") + "\n";
|
|
283
|
+
const canonicalRequest = [
|
|
284
|
+
method,
|
|
285
|
+
canonicalUri,
|
|
286
|
+
canonicalQueryString,
|
|
287
|
+
canonicalHeaders,
|
|
288
|
+
signedHeaders,
|
|
289
|
+
payloadHash
|
|
290
|
+
].join("\n");
|
|
291
|
+
const algorithm = "AWS4-HMAC-SHA256";
|
|
292
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
293
|
+
const stringToSign = [
|
|
294
|
+
algorithm,
|
|
295
|
+
amzDate,
|
|
296
|
+
credentialScope,
|
|
297
|
+
createHash("sha256").update(canonicalRequest).digest("hex")
|
|
298
|
+
].join("\n");
|
|
299
|
+
const signingKey = this.getSigningKey(dateStamp, region, service);
|
|
300
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
301
|
+
return `${algorithm} Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
302
|
+
}
|
|
303
|
+
getSigningKey(dateStamp, region, service) {
|
|
304
|
+
const kDate = createHmac("sha256", `AWS4${this.config.secretAccessKey}`).update(dateStamp).digest();
|
|
305
|
+
const kRegion = createHmac("sha256", kDate).update(region).digest();
|
|
306
|
+
const kService = createHmac("sha256", kRegion).update(service).digest();
|
|
307
|
+
const kSigning = createHmac("sha256", kService).update("aws4_request").digest();
|
|
308
|
+
return kSigning;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/backends/webdav.ts
|
|
313
|
+
var WebDAVStorageBackend = class {
|
|
314
|
+
constructor(config) {
|
|
315
|
+
this.config = config;
|
|
316
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
317
|
+
this.basePath = this.trimSlash(config.basePath ?? "chatluna-storage");
|
|
318
|
+
}
|
|
319
|
+
static {
|
|
320
|
+
__name(this, "WebDAVStorageBackend");
|
|
321
|
+
}
|
|
322
|
+
type = "webdav";
|
|
323
|
+
hasPublicUrl;
|
|
324
|
+
basePath;
|
|
325
|
+
async init() {
|
|
326
|
+
await this.ensureBasePath();
|
|
327
|
+
}
|
|
328
|
+
async upload(buffer, filename) {
|
|
329
|
+
const remotePath = `${this.basePath}/temp/${filename}`;
|
|
330
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(remotePath)}`;
|
|
331
|
+
const headers = {
|
|
332
|
+
"Content-Type": "application/octet-stream"
|
|
333
|
+
};
|
|
334
|
+
if (this.config.username && this.config.password) {
|
|
335
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
336
|
+
}
|
|
337
|
+
await this.ensureDirectory(`${this.basePath}/temp`);
|
|
338
|
+
const response = await fetch(url, {
|
|
339
|
+
method: "PUT",
|
|
340
|
+
headers,
|
|
341
|
+
body: new Uint8Array(buffer)
|
|
342
|
+
});
|
|
343
|
+
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
|
344
|
+
throw new Error(`WebDAV upload failed: ${response.status}`);
|
|
345
|
+
}
|
|
346
|
+
const publicUrl = this.config.publicUrl ? `${this.trimSlash(this.config.publicUrl)}/${this.encodePath(remotePath)}` : void 0;
|
|
347
|
+
return {
|
|
348
|
+
key: remotePath,
|
|
349
|
+
publicUrl
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async download(key) {
|
|
353
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
354
|
+
const headers = {};
|
|
355
|
+
if (this.config.username && this.config.password) {
|
|
356
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
357
|
+
}
|
|
358
|
+
const response = await fetch(url, {
|
|
359
|
+
method: "GET",
|
|
360
|
+
headers
|
|
361
|
+
});
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
throw new Error(`WebDAV download failed: ${response.status}`);
|
|
364
|
+
}
|
|
365
|
+
return Buffer.from(await response.arrayBuffer());
|
|
366
|
+
}
|
|
367
|
+
async delete(key) {
|
|
368
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
369
|
+
const headers = {};
|
|
370
|
+
if (this.config.username && this.config.password) {
|
|
371
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
372
|
+
}
|
|
373
|
+
const response = await fetch(url, {
|
|
374
|
+
method: "DELETE",
|
|
375
|
+
headers
|
|
376
|
+
});
|
|
377
|
+
if (!response.ok && response.status !== 404) {
|
|
378
|
+
throw new Error(`WebDAV delete failed: ${response.status}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async exists(key) {
|
|
382
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
383
|
+
const headers = {};
|
|
384
|
+
if (this.config.username && this.config.password) {
|
|
385
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
386
|
+
}
|
|
387
|
+
const response = await fetch(url, {
|
|
388
|
+
method: "HEAD",
|
|
389
|
+
headers
|
|
390
|
+
});
|
|
391
|
+
return response.ok;
|
|
392
|
+
}
|
|
393
|
+
async ensureBasePath() {
|
|
394
|
+
await this.ensureDirectory(this.basePath);
|
|
395
|
+
}
|
|
396
|
+
async ensureDirectory(path) {
|
|
397
|
+
const parts = path.split("/");
|
|
398
|
+
let currentPath = "";
|
|
399
|
+
for (const part of parts) {
|
|
400
|
+
if (!part) continue;
|
|
401
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
402
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(currentPath)}/`;
|
|
403
|
+
const headers = {};
|
|
404
|
+
if (this.config.username && this.config.password) {
|
|
405
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
406
|
+
}
|
|
407
|
+
const response = await fetch(url, {
|
|
408
|
+
method: "MKCOL",
|
|
409
|
+
headers
|
|
410
|
+
});
|
|
411
|
+
if (!response.ok && response.status !== 405 && response.status !== 201) {
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
b64(str) {
|
|
416
|
+
return Buffer.from(str, "utf8").toString("base64");
|
|
417
|
+
}
|
|
418
|
+
trimSlash(s) {
|
|
419
|
+
return s.replace(/\/+$/, "");
|
|
420
|
+
}
|
|
421
|
+
encodePath(path) {
|
|
422
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// src/backends/r2.ts
|
|
427
|
+
import { createHmac as createHmac2, createHash as createHash2 } from "crypto";
|
|
428
|
+
var R2StorageBackend = class {
|
|
429
|
+
constructor(config) {
|
|
430
|
+
this.config = config;
|
|
431
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
432
|
+
this.endpoint = `https://${config.accountId}.r2.cloudflarestorage.com`;
|
|
433
|
+
}
|
|
434
|
+
static {
|
|
435
|
+
__name(this, "R2StorageBackend");
|
|
436
|
+
}
|
|
437
|
+
type = "r2";
|
|
438
|
+
hasPublicUrl;
|
|
439
|
+
endpoint;
|
|
440
|
+
async init() {
|
|
441
|
+
}
|
|
442
|
+
async upload(buffer, filename) {
|
|
443
|
+
const key = `temp/${filename}`;
|
|
444
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
445
|
+
const date = /* @__PURE__ */ new Date();
|
|
446
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
447
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
448
|
+
const payloadHash = createHash2("sha256").update(buffer).digest("hex");
|
|
449
|
+
const headers = {
|
|
450
|
+
"Host": new URL(url).host,
|
|
451
|
+
"x-amz-date": amzDate,
|
|
452
|
+
"x-amz-content-sha256": payloadHash,
|
|
453
|
+
"Content-Type": "application/octet-stream",
|
|
454
|
+
"Content-Length": buffer.length.toString()
|
|
455
|
+
};
|
|
456
|
+
const authorization = this.generateAuthorizationHeader(
|
|
457
|
+
"PUT",
|
|
458
|
+
key,
|
|
459
|
+
headers,
|
|
460
|
+
payloadHash,
|
|
461
|
+
dateStamp,
|
|
462
|
+
amzDate
|
|
463
|
+
);
|
|
464
|
+
headers["Authorization"] = authorization;
|
|
465
|
+
const response = await fetch(url, {
|
|
466
|
+
method: "PUT",
|
|
467
|
+
headers,
|
|
468
|
+
body: new Uint8Array(buffer)
|
|
469
|
+
});
|
|
470
|
+
if (!response.ok) {
|
|
471
|
+
const text = await response.text();
|
|
472
|
+
throw new Error(`R2 upload failed: ${response.status} ${text}`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
key,
|
|
476
|
+
publicUrl: this.hasPublicUrl ? `${this.config.publicUrl}/${key}` : void 0
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
async download(key) {
|
|
480
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
481
|
+
const date = /* @__PURE__ */ new Date();
|
|
482
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
483
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
484
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
485
|
+
const headers = {
|
|
486
|
+
"Host": new URL(url).host,
|
|
487
|
+
"x-amz-date": amzDate,
|
|
488
|
+
"x-amz-content-sha256": payloadHash
|
|
489
|
+
};
|
|
490
|
+
const authorization = this.generateAuthorizationHeader(
|
|
491
|
+
"GET",
|
|
492
|
+
key,
|
|
493
|
+
headers,
|
|
494
|
+
payloadHash,
|
|
495
|
+
dateStamp,
|
|
496
|
+
amzDate
|
|
497
|
+
);
|
|
498
|
+
headers["Authorization"] = authorization;
|
|
499
|
+
const response = await fetch(url, {
|
|
500
|
+
method: "GET",
|
|
501
|
+
headers
|
|
502
|
+
});
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
throw new Error(`R2 download failed: ${response.status}`);
|
|
505
|
+
}
|
|
506
|
+
return Buffer.from(await response.arrayBuffer());
|
|
507
|
+
}
|
|
508
|
+
async delete(key) {
|
|
509
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
510
|
+
const date = /* @__PURE__ */ new Date();
|
|
511
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
512
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
513
|
+
const payloadHash = createHash2("sha256").update("").digest("hex");
|
|
514
|
+
const headers = {
|
|
515
|
+
"Host": new URL(url).host,
|
|
516
|
+
"x-amz-date": amzDate,
|
|
517
|
+
"x-amz-content-sha256": payloadHash
|
|
518
|
+
};
|
|
519
|
+
const authorization = this.generateAuthorizationHeader(
|
|
520
|
+
"DELETE",
|
|
521
|
+
key,
|
|
522
|
+
headers,
|
|
523
|
+
payloadHash,
|
|
524
|
+
dateStamp,
|
|
525
|
+
amzDate
|
|
526
|
+
);
|
|
527
|
+
headers["Authorization"] = authorization;
|
|
528
|
+
const response = await fetch(url, {
|
|
529
|
+
method: "DELETE",
|
|
530
|
+
headers
|
|
531
|
+
});
|
|
532
|
+
if (!response.ok && response.status !== 404) {
|
|
533
|
+
throw new Error(`R2 delete failed: ${response.status}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async exists(key) {
|
|
537
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
538
|
+
const date = /* @__PURE__ */ new Date();
|
|
539
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
540
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
541
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
542
|
+
const headers = {
|
|
543
|
+
"Host": new URL(url).host,
|
|
544
|
+
"x-amz-date": amzDate,
|
|
545
|
+
"x-amz-content-sha256": payloadHash
|
|
546
|
+
};
|
|
547
|
+
const authorization = this.generateAuthorizationHeader(
|
|
548
|
+
"HEAD",
|
|
549
|
+
key,
|
|
550
|
+
headers,
|
|
551
|
+
payloadHash,
|
|
552
|
+
dateStamp,
|
|
553
|
+
amzDate
|
|
554
|
+
);
|
|
555
|
+
headers["Authorization"] = authorization;
|
|
556
|
+
const response = await fetch(url, {
|
|
557
|
+
method: "HEAD",
|
|
558
|
+
headers
|
|
559
|
+
});
|
|
560
|
+
return response.ok;
|
|
561
|
+
}
|
|
562
|
+
generateAuthorizationHeader(method, key, headers, payloadHash, dateStamp, amzDate) {
|
|
563
|
+
const region = "auto";
|
|
564
|
+
const service = "s3";
|
|
565
|
+
const canonicalUri = `/${this.config.bucket}/${key}`;
|
|
566
|
+
const canonicalQueryString = "";
|
|
567
|
+
const signedHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().join(";");
|
|
568
|
+
const canonicalHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().map((h) => `${h}:${headers[Object.keys(headers).find((k) => k.toLowerCase() === h)].trim()}`).join("\n") + "\n";
|
|
569
|
+
const canonicalRequest = [
|
|
570
|
+
method,
|
|
571
|
+
canonicalUri,
|
|
572
|
+
canonicalQueryString,
|
|
573
|
+
canonicalHeaders,
|
|
574
|
+
signedHeaders,
|
|
575
|
+
payloadHash
|
|
576
|
+
].join("\n");
|
|
577
|
+
const algorithm = "AWS4-HMAC-SHA256";
|
|
578
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
579
|
+
const stringToSign = [
|
|
580
|
+
algorithm,
|
|
581
|
+
amzDate,
|
|
582
|
+
credentialScope,
|
|
583
|
+
createHash2("sha256").update(canonicalRequest).digest("hex")
|
|
584
|
+
].join("\n");
|
|
585
|
+
const signingKey = this.getSigningKey(dateStamp, region, service);
|
|
586
|
+
const signature = createHmac2("sha256", signingKey).update(stringToSign).digest("hex");
|
|
587
|
+
return `${algorithm} Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
588
|
+
}
|
|
589
|
+
getSigningKey(dateStamp, region, service) {
|
|
590
|
+
const kDate = createHmac2("sha256", `AWS4${this.config.secretAccessKey}`).update(dateStamp).digest();
|
|
591
|
+
const kRegion = createHmac2("sha256", kDate).update(region).digest();
|
|
592
|
+
const kService = createHmac2("sha256", kRegion).update(service).digest();
|
|
593
|
+
const kSigning = createHmac2("sha256", kService).update("aws4_request").digest();
|
|
594
|
+
return kSigning;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// src/backends/index.ts
|
|
599
|
+
function createStorageBackend(config) {
|
|
600
|
+
switch (config.type) {
|
|
601
|
+
case "local":
|
|
602
|
+
return new LocalStorageBackend(config);
|
|
603
|
+
case "s3":
|
|
604
|
+
return new S3StorageBackend(config);
|
|
605
|
+
case "webdav":
|
|
606
|
+
return new WebDAVStorageBackend(config);
|
|
607
|
+
case "r2":
|
|
608
|
+
return new R2StorageBackend(config);
|
|
609
|
+
default:
|
|
610
|
+
throw new Error(`Unknown storage backend type: ${config.type}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
__name(createStorageBackend, "createStorageBackend");
|
|
614
|
+
|
|
615
|
+
// src/service/storage.ts
|
|
616
|
+
import fs2 from "fs/promises";
|
|
90
617
|
var ChatLunaStorageService = class extends Service {
|
|
91
618
|
constructor(ctx, config) {
|
|
92
619
|
super(ctx, "chatluna_storage", true);
|
|
@@ -97,6 +624,7 @@ var ChatLunaStorageService = class extends Service {
|
|
|
97
624
|
this.lruTail.prev = this.lruHead;
|
|
98
625
|
this.lruMap = /* @__PURE__ */ new Map();
|
|
99
626
|
this.backendPath = this.config.backendPath;
|
|
627
|
+
this.storageBackend = createStorageBackend(this.getStorageConfig());
|
|
100
628
|
ctx.database.extend(
|
|
101
629
|
"chatluna_storage_temp",
|
|
102
630
|
{
|
|
@@ -110,7 +638,16 @@ var ChatLunaStorageService = class extends Service {
|
|
|
110
638
|
expireTime: "timestamp",
|
|
111
639
|
size: "integer",
|
|
112
640
|
accessTime: "timestamp",
|
|
113
|
-
accessCount: "integer"
|
|
641
|
+
accessCount: "integer",
|
|
642
|
+
// New optional fields for multi-backend support
|
|
643
|
+
storageType: {
|
|
644
|
+
type: "string",
|
|
645
|
+
nullable: true
|
|
646
|
+
},
|
|
647
|
+
publicUrl: {
|
|
648
|
+
type: "string",
|
|
649
|
+
nullable: true
|
|
650
|
+
}
|
|
114
651
|
},
|
|
115
652
|
{
|
|
116
653
|
autoInc: false,
|
|
@@ -119,6 +656,7 @@ var ChatLunaStorageService = class extends Service {
|
|
|
119
656
|
);
|
|
120
657
|
this.setupAutoDelete();
|
|
121
658
|
this.initializeLRU();
|
|
659
|
+
this.initStorageBackend();
|
|
122
660
|
ctx.inject(["server"], (ctx2) => {
|
|
123
661
|
const backendPath = `${config.serverPath ?? ctx2.server.selfUrl}${this.config.backendPath}`;
|
|
124
662
|
this.backendPath = backendPath;
|
|
@@ -131,6 +669,55 @@ var ChatLunaStorageService = class extends Service {
|
|
|
131
669
|
lruTail;
|
|
132
670
|
lruMap;
|
|
133
671
|
backendPath;
|
|
672
|
+
storageBackend;
|
|
673
|
+
getStorageConfig() {
|
|
674
|
+
const backendType = this.config.storageBackend ?? "local";
|
|
675
|
+
switch (backendType) {
|
|
676
|
+
case "s3":
|
|
677
|
+
return {
|
|
678
|
+
type: "s3",
|
|
679
|
+
endpoint: this.config.s3Endpoint,
|
|
680
|
+
bucket: this.config.s3Bucket,
|
|
681
|
+
region: this.config.s3Region,
|
|
682
|
+
accessKeyId: this.config.s3AccessKeyId,
|
|
683
|
+
secretAccessKey: this.config.s3SecretAccessKey,
|
|
684
|
+
publicUrl: this.config.s3PublicUrl,
|
|
685
|
+
pathStyle: this.config.s3PathStyle
|
|
686
|
+
};
|
|
687
|
+
case "webdav":
|
|
688
|
+
return {
|
|
689
|
+
type: "webdav",
|
|
690
|
+
endpoint: this.config.webdavEndpoint,
|
|
691
|
+
username: this.config.webdavUsername,
|
|
692
|
+
password: this.config.webdavPassword,
|
|
693
|
+
basePath: this.config.webdavBasePath,
|
|
694
|
+
publicUrl: this.config.webdavPublicUrl
|
|
695
|
+
};
|
|
696
|
+
case "r2":
|
|
697
|
+
return {
|
|
698
|
+
type: "r2",
|
|
699
|
+
accountId: this.config.r2AccountId,
|
|
700
|
+
bucket: this.config.r2Bucket,
|
|
701
|
+
accessKeyId: this.config.r2AccessKeyId,
|
|
702
|
+
secretAccessKey: this.config.r2SecretAccessKey,
|
|
703
|
+
publicUrl: this.config.r2PublicUrl
|
|
704
|
+
};
|
|
705
|
+
case "local":
|
|
706
|
+
default:
|
|
707
|
+
return {
|
|
708
|
+
type: "local",
|
|
709
|
+
storagePath: this.config.storagePath
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async initStorageBackend() {
|
|
714
|
+
try {
|
|
715
|
+
await this.storageBackend.init();
|
|
716
|
+
logger.info(`Storage backend initialized: ${this.storageBackend.type}`);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
logger.error("Failed to initialize storage backend:", error);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
134
721
|
async initializeLRU() {
|
|
135
722
|
const files = await this.ctx.database.get("chatluna_storage_temp", {});
|
|
136
723
|
files.sort((a, b) => b.accessTime.getTime() - a.accessTime.getTime());
|
|
@@ -174,7 +761,7 @@ var ChatLunaStorageService = class extends Service {
|
|
|
174
761
|
for (const file of sortedFiles) {
|
|
175
762
|
if (currentSize <= maxSizeBytes * 0.8) break;
|
|
176
763
|
try {
|
|
177
|
-
await
|
|
764
|
+
await this.deleteFileFromBackend(file);
|
|
178
765
|
await this.ctx.database.remove("chatluna_storage_temp", {
|
|
179
766
|
id: file.id
|
|
180
767
|
});
|
|
@@ -197,7 +784,7 @@ var ChatLunaStorageService = class extends Service {
|
|
|
197
784
|
for (let i = 0; i < filesToDelete; i++) {
|
|
198
785
|
const file = sortedFiles[i];
|
|
199
786
|
try {
|
|
200
|
-
await
|
|
787
|
+
await this.deleteFileFromBackend(file);
|
|
201
788
|
await this.ctx.database.remove("chatluna_storage_temp", {
|
|
202
789
|
id: file.id
|
|
203
790
|
});
|
|
@@ -210,8 +797,24 @@ var ChatLunaStorageService = class extends Service {
|
|
|
210
797
|
}
|
|
211
798
|
}
|
|
212
799
|
}
|
|
800
|
+
async deleteFileFromBackend(file) {
|
|
801
|
+
const storageType = file.storageType ?? "local";
|
|
802
|
+
if (storageType === "local") {
|
|
803
|
+
await fs2.unlink(file.path);
|
|
804
|
+
} else {
|
|
805
|
+
if (this.storageBackend.type === storageType) {
|
|
806
|
+
await this.storageBackend.delete(file.path);
|
|
807
|
+
} else {
|
|
808
|
+
try {
|
|
809
|
+
await this.storageBackend.delete(file.path);
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
213
815
|
setupAutoDelete() {
|
|
214
816
|
const ctx = this.ctx;
|
|
817
|
+
const self = this;
|
|
215
818
|
async function execute() {
|
|
216
819
|
if (!ctx.scope.isActive) {
|
|
217
820
|
return;
|
|
@@ -230,7 +833,7 @@ var ChatLunaStorageService = class extends Service {
|
|
|
230
833
|
const success = [];
|
|
231
834
|
for (const file of expiredFiles) {
|
|
232
835
|
try {
|
|
233
|
-
await
|
|
836
|
+
await self.deleteFileFromBackend(file);
|
|
234
837
|
await ctx.database.remove("chatluna_storage_temp", {
|
|
235
838
|
id: file.id
|
|
236
839
|
});
|
|
@@ -268,29 +871,28 @@ var ChatLunaStorageService = class extends Service {
|
|
|
268
871
|
if (fileType != null) {
|
|
269
872
|
randomName = (randomName.split(".")?.[0] ?? randomName) + "." + fileType;
|
|
270
873
|
}
|
|
271
|
-
const
|
|
272
|
-
await fs.mkdir(join(this.config.storagePath, "temp"), {
|
|
273
|
-
recursive: true
|
|
274
|
-
});
|
|
275
|
-
await fs.writeFile(filePath, processedBuffer);
|
|
874
|
+
const result = await this.storageBackend.upload(processedBuffer, randomName);
|
|
276
875
|
const expireTime = new Date(Date.now() + (expireHours || this.config.tempCacheTime) * 60 * 60 * 1e3);
|
|
277
876
|
const currentTime = /* @__PURE__ */ new Date();
|
|
278
877
|
const fileInfo = {
|
|
279
878
|
id: randomName.split(".")[0],
|
|
280
|
-
path:
|
|
879
|
+
path: result.key,
|
|
281
880
|
name: randomName,
|
|
282
881
|
type: fileType,
|
|
283
882
|
expireTime,
|
|
284
883
|
size: processedBuffer.length,
|
|
285
884
|
accessTime: currentTime,
|
|
286
|
-
accessCount: 1
|
|
885
|
+
accessCount: 1,
|
|
886
|
+
storageType: this.storageBackend.type,
|
|
887
|
+
publicUrl: result.publicUrl
|
|
287
888
|
};
|
|
288
889
|
await this.ctx.database.create("chatluna_storage_temp", fileInfo);
|
|
289
890
|
this.addToLRU(fileInfo.id);
|
|
891
|
+
const url = result.publicUrl ?? `${this.backendPath}/temp/${randomName}`;
|
|
290
892
|
return {
|
|
291
893
|
...fileInfo,
|
|
292
894
|
data: Promise.resolve(processedBuffer),
|
|
293
|
-
url
|
|
895
|
+
url
|
|
294
896
|
};
|
|
295
897
|
}
|
|
296
898
|
async getTempFile(id) {
|
|
@@ -315,12 +917,22 @@ var ChatLunaStorageService = class extends Service {
|
|
|
315
917
|
);
|
|
316
918
|
this.addToLRU(id);
|
|
317
919
|
try {
|
|
920
|
+
const storageType = file.storageType ?? "local";
|
|
921
|
+
let dataPromise;
|
|
922
|
+
if (storageType === "local") {
|
|
923
|
+
dataPromise = fs2.readFile(file.path);
|
|
924
|
+
} else if (this.storageBackend.type === storageType) {
|
|
925
|
+
dataPromise = this.storageBackend.download(file.path);
|
|
926
|
+
} else {
|
|
927
|
+
dataPromise = fs2.readFile(file.path);
|
|
928
|
+
}
|
|
929
|
+
const url = file.publicUrl ?? `${this.backendPath}/temp/${file.name}`;
|
|
318
930
|
return {
|
|
319
931
|
...file,
|
|
320
932
|
accessTime: currentTime,
|
|
321
933
|
accessCount: file.accessCount + 1,
|
|
322
|
-
data:
|
|
323
|
-
url
|
|
934
|
+
data: dataPromise,
|
|
935
|
+
url
|
|
324
936
|
};
|
|
325
937
|
} catch (error) {
|
|
326
938
|
await this.ctx.database.remove("chatluna_storage_temp", { id });
|
|
@@ -332,7 +944,15 @@ var ChatLunaStorageService = class extends Service {
|
|
|
332
944
|
|
|
333
945
|
// src/index.ts
|
|
334
946
|
var logger;
|
|
335
|
-
var usage = `使用此插件需要确保你 Koishi 运行在公网,或你的聊天适配器(如 onebot,telegram)和 koishi
|
|
947
|
+
var usage = `使用此插件需要确保你 Koishi 运行在公网,或你的聊天适配器(如 onebot,telegram)和 koishi 运行在同一个局域网中。
|
|
948
|
+
|
|
949
|
+
不同的存储后端的作用:
|
|
950
|
+
- **本地文件**:默认选项,文件存储在本地磁盘,通过 Koishi 的 HTTP 服务器提供访问
|
|
951
|
+
- **S3 兼容存储**:支持 AWS S3、MinIO 等 S3 兼容服务,可配置公网 URL 直接访问
|
|
952
|
+
- **WebDAV**:支持 WebDAV 协议的存储服务
|
|
953
|
+
- **Cloudflare R2**:Cloudflare 的对象存储服务,S3 兼容
|
|
954
|
+
|
|
955
|
+
如果存储后端支持公网访问(配置了公网 URL),系统会直接返回公网 URL,不经过 Koishi 服务器中转。`;
|
|
336
956
|
function apply2(ctx, config) {
|
|
337
957
|
ctx.on("ready", async () => {
|
|
338
958
|
ctx.plugin(ChatLunaStorageService, config);
|
|
@@ -346,15 +966,61 @@ var inject = {
|
|
|
346
966
|
};
|
|
347
967
|
var Config3 = Schema.intersect([
|
|
348
968
|
Schema.object({
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
969
|
+
storageBackend: Schema.union([
|
|
970
|
+
Schema.const("null").description("??").hidden(),
|
|
971
|
+
Schema.const("local").description("本地文件存储"),
|
|
972
|
+
Schema.const("s3").description("S3 兼容存储 (AWS S3, MinIO 等)"),
|
|
973
|
+
Schema.const("webdav").description("WebDAV 存储"),
|
|
974
|
+
Schema.const("r2").description("Cloudflare R2 存储")
|
|
975
|
+
]).required().description("存储后端类型"),
|
|
352
976
|
serverPath: Schema.string().description("Koishi 在公网或者局域网中的路径").default("http://127.0.0.1:5140"),
|
|
353
|
-
backendPath: Schema.string().description("后端服务器路径").default("/chatluna-storage"),
|
|
354
977
|
tempCacheTime: Schema.number().description("过期数据的缓存时间(小时)").default(24 * 30),
|
|
355
978
|
maxStorageSize: Schema.number().description("最大存储空间(MB)").default(500).min(1),
|
|
356
979
|
maxStorageCount: Schema.number().description("最大存储文件数").default(300).min(1)
|
|
357
|
-
}).description("基础配置")
|
|
980
|
+
}).description("基础配置"),
|
|
981
|
+
Schema.union([
|
|
982
|
+
Schema.object({
|
|
983
|
+
storageBackend: Schema.const("s3").required(),
|
|
984
|
+
s3Endpoint: Schema.string().description(
|
|
985
|
+
"S3 端点 URL(如 https://s3.amazonaws.com)"
|
|
986
|
+
),
|
|
987
|
+
s3Bucket: Schema.string().description("S3 存储桶名称"),
|
|
988
|
+
s3Region: Schema.string().description("S3 区域(如 us-east-1)").default("us-east-1"),
|
|
989
|
+
s3AccessKeyId: Schema.string().description("S3 Access Key ID"),
|
|
990
|
+
s3SecretAccessKey: Schema.string().description("S3 Secret Access Key").role("secret"),
|
|
991
|
+
s3PublicUrl: Schema.string().description(
|
|
992
|
+
"S3 公网访问 URL(可选,如果配置则直接返回公网 URL)"
|
|
993
|
+
),
|
|
994
|
+
s3PathStyle: Schema.boolean().description("使用路径风格 URL(用于 MinIO 等)").default(false)
|
|
995
|
+
}).description("S3 配置"),
|
|
996
|
+
Schema.object({
|
|
997
|
+
storageBackend: Schema.const("webdav").required(),
|
|
998
|
+
webdavEndpoint: Schema.string().description("WebDAV 服务器地址"),
|
|
999
|
+
webdavUsername: Schema.string().description("WebDAV 用户名"),
|
|
1000
|
+
webdavPassword: Schema.string().description("WebDAV 密码").role("secret"),
|
|
1001
|
+
webdavBasePath: Schema.string().description("WebDAV 基础路径").default("chatluna-storage"),
|
|
1002
|
+
webdavPublicUrl: Schema.string().description(
|
|
1003
|
+
"WebDAV 公网访问 URL(可选)"
|
|
1004
|
+
)
|
|
1005
|
+
}).description("WebDAV 配置"),
|
|
1006
|
+
Schema.object({
|
|
1007
|
+
storageBackend: Schema.const("r2").required(),
|
|
1008
|
+
r2AccountId: Schema.string().description("Cloudflare 账户 ID"),
|
|
1009
|
+
r2Bucket: Schema.string().description("R2 存储桶名称"),
|
|
1010
|
+
r2AccessKeyId: Schema.string().description("R2 Access Key ID"),
|
|
1011
|
+
r2SecretAccessKey: Schema.string().description("R2 Secret Access Key").role("secret"),
|
|
1012
|
+
r2PublicUrl: Schema.string().description(
|
|
1013
|
+
"R2 公网访问 URL(需在 Cloudflare 配置公共访问)"
|
|
1014
|
+
)
|
|
1015
|
+
}).description("Cloudflare R2 配置"),
|
|
1016
|
+
Schema.object({
|
|
1017
|
+
storageBackend: Schema.const("local").required(),
|
|
1018
|
+
storagePath: Schema.path({
|
|
1019
|
+
filters: ["directory"]
|
|
1020
|
+
}).description("本地缓存存储路径(仅本地存储使用)").default("./data/chatluna-storage"),
|
|
1021
|
+
backendPath: Schema.string().description("后端文件服务器监听的路径").default("/chatluna-storage")
|
|
1022
|
+
}).description("本地存储配置")
|
|
1023
|
+
])
|
|
358
1024
|
]);
|
|
359
1025
|
var name = "chatluna-storage-service";
|
|
360
1026
|
export {
|