canvas-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 93fdaafeda16
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 788ba0fa01083b300ad50913
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/api.ts
// Canvas's public HTTP API: no account. An assistant can start a board from a flowchart, a mind map,
// columns of sticky notes or a template, read a board back, and (with the admin key) rename it,
// turn its links off or delete it. Every route answers JSON; GET / describes the routes.
// Writes go through the same policy rules as the web app, as this route's public principal. Every
// caller shares that principal, so the routes check the key a caller presents before acting.
import { SITE_URL } from "./lib/config";
import { LIMITS, TEMPLATES, byteLength, expandSeed, isElement, parseSeed, randomId, seedFromInput, summarize, templateById, type Seed } from "./lib/canvas";

const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{16,32}$/;

const cors = {
  "access-control-allow-origin": "*",
  "access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
  "access-control-allow-headers": "content-type, x-edit-key, x-admin-key, idempotency-key",
};
const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);

class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }

async function body(req: Request): Promise<Record<string, any>> {
  try {
    const parsed = await req.json();
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
  } catch { /* fall through */ }
  throw new HttpError(400, "Send a JSON object body.");
}

/** Reads a collection page by page, keeping only what `keep` returns for each row, for up to `max` rows. */
async function listAll<T>(ctx: any, path: string, keep: (row: any, id: string) => T | null, max = 50_000): Promise<{ items: T[]; complete: boolean }> {
  const items: T[] = [];
  let cursor: string | undefined;
  for (let page = 0; page * 200 < max; page++) {
    const res = await ctx.bounded.get(path, { limit: 200, ...(cursor ? { cursor } : {}) });
    // Rows name their path in _id (or pathId); the bare id is its last segment.
    for (const r of res?.data || []) {
      const item = keep(r, r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop());
      if (item != null) items.push(item);
    }
    cursor = res?.nextCursor;
    if (!cursor) return { items, complete: true };
  }
  return { items, complete: false };
}

/** The parts of a stored element the summary reads, cut to size: an element can be 100 KB of anything. */
function readElement(json: string) {
  let e: any;
  try { e = JSON.parse(json); } catch { return null; }
  if (!isElement(e)) return null;
  const cut = (s: unknown, max: number) => (typeof s === "string" ? s.slice(0, max) : undefined);
  const bound = (b: any) => (typeof b?.elementId === "string" ? { elementId: b.elementId.slice(0, 64) } : null);
  return {
    id: cut(e.id, 64)!, type: e.type, x: e.x, y: e.y, width: e.width, height: e.height, isDeleted: e.isDeleted,
    text: cut(e.originalText ?? e.text, LIMITS.readText), name: cut(e.name, LIMITS.readText), backgroundColor: cut(e.backgroundColor, 32),
    containerId: cut(e.containerId, 64), frameId: cut(e.frameId, 64), startBinding: bound(e.startBinding), endBinding: bound(e.endBinding),
    // The summary looks only for a shape's text label among what is bound to it.
    boundElements: (e.boundElements || []).filter((b: any) => b.type === "text").slice(0, 5).map((b: any) => ({ id: b.id.slice(0, 64), type: b.type })),
  };
}

const links = (id: string, editKey?: string, adminKey?: string) => ({
  viewUrl: `${SITE_URL}/b/${id}`,
  ...(editKey ? { editUrl: `${SITE_URL}/b/${id}#edit=${editKey}` } : {}),
  ...(adminKey ? { adminUrl: `${SITE_URL}/b/${id}/admin#k=${adminKey}` } : {}),
});

function describeSeed(seed: Seed | null): string {
  if (!seed) return "an empty board";
  if ("template" in seed) return `the ${templateById(seed.template)?.name ?? seed.template} template`;
  const { graph, columns } = expandSeed(seed);
  if (graph) return `a diagram of ${graph.nodes.length} shapes and ${graph.edges.length} arrows, laid out ${graph.direction === "right" ? "left to right" : "top to bottom"}`;
  if (columns) return `${columns.length} columns of sticky notes`;
  return "an empty board";
}

type Role = "admin" | "editor" | null;

/** Opens a board as this route, checking the key the caller sent. Knowing the id is enough to view while the view link is on. */
async function openBoard(ctx: any, req: Request, id: string) {
  if (!ID_RE.test(id)) throw new HttpError(404, "No board with that id. The id is the part of the link after /b/.");
  const me = ctx.user.id;
  const keys = await ctx.bounded.get(`boardkeys/${id}`);
  if (!keys) throw new HttpError(404, "No board with that id. The id is the part of the link after /b/.");
  const join = await ctx.bounded.get(`boards/${id}/joins/${me}`);
  if (!join) {
    try {
      await ctx.bounded.set(`boards/${id}/joins/${me}`, { at: NOW });
    } catch {
      throw new HttpError(404, "This board was deleted.");
    }
  }
  const adminKey = (req.headers.get("x-admin-key") || "").trim();
  const editKey = (req.headers.get("x-edit-key") || "").trim();
  let role: Role = null;
  if (adminKey) {
    if (adminKey !== keys.key) throw new HttpError(403, "That admin key does not match this board. It is the part of the admin link after #k=.");
    if (!(await ctx.bounded.get(`boards/${id}/owners/${me}`))) {
      await ctx.bounded.setMany([
        { path: `boardclaims/${id}/c/${me}`, document: { key: adminKey } },
        { path: `boards/${id}/owners/${me}`, document: { at: NOW } },
      ]);
    }
    role = "admin";
  }
  const edit = await ctx.bounded.get(`editkeys/${id}`);
  if (!role && editKey) {
    if (!edit || editKey !== edit.key) throw new HttpError(403, "That edit key does not match this board. It is the part of the edit link after #edit=; the owner may have made a new edit link.");
    role = "editor";
    // This route reads as an editor only after checking the key, so other callers sharing it gain nothing.
    if ((join?.key ?? null) !== edit.key) await ctx.bounded.set(`boards/${id}/joins/${me}`, { key: edit.key });
  }
  const board = await ctx.bounded.get(`boards/${id}`);
  if (!board) throw new HttpError(404, "This board was deleted.");
  if (role === "editor" && !board.editOn) role = null;
  if (!role && !board.viewOn) throw new HttpError(403, "This board's view link is off. Send its admin key in x-admin-key, or its edit key in x-edit-key while the edit link is on.");
  return { board, role, editKey: edit?.key as string | undefined, adminKey: role === "admin" ? adminKey : undefined };
}

function boardInfo(id: string, board: any, role: Role, editKey?: string, adminKey?: string) {
  const canSeeEdit = role === "admin" || (role === "editor" && board.editOn);
  return {
    id,
    title: board.title || "Untitled board",
    editLink: board.editOn ? "on" : "off",
    viewLink: board.viewOn ? "on" : "off",
    ...links(id, canSeeEdit && board.editOn ? editKey : undefined, adminKey),
  };
}

const routes = {
  name: "Canvas by OpenApps",
  about: "A free online whiteboard. No key and no account. Start a board from a diagram, a mind map, sticky notes or a template, and send the person its link.",
  docs: `${SITE_URL}/llms.txt`,
  openapi: `${SITE_URL}/openapi.json`,
  routes: [
    "POST /boards {title?, nodes?, edges?, direction?} | {title?, columns?} | {title?, template?} -> the board's edit, view and admin links",
    "GET /boards/:id -> what is on the board (needs x-edit-key or x-admin-key when the view link is off)",
    "PATCH /boards/:id {title?, editLink?, viewLink?, newEditLink?} with x-admin-key",
    "DELETE /boards/:id with x-admin-key",
  ],
  templates: TEMPLATES.map((t) => ({ id: t.id, name: t.name, summary: t.summary })),
};

export default async function api(req: Request, ctx: any) {
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
  const parts = new URL(req.url).pathname.split("/").filter(Boolean);
  // Accept both /boards/... and /api/boards/... so a copied path works either way.
  if (parts[0] === "api") parts.shift();
  try {
    if (!parts.length && req.method === "GET") return json(routes);
    if (parts[0] !== "boards") return fail(404, "Unknown route. GET / lists the routes.");
    const me = ctx.user.id;

    if (parts.length === 1 && req.method === "POST") {
      const input = await body(req);
      const made = seedFromInput(input);
      if ("error" in made) return fail(400, made.error);
      const seed = made.seed;
      const fallback = seed && "template" in seed ? templateById(seed.template)?.title : undefined;
      const title = String(input.title ?? fallback ?? "Untitled board").replace(/\s+/g, " ").trim().slice(0, LIMITS.title) || "Untitled board";
      const seedJson = seed ? JSON.stringify(seed) : "";
      if (byteLength(seedJson) > 100_000) return fail(400, "That is too much to start a board with. Send fewer or shorter nodes or notes.");
      const id = randomId(20);
      const editKey = randomId(24);
      const adminKey = randomId(24);
      await ctx.bounded.setMany([
        { path: `boards/${id}`, document: { title, editOn: true, viewOn: true, seed: seedJson, createdBy: me, createdAt: NOW } },
        { path: `boards/${id}/joins/${me}`, document: { at: NOW, key: editKey } },
        { path: `boards/${id}/owners/${me}`, document: { at: NOW } },
        { path: `boardkeys/${id}`, document: { key: adminKey } },
        { path: `editkeys/${id}`, document: { key: editKey } },
      ]);
      return json({
        id, title, ...links(id, editKey, adminKey), editKey, adminKey,
        startsWith: describeSeed(seed),
        share: "Send editUrl to people who should draw on the board and viewUrl to people who should only look. Keep adminUrl private: it renames the board, turns its links off and deletes it. The board is drawn the first time someone opens editUrl.",
      }, 201);
    }

    const id = parts[1];
    if (parts.length !== 2) return fail(404, "Unknown route. GET / lists the routes.");

    if (req.method === "GET") {
      const { board, role, editKey, adminKey } = await openBoard(ctx, req, id);
      const { items: elements, complete } = await listAll(ctx, `boards/${id}/els`, (r) => readElement(r.json), LIMITS.readElements);
      const seed = parseSeed(board.seed);
      return json({
        board: boardInfo(id, board, role, editKey, adminKey),
        ...(elements.length
          ? { contents: summarize(elements), ...(complete ? {} : { note: `This board is large: contents covers its first ${LIMITS.readElements} elements.` }) }
          : { contents: null, startsWith: describeSeed(seed), note: seed ? "Nothing has been drawn yet: the board is drawn from what it was started with the first time someone opens the edit link." : "The board is empty." }),
      });
    }

    if (req.method === "PATCH") {
      const { board, role, editKey } = await openBoard(ctx, req, id);
      if (role !== "admin") return fail(401, "Changing a board needs its admin key in the x-admin-key header.");
      const input = await body(req);
      const next: Record<string, unknown> = {};
      if (input.title != null) {
        const title = String(input.title).replace(/\s+/g, " ").trim().slice(0, LIMITS.title);
        if (!title) return fail(400, "title cannot be empty.");
        next.title = title;
      }
      for (const [field, key] of [["editLink", "editOn"], ["viewLink", "viewOn"]] as const) {
        if (input[field] == null) continue;
        if (typeof input[field] !== "boolean") return fail(400, `${field} must be true (on) or false (off).`);
        next[key] = input[field];
      }
      if (input.newEditLink != null && input.newEditLink !== true) return fail(400, "newEditLink must be true, or left out.");
      if (!Object.keys(next).length && !input.newEditLink) return fail(400, "Send title, editLink, viewLink or newEditLink.");
      if (Object.keys(next).length) await ctx.bounded.set(`boards/${id}`, next);
      let key = editKey;
      if (input.newEditLink) {
        key = randomId(24);
        await ctx.bounded.set(`editkeys/${id}`, { key });
        await ctx.bounded.set(`boards/${id}/joins/${me}`, { key });
      }
      return json({ board: boardInfo(id, { ...board, ...next }, role, key, req.headers.get("x-admin-key")!.trim()) });
    }

    if (req.method === "DELETE") {
      const { role } = await openBoard(ctx, req, id);
      if (role !== "admin") return fail(401, "Deleting a board needs its admin key in the x-admin-key header.");
      // Links off first, so nobody keeps drawing while the board is cleared.
      await ctx.bounded.set(`boards/${id}`, { editOn: false, viewOn: false });
      for (const sub of ["els", "cursors", "files"]) {
        const { items: ids } = await listAll(ctx, `boards/${id}/${sub}`, (_r, rowId) => rowId);
        for (let i = 0; i < ids.length; i += 100) {
          await ctx.bounded.setMany(ids.slice(i, i + 100).map((rowId) => ({ path: `boards/${id}/${sub}/${rowId}`, document: null })));
        }
      }
      await ctx.bounded.set(`boards/${id}`, null);
      return json({ deleted: true, id, note: "The board and everything on it are gone. Its links no longer open anything." });
    }

    return fail(405, "Use GET, PATCH or DELETE on /boards/:id, and POST on /boards.");
  } catch (err) {
    if (err instanceof HttpError) return fail(err.status, err.message);
    const message = err instanceof Error ? err.message : String(err);
    return fail(500, `Something went wrong: ${message}`);
  }
}

Change history

Point OpenApps links to openapps.free
93fdaafeda · 18 hours ago
Bind AGENT_ADDRESS in policy constants
bf8f43b096 · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
56f1a381b2 · 20 hours ago
Bounded source sync
9ec707aeb8 · 1 day ago
Bounded source sync
5645481c23 · 1 day ago
Bounded source sync
c38fe71874 · 1 day ago
Bounded source sync
28c0350b40 · 1 day ago