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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed

- Fixed saving from the Figma plugin, which did nothing and logged `SecurityError: Failed to read the 'localStorage' property from 'Window'`. The editor runs inside Figma's sandboxed `about:srcdoc` frame, where reading `localStorage` is denied, and the save's rate limiter read it on every push and threw before the save could run. Storage access now falls back to an in-memory store when the browser blocks it, so saving from the Figma Desktop app works again.
- Fixed connecting a WordPress project from the Figma plugin, which failed with "Invalid WordPress connection key" or "Failed to import project" on sites left on the default "Plain" permalink setting. The plugin only called the `/wp-json/` REST path, which WordPress does not route until pretty permalinks are enabled, so the request was redirected away before it reached the plugin. It now falls back to the permalink-independent `?rest_route=` form, and a failed connection now reports whether the site was unreachable, rejected the key, or returned an error instead of one generic message.
- Fixed the Oxygen Classic builder hanging on its loading screen when the active preset contained custom fonts. The font list was written into Oxygen's `ng-init` attribute without HTML-escaping, so the first quote closed the attribute and left AngularJS with a truncated expression that never finished loading the builder. The value is now escaped as Oxygen's own core does.
- Stopped disabled fonts from reaching the Oxygen and Bricks builders. The Oxygen font dropdown and the styles injected into both builders now cover only the fonts you have enabled, matching what the editor previews.

Expand Down
70 changes: 39 additions & 31 deletions packages/figma/main/code.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
import { SimpleVariable } from "../src/types";
import { parseHttpUrl, parseWordPressConnectionKey } from "./wordpressConnection";
import {
ALLOWED_REST_ROUTES,
PRESET_REST_ROUTE,
extractRestRoute,
fetchWordPressRest,
parseHttpUrl,
parseWordPressConnectionKey,
} from "./wordpressConnection";

const PRESET_API_KEY_STORAGE_KEY = "cf_plugin_project_api_key";
const PRESET_LOCAL_STORAGE_KEY = "cf_plugin_project_local";

async function fetchPreset(connectionKey: string) {
interface PresetResponse {
success?: boolean;
data?: unknown;
}

async function fetchPreset(connectionKey: string): Promise<PresetResponse> {
const connection = parseWordPressConnectionKey(connectionKey);
if (!connection) throw new Error("Invalid WordPress connection key");

const endpoint = `${connection.siteUrl}/wp-json/core-framework/v2/preset`;
const response = await fetch(endpoint, {
const result = await fetchWordPressRest(connection.siteUrl, PRESET_REST_ROUTE, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Core-Framework-Key": connectionKey,
},
connectionKey,
});

if (response?.status !== 200) {
throw new Error("Failed to fetch preset");
if (!result.reachable) {
throw new Error(
`Could not reach ${connection.siteUrl}. Check the site is online and the Core Framework plugin is active.`,
);
}

return await response.json();
}
if (result.status === 401 || result.status === 403) {
throw new Error(
"WordPress rejected the connection key. Open the Core Framework plugin on your site, generate a new key, and paste it again.",
);
}

const ALLOWED_WORDPRESS_PATHS = new Set([
"/wp-json/core-framework/v2/preset",
"/wp-json/core-framework/v2/preset-css",
"/wp-json/core-framework/v2/figma/update-colors",
"/wp-json/core-framework/v2/figma/update-classes",
"/wp-json/core-framework/v2/figma/update-grouped-classes",
"/wp-json/core-framework/v2/figma/update-prefixed-css-file",
"/wp-json/core-framework/v2/figma/save-oxygen-css-helper",
]);
if (!result.ok) {
throw new Error(`WordPress returned ${result.status} for the preset request.`);
}

return (result.data ?? {}) as PresetResponse;
}

async function getStoredConnectionKey() {
const storedKey = await figma.clientStorage.getAsync(PRESET_API_KEY_STORAGE_KEY);
Expand Down Expand Up @@ -65,33 +75,31 @@ async function handleWordPressRequest(msg: {
try {
const connection = await getWordPressConnection();
const target = parseHttpUrl(msg.url);
const route = extractRestRoute(msg.url);

if (
!connection ||
!target ||
target.origin !== connection.siteUrl ||
!ALLOWED_WORDPRESS_PATHS.has(target.pathname) ||
!route ||
!ALLOWED_REST_ROUTES.has(route) ||
!["GET", "POST", "PUT"].includes(msg.method)
) {
throw new Error("Blocked WordPress request");
}

const response = await fetch(target.href, {
const result = await fetchWordPressRest(connection.siteUrl, route, {
method: msg.method,
headers: {
"Content-Type": "application/json",
"X-Core-Framework-Key": connection.connectionKey,
},
connectionKey: connection.connectionKey,
body: msg.body,
});
const data = await response.json();

figma.ui.postMessage({
type: "wordpress-response",
requestId: msg.requestId,
ok: response.ok,
status: response.status,
data,
ok: result.ok,
status: result.status,
data: result.data,
});
} catch (error) {
figma.ui.postMessage({
Expand Down
133 changes: 133 additions & 0 deletions packages/figma/main/wordpressConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,136 @@ export function parseWordPressConnectionKey(rawConnectionKey: string): WordPress
return null;
}
}

const WP_JSON_PREFIX = "/wp-json";

// The REST route the "Connect a WordPress project" flow reads the preset from.
export const PRESET_REST_ROUTE = "/core-framework/v2/preset";

// Namespace-relative REST routes the plugin is allowed to reach on the connected
// site. Kept form-independent (no /wp-json prefix) so a request is validated the
// same way whether it arrives as /wp-json/... or as ?rest_route=/...
export const ALLOWED_REST_ROUTES: ReadonlySet<string> = new Set([
PRESET_REST_ROUTE,
"/core-framework/v2/preset-css",
"/core-framework/v2/figma/update-colors",
"/core-framework/v2/figma/update-classes",
"/core-framework/v2/figma/update-grouped-classes",
"/core-framework/v2/figma/update-prefixed-css-file",
"/core-framework/v2/figma/save-oxygen-css-helper",
]);

// Normalize a request URL to its namespace-relative REST route, accepting both
// the pretty-permalink form (/wp-json/<route>) and the plain-permalink form
// (/?rest_route=/<route>). Returns null when no REST route is present.
export function extractRestRoute(rawUrl: string): string | null {
const parsed = parseHttpUrl(rawUrl);
if (!parsed) return null;

if (parsed.pathname === WP_JSON_PREFIX || parsed.pathname.startsWith(`${WP_JSON_PREFIX}/`)) {
const route = parsed.pathname.slice(WP_JSON_PREFIX.length);
return route.startsWith("/") ? route : null;
}

const restRouteMatch = parsed.href.match(/[?&]rest_route=([^&#]+)/);
if (restRouteMatch) {
try {
const route = decodeURIComponent(restRouteMatch[1]);
return route.startsWith("/") ? route : `/${route}`;
} catch {
return null;
}
}

return null;
}

// Both URL forms WordPress can serve a REST route from. Sites using the default
// "Plain" permalink setting do not route /wp-json/ (it 301-redirects away), but
// ?rest_route= works on every permalink setting, so we try the pretty form first
// and fall back to it.
export function buildRestRequestUrls(siteUrl: string, route: string): string[] {
const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
return [`${siteUrl}${WP_JSON_PREFIX}${normalizedRoute}`, `${siteUrl}/?rest_route=${normalizedRoute}`];
}

export interface WordPressRestResult {
ok: boolean;
status: number;
data: unknown;
reachable: boolean;
}

export interface WordPressRestRequest {
method: "GET" | "POST" | "PUT";
connectionKey: string;
body?: string;
}

type FetchLike = (input: string, init: RequestInit) => Promise<Response>;

// Reach a REST route on the connected site, trying both permalink forms. Only a
// redirect (or a 404, or a 200 that is not JSON) triggers the fallback to the
// next form; a real answer from the route (2xx JSON, 401, 403, 5xx) is returned
// as-is so genuine key rejections are not masked. `reachable` is false only when
// no form could be contacted at all.
export async function fetchWordPressRest(
siteUrl: string,
route: string,
request: WordPressRestRequest,
fetchImpl: FetchLike = (input, init) => fetch(input, init),
): Promise<WordPressRestResult> {
const urls = buildRestRequestUrls(siteUrl, route);
let reachable = false;
let lastResult: WordPressRestResult | null = null;

for (const url of urls) {
let response: Response;
try {
response = await fetchImpl(url, {
method: request.method,
headers: {
"Content-Type": "application/json",
"X-Core-Framework-Key": request.connectionKey,
},
body: request.body,
// Do not chase a redirect into an ambiguous page: on "plain" permalinks
// /wp-json/ 301-redirects away from the route, and we want the
// ?rest_route= form instead of whatever that redirect lands on.
redirect: "manual",
});
} catch {
// Could not contact this form; try the next one.
continue;
}

reachable = true;
const status = response.status;
const isRedirect =
response.type === "opaqueredirect" || status === 0 || (status >= 300 && status < 400);

if (isRedirect || status === 404) {
lastResult = { ok: false, status: status || 404, data: null, reachable: true };
continue;
}

let data: unknown = null;
let parsedJson = true;
try {
data = await response.json();
} catch {
parsedJson = false;
}

// A 200 that is not JSON means a followed redirect landed on an HTML page
// (a runtime that ignores redirect: "manual"); try the other form.
if (response.ok && !parsedJson) {
lastResult = { ok: false, status, data: null, reachable: true };
continue;
}

return { ok: response.ok, status, data, reachable: true };
}

return lastResult ?? { ok: false, status: 0, data: null, reachable };
}
131 changes: 130 additions & 1 deletion packages/figma/tests/wordpressConnection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import { describe, expect, test } from "bun:test";
import { parseHttpUrl, parseWordPressConnectionKey } from "../main/wordpressConnection";
import {
ALLOWED_REST_ROUTES,
PRESET_REST_ROUTE,
buildRestRequestUrls,
extractRestRoute,
fetchWordPressRest,
parseHttpUrl,
parseWordPressConnectionKey,
} from "../main/wordpressConnection";

// A fetch stand-in that maps exact URLs to canned responses. Any URL not mapped
// throws, simulating a connection failure.
function stubFetch(routes: Record<string, { status: number; type?: string; body?: unknown; nonJson?: boolean }>) {
const calls: string[] = [];
const fetchImpl = async (input: string) => {
calls.push(input);
const canned = routes[input];
if (!canned) throw new TypeError("Failed to fetch");
return {
status: canned.status,
ok: canned.status >= 200 && canned.status < 300,
type: canned.type ?? "default",
json: async () => {
if (canned.nonJson) throw new SyntaxError("Unexpected token < in JSON");
return canned.body ?? null;
},
};
};
return { fetchImpl: fetchImpl as unknown as typeof fetch, calls };
}

const SITE = "https://biroty.s6-tastewp.com";
const PRETTY = `${SITE}/wp-json${PRESET_REST_ROUTE}`;
const REST_ROUTE = `${SITE}/?rest_route=${PRESET_REST_ROUTE}`;
const REQUEST = { method: "GET", connectionKey: "k".repeat(24) } as const;

describe("parseWordPressConnectionKey", () => {
test("parses the 79-character WordPress connection key format used by 2.0", () => {
Expand Down Expand Up @@ -45,3 +79,98 @@ describe("parseHttpUrl", () => {
});
});
});

describe("extractRestRoute", () => {
test("normalizes the pretty-permalink form to the namespace route", () => {
expect(extractRestRoute(`${SITE}/wp-json/core-framework/v2/preset`)).toBe("/core-framework/v2/preset");
});

test("normalizes the plain-permalink ?rest_route= form to the same route", () => {
expect(extractRestRoute(`${SITE}/?rest_route=/core-framework/v2/figma/update-colors`)).toBe(
"/core-framework/v2/figma/update-colors",
);
});

test("returns null when there is no REST route in the URL", () => {
expect(extractRestRoute(`${SITE}/some/other/path`)).toBeNull();
expect(extractRestRoute("not a url")).toBeNull();
});
});

describe("buildRestRequestUrls", () => {
test("offers the pretty form first, then the permalink-independent form", () => {
expect(buildRestRequestUrls(SITE, PRESET_REST_ROUTE)).toEqual([PRETTY, REST_ROUTE]);
});

test("every allowed route round-trips through extractRestRoute for both forms", () => {
for (const route of ALLOWED_REST_ROUTES) {
const [pretty, restRoute] = buildRestRequestUrls(SITE, route);
expect(extractRestRoute(pretty)).toBe(route);
expect(extractRestRoute(restRoute)).toBe(route);
}
});
});

describe("fetchWordPressRest permalink fallback (issue #12/#18)", () => {
test("falls back to ?rest_route= when /wp-json/ 301-redirects (plain permalinks)", async () => {
const { fetchImpl, calls } = stubFetch({
[PRETTY]: { status: 301 },
[REST_ROUTE]: { status: 200, body: { success: true, data: { ok: 1 } } },
});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);

expect(result).toEqual({ ok: true, status: 200, data: { success: true, data: { ok: 1 } }, reachable: true });
expect(calls).toEqual([PRETTY, REST_ROUTE]);
});

test("also falls back on an opaque redirect (runtime honours redirect: manual)", async () => {
const { fetchImpl } = stubFetch({
[PRETTY]: { status: 0, type: "opaqueredirect" },
[REST_ROUTE]: { status: 200, body: { success: true } },
});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);
expect(result.ok).toBe(true);
expect(result.status).toBe(200);
});

test("also falls back when a followed redirect returns 200 HTML instead of JSON", async () => {
const { fetchImpl } = stubFetch({
[PRETTY]: { status: 200, nonJson: true },
[REST_ROUTE]: { status: 200, body: { success: true } },
});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);
expect(result.ok).toBe(true);
});

test("uses /wp-json/ directly and does not probe ?rest_route= when pretty permalinks work", async () => {
const { fetchImpl, calls } = stubFetch({
[PRETTY]: { status: 200, body: { success: true } },
[REST_ROUTE]: { status: 500 },
});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);
expect(result.ok).toBe(true);
expect(calls).toEqual([PRETTY]);
});

test("does not mask a genuine key rejection (401) as a permalink problem", async () => {
const { fetchImpl } = stubFetch({
[PRETTY]: { status: 301 },
[REST_ROUTE]: { status: 401, body: { code: "rest_forbidden" } },
});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);
expect(result.ok).toBe(false);
expect(result.status).toBe(401);
});

test("reports the site as unreachable when no form can be contacted", async () => {
const { fetchImpl } = stubFetch({});

const result = await fetchWordPressRest(SITE, PRESET_REST_ROUTE, REQUEST, fetchImpl);
expect(result).toEqual({ ok: false, status: 0, data: null, reachable: false });
});
});
Loading