diff --git a/package.json b/package.json index c9b837a..5a55fb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.26.2", + "version": "0.26.3", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/server/api.ts b/server/api.ts index fcf1113..bb5e739 100644 --- a/server/api.ts +++ b/server/api.ts @@ -24,6 +24,8 @@ const json = ( ) => Response.json(body, { status, headers: { ...headers, ...extra } }); /** A file sent for a summary: up to 20 MB as base64, with room for the JSON around it. */ const MAX_SUMMARY_BODY = 28 * 1024 * 1024; +/** Creating or saving a document: its source (up to 8 MB, server/store.ts) as JSON. */ +const MAX_DOCUMENT_BODY = 9 * 1024 * 1024; /** Ask AI: 400 KB of file text plus paths, the question and the turns before it, JSON-escaped. */ const MAX_ASK_BODY = 2 * 1024 * 1024; async function body(request: Request, max = MAX_BODY): Promise> { @@ -370,7 +372,7 @@ export function createApi( }); } if (path === "actions" && request.method === "POST") { - const data = await body(request); + const data = await body(request, MAX_DOCUMENT_BODY); const args = data.args ?? {}; if (!args || typeof args !== "object" || Array.isArray(args)) throw new HttpError(400, "args must be an object."); @@ -406,7 +408,7 @@ export function createApi( store, user, "documents_create", - await body(request), + await body(request, MAX_DOCUMENT_BODY), origin, ), 201, @@ -457,7 +459,7 @@ export function createApi( userId: doc[3], }; if (["POST", "PATCH"].includes(request.method)) - args = { ...(await body(request)), ...args }; + args = { ...(await body(request, MAX_DOCUMENT_BODY)), ...args }; if (op) return json(operate(store, user, op, args, origin)); } throw new HttpError(404, "API route not found."); diff --git a/server/store.ts b/server/store.ts index 572d81c..f7aba2e 100644 --- a/server/store.ts +++ b/server/store.ts @@ -58,8 +58,9 @@ export function text(value: unknown, name: string, max = 200): string { return value.trim(); } export function source(value: unknown): string { - if (typeof value !== "string" || Buffer.byteLength(value) > 1024 * 1024) - throw new HttpError(400, "Markdown source must be text, up to 1 MB."); + // 8 MB: a shared PDF or image is its base64 (a 6 MB file), not only Markdown. + if (typeof value !== "string" || Buffer.byteLength(value) > 8 * 1024 * 1024) + throw new HttpError(400, "A document's source must be text, up to 8 MB (a PDF or image up to 6 MB)."); return value; } diff --git a/site/test/sync.spec.ts b/site/test/sync.spec.ts index 733d913..72ffc7f 100644 --- a/site/test/sync.spec.ts +++ b/site/test/sync.spec.ts @@ -216,6 +216,35 @@ test("a file whose name sync cannot carry stays on this device and the rest stil await expect(page.locator('#file-list button[title="notes 10:44.md"]')).toBeVisible(); }); +test("sharing an image the desktop app reads from disk shares the image, not an empty file", async ({ page, context }) => { + await register(context, username()); + await page.addInitScript(() => { + // A 1x1 PNG. + const png = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="), (c) => c.charCodeAt(0)); + const path = "/home/me/Desktop/dot.png"; + Object.assign(window, { + readm3Desktop: { + takeLaunchFiles: async () => [], onOpenFiles: () => {}, + pick: async () => [{ path, name: "dot.png", size: png.length }], + readFile: async () => ({ data: png, modified: 1 }), + stat: async () => ({ size: png.length, modified: 1 }), + readRange: async () => new Uint8Array(), writeFile: async () => {}, + }, + }); + }); + await page.goto("/viewer"); + await setPassphrase(page); + await page.locator("#open-files").click(); + await expect(page.locator("#media-content img")).toBeVisible(); + await page.locator("#share").click(); + const save = page.getByRole("dialog", { name: "Save to your workspace" }); + await save.getByRole("button", { name: "Save online" }).click(); + await expect(page).toHaveURL(/\/viewer\?doc=/); + const id = new URL(page.url()).searchParams.get("doc")!; + const doc = await (await page.request.get(`/api/v1/documents/${id}`)).json(); + expect(doc.version.source).toBe("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="); +}); + test("signed in, a folder past the old 5,000-file cap opens whole with its subfolders folded", async ({ page, context }) => { await register(context, username()); await page.addInitScript(() => { diff --git a/site/viewer.ts b/site/viewer.ts index 9e7169f..2ab36a0 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -799,6 +799,22 @@ async function importFiles(files: disk.Picked[], name = "Your workspace") { finally { importBusy = false; filesInput.value = ""; folderInput.value = ""; } } +/** A shared document holds up to 8 MB of source (server/store.ts): base64 for a PDF or an image. */ +const MAX_SHARED_SOURCE = 8 * 1024 * 1024; +/** + * What a share or paste link carries: the text, or for a stored file (large, or a PDF or + * image the desktop app reads from disk, whose source here is empty) its bytes, fetched + * from this device or the account, as base64 for a binary. Never an empty file. + */ +async function sharedSource(doc: Document): Promise { + if (!doc.blob) return doc.source; + const binary = binaryType(doc.path); + const limit = binary ? MAX_SHARED_SOURCE * 3 / 4 : MAX_SHARED_SOURCE; + if (doc.blob.size > limit) throw new Error(`This file is ${formatStorage(doc.blob.size)}; a share link carries up to ${formatStorage(limit)}. Download it and send it another way, or share a smaller copy.`); + const blob = await stored.bytes(doc.blob, binary?.mime ?? ""); + return binary ? toBase64(new Uint8Array(await blob.arrayBuffer())) : await blob.text(); +} + /** Ask AI about the open file, a folder it is in, or the workspace: text as it reads now. */ function askAi() { void showAsk(() => workspace.documents, workspace.active, () => void showTopUp()); } @@ -998,12 +1014,14 @@ function bindEvents() { apply: (incoming, settings) => applySynced(incoming as Workspace | undefined, settings), }); element("cloud-save").onclick = () => void saveCloud(); - element("paste-share").onclick = () => pasteDialog(current().path, current().source); + element("paste-share").onclick = () => void sharedSource(current()).then((source) => pasteDialog(current().path, source)) + .catch((error) => notice(error instanceof Error ? error.message : "Could not read this file.")); element("share").onclick = () => { if (pendingSave && cloudDocument) { notice("Save your changes before changing sharing settings."); return; } - const task = cloudDocument ? sharing(cloudDocument, adoptCloud) : publish(current().path, current().source, doc => { - history.pushState(null, "", `/viewer?doc=${doc.id}`); adoptCloud(doc); - }); + const doc = current(); + const task = cloudDocument ? sharing(cloudDocument, adoptCloud) : sharedSource(doc).then((source) => publish(doc.path, source, (created) => { + history.pushState(null, "", `/viewer?doc=${created.id}`); adoptCloud(created); + })); void task.catch(error => notice(error instanceof Error ? error.message : "Could not open sharing.")); }; element("paste-delete").onclick = async () => { diff --git a/src/cli.ts b/src/cli.ts index 55d5c93..55e7eea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.26.2"; +export const VERSION = "0.26.3"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code