Skip to content
Merged
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.3",
"version": "0.27.0",
"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: 8 additions & 0 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ export function createApi(
if (handled) return handled;
throw new HttpError(404, "API route not found.");
}
// A share link's six-digit voice code opens the document read-only. A code is short
// enough to guess, so lookups are few per address.
const code = path.match(/^codes\/(\d{6})$/);
if (code) {
if (request.method !== "GET") throw new HttpError(405, "Document codes are read-only.");
limit(`code:${ip}`, 20);
return json(store.viewByCode(code[1]!));
}
const shared = path.match(/^shared\/([A-Za-z0-9_-]{43})$/);
if (shared) {
const link = store.get<{
Expand Down
9 changes: 7 additions & 2 deletions server/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export function operate(
case "shares_list":
owner();
return store.all(
"SELECT id,label,role,versionId,expiresAt,createdAt FROM shares WHERE documentId=? ORDER BY createdAt DESC",
"SELECT id,label,role,versionId,expiresAt,createdAt,code FROM shares WHERE documentId=? ORDER BY createdAt DESC",
docId(),
);
case "shares_create": {
Expand Down Expand Up @@ -460,8 +460,10 @@ export function operate(
const normalized = expiresAt ? new Date(expiresAt).toISOString() : null;
const token = secret();
const shareId = id();
// The voice code lives as long as the link: "open document 437301" on a call.
const code = store.shareCode();
store.run(
"INSERT INTO shares VALUES (?,?,?,?,?,?,?,?)",
"INSERT INTO shares (id,documentId,tokenHash,versionId,role,label,expiresAt,createdAt,code) VALUES (?,?,?,?,?,?,?,?,?)",
shareId,
doc.id,
checksum(token),
Expand All @@ -470,6 +472,7 @@ export function operate(
text(args.label ?? "Shared link", "Label"),
normalized,
now(),
code,
);
// What the link exposes, so the caller can warn: the pinned version, or the latest.
const shared = store.get<{ source: string }>(
Expand All @@ -481,6 +484,8 @@ export function operate(
role: access,
versionId,
expiresAt: normalized,
code,
codeUrl: `${origin}/c/${code}`,
url: `${origin}/s/${token}`,
// A PDF or image is base64: nothing in it reads as a secret, and scanning it only misfires.
sensitive: scanSensitive(binaryType(doc.title) ? "" : shared?.source ?? ""),
Expand Down
40 changes: 39 additions & 1 deletion server/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Database } from "bun:sqlite";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { binaryType } from "../src/code.ts";
Expand Down Expand Up @@ -108,6 +108,44 @@ export class Store {
const pasteColumns = this.db.query("PRAGMA table_info(pastes)").all() as { name: string }[];
if (!pasteColumns.some((column) => column.name === "language"))
this.db.exec("ALTER TABLE pastes ADD COLUMN language TEXT");
const shareColumns = this.db.query("PRAGMA table_info(shares)").all() as { name: string }[];
if (!shareColumns.some((column) => column.name === "code"))
this.db.exec("ALTER TABLE shares ADD COLUMN code TEXT");
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS shares_code ON shares(code)");
// Links made before codes existed get one now.
for (const { id: shareId } of this.all<{ id: string }>("SELECT id FROM shares WHERE code IS NULL AND (expiresAt IS NULL OR expiresAt>?)", now()))
this.run("UPDATE shares SET code=? WHERE id=?", this.shareCode(), shareId);
}

/**
* A share link's voice code: six digits, said on a call ("open document 437301"),
* unique among the links that still work. An expired link gives its code back first.
* 900,000 codes (100000-999999, no leading zero to mishear); the unique index is the
* last word if two links are made at once.
*/
shareCode(): string {
this.run("UPDATE shares SET code=NULL WHERE code IS NOT NULL AND expiresAt IS NOT NULL AND expiresAt<=?", now());
for (let attempt = 0; attempt < 50; attempt++) {
const code = String(randomInt(100_000, 1_000_000));
if (!this.get("SELECT id FROM shares WHERE code=?", code)) return code;
}
throw new HttpError(503, "No free document code right now; try again.");
}

/** What a voice code opens: the shared document, read-only, as its link would show it. */
viewByCode(code: string) {
const link = this.get<{ documentId: string; versionId: string | null; role: string }>(
"SELECT documentId,versionId,role FROM shares WHERE code=? AND (expiresAt IS NULL OR expiresAt>?)", code, now(),
);
if (!link) throw new HttpError(404, "No document has that code, or its link expired.");
const doc = this.get<Doc>("SELECT * FROM documents WHERE id=?", link.documentId);
if (!doc) throw new HttpError(404, "No document has that code, or its link expired.");
const version = this.get<Record<string, unknown>>(
"SELECT v.*,COALESCE(u.displayName,'Link editor') AS author FROM versions v LEFT JOIN users u ON u.id=v.authorId WHERE v.documentId=? AND v.id=?",
doc.id, link.versionId || doc.currentVersion,
);
if (!version) throw new HttpError(404, "Version not found.");
return { ...doc, ...(link.versionId ? { title: version.title as string } : {}), version, canEdit: false, canManage: false, sharedRole: "view", pinned: !!link.versionId, code };
}
get<T>(sql: string, ...args: (string | number | null)[]): T | null {
return this.db.query(sql).get(...args) as T | null;
Expand Down
33 changes: 33 additions & 0 deletions server/test/sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,3 +707,36 @@ test("a shared image is its bytes as base64, up to 6 MB, never an empty file", a
f.store.close();
}
});

test("every share link gets a six-digit voice code that opens it read-only and dies with the link", async () => {
const f = fixture();
try {
const owner = await f.account();
const doc = (await owner.action("documents_create", { orgId: owner.org.id, title: "Plan.md", source: "# Ships Friday" })).data;
const link = (await owner.action("shares_create", { documentId: doc.id, role: "edit" })).data;
expect(link.code).toMatch(/^[1-9]\d{5}$/);
expect(link.codeUrl).toBe(`http://localhost/c/${link.code}`);
const opened = await f.call(`codes/${link.code}`);
expect(opened.status).toBe(200);
// An edit link's code still only reads.
expect(opened.data).toMatchObject({ id: doc.id, canEdit: false, canManage: false, code: link.code });
expect(opened.data.version.source).toBe("# Ships Friday");
expect((await owner.action("shares_list", { documentId: doc.id })).data[0].code).toBe(link.code);
// Unique among links.
const codes = new Set([link.code]);
for (let i = 0; i < 15; i++) codes.add((await owner.action("shares_create", { documentId: doc.id })).data.code);
expect(codes.size).toBe(16);
// Revoked: the code stops working.
await owner.action("shares_revoke", { documentId: doc.id, shareId: link.id });
expect((await f.call(`codes/${link.code}`)).status).toBe(404);
// Expired: the code stops working and is freed for another link.
const soon = (await owner.action("shares_create", { documentId: doc.id, expiresAt: new Date(Date.now() + 60_000).toISOString() })).data;
f.store.run("UPDATE shares SET expiresAt=? WHERE id=?", new Date(Date.now() - 1000).toISOString(), soon.id);
expect((await f.call(`codes/${soon.code}`)).status).toBe(404);
f.store.shareCode();
expect(f.store.get<{ code: string | null }>("SELECT code FROM shares WHERE id=?", soon.id)!.code).toBeNull();
expect((await f.call("codes/12345")).status).not.toBe(200);
} finally {
f.store.close();
}
});
1 change: 1 addition & 0 deletions site/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ terminal.
- Desktop app: ${REPO}/releases/latest (the same editor for Linux as AppImage and Flatpak, macOS dmg, Windows installer); \`readm3 edit PATH...\` opens files and folders in it
- Summaries: Summarize in ${SITE}/viewer, or POST /api/v1/summaries/document { path, text } signed in (10 free documents a week, then pay as you go)
- Tables: CSV and TSV (also .tab, .psv) open as a sortable, filterable grid in the terminal reader (hqtui data grid), ${SITE}/viewer, the desktop app and paste links; \`readm3 --print data.csv\` prints an aligned table. Filter language: free text, col:text, col=v, col!=v, col>n, col<=n, !negate, #3 for the third column
- Document codes: every share link has a six-digit code that lasts as long as the link (optional expiry); ${SITE}/c/<code> or GET /api/v1/codes/<code> opens it read-only, no account (for voice: "open document 437301")
- Ask AI: ${SITE}/talk, or Ask AI in ${SITE}/viewer: questions about a file, a folder or the workspace; POST /api/v1/ask { question, files: [{ path, text }], history? } signed in (10 free questions a day, then pay as you go)
- Paste: ${SITE}/paste (anonymous pastes of any text, PDF or image behind an expiring secret link; CLI, curl, API and MCP examples)
- Site: ${SITE}
Expand Down
2 changes: 1 addition & 1 deletion site/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP
return (await api(new Request(target, { method: request.method, headers: request.headers }), ip))!;
}

const sharedPage = /^\/(s|p)\/[A-Za-z0-9_-]{43}$/.test(url.pathname);
const sharedPage = /^\/(s|p)\/[A-Za-z0-9_-]{43}$/.test(url.pathname) || /^\/c\/\d{6}$/.test(url.pathname);
const file = resolve(sharedPage ? "/viewer" : url.pathname);
if (!file) {
return new Response("Not found", { status: 404, headers: { "content-type": "text/plain" } });
Expand Down
17 changes: 13 additions & 4 deletions site/sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export function pasteDialog(title: string, source: string) {
};
}

/** "437301" as it is read out: "437 301". */
const spokenCode = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`;

export async function publish(
title: string,
source: string,
Expand Down Expand Up @@ -89,7 +92,7 @@ export async function sharing(
) {
const [links, people, teams] = await Promise.all([
action<
{ id: string; label: string; role: string; versionId: string | null }[]
{ id: string; label: string; role: string; versionId: string | null; code: string | null; expiresAt: string | null }[]
>("shares_list", { documentId: doc.id }),
action<{ userId: string; username: string; role: string }[]>(
"permissions_list",
Expand All @@ -100,7 +103,7 @@ export async function sharing(
}).catch(() => []),
]);
const box = modal(`Share ${doc.title}`);
box.body.innerHTML = `<p class="permission-note">Viewers can read. Editors can save new versions. Only you and the super admin can change permissions or delete the file.</p><form id="link-form" class="cloud-form"><label>Anyone with the link<select name="role"><option value="view">Can view</option><option value="edit">Can edit</option></select></label><label>Version<select name="version"><option value="">Latest version (follows future saves)</option><option value="${doc.version.id}">This version only (view only)</option></select></label><div class="sensitive" role="status" hidden></div><button class="primary-action">Create share link</button></form><div id="new-link"></div><div id="existing-links"></div><h3>People</h3><form id="person-form" class="cloud-form"><label>Email or username<input name="username" required placeholder="name@example.com"></label><label>Permission<select name="role"><option value="view">Can view</option><option value="edit">Can edit</option></select></label><button>Add collaborator</button></form><div id="existing-people"></div><h3>Organization and team access</h3><form id="group-form" class="cloud-form"><label>Group<select name="teamId"><option value="">Whole organization</option>${teams.map((t) => `<option value="${t.id}"${doc.teamId === t.id ? " selected" : ""}>${escape(t.name)}</option>`).join("")}</select></label><label>Permission<select name="access">${["private", "view", "edit"].map((v) => `<option value="${v}"${doc.access === v ? " selected" : ""}>${v === "private" ? "No group access" : v === "view" ? "Can view" : "Can edit"}</option>`).join("")}</select></label><button>Update group access</button></form><h3>Ownership</h3><form id="transfer-form" class="cloud-form"><label>New owner’s email or username<input name="username" required></label><button class="danger-action">Transfer ownership</button></form><p class="error" role="alert"></p>`;
box.body.innerHTML = `<p class="permission-note">Viewers can read. Editors can save new versions. Only you and the super admin can change permissions or delete the file.</p><form id="link-form" class="cloud-form"><label>Anyone with the link<select name="role"><option value="view">Can view</option><option value="edit">Can edit</option></select></label><label>Version<select name="version"><option value="">Latest version (follows future saves)</option><option value="${doc.version.id}">This version only (view only)</option></select></label><label>Link and code expire<select name="expires"><option value="">Never</option><option value="1">In 1 day</option><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select></label><div class="sensitive" role="status" hidden></div><button class="primary-action">Create share link</button></form><div id="new-link"></div><div id="existing-links"></div><h3>People</h3><form id="person-form" class="cloud-form"><label>Email or username<input name="username" required placeholder="name@example.com"></label><label>Permission<select name="role"><option value="view">Can view</option><option value="edit">Can edit</option></select></label><button>Add collaborator</button></form><div id="existing-people"></div><h3>Organization and team access</h3><form id="group-form" class="cloud-form"><label>Group<select name="teamId"><option value="">Whole organization</option>${teams.map((t) => `<option value="${t.id}"${doc.teamId === t.id ? " selected" : ""}>${escape(t.name)}</option>`).join("")}</select></label><label>Permission<select name="access">${["private", "view", "edit"].map((v) => `<option value="${v}"${doc.access === v ? " selected" : ""}>${v === "private" ? "No group access" : v === "view" ? "Can view" : "Can edit"}</option>`).join("")}</select></label><button>Update group access</button></form><h3>Ownership</h3><form id="transfer-form" class="cloud-form"><label>New owner’s email or username<input name="username" required></label><button class="danger-action">Transfer ownership</button></form><p class="error" role="alert"></p>`;
const error = box.body.querySelector<HTMLElement>(".error")!;
function bind(selector: string, fn: (data: FormData) => Promise<void>, gate?: SensitiveGate) {
const form = box.body.querySelector<HTMLFormElement>(selector)!;
Expand Down Expand Up @@ -145,10 +148,12 @@ export async function sharing(
const linkGate = sensitiveGate(linkForm.querySelector<HTMLElement>(".sensitive")!, linkForm.querySelector("button")!);
linkGate.check(doc.version.source);
bind("#link-form", async (data) => {
const link = await action<{ url: string; id: string }>("shares_create", {
const days = Number(data.get("expires") || 0);
const link = await action<{ url: string; id: string; code: string; codeUrl: string; expiresAt: string | null }>("shares_create", {
documentId: doc.id,
role: data.get("role"),
versionId: data.get("version") || undefined,
expiresAt: days ? new Date(Date.now() + days * 86_400_000).toISOString() : undefined,
});
const target = box.body.querySelector<HTMLElement>("#new-link")!;
target.replaceChildren();
Expand All @@ -157,6 +162,10 @@ export async function sharing(
"Copy this link now. For privacy, its secret is not stored on the server.";
target.append(note);
outputLink(target, link.url);
const voice = document.createElement("p");
voice.className = "share-code";
voice.textContent = `Document code ${spokenCode(link.code)}: on a call, say “open document ${link.code}”, or open ${link.codeUrl}. Read-only${link.expiresAt ? `, until ${new Date(link.expiresAt).toLocaleDateString()}` : ""}.`;
target.append(voice);
smallAction(
"Revoke this link",
() => action("shares_revoke", { documentId: doc.id, shareId: link.id }),
Expand All @@ -165,7 +174,7 @@ export async function sharing(
}, linkGate);
for (const link of links) {
const row = document.createElement("p");
row.textContent = `${link.label} · ${link.role}${link.versionId ? " · pinned version" : ""} `;
row.textContent = `${link.label} · ${link.role}${link.versionId ? " · pinned version" : ""}${link.code ? ` · code ${spokenCode(link.code)}` : ""}${link.expiresAt ? ` · until ${new Date(link.expiresAt).toLocaleDateString()}` : ""} `;
smallAction(
"Revoke",
() => action("shares_revoke", { documentId: doc.id, shareId: link.id }),
Expand Down
25 changes: 25 additions & 0 deletions site/test/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,31 @@ 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("a share link shows its six-digit document code, and readm3.com/c/<code> opens the document", async ({ page, context, browser }) => {
await register(context, username());
await page.goto("/viewer");
await setPassphrase(page);
await page.locator("#files-input").setInputFiles({ name: "Plan.md", mimeType: "text/markdown", buffer: Buffer.from("# Ships Friday\n") });
await page.locator("#share").click();
await page.getByRole("dialog", { name: "Save to your workspace" }).getByRole("button", { name: "Save online" }).click();
await expect(page).toHaveURL(/\/viewer\?doc=/);
// Saving online goes straight on to its sharing settings.
const dialog = page.getByRole("dialog", { name: "Share Plan.md" });
await dialog.getByLabel("Link and code expire").selectOption("7", { timeout: 5000 });
await dialog.getByRole("button", { name: "Create share link" }).click();
const line = dialog.locator(".share-code");
await expect(line).toContainText("Document code");
await expect(line).toContainText("until");
const code = (await line.textContent())!.match(/open document (\d{6})/)![1]!;
// Anyone with the code, signed in or not, reads it.
const other = await browser.newContext();
try {
const reader = await other.newPage();
await reader.goto(`${new URL(page.url()).origin}/c/${code}`);
await expect(reader.locator("#document-content")).toContainText("Ships Friday");
} finally { await other.close(); }
});

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
2 changes: 2 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,8 @@ test("storage failure still permits reading, editing, and downloading", async ({
await expect(page.locator("#document-content")).toContainText("Your docs. Your space.");
await expect(page.locator("#save-status")).toContainText("storage unavailable");
await page.locator("#edit-mode").click();
// Type once the editor holds the document, as a person would; earlier keys race its load.
await expect(editorInput(page)).toContainText("Your docs. Your space.");
await editorInput(page).fill("# Keep this draft");
const downloading = page.waitForEvent("download");
await page.locator("#download").click();
Expand Down
Loading
Loading