@southwind-ai/storage 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.
@@ -0,0 +1,123 @@
1
+ import type { ArtifactRef, BlobStore } from "./blob-store.js";
2
+ import { UploadError } from "./upload-types.js";
3
+
4
+ export function concatBlobStreams(
5
+ store: BlobStore,
6
+ refs: readonly ArtifactRef[],
7
+ ): ReadableStream<Uint8Array> {
8
+ let current: ReadableStreamDefaultReader<Uint8Array> | undefined;
9
+ let index = 0;
10
+ return new ReadableStream({
11
+ async pull(controller) {
12
+ while (true) {
13
+ if (!current) {
14
+ const ref = refs[index++];
15
+ if (!ref) return controller.close();
16
+ const blob = await store.open(ref);
17
+ if (!blob) {
18
+ return controller.error(
19
+ new UploadError("UPLOAD_INCOMPLETE", "上传分片内容不存在"),
20
+ );
21
+ }
22
+ current = blob.body.getReader();
23
+ }
24
+ const result = await current.read();
25
+ if (result.done) {
26
+ current.releaseLock();
27
+ current = undefined;
28
+ continue;
29
+ }
30
+ controller.enqueue(result.value);
31
+ return;
32
+ }
33
+ },
34
+ async cancel(reason) {
35
+ await current?.cancel(reason);
36
+ },
37
+ });
38
+ }
39
+
40
+ export function guardFileSignature(
41
+ body: ReadableStream<Uint8Array>,
42
+ mimeType: string,
43
+ ): ReadableStream<Uint8Array> {
44
+ const reader = body.getReader();
45
+ const requiredBytes = signatureByteCount(mimeType);
46
+ const pending: Uint8Array[] = [];
47
+ const prefix = new Uint8Array(requiredBytes);
48
+ let prefixSize = 0;
49
+ let checked = false;
50
+ return new ReadableStream({
51
+ async pull(controller) {
52
+ if (!checked) {
53
+ while (prefixSize < requiredBytes) {
54
+ const result = await reader.read();
55
+ if (result.done) {
56
+ controller.error(signatureError());
57
+ return;
58
+ }
59
+ pending.push(result.value);
60
+ const needed = requiredBytes - prefixSize;
61
+ const slice = result.value.subarray(0, needed);
62
+ prefix.set(slice, prefixSize);
63
+ prefixSize += slice.byteLength;
64
+ }
65
+ checked = true;
66
+ if (!matchesSignature(prefix, mimeType)) {
67
+ await reader.cancel();
68
+ controller.error(signatureError());
69
+ return;
70
+ }
71
+ }
72
+ const buffered = pending.shift();
73
+ if (buffered) {
74
+ controller.enqueue(buffered);
75
+ return;
76
+ }
77
+ const result = await reader.read();
78
+ if (result.done) controller.close();
79
+ else controller.enqueue(result.value);
80
+ },
81
+ async cancel(reason) {
82
+ await reader.cancel(reason);
83
+ },
84
+ });
85
+ }
86
+
87
+ function signatureByteCount(mimeType: string) {
88
+ if (mimeType === "image/png") return 8;
89
+ if (mimeType === "image/jpeg") return 3;
90
+ if (mimeType === "image/webp") return 12;
91
+ return 1;
92
+ }
93
+
94
+ function matchesSignature(prefix: Uint8Array, mimeType: string): boolean {
95
+ if (mimeType === "image/png") {
96
+ return startsWith(prefix, [137, 80, 78, 71, 13, 10, 26, 10]);
97
+ }
98
+ if (mimeType === "image/jpeg") {
99
+ return startsWith(prefix, [255, 216, 255]);
100
+ }
101
+ if (mimeType === "image/webp") {
102
+ return text(prefix, 0, 4) === "RIFF" && text(prefix, 8, 12) === "WEBP";
103
+ }
104
+ return true;
105
+ }
106
+
107
+ function startsWith(value: Uint8Array, expected: number[]) {
108
+ return (
109
+ value.length >= expected.length &&
110
+ expected.every((byte, index) => value[index] === byte)
111
+ );
112
+ }
113
+
114
+ function text(value: Uint8Array, start: number, end: number) {
115
+ return new TextDecoder().decode(value.subarray(start, end));
116
+ }
117
+
118
+ function signatureError() {
119
+ return new UploadError(
120
+ "FILE_SIGNATURE_MISMATCH",
121
+ "文件内容与声明的 MIME 类型不一致",
122
+ );
123
+ }
@@ -0,0 +1,110 @@
1
+ import type { ArtifactRef, ContentHash } from "./blob-store.js";
2
+
3
+ export type UploadSessionStatus =
4
+ | "uploading"
5
+ | "completed"
6
+ | "aborted"
7
+ | "expired";
8
+
9
+ export interface UploadSession {
10
+ id: string;
11
+ ownerId: string;
12
+ purpose: string;
13
+ filename: string;
14
+ extension: string;
15
+ mimeType: string;
16
+ expectedByteSize: number;
17
+ expectedContentHash?: ContentHash;
18
+ receivedByteSize: number;
19
+ nextPartNumber: number;
20
+ status: UploadSessionStatus;
21
+ expiresAt: string;
22
+ fileExpiresAt?: string;
23
+ fileId?: string;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ }
27
+
28
+ export interface UploadPart {
29
+ sessionId: string;
30
+ partNumber: number;
31
+ byteOffset: number;
32
+ artifactRef: ArtifactRef;
33
+ byteSize: number;
34
+ contentHash: ContentHash;
35
+ createdAt: string;
36
+ }
37
+
38
+ export interface FileMetadata {
39
+ id: string;
40
+ ownerId: string;
41
+ purpose: string;
42
+ filename: string;
43
+ extension: string;
44
+ mimeType: string;
45
+ artifactRef: ArtifactRef;
46
+ byteSize: number;
47
+ contentHash: ContentHash;
48
+ expiresAt?: string;
49
+ createdAt: string;
50
+ }
51
+
52
+ export interface OpenedFile extends FileMetadata {
53
+ body: ReadableStream<Uint8Array>;
54
+ }
55
+
56
+ export interface UploadPolicy {
57
+ purpose: string;
58
+ maxByteSize: number;
59
+ maxPartByteSize: number;
60
+ sessionTtlMs: number;
61
+ allowedTypes: Readonly<Record<string, readonly string[]>>;
62
+ signature?: "image" | "none";
63
+ }
64
+
65
+ export interface CreateUploadInput {
66
+ ownerId: string;
67
+ purpose: string;
68
+ filename: string;
69
+ mimeType: string;
70
+ expectedByteSize: number;
71
+ expectedSha256?: string;
72
+ fileExpiresAt?: Date;
73
+ }
74
+
75
+ export interface UploadPartInput {
76
+ sessionId: string;
77
+ ownerId: string;
78
+ partNumber: number;
79
+ byteOffset: number;
80
+ byteSize: number;
81
+ sha256: string;
82
+ body: ReadableStream<Uint8Array>;
83
+ }
84
+
85
+ export type UploadErrorCode =
86
+ | "INVALID_UPLOAD"
87
+ | "UNSUPPORTED_FILE_TYPE"
88
+ | "UPLOAD_TOO_LARGE"
89
+ | "PART_TOO_LARGE"
90
+ | "UPLOAD_NOT_FOUND"
91
+ | "UPLOAD_FORBIDDEN"
92
+ | "UPLOAD_NOT_ACTIVE"
93
+ | "UPLOAD_EXPIRED"
94
+ | "UPLOAD_OFFSET_CONFLICT"
95
+ | "UPLOAD_INCOMPLETE"
96
+ | "FILE_SIGNATURE_MISMATCH"
97
+ | "CONCURRENT_UPLOAD"
98
+ | "FILE_NOT_FOUND"
99
+ | "FILE_EXPIRED"
100
+ | "FILE_CONTENT_MISSING";
101
+
102
+ export class UploadError extends Error {
103
+ constructor(
104
+ readonly code: UploadErrorCode,
105
+ message: string,
106
+ ) {
107
+ super(message);
108
+ this.name = "UploadError";
109
+ }
110
+ }