Open History to see the last commit
1import {
2 GUEST_ATTACHMENT_ROUTE,
3 GUEST_COMMAND_OUTPUT_ROUTE,
4 GUEST_HISTORY_ROUTE,
5 GUEST_ITEM_ROUTE,
6 GUEST_MEDIA_ROUTE,
7 GUEST_RENDER_ROUTE,
8 GUEST_ROUTE_PREFIX,
9 GUEST_SHARE_PARAM,
10 GUEST_MESSAGE_MAX_ATTACHMENT_BYTES,
11 GUEST_MESSAGE_MAX_ATTACHMENTS,
12 GUEST_SHARE_TTL_MS,
13 GuestRedeemRequest,
14 GuestStreamEvent,
15 GuestSubmitRequest,
16 guestSessionCookieName,
17 isProviderSendTurnSupportedImageMimeType,
18 OrchestrationV2CommandOutputFrame,
19 OrchestrationV2GetTurnItemResult,
20 OrchestrationV2ThreadHistoryPage,
21} from "@t3tools/contracts";
22import * as ByteSize from "effect/ByteSize";
23import * as Duration from "effect/Duration";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Schema from "effect/Schema";
27import * as Stream from "effect/Stream";
28import { HttpRouter, HttpServerRequest, HttpServerResponse, Multipart } from "effect/http";
29import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage";
30
31import { htmlRenderFileName } from "@t3tools/shared/htmlRender";
32
33import { assetFileResponse, downloadContentDisposition } from "../http.ts";
34import type { GuestUpload } from "./guestAttachments.ts";
35import { GuestShareAccessError, GuestShareService } from "./GuestShareService.ts";
36
37// Cloudflare closes idle streams after about 100 seconds.
38const HEARTBEAT_INTERVAL = Duration.seconds(20);
39const NO_STORE = { "cache-control": "no-store" };
40// A file never runs as a page on this origin, whatever its bytes.
41const FILE_HEADERS = {
42 ...NO_STORE,
43 "x-content-type-options": "nosniff",
44 "content-security-policy": "default-src 'none'; sandbox",
45};
46
47const decodeRedeem = Schema.decodeUnknownEffect(GuestRedeemRequest);
48const decodeSubmit = Schema.decodeUnknownEffect(GuestSubmitRequest);
49// The owner's thread types hold DateTime values; the JSON codec writes them as strings.
50const encodeStreamEvent = Schema.encodeEffect(
51 Schema.fromJsonString(Schema.toCodecJson(GuestStreamEvent)),
52);
53const encodeOutputFrame = Schema.encodeEffect(
54 Schema.fromJsonString(OrchestrationV2CommandOutputFrame),
55);
56const encodeItem = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2GetTurnItemResult));
57const encodeHistoryPage = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2ThreadHistoryPage));
58
59const errorResponse = (error: GuestShareAccessError) =>
60 HttpServerResponse.json(
61 { reason: error.reason, message: error.message },
62 {
63 status:
64 error.reason === "invalid" || error.reason === "inactive" || error.reason === "unavailable"
65 ? 403
66 : error.reason === "empty" ||
67 error.reason === "too_many_files" ||
68 error.reason === "too_large" ||
69 error.reason === "unsupported_file"
70 ? 400
71 : 409,
72 headers: NO_STORE,
73 },
74 );
75
76const badRequest = HttpServerResponse.json(
77 { reason: "invalid", message: "The request was not valid." },
78 { status: 400, headers: NO_STORE },
79);
80
81const sessionFor = (request: HttpServerRequest.HttpServerRequest, shareId: string | null) =>
82 shareId === null ? "" : (request.cookies[guestSessionCookieName(shareId)] ?? "");
83
84const requestedShareId = (request: HttpServerRequest.HttpServerRequest) =>
85 new URLSearchParams(request.url.split("?", 2)[1] ?? "").get(GUEST_SHARE_PARAM);
86
87/** The session for the share the request names in `?share=`. */
88const sessionToken = Effect.map(HttpServerRequest.HttpServerRequest, (request) =>
89 sessionFor(request, requestedShareId(request)),
90);
91
92/** The `?item=` a per-item route names. */
93const itemParam = Effect.map(HttpServerRequest.HttpServerRequest, (request) =>
94 new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("item"),
95);
96
97/** Server-sent events with a heartbeat, never cached or buffered on the way. */
98const eventStreamResponse = <A>(
99 events: Stream.Stream<A>,
100 encode: (event: A) => Effect.Effect<string, Schema.SchemaError>,
101) => {
102 const data = events.pipe(
103 Stream.mapEffect((event) =>
104 encode(event).pipe(
105 Effect.map((json) => [`data: ${json}\n\n`]),
106 // The guest misses one update instead of the whole stream.
107 Effect.catch(() =>
108 Effect.logWarning("Dropped a guest stream event that failed to encode.").pipe(
109 Effect.as([]),
110 ),
111 ),
112 ),
113 ),
114 Stream.flattenIterable,
115 );
116 const heartbeat = Stream.tick(HEARTBEAT_INTERVAL).pipe(Stream.map(() => ": ping\n\n"));
117 return HttpServerResponse.stream(
118 data.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
119 {
120 contentType: "text/event-stream",
121 // no-transform keeps the compression middleware from buffering the stream.
122 headers: { "cache-control": "no-store, no-transform", "x-accel-buffering": "no" },
123 },
124 );
125};
126
127// Guest JSON bodies are a name or one message; anyone who can reach the tunnel can post them.
128const JSON_MAX_BODY_BYTES = 64 * 1024;
129const readJson = Effect.gen(function* () {
130 const request = yield* HttpServerRequest.HttpServerRequest;
131 // A declared length over the cap is refused before reading; chunked bodies hit the reader cap.
132 if (Number(request.headers["content-length"] ?? "0") > JSON_MAX_BODY_BYTES) {
133 return yield* new GuestShareAccessError({ reason: "too_large" });
134 }
135 return yield* request.json.pipe(
136 Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(JSON_MAX_BODY_BYTES)),
137 );
138});
139
140const redeemRoute = HttpRouter.add(
141 "POST",
142 `${GUEST_ROUTE_PREFIX}/redeem`,
143 Effect.gen(function* () {
144 const body = yield* readJson.pipe(Effect.flatMap(decodeRedeem), Effect.option);
145 if (body._tag === "None") return yield* badRequest;
146 const guestShares = yield* GuestShareService;
147 return yield* guestShares.redeem(body.value).pipe(
148 Effect.flatMap(({ token, shareId }) =>
149 HttpServerResponse.empty({ status: 204, headers: NO_STORE }).pipe(
150 HttpServerResponse.setCookie(guestSessionCookieName(shareId), token, {
151 httpOnly: true,
152 secure: true,
153 sameSite: "strict",
154 path: GUEST_ROUTE_PREFIX,
155 maxAge: Duration.millis(GUEST_SHARE_TTL_MS),
156 }),
157 ),
158 ),
159 Effect.catchTags({ GuestShareAccessError: errorResponse }),
160 );
161 }),
162);
163
164// The parser stops a request with too many parts or bytes before it reaches the disk; the
165// service checks the exact limits and types.
166const UPLOAD_LIMITS = Multipart.limitsServices({
167 maxParts: GUEST_MESSAGE_MAX_ATTACHMENTS + 4,
168 maxFileSize: GUEST_MESSAGE_MAX_ATTACHMENT_BYTES,
169 maxTotalSize: GUEST_MESSAGE_MAX_ATTACHMENT_BYTES + 64 * 1024,
170 // By default a JSON part is read as a field, which would drop a guest's `.json` file.
171 fieldMimeTypes: [],
172});
173
174/** A message as JSON (text only), or as multipart form data with `file` parts. */
175export const readSubmission = Effect.gen(function* () {
176 const request = yield* HttpServerRequest.HttpServerRequest;
177 if (!request.headers["content-type"]?.startsWith("multipart/form-data")) {
178 const body = yield* readJson.pipe(Effect.flatMap(decodeSubmit), Effect.option);
179 return body._tag === "None" ? null : { fields: body.value, uploads: [] };
180 }
181 const parts = yield* request.multipart.pipe(
182 Effect.provideContext(UPLOAD_LIMITS),
183 Effect.mapError(
184 (error) =>
185 new GuestShareAccessError({
186 reason:
187 error.reason._tag === "TooManyParts"
188 ? "too_many_files"
189 : error.reason._tag === "Parse"
190 ? "rejected"
191 : "too_large",
192 }),
193 ),
194 );
195 const fields = yield* decodeSubmit({ shareId: parts.shareId, text: parts.text ?? "" }).pipe(
196 Effect.option,
197 );
198 if (fields._tag === "None") return null;
199 const files = parts.file;
200 const uploads: ReadonlyArray<GuestUpload> = Array.isArray(files)
201 ? files.filter(Multipart.isPersistedFile).map(({ name, contentType, path }) => ({
202 name,
203 contentType,
204 path,
205 }))
206 : [];
207 return { fields: fields.value, uploads };
208});
209
210const submitRoute = HttpRouter.add(
211 "POST",
212 `${GUEST_ROUTE_PREFIX}/messages`,
213 Effect.gen(function* () {
214 const request = yield* HttpServerRequest.HttpServerRequest;
215 const guestShares = yield* GuestShareService;
216 // The session is checked before the body is read, so only a guest can make the server parse
217 // and store up to 10 MB.
218 const shareId = requestedShareId(request);
219 const session = sessionFor(request, shareId);
220 yield* guestShares.check(session);
221 const submission = yield* readSubmission;
222 if (submission === null || submission.fields.shareId !== shareId) return yield* badRequest;
223 yield* guestShares.submit(session, submission.fields, submission.uploads);
224 return HttpServerResponse.empty({ status: 204, headers: NO_STORE });
225 }).pipe(
226 Effect.catchTags({ GuestShareAccessError: errorResponse }),
227 // The parser's temporary files are removed once the response is ready.
228 Effect.scoped,
229 ),
230);
231
232const streamRoute = HttpRouter.add(
233 "GET",
234 `${GUEST_ROUTE_PREFIX}/stream`,
235 Effect.gen(function* () {
236 const guestShares = yield* GuestShareService;
237 return yield* guestShares.stream(yield* sessionToken).pipe(
238 Effect.map((events) => eventStreamResponse(events, encodeStreamEvent)),
239 Effect.catchTags({ GuestShareAccessError: errorResponse }),
240 );
241 }),
242);
243
244// Lets a returning guest skip the name form when this browser already joined the share.
245const sessionRoute = HttpRouter.add(
246 "GET",
247 `${GUEST_ROUTE_PREFIX}/session`,
248 Effect.gen(function* () {
249 const guestShares = yield* GuestShareService;
250 return yield* guestShares
251 .check(yield* sessionToken)
252 .pipe(
253 Effect.as(HttpServerResponse.empty({ status: 204, headers: NO_STORE })),
254 Effect.catchTags({ GuestShareAccessError: errorResponse }),
255 );
256 }),
257);
258
259// The response carries the asset route's sandbox CSP: the page gets an opaque origin, so its
260// scripts cannot send the guest cookie or call these routes as the guest.
261const renderRoute = HttpRouter.add(
262 "GET",
263 GUEST_RENDER_ROUTE,
264 Effect.gen(function* () {
265 const itemId = yield* itemParam;
266 if (!itemId) return yield* badRequest;
267 const guestShares = yield* GuestShareService;
268 return yield* guestShares.render(yield* sessionToken, itemId).pipe(
269 Effect.flatMap(({ path, title }) =>
270 assetFileResponse({ path, mimeType: "text/html", fileName: htmlRenderFileName(title) }),
271 ),
272 // Each load re-checks the share, so a closed, replaced, or expired link stops it at once.
273 Effect.map((response) => HttpServerResponse.setHeaders(response, NO_STORE)),
274 Effect.catchTags({ GuestShareAccessError: errorResponse }),
275 );
276 }),
277);
278
279const itemRoute = HttpRouter.add(
280 "GET",
281 GUEST_ITEM_ROUTE,
282 Effect.gen(function* () {
283 const itemId = yield* itemParam;
284 if (!itemId) return yield* badRequest;
285 const guestShares = yield* GuestShareService;
286 return yield* guestShares.item(yield* sessionToken, itemId).pipe(
287 Effect.flatMap((result) =>
288 encodeItem(result).pipe(
289 Effect.mapError(() => new GuestShareAccessError({ reason: "unavailable" })),
290 ),
291 ),
292 Effect.map((body) => HttpServerResponse.jsonUnsafe(body, { headers: NO_STORE })),
293 Effect.catchTags({ GuestShareAccessError: errorResponse }),
294 );
295 }),
296);
297
298const historyRoute = HttpRouter.add(
299 "GET",
300 GUEST_HISTORY_ROUTE,
301 Effect.gen(function* () {
302 const request = yield* HttpServerRequest.HttpServerRequest;
303 const cursor = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("cursor");
304 if (!cursor) return yield* badRequest;
305 const guestShares = yield* GuestShareService;
306 return yield* guestShares.history(yield* sessionToken, cursor).pipe(
307 Effect.flatMap((page) =>
308 encodeHistoryPage(page).pipe(
309 Effect.mapError(() => new GuestShareAccessError({ reason: "unavailable" })),
310 ),
311 ),
312 Effect.map((body) => HttpServerResponse.jsonUnsafe(body, { headers: NO_STORE })),
313 Effect.catchTags({ GuestShareAccessError: errorResponse }),
314 );
315 }),
316);
317
318// Ends when the command settles; the page closes its EventSource on that last frame, so the
319// browser does not reconnect.
320const commandOutputRoute = HttpRouter.add(
321 "GET",
322 GUEST_COMMAND_OUTPUT_ROUTE,
323 Effect.gen(function* () {
324 const itemId = yield* itemParam;
325 if (!itemId) return yield* badRequest;
326 const guestShares = yield* GuestShareService;
327 return yield* guestShares.commandOutput(yield* sessionToken, itemId).pipe(
328 Effect.map((frames) => eventStreamResponse(frames, encodeOutputFrame)),
329 Effect.catchTags({ GuestShareAccessError: errorResponse }),
330 );
331 }),
332);
333
334// Images of the four checked types load inline; anything else downloads as bytes, so a guest's
335// HTML or SVG never renders as a page on this origin.
336const attachmentRoute = HttpRouter.add(
337 "GET",
338 GUEST_ATTACHMENT_ROUTE,
339 Effect.gen(function* () {
340 const request = yield* HttpServerRequest.HttpServerRequest;
341 const attachmentId = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("id");
342 if (!attachmentId) return yield* badRequest;
343 const guestShares = yield* GuestShareService;
344 return yield* guestShares.attachment(yield* sessionToken, attachmentId).pipe(
345 Effect.flatMap(({ path, attachment }) =>
346 HttpServerResponse.file(path, {
347 headers: {
348 ...FILE_HEADERS,
349 ...(attachment.type === "image" &&
350 isProviderSendTurnSupportedImageMimeType(attachment.mimeType)
351 ? { "content-type": attachment.mimeType }
352 : {
353 "content-type": "application/octet-stream",
354 "content-disposition": downloadContentDisposition(attachment.name),
355 }),
356 },
357 }),
358 ),
359 Effect.catchTags({ GuestShareAccessError: errorResponse }),
360 Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 500, headers: NO_STORE })),
361 );
362 }),
363);
364
365// Only an image an agent reply shows loads; anything else on the host is refused.
366const mediaRoute = HttpRouter.add(
367 "GET",
368 GUEST_MEDIA_ROUTE,
369 Effect.gen(function* () {
370 const request = yield* HttpServerRequest.HttpServerRequest;
371 const path = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("path");
372 if (!path) return yield* badRequest;
373 const guestShares = yield* GuestShareService;
374 return yield* guestShares.media(yield* sessionToken, path).pipe(
375 Effect.flatMap(({ path, mimeType }) =>
376 HttpServerResponse.file(path, { headers: { ...FILE_HEADERS, "content-type": mimeType } }),
377 ),
378 Effect.catchTags({ GuestShareAccessError: errorResponse }),
379 Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 500, headers: NO_STORE })),
380 );
381 }),
382);
383
384export const layer = Layer.mergeAll(
385 redeemRoute,
386 sessionRoute,
387 submitRoute,
388 streamRoute,
389 renderRoute,
390 itemRoute,
391 historyRoute,
392 commandOutputRoute,
393 attachmentRoute,
394 mediaRoute,
395);