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/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/service/storage.ts
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 import_promises.default.unlink(file.path);
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 import_promises.default.unlink(file.path);
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 import_promises.default.unlink(file.path);
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 filePath = (0, import_path.join)(this.config.storagePath, "temp", randomName);
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: filePath,
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: `${this.backendPath}/temp/${randomName}`
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: import_promises.default.readFile(file.path),
360
- url: `${this.backendPath}/temp/${file.name}`
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
- storagePath: import_koishi2.Schema.path({
387
- filters: ["directory"]
388
- }).description("缓存存储路径").default("./data/chatluna-storage"),
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: