1import {
2 ArchiveCatalogEntry,
3 ArchiveError,
4 ArchiveAssetCreateUrlInput,
5 AssetCreateUrlResult,
6 ArchiveObjectHash,
7 ArchiveCatalogResult,
8 ArchivePublication,
9 OrchestrationV2ThreadDetailSnapshot,
10 AuthArchiveReadScope,
11 AuthArchiveWriteScope,
12 EnvironmentId,
13 HostedAuthError,
14 ThreadId,
15} from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Redacted from "effect/Redacted";
19import * as Schema from "effect/Schema";
20import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
21import * as HostedAuth from "../auth/HostedAuth.ts";
22import { archiveRouteErrors, authenticateRawRouteWithScope, assetFileResponse } from "../http.ts";
23import * as ArchiveAssets from "./ArchiveAssets.ts";
24import * as Option from "effect/Option";
25import * as ArchiveCatalog from "./ArchiveCatalog.ts";
26
27const json = <A, I>(schema: Schema.Codec<A, I>) => {
28 const encode = Schema.encodeEffect(Schema.toCodecJson(schema));
29 return (value: A) =>
30 encode(value).pipe(
31 Effect.flatMap((body) =>
32 HttpServerResponse.json(body, { headers: { "cache-control": "no-store" } }),
33 ),
34 );
35};
36const decodeParams = Schema.decodeUnknownEffect(
37 Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId }),
38);
39const params = HttpRouter.params.pipe(Effect.flatMap(decodeParams));
40const catalogJson = json(ArchiveCatalogResult);
41const entryJson = json(ArchiveCatalogEntry);
42const assetJson = json(AssetCreateUrlResult);
43const snapshotJson = json(OrchestrationV2ThreadDetailSnapshot);
44const isHostedAuthError = Schema.is(HostedAuthError);
45const withdrawal = HttpServerRequest.schemaBodyJson(
46 Schema.Struct({ manifestHash: ArchiveObjectHash }),
47);
48const errors = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
49 archiveRouteErrors(effect).pipe(
50 Effect.catch((error) => {
51 if (isHostedAuthError(error))
52 return Effect.succeed(HttpServerResponse.empty({ status: 403 }));
53 if (Schema.isSchemaError(error))
54 return Effect.succeed(HttpServerResponse.empty({ status: 400 }));
55 return Effect.fail(error);
56 }),
57 );
58
59export const layer = Layer.mergeAll(
60 HttpRouter.add(
61 "POST",
62 "/api/archive/assets",
63 Effect.gen(function* () {
64 const session = yield* authenticateRawRouteWithScope(AuthArchiveReadScope);
65 const input = yield* HttpServerRequest.schemaBodyJson(ArchiveAssetCreateUrlInput);
66 return yield* (yield* ArchiveAssets.ArchiveAssets)
67 .createUrl(input.environmentId, input.resource, session.sessionId)
68 .pipe(Effect.flatMap(assetJson));
69 }).pipe(errors),
70 ),
71 HttpRouter.add(
72 "GET",
73 "/api/archive/assets/*",
74 Effect.gen(function* () {
75 const request = yield* HttpServerRequest.HttpServerRequest;
76 const url = HttpServerRequest.toURL(request);
77 if (Option.isNone(url)) return HttpServerResponse.empty({ status: 404 });
78 const suffix = url.value.pathname.slice("/api/archive/assets/".length);
79 const separator = suffix.indexOf("/");
80 if (separator <= 0) return HttpServerResponse.empty({ status: 404 });
81 const asset = yield* (yield* ArchiveAssets.ArchiveAssets).resolve(suffix.slice(0, separator));
82 if (Option.isNone(asset)) return HttpServerResponse.empty({ status: 404 });
83 return yield* assetFileResponse(
84 asset.value,
85 request.headers.range,
86 request.headers["if-range"],
87 request.method === "HEAD" ? "HEAD" : "GET",
88 ).pipe(
89 Effect.mapError(
90 (cause) => new ArchiveError({ reason: "storage", cause: Redacted.make(cause) }),
91 ),
92 Effect.map((response) =>
93 HttpServerResponse.setHeaders(response, {
94 "cache-control": "private, no-store",
95 "content-type":
96 asset.value.mimeType === "text/html"
97 ? "text/html; charset=utf-8"
98 : asset.value.mimeType,
99 ...(asset.value.mimeType === "image/svg+xml"
100 ? {
101 "content-security-policy":
102 "default-src 'none'; style-src 'unsafe-inline'; sandbox",
103 }
104 : {}),
105 }),
106 ),
107 );
108 }).pipe(errors),
109 ),
110 HttpRouter.add(
111 "GET",
112 "/api/archive/catalog",
113 Effect.gen(function* () {
114 yield* authenticateRawRouteWithScope(AuthArchiveReadScope);
115 return yield* (yield* ArchiveCatalog.ArchiveCatalog).list.pipe(Effect.flatMap(catalogJson));
116 }).pipe(errors),
117 ),
118 HttpRouter.add(
119 "POST",
120 "/api/archive/catalog",
121 Effect.gen(function* () {
122 const session = yield* authenticateRawRouteWithScope(AuthArchiveWriteScope);
123 const input = yield* HttpServerRequest.schemaBodyJson(ArchivePublication);
124 yield* (yield* HostedAuth.HostedAuth).authorizeEnvironment(session, input.environmentId);
125 return yield* (yield* ArchiveCatalog.ArchiveCatalog)
126 .publish(input)
127 .pipe(Effect.flatMap(entryJson));
128 }).pipe(errors),
129 ),
130 HttpRouter.add(
131 "GET",
132 "/api/archive/catalog/:environmentId/:threadId",
133 Effect.gen(function* () {
134 yield* authenticateRawRouteWithScope(AuthArchiveReadScope);
135 const input = yield* params;
136 return yield* (yield* ArchiveCatalog.ArchiveCatalog)
137 .read(input.environmentId, input.threadId)
138 .pipe(Effect.flatMap(snapshotJson));
139 }).pipe(errors),
140 ),
141 HttpRouter.add(
142 "DELETE",
143 "/api/archive/catalog/:environmentId/:threadId",
144 Effect.gen(function* () {
145 const session = yield* authenticateRawRouteWithScope(AuthArchiveWriteScope);
146 const input = yield* params;
147 yield* (yield* HostedAuth.HostedAuth).authorizeEnvironment(session, input.environmentId);
148 const { manifestHash } = yield* withdrawal;
149 yield* (yield* ArchiveCatalog.ArchiveCatalog).withdraw(
150 input.environmentId,
151 input.threadId,
152 manifestHash,
153 );
154 return HttpServerResponse.empty({ status: 204 });
155 }).pipe(errors),
156 ),
157);