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
7 changes: 7 additions & 0 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ else {
const info = await stat(path);
return { data: new Uint8Array(await readFile(path)), modified: info.mtimeMs };
});
// A large file's size and time, so the page notices it change without reading it.
ipcMain.handle("stat-file", async (event, path) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
if (typeof path !== "string" || !opened.has(path)) throw new Error("readm3 can only read files it opened.");
const info = await stat(path);
return { size: info.size, modified: info.mtimeMs };
});
// A large file reaches the page a range at a time, never whole through one message.
ipcMain.handle("read-range", async (event, path, start, end) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "readm3-desktop",
"productName": "readm3",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
1 change: 1 addition & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o
onOpenFiles: (callback) => { ipcRenderer.on("open-files", (_event, files) => callback(files)); },
readFile: (path) => ipcRenderer.invoke("read-file", path),
readRange: (path, start, end) => ipcRenderer.invoke("read-range", path, start, end),
stat: (path) => ipcRenderer.invoke("stat-file", path),
writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data),
pick: (folder) => ipcRenderer.invoke("pick", folder),
saveAs: (name) => ipcRenderer.invoke("save-as", name),
Expand Down
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.24.4",
"version": "0.25.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
113 changes: 113 additions & 0 deletions server/file-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Version history of each synced file, kept on the account so every device sees it and
* any version can be restored. Like the workspace it is end-to-end encrypted: a version
* is a sealed envelope (src/sync-crypto.ts) the server cannot open, filed under a tag
* the device derives from the path with the sync key (`pathTag`), so the server never
* learns a file's name either.
*
* GET /api/v1/file-history?tag=<tag> the file's versions, newest first (no bodies)
* GET /api/v1/file-history/<id> one version's sealed envelope
* POST /api/v1/file-history { versions: [{ tag, sealed, ifEmpty? }] }
*
* `ifEmpty` files a version only when the tag has none yet: a device sends the text a
* file had before its first synced edit that way, so the history starts from there.
* The newest KEEP_VERSIONS per file are kept, and an account holds at most MAX_BYTES.
*/
import { Store, HttpError, now } from "./store.ts";
import type { Accounts } from "./accounts.ts";
import { envelopeProblem } from "../src/sync-crypto.ts";

export const KEEP_VERSIONS = 50;
/** A sealed version: a 4 MB file compresses, and base64 adds a third. */
export const MAX_VERSION_BYTES = 6 * 1024 * 1024;
export const MAX_ACCOUNT_BYTES = 512 * 1024 * 1024;
const MAX_BATCH = 200;
const TAG = /^[0-9a-f]{64}$/;

export const FILE_HISTORY_SCHEMA = `
CREATE TABLE IF NOT EXISTS file_versions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag TEXT NOT NULL, sealed TEXT NOT NULL, size INTEGER NOT NULL, createdAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS file_versions_tag ON file_versions(userId, tag, createdAt);
`;

export interface VersionInput { tag: string; sealed: string; ifEmpty?: boolean }

export class FileHistory {
constructor(private store: Store) { store.db.exec(FILE_HISTORY_SCHEMA); }

list(userId: string, tag: string) {
if (!TAG.test(tag)) throw new HttpError(400, "tag must be 64 hex characters.");
return this.store.all<{ id: string; size: number; createdAt: string }>(
"SELECT id,size,createdAt FROM file_versions WHERE userId=? AND tag=? ORDER BY createdAt DESC, rowid DESC LIMIT ?", userId, tag, KEEP_VERSIONS);
}

get(userId: string, id: string) {
const row = this.store.get<{ id: string; sealed: string; createdAt: string }>("SELECT id,sealed,createdAt FROM file_versions WHERE userId=? AND id=?", userId, id);
if (!row) throw new HttpError(404, "Version not found.");
return row;
}

add(userId: string, versions: unknown): { saved: number } {
if (!Array.isArray(versions) || versions.length > MAX_BATCH) throw new HttpError(400, `versions must be an array of up to ${MAX_BATCH}.`);
for (const version of versions as VersionInput[]) {
if (!version || typeof version !== "object" || !TAG.test(String(version.tag)) || typeof version.sealed !== "string")
throw new HttpError(400, "Each version needs a tag and a sealed envelope.");
if (version.sealed.length > MAX_VERSION_BYTES) throw new HttpError(413, "A version may be up to 6 MB sealed.");
let envelope: unknown;
try { envelope = JSON.parse(version.sealed); } catch { throw new HttpError(400, "sealed must be a JSON envelope."); }
const problem = envelopeProblem(envelope);
if (problem) throw new HttpError(400, problem.replace("sealed.json", "sealed"));
}
let saved = 0;
this.store.db.transaction(() => {
for (const { tag, sealed, ifEmpty } of versions as VersionInput[]) {
if (ifEmpty && this.store.get("SELECT 1 FROM file_versions WHERE userId=? AND tag=? LIMIT 1", userId, tag)) continue;
this.store.run("INSERT INTO file_versions VALUES (?,?,?,?,?,?)", crypto.randomUUID(), userId, tag, sealed, sealed.length, now());
saved++;
this.store.run(`DELETE FROM file_versions WHERE userId=? AND tag=? AND id NOT IN
(SELECT id FROM file_versions WHERE userId=? AND tag=? ORDER BY createdAt DESC, rowid DESC LIMIT ?)`, userId, tag, userId, tag, KEEP_VERSIONS);
}
// Past the account's allowance the oldest versions of any file go first.
let total = this.store.get<{ total: number }>("SELECT COALESCE(SUM(size),0) AS total FROM file_versions WHERE userId=?", userId)!.total;
while (total > MAX_ACCOUNT_BYTES) {
const oldest = this.store.get<{ id: string; size: number }>("SELECT id,size FROM file_versions WHERE userId=? ORDER BY createdAt, rowid LIMIT 1", userId);
if (!oldest) break;
this.store.run("DELETE FROM file_versions WHERE id=?", oldest.id);
total -= oldest.size;
}
}).immediate();
return { saved };
}
}

export function createFileHistoryApi(history: FileHistory, accounts: Accounts) {
return async (request: Request): Promise<Response | null> => {
const url = new URL(request.url);
const match = url.pathname.match(/^\/api\/v1\/file-history(?:\/([\w-]{1,64}))?$/);
if (!match) return null;
const reply = (body: unknown, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store", "x-content-type-options": "nosniff" } });
try {
const origin = request.headers.get("origin");
if ((origin && origin !== accounts.origin) || request.headers.get("sec-fetch-site") === "cross-site") throw new HttpError(403, "Cross-origin API requests are not allowed.");
const authorization = request.headers.get("authorization");
const token = authorization ? /^Bearer (\S+)$/i.exec(authorization)?.[1] : accounts.token(request);
const user = token ? accounts.authenticate(token) : null;
if (!user) throw new HttpError(401, "Sign in to keep file history on your account.");
if (!authorization && request.method !== "GET" && !origin) throw new HttpError(403, "Browser writes require a same-origin Origin header.");
const [, id] = match;
if (request.method === "GET") return reply(id ? history.get(user.id, id) : { versions: history.list(user.id, url.searchParams.get("tag") ?? "") });
if (id || request.method !== "POST") throw new HttpError(405, "Method not allowed.");
if (Number(request.headers.get("content-length")) > 40 * 1024 * 1024) throw new HttpError(413, "Request too large.");
let body: { versions?: unknown };
try { body = await request.json() as { versions?: unknown }; } catch { throw new HttpError(400, "Invalid JSON object."); }
return reply(history.add(user.id, body?.versions));
} catch (error) {
if (error instanceof HttpError) return reply({ ok: false, error: error.message }, error.status);
console.error("readm3 file history:", error instanceof Error ? error.message : "Unknown error");
return reply({ ok: false, error: "Unable to reach file history right now." }, 500);
}
};
}
7 changes: 4 additions & 3 deletions server/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,17 +368,18 @@ export function operate(
store.run("UPDATE documents SET ownerId=? WHERE id=?", next.id, doc.id);
return { ok: true };
}
// Anyone who can see a document sees its history; restoring a version needs write access.
case "versions_list":
owner();
store.document(user, docId(), "view");
return store.all(
"SELECT v.id,v.checksum,v.title,v.parentId,v.createdAt,COALESCE(u.displayName,'Link editor') AS author FROM versions v LEFT JOIN users u ON u.id=v.authorId WHERE documentId=? ORDER BY v.rowid DESC LIMIT 1000",
docId(),
);
case "versions_get":
owner();
store.document(user, docId(), "view");
return store.view(user, docId(), key(args, "versionId"));
case "versions_restore": {
owner();
store.document(user, docId(), "edit");
const version = store.get<{ source: string; title: string }>(
"SELECT source,title FROM versions WHERE id=? AND documentId=?",
key(args, "versionId"),
Expand Down
68 changes: 68 additions & 0 deletions server/test/file-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, test, expect } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Store } from "../store.ts";
import { Accounts } from "../accounts.ts";
import { FileHistory, KEEP_VERSIONS, createFileHistoryApi } from "../file-history.ts";
import { deriveKey, newKdf, open, pathTag, seal } from "../../src/sync-crypto.ts";
import { verifiedAccount } from "./fixtures.ts";

const cleanup: (() => void)[] = [];
afterEach(() => { while (cleanup.length) cleanup.pop()!(); });

async function fixture() {
const root = mkdtempSync(join(tmpdir(), "readm3-file-history-"));
const store = new Store(join(root, "test.sqlite"));
const accounts = new Accounts(store.db, "http://localhost", async () => {});
const api = createFileHistoryApi(new FileHistory(store), accounts);
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: async (request) => await api(request) || new Response("Missing", { status: 404 }) });
cleanup.push(() => { server.stop(true); store.close(); rmSync(root, { recursive: true, force: true }); });
const alice = await verifiedAccount(store, "history_alice");
const bob = await verifiedAccount(store, "history_bob");
const base = `http://127.0.0.1:${server.port}/api/v1/file-history`;
accounts.origin = new URL(base).origin;
const call = async (path: string, token: string, body?: unknown) => {
const response = await fetch(`${base}${path}`, {
method: body === undefined ? "GET" : "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, body: await response.json() as Record<string, any> };
};
return { alice, bob, call };
}

test("versions are filed per encrypted path, newest first, and only the owner reads them", async () => {
const f = await fixture();
const key = await deriveKey("correct horse battery", { ...newKdf(), iterations: 100_000 });
const tag = await pathTag("notes/plan.md", key);
const version = async (source: string) => JSON.stringify(await seal({ path: "notes/plan.md", source, device: "desktop" }, key));

expect((await f.call(`?tag=${tag}`, "nope")).status).toBe(401);
expect((await f.call("", f.alice.token, { versions: [{ tag, sealed: "not json" }] })).status).toBe(400);
expect((await f.call("", f.alice.token, { versions: [{ tag: "notes/plan.md", sealed: await version("x") }] })).status).toBe(400);

// The text before the first synced edit is filed only while the file has no history.
expect((await f.call("", f.alice.token, { versions: [{ tag, sealed: await version("one"), ifEmpty: true }, { tag, sealed: await version("two") }] })).body).toEqual({ saved: 2 });
expect((await f.call("", f.alice.token, { versions: [{ tag, sealed: await version("stale"), ifEmpty: true }, { tag, sealed: await version("three") }] })).body).toEqual({ saved: 1 });

const listed = (await f.call(`?tag=${tag}`, f.alice.token)).body.versions as { id: string }[];
expect(listed.length).toBe(3);
const sources = [];
for (const { id } of listed) sources.push((await open<{ source: string }>(JSON.parse((await f.call(`/${id}`, f.alice.token)).body.sealed), key)).source);
expect(sources).toEqual(["three", "two", "one"]);

expect((await f.call(`?tag=${tag}`, f.bob.token)).body.versions).toEqual([]);
expect((await f.call(`/${listed[0]!.id}`, f.bob.token)).status).toBe(404);
});

test("a file keeps its newest versions", async () => {
const f = await fixture();
const key = await deriveKey("correct horse battery", { ...newKdf(), iterations: 100_000 });
const tag = await pathTag("a.md", key);
const sealed = JSON.stringify(await seal({ source: "x" }, key));
const versions = Array.from({ length: KEEP_VERSIONS + 5 }, () => ({ tag, sealed }));
expect((await f.call("", f.alice.token, { versions })).body).toEqual({ saved: KEEP_VERSIONS + 5 });
expect(((await f.call(`?tag=${tag}`, f.alice.token)).body.versions as unknown[]).length).toBe(KEEP_VERSIONS);
});
17 changes: 16 additions & 1 deletion server/test/sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ test("named editors can save but only owners manage sharing, history, deletion,
})
).status,
).toBe(403);
// A viewer sees the history but cannot restore from it.
const seen = await collaborator.action("versions_list", { documentId: doc.id });
expect(seen.status).toBe(200);
expect(
(await collaborator.action("versions_restore", { documentId: doc.id, versionId: seen.data[0].id, baseVersion: doc.currentVersion })).status,
).toBe(403);
await owner.action("permissions_set", {
documentId: doc.id,
username: collaborator.user.username,
Expand All @@ -207,9 +213,18 @@ test("named editors can save but only owners manage sharing, history, deletion,
source: "Editor can edit",
});
expect(changed.status).toBe(200);
// Write access includes the history: an editor lists versions and restores one.
const versions = await collaborator.action("versions_list", { documentId: doc.id });
expect(versions.status).toBe(200);
expect(versions.data.length).toBe(2);
const restored = await collaborator.action("versions_restore", {
documentId: doc.id,
versionId: versions.data.at(-1).id,
baseVersion: changed.data.currentVersion,
});
expect(restored.status).toBe(200);
for (const operation of [
"documents_delete",
"versions_list",
"shares_list",
"shares_create",
"permissions_list",
Expand Down
9 changes: 9 additions & 0 deletions site/autosync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { digestDocument, digests, mergeWorkspaces } from "../src/sync-merge.ts";
import { isSealed, syncablePath, validateSyncFiles, type ReaderSettings, type SyncedWorkspace } from "../src/sync-schema.ts";
import { store, stored } from "./sync.ts";
import * as blobs from "./blobs.ts";
import * as cloudHistory from "./cloud-history.ts";
import { formatStorage, MAX_SYNC_FILES, newBlobRef, SNAPSHOT_INLINE_BYTES, type BlobRef } from "../src/blob-store.ts";

const OFF = "readm3:autosync";
Expand Down Expand Up @@ -308,6 +309,14 @@ async function pass(user: CloudUser) {
if (saved.userId !== user.id) throw new Error("your account changed");
revision = saved.revision;
void collect(uploaded, outgoing.account);
// Each file this device changed gets a version in its account history (site/cloud-history.ts).
if (state) {
const before = new Map(remoteSynced?.documents.map((doc) => [doc.path, doc]));
const changes = merge.workspace.documents
.filter((doc) => localDigests[doc.path] === digestDocument(doc) && state.base[doc.path] !== localDigests[doc.path])
.map((doc) => ({ doc, before: before.get(doc.path) }));
if (changes.length) void cloudHistory.record(changes, syncKey, h.device).catch(() => { /* History is best effort; the file itself synced. */ });
}
} catch (error) {
if (error instanceof ApiError && error.status === 409) continue; // Another device saved first.
throw error;
Expand Down
Loading
Loading