Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
18 changes: 18 additions & 0 deletions canvas/community.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<title>Community — Super Prototyping</title>
<meta
name="description"
content="Community-made app clones, device mockups and editable boards, made with Super Prototyping."
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/communitySite.tsx"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions canvas/public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Cloudflare Pages: a board runs in an origin of its own, as server/sp.ts serves it (SANDBOX).
/board/*
Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads
23 changes: 21 additions & 2 deletions canvas/server/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ it("serves every project at its own address and makes new ones", async () => {
req.end(body && JSON.stringify(body));
});
try {
// Asked for by a name DNS could point here, a page from anywhere would be same-origin.
const at = (host: string) => ask("/", undefined, { host });
expect((await at("rebound.example")).status).toBe(403);
expect((await at(`[::1]:${port}`)).status).toBe(302);
expect((await at("app.localhost")).status).toBe(302);
// Nothing opened yet: the root is home, and the examples' window.
expect(await ask("/")).toMatchObject({
status: 302,
Expand Down Expand Up @@ -166,15 +171,29 @@ it("serves every project at its own address and makes new ones", async () => {
expect(JSON.parse(made.text)).toEqual({ name: "beta", url: "/p/beta/" });
expect(fs.existsSync(path.join(tmp, "projects/beta/canvases"))).toBe(true);
expect(fs.existsSync(path.join(tmp, "projects/beta/.claude"))).toBe(false);
const beta = JSON.parse(
fs.readFileSync(path.join(tmp, "projects/beta/project.json"), "utf8"),
);
expect(beta.format).toBe(1);
expect(beta.id).toMatch(/^[0-9a-f-]{36}$/);
// One a newer app made is not opened, since this one could misread it and write it back.
write("projects/delta/canvases/one/01-a.html", "delta");
write("projects/delta/project.json", JSON.stringify({ format: 2 }));
expect((await ask("/p/delta/")).status).toBe(409);
fs.rmSync(path.join(tmp, "projects/delta"), { recursive: true });
expect((await ask("/p/beta/__sp/index.json")).status).toBe(200);
expect((await ask("/__sp/projects", { name: "beta" })).status).toBe(409);
// No name: the first free "Untitled", which its agent names in project.json.
for (const url of ["/p/Untitled/", "/p/Untitled%202/"])
expect(JSON.parse((await ask("/__sp/projects", { name: " " })).text).url).toBe(url);
expect(
JSON.parse((await ask("/__sp/projects", { name: " " })).text).url,
).toBe(url);
write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" }));
const titled = JSON.parse((await ask("/__sp/projects.json")).text);
expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma");
expect(JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title).toBe("Gamma");
expect(
JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title,
).toBe("Gamma");
expect((await ask("/__sp/projects", { name: "a/b" })).status).toBe(400);

// A reference for a clone: into the project's `refs`, once, and only under a plain name.
Expand Down
47 changes: 46 additions & 1 deletion canvas/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
*/
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { createAgentServer } from "./agent.ts";
import { CANVASES } from "./boards.ts";
import { CANVASES, readJson } from "./boards.ts";
import { createSpServer, reveal, sameOrigin, trash } from "./sp.ts";

/**
Expand Down Expand Up @@ -49,6 +50,29 @@ function moveOldBoards(dir: string) {
fs.rmSync(path.dirname(old), { recursive: true });
}

/**
* The newest `project.json` format this app reads. It goes up only when an app that reads this one
* would misread a project of the next; a file or key it does not know is ignored and kept, so
* adding one needs no new format. A project with none is format 1.
*/
const PROJECT_FORMAT = 1;

/** Whether a Host header names this machine by an address or by localhost, which no DNS can move. */
export function loopbackHost(host: string | undefined) {
if (!host) return false;
let hostname: string;
try {
hostname = new URL(`http://${host}`).hostname;
} catch {
return false;
}
return (
net.isIP(hostname.replace(/^\[(.*)\]$/, "$1")) !== 0 ||
hostname === "localhost" ||
hostname.endsWith(".localhost")
);
}

export function createProjectsServer(options: {
/** Where every project is listed from, and where `POST /__sp/projects` makes one. */
projectsDir: string;
Expand Down Expand Up @@ -111,6 +135,14 @@ export function createProjectsServer(options: {
res: ServerResponse,
next: () => void,
) => {
// Asked for by an address, not a name that could be anyone's. A site can point its own name at
// 127.0.0.1 once its page is open (DNS rebinding), and then it is same-origin with this server
// and every guard here waves it through. The browser still sends the name it asked for as
// Host, so a name other than localhost is refused.
if (!loopbackHost(req.headers.host)) {
res.statusCode = 403;
return res.end("This server answers only to localhost or an IP address.");
}
const url = req.url ?? "/";
const [pathname, query = ""] = url.split(/\?(.*)/s);
// The bare root is the home page, and the root with a query is the window on an example
Expand Down Expand Up @@ -229,6 +261,11 @@ export function createProjectsServer(options: {
// here and the examples, is the tree's and shown beside the project's own, so there is
// nothing to copy in.
fs.mkdirSync(path.join(dir, CANVASES), { recursive: true });
// Its id is what a package of it is known by, whatever the folder is renamed to.
fs.writeFileSync(
path.join(dir, "project.json"),
`${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID() }, null, 2)}\n`,
);
Comment on lines +265 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the generated ID when naming an untitled project

When the user leaves the project name blank, AppShell.tsx immediately instructs the agent to write {"name": "…"} as project.json. This newly created file already contains format and id, so following that instruction replaces the generated identity; sp pack --check then rejects the project for lacking an ID, while sp pack -o silently assigns a different one. Update the naming instruction or flow to merge name into the existing object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 038d8ca: the prompt now tells the agent to add name to project.json, keeping the keys already there.

} catch (e) {
// A new name does not fix an unwritable Documents, so say what failed.
return send(
Expand All @@ -255,6 +292,14 @@ export function createProjectsServer(options: {
res.statusCode = 404;
return res.end("no such project");
}
// Made by a newer app, which may keep it in a way this one would misread, and then write back.
const format = (readJson(path.join(dir, "project.json")) as { format?: unknown })?.format;
if (typeof format === "number" && format > PROJECT_FORMAT) {
res.statusCode = 409;
return res.end(
`“${decodeURIComponent(name)}” was made by a newer Super Prototyping. Update the app to open it.`,
);
}
// The url stays stripped for `next`, which is the static app or Vite serving the page.
req.url = rest;
spFor(dir).handle(req, res, next);
Expand Down
23 changes: 20 additions & 3 deletions canvas/server/sp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ it("shows the examples read-only beside the project's canvases", async () => {
]);
expect((await ask("/board/an-example/01-a.html")).text).toBe("example");
expect((await ask("/board/shadowed/01-a.html")).text).toBe("mine");
// Opened at its own address, a board runs in an origin of its own, not the canvas's.
const board = await ask("/board/shadowed/01-a.html");
expect(board.csp).toMatch(/^sandbox allow-scripts /);

const ground = { ground: "#000000" };
expect(
Expand Down Expand Up @@ -342,6 +345,7 @@ it("serves the project's own files by their absolute path", async () => {
).toEqual({
status: 200,
text: "glow",
csp: expect.stringMatching(/^sandbox allow-scripts /),
});
expect((await ask(at(path.join(tmp, "secret.html")))).status).toBe(404);
// A junction is the link Windows makes without admin rights, and a symlink elsewhere.
Expand Down Expand Up @@ -406,7 +410,10 @@ it("hands a canvas command to the open canvas page and its answer back", async (
sheet.close();

// A shell cancelled while its command waited: the command is not run for nobody.
const cancelled = ask("/__sp/canvas", { slug: "home", command: { op: "delete" } });
const cancelled = ask("/__sp/canvas", {
slug: "home",
command: { op: "delete" },
});
await waiting();
cancelled.drop();
// Sent before the page opens, which is a reload: it goes to the page once it does.
Expand Down Expand Up @@ -459,13 +466,23 @@ async function serve(options: Parameters<typeof createSpServer>[0]) {
// `drop` is the caller going away before the answer, as a cancelled shell does.
const ask = (url: string, body?: object) => {
let req!: http.ClientRequest;
const answered = new Promise<{ status: number; text: string }>((done) => {
const answered = new Promise<{
status: number;
text: string;
csp?: string | string[];
}>((done) => {
req = http.request(
{ port, path: url, method: body ? "POST" : "GET" },
(res) => {
let text = "";
res.on("data", (chunk) => (text += chunk));
res.on("end", () => done({ status: res.statusCode!, text }));
res.on("end", () =>
done({
status: res.statusCode!,
text,
csp: res.headers["content-security-policy"],
}),
);
},
);
req.on("error", () => {});
Expand Down
15 changes: 11 additions & 4 deletions canvas/server/sp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ export function sameOrigin(req: IncomingMessage) {
return site === undefined || site === "same-origin" || site === "none";
}

/**
* What a board, or any page a project holds, runs under when it is opened at its own address: an
* origin of its own, so its script cannot reach the canvas's storage or its write endpoints, whose
* guard sees it as cross-site. A board is a project's, and a project can be someone else's.
* `public/_headers` gives the hosted build's `/board/*` the same.
*/
const SANDBOX =
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";

/**
* A canvas's folder: the project's own, else the example of that name. The project's own is what
* the scan in boards.ts calls a canvas, a folder with a board or a layout.json in it, so a folder
Expand Down Expand Up @@ -390,6 +399,7 @@ export function createSpServer(options: {
return send(404, "not a board");
}
res.setHeader("Content-Type", type);
res.setHeader("Content-Security-Policy", SANDBOX);
res.setHeader("Cache-Control", "no-store");
res.setHeader("Accept-Ranges", "bytes");
// A byte range, which is how a video seeks: a pasted one can be a gigabyte, and without it
Expand Down Expand Up @@ -467,10 +477,7 @@ export function createSpServer(options: {
return res.end("not a file of this project");
}
res.setHeader("Content-Type", type);
res.setHeader(
"Content-Security-Policy",
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads",
);
res.setHeader("Content-Security-Policy", SANDBOX);
res.setHeader("Cache-Control", "no-store");
fs.createReadStream(file).pipe(res);
});
Expand Down
41 changes: 38 additions & 3 deletions canvas/src/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
readOpenTabs,
tabFor,
tabKey,
tabOfExample,
tabUrl,
withTab,
writeOpenTabs,
Expand All @@ -21,6 +22,7 @@ import {
type CanvasAttachDetail,
type Working,
} from "./ChatPanel";
import { CommunityPage } from "./Community";
import { HomePage } from "./HomePage";
import { NewProjectDialog, type NewProjectStart } from "./NewProjectDialog";
import { Onboarding } from "./Onboarding";
Expand Down Expand Up @@ -83,7 +85,16 @@ const onboarding = new URLSearchParams(location.search).get("onboarding");
*/
export function AppShell() {
const chat = useChat();
const [home, setHome] = useState(opened === null);
/** What is over the frame, if anything: home, or the community (Community.tsx). */
const [page, setPage] = useState<"home" | "community" | null>(
opened === null ? "home" : null,
);
/** Whether the frame is behind a page, so no project's canvas is in front. */
const home = page !== null;
const setHome = (on: boolean) => setPage(on ? "home" : null);
/** Whether the community has its chip on the bar. */
// ponytail: not among the tabs kept for the next visit; keep it in writeOpenTabs if missed.
const [community, setCommunity] = useState(false);
/** What the canvas last said it has in front, and its address; null before one has loaded. */
const [shown, setShown] = useState(opened);
/**
Expand Down Expand Up @@ -289,10 +300,25 @@ export function AppShell() {
active={home ? null : (shown?.tab ?? null)}
working={working}
onHome={() => setHome(true)}
community={
community
? {
active: page === "community",
open: () => setPage("community"),
// Closing it in front goes home, where it was opened from.
close: () => {
setCommunity(false);
if (page === "community") setHome(true);
},
}
: undefined
}
goTo={goTo}
closeTabs={closeTabs}
reload={() =>
home ? listProjects() : frame.current!.contentWindow!.location.reload()
page === "home"
? listProjects()
: page === null && frame.current!.contentWindow!.location.reload()
}
newProject={served ? newProject : undefined}
>
Expand Down Expand Up @@ -322,8 +348,17 @@ export function AppShell() {
src={opened?.href}
style={home ? { visibility: "hidden" } : undefined}
/>
{home && (
{page === "community" && (
<CommunityPage
openExample={(slug) => goTo(tabOfExample(slug, tabs))}
/>
)}
{page === "home" && (
<HomePage
openCommunity={() => {
setCommunity(true);
setPage("community");
}}
projects={projects}
tabs={tabs}
goTo={goTo}
Expand Down
38 changes: 22 additions & 16 deletions canvas/src/BoardsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import { brandPageUrl, canvasPageUrl } from "./canvasUrl";
/** The extension that reads a page, and the Figma plugin that the extension can hand off to. */
const H2D_EXTENSION =
"https://chromewebstore.google.com/detail/htmltodesign/ldnheaepmnmbjjjahokphckbpgciiaed";
const H2D_PLUGIN = "https://www.figma.com/community/plugin/1159123024924461424/html-to-design";
const H2D_PLUGIN =
"https://www.figma.com/community/plugin/1159123024924461424/html-to-design";

/**
* One canvas page's boards, each at its own size, in one scrolling document. The page behind
* `sheet.html?canvas=<slug>`, which Export to Figma on the canvas strip links to.
*
* Each board is an iframe pointed at that board's own address rather than inlined, because a
* board is a whole document — its own doctype, its own reset, its own fonts — and forty of them
* flattened into one would be forty stylesheets fighting. The frames are same-origin, so a board
* behaves here exactly as it does in a tab of its own.
* flattened into one would be forty stylesheets fighting. The server sandboxes each in an origin
* of its own (sp.ts, SANDBOX), so a board behaves here exactly as it does in a tab of its own.
*/
export function BoardsSheet({ slug }: { slug: string }) {
const rows = sheetRows(slug);
Expand Down Expand Up @@ -47,10 +48,11 @@ export function BoardsSheet({ slug }: { slug: string }) {
<div className="howto__body">
<ol>
<li>
<b>Install the browser extension.</b> The extension rather than the Figma plugin
alone, because the plugin fetches a public address from Figma's servers and a
canvas on localhost is not one — the extension reads the page from inside the
browser that already has it open.
<b>Install the browser extension.</b> The extension rather than
the Figma plugin alone, because the plugin fetches a public
address from Figma's servers and a canvas on localhost is not one
— the extension reads the page from inside the browser that
already has it open.
{/* The one thing on this page that has to be done before anything else works, so
it is a button and not the third link in a paragraph. */}
<a
Expand All @@ -75,24 +77,28 @@ export function BoardsSheet({ slug }: { slug: string }) {
</svg>
<span>
Add html.to.design to your browser
<small>Free · Chrome, Edge, Brave, Arc and other Chromium browsers</small>
<small>
Free · Chrome, Edge, Brave, Arc and other Chromium browsers
</small>
</span>
<span className="install__arrow" aria-hidden>
&#8599;
</span>
</a>
</li>
<li>
<b>Capture this page.</b> Click the extension's icon while this tab is in front,
leave the viewport on <i>Browser</i>, and press <i>Capture Current Page</i>. It
reads every board below at the size it ships at, rather than the zoomed-out
thumbnail the canvas shows.
<b>Capture this page.</b> Click the extension's icon while this
tab is in front, leave the viewport on <i>Browser</i>, and press{" "}
<i>Capture Current Page</i>. It reads every board below at the
size it ships at, rather than the zoomed-out thumbnail the canvas
shows.
</li>
<li>
<b>Paste it into Figma.</b> Pick <i>Copy to clipboard</i> and press ⌘V in a Figma
file; that route needs no plugin at all. The plugin is for the other two routes —
sending the capture straight over, or opening a saved <code>.h2d</code> file.
Either way the boards arrive as editable layers, not as images.
<b>Paste it into Figma.</b> Pick <i>Copy to clipboard</i> and
press ⌘V in a Figma file; that route needs no plugin at all. The
plugin is for the other two routes — sending the capture straight
over, or opening a saved <code>.h2d</code> file. Either way the
boards arrive as editable layers, not as images.
<a
className="install install--plugin"
href={H2D_PLUGIN}
Expand Down
Loading
Loading