Skip to content
31 changes: 31 additions & 0 deletions apps/builder/app/routes/_ui.$.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { loader } from "./_ui.$";

describe("_ui.$ loader", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("returns 404 for missing apple touch icons without cross-origin logging", async () => {
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const request = new Request(
"https://p-project.apps.webstudio.is/apple-touch-icon-precomposed.png",
{
headers: {
accept: "*/*",
},
}
);

await expect(
loader({
request,
params: {},
context: {},
})
).rejects.toMatchObject({
status: 404,
});
expect(consoleInfo).not.toHaveBeenCalled();
});
});
8 changes: 4 additions & 4 deletions apps/builder/app/routes/_ui.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
export { ErrorBoundary } from "~/shared/error/error-boundary";

export const loader = async ({ request }: LoaderFunctionArgs) => {
preventCrossOriginCookie(request);

// No data to protect with CSRF token

const url = new URL(request.url);

// Redirecting asset files (e.g., .js, .css) to the dashboard should be avoided.
Expand All @@ -28,6 +24,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
});
}

preventCrossOriginCookie(request);

// No data to protect with CSRF token

const contentType = request.headers.get("Content-Type");

if (contentType?.includes("application/json")) {
Expand Down
10 changes: 10 additions & 0 deletions apps/builder/app/routes/rest.assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, test } from "vitest";
import { MaxAssetsPerProjectError } from "@webstudio-is/asset-uploader/index.server";

describe("MaxAssetsPerProjectError", () => {
test("can be classified by type", () => {
expect(new MaxAssetsPerProjectError(100)).toBeInstanceOf(
MaxAssetsPerProjectError
);
});
});
11 changes: 9 additions & 2 deletions apps/builder/app/routes/rest.assets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Asset } from "@webstudio-is/sdk";
import {
loadAssetsByProject,
createUploadName,
MaxAssetsPerProjectError,
} from "@webstudio-is/asset-uploader/index.server";
import { createContext } from "~/shared/context.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
Expand Down Expand Up @@ -60,10 +61,16 @@ export const action = async (props: ActionFunctionArgs) => {
};
}
} catch (error) {
console.error(error);
const parsedError = parseError(error);

if (error instanceof MaxAssetsPerProjectError) {
console.info(error);
} else {
console.error(error);
}

return {
errors: parseError(error).message,
errors: parsedError.message,
};
}
};
30 changes: 30 additions & 0 deletions apps/builder/app/services/no-cross-origin-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { preventCrossOriginCookie } from "./no-cross-origin-cookie";

describe("preventCrossOriginCookie", () => {
afterEach(() => {
vi.restoreAllMocks();
});

test("logs blocked cross-origin requests as info", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const request = new Request("https://apps.webstudio.is/rest/data", {
method: "POST",
headers: {
cookie: "session=1",
},
});

expect(() => preventCrossOriginCookie(request)).toThrow();

expect(consoleError).not.toHaveBeenCalled();
expect(consoleInfo).toHaveBeenCalledWith(
"Blocked cross-origin request to https://apps.webstudio.is/rest/data",
[]
);
expect(request.headers.has("cookie")).toBe(false);
});
});
2 changes: 1 addition & 1 deletion apps/builder/app/services/no-cross-origin-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const preventCrossOriginCookie = (
}

if (throwError) {
console.error(`Cross-origin request to ${request.url} blocked`, [
console.info(`Blocked cross-origin request to ${request.url}`, [
...request.headers.entries(),
]);

Expand Down
95 changes: 93 additions & 2 deletions apps/builder/app/shared/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import type { Page, Pages } from "@webstudio-is/sdk";
import { $pages } from "~/shared/sync/data-stores";
import { registerContainers } from "~/shared/sync/sync-stores";
import { updateCurrentSystem } from "./system";
import {
$currentSystem,
$systemDataByPage,
updateCurrentSystem,
} from "./system";
import { selectPage } from "./nano-states";

registerContainers();

beforeEach(() => {
$systemDataByPage.set(new Map());
});

const getInitialPages = (page: Page): Pages => ({
homePageId: "homeId",
rootFolderId: "rootId",
Expand Down Expand Up @@ -90,3 +98,86 @@ describe("history", () => {
]);
});
});

test("system pathname includes parent folder slug", () => {
$pages.set({
...getInitialPages({
id: "dynamicId",
path: "/post/:slug",
name: "",
title: "",
meta: {},
rootInstanceId: "",
}),
folders: new Map([
[
"rootId",
{
id: "rootId",
name: "",
slug: "",
children: ["homeId", "folderId"],
},
],
[
"folderId",
{
id: "folderId",
name: "Blog",
slug: "blog",
children: ["dynamicId"],
},
],
]),
});
selectPage("dynamicId");

updateCurrentSystem({
params: { slug: "my-post" },
});

expect($currentSystem.get().pathname).toBe("/blog/post/my-post");
expect($pages.get()?.pages.get("dynamicId")?.history).toEqual([
"/blog/post/my-post",
]);
});

test("system params support legacy history without parent folder slug", () => {
$pages.set({
...getInitialPages({
id: "dynamicId",
path: "/post/:slug",
name: "",
title: "",
meta: {},
rootInstanceId: "",
history: ["/post/my-post"],
}),
folders: new Map([
[
"rootId",
{
id: "rootId",
name: "",
slug: "",
children: ["homeId", "folderId"],
},
],
[
"folderId",
{
id: "folderId",
name: "Blog",
slug: "blog",
children: ["dynamicId"],
},
],
]),
});
selectPage("dynamicId");

expect($currentSystem.get()).toMatchObject({
params: { slug: "my-post" },
pathname: "/blog/post/my-post",
});
});
42 changes: 32 additions & 10 deletions apps/builder/app/shared/system.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { atom, computed } from "nanostores";
import { findPageByIdOrPath, type Page, type System } from "@webstudio-is/sdk";
import {
findPageByIdOrPath,
getPagePath,
type Page,
type System,
} from "@webstudio-is/sdk";
import {
compilePathnamePattern,
matchPathnamePattern,
Expand All @@ -14,13 +19,22 @@ export const $systemDataByPage = atom(
new Map<Page["id"], Pick<System, "search" | "params">>()
);

const extractParams = (pattern: string, path?: string) => {
const extractParams = (
pattern: string,
path?: string,
fallbackPattern?: string
) => {
const params: System["params"] = {};
const tokens = tokenizePathnamePattern(pattern);
// try to match the first item in history to let user
// see the page without manually entering params
// or selecting them in address bar
const matchedParams = path ? matchPathnamePattern(pattern, path) : undefined;
const matchedParams = path
? (matchPathnamePattern(pattern, path) ??
(fallbackPattern
? matchPathnamePattern(fallbackPattern, path)
: undefined))
: undefined;
for (const token of tokens) {
if (token.type === "param") {
params[token.name] = matchedParams?.[token.name] ?? undefined;
Expand All @@ -30,21 +44,26 @@ const extractParams = (pattern: string, path?: string) => {
};

export const $currentSystem = computed(
[$publishedOrigin, $selectedPage, $systemDataByPage],
(origin, page, systemByPage) => {
[$publishedOrigin, $selectedPage, $pages, $systemDataByPage],
(origin, page, pages, systemByPage) => {
const system: System = {
search: {},
params: {},
pathname: "/",
origin,
};
if (page === undefined) {
if (page === undefined || pages === undefined) {
return system;
}
const systemData = systemByPage.get(page.id);
const extractedParams = extractParams(page.path, page.history?.[0]);
const pagePath = getPagePath(page.id, pages);
const extractedParams = extractParams(
pagePath,
page.history?.[0],
page.path
);
const params = { ...extractedParams, ...systemData?.params };
const pathname = compilePath(page.path, params) || "/";
const pathname = compilePath(pagePath, params) || "/";
return {
search: { ...system.search, ...systemData?.search },
params,
Expand Down Expand Up @@ -72,8 +91,9 @@ const savePathInHistory = (pageId: string, path: string) => {
if (page === undefined) {
return;
}
const pagePath = getPagePath(page.id, pages);
const history = Array.from(page.history ?? []);
history.unshift(path);
history.unshift(path || pagePath);
page.history = Array.from(new Set(history)).slice(0, 20);
});
};
Expand All @@ -91,5 +111,7 @@ export const updateCurrentSystem = (
const params = update.params ?? systemData?.params ?? {};
systemDataByPage.set(page.id, { search, params });
$systemDataByPage.set(systemDataByPage);
savePathInHistory(page.id, compilePath(page.path, params));
const pages = $pages.get();
const pagePath = pages ? getPagePath(page.id, pages) : page.path;
savePathInHistory(page.id, compilePath(pagePath, params));
};
1 change: 1 addition & 0 deletions apps/builder/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default defineConfig(({ mode }) => {
plugins: [
remix({
presets: [vercelPreset()],
ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"],
future: {
v3_lazyRouteDiscovery: false,
v3_relativeSplatPath: false,
Expand Down
6 changes: 0 additions & 6 deletions packages/asset-uploader/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,3 @@ export const MaxSize: z.ZodEffects<
.default(MAX_UPLOAD_SIZE)
// user inputs the max value in mb and we transform it to bytes
.transform(toBytes);

export const MaxAssets: z.ZodEffects<
z.ZodDefault<z.ZodString>,
number,
string | undefined
> = z.string().default("50").transform(Number.parseFloat);
13 changes: 10 additions & 3 deletions packages/asset-uploader/src/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ type UploadData = {
filename: string;
};

export class MaxAssetsPerProjectError extends Error {
constructor(maxAssetsPerProject: number) {
super(
`The maximum number of assets per project is ${maxAssetsPerProject}.`
);
this.name = "MaxAssetsPerProjectError";
}
}

const UPLOADING_STALE_TIMEOUT = 1000 * 60 * 30; // 30 minutes

export const createUploadName = async (
Expand Down Expand Up @@ -70,9 +79,7 @@ export const createUploadName = async (
* it's probable that the user can exceed the limit a little bit.
* So it can be a little bit strange that the limit is 5 but the user already has 7.
**/
throw new Error(
`The maximum number of assets per project is ${maxAssetsPerProject}.`
);
throw new MaxAssetsPerProjectError(maxAssetsPerProject);
}

/**
Expand Down
Loading
Loading