| 1 | import { |
| 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"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Redacted from "effect/Redacted"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 21 | import * as HostedAuth from "../auth/HostedAuth.ts"; |
| 22 | import { archiveRouteErrors, authenticateRawRouteWithScope, assetFileResponse } from "../http.ts"; |
| 23 | import * as ArchiveAssets from "./ArchiveAssets.ts"; |
| 24 | import * as Option from "effect/Option"; |
| 25 | import * as ArchiveCatalog from "./ArchiveCatalog.ts"; |
| 26 | |
| 27 | const 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 | }; |
| 36 | const decodeParams = Schema.decodeUnknownEffect( |
| 37 | Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId }), |
| 38 | ); |
| 39 | const params = HttpRouter.params.pipe(Effect.flatMap(decodeParams)); |
| 40 | const catalogJson = json(ArchiveCatalogResult); |
| 41 | const entryJson = json(ArchiveCatalogEntry); |
| 42 | const assetJson = json(AssetCreateUrlResult); |
| 43 | const snapshotJson = json(OrchestrationV2ThreadDetailSnapshot); |
| 44 | const isHostedAuthError = Schema.is(HostedAuthError); |
| 45 | const withdrawal = HttpServerRequest.schemaBodyJson( |
| 46 | Schema.Struct({ manifestHash: ArchiveObjectHash }), |
| 47 | ); |
| 48 | const 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 | |
| 59 | export 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 | ); |