diff --git a/desktop/main.cjs b/desktop/main.cjs index 6d67392..1193eee 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -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."); diff --git a/desktop/package.json b/desktop/package.json index 3358ec3..293f6fa 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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. ", diff --git a/desktop/preload.cjs b/desktop/preload.cjs index f5021b1..e2277c1 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -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), diff --git a/package.json b/package.json index 49f4da7..eab943a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/file-history.ts b/server/file-history.ts new file mode 100644 index 0000000..d9ddff8 --- /dev/null +++ b/server/file-history.ts @@ -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= the file's versions, newest first (no bodies) + * GET /api/v1/file-history/ 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 => { + 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); + } + }; +} diff --git a/server/operations.ts b/server/operations.ts index 7c964c4..9bbe6ec 100644 --- a/server/operations.ts +++ b/server/operations.ts @@ -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"), diff --git a/server/test/file-history.test.ts b/server/test/file-history.test.ts new file mode 100644 index 0000000..cde4906 --- /dev/null +++ b/server/test/file-history.test.ts @@ -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 }; + }; + 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); +}); diff --git a/server/test/sharing.test.ts b/server/test/sharing.test.ts index 567f8d1..4314475 100644 --- a/server/test/sharing.test.ts +++ b/server/test/sharing.test.ts @@ -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, @@ -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", diff --git a/site/autosync.ts b/site/autosync.ts index 74dc68a..8c7d776 100644 --- a/site/autosync.ts +++ b/site/autosync.ts @@ -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"; @@ -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; diff --git a/site/cloud-history.ts b/site/cloud-history.ts new file mode 100644 index 0000000..71aa07a --- /dev/null +++ b/site/cloud-history.ts @@ -0,0 +1,58 @@ +/** + * The account side of a file's history (server/file-history.ts): every version a device + * syncs is sealed with the sync key and filed under the path's tag, so the History dialog + * on any signed-in device lists versions from all of them and can restore one. + */ +import { request, type CloudUser } from "./cloud.ts"; +import { stored } from "./sync.ts"; +import { open, pathTag, seal, type Envelope, type SyncKey } from "../src/sync-crypto.ts"; +import type { WorkspaceDocument } from "../src/sync-schema.ts"; + +export interface CloudVersion { id: string; at: string; device: string; source: string; language?: string } +interface Sealed { path: string; source: string; language?: string; device: string; at: string } +type Change = { doc: WorkspaceDocument; before?: WorkspaceDocument }; + +/** Past this many changed files in one pass (a folder opened again), only the first are filed. */ +const MAX_PER_PASS = 200; + +/** Files a version for each document that changed in this sync pass, and the text it had before when the file has no history yet. */ +export async function record(changes: Change[], key: SyncKey, device: string): Promise { + const at = new Date().toISOString(); + const versions = []; + for (const { doc, before } of changes.filter(({ doc }) => !doc.blob).slice(0, MAX_PER_PASS)) { + const tag = await pathTag(doc.path, key); + const sealed = async (source: Pick, when: string, ifEmpty?: boolean) => ({ + tag, ifEmpty, sealed: JSON.stringify(await seal({ path: doc.path, source: source.source, language: source.language, device, at: when } satisfies Sealed, key)), + }); + if (before && !before.blob && before.source !== doc.source) versions.push(await sealed(before, "", true)); + versions.push(await sealed(doc, at)); + } + for (let i = 0; i < versions.length; i += 50) await request("file-history", "POST", { versions: versions.slice(i, i + 50) }); +} + +/** This device's sync key for the signed-in account, if it has one. */ +export async function syncKey(): Promise { + try { + const { user } = await request<{ user: CloudUser | null }>("me"); + return user ? await stored(`key:${user.id}`) : undefined; + } catch { return undefined; } +} + +/** Newest first; empty when signed out, without a sync key, or offline. */ +export async function list(path: string, limit = 25): Promise { + const key = await syncKey(); + if (!key) return []; + try { + const { versions } = await request<{ versions: { id: string; createdAt: string }[] }>(`file-history?tag=${await pathTag(path, key)}`); + const opened = await Promise.all(versions.slice(0, limit).map(async ({ id, createdAt }) => { + try { + const { sealed } = await request<{ sealed: string }>(`file-history/${id}`); + const version = await open(JSON.parse(sealed) as Envelope, key); + // A version sealed for another path under the same tag cannot happen, but is never shown. + if (version.path !== path) return null; + return { id, at: version.at || createdAt, device: version.device, source: version.source, ...(version.language ? { language: version.language } : {}) }; + } catch { return null; } + })); + return opened.filter((version): version is CloudVersion => !!version); + } catch { return []; } +} diff --git a/site/disk.ts b/site/disk.ts index b45216e..11b266c 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -97,6 +97,8 @@ interface DesktopBridge { /** Absent in desktop builds before 0.21.0, which used the page's pickers. */ pick?(folder: boolean): Promise; saveAs?(name: string): Promise<{ path: string; name: string } | null>; + /** Absent in desktop builds before 0.18.0: a large file's size and time without reading it. */ + stat?(path: string): Promise<{ size: number; modified: number }>; } /** Present only inside the readm3 desktop app (desktop/preload.cjs). */ export const desktop = typeof window === "undefined" ? undefined : (window as unknown as { readm3Desktop?: DesktopBridge }).readm3Desktop; @@ -142,12 +144,15 @@ function desktopHandle(entry: DesktopFile): FileSystemFileHandle { kind: "file", name: entry.name, // Read from disk each time, so the editor sees a file change under it and a save is not undone. getFile: async () => { + // The file may have grown or shrunk since it was opened. + if (desktop!.stat) { try { entry.size = (await desktop!.stat(entry.path)).size; } catch { /* Read as before. */ } } // A large file is read a range at a time, never whole through one message. if (!entry.data && entry.size !== undefined && (entry.size > LARGE || !desktop!.readFile)) return whole(entry); if (!desktop!.readFile) return new File([entry.data!.buffer as ArrayBuffer], entry.name); const { data, modified } = await desktop!.readFile(entry.path); return new File([data.buffer as ArrayBuffer], entry.name, { lastModified: modified }); }, + stat: desktop!.stat ? () => desktop!.stat!(entry.path) : undefined, createWritable: async () => { const parts: BlobPart[] = []; return { write: async (data: BlobPart) => { parts.push(data); }, close: async () => { await desktop!.writeFile(entry.path, new Uint8Array(await new Blob(parts).arrayBuffer())); } }; @@ -155,6 +160,19 @@ function desktopHandle(entry: DesktopFile): FileSystemFileHandle { } as unknown as FileSystemFileHandle; } +/** + * A file's size and time without reading it, or null where that would mean reading the + * whole file (a large file in a desktop build before 0.18.0). A picker's File is lazy. + */ +export async function stat(handle: FileSystemFileHandle): Promise<{ size: number; modified: number } | null> { + const desktopStat = (handle as FileSystemFileHandle & { stat?: () => Promise<{ size: number; modified: number }> }).stat; + try { + if (desktopStat) return await desktopStat(); + if (typeof FileSystemHandle !== "undefined" && handle instanceof FileSystemHandle) { const file = await handle.getFile(); return { size: file.size, modified: file.lastModified }; } + } catch { /* Moved or no longer readable. */ } + return null; +} + /** Writes the file, asking for write access first if this session has not been granted it. */ export async function write(handle: FileSystemFileHandle, data: string | Uint8Array): Promise { const permissioned = handle as FileSystemFileHandle & Partial; diff --git a/site/history-panel.ts b/site/history-panel.ts index 2dd09d6..1a20bf5 100644 --- a/site/history-panel.ts +++ b/site/history-panel.ts @@ -1,9 +1,11 @@ import { ApiError, request } from "./cloud.ts"; import { diffLines, snippet } from "../src/diff.ts"; import * as history from "./history.ts"; +import * as cloudHistory from "./cloud-history.ts"; /** - * The History dialog for a file on this device: its versions newest first, each with + * The History dialog for a file: its versions newest first, from this device and from + * the account (every device that synced it, site/cloud-history.ts), each with * when and why it was recorded, how much changed against the one before, the changed * lines themselves, and an AI abstract on request (prepaid, through CoinPay). */ @@ -20,7 +22,17 @@ export const el = (tag: K, className?: st let dialog: HTMLDialogElement | undefined; -export async function showHistory(path: string, restore: (source: string) => void, topUp: () => void): Promise { +type Entry = Omit & { reason: string; cloud?: boolean }; + +/** Both histories in one list, newest first, without a version repeated next to itself. */ +export function combine(local: history.Snapshot[], cloud: cloudHistory.CloudVersion[]): Entry[] { + const all: Entry[] = [...local, ...cloud.map((version) => ({ id: version.id, at: version.at, reason: `synced from ${version.device}`, source: version.source, cloud: true }))]; + all.sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || Number(!!a.cloud) - Number(!!b.cloud)); + return all.filter((entry, index) => index === 0 || entry.source !== all[index - 1]!.source); +} + +/** `writable`: false where the file cannot be changed (read-only), so no version can be restored. */ +export async function showHistory(path: string, restore: (source: string) => void, topUp: () => void, writable = true): Promise { dialog ??= build(); const title = dialog.querySelector("h2")!; const account = dialog.querySelector(".history-account")!; @@ -45,10 +57,11 @@ export async function showHistory(path: string, restore: (source: string) => voi top.onclick = topUp; account.append(`AI summaries · balance ${dollars(billing.balanceMicros)} · `, top); } - const snapshots = await history.list(path); + const [local, cloud] = await Promise.all([history.list(path), cloudHistory.list(path)]); + const snapshots = combine(local, cloud); list.replaceChildren(); if (!snapshots.length) { - list.append(el("p", "history-empty", "No versions yet. A version is kept each time this file is opened from disk, saved, or changes on disk.")); + list.append(el("p", "history-empty", "No versions yet. A version is kept each time this file is opened from disk, saved, changes on disk, or syncs to your account.")); return; } snapshots.forEach((snapshot, index) => { @@ -69,7 +82,7 @@ export async function showHistory(path: string, restore: (source: string) => voi } } const actions = el("div", "history-actions"); - if (older && !snapshot.summary && billing?.enabled) { + if (older && !snapshot.cloud && !snapshot.summary && billing?.enabled) { const summarize = el("button", "ghost", "Summarize with AI"); summarize.type = "button"; summarize.onclick = async () => { @@ -90,7 +103,7 @@ export async function showHistory(path: string, restore: (source: string) => voi }; actions.append(summarize); } - if (index > 0) { + if (index > 0 && writable) { const back = el("button", "ghost", "Restore"); back.type = "button"; back.onclick = () => { restore(snapshot.source); dialog!.close(); }; diff --git a/site/server.ts b/site/server.ts index c81aa19..b222587 100644 --- a/site/server.ts +++ b/site/server.ts @@ -19,6 +19,7 @@ import { CoinPay } from "../server/coinpay.ts"; import { createOAuth } from "../server/oauth.ts"; import { createSyncApi } from "../server/sync-api.ts"; import { Blobs, createBlobApi } from "../server/blobs.ts"; +import { FileHistory, createFileHistoryApi } from "../server/file-history.ts"; import { createCrawlGate, type CrawlGate } from "../server/crawl-gate.ts"; const dist = join(dirname(fileURLToPath(import.meta.url)), "dist"); @@ -154,6 +155,7 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP topups: () => payments.enabled, }); const blobApi = createBlobApi(blobs, accounts); + const fileHistoryApi = createFileHistoryApi(new FileHistory(store), accounts); setInterval(() => { try { blobs.tick(); } catch (error) { console.error("readm3 storage charge:", error instanceof Error ? error.message : error); } }, 3600_000).unref(); // `readm3 login`: OAuth 2.1 for the CLI (server/oauth.ts). const oauth = createOAuth(accounts); @@ -202,6 +204,8 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP if (syncResponse) return syncResponse; const blobResponse = await blobApi(request); if (blobResponse) return blobResponse; + const fileHistoryResponse = await fileHistoryApi(request); + if (fileHistoryResponse) return fileHistoryResponse; const documentResponse = await api(request, ip); if (documentResponse) return documentResponse; diff --git a/site/sharing.ts b/site/sharing.ts index e8a4d3d..856a331 100644 --- a/site/sharing.ts +++ b/site/sharing.ts @@ -235,7 +235,7 @@ export async function history( >("versions_list", { documentId: doc.id }); const box = modal("Version history"); box.body.innerHTML = - '

Each save creates an immutable version. Restoring keeps the history and creates a new current version.

'; + `

Each save creates an immutable version. ${doc.canEdit ? "Restoring keeps the history and creates a new current version." : "You can view this document, so its versions are read-only for you."}

`; for (const version of versions) { const row = document.createElement("div"); row.className = "history-item"; @@ -258,7 +258,8 @@ export async function history( } }; actions.append(preview); - if (version.id !== doc.currentVersion) { + // Only someone who can edit the document can restore a version of it. + if (doc.canEdit && version.id !== doc.currentVersion) { const restore = document.createElement("button"); restore.textContent = "Restore"; restore.onclick = async () => { diff --git a/site/test/sync.spec.ts b/site/test/sync.spec.ts index dab5fd9..94c1910 100644 --- a/site/test/sync.spec.ts +++ b/site/test/sync.spec.ts @@ -103,6 +103,15 @@ test("two devices sync files and settings automatically through the encrypted co await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await page.locator('.file-row[data-path="Synced.md"]').click(); await expect(page.locator("#document-content")).toContainText("Edited on second device", { timeout: 20_000 }); + // The other device's edit is in this device's History, from the account, and the text before it can be restored. + await page.locator("#history").click(); + const history = page.locator("#file-history-dialog"); + await expect(history.locator(".history-reason", { hasText: "synced from browser" }).first()).toBeVisible({ timeout: 20_000 }); + await expect(history.locator(".history-item").first()).toContainText("Edited on second device"); + const raw = await (await page.request.get("/api/v1/file-history?tag=" + "0".repeat(64))).json(); + expect(raw.versions).toEqual([]); + await history.locator(".history-item").last().getByRole("button", { name: "Restore" }).click(); + await expect(page.locator("#document-content")).toContainText("Across devices"); await openSync(page); await page.getByRole("button", { name: "Local backups", exact: true }).click(); await expect(page.getByRole("dialog", { name: "Local sync backups" })).toBeVisible(); diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 5c8147d..e0175a4 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -253,6 +253,51 @@ test("keeps a rolling history: opened, saved, changed on disk, with snippets and await expect(page.locator("#notice")).toContainText("Restored"); }); +test("picks up a file saved in another editor even when it is not the open one", async ({ page }) => { + // Two files from disk; the second is changed by another program while the first is open. + await page.addInitScript(() => { + const disk: Record = { + "a.md": { text: "# Alpha\n", modified: 1 }, + "b.md": { text: "# Bravo first\n", modified: 1 }, + }; + Object.assign(window, { disk }); + const handle = (name: string) => ({ + kind: "file", name, queryPermission: async () => "granted", + getFile: async () => new File([disk[name]!.text], name, { lastModified: disk[name]!.modified }), + createWritable: async () => { let out = ""; return { write: async (data: string) => { out += data; }, close: async () => { disk[name]!.text = out; disk[name]!.modified++; } }; }, + }); + Object.assign(window, { + showDirectoryPicker: async () => ({ kind: "directory", name: "notes", values: async function* () { yield handle("a.md"); yield handle("b.md"); } }), + }); + }); + await page.reload(); + await page.locator("#open-folder").click(); + await expect(page.locator("#workspace-name")).toHaveText("notes"); + await page.locator('#file-list button[title="notes/a.md"]').click(); + await expect(page.locator("#document-name")).toHaveText("notes/a.md"); + await page.evaluate(() => { + const disk = (window as unknown as { disk: Record }).disk; + disk["b.md"] = { text: "# Bravo saved elsewhere\n", modified: 50 }; + window.dispatchEvent(new Event("focus")); + }); + // The workspace (what sync uploads) has the new text without b.md ever being opened. + await expect.poll(() => page.evaluate(() => new Promise((done) => { + const open = indexedDB.open("readm3"); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction([...db.objectStoreNames], "readonly"); + let found = ""; + for (const name of db.objectStoreNames) tx.objectStore(name).getAll().onsuccess = (event) => { + const text = JSON.stringify((event.target as IDBRequest).result); + if (text.includes("Bravo saved elsewhere")) found = "saved"; + }; + tx.oncomplete = () => done(found); + }; + }))).toBe("saved"); + await page.locator('#file-list button[title="notes/b.md"]').click(); + await expect(page.locator("#reader")).toContainText("Bravo saved elsewhere"); +}); + test("goes to files with Ctrl+P, runs commands with Ctrl+Shift+P, and keeps open files as tabs", async ({ page }) => { await page.locator("#reader").press("Control+p"); await expect(page.locator("#palette")).toBeVisible(); diff --git a/site/viewer.ts b/site/viewer.ts index 4a27fe2..545a8eb 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -816,34 +816,82 @@ function openHistory() { /** When each disk file was last seen to change, so the watcher reads it only when it moves. */ const diskSeen = new Map(); +/** Where the round of the other disk files stopped; the open file is checked every time. */ +let sweepFrom = 0; +let checking = false; +const SWEEP_BATCH = 100; + +/** + * Every file from disk is watched, not only the open one: a file saved in another editor + * (or regenerated, or checked out) is reloaded here so sync carries it to the account. + * The open file is checked on each tick; the rest in rounds of SWEEP_BATCH, all of them + * when the window comes back into focus. + */ +async function checkDisk(all = false) { + if (!workspace || cloudDocument || pasteDocument || document.hidden || checking) return; + checking = true; + try { + const others = [...diskEntries.keys()].filter((path) => path !== workspace.active); + const batch = all ? others : others.slice(sweepFrom, sweepFrom + SWEEP_BATCH); + sweepFrom = all || sweepFrom + SWEEP_BATCH >= others.length ? 0 : sweepFrom + SWEEP_BATCH; + for (const path of [workspace.active, ...batch]) await checkDiskFile(path); + } finally { checking = false; } +} + /** * A file from disk that changes underneath the editor (regenerated output, a git * checkout, another editor) gets a version in History. With no local edits it is * reloaded; with local edits it is left alone and the disk version waits in History. */ -async function checkDisk() { - if (!workspace || cloudDocument || pasteDocument || document.hidden || isBinary() || current().blob) return; - const doc = current(); - const entry = diskEntries.get(doc.path); - if (!entry) return; +async function checkDiskFile(path: string) { + const doc = workspace.documents.find((candidate) => candidate.path === path); + const entry = diskEntries.get(path); + if (!doc || !entry) return; + const name = path.split("/").at(-1); + const active = () => workspace.active === path && !cloudDocument && !pasteDocument; + if (doc.blob) { + // A large file is never read to be checked: its size and time come from a stat. + const seen = await disk.stat(entry.handle); + if (!seen) return; + const before = diskSeen.get(path); + diskSeen.set(path, seen.modified); + if (before === undefined ? seen.size === doc.blob.size : before === seen.modified) return; + const ref = newBlobRef(seen.size); + stored.fromDisk(ref, () => entry.handle.getFile()); + doc.blob = ref; + if (active()) { previousRender = ""; openDocument(path, false); notice(`${name} changed on disk and was reloaded.`); } + changed(); + return; + } let file: File; try { file = await entry.handle.getFile(); } catch { return; } - if (diskSeen.get(doc.path) === file.lastModified) return; - diskSeen.set(doc.path, file.lastModified); + if (diskSeen.get(path) === file.lastModified) return; + diskSeen.set(path, file.lastModified); + if (binaryType(path)) { + const source = toBase64(new Uint8Array(await file.arrayBuffer())); + if (source === doc.source || Math.ceil(file.size * 4 / 3) > MAX_FILE_BYTES) return; + doc.source = source; + entry.saved = disk.fingerprint(source); + void disk.storeEntries(diskEntries); + if (active()) { previousRender = ""; openDocument(path, false); } + changed(); + return; + } const text = await file.text(); - if (disk.fingerprint(text) === entry.saved || workspace.active !== doc.path) return; - const name = doc.path.split("/").at(-1); + if (disk.fingerprint(text) === entry.saved) return; const edited = unsavedOnDisk(doc); - await fileHistory.record(doc.path, text, "changed on disk"); - if (edited) { notice(`${name} changed on disk. Your unsaved edits are kept; the disk version is in History.`); return; } + await fileHistory.record(path, text, "changed on disk"); + if (edited) { if (active()) notice(`${name} changed on disk. Your unsaved edits are kept; the disk version is in History.`); return; } entry.saved = disk.fingerprint(text); void disk.storeEntries(diskEntries); doc.source = text; - previousRender = ""; - documentInfo(); - setMode(editing, false); + if (active()) { + previousRender = ""; + documentInfo(); + setMode(editing, false); + notice(`${name} changed on disk and was reloaded. History has both versions.`); + } changed(); - notice(`${name} changed on disk and was reloaded. History has both versions.`); } /** What an input or a drop hands over: files with their relative paths, no way back to disk. */ @@ -1303,7 +1351,7 @@ async function start() { if (remote) await openUrlParam(remote); receiveLaunches(); setInterval(() => void checkDisk(), 5000); - window.addEventListener("focus", () => void checkDisk()); + window.addEventListener("focus", () => void checkDisk(true)); const topup = params.get("topup"); if (topup) { history.replaceState(null, "", "/viewer"); void awaitTopUp(topup, notice); } void setupPwa(); diff --git a/src/cli.ts b/src/cli.ts index 9d1868e..34d5cac 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.24.4"; +export const VERSION = "0.25.0"; /** 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 diff --git a/src/sync-crypto.ts b/src/sync-crypto.ts index a55ee11..cf73343 100644 --- a/src/sync-crypto.ts +++ b/src/sync-crypto.ts @@ -73,3 +73,16 @@ export function envelopeProblem(value: unknown): string | null { if (typeof e.data !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(e.data)) return "invalid sealed data"; return null; } + +/** + * A file's name as the server may see it: 64 hex characters that only someone with the + * sync key can produce from the path. AES-GCM over the path's SHA-256, with an IV taken + * from that hash, so the same path always gives the same tag and an IV repeats only + * for the same path (the one case where the output is meant to repeat). + */ +export async function pathTag(path: string, { key }: SyncKey): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`readm3 path\0${path}`))); + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: digest.slice(0, 12) as BufferSource }, key, digest as BufferSource)); + const tag = new Uint8Array(await crypto.subtle.digest("SHA-256", sealed as BufferSource)); + return [...tag].map((n) => n.toString(16).padStart(2, "0")).join(""); +} diff --git a/test/sync-crypto.test.ts b/test/sync-crypto.test.ts index bdc5a38..09ddf4e 100644 --- a/test/sync-crypto.test.ts +++ b/test/sync-crypto.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { deriveKey, envelopeProblem, newKdf, open, seal, WrongPassphrase, type Kdf } from "../src/sync-crypto.ts"; +import { deriveKey, envelopeProblem, newKdf, open, pathTag, seal, WrongPassphrase, type Kdf } from "../src/sync-crypto.ts"; // Fewer iterations keep the suite fast; the format and checks are the same. const quick = (): Kdf => ({ ...newKdf(), iterations: 100_000 }); @@ -41,3 +41,13 @@ test("the server's shape check rejects anything but an envelope", async () => { assert.match(envelopeProblem({ ...(await seal({}, await deriveKey("x".repeat(10), quick()))), extra: 1 }) ?? "", /unknown fields/); assert.match(envelopeProblem("x") ?? "", /object/); }); + +test("a path tag is stable per key and path, and says nothing about the path", async () => { + const kdf = quick(); + const key = await deriveKey("correct horse battery", kdf); + const tag = await pathTag("notes/plan.md", key); + assert.match(tag, /^[0-9a-f]{64}$/); + assert.equal(await pathTag("notes/plan.md", await deriveKey("correct horse battery", kdf)), tag, "another device gets the same tag"); + assert.notEqual(await pathTag("notes/plan2.md", key), tag); + assert.notEqual(await pathTag("notes/plan.md", await deriveKey("another passphrase", kdf)), tag, "another key, another tag"); +});