@homurajs/storage 0.0.0-stage → 26.10.13
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/README.md +25 -2
- package/dist/index.d.ts +42 -0
- package/dist/index.js +11 -0
- package/dist/local.d.ts +8 -0
- package/dist/local.js +105 -0
- package/dist/proxy.d.ts +7 -0
- package/dist/proxy.js +25 -0
- package/dist/s3.d.ts +15 -0
- package/dist/s3.js +115 -0
- package/dist/supabase.d.ts +11 -0
- package/dist/supabase.js +109 -0
- package/package.json +65 -3
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @homurajs/storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
对象存储/图床:三后端(local-fs / s3 兼容 / supabase)+ `/files/<key>` 代理路由。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createLocalStorage } from '@homurajs/storage/local' // 独立形态
|
|
7
|
+
// import { createS3Storage } from '@homurajs/storage/s3' // minio/oss(AWS SDK 动态加载)
|
|
8
|
+
// import { createSupabaseStorage } from '@homurajs/storage/supabase' // qw-pages(手写 REST)
|
|
9
|
+
|
|
10
|
+
const storage = createLocalStorage({ rootDir: './data/files' })
|
|
11
|
+
|
|
12
|
+
// 上传:ns 前缀自动 key;返回代理 URL
|
|
13
|
+
const r = await storage.uploadFile('imgs', 'cat.png', bytes, 'image/png')
|
|
14
|
+
// r.objectKey = 'imgs/files/1760..._cat.png',r.url = '/files/imgs/files/...'
|
|
15
|
+
|
|
16
|
+
// 下载 / 删除 / 列举(列举强制 ns 前缀,无法越权)
|
|
17
|
+
const d = await storage.downloadFile(r.objectKey)
|
|
18
|
+
await storage.deleteFile(r.objectKey)
|
|
19
|
+
const files = await storage.listFiles('imgs', '', 20)
|
|
20
|
+
|
|
21
|
+
// 代理路由(Hono):统一下载入口,presign 弱后端自动降级到它
|
|
22
|
+
import { createFileProxyRoutes } from '@homurajs/storage/proxy'
|
|
23
|
+
app.route('/', createFileProxyRoutes(storage))
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
使用示范见模板 `src/server/routes/files.ts`。
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 文件存储契约(自 Suzuran storage 适配器平移):
|
|
4
|
+
* - 租户/命名空间隔离编码在 key 前缀
|
|
5
|
+
* - URL 统一走代理路径(/files/<key>),不直接暴露存储地址
|
|
6
|
+
* - presign 优雅降级:弱后端返回代理路径
|
|
7
|
+
*/
|
|
8
|
+
export interface UploadResult {
|
|
9
|
+
objectKey: string;
|
|
10
|
+
/** 平台相对代理路径(/files/<objectKey>) */
|
|
11
|
+
url: string;
|
|
12
|
+
size: number;
|
|
13
|
+
uploadedAt: Date;
|
|
14
|
+
}
|
|
15
|
+
export interface FileInfo {
|
|
16
|
+
objectKey: string;
|
|
17
|
+
size: number;
|
|
18
|
+
updatedAt: Date;
|
|
19
|
+
contentType?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface FileStorage {
|
|
22
|
+
/** 业务上传:自动生成 `<ns>/files/<unixNano>_<fileName>` 前缀 key */
|
|
23
|
+
uploadFile(ns: string, fileName: string, data: FilePayload, contentType: string): Promise<UploadResult>;
|
|
24
|
+
/** 显式 key 上传(如 code.zip 等精确控制场景) */
|
|
25
|
+
uploadFileRaw(objectKey: string, data: FilePayload, contentType: string): Promise<UploadResult>;
|
|
26
|
+
downloadFile(objectKey: string): Promise<{
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
contentType?: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** 弱后端降级为代理路径 */
|
|
31
|
+
getPresignedUrl(objectKey: string, expirySeconds: number): Promise<string>;
|
|
32
|
+
deleteFile(objectKey: string): Promise<void>;
|
|
33
|
+
/** 强制 ns 前缀,无法越权列举 */
|
|
34
|
+
listFiles(ns: string, prefix: string, limit: number): Promise<FileInfo[]>;
|
|
35
|
+
}
|
|
36
|
+
/** 上传载荷(Node 侧 Uint8Array;浏览器 File/Blob 由调用方先转) */
|
|
37
|
+
export type FilePayload = Uint8Array | ReadableStream<Uint8Array>;
|
|
38
|
+
/** object key 校验:禁止路径穿越 */
|
|
39
|
+
export declare function assertSafeObjectKey(key: string): void;
|
|
40
|
+
/** 统一代理路径 */
|
|
41
|
+
export declare function proxyUrl(objectKey: string): string;
|
|
42
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/** object key 校验:禁止路径穿越 */
|
|
3
|
+
function assertSafeObjectKey(key) {
|
|
4
|
+
if (key.includes("..") || key.startsWith("/") || key === "") throw new Error(`unsafe object key: ${key}`);
|
|
5
|
+
}
|
|
6
|
+
/** 统一代理路径 */
|
|
7
|
+
function proxyUrl(objectKey) {
|
|
8
|
+
return `/files/${objectKey}`;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { assertSafeObjectKey, proxyUrl };
|
package/dist/local.d.ts
ADDED
package/dist/local.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { assertSafeObjectKey, proxyUrl } from "./index.js";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
//#region src/local.ts
|
|
6
|
+
/**
|
|
7
|
+
* local-fs 后端:独立单机形态的缺省实现。
|
|
8
|
+
* - meta sidecar(<key>.meta.json)记录 contentType/size/uploadedAt
|
|
9
|
+
* - presign 降级为代理路径(接口语义:给我一个可下载 URL)
|
|
10
|
+
*/
|
|
11
|
+
const metaPath = (root, key) => `${resolve(root, key)}.meta.json`;
|
|
12
|
+
async function* walk(dir, base = dir) {
|
|
13
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
14
|
+
const full = join(dir, entry.name);
|
|
15
|
+
if (entry.isDirectory()) yield* walk(full, base);
|
|
16
|
+
else if (!entry.name.endsWith(".meta.json")) yield full.slice(base.length + 1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function createLocalStorage(options) {
|
|
20
|
+
const root = resolve(options.rootDir);
|
|
21
|
+
const put = async (key, data, contentType) => {
|
|
22
|
+
assertSafeObjectKey(key);
|
|
23
|
+
const abs = join(root, key);
|
|
24
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
25
|
+
await writeFile(abs, data);
|
|
26
|
+
const uploadedAt = /* @__PURE__ */ new Date();
|
|
27
|
+
const meta = {
|
|
28
|
+
contentType,
|
|
29
|
+
size: data.byteLength,
|
|
30
|
+
uploadedAt: uploadedAt.toISOString()
|
|
31
|
+
};
|
|
32
|
+
await writeFile(metaPath(root, key), JSON.stringify(meta));
|
|
33
|
+
return {
|
|
34
|
+
objectKey: key,
|
|
35
|
+
url: proxyUrl(key),
|
|
36
|
+
size: data.byteLength,
|
|
37
|
+
uploadedAt
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
async uploadFile(ns, fileName, data, contentType) {
|
|
42
|
+
assertSafeObjectKey(ns);
|
|
43
|
+
const safeName = fileName.replace(/[/\\]/g, "_");
|
|
44
|
+
const key = `${ns}/files/${Date.now()}_${safeName}`;
|
|
45
|
+
return put(key, toBytes(data), contentType);
|
|
46
|
+
},
|
|
47
|
+
async uploadFileRaw(objectKey, data, contentType) {
|
|
48
|
+
return put(objectKey, toBytes(data), contentType);
|
|
49
|
+
},
|
|
50
|
+
async downloadFile(objectKey) {
|
|
51
|
+
const abs = join(root, objectKey);
|
|
52
|
+
if (!existsSync(abs)) throw new Error(`object not found: ${objectKey}`);
|
|
53
|
+
const data = await readFile(abs);
|
|
54
|
+
let contentType;
|
|
55
|
+
try {
|
|
56
|
+
contentType = JSON.parse(await readFile(metaPath(root, objectKey), "utf-8")).contentType;
|
|
57
|
+
} catch {
|
|
58
|
+
contentType = void 0;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
data,
|
|
62
|
+
contentType
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
async getPresignedUrl(objectKey) {
|
|
66
|
+
return proxyUrl(objectKey);
|
|
67
|
+
},
|
|
68
|
+
async deleteFile(objectKey) {
|
|
69
|
+
await rm(join(root, objectKey), { force: true });
|
|
70
|
+
await rm(metaPath(root, objectKey), { force: true });
|
|
71
|
+
},
|
|
72
|
+
async listFiles(ns, prefix, limit) {
|
|
73
|
+
assertSafeObjectKey(ns);
|
|
74
|
+
const dir = join(root, ns);
|
|
75
|
+
if (!existsSync(dir)) return [];
|
|
76
|
+
const out = [];
|
|
77
|
+
for await (const rel of walk(dir)) {
|
|
78
|
+
if (prefix && !rel.startsWith(prefix)) continue;
|
|
79
|
+
const abs = join(dir, rel);
|
|
80
|
+
const s = await stat(abs);
|
|
81
|
+
let contentType;
|
|
82
|
+
const key = `${ns}/${rel}`;
|
|
83
|
+
try {
|
|
84
|
+
contentType = JSON.parse(await readFile(metaPath(root, key), "utf-8")).contentType;
|
|
85
|
+
} catch {
|
|
86
|
+
contentType = void 0;
|
|
87
|
+
}
|
|
88
|
+
out.push({
|
|
89
|
+
objectKey: key,
|
|
90
|
+
size: s.size,
|
|
91
|
+
updatedAt: s.mtime,
|
|
92
|
+
contentType
|
|
93
|
+
});
|
|
94
|
+
if (out.length >= limit) break;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function toBytes(data) {
|
|
101
|
+
if (data instanceof Uint8Array) return data;
|
|
102
|
+
throw new Error("local 后端不支持流式载荷:请先聚合为 Uint8Array");
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { createLocalStorage, createReadStream };
|
package/dist/proxy.d.ts
ADDED
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
//#region src/proxy.ts
|
|
3
|
+
/**
|
|
4
|
+
* Hono 代理路由:/files/<key> 统一下载入口(对冲各后端 URL 策略差异)。
|
|
5
|
+
* 所有后端的 UploadResult.url 都指向本路由——不直接暴露存储地址。
|
|
6
|
+
*/
|
|
7
|
+
function createFileProxyRoutes(storage, options) {
|
|
8
|
+
const mount = options?.mount ?? "/files";
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
app.get(`${mount}/:key{.+}`, async (c) => {
|
|
11
|
+
const key = c.req.param("key");
|
|
12
|
+
try {
|
|
13
|
+
const { data, contentType } = await storage.downloadFile(key);
|
|
14
|
+
return new Response(data, { headers: {
|
|
15
|
+
"content-type": contentType ?? "application/octet-stream",
|
|
16
|
+
"cache-control": "public, max-age=31536000, immutable"
|
|
17
|
+
} });
|
|
18
|
+
} catch {
|
|
19
|
+
return c.text("not found", 404);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return app;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { createFileProxyRoutes };
|
package/dist/s3.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FileStorage } from "./index.js";
|
|
2
|
+
//#region src/s3.d.ts
|
|
3
|
+
export interface S3StorageOptions {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
region: string;
|
|
6
|
+
bucket: string;
|
|
7
|
+
accessKeyId: string;
|
|
8
|
+
secretAccessKey: string;
|
|
9
|
+
/** 自举:bucket 不存在时创建(默认 true) */
|
|
10
|
+
autoCreateBucket?: boolean;
|
|
11
|
+
/** 预签名走直连 URL(默认 false:走代理路径降级,避免暴露 endpoint) */
|
|
12
|
+
presignDirect?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function createS3Storage(options: S3StorageOptions): FileStorage;
|
|
15
|
+
//#endregion
|
package/dist/s3.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { assertSafeObjectKey, proxyUrl } from "./index.js";
|
|
2
|
+
//#region src/s3.ts
|
|
3
|
+
function createS3Storage(options) {
|
|
4
|
+
let ready = null;
|
|
5
|
+
async function init() {
|
|
6
|
+
const [clientMod, presignerMod] = await Promise.all([import("@aws-sdk/client-s3"), import("@aws-sdk/s3-request-presigner")]);
|
|
7
|
+
const deps = {
|
|
8
|
+
S3Client: clientMod.S3Client,
|
|
9
|
+
PutObjectCommand: clientMod.PutObjectCommand,
|
|
10
|
+
GetObjectCommand: clientMod.GetObjectCommand,
|
|
11
|
+
DeleteObjectCommand: clientMod.DeleteObjectCommand,
|
|
12
|
+
ListObjectsV2Command: clientMod.ListObjectsV2Command,
|
|
13
|
+
HeadBucketCommand: clientMod.HeadBucketCommand,
|
|
14
|
+
CreateBucketCommand: clientMod.CreateBucketCommand,
|
|
15
|
+
getSignedUrl: presignerMod.getSignedUrl
|
|
16
|
+
};
|
|
17
|
+
const client = new deps.S3Client({
|
|
18
|
+
endpoint: options.endpoint,
|
|
19
|
+
region: options.region,
|
|
20
|
+
forcePathStyle: true,
|
|
21
|
+
credentials: {
|
|
22
|
+
accessKeyId: options.accessKeyId,
|
|
23
|
+
secretAccessKey: options.secretAccessKey
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (options.autoCreateBucket !== false) try {
|
|
27
|
+
await client.send(new deps.HeadBucketCommand({ Bucket: options.bucket }));
|
|
28
|
+
} catch {
|
|
29
|
+
await client.send(new deps.CreateBucketCommand({ Bucket: options.bucket }));
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
client,
|
|
33
|
+
deps
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function ensure() {
|
|
37
|
+
ready ??= init().catch((e) => {
|
|
38
|
+
ready = null;
|
|
39
|
+
throw e;
|
|
40
|
+
});
|
|
41
|
+
return ready;
|
|
42
|
+
}
|
|
43
|
+
const put = async (key, data, contentType) => {
|
|
44
|
+
assertSafeObjectKey(key);
|
|
45
|
+
const { client, deps } = await ensure();
|
|
46
|
+
await client.send(new deps.PutObjectCommand({
|
|
47
|
+
Bucket: options.bucket,
|
|
48
|
+
Key: key,
|
|
49
|
+
Body: data,
|
|
50
|
+
ContentType: contentType
|
|
51
|
+
}));
|
|
52
|
+
return {
|
|
53
|
+
objectKey: key,
|
|
54
|
+
url: proxyUrl(key),
|
|
55
|
+
size: data.byteLength,
|
|
56
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
async uploadFile(ns, fileName, data, contentType) {
|
|
61
|
+
assertSafeObjectKey(ns);
|
|
62
|
+
const safeName = fileName.replace(/[/\\]/g, "_");
|
|
63
|
+
return put(`${ns}/files/${Date.now()}_${safeName}`, toBytes(data), contentType);
|
|
64
|
+
},
|
|
65
|
+
async uploadFileRaw(objectKey, data, contentType) {
|
|
66
|
+
return put(objectKey, toBytes(data), contentType);
|
|
67
|
+
},
|
|
68
|
+
async downloadFile(objectKey) {
|
|
69
|
+
const { client, deps } = await ensure();
|
|
70
|
+
const res = await client.send(new deps.GetObjectCommand({
|
|
71
|
+
Bucket: options.bucket,
|
|
72
|
+
Key: objectKey
|
|
73
|
+
}));
|
|
74
|
+
if (!res.Body) throw new Error(`s3 object missing body: ${objectKey}`);
|
|
75
|
+
return {
|
|
76
|
+
data: await res.Body.transformToByteArray(),
|
|
77
|
+
contentType: res.ContentType
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
async getPresignedUrl(objectKey, expirySeconds) {
|
|
81
|
+
if (!options.presignDirect) return proxyUrl(objectKey);
|
|
82
|
+
const { client, deps } = await ensure();
|
|
83
|
+
return deps.getSignedUrl(client, new deps.GetObjectCommand({
|
|
84
|
+
Bucket: options.bucket,
|
|
85
|
+
Key: objectKey
|
|
86
|
+
}), { expiresIn: expirySeconds });
|
|
87
|
+
},
|
|
88
|
+
async deleteFile(objectKey) {
|
|
89
|
+
const { client, deps } = await ensure();
|
|
90
|
+
await client.send(new deps.DeleteObjectCommand({
|
|
91
|
+
Bucket: options.bucket,
|
|
92
|
+
Key: objectKey
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
95
|
+
async listFiles(ns, prefix, limit) {
|
|
96
|
+
assertSafeObjectKey(ns);
|
|
97
|
+
const { client, deps } = await ensure();
|
|
98
|
+
return ((await client.send(new deps.ListObjectsV2Command({
|
|
99
|
+
Bucket: options.bucket,
|
|
100
|
+
Prefix: `${ns}/${prefix}`,
|
|
101
|
+
MaxKeys: limit
|
|
102
|
+
}))).Contents ?? []).map((obj) => ({
|
|
103
|
+
objectKey: obj.Key ?? "",
|
|
104
|
+
size: obj.Size ?? 0,
|
|
105
|
+
updatedAt: obj.LastModified ?? /* @__PURE__ */ new Date()
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function toBytes(data) {
|
|
111
|
+
if (data instanceof Uint8Array) return data;
|
|
112
|
+
throw new Error("s3 后端不支持流式载荷:请先聚合为 Uint8Array");
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { createS3Storage };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FileStorage } from "./index.js";
|
|
2
|
+
//#region src/supabase.d.ts
|
|
3
|
+
export interface SupabaseStorageOptions {
|
|
4
|
+
platformUrl: string;
|
|
5
|
+
/** public bucket 用 anonKey;private bucket 用 service role key */
|
|
6
|
+
apiKey: string;
|
|
7
|
+
bucket: string;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare function createSupabaseStorage(options: SupabaseStorageOptions): FileStorage;
|
|
11
|
+
//#endregion
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { assertSafeObjectKey, proxyUrl } from "./index.js";
|
|
2
|
+
//#region src/supabase.ts
|
|
3
|
+
/**
|
|
4
|
+
* supabase storage 后端:qw-pages 云形态。
|
|
5
|
+
* 手写 REST(零 SDK 依赖,fetchImpl 可注入测试):
|
|
6
|
+
* - upload: POST /storage/v1/object/{bucket}/{key}
|
|
7
|
+
* - download: GET /storage/v1/object/{bucket}/{key}
|
|
8
|
+
* - presign: POST /storage/v1/object/sign/{bucket}/{key}
|
|
9
|
+
* - delete: DELETE /storage/v1/object/{bucket}/{key}
|
|
10
|
+
* - list: POST /storage/v1/object/list/{bucket}
|
|
11
|
+
*/
|
|
12
|
+
function createSupabaseStorage(options) {
|
|
13
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
14
|
+
const base = options.platformUrl.replace(/\/$/, "");
|
|
15
|
+
const bucket = options.bucket;
|
|
16
|
+
const headers = () => ({
|
|
17
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
18
|
+
apikey: options.apiKey
|
|
19
|
+
});
|
|
20
|
+
const put = async (key, data, contentType) => {
|
|
21
|
+
assertSafeObjectKey(key);
|
|
22
|
+
const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${key}`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
...headers(),
|
|
26
|
+
"content-type": contentType,
|
|
27
|
+
"x-upsert": "true"
|
|
28
|
+
},
|
|
29
|
+
body: data
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) throw new Error(`supabase upload failed: ${res.status} ${await res.text()}`);
|
|
32
|
+
const uploadedAt = /* @__PURE__ */ new Date();
|
|
33
|
+
return {
|
|
34
|
+
objectKey: key,
|
|
35
|
+
url: proxyUrl(key),
|
|
36
|
+
size: data.byteLength,
|
|
37
|
+
uploadedAt
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
async uploadFile(ns, fileName, data, contentType) {
|
|
42
|
+
assertSafeObjectKey(ns);
|
|
43
|
+
const safeName = fileName.replace(/[/\\]/g, "_");
|
|
44
|
+
return put(`${ns}/files/${Date.now()}_${safeName}`, toBytes(data), contentType);
|
|
45
|
+
},
|
|
46
|
+
async uploadFileRaw(objectKey, data, contentType) {
|
|
47
|
+
return put(objectKey, toBytes(data), contentType);
|
|
48
|
+
},
|
|
49
|
+
async downloadFile(objectKey) {
|
|
50
|
+
const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${objectKey}`, { headers: headers() });
|
|
51
|
+
if (!res.ok) throw new Error(`supabase download failed: ${res.status}`);
|
|
52
|
+
return {
|
|
53
|
+
data: new Uint8Array(await res.arrayBuffer()),
|
|
54
|
+
contentType: res.headers.get("content-type") ?? void 0
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
async getPresignedUrl(objectKey, expirySeconds) {
|
|
58
|
+
const res = await fetchImpl(`${base}/storage/v1/object/sign/${bucket}/${objectKey}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
...headers(),
|
|
62
|
+
"content-type": "application/json"
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({ expiresIn: expirySeconds })
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return proxyUrl(objectKey);
|
|
67
|
+
const { signedURL } = await res.json();
|
|
68
|
+
return `${base}/storage/v1${signedURL}`;
|
|
69
|
+
},
|
|
70
|
+
async deleteFile(objectKey) {
|
|
71
|
+
const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${objectKey}`, {
|
|
72
|
+
method: "DELETE",
|
|
73
|
+
headers: headers()
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) throw new Error(`supabase delete failed: ${res.status}`);
|
|
76
|
+
},
|
|
77
|
+
async listFiles(ns, prefix, limit) {
|
|
78
|
+
assertSafeObjectKey(ns);
|
|
79
|
+
const res = await fetchImpl(`${base}/storage/v1/object/list/${bucket}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
...headers(),
|
|
83
|
+
"content-type": "application/json"
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
prefix: `${ns}/${prefix}`,
|
|
87
|
+
limit,
|
|
88
|
+
sortBy: {
|
|
89
|
+
column: "name",
|
|
90
|
+
order: "asc"
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) throw new Error(`supabase list failed: ${res.status}`);
|
|
95
|
+
return (await res.json()).filter((it) => it.id !== null).map((it) => ({
|
|
96
|
+
objectKey: `${ns}/${prefix}${it.name}`,
|
|
97
|
+
size: it.metadata?.size ?? 0,
|
|
98
|
+
updatedAt: new Date(it.metadata?.lastModified ?? Date.now()),
|
|
99
|
+
contentType: it.metadata?.mimetype
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function toBytes(data) {
|
|
105
|
+
if (data instanceof Uint8Array) return data;
|
|
106
|
+
throw new Error("supabase 后端不支持流式载荷:请先聚合为 Uint8Array");
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { createSupabaseStorage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homurajs/storage",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "26.10.13",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "homura 对象存储/图床适配:supabase-storage / s3 兼容(minio/oss) / local-fs 三后端 + 代理路由",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./local": {
|
|
18
|
+
"types": "./dist/local.d.ts",
|
|
19
|
+
"import": "./dist/local.js"
|
|
20
|
+
},
|
|
21
|
+
"./s3": {
|
|
22
|
+
"types": "./dist/s3.d.ts",
|
|
23
|
+
"import": "./dist/s3.js"
|
|
24
|
+
},
|
|
25
|
+
"./supabase": {
|
|
26
|
+
"types": "./dist/supabase.d.ts",
|
|
27
|
+
"import": "./dist/supabase.js"
|
|
28
|
+
},
|
|
29
|
+
"./proxy": {
|
|
30
|
+
"types": "./dist/proxy.d.ts",
|
|
31
|
+
"import": "./dist/proxy.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@homurajs/core": "26.10.13"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@aws-sdk/client-s3": ">=3.0.0",
|
|
43
|
+
"@aws-sdk/s3-request-presigner": ">=3.0.0",
|
|
44
|
+
"hono": ">=4.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@aws-sdk/client-s3": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"@aws-sdk/s3-request-presigner": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"hono": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
59
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
60
|
+
"hono": "^4.10.0",
|
|
61
|
+
"tsdown": "^0.23.1"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsdown",
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"test": "vitest run"
|
|
67
|
+
}
|
|
6
68
|
}
|