1import {
2 ATTACHMENT_UPLOAD_URL_TTL_MS,
3 type AttachmentCreateUploadUrlInput,
4 AttachmentUploadSigningKeyError,
5} from "@t3tools/contracts";
6import * as Clock from "effect/Clock";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import type * as HttpServerRequest from "effect/http/HttpServerRequest";
15
16import {
17 attachmentFileExtension,
18 createPendingAttachmentId,
19 parseThreadSegmentFromAttachmentId,
20 PENDING_ATTACHMENT_THREAD_SEGMENT,
21 resolveAttachmentPathById,
22 sweepStalePendingAttachments,
23} from "../attachmentStore.ts";
24import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
25import {
26 base64UrlDecodeUtf8,
27 base64UrlEncode,
28 signPayload,
29 timingSafeEqualBase64Url,
30} from "../auth/utils.ts";
31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
32import * as ServerConfig from "../config.ts";
33import { inferImageExtension } from "../imageMime.ts";
34
35export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload";
36
37// Asset download tokens share this key, but their signed claim kind is different.
38const SIGNING_SECRET_NAME = "asset-access-signing-key";
39const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000;
40const lastPendingSweepByDirectory = new Map<string, number>();
41
42const AttachmentUploadClaims = Schema.Struct({
43 version: Schema.Literal(1),
44 kind: Schema.Literal("attachment-upload"),
45 type: Schema.Literals(["image", "file"]).pipe(
46 Schema.withDecodingDefault(Effect.succeed("image" as const)),
47 ),
48 attachmentId: Schema.String,
49 name: Schema.String,
50 mimeType: Schema.String,
51 sizeBytes: Schema.Number,
52 expiresAt: Schema.Number,
53});
54export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type;
55
56const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims);
57const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson);
58const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson);
59
60function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null {
61 try {
62 return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload)));
63 } catch {
64 return null;
65 }
66}
67
68const loadSigningSecret = Effect.gen(function* () {
69 const secretStore = yield* ServerSecretStore.ServerSecretStore;
70 return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
71});
72
73export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* (
74 input: AttachmentCreateUploadUrlInput,
75) {
76 const secret = yield* loadSigningSecret.pipe(
77 Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })),
78 );
79 const config = yield* ServerConfig.ServerConfig;
80 const nowMs = yield* Clock.currentTimeMillis;
81 const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir);
82 if (
83 previousSweep === undefined ||
84 nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS
85 ) {
86 lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs);
87 const swept = sweepStalePendingAttachments({
88 attachmentsDir: config.attachmentsDir,
89 nowMs,
90 });
91 if (swept.deleted > 0) {
92 yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted });
93 }
94 }
95
96 const attachmentType = input.type ?? "image";
97 const attachmentId = createPendingAttachmentId(
98 attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
99 );
100 const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
101 const encodedPayload = base64UrlEncode(
102 encodeAttachmentUploadClaims({
103 version: 1,
104 kind: "attachment-upload",
105 type: attachmentType,
106 attachmentId,
107 name: input.name,
108 mimeType: input.mimeType,
109 sizeBytes: input.sizeBytes,
110 expiresAt,
111 }),
112 );
113
114 return {
115 attachmentId,
116 relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`,
117 expiresAt,
118 };
119});
120
121export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* (
122 token: string,
123) {
124 const [encodedPayload, signature, unexpectedSegment] = token.split(".");
125 if (!encodedPayload || !signature || unexpectedSegment) {
126 return null;
127 }
128
129 const secret = yield* loadSigningSecret.pipe(
130 Effect.tapError((cause) =>
131 Effect.logError("Failed to load the attachment upload signing key.", { cause }),
132 ),
133 Effect.orElseSucceed(() => null),
134 );
135 if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) {
136 return null;
137 }
138
139 const claims = decodeClaims(encodedPayload);
140 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) {
141 return null;
142 }
143 return claims;
144});
145
146export type StoreAttachmentUploadResult =
147 | { readonly ok: true }
148 | { readonly ok: false; readonly status: number; readonly detail: string };
149
150export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
151 claims: AttachmentUploadClaims,
152 body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
153) {
154 if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
155 return {
156 ok: false,
157 status: 400,
158 detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
159 } satisfies StoreAttachmentUploadResult;
160 }
161
162 const config = yield* ServerConfig.ServerConfig;
163 const extension =
164 claims.type === "file"
165 ? attachmentFileExtension(claims.name)
166 : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
167 const relativePath = `${claims.attachmentId}${extension}`;
168 const finalPath = resolveAttachmentRelativePath({
169 attachmentsDir: config.attachmentsDir,
170 relativePath,
171 });
172 const crypto = yield* Crypto.Crypto;
173 const partId = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
174 const partPath = resolveAttachmentRelativePath({
175 attachmentsDir: config.attachmentsDir,
176 relativePath: `${relativePath}.${partId}.part`,
177 });
178 if (!finalPath || !partPath) {
179 return { ok: false, status: 500, detail: "Failed to resolve attachment path." };
180 }
181
182 const fileSystem = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 let receivedBytes = 0;
185 const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
186 return yield* Effect.gen(function* () {
187 yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
188 yield* Stream.run(
189 bodyStream.pipe(
190 Stream.takeWhile((chunk) => {
191 receivedBytes += chunk.byteLength;
192 return receivedBytes <= claims.sizeBytes;
193 }),
194 ),
195 fileSystem.sink(partPath),
196 );
197 if (receivedBytes !== claims.sizeBytes) {
198 return {
199 ok: false,
200 status: 400,
201 detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`,
202 } satisfies StoreAttachmentUploadResult;
203 }
204 yield* fileSystem.rename(partPath, finalPath);
205 return { ok: true } satisfies StoreAttachmentUploadResult;
206 }).pipe(
207 Effect.catch((cause) =>
208 Effect.logError("Failed to persist attachment upload.", {
209 attachmentId: claims.attachmentId,
210 cause,
211 }).pipe(
212 Effect.as({
213 ok: false,
214 status: 500,
215 detail: "Failed to persist upload.",
216 } satisfies StoreAttachmentUploadResult),
217 ),
218 ),
219 Effect.ensuring(
220 fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
221 ),
222 );
223});
224
225export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* (
226 attachmentId: string,
227) {
228 if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
229 return;
230 }
231
232 const config = yield* ServerConfig.ServerConfig;
233 const attachmentPath = resolveAttachmentPathById({
234 attachmentsDir: config.attachmentsDir,
235 attachmentId,
236 });
237 if (!attachmentPath) {
238 return;
239 }
240
241 const fileSystem = yield* FileSystem.FileSystem;
242 yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {}));
243});