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 Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ COPY --from=builder --chown=bun:bun /app/server ./server
# Only the src modules the server imports at runtime: sync-api needs sync-schema, and
# pastes needs code.ts (language detection; its one dependency, highlight.js, is in node_modules),
# and billing needs diff.ts (the AI summary is asked about the changed lines).
COPY --from=builder --chown=bun:bun /app/src/sync-schema.ts /app/src/flavors.ts /app/src/emoji.ts /app/src/code.ts /app/src/diff.ts /app/src/sensitive.ts /app/src/sync-crypto.ts ./src/
COPY --from=builder --chown=bun:bun /app/src/sync-schema.ts /app/src/flavors.ts /app/src/emoji.ts /app/src/code.ts /app/src/diff.ts /app/src/sensitive.ts /app/src/sync-crypto.ts /app/src/blob-store.ts ./src/
COPY --from=deps --chown=bun:bun /app/node_modules ./node_modules
RUN mkdir -p /data && chown bun:bun /data

Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,28 @@ work offline. Remote URLs
need a connection and a host that permits browser access (CORS); files are fetched
directly with no credentials or server proxy. Local files are uploaded only when you explicitly choose Share → Save online.

Imports are limited to 4 MB per file, 20 MB per workspace, and 1,000 files.
#### File limits and storage

| | Without an account | Free account | Pay as you go |
| --- | --- | --- | --- |
| Largest file | 4 MB | 5 GB | 5 GB |
| Number of files | 1,000 (20 MB in all) | no limit | no limit |
| Synced storage | none | 5 GB | $0.05 per GB a month over 5 GB |

Signed in, a file over 4 MB is a *stored file*: it opens read-only (a PDF or image is
shown whole, text as its first megabyte) and Download gives it back exactly. On the
device it is kept in browser storage as it is; on the account it is end-to-end
encrypted with its own key in 8 MB chunks, and the key travels only inside your
sealed workspace, so readm3.com holds data it cannot read or name. Small files that
do not fit the synced copy are stored the same way and turn back into ordinary,
editable files when opened.

Storage over the free 5 GB is charged hourly from the prepaid balance (CoinPay
top-ups, as for AI summaries). When the balance is empty, new uploads past the free
allowance are refused; nothing already stored is deleted. `GET /api/v1/blobs` reports
what an account stores, the allowance and the price. Self-hosted servers set
`READM3_FREE_STORAGE_BYTES`, `READM3_STORAGE_MICROS_PER_GB_MONTH` and
`READM3_BLOB_DIR` (default: `blobs/` beside the database).

### File history and AI summaries

Expand Down Expand Up @@ -713,8 +734,9 @@ conflicting local edits, including on a device that has never synced. `--force`
explicitly overrides a conflict. Replaced local files receive numbered `.bak-NNN`
backups. Browser replacements receive backups in IndexedDB, accessible through
**Sync → Local backups** for download or restoration. Clearing browser site data
also removes those local backups. Sync supports the browser's workspace limits:
1,000 files, 4 MB per file, and 20 MB of source in total.
also removes those local backups. The CLI syncs a readable workspace of up to 4 MB
per file and 20 MB in total; the browser and desktop app sync any number of files of
up to 5 GB each (see File limits and storage).

The authenticated API exposes:

Expand Down
28 changes: 21 additions & 7 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@
const { app, BrowserWindow, Menu, ipcMain, net, protocol, session, shell } = require("electron");
const { createHash, randomBytes } = require("node:crypto");
const { createServer } = require("node:http");
const { readFile, readdir, writeFile, stat } = require("node:fs/promises");
const { open, readFile, readdir, writeFile, stat } = require("node:fs/promises");
const { basename, join, resolve } = require("node:path");
const { fileURLToPath, pathToFileURL } = require("node:url");

const ORIGIN = (process.env.READM3_URL || "https://readm3.com").replace(/\/$/, "");
const LOCAL = "app://readm3";
const MAX_FILE_BYTES = 4 * 1024 * 1024;
/** The page's own limits (site/workspace.ts); it applies them again, these only bound the walk. */
const MAX_FILES = 1000;
/** Larger files go to the page without their bytes; it reads them in ranges (read-range). */
const INLINE_BYTES = 4 * 1024 * 1024;
/** The largest file a signed-in account may keep (src/blob-store.ts); the page applies its own limits again. */
const MAX_FILE_BYTES = 5 * 1024 ** 3;
const MAX_RANGE = 16 * 1024 * 1024;
const IGNORED = new Set(["node_modules", "dist", "build", "out", "target", "vendor", "coverage", "venv", "__pycache__", ".git"]);
const NEVER = /\.(?:zip|gz|tgz|bz2|xz|7z|rar|tar|exe|dll|so|dylib|o|a|class|jar|war|wasm|bin|dat|db|sqlite|sqlite3|mp3|mp4|m4a|mov|avi|mkv|webm|ogg|wav|flac|woff2?|ttf|otf|eot|psd|ai|doc|docx|xls|xlsx|ppt|pptx|iso|dmg|pkg|deb|rpm|apk|lock)$/i;
/** The bundled editor: inside the package, or the repo's site build when run with `electron .`. */
Expand All @@ -48,13 +50,13 @@ let window;
async function load(paths, cwd = process.cwd()) {
const files = [];
const add = async (path, info, relative) => {
if (info.size > MAX_FILE_BYTES || files.length >= MAX_FILES) return;
files.push({ path, name: basename(path), relative, data: new Uint8Array(await readFile(path)) });
if (info.size > MAX_FILE_BYTES) return;
const data = info.size > INLINE_BYTES ? undefined : new Uint8Array(await readFile(path));
files.push({ path, name: basename(path), relative, size: info.size, data });
opened.add(path);
};
const walk = async (directory, prefix) => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (files.length >= MAX_FILES) return;
if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
const path = join(directory, entry.name);
const relative = `${prefix}/${entry.name}`;
Expand Down Expand Up @@ -241,6 +243,18 @@ else {
const info = await stat(path);
return { data: new Uint8Array(await readFile(path)), 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.");
if (typeof path !== "string" || !opened.has(path)) throw new Error("readm3 can only read files it opened.");
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end - start > MAX_RANGE) throw new Error("Invalid range.");
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(end - start);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return new Uint8Array(buffer.subarray(0, bytesRead));
} finally { await handle.close(); }
});
ipcMain.handle("write-file", async (event, path, data) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
if (typeof path !== "string" || !opened.has(path)) throw new Error("readm3 can only save files it opened.");
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.15.0",
"version": "0.16.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 @@ -13,6 +13,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o
signInWithBrowser: () => ipcRenderer.invoke("sign-in-with-browser"),
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),
writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data),
});
}
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.15.0",
"version": "0.16.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
9 changes: 9 additions & 0 deletions server/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export class Billing {
return result.changes > 0;
}

/** Takes money once per reference (storage charges it by the hour); false when already taken. */
debit(userId: string, amountMicros: number, ref: string, detail: string): boolean {
const result = this.store.run(
"INSERT OR IGNORE INTO billing_ledger (id,userId,kind,amountMicros,ref,arm,detail,createdAt) VALUES (?,?,?,?,?,NULL,?,?)",
id(), userId, "charge", -amountMicros, ref, detail, now(),
);
return result.changes > 0;
}

account(user: User) {
const arm = this.arm(user);
return {
Expand Down
Loading
Loading