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.cjs
CHANGED
|
@@ -55,6 +55,10 @@ function apply(ctx, config) {
|
|
|
55
55
|
koa.status = 404;
|
|
56
56
|
return koa.body = "File not found";
|
|
57
57
|
}
|
|
58
|
+
if (fileInfo.publicUrl && fileInfo.storageType !== "local") {
|
|
59
|
+
koa.redirect(fileInfo.publicUrl);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
58
62
|
koa.set(
|
|
59
63
|
"Content-Type",
|
|
60
64
|
fileInfo.type || "application/octet-stream"
|
|
@@ -121,9 +125,532 @@ function randomFileName(fileName) {
|
|
|
121
125
|
}
|
|
122
126
|
__name(randomFileName, "randomFileName");
|
|
123
127
|
|
|
124
|
-
// src/
|
|
128
|
+
// src/backends/local.ts
|
|
125
129
|
var import_path = require("path");
|
|
126
130
|
var import_promises = __toESM(require("fs/promises"), 1);
|
|
131
|
+
var LocalStorageBackend = class {
|
|
132
|
+
constructor(config) {
|
|
133
|
+
this.config = config;
|
|
134
|
+
this.basePath = (0, import_path.join)(config.storagePath, "temp");
|
|
135
|
+
}
|
|
136
|
+
static {
|
|
137
|
+
__name(this, "LocalStorageBackend");
|
|
138
|
+
}
|
|
139
|
+
type = "local";
|
|
140
|
+
hasPublicUrl = false;
|
|
141
|
+
basePath;
|
|
142
|
+
async init() {
|
|
143
|
+
await import_promises.default.mkdir(this.basePath, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
async upload(buffer, filename) {
|
|
146
|
+
const filePath = (0, import_path.join)(this.basePath, filename);
|
|
147
|
+
await import_promises.default.writeFile(filePath, buffer);
|
|
148
|
+
return {
|
|
149
|
+
key: filePath
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async download(key) {
|
|
153
|
+
return import_promises.default.readFile(key);
|
|
154
|
+
}
|
|
155
|
+
async delete(key) {
|
|
156
|
+
try {
|
|
157
|
+
await import_promises.default.unlink(key);
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async exists(key) {
|
|
162
|
+
try {
|
|
163
|
+
await import_promises.default.access(key);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// src/backends/s3.ts
|
|
172
|
+
var import_crypto = require("crypto");
|
|
173
|
+
var S3StorageBackend = class {
|
|
174
|
+
constructor(config) {
|
|
175
|
+
this.config = config;
|
|
176
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
177
|
+
}
|
|
178
|
+
static {
|
|
179
|
+
__name(this, "S3StorageBackend");
|
|
180
|
+
}
|
|
181
|
+
type = "s3";
|
|
182
|
+
hasPublicUrl;
|
|
183
|
+
async init() {
|
|
184
|
+
}
|
|
185
|
+
async upload(buffer, filename) {
|
|
186
|
+
const key = `temp/${filename}`;
|
|
187
|
+
const url = this.buildS3Url(key);
|
|
188
|
+
const date = /* @__PURE__ */ new Date();
|
|
189
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
190
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
191
|
+
const payloadHash = (0, import_crypto.createHash)("sha256").update(buffer).digest("hex");
|
|
192
|
+
const headers = {
|
|
193
|
+
"Host": new URL(url).host,
|
|
194
|
+
"x-amz-date": amzDate,
|
|
195
|
+
"x-amz-content-sha256": payloadHash,
|
|
196
|
+
"Content-Type": "application/octet-stream",
|
|
197
|
+
"Content-Length": buffer.length.toString()
|
|
198
|
+
};
|
|
199
|
+
const authorization = this.generateAuthorizationHeader(
|
|
200
|
+
"PUT",
|
|
201
|
+
key,
|
|
202
|
+
headers,
|
|
203
|
+
payloadHash,
|
|
204
|
+
dateStamp,
|
|
205
|
+
amzDate
|
|
206
|
+
);
|
|
207
|
+
headers["Authorization"] = authorization;
|
|
208
|
+
const response = await fetch(url, {
|
|
209
|
+
method: "PUT",
|
|
210
|
+
headers,
|
|
211
|
+
body: new Uint8Array(buffer)
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
throw new Error(`S3 upload failed: ${response.status} ${text}`);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
key,
|
|
219
|
+
publicUrl: this.hasPublicUrl ? `${this.config.publicUrl}/${key}` : void 0
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
async download(key) {
|
|
223
|
+
const url = this.buildS3Url(key);
|
|
224
|
+
const date = /* @__PURE__ */ new Date();
|
|
225
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
226
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
227
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
228
|
+
const headers = {
|
|
229
|
+
"Host": new URL(url).host,
|
|
230
|
+
"x-amz-date": amzDate,
|
|
231
|
+
"x-amz-content-sha256": payloadHash
|
|
232
|
+
};
|
|
233
|
+
const authorization = this.generateAuthorizationHeader(
|
|
234
|
+
"GET",
|
|
235
|
+
key,
|
|
236
|
+
headers,
|
|
237
|
+
payloadHash,
|
|
238
|
+
dateStamp,
|
|
239
|
+
amzDate
|
|
240
|
+
);
|
|
241
|
+
headers["Authorization"] = authorization;
|
|
242
|
+
const response = await fetch(url, {
|
|
243
|
+
method: "GET",
|
|
244
|
+
headers
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error(`S3 download failed: ${response.status}`);
|
|
248
|
+
}
|
|
249
|
+
return Buffer.from(await response.arrayBuffer());
|
|
250
|
+
}
|
|
251
|
+
async delete(key) {
|
|
252
|
+
const url = this.buildS3Url(key);
|
|
253
|
+
const date = /* @__PURE__ */ new Date();
|
|
254
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
255
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
256
|
+
const payloadHash = (0, import_crypto.createHash)("sha256").update("").digest("hex");
|
|
257
|
+
const headers = {
|
|
258
|
+
"Host": new URL(url).host,
|
|
259
|
+
"x-amz-date": amzDate,
|
|
260
|
+
"x-amz-content-sha256": payloadHash
|
|
261
|
+
};
|
|
262
|
+
const authorization = this.generateAuthorizationHeader(
|
|
263
|
+
"DELETE",
|
|
264
|
+
key,
|
|
265
|
+
headers,
|
|
266
|
+
payloadHash,
|
|
267
|
+
dateStamp,
|
|
268
|
+
amzDate
|
|
269
|
+
);
|
|
270
|
+
headers["Authorization"] = authorization;
|
|
271
|
+
const response = await fetch(url, {
|
|
272
|
+
method: "DELETE",
|
|
273
|
+
headers
|
|
274
|
+
});
|
|
275
|
+
if (!response.ok && response.status !== 404) {
|
|
276
|
+
throw new Error(`S3 delete failed: ${response.status}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async exists(key) {
|
|
280
|
+
const url = this.buildS3Url(key);
|
|
281
|
+
const date = /* @__PURE__ */ new Date();
|
|
282
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
283
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
284
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
285
|
+
const headers = {
|
|
286
|
+
"Host": new URL(url).host,
|
|
287
|
+
"x-amz-date": amzDate,
|
|
288
|
+
"x-amz-content-sha256": payloadHash
|
|
289
|
+
};
|
|
290
|
+
const authorization = this.generateAuthorizationHeader(
|
|
291
|
+
"HEAD",
|
|
292
|
+
key,
|
|
293
|
+
headers,
|
|
294
|
+
payloadHash,
|
|
295
|
+
dateStamp,
|
|
296
|
+
amzDate
|
|
297
|
+
);
|
|
298
|
+
headers["Authorization"] = authorization;
|
|
299
|
+
const response = await fetch(url, {
|
|
300
|
+
method: "HEAD",
|
|
301
|
+
headers
|
|
302
|
+
});
|
|
303
|
+
return response.ok;
|
|
304
|
+
}
|
|
305
|
+
buildS3Url(key) {
|
|
306
|
+
const endpoint = new URL(this.config.endpoint);
|
|
307
|
+
const shouldUsePathStyle = this.config.pathStyle || endpoint.hostname === "localhost" || endpoint.hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/);
|
|
308
|
+
if (shouldUsePathStyle) {
|
|
309
|
+
return `${endpoint.origin}/${this.config.bucket}/${key}`;
|
|
310
|
+
}
|
|
311
|
+
return `${endpoint.protocol}//${this.config.bucket}.${endpoint.host}/${key}`;
|
|
312
|
+
}
|
|
313
|
+
generateAuthorizationHeader(method, key, headers, payloadHash, dateStamp, amzDate) {
|
|
314
|
+
const region = this.config.region;
|
|
315
|
+
const service = "s3";
|
|
316
|
+
const canonicalUri = `/${this.config.bucket}/${key}`;
|
|
317
|
+
const canonicalQueryString = "";
|
|
318
|
+
const signedHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().join(";");
|
|
319
|
+
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";
|
|
320
|
+
const canonicalRequest = [
|
|
321
|
+
method,
|
|
322
|
+
canonicalUri,
|
|
323
|
+
canonicalQueryString,
|
|
324
|
+
canonicalHeaders,
|
|
325
|
+
signedHeaders,
|
|
326
|
+
payloadHash
|
|
327
|
+
].join("\n");
|
|
328
|
+
const algorithm = "AWS4-HMAC-SHA256";
|
|
329
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
330
|
+
const stringToSign = [
|
|
331
|
+
algorithm,
|
|
332
|
+
amzDate,
|
|
333
|
+
credentialScope,
|
|
334
|
+
(0, import_crypto.createHash)("sha256").update(canonicalRequest).digest("hex")
|
|
335
|
+
].join("\n");
|
|
336
|
+
const signingKey = this.getSigningKey(dateStamp, region, service);
|
|
337
|
+
const signature = (0, import_crypto.createHmac)("sha256", signingKey).update(stringToSign).digest("hex");
|
|
338
|
+
return `${algorithm} Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
339
|
+
}
|
|
340
|
+
getSigningKey(dateStamp, region, service) {
|
|
341
|
+
const kDate = (0, import_crypto.createHmac)("sha256", `AWS4${this.config.secretAccessKey}`).update(dateStamp).digest();
|
|
342
|
+
const kRegion = (0, import_crypto.createHmac)("sha256", kDate).update(region).digest();
|
|
343
|
+
const kService = (0, import_crypto.createHmac)("sha256", kRegion).update(service).digest();
|
|
344
|
+
const kSigning = (0, import_crypto.createHmac)("sha256", kService).update("aws4_request").digest();
|
|
345
|
+
return kSigning;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// src/backends/webdav.ts
|
|
350
|
+
var WebDAVStorageBackend = class {
|
|
351
|
+
constructor(config) {
|
|
352
|
+
this.config = config;
|
|
353
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
354
|
+
this.basePath = this.trimSlash(config.basePath ?? "chatluna-storage");
|
|
355
|
+
}
|
|
356
|
+
static {
|
|
357
|
+
__name(this, "WebDAVStorageBackend");
|
|
358
|
+
}
|
|
359
|
+
type = "webdav";
|
|
360
|
+
hasPublicUrl;
|
|
361
|
+
basePath;
|
|
362
|
+
async init() {
|
|
363
|
+
await this.ensureBasePath();
|
|
364
|
+
}
|
|
365
|
+
async upload(buffer, filename) {
|
|
366
|
+
const remotePath = `${this.basePath}/temp/${filename}`;
|
|
367
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(remotePath)}`;
|
|
368
|
+
const headers = {
|
|
369
|
+
"Content-Type": "application/octet-stream"
|
|
370
|
+
};
|
|
371
|
+
if (this.config.username && this.config.password) {
|
|
372
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
373
|
+
}
|
|
374
|
+
await this.ensureDirectory(`${this.basePath}/temp`);
|
|
375
|
+
const response = await fetch(url, {
|
|
376
|
+
method: "PUT",
|
|
377
|
+
headers,
|
|
378
|
+
body: new Uint8Array(buffer)
|
|
379
|
+
});
|
|
380
|
+
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
|
381
|
+
throw new Error(`WebDAV upload failed: ${response.status}`);
|
|
382
|
+
}
|
|
383
|
+
const publicUrl = this.config.publicUrl ? `${this.trimSlash(this.config.publicUrl)}/${this.encodePath(remotePath)}` : void 0;
|
|
384
|
+
return {
|
|
385
|
+
key: remotePath,
|
|
386
|
+
publicUrl
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async download(key) {
|
|
390
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
391
|
+
const headers = {};
|
|
392
|
+
if (this.config.username && this.config.password) {
|
|
393
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
394
|
+
}
|
|
395
|
+
const response = await fetch(url, {
|
|
396
|
+
method: "GET",
|
|
397
|
+
headers
|
|
398
|
+
});
|
|
399
|
+
if (!response.ok) {
|
|
400
|
+
throw new Error(`WebDAV download failed: ${response.status}`);
|
|
401
|
+
}
|
|
402
|
+
return Buffer.from(await response.arrayBuffer());
|
|
403
|
+
}
|
|
404
|
+
async delete(key) {
|
|
405
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
406
|
+
const headers = {};
|
|
407
|
+
if (this.config.username && this.config.password) {
|
|
408
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
409
|
+
}
|
|
410
|
+
const response = await fetch(url, {
|
|
411
|
+
method: "DELETE",
|
|
412
|
+
headers
|
|
413
|
+
});
|
|
414
|
+
if (!response.ok && response.status !== 404) {
|
|
415
|
+
throw new Error(`WebDAV delete failed: ${response.status}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async exists(key) {
|
|
419
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(key)}`;
|
|
420
|
+
const headers = {};
|
|
421
|
+
if (this.config.username && this.config.password) {
|
|
422
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
423
|
+
}
|
|
424
|
+
const response = await fetch(url, {
|
|
425
|
+
method: "HEAD",
|
|
426
|
+
headers
|
|
427
|
+
});
|
|
428
|
+
return response.ok;
|
|
429
|
+
}
|
|
430
|
+
async ensureBasePath() {
|
|
431
|
+
await this.ensureDirectory(this.basePath);
|
|
432
|
+
}
|
|
433
|
+
async ensureDirectory(path) {
|
|
434
|
+
const parts = path.split("/");
|
|
435
|
+
let currentPath = "";
|
|
436
|
+
for (const part of parts) {
|
|
437
|
+
if (!part) continue;
|
|
438
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
439
|
+
const url = `${this.trimSlash(this.config.endpoint)}/${this.encodePath(currentPath)}/`;
|
|
440
|
+
const headers = {};
|
|
441
|
+
if (this.config.username && this.config.password) {
|
|
442
|
+
headers["Authorization"] = `Basic ${this.b64(`${this.config.username}:${this.config.password}`)}`;
|
|
443
|
+
}
|
|
444
|
+
const response = await fetch(url, {
|
|
445
|
+
method: "MKCOL",
|
|
446
|
+
headers
|
|
447
|
+
});
|
|
448
|
+
if (!response.ok && response.status !== 405 && response.status !== 201) {
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
b64(str) {
|
|
453
|
+
return Buffer.from(str, "utf8").toString("base64");
|
|
454
|
+
}
|
|
455
|
+
trimSlash(s) {
|
|
456
|
+
return s.replace(/\/+$/, "");
|
|
457
|
+
}
|
|
458
|
+
encodePath(path) {
|
|
459
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// src/backends/r2.ts
|
|
464
|
+
var import_crypto2 = require("crypto");
|
|
465
|
+
var R2StorageBackend = class {
|
|
466
|
+
constructor(config) {
|
|
467
|
+
this.config = config;
|
|
468
|
+
this.hasPublicUrl = !!config.publicUrl;
|
|
469
|
+
this.endpoint = `https://${config.accountId}.r2.cloudflarestorage.com`;
|
|
470
|
+
}
|
|
471
|
+
static {
|
|
472
|
+
__name(this, "R2StorageBackend");
|
|
473
|
+
}
|
|
474
|
+
type = "r2";
|
|
475
|
+
hasPublicUrl;
|
|
476
|
+
endpoint;
|
|
477
|
+
async init() {
|
|
478
|
+
}
|
|
479
|
+
async upload(buffer, filename) {
|
|
480
|
+
const key = `temp/${filename}`;
|
|
481
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
482
|
+
const date = /* @__PURE__ */ new Date();
|
|
483
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
484
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
485
|
+
const payloadHash = (0, import_crypto2.createHash)("sha256").update(buffer).digest("hex");
|
|
486
|
+
const headers = {
|
|
487
|
+
"Host": new URL(url).host,
|
|
488
|
+
"x-amz-date": amzDate,
|
|
489
|
+
"x-amz-content-sha256": payloadHash,
|
|
490
|
+
"Content-Type": "application/octet-stream",
|
|
491
|
+
"Content-Length": buffer.length.toString()
|
|
492
|
+
};
|
|
493
|
+
const authorization = this.generateAuthorizationHeader(
|
|
494
|
+
"PUT",
|
|
495
|
+
key,
|
|
496
|
+
headers,
|
|
497
|
+
payloadHash,
|
|
498
|
+
dateStamp,
|
|
499
|
+
amzDate
|
|
500
|
+
);
|
|
501
|
+
headers["Authorization"] = authorization;
|
|
502
|
+
const response = await fetch(url, {
|
|
503
|
+
method: "PUT",
|
|
504
|
+
headers,
|
|
505
|
+
body: new Uint8Array(buffer)
|
|
506
|
+
});
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
const text = await response.text();
|
|
509
|
+
throw new Error(`R2 upload failed: ${response.status} ${text}`);
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
key,
|
|
513
|
+
publicUrl: this.hasPublicUrl ? `${this.config.publicUrl}/${key}` : void 0
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
async download(key) {
|
|
517
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
518
|
+
const date = /* @__PURE__ */ new Date();
|
|
519
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
520
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
521
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
522
|
+
const headers = {
|
|
523
|
+
"Host": new URL(url).host,
|
|
524
|
+
"x-amz-date": amzDate,
|
|
525
|
+
"x-amz-content-sha256": payloadHash
|
|
526
|
+
};
|
|
527
|
+
const authorization = this.generateAuthorizationHeader(
|
|
528
|
+
"GET",
|
|
529
|
+
key,
|
|
530
|
+
headers,
|
|
531
|
+
payloadHash,
|
|
532
|
+
dateStamp,
|
|
533
|
+
amzDate
|
|
534
|
+
);
|
|
535
|
+
headers["Authorization"] = authorization;
|
|
536
|
+
const response = await fetch(url, {
|
|
537
|
+
method: "GET",
|
|
538
|
+
headers
|
|
539
|
+
});
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
throw new Error(`R2 download failed: ${response.status}`);
|
|
542
|
+
}
|
|
543
|
+
return Buffer.from(await response.arrayBuffer());
|
|
544
|
+
}
|
|
545
|
+
async delete(key) {
|
|
546
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
547
|
+
const date = /* @__PURE__ */ new Date();
|
|
548
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
549
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
550
|
+
const payloadHash = (0, import_crypto2.createHash)("sha256").update("").digest("hex");
|
|
551
|
+
const headers = {
|
|
552
|
+
"Host": new URL(url).host,
|
|
553
|
+
"x-amz-date": amzDate,
|
|
554
|
+
"x-amz-content-sha256": payloadHash
|
|
555
|
+
};
|
|
556
|
+
const authorization = this.generateAuthorizationHeader(
|
|
557
|
+
"DELETE",
|
|
558
|
+
key,
|
|
559
|
+
headers,
|
|
560
|
+
payloadHash,
|
|
561
|
+
dateStamp,
|
|
562
|
+
amzDate
|
|
563
|
+
);
|
|
564
|
+
headers["Authorization"] = authorization;
|
|
565
|
+
const response = await fetch(url, {
|
|
566
|
+
method: "DELETE",
|
|
567
|
+
headers
|
|
568
|
+
});
|
|
569
|
+
if (!response.ok && response.status !== 404) {
|
|
570
|
+
throw new Error(`R2 delete failed: ${response.status}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async exists(key) {
|
|
574
|
+
const url = `${this.endpoint}/${this.config.bucket}/${key}`;
|
|
575
|
+
const date = /* @__PURE__ */ new Date();
|
|
576
|
+
const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
577
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
578
|
+
const payloadHash = "UNSIGNED-PAYLOAD";
|
|
579
|
+
const headers = {
|
|
580
|
+
"Host": new URL(url).host,
|
|
581
|
+
"x-amz-date": amzDate,
|
|
582
|
+
"x-amz-content-sha256": payloadHash
|
|
583
|
+
};
|
|
584
|
+
const authorization = this.generateAuthorizationHeader(
|
|
585
|
+
"HEAD",
|
|
586
|
+
key,
|
|
587
|
+
headers,
|
|
588
|
+
payloadHash,
|
|
589
|
+
dateStamp,
|
|
590
|
+
amzDate
|
|
591
|
+
);
|
|
592
|
+
headers["Authorization"] = authorization;
|
|
593
|
+
const response = await fetch(url, {
|
|
594
|
+
method: "HEAD",
|
|
595
|
+
headers
|
|
596
|
+
});
|
|
597
|
+
return response.ok;
|
|
598
|
+
}
|
|
599
|
+
generateAuthorizationHeader(method, key, headers, payloadHash, dateStamp, amzDate) {
|
|
600
|
+
const region = "auto";
|
|
601
|
+
const service = "s3";
|
|
602
|
+
const canonicalUri = `/${this.config.bucket}/${key}`;
|
|
603
|
+
const canonicalQueryString = "";
|
|
604
|
+
const signedHeaders = Object.keys(headers).map((h) => h.toLowerCase()).sort().join(";");
|
|
605
|
+
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";
|
|
606
|
+
const canonicalRequest = [
|
|
607
|
+
method,
|
|
608
|
+
canonicalUri,
|
|
609
|
+
canonicalQueryString,
|
|
610
|
+
canonicalHeaders,
|
|
611
|
+
signedHeaders,
|
|
612
|
+
payloadHash
|
|
613
|
+
].join("\n");
|
|
614
|
+
const algorithm = "AWS4-HMAC-SHA256";
|
|
615
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
616
|
+
const stringToSign = [
|
|
617
|
+
algorithm,
|
|
618
|
+
amzDate,
|
|
619
|
+
credentialScope,
|
|
620
|
+
(0, import_crypto2.createHash)("sha256").update(canonicalRequest).digest("hex")
|
|
621
|
+
].join("\n");
|
|
622
|
+
const signingKey = this.getSigningKey(dateStamp, region, service);
|
|
623
|
+
const signature = (0, import_crypto2.createHmac)("sha256", signingKey).update(stringToSign).digest("hex");
|
|
624
|
+
return `${algorithm} Credential=${this.config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
625
|
+
}
|
|
626
|
+
getSigningKey(dateStamp, region, service) {
|
|
627
|
+
const kDate = (0, import_crypto2.createHmac)("sha256", `AWS4${this.config.secretAccessKey}`).update(dateStamp).digest();
|
|
628
|
+
const kRegion = (0, import_crypto2.createHmac)("sha256", kDate).update(region).digest();
|
|
629
|
+
const kService = (0, import_crypto2.createHmac)("sha256", kRegion).update(service).digest();
|
|
630
|
+
const kSigning = (0, import_crypto2.createHmac)("sha256", kService).update("aws4_request").digest();
|
|
631
|
+
return kSigning;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// src/backends/index.ts
|
|
636
|
+
function createStorageBackend(config) {
|
|
637
|
+
switch (config.type) {
|
|
638
|
+
case "local":
|
|
639
|
+
return new LocalStorageBackend(config);
|
|
640
|
+
case "s3":
|
|
641
|
+
return new S3StorageBackend(config);
|
|
642
|
+
case "webdav":
|
|
643
|
+
return new WebDAVStorageBackend(config);
|
|
644
|
+
case "r2":
|
|
645
|
+
return new R2StorageBackend(config);
|
|
646
|
+
default:
|
|
647
|
+
throw new Error(`Unknown storage backend type: ${config.type}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
__name(createStorageBackend, "createStorageBackend");
|
|
651
|
+
|
|
652
|
+
// src/service/storage.ts
|
|
653
|
+
var import_promises2 = __toESM(require("fs/promises"), 1);
|
|
127
654
|
var ChatLunaStorageService = class extends import_koishi.Service {
|
|
128
655
|
constructor(ctx, config) {
|
|
129
656
|
super(ctx, "chatluna_storage", true);
|
|
@@ -134,6 +661,7 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
134
661
|
this.lruTail.prev = this.lruHead;
|
|
135
662
|
this.lruMap = /* @__PURE__ */ new Map();
|
|
136
663
|
this.backendPath = this.config.backendPath;
|
|
664
|
+
this.storageBackend = createStorageBackend(this.getStorageConfig());
|
|
137
665
|
ctx.database.extend(
|
|
138
666
|
"chatluna_storage_temp",
|
|
139
667
|
{
|
|
@@ -147,7 +675,16 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
147
675
|
expireTime: "timestamp",
|
|
148
676
|
size: "integer",
|
|
149
677
|
accessTime: "timestamp",
|
|
150
|
-
accessCount: "integer"
|
|
678
|
+
accessCount: "integer",
|
|
679
|
+
// New optional fields for multi-backend support
|
|
680
|
+
storageType: {
|
|
681
|
+
type: "string",
|
|
682
|
+
nullable: true
|
|
683
|
+
},
|
|
684
|
+
publicUrl: {
|
|
685
|
+
type: "string",
|
|
686
|
+
nullable: true
|
|
687
|
+
}
|
|
151
688
|
},
|
|
152
689
|
{
|
|
153
690
|
autoInc: false,
|
|
@@ -156,6 +693,7 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
156
693
|
);
|
|
157
694
|
this.setupAutoDelete();
|
|
158
695
|
this.initializeLRU();
|
|
696
|
+
this.initStorageBackend();
|
|
159
697
|
ctx.inject(["server"], (ctx2) => {
|
|
160
698
|
const backendPath = `${config.serverPath ?? ctx2.server.selfUrl}${this.config.backendPath}`;
|
|
161
699
|
this.backendPath = backendPath;
|
|
@@ -168,6 +706,55 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
168
706
|
lruTail;
|
|
169
707
|
lruMap;
|
|
170
708
|
backendPath;
|
|
709
|
+
storageBackend;
|
|
710
|
+
getStorageConfig() {
|
|
711
|
+
const backendType = this.config.storageBackend ?? "local";
|
|
712
|
+
switch (backendType) {
|
|
713
|
+
case "s3":
|
|
714
|
+
return {
|
|
715
|
+
type: "s3",
|
|
716
|
+
endpoint: this.config.s3Endpoint,
|
|
717
|
+
bucket: this.config.s3Bucket,
|
|
718
|
+
region: this.config.s3Region,
|
|
719
|
+
accessKeyId: this.config.s3AccessKeyId,
|
|
720
|
+
secretAccessKey: this.config.s3SecretAccessKey,
|
|
721
|
+
publicUrl: this.config.s3PublicUrl,
|
|
722
|
+
pathStyle: this.config.s3PathStyle
|
|
723
|
+
};
|
|
724
|
+
case "webdav":
|
|
725
|
+
return {
|
|
726
|
+
type: "webdav",
|
|
727
|
+
endpoint: this.config.webdavEndpoint,
|
|
728
|
+
username: this.config.webdavUsername,
|
|
729
|
+
password: this.config.webdavPassword,
|
|
730
|
+
basePath: this.config.webdavBasePath,
|
|
731
|
+
publicUrl: this.config.webdavPublicUrl
|
|
732
|
+
};
|
|
733
|
+
case "r2":
|
|
734
|
+
return {
|
|
735
|
+
type: "r2",
|
|
736
|
+
accountId: this.config.r2AccountId,
|
|
737
|
+
bucket: this.config.r2Bucket,
|
|
738
|
+
accessKeyId: this.config.r2AccessKeyId,
|
|
739
|
+
secretAccessKey: this.config.r2SecretAccessKey,
|
|
740
|
+
publicUrl: this.config.r2PublicUrl
|
|
741
|
+
};
|
|
742
|
+
case "local":
|
|
743
|
+
default:
|
|
744
|
+
return {
|
|
745
|
+
type: "local",
|
|
746
|
+
storagePath: this.config.storagePath
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async initStorageBackend() {
|
|
751
|
+
try {
|
|
752
|
+
await this.storageBackend.init();
|
|
753
|
+
logger.info(`Storage backend initialized: ${this.storageBackend.type}`);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
logger.error("Failed to initialize storage backend:", error);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
171
758
|
async initializeLRU() {
|
|
172
759
|
const files = await this.ctx.database.get("chatluna_storage_temp", {});
|
|
173
760
|
files.sort((a, b) => b.accessTime.getTime() - a.accessTime.getTime());
|
|
@@ -211,7 +798,7 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
211
798
|
for (const file of sortedFiles) {
|
|
212
799
|
if (currentSize <= maxSizeBytes * 0.8) break;
|
|
213
800
|
try {
|
|
214
|
-
await
|
|
801
|
+
await this.deleteFileFromBackend(file);
|
|
215
802
|
await this.ctx.database.remove("chatluna_storage_temp", {
|
|
216
803
|
id: file.id
|
|
217
804
|
});
|
|
@@ -234,7 +821,7 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
234
821
|
for (let i = 0; i < filesToDelete; i++) {
|
|
235
822
|
const file = sortedFiles[i];
|
|
236
823
|
try {
|
|
237
|
-
await
|
|
824
|
+
await this.deleteFileFromBackend(file);
|
|
238
825
|
await this.ctx.database.remove("chatluna_storage_temp", {
|
|
239
826
|
id: file.id
|
|
240
827
|
});
|
|
@@ -247,8 +834,24 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
247
834
|
}
|
|
248
835
|
}
|
|
249
836
|
}
|
|
837
|
+
async deleteFileFromBackend(file) {
|
|
838
|
+
const storageType = file.storageType ?? "local";
|
|
839
|
+
if (storageType === "local") {
|
|
840
|
+
await import_promises2.default.unlink(file.path);
|
|
841
|
+
} else {
|
|
842
|
+
if (this.storageBackend.type === storageType) {
|
|
843
|
+
await this.storageBackend.delete(file.path);
|
|
844
|
+
} else {
|
|
845
|
+
try {
|
|
846
|
+
await this.storageBackend.delete(file.path);
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
250
852
|
setupAutoDelete() {
|
|
251
853
|
const ctx = this.ctx;
|
|
854
|
+
const self = this;
|
|
252
855
|
async function execute() {
|
|
253
856
|
if (!ctx.scope.isActive) {
|
|
254
857
|
return;
|
|
@@ -267,7 +870,7 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
267
870
|
const success = [];
|
|
268
871
|
for (const file of expiredFiles) {
|
|
269
872
|
try {
|
|
270
|
-
await
|
|
873
|
+
await self.deleteFileFromBackend(file);
|
|
271
874
|
await ctx.database.remove("chatluna_storage_temp", {
|
|
272
875
|
id: file.id
|
|
273
876
|
});
|
|
@@ -305,29 +908,28 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
305
908
|
if (fileType != null) {
|
|
306
909
|
randomName = (randomName.split(".")?.[0] ?? randomName) + "." + fileType;
|
|
307
910
|
}
|
|
308
|
-
const
|
|
309
|
-
await import_promises.default.mkdir((0, import_path.join)(this.config.storagePath, "temp"), {
|
|
310
|
-
recursive: true
|
|
311
|
-
});
|
|
312
|
-
await import_promises.default.writeFile(filePath, processedBuffer);
|
|
911
|
+
const result = await this.storageBackend.upload(processedBuffer, randomName);
|
|
313
912
|
const expireTime = new Date(Date.now() + (expireHours || this.config.tempCacheTime) * 60 * 60 * 1e3);
|
|
314
913
|
const currentTime = /* @__PURE__ */ new Date();
|
|
315
914
|
const fileInfo = {
|
|
316
915
|
id: randomName.split(".")[0],
|
|
317
|
-
path:
|
|
916
|
+
path: result.key,
|
|
318
917
|
name: randomName,
|
|
319
918
|
type: fileType,
|
|
320
919
|
expireTime,
|
|
321
920
|
size: processedBuffer.length,
|
|
322
921
|
accessTime: currentTime,
|
|
323
|
-
accessCount: 1
|
|
922
|
+
accessCount: 1,
|
|
923
|
+
storageType: this.storageBackend.type,
|
|
924
|
+
publicUrl: result.publicUrl
|
|
324
925
|
};
|
|
325
926
|
await this.ctx.database.create("chatluna_storage_temp", fileInfo);
|
|
326
927
|
this.addToLRU(fileInfo.id);
|
|
928
|
+
const url = result.publicUrl ?? `${this.backendPath}/temp/${randomName}`;
|
|
327
929
|
return {
|
|
328
930
|
...fileInfo,
|
|
329
931
|
data: Promise.resolve(processedBuffer),
|
|
330
|
-
url
|
|
932
|
+
url
|
|
331
933
|
};
|
|
332
934
|
}
|
|
333
935
|
async getTempFile(id) {
|
|
@@ -352,12 +954,22 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
352
954
|
);
|
|
353
955
|
this.addToLRU(id);
|
|
354
956
|
try {
|
|
957
|
+
const storageType = file.storageType ?? "local";
|
|
958
|
+
let dataPromise;
|
|
959
|
+
if (storageType === "local") {
|
|
960
|
+
dataPromise = import_promises2.default.readFile(file.path);
|
|
961
|
+
} else if (this.storageBackend.type === storageType) {
|
|
962
|
+
dataPromise = this.storageBackend.download(file.path);
|
|
963
|
+
} else {
|
|
964
|
+
dataPromise = import_promises2.default.readFile(file.path);
|
|
965
|
+
}
|
|
966
|
+
const url = file.publicUrl ?? `${this.backendPath}/temp/${file.name}`;
|
|
355
967
|
return {
|
|
356
968
|
...file,
|
|
357
969
|
accessTime: currentTime,
|
|
358
970
|
accessCount: file.accessCount + 1,
|
|
359
|
-
data:
|
|
360
|
-
url
|
|
971
|
+
data: dataPromise,
|
|
972
|
+
url
|
|
361
973
|
};
|
|
362
974
|
} catch (error) {
|
|
363
975
|
await this.ctx.database.remove("chatluna_storage_temp", { id });
|
|
@@ -369,7 +981,15 @@ var ChatLunaStorageService = class extends import_koishi.Service {
|
|
|
369
981
|
|
|
370
982
|
// src/index.ts
|
|
371
983
|
var logger;
|
|
372
|
-
var usage = `使用此插件需要确保你 Koishi 运行在公网,或你的聊天适配器(如 onebot,telegram)和 koishi
|
|
984
|
+
var usage = `使用此插件需要确保你 Koishi 运行在公网,或你的聊天适配器(如 onebot,telegram)和 koishi 运行在同一个局域网中。
|
|
985
|
+
|
|
986
|
+
不同的存储后端的作用:
|
|
987
|
+
- **本地文件**:默认选项,文件存储在本地磁盘,通过 Koishi 的 HTTP 服务器提供访问
|
|
988
|
+
- **S3 兼容存储**:支持 AWS S3、MinIO 等 S3 兼容服务,可配置公网 URL 直接访问
|
|
989
|
+
- **WebDAV**:支持 WebDAV 协议的存储服务
|
|
990
|
+
- **Cloudflare R2**:Cloudflare 的对象存储服务,S3 兼容
|
|
991
|
+
|
|
992
|
+
如果存储后端支持公网访问(配置了公网 URL),系统会直接返回公网 URL,不经过 Koishi 服务器中转。`;
|
|
373
993
|
function apply2(ctx, config) {
|
|
374
994
|
ctx.on("ready", async () => {
|
|
375
995
|
ctx.plugin(ChatLunaStorageService, config);
|
|
@@ -383,15 +1003,61 @@ var inject = {
|
|
|
383
1003
|
};
|
|
384
1004
|
var Config3 = import_koishi2.Schema.intersect([
|
|
385
1005
|
import_koishi2.Schema.object({
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1006
|
+
storageBackend: import_koishi2.Schema.union([
|
|
1007
|
+
import_koishi2.Schema.const("null").description("??").hidden(),
|
|
1008
|
+
import_koishi2.Schema.const("local").description("本地文件存储"),
|
|
1009
|
+
import_koishi2.Schema.const("s3").description("S3 兼容存储 (AWS S3, MinIO 等)"),
|
|
1010
|
+
import_koishi2.Schema.const("webdav").description("WebDAV 存储"),
|
|
1011
|
+
import_koishi2.Schema.const("r2").description("Cloudflare R2 存储")
|
|
1012
|
+
]).required().description("存储后端类型"),
|
|
389
1013
|
serverPath: import_koishi2.Schema.string().description("Koishi 在公网或者局域网中的路径").default("http://127.0.0.1:5140"),
|
|
390
|
-
backendPath: import_koishi2.Schema.string().description("后端服务器路径").default("/chatluna-storage"),
|
|
391
1014
|
tempCacheTime: import_koishi2.Schema.number().description("过期数据的缓存时间(小时)").default(24 * 30),
|
|
392
1015
|
maxStorageSize: import_koishi2.Schema.number().description("最大存储空间(MB)").default(500).min(1),
|
|
393
1016
|
maxStorageCount: import_koishi2.Schema.number().description("最大存储文件数").default(300).min(1)
|
|
394
|
-
}).description("基础配置")
|
|
1017
|
+
}).description("基础配置"),
|
|
1018
|
+
import_koishi2.Schema.union([
|
|
1019
|
+
import_koishi2.Schema.object({
|
|
1020
|
+
storageBackend: import_koishi2.Schema.const("s3").required(),
|
|
1021
|
+
s3Endpoint: import_koishi2.Schema.string().description(
|
|
1022
|
+
"S3 端点 URL(如 https://s3.amazonaws.com)"
|
|
1023
|
+
),
|
|
1024
|
+
s3Bucket: import_koishi2.Schema.string().description("S3 存储桶名称"),
|
|
1025
|
+
s3Region: import_koishi2.Schema.string().description("S3 区域(如 us-east-1)").default("us-east-1"),
|
|
1026
|
+
s3AccessKeyId: import_koishi2.Schema.string().description("S3 Access Key ID"),
|
|
1027
|
+
s3SecretAccessKey: import_koishi2.Schema.string().description("S3 Secret Access Key").role("secret"),
|
|
1028
|
+
s3PublicUrl: import_koishi2.Schema.string().description(
|
|
1029
|
+
"S3 公网访问 URL(可选,如果配置则直接返回公网 URL)"
|
|
1030
|
+
),
|
|
1031
|
+
s3PathStyle: import_koishi2.Schema.boolean().description("使用路径风格 URL(用于 MinIO 等)").default(false)
|
|
1032
|
+
}).description("S3 配置"),
|
|
1033
|
+
import_koishi2.Schema.object({
|
|
1034
|
+
storageBackend: import_koishi2.Schema.const("webdav").required(),
|
|
1035
|
+
webdavEndpoint: import_koishi2.Schema.string().description("WebDAV 服务器地址"),
|
|
1036
|
+
webdavUsername: import_koishi2.Schema.string().description("WebDAV 用户名"),
|
|
1037
|
+
webdavPassword: import_koishi2.Schema.string().description("WebDAV 密码").role("secret"),
|
|
1038
|
+
webdavBasePath: import_koishi2.Schema.string().description("WebDAV 基础路径").default("chatluna-storage"),
|
|
1039
|
+
webdavPublicUrl: import_koishi2.Schema.string().description(
|
|
1040
|
+
"WebDAV 公网访问 URL(可选)"
|
|
1041
|
+
)
|
|
1042
|
+
}).description("WebDAV 配置"),
|
|
1043
|
+
import_koishi2.Schema.object({
|
|
1044
|
+
storageBackend: import_koishi2.Schema.const("r2").required(),
|
|
1045
|
+
r2AccountId: import_koishi2.Schema.string().description("Cloudflare 账户 ID"),
|
|
1046
|
+
r2Bucket: import_koishi2.Schema.string().description("R2 存储桶名称"),
|
|
1047
|
+
r2AccessKeyId: import_koishi2.Schema.string().description("R2 Access Key ID"),
|
|
1048
|
+
r2SecretAccessKey: import_koishi2.Schema.string().description("R2 Secret Access Key").role("secret"),
|
|
1049
|
+
r2PublicUrl: import_koishi2.Schema.string().description(
|
|
1050
|
+
"R2 公网访问 URL(需在 Cloudflare 配置公共访问)"
|
|
1051
|
+
)
|
|
1052
|
+
}).description("Cloudflare R2 配置"),
|
|
1053
|
+
import_koishi2.Schema.object({
|
|
1054
|
+
storageBackend: import_koishi2.Schema.const("local").required(),
|
|
1055
|
+
storagePath: import_koishi2.Schema.path({
|
|
1056
|
+
filters: ["directory"]
|
|
1057
|
+
}).description("本地缓存存储路径(仅本地存储使用)").default("./data/chatluna-storage"),
|
|
1058
|
+
backendPath: import_koishi2.Schema.string().description("后端文件服务器监听的路径").default("/chatluna-storage")
|
|
1059
|
+
}).description("本地存储配置")
|
|
1060
|
+
])
|
|
395
1061
|
]);
|
|
396
1062
|
var name = "chatluna-storage-service";
|
|
397
1063
|
// Annotate the CommonJS export names for ESM import in node:
|