Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions apps/server/src/assets/AttachmentUpload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
import {
ATTACHMENT_UPLOAD_ROUTE_PREFIX,
deletePendingAttachment,
issueAttachmentUploadUrl,
storeAttachmentUpload,
validateAttachmentUploadToken,
} from "./AttachmentUpload.ts";

const testLayer = ServerSecretStore.layer.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })),
Layer.provideMerge(NodeServices.layer),
);

const uploadInput = {
name: "screenshot.png",
mimeType: "image/png",
sizeBytes: 6,
} as const;

describe("AttachmentUpload", () => {
it.effect("signs the attachment metadata and validates the upload token", () =>
Effect.gen(function* () {
const issued = yield* issueAttachmentUploadUrl(uploadInput);
expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending");

const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
expect(yield* validateAttachmentUploadToken(token)).toMatchObject({
kind: "attachment-upload",
attachmentId: issued.attachmentId,
name: "screenshot.png",
mimeType: "image/png",
sizeBytes: 6,
});
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects tampered and malformed upload tokens", () =>
Effect.gen(function* () {
const issued = yield* issueAttachmentUploadUrl(uploadInput);
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
const [payload, signature] = token.split(".");

expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull();
expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull();
expect(yield* validateAttachmentUploadToken("garbage")).toBeNull();
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects expired upload tokens", () =>
Effect.gen(function* () {
const issued = yield* issueAttachmentUploadUrl(uploadInput);
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);

yield* TestClock.adjust("11 minutes");
expect(yield* validateAttachmentUploadToken(token)).toBeNull();
}).pipe(Effect.provide(testLayer)),
);

it.effect("removes expired pending uploads while issuing a new upload URL", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const staleId = "pending-00000000-0000-4000-8000-0000000000cc";
const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`);
NodeFS.writeFileSync(stalePath, Buffer.from("pixels"));
NodeFS.utimesSync(stalePath, 0, 0);

yield* TestClock.adjust("25 hours");
yield* issueAttachmentUploadUrl(uploadInput);

expect(NodeFS.existsSync(stalePath)).toBe(false);
}).pipe(Effect.provide(testLayer)),
);

it.effect("stores the expected bytes without leaving temporary files", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const issued = yield* issueAttachmentUploadUrl(uploadInput);
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
const claims = yield* validateAttachmentUploadToken(token);
if (!claims) {
throw new Error("Expected valid upload claims.");
}

expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({
ok: false,
status: 400,
});
expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true });
expect(
NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)),
).toBe(true);
expect(
NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
).toEqual([]);
}).pipe(Effect.provide(testLayer)),
);

it.effect("deletes pending uploads without deleting thread-owned copies", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const uuid = "00000000-0000-4000-8000-0000000000dd";
const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`);
const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`);
NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));
NodeFS.writeFileSync(claimedPath, Buffer.from("pixels"));

yield* deletePendingAttachment(`pending-${uuid}`);
yield* deletePendingAttachment(`pending-${uuid}`);
yield* deletePendingAttachment(`thread-1-${uuid}`);

expect(NodeFS.existsSync(pendingPath)).toBe(false);
expect(NodeFS.existsSync(claimedPath)).toBe(true);
}).pipe(Effect.provide(testLayer)),
);
});
214 changes: 214 additions & 0 deletions apps/server/src/assets/AttachmentUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// @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 {
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"),
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 attachmentId = createPendingAttachmentId();
const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
const encodedPayload = base64UrlEncode(
encodeAttachmentUploadClaims({
version: 1,
kind: "attachment-upload",
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,
bytes: Uint8Array,
) {
if (bytes.byteLength !== claims.sizeBytes) {
return {
ok: false,
status: 400,
detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,
} satisfies StoreAttachmentUploadResult;
}

const config = yield* ServerConfig.ServerConfig;
const extension = 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;
return yield* Effect.gen(function* () {
yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
yield* fileSystem.writeFile(partPath, bytes);
yield* fileSystem.rename(partPath, finalPath);
return { ok: true } satisfies StoreAttachmentUploadResult;
}).pipe(
Effect.catch((cause) =>
fileSystem.remove(partPath, { force: true }).pipe(
Effect.orElseSucceed(() => undefined),
Effect.andThen(
Effect.logError("Failed to persist attachment upload.", {
attachmentId: claims.attachmentId,
cause,
}),
),
Effect.as({
ok: false,
status: 500,
detail: "Failed to persist upload.",
} satisfies StoreAttachmentUploadResult),
),
),
);
});

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(() => {}));
});
Loading
Loading