target-supabase-sdk 0.1.0

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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/auth/auth.api.d.ts +28 -0
  4. package/dist/auth/auth.api.d.ts.map +1 -0
  5. package/dist/auth/auth.api.js +50 -0
  6. package/dist/auth/auth.api.js.map +1 -0
  7. package/dist/core.api.d.ts +68 -0
  8. package/dist/core.api.d.ts.map +1 -0
  9. package/dist/core.api.js +226 -0
  10. package/dist/core.api.js.map +1 -0
  11. package/dist/core.interface.d.ts +36 -0
  12. package/dist/core.interface.d.ts.map +1 -0
  13. package/dist/core.interface.js +46 -0
  14. package/dist/core.interface.js.map +1 -0
  15. package/dist/core.utils.d.ts +19 -0
  16. package/dist/core.utils.d.ts.map +1 -0
  17. package/dist/core.utils.js +86 -0
  18. package/dist/core.utils.js.map +1 -0
  19. package/dist/feed/feed.interface.d.ts +27 -0
  20. package/dist/feed/feed.interface.d.ts.map +1 -0
  21. package/dist/feed/feed.interface.js +17 -0
  22. package/dist/feed/feed.interface.js.map +1 -0
  23. package/dist/file/file-manager.d.ts +24 -0
  24. package/dist/file/file-manager.d.ts.map +1 -0
  25. package/dist/file/file-manager.js +109 -0
  26. package/dist/file/file-manager.js.map +1 -0
  27. package/dist/file/file.api.d.ts +15 -0
  28. package/dist/file/file.api.d.ts.map +1 -0
  29. package/dist/file/file.api.js +30 -0
  30. package/dist/file/file.api.js.map +1 -0
  31. package/dist/file/file.interface.d.ts +25 -0
  32. package/dist/file/file.interface.d.ts.map +1 -0
  33. package/dist/file/file.interface.js +9 -0
  34. package/dist/file/file.interface.js.map +1 -0
  35. package/dist/file-list/file-list.api.d.ts +15 -0
  36. package/dist/file-list/file-list.api.d.ts.map +1 -0
  37. package/dist/file-list/file-list.api.js +54 -0
  38. package/dist/file-list/file-list.api.js.map +1 -0
  39. package/dist/file-list/file-list.interface.d.ts +22 -0
  40. package/dist/file-list/file-list.interface.d.ts.map +1 -0
  41. package/dist/file-list/file-list.interface.js +5 -0
  42. package/dist/file-list/file-list.interface.js.map +1 -0
  43. package/dist/idea/idea.interface.d.ts +25 -0
  44. package/dist/idea/idea.interface.d.ts.map +1 -0
  45. package/dist/idea/idea.interface.js +15 -0
  46. package/dist/idea/idea.interface.js.map +1 -0
  47. package/dist/index.d.ts +33 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +28 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/link/link.interface.d.ts +18 -0
  52. package/dist/link/link.interface.d.ts.map +1 -0
  53. package/dist/link/link.interface.js +6 -0
  54. package/dist/link/link.interface.js.map +1 -0
  55. package/dist/parcel/parcel-manager.d.ts +39 -0
  56. package/dist/parcel/parcel-manager.d.ts.map +1 -0
  57. package/dist/parcel/parcel-manager.js +156 -0
  58. package/dist/parcel/parcel-manager.js.map +1 -0
  59. package/dist/parcel/parcel.interface.d.ts +34 -0
  60. package/dist/parcel/parcel.interface.d.ts.map +1 -0
  61. package/dist/parcel/parcel.interface.js +11 -0
  62. package/dist/parcel/parcel.interface.js.map +1 -0
  63. package/dist/question-list/question-list.api.d.ts +13 -0
  64. package/dist/question-list/question-list.api.d.ts.map +1 -0
  65. package/dist/question-list/question-list.api.js +35 -0
  66. package/dist/question-list/question-list.api.js.map +1 -0
  67. package/dist/question-list/question-list.interface.d.ts +18 -0
  68. package/dist/question-list/question-list.interface.d.ts.map +1 -0
  69. package/dist/question-list/question-list.interface.js +5 -0
  70. package/dist/question-list/question-list.interface.js.map +1 -0
  71. package/dist/review/review.api.d.ts +14 -0
  72. package/dist/review/review.api.d.ts.map +1 -0
  73. package/dist/review/review.api.js +133 -0
  74. package/dist/review/review.api.js.map +1 -0
  75. package/dist/review/review.interface.d.ts +24 -0
  76. package/dist/review/review.interface.d.ts.map +1 -0
  77. package/dist/review/review.interface.js +10 -0
  78. package/dist/review/review.interface.js.map +1 -0
  79. package/dist/review-v2/apis/post-review-setting.api.d.ts +12 -0
  80. package/dist/review-v2/apis/post-review-setting.api.d.ts.map +1 -0
  81. package/dist/review-v2/apis/post-review-setting.api.js +27 -0
  82. package/dist/review-v2/apis/post-review-setting.api.js.map +1 -0
  83. package/dist/review-v2/apis/post-review.api.d.ts +13 -0
  84. package/dist/review-v2/apis/post-review.api.d.ts.map +1 -0
  85. package/dist/review-v2/apis/post-review.api.js +46 -0
  86. package/dist/review-v2/apis/post-review.api.js.map +1 -0
  87. package/dist/review-v2/review-setting.interface.d.ts +23 -0
  88. package/dist/review-v2/review-setting.interface.d.ts.map +1 -0
  89. package/dist/review-v2/review-setting.interface.js +5 -0
  90. package/dist/review-v2/review-setting.interface.js.map +1 -0
  91. package/dist/review-v2/review.interface.d.ts +20 -0
  92. package/dist/review-v2/review.interface.d.ts.map +1 -0
  93. package/dist/review-v2/review.interface.js +5 -0
  94. package/dist/review-v2/review.interface.js.map +1 -0
  95. package/dist/service/api.interface.d.ts +35 -0
  96. package/dist/service/api.interface.d.ts.map +1 -0
  97. package/dist/service/api.interface.js +12 -0
  98. package/dist/service/api.interface.js.map +1 -0
  99. package/dist/service/base.interface.d.ts +83 -0
  100. package/dist/service/base.interface.d.ts.map +1 -0
  101. package/dist/service/base.interface.js +7 -0
  102. package/dist/service/base.interface.js.map +1 -0
  103. package/dist/service/service.interface.d.ts +24 -0
  104. package/dist/service/service.interface.d.ts.map +1 -0
  105. package/dist/service/service.interface.js +11 -0
  106. package/dist/service/service.interface.js.map +1 -0
  107. package/dist/supabase.d.ts +32 -0
  108. package/dist/supabase.d.ts.map +1 -0
  109. package/dist/supabase.js +100 -0
  110. package/dist/supabase.js.map +1 -0
  111. package/package.json +45 -0
  112. package/src/auth/auth.api.ts +85 -0
  113. package/src/core.api.ts +338 -0
  114. package/src/core.interface.ts +74 -0
  115. package/src/core.utils.ts +98 -0
  116. package/src/feed/feed.interface.ts +33 -0
  117. package/src/file/file-manager.ts +145 -0
  118. package/src/file/file.api.ts +43 -0
  119. package/src/file/file.interface.ts +30 -0
  120. package/src/file-list/file-list.api.ts +64 -0
  121. package/src/file-list/file-list.interface.ts +24 -0
  122. package/src/idea/idea.interface.ts +28 -0
  123. package/src/index.ts +50 -0
  124. package/src/link/link.interface.ts +21 -0
  125. package/src/parcel/parcel-manager.ts +209 -0
  126. package/src/parcel/parcel.interface.ts +40 -0
  127. package/src/question-list/question-list.api.ts +42 -0
  128. package/src/question-list/question-list.interface.ts +21 -0
  129. package/src/review/review.api.ts +145 -0
  130. package/src/review/review.interface.ts +26 -0
  131. package/src/review-v2/apis/post-review-setting.api.ts +35 -0
  132. package/src/review-v2/apis/post-review.api.ts +59 -0
  133. package/src/review-v2/review-setting.interface.ts +26 -0
  134. package/src/review-v2/review.interface.ts +23 -0
  135. package/src/service/api.interface.ts +47 -0
  136. package/src/service/base.interface.ts +97 -0
  137. package/src/service/service.interface.ts +27 -0
  138. package/src/supabase.ts +143 -0
@@ -0,0 +1,145 @@
1
+ import type { FileDetails, FilePreview } from "./file.interface";
2
+
3
+ const FILE_MANIFEST_VERSION = 1;
4
+ const PREVIEW_MANIFEST_VERSION = 1;
5
+
6
+ export class FileManagerError extends Error {
7
+ constructor(
8
+ message: string,
9
+ cause?: unknown
10
+ ) {
11
+ super(message, { cause });
12
+ this.name = "FileManagerError";
13
+ }
14
+ }
15
+
16
+ export interface CreatePreviewProps {
17
+ refTargetId: string;
18
+ }
19
+
20
+ function createPreviewV0(props: CreatePreviewProps): FilePreview {
21
+ return {
22
+ manifestVersion: PREVIEW_MANIFEST_VERSION,
23
+ refTargetId: props.refTargetId,
24
+ };
25
+ }
26
+
27
+ export interface CreateFileProps {
28
+ manifestVersion: 0
29
+ /** "abc.1" */
30
+ value: string;
31
+ filePath: string;
32
+ needParcel: boolean;
33
+ }
34
+
35
+ async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
36
+ try {
37
+ const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", buffer);
38
+ const bytes = new Uint8Array(hashBuffer);
39
+ return Array.from(bytes)
40
+ .map((byte) => byte.toString(16).padStart(2, "0"))
41
+ .join("");
42
+ } catch (error) {
43
+ throw new FileManagerError("FileManager.sha256Hex: 計算文件 hash 失敗", error);
44
+ }
45
+ }
46
+
47
+ function isLikelyLocalPath(path: string): boolean {
48
+ // Windows: C:\foo\bar.txt / \\server\share ; POSIX absolute: /foo/bar
49
+ return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\") || path.startsWith("/");
50
+ }
51
+
52
+ function isFetchableResource(path: string): boolean {
53
+ return /^(https?:|blob:|data:)/.test(path) || path.startsWith("./") || path.startsWith("../");
54
+ }
55
+
56
+ function getBaseName(path: string): string {
57
+ const parts = path.split(/[\\/]/).filter(Boolean);
58
+ return parts.at(-1) ?? "unknown";
59
+ }
60
+
61
+ async function readLocalFileByPicker(filePath: string): Promise<ArrayBuffer> {
62
+ try {
63
+ if (!("showOpenFilePicker" in globalThis) || typeof globalThis.showOpenFilePicker !== "function") {
64
+ throw new FileManagerError(
65
+ "FileManager.readLocalFileByPicker: 當前瀏覽器不支持 File System Access API",
66
+ { filePath }
67
+ );
68
+ }
69
+
70
+ const [handle] = await globalThis.showOpenFilePicker({
71
+ multiple: false,
72
+ });
73
+ const file = await handle.getFile();
74
+ const expectedName = getBaseName(filePath);
75
+ if (expectedName !== "unknown" && file.name !== expectedName) {
76
+ throw new FileManagerError(
77
+ "FileManager.readLocalFileByPicker: 選擇的文件與目標路徑不一致",
78
+ { expectedName, actualName: file.name, filePath }
79
+ );
80
+ }
81
+ return file.arrayBuffer();
82
+ } catch (error) {
83
+ throw new FileManagerError("FileManager.readLocalFileByPicker: 本地文件讀取失敗", error);
84
+ }
85
+ }
86
+
87
+ async function readFileBufferFromPath(filePath: string): Promise<ArrayBuffer> {
88
+ try {
89
+ if (isLikelyLocalPath(filePath) && !isFetchableResource(filePath)) {
90
+ return readLocalFileByPicker(filePath);
91
+ }
92
+
93
+ const response = await fetch(filePath);
94
+ if (!response.ok) {
95
+ throw new FileManagerError(
96
+ `FileManager.readFileBufferFromPath: 無法讀取文件內容 (${response.status})`,
97
+ { status: response.status, statusText: response.statusText, filePath }
98
+ );
99
+ }
100
+ return response.arrayBuffer();
101
+ } catch (error) {
102
+ throw new FileManagerError("FileManager.readFileBufferFromPath: 讀取文件失敗", error);
103
+ }
104
+ }
105
+
106
+ async function createFileV0(props: CreateFileProps): Promise<FileDetails> {
107
+ try {
108
+ const fileBuffer = await readFileBufferFromPath(props.filePath);
109
+ const contentHash = await sha256Hex(fileBuffer);
110
+
111
+ return {
112
+ manifestVersion: props.manifestVersion ?? FILE_MANIFEST_VERSION,
113
+ value: props.value,
114
+ // Hash file bytes directly so checksum can be verified by re-hashing the same content.
115
+ hash: contentHash,
116
+ preview: null,
117
+ url: props.needParcel ? null : props.filePath,
118
+ parcelId: null,
119
+ };
120
+ } catch (error) {
121
+ throw new FileManagerError("FileManager.createFile: 建立文件 metadata 失敗", error);
122
+ }
123
+ }
124
+
125
+ async function verifyFileHash(filePath: string, expectedHash: string): Promise<boolean> {
126
+ try {
127
+ const normalizedExpected = expectedHash.trim().toLowerCase();
128
+ const fileBuffer = await readFileBufferFromPath(filePath);
129
+ const actualHash = await sha256Hex(fileBuffer);
130
+ return actualHash === normalizedExpected;
131
+ } catch (error) {
132
+ throw new FileManagerError("FileManager.verifyFileHash: 文件 hash 驗證失敗", error);
133
+ }
134
+ }
135
+
136
+ const createFile = createFileV0;
137
+ const createPreview = createPreviewV0;
138
+
139
+ const FileManager = {
140
+ createFile,
141
+ createPreview,
142
+ verifyFileHash,
143
+ };
144
+
145
+ export default FileManager;
@@ -0,0 +1,43 @@
1
+ import { createTarget } from "../core.api";
2
+ import { BaseValidator } from "../core.utils";
3
+ import { File, CategoryFile, FileDetails } from "./file.interface";
4
+
5
+ export interface PostFileCreatePayload {
6
+ name: File["name"];
7
+ value: File["value"];
8
+ category: File["category"];
9
+ details: FileDetails;
10
+ }
11
+
12
+ export class PostFileValidator extends BaseValidator<PostFileCreatePayload> {
13
+ protected requiredFields: (keyof PostFileCreatePayload)[] = ["name", "value", "category", "details"];
14
+ protected optionalFields: (keyof PostFileCreatePayload)[] = [];
15
+
16
+ constructor() {
17
+ super();
18
+ // Add custom
19
+ this.addCustomValidator((val) => {
20
+ return true;
21
+ });
22
+ }
23
+ }
24
+
25
+
26
+
27
+ export const postFileCreate = async (payload: PostFileCreatePayload) => {
28
+ return createTarget<File, PostFileCreatePayload>({
29
+ payload: payload,
30
+ validator: PostFileValidator,
31
+ createFn: (validPayload) => {
32
+ const { name, value, category, details } = validPayload;
33
+
34
+ return {
35
+ name,
36
+ category,
37
+ value,
38
+ tagList: [],
39
+ details,
40
+ };
41
+ },
42
+ });
43
+ };
@@ -0,0 +1,30 @@
1
+ import { Target } from "../core.interface";
2
+
3
+ export enum CategoryFile {
4
+ VIDEO = "video",
5
+ IMAGE = "image",
6
+ TEXT = "text",
7
+ }
8
+
9
+ export interface File extends Target {
10
+ details: FileDetails;
11
+ category: CategoryFile;
12
+ }
13
+
14
+ export interface FileDetails {
15
+ manifestVersion: number;
16
+ /** "abc.1" 唯一键 */
17
+ value: string;
18
+ hash: string | null;
19
+ preview: FilePreview | null;
20
+ /** Quickly access the file */
21
+ url: string | null;
22
+ parcelId: string | null;
23
+ /** TODO 消費與改動歷史 */
24
+ // reviewList?: string[];
25
+ };
26
+
27
+ export interface FilePreview {
28
+ manifestVersion: number;
29
+ refTargetId: string;
30
+ };
@@ -0,0 +1,64 @@
1
+ import { createTarget } from "../core.api";
2
+ import { BaseValidator } from "../core.utils";
3
+ import { FileList, CategoryFileList, FileListDetails } from "./file-list.interface";
4
+
5
+ export class PostCreateFileListValidator extends BaseValidator<PostCreateFileListPayload> {
6
+ protected requiredFields: (keyof PostCreateFileListPayload)[] = ["name", "dirName", "dirSize", "storageUrl"];
7
+ protected optionalFields: (keyof PostCreateFileListPayload)[] = [];
8
+
9
+ constructor() {
10
+ super();
11
+ // Add custom
12
+ this.addValidator((val) => {
13
+ return true;
14
+ });
15
+ }
16
+ }
17
+
18
+ export interface PostCreateFileListPayload {
19
+ name: FileListDetails["name"];
20
+ dirName: FileListDetails["dirName"];
21
+ dirSize: FileListDetails["dirSize"];
22
+ storageUrl: FileListDetails["storageUrl"];
23
+ }
24
+
25
+ export const postCreateFileList = async (payload: PostCreateFileListPayload) => {
26
+ return createTarget<FileList, PostCreateFileListPayload>({
27
+ payload: payload,
28
+ validator: PostCreateFileListValidator,
29
+ checkRedundancyFilterList: [
30
+ {
31
+ field: "category",
32
+ operator: "eq",
33
+ value: CategoryFileList.FILE_LIST,
34
+ },
35
+ {
36
+ field: "details->>dirName",
37
+ operator: "eq",
38
+ value: payload.dirName,
39
+ },
40
+ {
41
+ field: "details->>storageUrl",
42
+ operator: "eq",
43
+ value: payload.storageUrl,
44
+ },
45
+ ],
46
+ createFn: (validPayload) => {
47
+ const { name, dirName, dirSize, storageUrl = "" } = validPayload;
48
+ const details: FileListDetails = {
49
+ name,
50
+ dirName,
51
+ dirSize,
52
+ storageUrl,
53
+ };
54
+
55
+ return {
56
+ name: validPayload.name,
57
+ category: CategoryFileList.FILE_LIST,
58
+ value: dirName,
59
+ tagList: [],
60
+ details,
61
+ };
62
+ },
63
+ });
64
+ };
@@ -0,0 +1,24 @@
1
+ import { Target } from "../core.interface";
2
+
3
+ /**
4
+ * Use storageUrl+dirName to identify the file list
5
+ * You can iterate all files in the directory
6
+ */
7
+ export type FileListDetails = {
8
+ /** Human readable name, should be same to Target.name */
9
+ name: string;
10
+ /** unique path */
11
+ dirName: string;
12
+ dirSize: number;
13
+ /** where to store the file list */
14
+ storageUrl: string;
15
+ };
16
+
17
+ export enum CategoryFileList {
18
+ FILE_LIST = "file-list",
19
+ }
20
+
21
+ export interface FileList extends Target {
22
+ details: FileListDetails;
23
+ category: CategoryFileList;
24
+ }
@@ -0,0 +1,28 @@
1
+ import { Target } from "../core.interface";
2
+
3
+ export enum CategoryIdea {
4
+ OPPORTUNITY = "opportunity",
5
+ }
6
+
7
+ export interface Idea extends Target {
8
+ /** 主題 */
9
+ value: string;
10
+ details: IdeaDetails;
11
+ category: CategoryIdea;
12
+ }
13
+
14
+ export interface IdeaDetails {
15
+ manifestVersion: number;
16
+ marketList: Market[];
17
+ reviewList: string[];
18
+ /** 切入點 */
19
+ point: string;
20
+ };
21
+
22
+ export enum Market {
23
+ NA = "NA",
24
+ EU = "EU",
25
+ JP = "JP",
26
+ CN = "CN",
27
+ TW = "TW",
28
+ };
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ export { SupabaseInitializer } from "./supabase";
2
+ export type { SupabaseInitializerParams } from "./supabase";
3
+ export type { SupabaseClient } from "@supabase/supabase-js";
4
+
5
+ import { SupabaseInitializer } from "./supabase";
6
+
7
+ export const supabase = SupabaseInitializer.getInstance();
8
+
9
+ export * from "./core.api";
10
+ export * from "./core.interface";
11
+ export * from "./core.utils";
12
+
13
+ export * from "./auth/auth.api";
14
+
15
+ export * from "./feed/feed.interface";
16
+ export * from "./idea/idea.interface";
17
+ export * from "./link/link.interface";
18
+
19
+ export { CategoryParcel } from "./parcel/parcel.interface";
20
+ export type {
21
+ Parcel,
22
+ ParcelDetails,
23
+ Chunk,
24
+ LifecycleStatus as ParcelLifecycleStatus,
25
+ Lifecycle as ParcelLifecycle,
26
+ } from "./parcel/parcel.interface";
27
+ export { default as ParcelManager } from "./parcel/parcel-manager";
28
+
29
+ export * from "./file/file.interface";
30
+ export * from "./file/file.api";
31
+ export * from "./file/file-manager";
32
+
33
+ export * from "./file-list/file-list.interface";
34
+ export * from "./file-list/file-list.api";
35
+
36
+ export * from "./question-list/question-list.interface";
37
+ export * from "./question-list/question-list.api";
38
+
39
+ export * from "./review/review.interface";
40
+ export * from "./review/review.api";
41
+
42
+ export type { ReviewV2, ReviewV2Details, ReviewResult } from "./review-v2/review.interface";
43
+ export * from "./review-v2/review-setting.interface";
44
+ export { postReview as postReviewV2, PostReviewValidator as PostReviewV2Validator } from "./review-v2/apis/post-review.api";
45
+ export type { PostReviewPayload as PostReviewV2Payload } from "./review-v2/apis/post-review.api";
46
+ export * from "./review-v2/apis/post-review-setting.api";
47
+
48
+ export * from "./service/base.interface";
49
+ export * from "./service/service.interface";
50
+ export * from "./service/api.interface";
@@ -0,0 +1,21 @@
1
+ import { Target } from "../core.interface";
2
+
3
+ export enum CategoryLink {
4
+ LINK = "link",
5
+ }
6
+
7
+ export interface Link extends Target {
8
+ /** 鏈接名稱 */
9
+ name: string;
10
+ /** URL */
11
+ value: string;
12
+ details: LinkDetails;
13
+ category: CategoryLink;
14
+ }
15
+
16
+ export interface LinkDetails {
17
+ manifestVersion: number;
18
+ description: string;
19
+ iconUrl: string;
20
+ };
21
+
@@ -0,0 +1,209 @@
1
+ import type { Chunk, Parcel, ParcelDetails } from "./parcel.interface";
2
+
3
+ const MANIFEST_VERSION = 1;
4
+ const DEFAULT_CHUNK_SIZE = 256 * 1024; // 256KB
5
+ const AES_GCM_IV_LENGTH = 12;
6
+ const AES_GCM_TAG_LENGTH = 128;
7
+
8
+ /** IV 存在 Parcel.extra 中的 JSON 键 */
9
+ const EXTRA_IV_KEY = "iv";
10
+
11
+ /** 可插拔的存储适配器:不同文件系统 */
12
+ interface StorageAdapter {
13
+ /** 上传一段数据,返回可下载的 URL */
14
+ upload(data: ArrayBuffer, pathOrKey: string): Promise<{ url: string }>;
15
+ }
16
+
17
+ interface CreateOptions {
18
+ /** 每个 chunk 的字节数,默认 256KB */
19
+ chunkSize?: number;
20
+ /** AES-GCM 加密密钥;不传则生成并返回 */
21
+ key?: CryptoKey;
22
+ }
23
+
24
+ interface CreateResult {
25
+ /** 元数据,chunk 的 url 由 save 填充 */
26
+ details: ParcelDetails;
27
+ /** 已加密的 chunk 数据,供 save 上传 */
28
+ chunks: ArrayBuffer[];
29
+ /** Base64 编码的 IV,需在 save 时写入 Parcel.extra 供 reassembly 使用 */
30
+ ivBase64: string;
31
+ /** 若 create 时未传 key,则返回此处,调用方需妥善保存 */
32
+ key?: CryptoKey;
33
+ }
34
+
35
+ /** 调用方提供的 Target 基础字段,用于拼成完整 Parcel */
36
+ type ParcelTargetBase = Pick<Parcel, "id" | "category" | "name" | "value" | "tagList" | "created_at">;
37
+
38
+ function getCrypto(): Crypto {
39
+ if (typeof globalThis !== "undefined" && globalThis.crypto) return globalThis.crypto;
40
+ throw new Error("ParcelManager: crypto not available");
41
+ }
42
+
43
+ /** 计算 ArrayBuffer 的 SHA-256,返回 hex 字符串 */
44
+ async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
45
+ const crypto = getCrypto();
46
+ const hash = await crypto.subtle.digest("SHA-256", buffer);
47
+ return Array.from(new Uint8Array(hash))
48
+ .map((b) => b.toString(16).padStart(2, "0"))
49
+ .join("");
50
+ }
51
+
52
+ /** AES-GCM 加密 */
53
+ async function encrypt(data: ArrayBuffer, key: CryptoKey, iv: Uint8Array): Promise<ArrayBuffer> {
54
+ const crypto = getCrypto();
55
+ const ivCopy = new Uint8Array(iv);
56
+ return crypto.subtle.encrypt({ name: "AES-GCM", iv: ivCopy, tagLength: AES_GCM_TAG_LENGTH }, key, data);
57
+ }
58
+
59
+ /** AES-GCM 解密 */
60
+ async function decrypt(data: ArrayBuffer, key: CryptoKey, iv: Uint8Array): Promise<ArrayBuffer> {
61
+ const crypto = getCrypto();
62
+ const ivCopy = new Uint8Array(iv);
63
+ return crypto.subtle.decrypt({ name: "AES-GCM", iv: ivCopy, tagLength: AES_GCM_TAG_LENGTH }, key, data);
64
+ }
65
+
66
+ /** 生成 AES-GCM 密钥(256 位) */
67
+ async function generateKey(): Promise<CryptoKey> {
68
+ const crypto = getCrypto();
69
+ return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
70
+ }
71
+
72
+ /** 将文件加密后拆分成多个 chunk */
73
+ async function create(file: ArrayBuffer, options: CreateOptions = {}): Promise<CreateResult> {
74
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
75
+ let key = options.key;
76
+ const generatedKey = !key;
77
+ if (!key) key = await generateKey();
78
+
79
+ const crypto = getCrypto();
80
+ const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH));
81
+
82
+ const encrypted = await encrypt(file, key, iv);
83
+ const totalSize = encrypted.byteLength;
84
+
85
+ const chunks: ArrayBuffer[] = [];
86
+ const chunkList: Chunk[] = [];
87
+ let offset = 0;
88
+ let index = 0;
89
+
90
+ while (offset < totalSize) {
91
+ const end = Math.min(offset + chunkSize, totalSize);
92
+ const chunkBuffer = encrypted.slice(offset, end);
93
+ chunks.push(chunkBuffer);
94
+ const checksum = await sha256Hex(chunkBuffer);
95
+ chunkList.push({
96
+ index,
97
+ size: chunkBuffer.byteLength,
98
+ checksum,
99
+ url: "", // 由 save 填充
100
+ });
101
+ offset = end;
102
+ index += 1;
103
+ }
104
+
105
+ const checksum = await sha256Hex(file);
106
+
107
+ const details: ParcelDetails = {
108
+ manifestVersion: MANIFEST_VERSION,
109
+ chunkList: chunkList,
110
+ checksum,
111
+ size: file.byteLength,
112
+ };
113
+
114
+ const result: CreateResult = {
115
+ details,
116
+ chunks,
117
+ ivBase64: btoa(String.fromCharCode(...iv)),
118
+ };
119
+ if (generatedKey) result.key = key;
120
+ return result;
121
+ }
122
+
123
+ /** 将 chunk 均匀分布到不同文件系统并存储 */
124
+ async function save(
125
+ createResult: CreateResult,
126
+ fileSystems: StorageAdapter[],
127
+ targetBase: ParcelTargetBase
128
+ ): Promise<Parcel> {
129
+ if (fileSystems.length === 0) throw new Error("ParcelManager.save: fileSystems 不能为空");
130
+ const { details, chunks, ivBase64 } = createResult;
131
+
132
+ const chunkListWithUrls: Chunk[] = [];
133
+ for (let i = 0; i < chunks.length; i++) {
134
+ const fs = fileSystems[i % fileSystems.length];
135
+ const pathOrKey = `chunk-${i}-${details.checksum.slice(0, 8)}`;
136
+ const { url } = await fs.upload(chunks[i], pathOrKey);
137
+ chunkListWithUrls.push({ ...details.chunkList[i], url });
138
+ }
139
+
140
+ const extra = JSON.stringify({ [EXTRA_IV_KEY]: ivBase64 });
141
+
142
+ return {
143
+ ...targetBase,
144
+ category: "parcel" as Parcel["category"],
145
+ details: {
146
+ ...details,
147
+ chunkList: chunkListWithUrls,
148
+ },
149
+ extra,
150
+ };
151
+ }
152
+
153
+ /** 根据 Parcel 下载各 chunk,重组并校验后返回原文件 */
154
+ async function reassembly(parcel: Parcel, key: CryptoKey): Promise<ArrayBuffer> {
155
+ const { details } = parcel;
156
+ let ivBase64: string | undefined;
157
+ try {
158
+ const extra = parcel.extra ? JSON.parse(parcel.extra) : {};
159
+ ivBase64 = extra[EXTRA_IV_KEY];
160
+ } catch {
161
+ throw new Error("ParcelManager.reassembly: 无法从 extra 解析 IV");
162
+ }
163
+ if (!ivBase64 || typeof ivBase64 !== "string") {
164
+ throw new Error("ParcelManager.reassembly: Parcel.extra 中缺少 iv");
165
+ }
166
+
167
+ const iv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0));
168
+ if (iv.length !== AES_GCM_IV_LENGTH) {
169
+ throw new Error("ParcelManager.reassembly: IV 长度无效");
170
+ }
171
+
172
+ const sortedChunks = [...details.chunkList].sort((a, b) => a.index - b.index);
173
+ const buffers: ArrayBuffer[] = [];
174
+
175
+ for (const chunkMeta of sortedChunks) {
176
+ const res = await fetch(chunkMeta.url);
177
+ if (!res.ok) throw new Error(`ParcelManager.reassembly: 下载 chunk ${chunkMeta.index} 失败: ${res.status}`);
178
+ const chunkBuffer = await res.arrayBuffer();
179
+ const chunkChecksum = await sha256Hex(chunkBuffer);
180
+ if (chunkChecksum !== chunkMeta.checksum) {
181
+ throw new Error(`ParcelManager.reassembly: chunk ${chunkMeta.index} 校验失败 (checksum 不一致)`);
182
+ }
183
+ buffers.push(chunkBuffer);
184
+ }
185
+
186
+ const totalEncryptedLength = buffers.reduce((s, b) => s + b.byteLength, 0);
187
+ const encrypted = new Uint8Array(totalEncryptedLength);
188
+ let offset = 0;
189
+ for (const b of buffers) {
190
+ encrypted.set(new Uint8Array(b), offset);
191
+ offset += b.byteLength;
192
+ }
193
+
194
+ const decrypted = await decrypt(encrypted.buffer, key, iv);
195
+ const fileChecksum = await sha256Hex(decrypted);
196
+ if (fileChecksum !== details.checksum) {
197
+ throw new Error("ParcelManager.reassembly: 重组后文件校验失败 (sha256 与 details.checksum 不一致)");
198
+ }
199
+
200
+ return decrypted;
201
+ }
202
+
203
+ const ParcelManager = {
204
+ create,
205
+ save,
206
+ reassembly,
207
+ };
208
+
209
+ export default ParcelManager;
@@ -0,0 +1,40 @@
1
+ import { Target } from "../core.interface";
2
+
3
+ export interface Parcel extends Target {
4
+ category: CategoryParcel;
5
+ details: ParcelDetails;
6
+ }
7
+
8
+ export enum CategoryParcel {
9
+ PARCEL = "parcel",
10
+ }
11
+
12
+ export interface ParcelDetails {
13
+ manifestVersion: number;
14
+ chunkList: Chunk[];
15
+ /** sha256 */
16
+ checksum: string;
17
+ size: number;
18
+ }
19
+
20
+ export enum LifecycleStatus {
21
+ STORED = "STORED",
22
+ SOFT_DELETED = "SOFT_DELETED",
23
+ HARD_DELETED = "HARD_DELETED",
24
+ }
25
+
26
+ export interface Lifecycle {
27
+ status: LifecycleStatus;
28
+ storedAt: string;
29
+ softDeletedAt: string | null;
30
+ hardDeletedAt: string | null;
31
+ }
32
+
33
+ export interface Chunk {
34
+ index: number;
35
+ /** TODO Storage service provider */
36
+ // shardKey: string;
37
+ size: number;
38
+ checksum: string;
39
+ url: string;
40
+ }