Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
113 changes: 113 additions & 0 deletions apps/server/src/assets/AttachmentUpload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @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("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)),
);
});
193 changes: 193 additions & 0 deletions apps/server/src/assets/AttachmentUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// @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,
} 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";

const SIGNING_SECRET_NAME = "asset-access-signing-key";

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 attachmentId = createPendingAttachmentId();
const expiresAt = (yield* Clock.currentTimeMillis) + 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(() => {}));
});
66 changes: 66 additions & 0 deletions apps/server/src/attachmentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test";

import {
createAttachmentId,
createPendingAttachmentId,
parseAttachmentUuid,
planAttachmentClaim,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPathById,
sweepStalePendingAttachments,
} from "./attachmentStore.ts";

describe("attachmentStore", () => {
Expand Down Expand Up @@ -44,6 +48,16 @@ describe("attachmentStore", () => {
expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
});

it("reserves the pending attachment segment", () => {
const pendingId = createPendingAttachmentId();
expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending");
expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/);
expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending");
expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe(
"pending_thread",
);
});

it("resolves attachment path by id using the extension that exists on disk", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
Expand Down Expand Up @@ -77,4 +91,56 @@ describe("attachmentStore", () => {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("plans pending attachment claims with direct filename lookups", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
);
try {
const uuid = "00000000-0000-4000-8000-000000000001";
const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
NodeFS.writeFileSync(pendingPath, Buffer.from("pixels"));

expect(
planAttachmentClaim({
attachmentsDir,
threadId: "thread-1",
attachmentId: `pending-${uuid}`,
}),
).toEqual({
ok: true,
finalId: `thread-1-${uuid}`,
currentPath: pendingPath,
finalPath: NodePath.join(attachmentsDir, `thread-1-${uuid}.png`),
alreadyScoped: false,
});
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("removes expired pending and partial files without touching thread attachments", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"),
);
try {
const now = 1_800_000_000_000;
const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
const uuid = "00000000-0000-4000-8000-000000000002";
const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
for (const filePath of [pendingPath, threadPath, partialPath]) {
NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
}

expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 });
expect(NodeFS.existsSync(pendingPath)).toBe(false);
expect(NodeFS.existsSync(partialPath)).toBe(false);
expect(NodeFS.existsSync(threadPath)).toBe(true);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});
});
Loading
Loading