Archived
feat: support general chat file attachments
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
|
||||
let workspace: string;
|
||||
let externalDirectories: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
externalDirectories = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(workspace, { recursive: true, force: true }),
|
||||
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
]);
|
||||
});
|
||||
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(written.equals(pngBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves generic files with sanitized original filenames", async () => {
|
||||
const pdfBytes = Buffer.from("PDF bytes");
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[
|
||||
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
|
||||
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
|
||||
],
|
||||
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
|
||||
);
|
||||
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
|
||||
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
|
||||
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
|
||||
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
|
||||
|
||||
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
|
||||
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing attachment name", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
const first = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
const second = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
|
||||
expect(second[0]?.path).not.toBe(first[0]?.path);
|
||||
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
|
||||
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
|
||||
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
|
||||
});
|
||||
|
||||
it("rejects unsafe custom folders", async () => {
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "/tmp/uploads" },
|
||||
)).rejects.toThrow(/Absolute paths/);
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "../uploads" },
|
||||
)).rejects.toThrow(/Path traversal/);
|
||||
});
|
||||
|
||||
it("honors a custom folder", async () => {
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects attachment folders that resolve outside the workspace", async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
|
||||
externalDirectories.push(outside);
|
||||
await mkdir(join(workspace, ".pi-web"));
|
||||
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
|
||||
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
)).rejects.toThrow(/Path escapes workspace/);
|
||||
await expect(readdir(outside)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for no attachments", async () => {
|
||||
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
||||
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
||||
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
|
||||
/**
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
@@ -26,7 +26,7 @@ export interface InlineImage {
|
||||
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
|
||||
* are dropped, matching pi's `[Image omitted]` behaviour.
|
||||
*/
|
||||
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
|
||||
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
|
||||
const results: InlineImage[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
|
||||
attachments: PromptAttachment[],
|
||||
options: SaveAttachmentsOptions = {},
|
||||
): Promise<SavedPromptAttachment[]> {
|
||||
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
||||
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(folderTarget, { recursive: true });
|
||||
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(requestedFolderTarget, { recursive: true });
|
||||
const folderTarget = await realpath(requestedFolderTarget);
|
||||
ensureInside(root, folderTarget);
|
||||
|
||||
const stamp = timestamp(now());
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
|
||||
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
|
||||
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: string): string {
|
||||
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
|
||||
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
|
||||
try {
|
||||
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
|
||||
return candidate;
|
||||
} catch (error: unknown) {
|
||||
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to choose a unique attachment filename");
|
||||
}
|
||||
|
||||
function addCollisionSuffix(filename: string, suffix: number): string {
|
||||
const extension = extname(filename);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem}-${String(suffix)}${extension}`;
|
||||
}
|
||||
|
||||
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
|
||||
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
|
||||
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
|
||||
}
|
||||
|
||||
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
|
||||
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
return "file.bin";
|
||||
}
|
||||
|
||||
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
|
||||
|
||||
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed === undefined || trimmed === "") return undefined;
|
||||
const leaf = basename(trimmed.replace(/\\/g, "/"));
|
||||
const sanitized = stripControlCharacters(leaf)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/-+\./g, ".")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/[.-]+$/, "");
|
||||
if (sanitized === "") return undefined;
|
||||
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
|
||||
}
|
||||
|
||||
function stripControlCharacters(value: string): string {
|
||||
return Array.from(value).filter((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function truncateFilename(filename: string, maxLength: number): string {
|
||||
if (filename.length <= maxLength) return filename;
|
||||
const extension = extname(filename);
|
||||
if (extension.length >= maxLength) return filename.slice(0, maxLength);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
|
||||
}
|
||||
|
||||
function timestamp(date: Date): string {
|
||||
|
||||
@@ -957,7 +957,7 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
|
||||
if (parsed.length === 0) return [];
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
|
||||
Reference in New Issue
Block a user