From 05c8a084a0af79711ce72e08376da2f5d4374bfc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 14:03:34 +0000 Subject: [PATCH] Sharing an image or PDF read from disk shares the file, not an empty one (v0.26.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthony: "this public shared image is broken: readm3.com/s/UXBN…". The share held an empty source: a stored file (large, or a PDF or image the desktop app reads from disk) has an empty source in the editor, and Share and Paste link sent it as it was. Both now send the file's bytes (base64 for a binary), and a shared document holds up to 8 MB of source (a 6 MB image or PDF) on the document routes only; a larger file is refused with a message, never shared empty. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- server/api.ts | 8 +++++--- server/store.ts | 5 +++-- site/test/sync.spec.ts | 29 +++++++++++++++++++++++++++++ site/viewer.ts | 26 ++++++++++++++++++++++---- src/cli.ts | 2 +- 6 files changed, 61 insertions(+), 11 deletions(-) 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