Skip to content

Commit 3ed6520

Browse files
authored
Merge pull request #140 from pylon-code/upstream/2026-08-28-file-uploads
feat(server): accept PDF, ZIP, and other file uploads up to 50MB
2 parents 68e3560 + a570ecc commit 3ed6520

42 files changed

Lines changed: 1138 additions & 95 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/mobile/src/features/threads/ThreadFeed.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1040,7 +1040,9 @@ function renderFeedEntry(
10401040
const isUser = message.role === "user";
10411041
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
10421042
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
1043-
const attachments = message.attachments ?? [];
1043+
const attachments = (message.attachments ?? []).filter(
1044+
(attachment) => attachment.type === "image",
1045+
);
10441046
const hasReviewCommentContext = message.text.includes("<review_comment");
10451047
// A bubble that sizes itself from its content cannot lay out a block whose
10461048
// intrinsic width overflows `maxWidth`: Android positions the bubble's

apps/server/src/assets/AssetAccess.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
timingSafeEqualBase64Url,
3838
} from "../auth/utils.ts";
3939
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
40-
import { resolveAttachmentPathById } from "../attachmentStore.ts";
40+
import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
4141
import * as ServerConfig from "../config.ts";
4242
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4343
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
@@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([
7979
version: Schema.Literal(1),
8080
kind: Schema.Literal("attachment"),
8181
attachmentId: Schema.String,
82+
/** Decided at mint time. Absent tokens (from before this field) serve
83+
inline, which is only ever the image case. */
84+
download: Schema.optionalKey(Schema.Boolean),
85+
/** Display name and mime the caller supplied at mint time; drive the
86+
download filename and Content-Type. */
87+
fileName: Schema.optionalKey(Schema.String),
88+
mimeType: Schema.optionalKey(Schema.String),
8289
expiresAt: Schema.Number,
8390
}),
8491
Schema.Struct({
@@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
101108
const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
102109
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
103110

104-
export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
111+
export type ResolvedAsset = {
112+
readonly kind: "file";
113+
readonly path: string;
114+
readonly download?: boolean;
115+
readonly fileName?: string;
116+
readonly mimeType?: string;
117+
};
105118

106119
function decodeClaims(encodedPayload: string): AssetClaims | null {
107120
try {
@@ -297,13 +310,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
297310
resource: input.resource,
298311
});
299312
}
313+
// Generic files carry their extension inside the attachment id (that
314+
// shape resolves the on-disk path); images do not. Only generic files
315+
// download, images render inline in chat.
316+
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
300317
claims = {
301318
version: 1,
302319
kind: "attachment",
303320
attachmentId: input.resource.attachmentId,
321+
...(isGenericFile ? { download: true } : {}),
322+
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
323+
...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
304324
expiresAt,
305325
};
306-
fileName = path.basename(attachmentPath);
326+
fileName = input.resource.fileName ?? path.basename(attachmentPath);
307327
break;
308328
}
309329
case "project-favicon": {
@@ -478,7 +498,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
478498
Effect.orElseSucceed(() => Option.none()),
479499
);
480500
return Option.isSome(info) && info.value.type === "File"
481-
? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
501+
? ({
502+
kind: "file",
503+
path: attachmentPath,
504+
...(claims.download ? { download: true } : {}),
505+
...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
506+
...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
507+
} satisfies ResolvedAsset)
482508
: null;
483509
}
484510

apps/server/src/assets/AttachmentUpload.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ import * as NodePath from "node:path";
44

55
import * as NodeServices from "@effect/platform-node/NodeServices";
66
import { describe, expect, it } from "@effect/vitest";
7+
import * as Deferred from "effect/Deferred";
78
import * as Effect from "effect/Effect";
9+
import * as Fiber from "effect/Fiber";
810
import * as Layer from "effect/Layer";
11+
import * as Schema from "effect/Schema";
12+
import * as Stream from "effect/Stream";
913
import * as TestClock from "effect/testing/TestClock";
1014

1115
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16+
import { base64UrlEncode, signPayload } from "../auth/utils.ts";
1217
import * as ServerConfig from "../config.ts";
1318
import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
1419
import {
@@ -30,6 +35,19 @@ const uploadInput = {
3035
sizeBytes: 6,
3136
} as const;
3237

38+
const LegacyAttachmentUploadClaims = Schema.Struct({
39+
version: Schema.Literal(1),
40+
kind: Schema.Literal("attachment-upload"),
41+
attachmentId: Schema.String,
42+
name: Schema.String,
43+
mimeType: Schema.String,
44+
sizeBytes: Schema.Number,
45+
expiresAt: Schema.Number,
46+
});
47+
const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect(
48+
Schema.fromJsonString(LegacyAttachmentUploadClaims),
49+
);
50+
3351
describe("AttachmentUpload", () => {
3452
it.effect("signs the attachment metadata and validates the upload token", () =>
3553
Effect.gen(function* () {
@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {
5977
}).pipe(Effect.provide(testLayer)),
6078
);
6179

80+
it.effect("accepts unexpired image upload tokens issued before file support", () =>
81+
Effect.gen(function* () {
82+
const issued = yield* issueAttachmentUploadUrl(uploadInput);
83+
const secretStore = yield* ServerSecretStore.ServerSecretStore;
84+
const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32);
85+
const encodedPayload = base64UrlEncode(
86+
yield* encodeLegacyAttachmentUploadClaims({
87+
version: 1,
88+
kind: "attachment-upload",
89+
attachmentId: issued.attachmentId,
90+
name: uploadInput.name,
91+
mimeType: uploadInput.mimeType,
92+
sizeBytes: uploadInput.sizeBytes,
93+
expiresAt: issued.expiresAt,
94+
}),
95+
);
96+
const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`;
97+
98+
expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({
99+
type: "image",
100+
attachmentId: issued.attachmentId,
101+
});
102+
}).pipe(Effect.provide(testLayer)),
103+
);
104+
62105
it.effect("rejects expired upload tokens", () =>
63106
Effect.gen(function* () {
64107
const issued = yield* issueAttachmentUploadUrl(uploadInput);
@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {
108151
}).pipe(Effect.provide(testLayer)),
109152
);
110153

154+
it.effect("streams generic files to a path with their original extension", () =>
155+
Effect.gen(function* () {
156+
const config = yield* ServerConfig.ServerConfig;
157+
const issued = yield* issueAttachmentUploadUrl({
158+
type: "file",
159+
name: "report.PDF",
160+
mimeType: "application/pdf",
161+
sizeBytes: 6,
162+
});
163+
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
164+
const claims = yield* validateAttachmentUploadToken(token);
165+
if (!claims) {
166+
throw new Error("Expected valid upload claims.");
167+
}
168+
169+
expect(
170+
yield* storeAttachmentUpload(
171+
claims,
172+
Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])),
173+
),
174+
).toEqual({ ok: true });
175+
expect(issued.attachmentId).toMatch(/-pdf$/);
176+
expect(
177+
NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)),
178+
).toEqual(Buffer.from([1, 2, 3, 4, 5, 6]));
179+
180+
yield* deletePendingAttachment(issued.attachmentId);
181+
expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
182+
}).pipe(Effect.provide(testLayer)),
183+
);
184+
185+
it.effect("removes partial streamed uploads that exceed their signed size", () =>
186+
Effect.gen(function* () {
187+
const config = yield* ServerConfig.ServerConfig;
188+
const issued = yield* issueAttachmentUploadUrl(uploadInput);
189+
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
190+
const claims = yield* validateAttachmentUploadToken(token);
191+
if (!claims) {
192+
throw new Error("Expected valid upload claims.");
193+
}
194+
195+
expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({
196+
ok: false,
197+
status: 400,
198+
});
199+
expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
200+
}).pipe(Effect.provide(testLayer)),
201+
);
202+
203+
it.effect("removes partial streamed uploads when the upload is interrupted", () =>
204+
Effect.gen(function* () {
205+
const config = yield* ServerConfig.ServerConfig;
206+
const issued = yield* issueAttachmentUploadUrl(uploadInput);
207+
const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
208+
const claims = yield* validateAttachmentUploadToken(token);
209+
if (!claims) {
210+
throw new Error("Expected valid upload claims.");
211+
}
212+
213+
const nextChunkRequested = yield* Deferred.make<void>();
214+
const body = Stream.make(new Uint8Array([1, 2, 3])).pipe(
215+
Stream.concat(
216+
Stream.fromEffect(
217+
Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)),
218+
),
219+
),
220+
);
221+
const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped);
222+
223+
yield* Deferred.await(nextChunkRequested);
224+
expect(
225+
NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
226+
).toHaveLength(1);
227+
228+
yield* Fiber.interrupt(upload);
229+
expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
230+
}).pipe(Effect.provide(testLayer)),
231+
);
232+
111233
it.effect("deletes pending uploads without deleting thread-owned copies", () =>
112234
Effect.gen(function* () {
113235
const config = yield* ServerConfig.ServerConfig;

apps/server/src/assets/AttachmentUpload.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem";
1212
import * as Option from "effect/Option";
1313
import * as Path from "effect/Path";
1414
import * as Schema from "effect/Schema";
15+
import * as Stream from "effect/Stream";
16+
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
1517

1618
import {
19+
attachmentFileExtension,
1720
createPendingAttachmentId,
1821
parseThreadSegmentFromAttachmentId,
1922
PENDING_ATTACHMENT_THREAD_SEGMENT,
@@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map<string, number>();
4144
const AttachmentUploadClaims = Schema.Struct({
4245
version: Schema.Literal(1),
4346
kind: Schema.Literal("attachment-upload"),
47+
type: Schema.Literals(["image", "file"]).pipe(
48+
Schema.withDecodingDefault(Effect.succeed("image" as const)),
49+
),
4450
attachmentId: Schema.String,
4551
name: Schema.String,
4652
mimeType: Schema.String,
@@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f
8995
}
9096
}
9197

92-
const attachmentId = createPendingAttachmentId();
98+
const attachmentType = input.type ?? "image";
99+
const attachmentId = createPendingAttachmentId(
100+
attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
101+
);
93102
const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
94103
const encodedPayload = base64UrlEncode(
95104
encodeAttachmentUploadClaims({
96105
version: 1,
97106
kind: "attachment-upload",
107+
type: attachmentType,
98108
attachmentId,
99109
name: input.name,
100110
mimeType: input.mimeType,
@@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult =
141151

142152
export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
143153
claims: AttachmentUploadClaims,
144-
bytes: Uint8Array,
154+
body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
145155
) {
146-
if (bytes.byteLength !== claims.sizeBytes) {
156+
if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
147157
return {
148158
ok: false,
149159
status: 400,
150-
detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,
160+
detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
151161
} satisfies StoreAttachmentUploadResult;
152162
}
153163

154164
const config = yield* ServerConfig.ServerConfig;
155-
const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
165+
const extension =
166+
claims.type === "file"
167+
? attachmentFileExtension(claims.name)
168+
: inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
156169
const relativePath = `${claims.attachmentId}${extension}`;
157170
const finalPath = resolveAttachmentRelativePath({
158171
attachmentsDir: config.attachmentsDir,
@@ -168,28 +181,44 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
168181

169182
const fileSystem = yield* FileSystem.FileSystem;
170183
const path = yield* Path.Path;
184+
let receivedBytes = 0;
185+
const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
171186
return yield* Effect.gen(function* () {
172187
yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
173-
yield* fileSystem.writeFile(partPath, bytes);
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+
}
174204
yield* fileSystem.rename(partPath, finalPath);
175205
return { ok: true } satisfies StoreAttachmentUploadResult;
176206
}).pipe(
177207
Effect.catch((cause) =>
178-
fileSystem.remove(partPath, { force: true }).pipe(
179-
Effect.orElseSucceed(() => undefined),
180-
Effect.andThen(
181-
Effect.logError("Failed to persist attachment upload.", {
182-
attachmentId: claims.attachmentId,
183-
cause,
184-
}),
185-
),
208+
Effect.logError("Failed to persist attachment upload.", {
209+
attachmentId: claims.attachmentId,
210+
cause,
211+
}).pipe(
186212
Effect.as({
187213
ok: false,
188214
status: 500,
189215
detail: "Failed to persist upload.",
190216
} satisfies StoreAttachmentUploadResult),
191217
),
192218
),
219+
Effect.ensuring(
220+
fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
221+
),
193222
);
194223
});
195224

0 commit comments

Comments
 (0)