@striae-org/striae 5.0.0 → 5.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.
- package/.env.example +5 -2
- package/app/components/actions/case-export/download-handlers.ts +6 -7
- package/app/components/actions/case-manage.ts +10 -11
- package/app/components/actions/generate-pdf.ts +43 -1
- package/app/components/actions/image-manage.ts +13 -45
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +15 -4
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/package.json +2 -2
- package/scripts/deploy-config.sh +149 -6
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +66 -5
- package/scripts/encrypt-r2-backfill.mjs +376 -0
- package/worker-configuration.d.ts +13 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +5 -0
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +280 -2
- package/workers/data-worker/src/encryption-utils.ts +145 -1
- package/workers/data-worker/wrangler.jsonc.example +4 -0
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +196 -127
- package/workers/image-worker/wrangler.jsonc.example +7 -0
- package/workers/keys-worker/package.json +1 -4
- package/workers/pdf-worker/package.json +1 -4
- package/workers/user-worker/package.json +1 -4
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
2
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
3
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
4
|
+
const decoded = atob(normalized + padding);
|
|
5
|
+
const bytes = new Uint8Array(decoded.length);
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
8
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return bytes;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function base64UrlEncode(value: Uint8Array): string {
|
|
15
|
+
let binary = '';
|
|
16
|
+
const chunkSize = 8192;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < value.length; i += chunkSize) {
|
|
19
|
+
const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
|
|
20
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
21
|
+
binary += String.fromCharCode(chunk[j]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return btoa(binary)
|
|
26
|
+
.replace(/\+/g, '-')
|
|
27
|
+
.replace(/\//g, '_')
|
|
28
|
+
.replace(/=+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
32
|
+
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
33
|
+
|
|
34
|
+
export interface DataAtRestEnvelope {
|
|
35
|
+
algorithm: string;
|
|
36
|
+
encryptionVersion: string;
|
|
37
|
+
keyId: string;
|
|
38
|
+
dataIv: string;
|
|
39
|
+
wrappedKey: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EncryptBinaryAtRestResult {
|
|
43
|
+
ciphertext: Uint8Array;
|
|
44
|
+
envelope: DataAtRestEnvelope;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
|
|
48
|
+
const normalizedKey = publicKey
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^['"]|['"]$/g, '')
|
|
51
|
+
.replace(/\\n/g, '\n');
|
|
52
|
+
|
|
53
|
+
const pemBody = normalizedKey
|
|
54
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
55
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
56
|
+
.replace(/\s+/g, '');
|
|
57
|
+
|
|
58
|
+
if (!pemBody) {
|
|
59
|
+
throw new Error('Encryption public key is invalid');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const binary = atob(pemBody);
|
|
63
|
+
const bytes = new Uint8Array(binary.length);
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
66
|
+
bytes[index] = binary.charCodeAt(index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return bytes.buffer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
73
|
+
const normalizedKey = privateKey
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^['"]|['"]$/g, '')
|
|
76
|
+
.replace(/\\n/g, '\n');
|
|
77
|
+
|
|
78
|
+
const pemBody = normalizedKey
|
|
79
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
80
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
81
|
+
.replace(/\s+/g, '');
|
|
82
|
+
|
|
83
|
+
if (!pemBody) {
|
|
84
|
+
throw new Error('Encryption private key is invalid');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const binary = atob(pemBody);
|
|
88
|
+
const bytes = new Uint8Array(binary.length);
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
91
|
+
bytes[index] = binary.charCodeAt(index);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return bytes.buffer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
98
|
+
return crypto.subtle.importKey(
|
|
99
|
+
'pkcs8',
|
|
100
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
101
|
+
{
|
|
102
|
+
name: 'RSA-OAEP',
|
|
103
|
+
hash: 'SHA-256'
|
|
104
|
+
},
|
|
105
|
+
false,
|
|
106
|
+
['decrypt']
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
111
|
+
return crypto.subtle.importKey(
|
|
112
|
+
'spki',
|
|
113
|
+
parseSpkiPublicKey(publicKeyPem),
|
|
114
|
+
{
|
|
115
|
+
name: 'RSA-OAEP',
|
|
116
|
+
hash: 'SHA-256'
|
|
117
|
+
},
|
|
118
|
+
false,
|
|
119
|
+
['encrypt']
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
|
|
124
|
+
return crypto.subtle.generateKey(
|
|
125
|
+
{
|
|
126
|
+
name: 'AES-GCM',
|
|
127
|
+
length: 256
|
|
128
|
+
},
|
|
129
|
+
true,
|
|
130
|
+
usages
|
|
131
|
+
) as Promise<CryptoKey>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
|
|
135
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
136
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
137
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
138
|
+
{ name: 'RSA-OAEP' },
|
|
139
|
+
rsaPublicKey,
|
|
140
|
+
rawAesKey as BufferSource
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
|
|
147
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
148
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
149
|
+
|
|
150
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
151
|
+
{ name: 'RSA-OAEP' },
|
|
152
|
+
rsaPrivateKey,
|
|
153
|
+
wrappedKeyBytes as BufferSource
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return crypto.subtle.importKey(
|
|
157
|
+
'raw',
|
|
158
|
+
rawAesKey,
|
|
159
|
+
{ name: 'AES-GCM' },
|
|
160
|
+
false,
|
|
161
|
+
['encrypt', 'decrypt']
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateEnvelope(envelope: DataAtRestEnvelope): void {
|
|
166
|
+
if (envelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
|
|
167
|
+
throw new Error('Unsupported data-at-rest encryption algorithm');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (envelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
|
|
171
|
+
throw new Error('Unsupported data-at-rest encryption version');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function encryptBinaryForStorage(
|
|
176
|
+
plaintextBytes: ArrayBuffer,
|
|
177
|
+
publicKeyPem: string,
|
|
178
|
+
keyId: string
|
|
179
|
+
): Promise<EncryptBinaryAtRestResult> {
|
|
180
|
+
const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
|
|
181
|
+
const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
|
|
182
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
183
|
+
|
|
184
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
185
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
186
|
+
aesKey,
|
|
187
|
+
plaintextBytes as BufferSource
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ciphertext: new Uint8Array(encryptedBuffer),
|
|
192
|
+
envelope: {
|
|
193
|
+
algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
194
|
+
encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
|
|
195
|
+
keyId,
|
|
196
|
+
dataIv: base64UrlEncode(iv),
|
|
197
|
+
wrappedKey
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function decryptBinaryFromStorage(
|
|
203
|
+
ciphertext: ArrayBuffer,
|
|
204
|
+
envelope: DataAtRestEnvelope,
|
|
205
|
+
privateKeyPem: string
|
|
206
|
+
): Promise<ArrayBuffer> {
|
|
207
|
+
validateEnvelope(envelope);
|
|
208
|
+
|
|
209
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
210
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
211
|
+
|
|
212
|
+
return crypto.subtle.decrypt(
|
|
213
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
214
|
+
aesKey,
|
|
215
|
+
ciphertext as BufferSource
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -1,179 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decryptBinaryFromStorage,
|
|
3
|
+
encryptBinaryForStorage,
|
|
4
|
+
type DataAtRestEnvelope
|
|
5
|
+
} from './encryption-utils';
|
|
6
|
+
|
|
1
7
|
interface Env {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
8
|
+
IMAGES_API_TOKEN: string;
|
|
9
|
+
STRIAE_FILES: R2Bucket;
|
|
10
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY: string;
|
|
11
|
+
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
|
|
12
|
+
DATA_AT_REST_ENCRYPTION_KEY_ID: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UploadResult {
|
|
16
|
+
id: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
uploaded: string;
|
|
19
|
+
requireSignedURLs: boolean;
|
|
20
|
+
variants: string[];
|
|
5
21
|
}
|
|
6
22
|
|
|
7
|
-
interface
|
|
23
|
+
interface UploadResponse {
|
|
24
|
+
success: boolean;
|
|
25
|
+
errors: Array<{ code: number; message: string }>;
|
|
26
|
+
messages: string[];
|
|
27
|
+
result: UploadResult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SuccessResponse {
|
|
8
31
|
success: boolean;
|
|
9
|
-
errors?: Array<{
|
|
10
|
-
code: number;
|
|
11
|
-
message: string;
|
|
12
|
-
}>;
|
|
13
|
-
messages?: string[];
|
|
14
|
-
result?: {
|
|
15
|
-
id: string;
|
|
16
|
-
filename: string;
|
|
17
|
-
uploaded: string;
|
|
18
|
-
requireSignedURLs: boolean;
|
|
19
|
-
variants: string[];
|
|
20
|
-
};
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
interface ErrorResponse {
|
|
24
35
|
error: string;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
type APIResponse =
|
|
38
|
+
type APIResponse = UploadResponse | SuccessResponse | ErrorResponse;
|
|
28
39
|
|
|
29
|
-
const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* CORS headers to allow requests from the Striae app
|
|
33
|
-
*/
|
|
34
40
|
const corsHeaders: Record<string, string> = {
|
|
35
41
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
36
42
|
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
37
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key'
|
|
38
|
-
'Content-Type': 'application/json'
|
|
43
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key'
|
|
39
44
|
};
|
|
40
45
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
{
|
|
46
|
+
const createJsonResponse = (data: APIResponse, status: number = 200): Response => new Response(
|
|
47
|
+
JSON.stringify(data),
|
|
48
|
+
{
|
|
49
|
+
status,
|
|
50
|
+
headers: {
|
|
51
|
+
...corsHeaders,
|
|
52
|
+
'Content-Type': 'application/json'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
);
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
const authHeader = request.headers.get(
|
|
48
|
-
const expectedToken = `Bearer ${env.
|
|
57
|
+
function hasValidToken(request: Request, env: Env): boolean {
|
|
58
|
+
const authHeader = request.headers.get('Authorization');
|
|
59
|
+
const expectedToken = `Bearer ${env.IMAGES_API_TOKEN}`;
|
|
49
60
|
return authHeader === expectedToken;
|
|
50
|
-
}
|
|
61
|
+
}
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function handleImageUpload(request: Request, env: Env): Promise<Response> {
|
|
56
|
-
if (!hasValidToken(request, env)) {
|
|
57
|
-
return createResponse({ error: 'Unauthorized' }, 403);
|
|
63
|
+
function requireEncryptionUploadConfig(env: Env): void {
|
|
64
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
|
|
65
|
+
throw new Error('Data-at-rest encryption is not configured for image uploads');
|
|
58
66
|
}
|
|
67
|
+
}
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
function requireEncryptionRetrievalConfig(env: Env): void {
|
|
70
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
|
|
71
|
+
throw new Error('Data-at-rest decryption is not configured for image retrieval');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
function parseFileId(pathname: string): string | null {
|
|
76
|
+
const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
77
|
+
if (!encodedFileId) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
let decodedFileId = '';
|
|
82
|
+
try {
|
|
83
|
+
decodedFileId = decodeURIComponent(encodedFileId);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!decodedFileId || decodedFileId.includes('/')) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return decodedFileId;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!hasValidToken(request, env)) {
|
|
83
|
-
return createResponse({ error: 'Unauthorized' }, 403);
|
|
95
|
+
function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
96
|
+
const metadata = file.customMetadata;
|
|
97
|
+
if (!metadata) {
|
|
98
|
+
return null;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
const { algorithm, encryptionVersion, keyId, dataIv, wrappedKey } = metadata;
|
|
102
|
+
if (
|
|
103
|
+
typeof algorithm !== 'string' ||
|
|
104
|
+
typeof encryptionVersion !== 'string' ||
|
|
105
|
+
typeof keyId !== 'string' ||
|
|
106
|
+
typeof dataIv !== 'string' ||
|
|
107
|
+
typeof wrappedKey !== 'string'
|
|
108
|
+
) {
|
|
109
|
+
return null;
|
|
91
110
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const data: CloudflareImagesResponse = await response.json();
|
|
102
|
-
return createResponse(data, response.status);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
algorithm,
|
|
114
|
+
encryptionVersion,
|
|
115
|
+
keyId,
|
|
116
|
+
dataIv,
|
|
117
|
+
wrappedKey
|
|
118
|
+
};
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
async function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
121
|
+
function deriveFileKind(contentType: string): string {
|
|
122
|
+
if (contentType.startsWith('image/')) {
|
|
123
|
+
return 'image';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return 'file';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleImageUpload(request: Request, env: Env): Promise<Response> {
|
|
130
|
+
if (!hasValidToken(request, env)) {
|
|
131
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
requireEncryptionUploadConfig(env);
|
|
135
|
+
|
|
136
|
+
const formData = await request.formData();
|
|
137
|
+
const fileValue = formData.get('file');
|
|
138
|
+
if (!(fileValue instanceof Blob)) {
|
|
139
|
+
return createJsonResponse({ error: 'Missing file upload payload' }, 400);
|
|
140
|
+
}
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
142
|
+
const fileBlob = fileValue;
|
|
143
|
+
const uploadedAt = new Date().toISOString();
|
|
144
|
+
const filename = fileValue instanceof File && fileValue.name ? fileValue.name : 'upload.bin';
|
|
145
|
+
const contentType = fileBlob.type || 'application/octet-stream';
|
|
146
|
+
const fileId = crypto.randomUUID().replace(/-/g, '');
|
|
147
|
+
const plaintextBytes = await fileBlob.arrayBuffer();
|
|
127
148
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
const encryptedPayload = await encryptBinaryForStorage(
|
|
150
|
+
plaintextBytes,
|
|
151
|
+
env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
|
|
152
|
+
env.DATA_AT_REST_ENCRYPTION_KEY_ID
|
|
153
|
+
);
|
|
131
154
|
|
|
132
|
-
|
|
133
|
-
|
|
155
|
+
await env.STRIAE_FILES.put(fileId, encryptedPayload.ciphertext, {
|
|
156
|
+
customMetadata: {
|
|
157
|
+
algorithm: encryptedPayload.envelope.algorithm,
|
|
158
|
+
encryptionVersion: encryptedPayload.envelope.encryptionVersion,
|
|
159
|
+
keyId: encryptedPayload.envelope.keyId,
|
|
160
|
+
dataIv: encryptedPayload.envelope.dataIv,
|
|
161
|
+
wrappedKey: encryptedPayload.envelope.wrappedKey,
|
|
162
|
+
contentType,
|
|
163
|
+
originalFilename: filename,
|
|
164
|
+
byteLength: String(fileBlob.size),
|
|
165
|
+
createdAt: uploadedAt,
|
|
166
|
+
fileKind: deriveFileKind(contentType)
|
|
167
|
+
}
|
|
168
|
+
});
|
|
134
169
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
return createJsonResponse({
|
|
171
|
+
success: true,
|
|
172
|
+
errors: [],
|
|
173
|
+
messages: [],
|
|
174
|
+
result: {
|
|
175
|
+
id: fileId,
|
|
176
|
+
filename,
|
|
177
|
+
uploaded: uploadedAt,
|
|
178
|
+
requireSignedURLs: false,
|
|
179
|
+
variants: []
|
|
180
|
+
}
|
|
138
181
|
});
|
|
139
182
|
}
|
|
140
183
|
|
|
141
|
-
async function
|
|
184
|
+
async function handleImageDelete(request: Request, env: Env): Promise<Response> {
|
|
142
185
|
if (!hasValidToken(request, env)) {
|
|
143
|
-
return
|
|
186
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fileId = parseFileId(new URL(request.url).pathname);
|
|
190
|
+
if (!fileId) {
|
|
191
|
+
return createJsonResponse({ error: 'Image ID is required' }, 400);
|
|
144
192
|
}
|
|
145
193
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return createResponse({ error: 'Image delivery URL is required' }, 400);
|
|
194
|
+
const existing = await env.STRIAE_FILES.head(fileId);
|
|
195
|
+
if (!existing) {
|
|
196
|
+
return createJsonResponse({ error: 'File not found' }, 404);
|
|
150
197
|
}
|
|
151
198
|
|
|
152
|
-
|
|
199
|
+
await env.STRIAE_FILES.delete(fileId);
|
|
200
|
+
return createJsonResponse({ success: true });
|
|
201
|
+
}
|
|
153
202
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return createResponse({ error: 'Image delivery URL must be URL-encoded' }, 400);
|
|
203
|
+
async function handleImageServing(request: Request, env: Env): Promise<Response> {
|
|
204
|
+
if (!hasValidToken(request, env)) {
|
|
205
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
158
206
|
}
|
|
159
207
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
208
|
+
requireEncryptionRetrievalConfig(env);
|
|
209
|
+
|
|
210
|
+
const fileId = parseFileId(new URL(request.url).pathname);
|
|
211
|
+
if (!fileId) {
|
|
212
|
+
return createJsonResponse({ error: 'Image ID is required' }, 400);
|
|
165
213
|
}
|
|
166
214
|
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
const file = await env.STRIAE_FILES.get(fileId);
|
|
216
|
+
if (!file) {
|
|
217
|
+
return createJsonResponse({ error: 'File not found' }, 404);
|
|
169
218
|
}
|
|
170
|
-
|
|
171
|
-
|
|
219
|
+
|
|
220
|
+
const envelope = extractEnvelope(file);
|
|
221
|
+
if (!envelope) {
|
|
222
|
+
return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const encryptedData = await file.arrayBuffer();
|
|
226
|
+
const plaintext = await decryptBinaryFromStorage(
|
|
227
|
+
encryptedData,
|
|
228
|
+
envelope,
|
|
229
|
+
env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const contentType = file.customMetadata?.contentType || 'application/octet-stream';
|
|
233
|
+
const filename = file.customMetadata?.originalFilename || fileId;
|
|
234
|
+
|
|
235
|
+
return new Response(plaintext, {
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: {
|
|
238
|
+
...corsHeaders,
|
|
239
|
+
'Cache-Control': 'no-store',
|
|
240
|
+
'Content-Type': contentType,
|
|
241
|
+
'Content-Disposition': `inline; filename="${filename.replace(/"/g, '')}"`
|
|
242
|
+
}
|
|
243
|
+
});
|
|
172
244
|
}
|
|
173
245
|
|
|
174
|
-
/**
|
|
175
|
-
* Main worker functions
|
|
176
|
-
*/
|
|
177
246
|
export default {
|
|
178
247
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
179
248
|
if (request.method === 'OPTIONS') {
|
|
@@ -189,11 +258,11 @@ export default {
|
|
|
189
258
|
case 'DELETE':
|
|
190
259
|
return handleImageDelete(request, env);
|
|
191
260
|
default:
|
|
192
|
-
return
|
|
261
|
+
return createJsonResponse({ error: 'Method not allowed' }, 405);
|
|
193
262
|
}
|
|
194
263
|
} catch (error) {
|
|
195
264
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
196
|
-
return
|
|
265
|
+
return createJsonResponse({ error: errorMessage }, 500);
|
|
197
266
|
}
|
|
198
267
|
}
|
|
199
268
|
};
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"deploy": "wrangler deploy",
|
|
7
7
|
"dev": "wrangler dev",
|
|
8
|
-
"start": "wrangler dev"
|
|
9
|
-
"test": "vitest"
|
|
8
|
+
"start": "wrangler dev"
|
|
10
9
|
},
|
|
11
10
|
"devDependencies": {
|
|
12
11
|
"@cloudflare/puppeteer": "^1.0.6",
|
|
13
|
-
"@cloudflare/vitest-pool-workers": "^0.13.0",
|
|
14
|
-
"vitest": "~4.1.0",
|
|
15
12
|
"wrangler": "^4.76.0"
|
|
16
13
|
},
|
|
17
14
|
"overrides": {
|
|
@@ -6,13 +6,10 @@
|
|
|
6
6
|
"generate:assets": "node scripts/generate-assets.js",
|
|
7
7
|
"deploy": "wrangler deploy",
|
|
8
8
|
"dev": "wrangler dev",
|
|
9
|
-
"start": "wrangler dev"
|
|
10
|
-
"test": "vitest"
|
|
9
|
+
"start": "wrangler dev"
|
|
11
10
|
},
|
|
12
11
|
"devDependencies": {
|
|
13
12
|
"@cloudflare/puppeteer": "^1.0.6",
|
|
14
|
-
"@cloudflare/vitest-pool-workers": "^0.13.0",
|
|
15
|
-
"vitest": "~4.1.0",
|
|
16
13
|
"wrangler": "^4.76.0"
|
|
17
14
|
},
|
|
18
15
|
"overrides": {
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"deploy": "wrangler deploy",
|
|
7
7
|
"dev": "wrangler dev",
|
|
8
|
-
"start": "wrangler dev"
|
|
9
|
-
"test": "vitest"
|
|
8
|
+
"start": "wrangler dev"
|
|
10
9
|
},
|
|
11
10
|
"devDependencies": {
|
|
12
11
|
"@cloudflare/puppeteer": "^1.0.6",
|
|
13
|
-
"@cloudflare/vitest-pool-workers": "^0.13.0",
|
|
14
|
-
"vitest": "~4.1.0",
|
|
15
12
|
"wrangler": "^4.76.0"
|
|
16
13
|
},
|
|
17
14
|
"overrides": {
|