Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 5 additions & 3 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -406,7 +408,7 @@ export function createApi(
store,
user,
"documents_create",
await body(request),
await body(request, MAX_DOCUMENT_BODY),
origin,
),
201,
Expand Down Expand Up @@ -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.");
Expand Down
5 changes: 3 additions & 2 deletions server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
29 changes: 29 additions & 0 deletions site/test/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
26 changes: 22 additions & 4 deletions site/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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()); }

Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading