@striae-org/striae 3.2.2 → 4.0.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 (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -0,0 +1,255 @@
1
+ import firebaseConfig from '../../../app/config/firebase';
2
+
3
+ interface FirebaseJwtHeader {
4
+ alg?: string;
5
+ kid?: string;
6
+ typ?: string;
7
+ }
8
+
9
+ interface FirebaseJwtPayload {
10
+ aud?: string;
11
+ iss?: string;
12
+ sub?: string;
13
+ user_id?: string;
14
+ email?: string;
15
+ email_verified?: boolean;
16
+ iat?: number;
17
+ exp?: number;
18
+ }
19
+
20
+ interface GoogleJwksResponse {
21
+ keys?: Array<JsonWebKey & { kid?: string; kty?: string }>;
22
+ }
23
+
24
+ export interface VerifiedFirebaseIdentity {
25
+ uid: string;
26
+ email: string | null;
27
+ emailVerified: boolean;
28
+ }
29
+
30
+ const GOOGLE_SECURETOKEN_JWKS_URL =
31
+ 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
32
+ const DEFAULT_JWKS_CACHE_SECONDS = 300;
33
+ const CLOCK_SKEW_SECONDS = 300;
34
+ const FALLBACK_PROJECT_ID =
35
+ typeof firebaseConfig.projectId === 'string' ? firebaseConfig.projectId.trim() : '';
36
+
37
+ const textEncoder = new TextEncoder();
38
+ const textDecoder = new TextDecoder();
39
+
40
+ let cachedJwks: GoogleJwksResponse | null = null;
41
+ let cachedJwksExpiresAt = 0;
42
+
43
+ function base64UrlToBytes(value: string): Uint8Array {
44
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
45
+ const paddingLength = normalized.length % 4 === 0 ? 0 : 4 - (normalized.length % 4);
46
+ const padded = normalized + '='.repeat(paddingLength);
47
+ const binary = atob(padded);
48
+
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let index = 0; index < binary.length; index += 1) {
51
+ bytes[index] = binary.charCodeAt(index);
52
+ }
53
+
54
+ return bytes;
55
+ }
56
+
57
+ function decodeJsonSegment<T>(segment: string): T | null {
58
+ try {
59
+ const decoded = textDecoder.decode(base64UrlToBytes(segment));
60
+ return JSON.parse(decoded) as T;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function resolveJwksCacheSeconds(cacheControl: string | null): number {
67
+ if (!cacheControl) {
68
+ return DEFAULT_JWKS_CACHE_SECONDS;
69
+ }
70
+
71
+ const maxAgeMatch = cacheControl.match(/max-age=(\d+)/i);
72
+ if (!maxAgeMatch) {
73
+ return DEFAULT_JWKS_CACHE_SECONDS;
74
+ }
75
+
76
+ const parsed = Number.parseInt(maxAgeMatch[1], 10);
77
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_JWKS_CACHE_SECONDS;
78
+ }
79
+
80
+ async function getSecureTokenJwks(): Promise<GoogleJwksResponse | null> {
81
+ if (cachedJwks && Date.now() < cachedJwksExpiresAt) {
82
+ return cachedJwks;
83
+ }
84
+
85
+ let response: Response;
86
+ try {
87
+ response = await fetch(GOOGLE_SECURETOKEN_JWKS_URL, { method: 'GET' });
88
+ } catch {
89
+ return null;
90
+ }
91
+
92
+ if (!response.ok) {
93
+ return null;
94
+ }
95
+
96
+ const payload = await response.json().catch(() => null) as GoogleJwksResponse | null;
97
+ if (!payload?.keys || !Array.isArray(payload.keys) || payload.keys.length === 0) {
98
+ return null;
99
+ }
100
+
101
+ const cacheSeconds = resolveJwksCacheSeconds(response.headers.get('Cache-Control'));
102
+ cachedJwks = payload;
103
+ cachedJwksExpiresAt = Date.now() + cacheSeconds * 1000;
104
+
105
+ return payload;
106
+ }
107
+
108
+ async function verifyTokenSignature(
109
+ headerSegment: string,
110
+ payloadSegment: string,
111
+ signatureSegment: string,
112
+ jwtHeader: FirebaseJwtHeader
113
+ ): Promise<boolean> {
114
+ if (jwtHeader.alg !== 'RS256' || typeof jwtHeader.kid !== 'string' || jwtHeader.kid.length === 0) {
115
+ return false;
116
+ }
117
+
118
+ const jwks = await getSecureTokenJwks();
119
+ const verificationJwk = jwks?.keys?.find(
120
+ (candidate) => candidate.kid === jwtHeader.kid && candidate.kty === 'RSA'
121
+ );
122
+ if (!verificationJwk) {
123
+ return false;
124
+ }
125
+
126
+ let cryptoKey: CryptoKey;
127
+ try {
128
+ cryptoKey = await crypto.subtle.importKey(
129
+ 'jwk',
130
+ verificationJwk,
131
+ {
132
+ name: 'RSASSA-PKCS1-v1_5',
133
+ hash: 'SHA-256'
134
+ },
135
+ false,
136
+ ['verify']
137
+ );
138
+ } catch {
139
+ return false;
140
+ }
141
+
142
+ const signedContent = new Uint8Array(textEncoder.encode(`${headerSegment}.${payloadSegment}`));
143
+ const signatureBytes = new Uint8Array(base64UrlToBytes(signatureSegment));
144
+
145
+ try {
146
+ return await crypto.subtle.verify(
147
+ { name: 'RSASSA-PKCS1-v1_5' },
148
+ cryptoKey,
149
+ signatureBytes,
150
+ signedContent
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function validateTokenClaims(payload: FirebaseJwtPayload, env: Env): boolean {
158
+ const configuredProjectId = typeof env.PROJECT_ID === 'string' ? env.PROJECT_ID.trim() : '';
159
+ const allowedProjectIds = new Set([configuredProjectId, FALLBACK_PROJECT_ID].filter(Boolean));
160
+ if (allowedProjectIds.size === 0) {
161
+ return false;
162
+ }
163
+
164
+ if (typeof payload.aud !== 'string' || !allowedProjectIds.has(payload.aud)) {
165
+ return false;
166
+ }
167
+
168
+ const nowSeconds = Math.floor(Date.now() / 1000);
169
+ const expectedIssuer = `https://securetoken.google.com/${payload.aud}`;
170
+
171
+ if (payload.iss !== expectedIssuer) {
172
+ return false;
173
+ }
174
+
175
+ if (typeof payload.sub !== 'string' || payload.sub.trim().length === 0) {
176
+ return false;
177
+ }
178
+
179
+ if (typeof payload.exp !== 'number' || payload.exp < nowSeconds - CLOCK_SKEW_SECONDS) {
180
+ return false;
181
+ }
182
+
183
+ if (typeof payload.iat !== 'number' || payload.iat > nowSeconds + CLOCK_SKEW_SECONDS) {
184
+ return false;
185
+ }
186
+
187
+ return true;
188
+ }
189
+
190
+ function extractBearerToken(request: Request): string | null {
191
+ const authorizationHeader = request.headers.get('Authorization');
192
+ if (!authorizationHeader) {
193
+ return null;
194
+ }
195
+
196
+ const [scheme, token] = authorizationHeader.split(' ');
197
+ if (scheme !== 'Bearer' || !token) {
198
+ return null;
199
+ }
200
+
201
+ const normalizedToken = token.trim();
202
+ return normalizedToken.length > 0 ? normalizedToken : null;
203
+ }
204
+
205
+ export async function verifyFirebaseIdentityFromRequest(
206
+ request: Request,
207
+ env: Env
208
+ ): Promise<VerifiedFirebaseIdentity | null> {
209
+ const idToken = extractBearerToken(request);
210
+ if (!idToken) {
211
+ return null;
212
+ }
213
+
214
+ const tokenSegments = idToken.split('.');
215
+ if (tokenSegments.length !== 3) {
216
+ return null;
217
+ }
218
+
219
+ const [headerSegment, payloadSegment, signatureSegment] = tokenSegments;
220
+ const jwtHeader = decodeJsonSegment<FirebaseJwtHeader>(headerSegment);
221
+ const jwtPayload = decodeJsonSegment<FirebaseJwtPayload>(payloadSegment);
222
+
223
+ if (!jwtHeader || !jwtPayload) {
224
+ return null;
225
+ }
226
+
227
+ const signatureValid = await verifyTokenSignature(
228
+ headerSegment,
229
+ payloadSegment,
230
+ signatureSegment,
231
+ jwtHeader
232
+ );
233
+ if (!signatureValid) {
234
+ return null;
235
+ }
236
+
237
+ if (!validateTokenClaims(jwtPayload, env)) {
238
+ return null;
239
+ }
240
+
241
+ const uid =
242
+ (typeof jwtPayload.user_id === 'string' && jwtPayload.user_id.trim().length > 0
243
+ ? jwtPayload.user_id
244
+ : jwtPayload.sub) ||
245
+ null;
246
+ if (!uid) {
247
+ return null;
248
+ }
249
+
250
+ return {
251
+ uid,
252
+ email: typeof jwtPayload.email === 'string' ? jwtPayload.email : null,
253
+ emailVerified: jwtPayload.email_verified === true
254
+ };
255
+ }
@@ -0,0 +1,150 @@
1
+ import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+
3
+ interface AuditProxyContext {
4
+ request: Request;
5
+ env: Env;
6
+ }
7
+
8
+ const SUPPORTED_METHODS = new Set(['GET', 'POST', 'OPTIONS']);
9
+ const AUDIT_PATH_PREFIX = '/audit/';
10
+
11
+ function textResponse(message: string, status: number): Response {
12
+ return new Response(message, {
13
+ status,
14
+ headers: {
15
+ 'Cache-Control': 'no-store',
16
+ 'Content-Type': 'text/plain; charset=utf-8'
17
+ }
18
+ });
19
+ }
20
+
21
+ function normalizeWorkerBaseUrl(workerDomain: string): string {
22
+ if (typeof workerDomain !== 'string' || workerDomain.trim().length === 0) {
23
+ throw new Error('Invalid worker domain');
24
+ }
25
+
26
+ const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
27
+ if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
28
+ return trimmedDomain;
29
+ }
30
+
31
+ return `https://${trimmedDomain}`;
32
+ }
33
+
34
+ function extractProxyPath(url: URL): string | null {
35
+ const routePrefix = '/api/audit';
36
+ if (!url.pathname.startsWith(routePrefix)) {
37
+ return null;
38
+ }
39
+
40
+ const remainder = url.pathname.slice(routePrefix.length);
41
+ return remainder.length > 0 ? remainder : '/';
42
+ }
43
+
44
+ function extractRequestedUserId(url: URL): string | null {
45
+ const userId = url.searchParams.get('userId');
46
+ if (!userId) {
47
+ return null;
48
+ }
49
+
50
+ const normalizedUserId = userId.trim();
51
+ return normalizedUserId.length > 0 ? normalizedUserId : null;
52
+ }
53
+
54
+ export const onRequest = async ({ request, env }: AuditProxyContext): Promise<Response> => {
55
+ if (!SUPPORTED_METHODS.has(request.method)) {
56
+ return textResponse('Method not allowed', 405);
57
+ }
58
+
59
+ if (request.method === 'OPTIONS') {
60
+ return new Response(null, {
61
+ status: 204,
62
+ headers: {
63
+ 'Allow': 'GET, POST, OPTIONS',
64
+ 'Cache-Control': 'no-store'
65
+ }
66
+ });
67
+ }
68
+
69
+ const identity = await verifyFirebaseIdentityFromRequest(request, env);
70
+ if (!identity) {
71
+ return textResponse('Unauthorized', 401);
72
+ }
73
+
74
+ const requestUrl = new URL(request.url);
75
+ const proxyPath = extractProxyPath(requestUrl);
76
+ if (!proxyPath || !proxyPath.startsWith(AUDIT_PATH_PREFIX)) {
77
+ return textResponse('Not Found', 404);
78
+ }
79
+
80
+ const requestedUserId = extractRequestedUserId(requestUrl);
81
+ if (!requestedUserId) {
82
+ return textResponse('Missing user identifier', 400);
83
+ }
84
+
85
+ if (requestedUserId !== identity.uid) {
86
+ return textResponse('Forbidden', 403);
87
+ }
88
+
89
+ if (!env.AUDIT_WORKER_DOMAIN || !env.R2_KEY_SECRET) {
90
+ return textResponse('Audit service not configured', 502);
91
+ }
92
+
93
+ const auditWorkerBaseUrl = normalizeWorkerBaseUrl(env.AUDIT_WORKER_DOMAIN);
94
+ const upstreamUrl = `${auditWorkerBaseUrl}${proxyPath}${requestUrl.search}`;
95
+
96
+ let bodyToForward: BodyInit | undefined;
97
+ if (request.method === 'POST') {
98
+ const payload = await request.json().catch(() => null) as Record<string, unknown> | null;
99
+ if (!payload || typeof payload !== 'object') {
100
+ return textResponse('Invalid audit payload', 400);
101
+ }
102
+
103
+ const payloadUserId = typeof payload.userId === 'string' ? payload.userId.trim() : '';
104
+ if (payloadUserId.length > 0 && payloadUserId !== identity.uid) {
105
+ return textResponse('Forbidden', 403);
106
+ }
107
+
108
+ payload.userId = identity.uid;
109
+ bodyToForward = JSON.stringify(payload);
110
+ }
111
+
112
+ const upstreamHeaders = new Headers();
113
+ const contentTypeHeader = request.headers.get('Content-Type');
114
+ if (contentTypeHeader) {
115
+ upstreamHeaders.set('Content-Type', contentTypeHeader);
116
+ }
117
+
118
+ if (request.method === 'POST' && !upstreamHeaders.has('Content-Type')) {
119
+ upstreamHeaders.set('Content-Type', 'application/json');
120
+ }
121
+
122
+ const acceptHeader = request.headers.get('Accept');
123
+ if (acceptHeader) {
124
+ upstreamHeaders.set('Accept', acceptHeader);
125
+ }
126
+
127
+ upstreamHeaders.set('X-Custom-Auth-Key', env.R2_KEY_SECRET);
128
+
129
+ let upstreamResponse: Response;
130
+ try {
131
+ upstreamResponse = await fetch(upstreamUrl, {
132
+ method: request.method,
133
+ headers: upstreamHeaders,
134
+ body: bodyToForward
135
+ });
136
+ } catch {
137
+ return textResponse('Upstream audit service unavailable', 502);
138
+ }
139
+
140
+ const responseHeaders = new Headers(upstreamResponse.headers);
141
+ if (!responseHeaders.has('Cache-Control')) {
142
+ responseHeaders.set('Cache-Control', 'no-store');
143
+ }
144
+
145
+ return new Response(upstreamResponse.body, {
146
+ status: upstreamResponse.status,
147
+ statusText: upstreamResponse.statusText,
148
+ headers: responseHeaders
149
+ });
150
+ };
@@ -0,0 +1,141 @@
1
+ import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+
3
+ interface DataProxyContext {
4
+ request: Request;
5
+ env: Env;
6
+ }
7
+
8
+ const SUPPORTED_METHODS = new Set(['GET', 'PUT', 'DELETE', 'POST', 'OPTIONS']);
9
+ const UNSCOPED_PATH_PREFIXES = ['/api/forensic/'];
10
+
11
+ function textResponse(message: string, status: number): Response {
12
+ return new Response(message, {
13
+ status,
14
+ headers: {
15
+ 'Cache-Control': 'no-store',
16
+ 'Content-Type': 'text/plain; charset=utf-8'
17
+ }
18
+ });
19
+ }
20
+
21
+ function normalizeWorkerBaseUrl(workerDomain: string): string {
22
+ if (typeof workerDomain !== 'string' || workerDomain.trim().length === 0) {
23
+ throw new Error('Invalid worker domain');
24
+ }
25
+
26
+ const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
27
+ if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
28
+ return trimmedDomain;
29
+ }
30
+
31
+ return `https://${trimmedDomain}`;
32
+ }
33
+
34
+ function extractProxyPath(url: URL): string | null {
35
+ const routePrefix = '/api/data';
36
+ if (!url.pathname.startsWith(routePrefix)) {
37
+ return null;
38
+ }
39
+
40
+ const remainder = url.pathname.slice(routePrefix.length);
41
+ return remainder.length > 0 ? remainder : '/';
42
+ }
43
+
44
+ function extractUserIdFromProxyPath(proxyPath: string): string | null {
45
+ const firstSegment = proxyPath.split('/').filter(Boolean)[0];
46
+ if (!firstSegment) {
47
+ return null;
48
+ }
49
+
50
+ try {
51
+ return decodeURIComponent(firstSegment);
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function isUnscopedProxyPath(proxyPath: string): boolean {
58
+ return UNSCOPED_PATH_PREFIXES.some((prefix) => proxyPath.startsWith(prefix));
59
+ }
60
+
61
+ export const onRequest = async ({ request, env }: DataProxyContext): Promise<Response> => {
62
+ if (!SUPPORTED_METHODS.has(request.method)) {
63
+ return textResponse('Method not allowed', 405);
64
+ }
65
+
66
+ if (request.method === 'OPTIONS') {
67
+ return new Response(null, {
68
+ status: 204,
69
+ headers: {
70
+ 'Allow': 'GET, PUT, DELETE, POST, OPTIONS',
71
+ 'Cache-Control': 'no-store'
72
+ }
73
+ });
74
+ }
75
+
76
+ const identity = await verifyFirebaseIdentityFromRequest(request, env);
77
+ if (!identity) {
78
+ return textResponse('Unauthorized', 401);
79
+ }
80
+
81
+ const requestUrl = new URL(request.url);
82
+ const proxyPath = extractProxyPath(requestUrl);
83
+ if (!proxyPath) {
84
+ return textResponse('Not Found', 404);
85
+ }
86
+
87
+ if (!isUnscopedProxyPath(proxyPath)) {
88
+ const requestedUserId = extractUserIdFromProxyPath(proxyPath);
89
+ if (!requestedUserId) {
90
+ return textResponse('Missing user identifier', 400);
91
+ }
92
+
93
+ if (requestedUserId !== identity.uid) {
94
+ return textResponse('Forbidden', 403);
95
+ }
96
+ }
97
+
98
+ if (!env.DATA_WORKER_DOMAIN || !env.R2_KEY_SECRET) {
99
+ return textResponse('Data service not configured', 502);
100
+ }
101
+
102
+ const dataWorkerBaseUrl = normalizeWorkerBaseUrl(env.DATA_WORKER_DOMAIN);
103
+ const upstreamUrl = `${dataWorkerBaseUrl}${proxyPath}${requestUrl.search}`;
104
+
105
+ const upstreamHeaders = new Headers();
106
+ const contentTypeHeader = request.headers.get('Content-Type');
107
+ if (contentTypeHeader) {
108
+ upstreamHeaders.set('Content-Type', contentTypeHeader);
109
+ }
110
+
111
+ const acceptHeader = request.headers.get('Accept');
112
+ if (acceptHeader) {
113
+ upstreamHeaders.set('Accept', acceptHeader);
114
+ }
115
+
116
+ upstreamHeaders.set('X-Custom-Auth-Key', env.R2_KEY_SECRET);
117
+
118
+ const shouldForwardBody = request.method !== 'GET' && request.method !== 'HEAD';
119
+
120
+ let upstreamResponse: Response;
121
+ try {
122
+ upstreamResponse = await fetch(upstreamUrl, {
123
+ method: request.method,
124
+ headers: upstreamHeaders,
125
+ body: shouldForwardBody ? request.body : undefined
126
+ });
127
+ } catch {
128
+ return textResponse('Upstream data service unavailable', 502);
129
+ }
130
+
131
+ const responseHeaders = new Headers(upstreamResponse.headers);
132
+ if (!responseHeaders.has('Cache-Control')) {
133
+ responseHeaders.set('Cache-Control', 'no-store');
134
+ }
135
+
136
+ return new Response(upstreamResponse.body, {
137
+ status: upstreamResponse.status,
138
+ statusText: upstreamResponse.statusText,
139
+ headers: responseHeaders
140
+ });
141
+ };
@@ -0,0 +1,127 @@
1
+ import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+
3
+ interface ImageProxyContext {
4
+ request: Request;
5
+ env: Env;
6
+ }
7
+
8
+ const SUPPORTED_METHODS = new Set(['GET', 'POST', 'DELETE', 'OPTIONS']);
9
+
10
+ function textResponse(message: string, status: number): Response {
11
+ return new Response(message, {
12
+ status,
13
+ headers: {
14
+ 'Cache-Control': 'no-store',
15
+ 'Content-Type': 'text/plain; charset=utf-8'
16
+ }
17
+ });
18
+ }
19
+
20
+ function normalizeWorkerBaseUrl(workerDomain: string): string {
21
+ if (typeof workerDomain !== 'string' || workerDomain.trim().length === 0) {
22
+ throw new Error('Invalid worker domain');
23
+ }
24
+
25
+ const trimmedDomain = workerDomain.trim().replace(/\/+$/, '');
26
+ if (trimmedDomain.startsWith('http://') || trimmedDomain.startsWith('https://')) {
27
+ return trimmedDomain;
28
+ }
29
+
30
+ return `https://${trimmedDomain}`;
31
+ }
32
+
33
+ function extractProxyPath(url: URL): string | null {
34
+ const routePrefix = '/api/image';
35
+ if (!url.pathname.startsWith(routePrefix)) {
36
+ return null;
37
+ }
38
+
39
+ const remainder = url.pathname.slice(routePrefix.length);
40
+ return remainder.length > 0 ? remainder : '/';
41
+ }
42
+
43
+ function resolveImageWorkerToken(env: Env): string {
44
+ const imageToken = typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
45
+ if (imageToken.length > 0) {
46
+ return imageToken;
47
+ }
48
+
49
+ const apiToken = typeof env.API_TOKEN === 'string' ? env.API_TOKEN.trim() : '';
50
+ if (apiToken.length > 0) {
51
+ return apiToken;
52
+ }
53
+
54
+ return '';
55
+ }
56
+
57
+ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
58
+ if (!SUPPORTED_METHODS.has(request.method)) {
59
+ return textResponse('Method not allowed', 405);
60
+ }
61
+
62
+ if (request.method === 'OPTIONS') {
63
+ return new Response(null, {
64
+ status: 204,
65
+ headers: {
66
+ 'Allow': 'GET, POST, DELETE, OPTIONS',
67
+ 'Cache-Control': 'no-store'
68
+ }
69
+ });
70
+ }
71
+
72
+ const identity = await verifyFirebaseIdentityFromRequest(request, env);
73
+ if (!identity) {
74
+ return textResponse('Unauthorized', 401);
75
+ }
76
+
77
+ const requestUrl = new URL(request.url);
78
+ const proxyPath = extractProxyPath(requestUrl);
79
+ if (!proxyPath) {
80
+ return textResponse('Not Found', 404);
81
+ }
82
+
83
+ const imageWorkerToken = resolveImageWorkerToken(env);
84
+ if (!env.IMAGES_WORKER_DOMAIN || !imageWorkerToken) {
85
+ return textResponse('Image service not configured', 502);
86
+ }
87
+
88
+ const imageWorkerBaseUrl = normalizeWorkerBaseUrl(env.IMAGES_WORKER_DOMAIN);
89
+ const upstreamUrl = `${imageWorkerBaseUrl}${proxyPath}${requestUrl.search}`;
90
+
91
+ const upstreamHeaders = new Headers();
92
+ const contentTypeHeader = request.headers.get('Content-Type');
93
+ if (contentTypeHeader) {
94
+ upstreamHeaders.set('Content-Type', contentTypeHeader);
95
+ }
96
+
97
+ const acceptHeader = request.headers.get('Accept');
98
+ if (acceptHeader) {
99
+ upstreamHeaders.set('Accept', acceptHeader);
100
+ }
101
+
102
+ upstreamHeaders.set('Authorization', `Bearer ${imageWorkerToken}`);
103
+
104
+ const shouldForwardBody = request.method !== 'GET' && request.method !== 'HEAD';
105
+
106
+ let upstreamResponse: Response;
107
+ try {
108
+ upstreamResponse = await fetch(upstreamUrl, {
109
+ method: request.method,
110
+ headers: upstreamHeaders,
111
+ body: shouldForwardBody ? request.body : undefined
112
+ });
113
+ } catch {
114
+ return textResponse('Upstream image service unavailable', 502);
115
+ }
116
+
117
+ const responseHeaders = new Headers(upstreamResponse.headers);
118
+ if (!responseHeaders.has('Cache-Control')) {
119
+ responseHeaders.set('Cache-Control', 'no-store');
120
+ }
121
+
122
+ return new Response(upstreamResponse.body, {
123
+ status: upstreamResponse.status,
124
+ statusText: upstreamResponse.statusText,
125
+ headers: responseHeaders
126
+ });
127
+ };