@nkzw/codiff-service 1.9.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/LICENSE +21 -0
- package/api.test.ts +44 -0
- package/api.ts +707 -0
- package/db.ts +45 -0
- package/dist/api.mjs +403 -0
- package/dist/db.mjs +26 -0
- package/dist/fate.mjs +700 -0
- package/dist/index.mjs +6 -0
- package/dist/react.mjs +62 -0
- package/dist/schema.mjs +219 -0
- package/dist/upload-intent.mjs +48 -0
- package/dist/views.mjs +142 -0
- package/fate.test.ts +21 -0
- package/fate.ts +1258 -0
- package/index.ts +5 -0
- package/package.json +79 -0
- package/react.test.ts +70 -0
- package/react.tsx +151 -0
- package/schema.ts +320 -0
- package/upload-intent.test.ts +24 -0
- package/upload-intent.ts +92 -0
- package/views.ts +269 -0
- package/vite.config.ts +8 -0
package/api.ts
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { parsePlanShareUpload } from '@nkzw/codiff-core/share';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { db, eq, sql, withDatabase, type D1Binding } from './db.ts';
|
|
4
|
+
import { plan, shareDailyUsage, uploadIntent, user, walkthrough, type UserRow } from './schema.ts';
|
|
5
|
+
import {
|
|
6
|
+
createUploadIntentSecret,
|
|
7
|
+
hashUploadIntentSecret,
|
|
8
|
+
verifyUploadIntentSecret,
|
|
9
|
+
} from './upload-intent.ts';
|
|
10
|
+
|
|
11
|
+
export type SharingBucket = {
|
|
12
|
+
delete(key: string): Promise<unknown>;
|
|
13
|
+
get(key: string): Promise<null | { body: ReadableStream; httpEtag?: string }>;
|
|
14
|
+
put(
|
|
15
|
+
key: string,
|
|
16
|
+
value: ArrayBuffer | ReadableStream | string,
|
|
17
|
+
options?: { httpMetadata?: { contentType?: string } },
|
|
18
|
+
): Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SharingEnv = {
|
|
22
|
+
BETTER_AUTH_SECRET?: string;
|
|
23
|
+
DB: D1Binding;
|
|
24
|
+
PUBLIC_ORIGIN?: string;
|
|
25
|
+
WALKTHROUGH_BUCKET: SharingBucket;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SharingApiOptions = {
|
|
29
|
+
auth?: unknown;
|
|
30
|
+
enforceDailyQuota?: boolean;
|
|
31
|
+
onUploadIntentUpdated?: (update: {
|
|
32
|
+
changed: Array<'shareKind' | 'status' | 'walkthroughSlug'>;
|
|
33
|
+
id: string;
|
|
34
|
+
shareKind: 'plan' | 'walkthrough';
|
|
35
|
+
walkthroughSlug: string;
|
|
36
|
+
}) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type BetterAuthApi = {
|
|
40
|
+
api: {
|
|
41
|
+
getSession(options: {
|
|
42
|
+
asResponse?: false;
|
|
43
|
+
headers: Headers;
|
|
44
|
+
returnHeaders?: boolean;
|
|
45
|
+
}): Promise<unknown>;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ShareUser = {
|
|
50
|
+
id: string;
|
|
51
|
+
image: null | string;
|
|
52
|
+
name: string;
|
|
53
|
+
username: null | string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const boundedString = (maximum: number) => z.string().max(maximum);
|
|
57
|
+
const nonEmptyString = (maximum: number) => z.string().min(1).max(maximum);
|
|
58
|
+
const reviewAuthorSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
avatarUrl: boundedString(2048).optional(),
|
|
61
|
+
login: nonEmptyString(200),
|
|
62
|
+
name: nonEmptyString(200).optional(),
|
|
63
|
+
})
|
|
64
|
+
.passthrough();
|
|
65
|
+
const reviewCommentSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
anchor: z.enum(['file', 'line']).optional(),
|
|
68
|
+
author: reviewAuthorSchema,
|
|
69
|
+
body: boundedString(128 * 1024),
|
|
70
|
+
filePath: nonEmptyString(4096),
|
|
71
|
+
id: nonEmptyString(256),
|
|
72
|
+
isThreadResolved: z.boolean().optional(),
|
|
73
|
+
lineNumber: z.number().int().positive().optional(),
|
|
74
|
+
sectionId: nonEmptyString(4096).optional(),
|
|
75
|
+
side: z.enum(['additions', 'deletions']).optional(),
|
|
76
|
+
startLineNumber: z.number().int().positive().optional(),
|
|
77
|
+
startSide: z.enum(['additions', 'deletions']).optional(),
|
|
78
|
+
submittedAt: boundedString(64).optional(),
|
|
79
|
+
threadId: nonEmptyString(256).optional(),
|
|
80
|
+
})
|
|
81
|
+
.passthrough()
|
|
82
|
+
.refine(
|
|
83
|
+
(comment) =>
|
|
84
|
+
comment.anchor === 'file' ||
|
|
85
|
+
(comment.lineNumber != null &&
|
|
86
|
+
comment.side != null &&
|
|
87
|
+
(comment.startLineNumber == null || comment.startSide != null)),
|
|
88
|
+
'Invalid walkthrough review comment.',
|
|
89
|
+
);
|
|
90
|
+
const generalCommentSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
author: reviewAuthorSchema,
|
|
93
|
+
body: boundedString(128 * 1024),
|
|
94
|
+
id: nonEmptyString(256),
|
|
95
|
+
submittedAt: boundedString(64).optional(),
|
|
96
|
+
})
|
|
97
|
+
.passthrough();
|
|
98
|
+
const generalCommentThreadSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
comments: z.array(generalCommentSchema).max(1000),
|
|
101
|
+
id: nonEmptyString(256),
|
|
102
|
+
isResolved: z.boolean().optional(),
|
|
103
|
+
})
|
|
104
|
+
.passthrough();
|
|
105
|
+
const walkthroughFileSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
path: nonEmptyString(4096),
|
|
108
|
+
})
|
|
109
|
+
.passthrough();
|
|
110
|
+
const walkthroughSourceSchema = z
|
|
111
|
+
.object({
|
|
112
|
+
host: boundedString(512).optional(),
|
|
113
|
+
number: z.number().int().positive().optional(),
|
|
114
|
+
projectPath: boundedString(4096).optional(),
|
|
115
|
+
title: boundedString(300).optional(),
|
|
116
|
+
type: nonEmptyString(64),
|
|
117
|
+
url: boundedString(4096).optional(),
|
|
118
|
+
})
|
|
119
|
+
.passthrough();
|
|
120
|
+
const walkthroughManifestSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
branch: boundedString(1024).optional(),
|
|
123
|
+
codiffVersion: nonEmptyString(100),
|
|
124
|
+
exportedAt: boundedString(64),
|
|
125
|
+
files: z.array(walkthroughFileSchema).max(100_000),
|
|
126
|
+
kind: z.literal('codiff-walkthrough-share'),
|
|
127
|
+
repository: z
|
|
128
|
+
.object({
|
|
129
|
+
generalComments: z.array(generalCommentThreadSchema).max(1000).optional(),
|
|
130
|
+
source: walkthroughSourceSchema.optional(),
|
|
131
|
+
title: boundedString(300).optional(),
|
|
132
|
+
})
|
|
133
|
+
.passthrough(),
|
|
134
|
+
reviewComments: z.array(reviewCommentSchema).max(10_000).optional(),
|
|
135
|
+
version: z.literal(1),
|
|
136
|
+
walkthrough: z
|
|
137
|
+
.object({
|
|
138
|
+
summary: boundedString(128 * 1024).optional(),
|
|
139
|
+
title: nonEmptyString(300),
|
|
140
|
+
})
|
|
141
|
+
.passthrough(),
|
|
142
|
+
})
|
|
143
|
+
.passthrough();
|
|
144
|
+
const uploaderSchema = z
|
|
145
|
+
.object({
|
|
146
|
+
email: boundedString(320).optional(),
|
|
147
|
+
name: boundedString(200).optional(),
|
|
148
|
+
})
|
|
149
|
+
.passthrough();
|
|
150
|
+
const walkthroughUploadSchema = z
|
|
151
|
+
.object({
|
|
152
|
+
snapshot: walkthroughManifestSchema,
|
|
153
|
+
uploader: uploaderSchema.optional(),
|
|
154
|
+
})
|
|
155
|
+
.passthrough();
|
|
156
|
+
|
|
157
|
+
type WalkthroughManifest = z.infer<typeof walkthroughManifestSchema>;
|
|
158
|
+
|
|
159
|
+
type ParsedUpload =
|
|
160
|
+
| {
|
|
161
|
+
kind: 'plan';
|
|
162
|
+
sharedByEmail: null | string;
|
|
163
|
+
sharedByName: null | string;
|
|
164
|
+
snapshot: ReturnType<typeof parsePlanShareUpload>['snapshot'];
|
|
165
|
+
}
|
|
166
|
+
| {
|
|
167
|
+
kind: 'walkthrough';
|
|
168
|
+
sharedByEmail: null | string;
|
|
169
|
+
sharedByName: null | string;
|
|
170
|
+
snapshot: WalkthroughManifest;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const json = (value: unknown, init?: ResponseInit) =>
|
|
174
|
+
new Response(JSON.stringify(value), {
|
|
175
|
+
...init,
|
|
176
|
+
headers: {
|
|
177
|
+
'content-type': 'application/json; charset=utf-8',
|
|
178
|
+
...(init?.headers ?? {}),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const notFound = () => json({ error: 'not-found' }, { status: 404 });
|
|
183
|
+
const badRequest = (message: string) => json({ error: message }, { status: 400 });
|
|
184
|
+
const unauthorized = () => json({ error: 'authentication-required' }, { status: 401 });
|
|
185
|
+
const maxUploadByteSize = 25 * 1024 * 1024;
|
|
186
|
+
const textEncoder = new TextEncoder();
|
|
187
|
+
|
|
188
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
189
|
+
value !== null && typeof value === 'object';
|
|
190
|
+
|
|
191
|
+
const isBetterAuthApi = (value: unknown): value is BetterAuthApi =>
|
|
192
|
+
isRecord(value) && isRecord(value.api) && typeof value.api.getSession === 'function';
|
|
193
|
+
|
|
194
|
+
const stringValue = (value: unknown) => (typeof value === 'string' && value ? value : null);
|
|
195
|
+
|
|
196
|
+
export const shareUserFromAuthValue = (value: unknown): ShareUser | null => {
|
|
197
|
+
const result = isRecord(value) && isRecord(value.response) ? value.response : value;
|
|
198
|
+
const userValue = isRecord(result) ? result.user : null;
|
|
199
|
+
if (!isRecord(userValue)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const id = stringValue(userValue.id);
|
|
204
|
+
const name = stringValue(userValue.name) ?? stringValue(userValue.username);
|
|
205
|
+
if (!id || !name) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
id,
|
|
211
|
+
image: stringValue(userValue.image),
|
|
212
|
+
name,
|
|
213
|
+
username: stringValue(userValue.displayUsername) ?? stringValue(userValue.username),
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const shareUserFromRow = (row: UserRow | null | undefined): ShareUser | null =>
|
|
218
|
+
row
|
|
219
|
+
? {
|
|
220
|
+
id: row.id,
|
|
221
|
+
image: row.image,
|
|
222
|
+
name: row.name,
|
|
223
|
+
username: row.displayUsername ?? row.username,
|
|
224
|
+
}
|
|
225
|
+
: null;
|
|
226
|
+
|
|
227
|
+
export const getAuthenticatedShareUser = async (
|
|
228
|
+
request: Request,
|
|
229
|
+
auth: SharingApiOptions['auth'],
|
|
230
|
+
): Promise<ShareUser | null> => {
|
|
231
|
+
if (!isBetterAuthApi(auth)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return shareUserFromAuthValue(
|
|
236
|
+
await auth.api.getSession({
|
|
237
|
+
asResponse: false,
|
|
238
|
+
headers: request.headers,
|
|
239
|
+
returnHeaders: true,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const getShareUserById = async (userId: null | string) => {
|
|
248
|
+
if (!userId) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const [record] = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
|
252
|
+
return shareUserFromRow(record);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const randomToken = (bytes = 24) => {
|
|
256
|
+
const value = new Uint8Array(bytes);
|
|
257
|
+
crypto.getRandomValues(value);
|
|
258
|
+
return btoa(String.fromCharCode(...value))
|
|
259
|
+
.replaceAll('+', '-')
|
|
260
|
+
.replaceAll('/', '_')
|
|
261
|
+
.replaceAll('=', '');
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const randomCode = () => randomToken(8).slice(0, 10).toUpperCase();
|
|
265
|
+
const publicOrigin = (request: Request, env: SharingEnv) => {
|
|
266
|
+
const url = new URL(request.url);
|
|
267
|
+
const isLoopback =
|
|
268
|
+
url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
|
269
|
+
return (isLoopback ? url.origin : env.PUBLIC_ORIGIN || url.origin).replace(/\/+$/, '');
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const sha256 = async (value: string) => {
|
|
273
|
+
const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(value));
|
|
274
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const cleanTitleValue = (value: unknown) =>
|
|
278
|
+
typeof value === 'string' ? value.replaceAll(/\s+/g, ' ').trim().slice(0, 300) || null : null;
|
|
279
|
+
|
|
280
|
+
const cleanIdentityValue = (value: unknown, maxLength: number) =>
|
|
281
|
+
typeof value === 'string' ? value.trim().slice(0, maxLength) || null : null;
|
|
282
|
+
|
|
283
|
+
const sanitizeWalkthrough = (snapshot: WalkthroughManifest) => {
|
|
284
|
+
const repository = snapshot.repository;
|
|
285
|
+
const source = repository.source;
|
|
286
|
+
const commitTitle =
|
|
287
|
+
source?.type === 'commit'
|
|
288
|
+
? (cleanTitleValue(repository.title) ?? cleanTitleValue(snapshot.walkthrough.title))
|
|
289
|
+
: null;
|
|
290
|
+
const sanitizedRepository = {
|
|
291
|
+
...repository,
|
|
292
|
+
root: commitTitle ?? 'Shared Codiff review',
|
|
293
|
+
...(commitTitle ? { title: commitTitle } : {}),
|
|
294
|
+
};
|
|
295
|
+
if (!commitTitle) {
|
|
296
|
+
delete sanitizedRepository.title;
|
|
297
|
+
}
|
|
298
|
+
return { ...snapshot, repository: sanitizedRepository };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const parseShareUpload = (body: string): ParsedUpload => {
|
|
302
|
+
const payload: unknown = JSON.parse(body);
|
|
303
|
+
const record = isRecord(payload) ? payload : null;
|
|
304
|
+
const snapshotValue = record?.snapshot ?? payload;
|
|
305
|
+
if (!isRecord(snapshotValue) || snapshotValue.version !== 1) {
|
|
306
|
+
throw new Error('unsupported-manifest');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (snapshotValue.kind === 'codiff-walkthrough-share') {
|
|
310
|
+
const envelope = walkthroughUploadSchema.safeParse(payload);
|
|
311
|
+
const parsed = envelope.success
|
|
312
|
+
? envelope.data
|
|
313
|
+
: { snapshot: walkthroughManifestSchema.parse(payload) };
|
|
314
|
+
return {
|
|
315
|
+
kind: 'walkthrough',
|
|
316
|
+
sharedByEmail: cleanIdentityValue(parsed.uploader?.email, 320),
|
|
317
|
+
sharedByName: cleanIdentityValue(parsed.uploader?.name, 200),
|
|
318
|
+
snapshot: sanitizeWalkthrough(parsed.snapshot),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (snapshotValue.kind !== 'codiff-plan-share') {
|
|
323
|
+
throw new Error('unsupported-manifest');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const parsed = parsePlanShareUpload(payload);
|
|
327
|
+
return {
|
|
328
|
+
kind: 'plan',
|
|
329
|
+
sharedByEmail: cleanIdentityValue(parsed.uploader?.email, 320),
|
|
330
|
+
sharedByName: cleanIdentityValue(parsed.uploader?.name, 200),
|
|
331
|
+
snapshot: parsed.snapshot,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export const parseGitPullRequestUrl = (value: string | undefined) => {
|
|
336
|
+
if (!value) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const url = new URL(value);
|
|
341
|
+
const gitLab = /^\/(.+)\/-\/merge_requests\/(\d+)(?:\/.*)?$/.exec(url.pathname);
|
|
342
|
+
if (gitLab) {
|
|
343
|
+
return {
|
|
344
|
+
host: url.host,
|
|
345
|
+
number: Number(gitLab[2]),
|
|
346
|
+
projectPath: decodeURIComponent(gitLab[1]),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const gitHub = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/.exec(url.pathname);
|
|
350
|
+
if (gitHub) {
|
|
351
|
+
return {
|
|
352
|
+
host: url.host,
|
|
353
|
+
number: Number(gitHub[3]),
|
|
354
|
+
projectPath: `${decodeURIComponent(gitHub[1])}/${decodeURIComponent(gitHub[2])}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export const getRepositoryMetadata = (source: WalkthroughManifest['repository']['source']) => {
|
|
364
|
+
if (!source || source.type !== 'pull-request') {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const parsed = parseGitPullRequestUrl(source.url);
|
|
368
|
+
const projectPath = source.projectPath || parsed?.projectPath;
|
|
369
|
+
const host = source.host || parsed?.host;
|
|
370
|
+
if (!projectPath || !host) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const segments = projectPath.split('/');
|
|
374
|
+
return {
|
|
375
|
+
host,
|
|
376
|
+
name: segments.at(-1) ?? projectPath,
|
|
377
|
+
number: source.number ?? parsed?.number ?? null,
|
|
378
|
+
owner: segments.slice(0, -1).join('/') || null,
|
|
379
|
+
url: `https://${host}/${projectPath}`,
|
|
380
|
+
};
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const utcDateKey = (date = new Date()) => date.toISOString().slice(0, 10);
|
|
384
|
+
|
|
385
|
+
const quotaResponse = () => {
|
|
386
|
+
const now = new Date();
|
|
387
|
+
const resetAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
|
388
|
+
return json(
|
|
389
|
+
{
|
|
390
|
+
error: 'share-quota-exceeded',
|
|
391
|
+
resetAt: resetAt.toISOString(),
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
headers: { 'retry-after': String(Math.max(1, Math.ceil((+resetAt - +now) / 1000))) },
|
|
395
|
+
status: 429,
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const hasAvailableQuota = async (userId: string, kind?: 'plan' | 'walkthrough') => {
|
|
401
|
+
const [usage] = await db
|
|
402
|
+
.select()
|
|
403
|
+
.from(shareDailyUsage)
|
|
404
|
+
.where(
|
|
405
|
+
// The table's compound primary key makes this lookup exact after both values are supplied.
|
|
406
|
+
// Drizzle's generated predicate remains parameterized.
|
|
407
|
+
sql`${shareDailyUsage.userId} = ${userId} and ${shareDailyUsage.date} = ${utcDateKey()}`,
|
|
408
|
+
)
|
|
409
|
+
.limit(1);
|
|
410
|
+
if (!usage) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
const total = usage.planCount + usage.walkthroughCount;
|
|
414
|
+
return (
|
|
415
|
+
total < 100 &&
|
|
416
|
+
(kind === 'plan'
|
|
417
|
+
? usage.planCount < 50
|
|
418
|
+
: kind === 'walkthrough'
|
|
419
|
+
? usage.walkthroughCount < 50
|
|
420
|
+
: true)
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const createUploadIntent = async (
|
|
425
|
+
request: Request,
|
|
426
|
+
env: SharingEnv,
|
|
427
|
+
options: SharingApiOptions,
|
|
428
|
+
) => {
|
|
429
|
+
const code = randomCode();
|
|
430
|
+
const now = new Date();
|
|
431
|
+
const expiresAt = new Date(now.getTime() + 10 * 60 * 1000);
|
|
432
|
+
const secret = await createUploadIntentSecret(env, code, expiresAt);
|
|
433
|
+
const tokenHash = await hashUploadIntentSecret(secret);
|
|
434
|
+
const shareUser = await getAuthenticatedShareUser(request, options.auth);
|
|
435
|
+
|
|
436
|
+
if (shareUser && options.enforceDailyQuota && !(await hasAvailableQuota(shareUser.id))) {
|
|
437
|
+
return quotaResponse();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (shareUser) {
|
|
441
|
+
await db.insert(uploadIntent).values({
|
|
442
|
+
claimedAt: now,
|
|
443
|
+
code,
|
|
444
|
+
expiresAt,
|
|
445
|
+
secretHash: tokenHash,
|
|
446
|
+
sharedByUserId: shareUser.id,
|
|
447
|
+
status: 'claimed',
|
|
448
|
+
updatedAt: now,
|
|
449
|
+
uploadTokenHash: tokenHash,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const origin = publicOrigin(request, env);
|
|
454
|
+
return json({
|
|
455
|
+
claimUrl: `${origin}/connect/${code}?secret=${secret}`,
|
|
456
|
+
code,
|
|
457
|
+
pollUrl: `${origin}/api/upload-intents/${code}?secret=${secret}`,
|
|
458
|
+
secret,
|
|
459
|
+
status: shareUser ? 'claimed' : 'pending',
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const readUploadIntent = async (request: Request, env: SharingEnv, code: string) => {
|
|
464
|
+
const secret = new URL(request.url).searchParams.get('secret');
|
|
465
|
+
if (!secret) {
|
|
466
|
+
return badRequest('missing-secret');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const [intent] = await db.select().from(uploadIntent).where(eq(uploadIntent.code, code)).limit(1);
|
|
470
|
+
const tokenHash = await hashUploadIntentSecret(secret);
|
|
471
|
+
if (intent && intent.secretHash !== tokenHash) {
|
|
472
|
+
return notFound();
|
|
473
|
+
}
|
|
474
|
+
const pending = intent ? null : await verifyUploadIntentSecret(env, code, secret);
|
|
475
|
+
if (!intent && !pending) {
|
|
476
|
+
return notFound();
|
|
477
|
+
}
|
|
478
|
+
const expiresAt = intent?.expiresAt ?? pending!.expiresAt;
|
|
479
|
+
if (expiresAt.getTime() < Date.now() && intent?.status !== 'uploaded') {
|
|
480
|
+
return json({ status: 'expired' }, { status: 410 });
|
|
481
|
+
}
|
|
482
|
+
if (!intent) {
|
|
483
|
+
return json({ status: 'pending' });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return json({
|
|
487
|
+
status: intent.status,
|
|
488
|
+
uploadToken: intent.status === 'claimed' ? secret : undefined,
|
|
489
|
+
url:
|
|
490
|
+
intent.status === 'uploaded' && intent.walkthroughSlug
|
|
491
|
+
? `${publicOrigin(request, env)}/${intent.shareKind === 'plan' ? 'p' : 'w'}/${
|
|
492
|
+
intent.walkthroughSlug
|
|
493
|
+
}`
|
|
494
|
+
: undefined,
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const isQuotaError = (error: unknown) =>
|
|
499
|
+
error instanceof Error && error.message.toLowerCase().includes('share-quota-exceeded');
|
|
500
|
+
|
|
501
|
+
const uploadShare = async (request: Request, env: SharingEnv, options: SharingApiOptions) => {
|
|
502
|
+
const code = request.headers.get('x-codiff-upload-code');
|
|
503
|
+
const authorization = request.headers.get('authorization');
|
|
504
|
+
const token = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null;
|
|
505
|
+
if (!code || !token) {
|
|
506
|
+
return unauthorized();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const [intent] = await db.select().from(uploadIntent).where(eq(uploadIntent.code, code)).limit(1);
|
|
510
|
+
if (
|
|
511
|
+
!intent ||
|
|
512
|
+
intent.status !== 'claimed' ||
|
|
513
|
+
!intent.sharedByUserId ||
|
|
514
|
+
intent.expiresAt.getTime() < Date.now() ||
|
|
515
|
+
intent.uploadTokenHash !== (await sha256(token))
|
|
516
|
+
) {
|
|
517
|
+
return unauthorized();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const body = await request.text();
|
|
521
|
+
if (textEncoder.encode(body).byteLength > maxUploadByteSize) {
|
|
522
|
+
return json({ error: 'manifest-too-large' }, { status: 413 });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let upload: ParsedUpload;
|
|
526
|
+
try {
|
|
527
|
+
upload = parseShareUpload(body);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return badRequest(
|
|
530
|
+
error instanceof Error && error.message === 'unsupported-manifest'
|
|
531
|
+
? error.message
|
|
532
|
+
: 'invalid-manifest',
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (options.enforceDailyQuota && !(await hasAvailableQuota(intent.sharedByUserId, upload.kind))) {
|
|
537
|
+
return quotaResponse();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const canonical = JSON.stringify(upload.snapshot);
|
|
541
|
+
const digest = await sha256(canonical);
|
|
542
|
+
const slug = randomToken(18);
|
|
543
|
+
const objectKey = `${upload.kind === 'plan' ? 'plans' : 'walkthroughs'}/${slug}/manifest.json`;
|
|
544
|
+
const shareUser = await getShareUserById(intent.sharedByUserId);
|
|
545
|
+
if (!shareUser) {
|
|
546
|
+
return unauthorized();
|
|
547
|
+
}
|
|
548
|
+
const now = new Date();
|
|
549
|
+
|
|
550
|
+
await env.WALKTHROUGH_BUCKET.put(objectKey, canonical, {
|
|
551
|
+
httpMetadata: { contentType: 'application/json; charset=utf-8' },
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
if (upload.kind === 'walkthrough') {
|
|
556
|
+
const source = upload.snapshot.repository.source;
|
|
557
|
+
const repository = getRepositoryMetadata(source);
|
|
558
|
+
const commitTitle =
|
|
559
|
+
source?.type === 'commit' ? cleanTitleValue(upload.snapshot.repository.title) : null;
|
|
560
|
+
const walkthroughId = crypto.randomUUID();
|
|
561
|
+
await db.batch([
|
|
562
|
+
db.insert(walkthrough).values({
|
|
563
|
+
branch: upload.snapshot.branch ?? null,
|
|
564
|
+
byteSize: textEncoder.encode(canonical).byteLength,
|
|
565
|
+
codiffVersion: upload.snapshot.codiffVersion,
|
|
566
|
+
description: upload.snapshot.walkthrough.summary ?? null,
|
|
567
|
+
id: walkthroughId,
|
|
568
|
+
objectKey,
|
|
569
|
+
pullRequestNumber: repository?.number ?? source?.number ?? null,
|
|
570
|
+
pullRequestTitle: source?.title ?? null,
|
|
571
|
+
pullRequestUrl: source?.type === 'pull-request' ? (source.url ?? null) : null,
|
|
572
|
+
repositoryHost: repository?.host ?? null,
|
|
573
|
+
repositoryName: repository?.name ?? null,
|
|
574
|
+
repositoryOwner: repository?.owner ?? null,
|
|
575
|
+
repositoryUrl: repository?.url ?? null,
|
|
576
|
+
schemaVersion: upload.snapshot.version,
|
|
577
|
+
sha256: digest,
|
|
578
|
+
sharedByEmail: null,
|
|
579
|
+
sharedByName: null,
|
|
580
|
+
sharedByUserId: shareUser.id,
|
|
581
|
+
slug,
|
|
582
|
+
sourceType: source?.type ?? 'unknown',
|
|
583
|
+
title: commitTitle ?? upload.snapshot.walkthrough.title,
|
|
584
|
+
updatedAt: now,
|
|
585
|
+
}),
|
|
586
|
+
db
|
|
587
|
+
.update(uploadIntent)
|
|
588
|
+
.set({
|
|
589
|
+
shareKind: upload.kind,
|
|
590
|
+
status: 'uploaded',
|
|
591
|
+
updatedAt: now,
|
|
592
|
+
walkthroughSlug: slug,
|
|
593
|
+
})
|
|
594
|
+
.where(eq(uploadIntent.id, intent.id)),
|
|
595
|
+
]);
|
|
596
|
+
} else {
|
|
597
|
+
await db.batch([
|
|
598
|
+
db.insert(plan).values({
|
|
599
|
+
agent: cleanIdentityValue(upload.snapshot.source?.agent, 40),
|
|
600
|
+
byteSize: textEncoder.encode(canonical).byteLength,
|
|
601
|
+
codiffVersion: upload.snapshot.codiffVersion,
|
|
602
|
+
objectKey,
|
|
603
|
+
schemaVersion: upload.snapshot.version,
|
|
604
|
+
sessionId: cleanIdentityValue(upload.snapshot.source?.sessionId, 200),
|
|
605
|
+
sha256: digest,
|
|
606
|
+
sharedByEmail: null,
|
|
607
|
+
sharedByName: null,
|
|
608
|
+
sharedByUserId: shareUser.id,
|
|
609
|
+
slug,
|
|
610
|
+
sourceFileName: cleanTitleValue(upload.snapshot.document.name) ?? 'plan.md',
|
|
611
|
+
title: cleanTitleValue(upload.snapshot.document.title) ?? 'Codiff Plan',
|
|
612
|
+
updatedAt: now,
|
|
613
|
+
}),
|
|
614
|
+
db
|
|
615
|
+
.update(uploadIntent)
|
|
616
|
+
.set({
|
|
617
|
+
shareKind: upload.kind,
|
|
618
|
+
status: 'uploaded',
|
|
619
|
+
updatedAt: now,
|
|
620
|
+
walkthroughSlug: slug,
|
|
621
|
+
})
|
|
622
|
+
.where(eq(uploadIntent.id, intent.id)),
|
|
623
|
+
]);
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
await env.WALKTHROUGH_BUCKET.delete(objectKey);
|
|
627
|
+
if (isQuotaError(error)) {
|
|
628
|
+
return quotaResponse();
|
|
629
|
+
}
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
options.onUploadIntentUpdated?.({
|
|
634
|
+
changed:
|
|
635
|
+
upload.kind === 'plan'
|
|
636
|
+
? ['shareKind', 'status', 'walkthroughSlug']
|
|
637
|
+
: ['status', 'walkthroughSlug'],
|
|
638
|
+
id: intent.id,
|
|
639
|
+
shareKind: upload.kind,
|
|
640
|
+
walkthroughSlug: slug,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return json({
|
|
644
|
+
slug,
|
|
645
|
+
status: 'uploaded',
|
|
646
|
+
url: `${publicOrigin(request, env)}/${upload.kind === 'plan' ? 'p' : 'w'}/${slug}`,
|
|
647
|
+
});
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const getManifest = async (env: SharingEnv, kind: 'plan' | 'walkthrough', slug: string) => {
|
|
651
|
+
const [record] =
|
|
652
|
+
kind === 'plan'
|
|
653
|
+
? await db
|
|
654
|
+
.select({ objectKey: plan.objectKey })
|
|
655
|
+
.from(plan)
|
|
656
|
+
.where(eq(plan.slug, slug))
|
|
657
|
+
.limit(1)
|
|
658
|
+
: await db
|
|
659
|
+
.select({ objectKey: walkthrough.objectKey })
|
|
660
|
+
.from(walkthrough)
|
|
661
|
+
.where(eq(walkthrough.slug, slug))
|
|
662
|
+
.limit(1);
|
|
663
|
+
if (!record) {
|
|
664
|
+
return notFound();
|
|
665
|
+
}
|
|
666
|
+
const object = await env.WALKTHROUGH_BUCKET.get(record.objectKey);
|
|
667
|
+
if (!object) {
|
|
668
|
+
return notFound();
|
|
669
|
+
}
|
|
670
|
+
return new Response(object.body, {
|
|
671
|
+
headers: {
|
|
672
|
+
'cache-control': 'private, no-store',
|
|
673
|
+
'content-type': 'application/json; charset=utf-8',
|
|
674
|
+
'x-robots-tag': 'noindex',
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const handleSharingApi = async (request: Request, env: SharingEnv, options: SharingApiOptions) => {
|
|
680
|
+
const url = new URL(request.url);
|
|
681
|
+
const uploadIntentMatch = /^\/api\/upload-intents\/([^/]+)$/.exec(url.pathname);
|
|
682
|
+
const planManifestMatch = /^\/api\/plans\/([^/]+)\/manifest$/.exec(url.pathname);
|
|
683
|
+
const walkthroughManifestMatch = /^\/api\/walkthroughs\/([^/]+)\/manifest$/.exec(url.pathname);
|
|
684
|
+
|
|
685
|
+
if (request.method === 'POST' && url.pathname === '/api/upload-intents') {
|
|
686
|
+
return createUploadIntent(request, env, options);
|
|
687
|
+
}
|
|
688
|
+
if (request.method === 'GET' && uploadIntentMatch) {
|
|
689
|
+
return readUploadIntent(request, env, uploadIntentMatch[1]);
|
|
690
|
+
}
|
|
691
|
+
if (request.method === 'POST' && url.pathname === '/api/uploads') {
|
|
692
|
+
return uploadShare(request, env, options);
|
|
693
|
+
}
|
|
694
|
+
if (request.method === 'GET' && planManifestMatch) {
|
|
695
|
+
return getManifest(env, 'plan', planManifestMatch[1]);
|
|
696
|
+
}
|
|
697
|
+
if (request.method === 'GET' && walkthroughManifestMatch) {
|
|
698
|
+
return getManifest(env, 'walkthrough', walkthroughManifestMatch[1]);
|
|
699
|
+
}
|
|
700
|
+
return notFound();
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
export const handleSharingApiRequest = (
|
|
704
|
+
request: Request,
|
|
705
|
+
env: SharingEnv,
|
|
706
|
+
options: SharingApiOptions = {},
|
|
707
|
+
): Promise<Response> => withDatabase(env.DB, () => handleSharingApi(request, env, options));
|