Skip to content

Commit 05378a7

Browse files
committed
fix(terminal): support browser image paste
1 parent 4308bf6 commit 05378a7

8 files changed

Lines changed: 730 additions & 33 deletions
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
export type TerminalImagePastePayload = {
2+
readonly data: string
3+
readonly mediaType: string
4+
readonly name: string
5+
readonly size: number
6+
}
7+
8+
export type TerminalImagePastePlan =
9+
| {
10+
readonly _tag: "InvalidTerminalImagePaste"
11+
readonly message: string
12+
}
13+
| {
14+
readonly _tag: "ValidTerminalImagePaste"
15+
readonly containerPath: string
16+
readonly decodedBytes: number
17+
readonly normalizedBase64: string
18+
}
19+
20+
export const terminalImagePasteDirectory = "/home/dev/.docker-git/pasted-images"
21+
export const terminalImagePasteMaxBytes = 10 * 1024 * 1024
22+
23+
const base64Pattern = /^(?:[+/0-9A-Za-z]{4})*(?:[+/0-9A-Za-z]{2}==|[+/0-9A-Za-z]{3}=)?$/u
24+
const terminalImagePasteMaxBase64Length = Math.ceil(terminalImagePasteMaxBytes / 3) * 4
25+
const safeFileNameMaxLength = 72
26+
27+
const imageMediaTypeExtensions = new Map<string, string>([
28+
["image/gif", "gif"],
29+
["image/jpeg", "jpg"],
30+
["image/png", "png"],
31+
["image/webp", "webp"]
32+
])
33+
34+
export const isSupportedTerminalImageMediaType = (mediaType: string): boolean =>
35+
imageMediaTypeExtensions.has(mediaType.toLowerCase())
36+
37+
const extensionForMediaType = (mediaType: string): string | null =>
38+
imageMediaTypeExtensions.get(mediaType.toLowerCase()) ?? null
39+
40+
const normalizeBase64 = (data: string): string => data.replace(/\s+/gu, "")
41+
42+
const decodedBase64Bytes = (data: string): number | null => {
43+
if (data.length === 0 || data.length % 4 !== 0 || !base64Pattern.test(data)) {
44+
return null
45+
}
46+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0
47+
return data.length / 4 * 3 - padding
48+
}
49+
50+
const lastPathSegment = (name: string): string => {
51+
const segments = name.split(/[\\/]/u)
52+
return segments.at(-1) ?? ""
53+
}
54+
55+
export const sanitizeTerminalImageBaseName = (name: string): string => {
56+
const withoutExtension = lastPathSegment(name).replace(/\.[^.]*$/u, "")
57+
const sanitized = withoutExtension
58+
.replace(/[^0-9A-Za-z._-]+/gu, "-")
59+
.replace(/^[.-]+/u, "")
60+
.replace(/[.-]+$/u, "")
61+
.slice(0, safeFileNameMaxLength)
62+
return sanitized.length > 0 ? sanitized : "clipboard-image"
63+
}
64+
65+
const terminalImageFileName = (
66+
id: string,
67+
name: string,
68+
mediaType: string
69+
): string | null => {
70+
const extension = extensionForMediaType(mediaType)
71+
if (extension === null) {
72+
return null
73+
}
74+
return `${id}-${sanitizeTerminalImageBaseName(name)}.${extension}`
75+
}
76+
77+
export const createTerminalImagePastePlan = (
78+
payload: TerminalImagePastePayload,
79+
id: string
80+
): TerminalImagePastePlan => {
81+
const mediaType = payload.mediaType.toLowerCase()
82+
const fileName = terminalImageFileName(id, payload.name, mediaType)
83+
if (fileName === null) {
84+
return {
85+
_tag: "InvalidTerminalImagePaste",
86+
message: `Unsupported image type: ${payload.mediaType || "unknown"}.`
87+
}
88+
}
89+
if (!Number.isFinite(payload.size) || payload.size <= 0) {
90+
return {
91+
_tag: "InvalidTerminalImagePaste",
92+
message: "Image payload is empty."
93+
}
94+
}
95+
if (payload.size > terminalImagePasteMaxBytes) {
96+
return {
97+
_tag: "InvalidTerminalImagePaste",
98+
message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`
99+
}
100+
}
101+
const normalizedBase64 = normalizeBase64(payload.data)
102+
if (normalizedBase64.length > terminalImagePasteMaxBase64Length) {
103+
return {
104+
_tag: "InvalidTerminalImagePaste",
105+
message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`
106+
}
107+
}
108+
const decodedBytes = decodedBase64Bytes(normalizedBase64)
109+
if (decodedBytes === null) {
110+
return {
111+
_tag: "InvalidTerminalImagePaste",
112+
message: "Image payload is not valid base64."
113+
}
114+
}
115+
if (decodedBytes !== payload.size) {
116+
return {
117+
_tag: "InvalidTerminalImagePaste",
118+
message: "Image payload size does not match its base64 data."
119+
}
120+
}
121+
return {
122+
_tag: "ValidTerminalImagePaste",
123+
containerPath: `${terminalImagePasteDirectory}/${fileName}`,
124+
decodedBytes,
125+
normalizedBase64
126+
}
127+
}

packages/api/src/services/terminal-sessions.ts

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,29 @@ import * as FileSystem from "@effect/platform/FileSystem"
77
import * as ParseResult from "@effect/schema/ParseResult"
88
import * as Schema from "@effect/schema/Schema"
99
import { Effect, Either } from "effect"
10+
import { Buffer } from "node:buffer"
11+
import { spawn } from "node:child_process"
1012
import { randomUUID } from "node:crypto"
1113
import type { IncomingMessage, Server as HttpServer } from "node:http"
1214
import type { Duplex } from "node:stream"
1315
import { WebSocket, WebSocketServer, type RawData } from "ws"
1416

1517
import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
1618
import { ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
17-
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
1819
import { emitProjectEvent } from "./events.js"
20+
import {
21+
createTerminalImagePastePlan,
22+
terminalImagePasteDirectory,
23+
type TerminalImagePastePayload
24+
} from "./terminal-image-paste-core.js"
25+
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
1926
import { getProjectItemById, upProject } from "./projects.js"
2027
import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js"
2128

2229
type TerminalClientMessage =
2330
| { readonly type: "input"; readonly data: string }
2431
| { readonly type: "resize"; readonly cols: number; readonly rows: number }
32+
| ({ readonly type: "image" } & TerminalImagePastePayload)
2533
| { readonly type: "close" }
2634

2735
type TerminalServerMessage =
@@ -36,6 +44,7 @@ type TerminalRecord = {
3644
socket: WebSocket | null
3745
attachTimeout: ReturnType<typeof setTimeout> | null
3846
detachTimeout: ReturnType<typeof setTimeout> | null
47+
projectContainerName: string
3948
projectId: string
4049
prepared: ReturnType<typeof prepareProjectSsh>
4150
}
@@ -56,6 +65,13 @@ const TerminalClientMessageSchema = Schema.parseJson(
5665
cols: Schema.Number,
5766
rows: Schema.Number
5867
}),
68+
Schema.Struct({
69+
type: Schema.Literal("image"),
70+
data: Schema.String,
71+
mediaType: Schema.String,
72+
name: Schema.String,
73+
size: Schema.Number
74+
}),
5975
Schema.Struct({
6076
type: Schema.Literal("close")
6177
})
@@ -264,6 +280,118 @@ const writePtyInput = (pty: PtyBridge | null, data: string): void => {
264280
}
265281
}
266282

283+
const shellQuote = (value: string): string => `'${value.replace(/'/gu, "'\\''")}'`
284+
285+
const writeBufferToProjectContainer = (
286+
containerName: string,
287+
containerPath: string,
288+
buffer: Buffer
289+
): Effect.Effect<void, ApiInternalError> =>
290+
Effect.async((resume) => {
291+
const child = spawn(
292+
"docker",
293+
[
294+
"exec",
295+
"-i",
296+
"-u",
297+
"dev",
298+
containerName,
299+
"bash",
300+
"--noprofile",
301+
"--norc",
302+
"-c",
303+
`mkdir -p ${shellQuote(terminalImagePasteDirectory)} && cat > ${shellQuote(containerPath)}`
304+
],
305+
{
306+
cwd: process.cwd(),
307+
stdio: ["pipe", "ignore", "pipe"]
308+
}
309+
)
310+
const stderrChunks: Array<Buffer> = []
311+
let completed = false
312+
const resumeOnce = (effect: Effect.Effect<void, ApiInternalError>): void => {
313+
if (completed) {
314+
return
315+
}
316+
completed = true
317+
resume(effect)
318+
}
319+
child.stderr.on("data", (chunk: Buffer | string) => {
320+
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
321+
})
322+
child.stdin.on("error", (error) => {
323+
resumeOnce(Effect.fail(new ApiInternalError({
324+
message: `Failed to write pasted image to ${containerName}.`,
325+
cause: error
326+
})))
327+
})
328+
child.on("error", (error) => {
329+
resumeOnce(Effect.fail(new ApiInternalError({
330+
message: `Failed to run docker exec for ${containerName}.`,
331+
cause: error
332+
})))
333+
})
334+
child.on("close", (exitCode) => {
335+
if (exitCode === 0) {
336+
resumeOnce(Effect.void)
337+
return
338+
}
339+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim()
340+
resumeOnce(Effect.fail(new ApiInternalError({
341+
message: stderr.length > 0
342+
? `Failed to save pasted image: ${stderr}`
343+
: `Failed to save pasted image; docker exec exited with code ${exitCode ?? "unknown"}.`
344+
})))
345+
})
346+
child.stdin.end(buffer)
347+
})
348+
349+
const saveTerminalImagePaste = (
350+
record: TerminalRecord,
351+
payload: TerminalImagePastePayload
352+
): Effect.Effect<string, ApiInternalError> =>
353+
Effect.gen(function*(_) {
354+
const plan = createTerminalImagePastePlan(payload, randomUUID())
355+
if (plan._tag === "InvalidTerminalImagePaste") {
356+
return yield* _(Effect.fail(new ApiInternalError({ message: plan.message })))
357+
}
358+
const bytes = Buffer.from(plan.normalizedBase64, "base64")
359+
if (bytes.length !== plan.decodedBytes) {
360+
return yield* _(Effect.fail(new ApiInternalError({ message: "Decoded image size changed during upload." })))
361+
}
362+
yield* _(writeBufferToProjectContainer(record.projectContainerName, plan.containerPath, bytes))
363+
return plan.containerPath
364+
})
365+
366+
const terminalOutputLine = (line: string): string => `\r\n[docker-git] ${line}\r\n`
367+
368+
const handleImagePasteMessage = (
369+
record: TerminalRecord,
370+
message: Extract<TerminalClientMessage, { readonly type: "image" }>
371+
): void => {
372+
Effect.runFork(
373+
saveTerminalImagePaste(record, message).pipe(
374+
Effect.tap((containerPath) =>
375+
Effect.sync(() => {
376+
sendServerMessage(record.socket, {
377+
type: "output",
378+
data: terminalOutputLine(`Pasted image saved: ${containerPath}`)
379+
})
380+
writePtyInput(record.pty, `${containerPath} `)
381+
})
382+
),
383+
Effect.catchAll((error) =>
384+
Effect.sync(() => {
385+
sendServerMessage(record.socket, {
386+
type: "output",
387+
data: terminalOutputLine(`Failed to paste image: ${error.message}`)
388+
})
389+
})
390+
)
391+
)
392+
)
393+
}
394+
267395
const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => {
268396
if (pty === null) {
269397
return
@@ -321,7 +449,8 @@ const createAttachTimeout = (sessionId: string): ReturnType<typeof setTimeout> =
321449

322450
const registerRecord = (
323451
projectId: string,
324-
prepared: ReturnType<typeof prepareProjectSsh>
452+
prepared: ReturnType<typeof prepareProjectSsh>,
453+
projectContainerName: string
325454
): TerminalSession => {
326455
const session: TerminalSession = {
327456
createdAt: nowIso(),
@@ -334,6 +463,7 @@ const registerRecord = (
334463
attachTimeout: null,
335464
detachTimeout: null,
336465
prepared,
466+
projectContainerName,
337467
projectId,
338468
pty: null,
339469
session,
@@ -378,7 +508,7 @@ export const createTerminalSession = (
378508
})
379509
)
380510
const prepared = prepareProjectSsh(projectItem)
381-
const session = registerRecord(projectId, prepared)
511+
const session = registerRecord(projectId, prepared, projectItem.containerName)
382512
yield* _(
383513
Effect.sync(() => {
384514
emitProjectEvent(projectId, "project.ssh.session", {
@@ -451,6 +581,10 @@ const handleSocketMessage = (record: TerminalRecord, raw: RawData): void => {
451581
resizePty(record.pty, clampTerminalSize(message.cols, 120), clampTerminalSize(message.rows, 32))
452582
return
453583
}
584+
if (message.type === "image") {
585+
handleImagePasteMessage(record, message)
586+
return
587+
}
454588
handleCloseMessage(record)
455589
}
456590

0 commit comments

Comments
 (0)