Skip to content
Source
Sign inSign up

sourceControlMediaResponse.ts

99 lines4.0 KB

1import * as Mime from "effect/http/Mime";
2import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { HttpServerResponse } from "effect/http";
6​
7import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
8import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
9import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
10​
11const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;
12const FORWARDED_RESPONSE_HEADERS = [
13 "content-type",
14 "content-length",
15 "content-range",
16 "accept-ranges",
17 "etag",
18 "last-modified",
19] as const;
20const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i;
21const SVG_CONTENT_TYPE = "image/svg+xml";
22// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a
23// body cannot run script against this origin.
24const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
25​
26export const sourceControlMediaResponse = Effect.fn(
27 "SourceControlMediaResponse.sourceControlMediaResponse",
28)(function* (
29 asset: {
30 readonly url: string;
31 readonly cwd: string;
32 readonly repository?: string;
33 readonly expiresAt: number;
34 },
35 requestHeaders: Record<string, string | undefined>,
36) {
37 const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000);
38 const headers: Record<string, string> = {
39 "cache-control":
40 remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store",
41 "x-content-type-options": "nosniff",
42 };
43 const media = sourceControlDefinitions.mediaUrl(asset.url);
44 const drivers = yield* BuiltInDrivers.SourceControlDrivers;
45 const fetchMedia = drivers.find(({ driver }) => driver.kind === media?.definition.kind)?.instance
46 .fetchMedia;
47 if (media === null || fetchMedia === undefined) {
48 return HttpServerResponse.empty({ status: 404, headers });
49 }
50 const forwarded: Record<string, string> = {};
51 for (const name of FORWARDED_REQUEST_HEADERS) {
52 const value = requestHeaders[name];
53 if (value !== undefined) forwarded[name] = value;
54 }
55 // Clients that predate `repository` fall back to the repository checked out at `cwd`.
56 let repository = asset.repository;
57 if (repository === undefined) {
58 const identities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
59 const identity = yield* identities.resolve(asset.cwd);
60 if (identity?.provider === media.definition.kind && identity.owner && identity.name) {
61 repository = `${identity.owner}/${identity.name}`;
62 }
63 }
64 const response = yield* fetchMedia({
65 url: asset.url,
66 headers: forwarded,
67 ...(repository === undefined ? {} : { repository }),
68 });
69 if (response === null) return HttpServerResponse.empty({ status: 502, headers });
70 // It carries none of the upstream entity headers: a `content-length` with no body behind it
71 // holds the connection open until the browser gives up on it.
72 if (response.status >= 400) {
73 return HttpServerResponse.empty({
74 status: response.status >= 500 ? 502 : response.status,
75 headers,
76 });
77 }
78 const upstreamType =
79 response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
80 const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType)
81 ? upstreamType
82 : Option.getOrElse(Mime.getType(media.fileName), () => "").toLowerCase();
83 if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) {
84 return HttpServerResponse.empty({ status: 415, headers });
85 }
86 for (const name of FORWARDED_RESPONSE_HEADERS) {
87 const value = response.headers[name];
88 if (value !== undefined) headers[name] = value;
89 }
90 headers["content-type"] = contentType;
91 if (contentType === SVG_CONTENT_TYPE) {
92 headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY;
93 }
94 return HttpServerResponse.stream(response.stream, {
95 status: response.status,
96 headers,
97 contentType,
98 });
99});