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