// @effect-diagnostics nodeBuiltinImport:offimport * as NodeCrypto from "node:crypto"; import { ATTACHMENT_UPLOAD_URL_TTL_MS, type AttachmentCreateUploadUrlInput, AttachmentUploadSigningKeyError,} from "@t3tools/contracts";import * as Clock from "effect/Clock";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { attachmentFileExtension, createPendingAttachmentId, parseThreadSegmentFromAttachmentId, PENDING_ATTACHMENT_THREAD_SEGMENT, resolveAttachmentPathById, sweepStalePendingAttachments,} from "../attachmentStore.ts";import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";import { base64UrlDecodeUtf8, base64UrlEncode, signPayload, timingSafeEqualBase64Url,} from "../auth/utils.ts";import * as ServerSecretStore from "../auth/ServerSecretStore.ts";import * as ServerConfig from "../config.ts";import { inferImageExtension } from "../imageMime.ts"; export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; // Asset download tokens share this key, but their signed claim kind is different.const SIGNING_SECRET_NAME = "asset-access-signing-key";const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000;const lastPendingSweepByDirectory = new Map<string, number>(); const AttachmentUploadClaims = Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment-upload"), type: Schema.Literals(["image", "file"]).pipe( Schema.withDecodingDefault(Effect.succeed("image" as const)), ), attachmentId: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, expiresAt: Schema.Number,});export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type; const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims);const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson);const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson); function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { try { return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload))); } catch { return null; }} const loadSigningSecret = Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);}); export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( input: AttachmentCreateUploadUrlInput,) { const secret = yield* loadSigningSecret.pipe( Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), ); const config = yield* ServerConfig.ServerConfig; const nowMs = yield* Clock.currentTimeMillis; const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); if ( previousSweep === undefined || nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS ) { lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs); const swept = sweepStalePendingAttachments({ attachmentsDir: config.attachmentsDir, nowMs, }); if (swept.deleted > 0) { yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); } } const attachmentType = input.type ?? "image"; const attachmentId = createPendingAttachmentId( attachmentType === "file" ? attachmentFileExtension(input.name) : undefined, ); const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; const encodedPayload = base64UrlEncode( encodeAttachmentUploadClaims({ version: 1, kind: "attachment-upload", type: attachmentType, attachmentId, name: input.name, mimeType: input.mimeType, sizeBytes: input.sizeBytes, expiresAt, }), ); return { attachmentId, relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, expiresAt, };}); export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( token: string,) { const [encodedPayload, signature, unexpectedSegment] = token.split("."); if (!encodedPayload || !signature || unexpectedSegment) { return null; } const secret = yield* loadSigningSecret.pipe( Effect.tapError((cause) => Effect.logError("Failed to load the attachment upload signing key.", { cause }), ), Effect.orElseSucceed(() => null), ); if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { return null; } const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { return null; } return claims;}); export type StoreAttachmentUploadResult = | { readonly ok: true } | { readonly ok: false; readonly status: number; readonly detail: string }; export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( claims: AttachmentUploadClaims, body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],) { if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) { return { ok: false, status: 400, detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`, } satisfies StoreAttachmentUploadResult; } const config = yield* ServerConfig.ServerConfig; const extension = claims.type === "file" ? attachmentFileExtension(claims.name) : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); const relativePath = `${claims.attachmentId}${extension}`; const finalPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, relativePath, }); const partPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, }); if (!finalPath || !partPath) { return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; let receivedBytes = 0; const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body; return yield* Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); yield* Stream.run( bodyStream.pipe( Stream.takeWhile((chunk) => { receivedBytes += chunk.byteLength; return receivedBytes <= claims.sizeBytes; }), ), fileSystem.sink(partPath), ); if (receivedBytes !== claims.sizeBytes) { return { ok: false, status: 400, detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`, } satisfies StoreAttachmentUploadResult; } yield* fileSystem.rename(partPath, finalPath); return { ok: true } satisfies StoreAttachmentUploadResult; }).pipe( Effect.catch((cause) => Effect.logError("Failed to persist attachment upload.", { attachmentId: claims.attachmentId, cause, }).pipe( Effect.as({ ok: false, status: 500, detail: "Failed to persist upload.", } satisfies StoreAttachmentUploadResult), ), ), Effect.ensuring( fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)), ), );}); export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( attachmentId: string,) { if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { return; } const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId, }); if (!attachmentPath) { return; } const fileSystem = yield* FileSystem.FileSystem; yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {}));});
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeCrypto from "node:crypto";
import {
ATTACHMENT_UPLOAD_URL_TTL_MS,
type AttachmentCreateUploadUrlInput,
AttachmentUploadSigningKeyError,
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import {
attachmentFileExtension,
createPendingAttachmentId,
parseThreadSegmentFromAttachmentId,
PENDING_ATTACHMENT_THREAD_SEGMENT,
resolveAttachmentPathById,
sweepStalePendingAttachments,
} from "../attachmentStore.ts";
import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
import {
base64UrlDecodeUtf8,
base64UrlEncode,
signPayload,
timingSafeEqualBase64Url,
} from "../auth/utils.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import { inferImageExtension } from "../imageMime.ts";
export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload";
// Asset download tokens share this key, but their signed claim kind is different.
const SIGNING_SECRET_NAME = "asset-access-signing-key";
const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000;
const lastPendingSweepByDirectory = new Map<string, number>();
const AttachmentUploadClaims = Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("attachment-upload"),
type: Schema.Literals(["image", "file"]).pipe(
Schema.withDecodingDefault(Effect.succeed("image" as const)),
),
attachmentId: Schema.String,
name: Schema.String,
mimeType: Schema.String,
sizeBytes: Schema.Number,
expiresAt: Schema.Number,
});
export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type;
const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims);
const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson);
const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson);
function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null {
try {
return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload)));
} catch {
return null;
}
}
const loadSigningSecret = Effect.gen(function* () {
const secretStore = yield* ServerSecretStore.ServerSecretStore;
return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
});
export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* (
input: AttachmentCreateUploadUrlInput,
) {
const secret = yield* loadSigningSecret.pipe(
Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })),
);
const config = yield* ServerConfig.ServerConfig;
const nowMs = yield* Clock.currentTimeMillis;
const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir);
if (
previousSweep === undefined ||
nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS
) {
lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs);
const swept = sweepStalePendingAttachments({
attachmentsDir: config.attachmentsDir,
nowMs,
});
if (swept.deleted > 0) {
yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted });
}
}
const attachmentType = input.type ?? "image";
const attachmentId = createPendingAttachmentId(
attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
);
const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
const encodedPayload = base64UrlEncode(
encodeAttachmentUploadClaims({
version: 1,
kind: "attachment-upload",
type: attachmentType,
attachmentId,
name: input.name,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
expiresAt,
}),
);
return {
attachmentId,
relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`,
expiresAt,
};
});
export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* (
token: string,
) {
const [encodedPayload, signature, unexpectedSegment] = token.split(".");
if (!encodedPayload || !signature || unexpectedSegment) {
return null;
}
const secret = yield* loadSigningSecret.pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to load the attachment upload signing key.", { cause }),
),
Effect.orElseSucceed(() => null),
);
if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) {
return null;
}
const claims = decodeClaims(encodedPayload);
if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) {
return null;
}
return claims;
});
export type StoreAttachmentUploadResult =
| { readonly ok: true }
| { readonly ok: false; readonly status: number; readonly detail: string };
export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
claims: AttachmentUploadClaims,
body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
) {
if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
return {
ok: false,
status: 400,
detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
} satisfies StoreAttachmentUploadResult;
}
const config = yield* ServerConfig.ServerConfig;
const extension =
claims.type === "file"
? attachmentFileExtension(claims.name)
: inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
const relativePath = `${claims.attachmentId}${extension}`;
const finalPath = resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
relativePath,
});
const partPath = resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`,
});
if (!finalPath || !partPath) {
return { ok: false, status: 500, detail: "Failed to resolve attachment path." };
}
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
let receivedBytes = 0;
const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
return yield* Effect.gen(function* () {
yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
yield* Stream.run(
bodyStream.pipe(
Stream.takeWhile((chunk) => {
receivedBytes += chunk.byteLength;
return receivedBytes <= claims.sizeBytes;
}),
),
fileSystem.sink(partPath),
);
if (receivedBytes !== claims.sizeBytes) {
return {
ok: false,
status: 400,
detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`,
} satisfies StoreAttachmentUploadResult;
}
yield* fileSystem.rename(partPath, finalPath);
return { ok: true } satisfies StoreAttachmentUploadResult;
}).pipe(
Effect.catch((cause) =>
Effect.logError("Failed to persist attachment upload.", {
attachmentId: claims.attachmentId,
cause,
}).pipe(
Effect.as({
ok: false,
status: 500,
detail: "Failed to persist upload.",
} satisfies StoreAttachmentUploadResult),
),
),
Effect.ensuring(
fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
),
);
});
export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* (
attachmentId: string,
) {
if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
return;
}
const config = yield* ServerConfig.ServerConfig;
const attachmentPath = resolveAttachmentPathById({
attachmentsDir: config.attachmentsDir,
attachmentId,
});
if (!attachmentPath) {
return;
}
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {}));
});