Skip to content
Closed
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
27 changes: 22 additions & 5 deletions .github/actions/vercel/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ inputs:
description: "Sha"
required: true
environment:
description: "Sha"
description: "Alias environment"
required: true
production-staged:
description: "Create a production deployment without assigning production domains"
required: false
default: "false"
PUBLIC_COLLAB_RELAY_URL:
description: "Public collaboration relay URL injected into the builder bundle"
required: false
Expand Down Expand Up @@ -79,9 +83,14 @@ runs:
run: |
export GITHUB_SHA=${{ inputs.sha }}
export GITHUB_REF_NAME=${{ inputs.ref-name }}
export PUBLIC_COLLAB_RELAY_URL=${{ inputs.PUBLIC_COLLAB_RELAY_URL }}

pnpx vercel build
if [ "${{ inputs.production-staged }}" = "true" ]; then
unset PUBLIC_COLLAB_RELAY_URL
pnpx vercel build --prod --token "${{ inputs.vercel-token }}"
else
export PUBLIC_COLLAB_RELAY_URL=${{ inputs.PUBLIC_COLLAB_RELAY_URL }}
pnpx vercel build --token "${{ inputs.vercel-token }}"
fi
shell: bash

- name: Patch
Expand All @@ -108,9 +117,17 @@ runs:
- name: Deploy
id: deploy
run: |
deploy_args=(
--prebuilt
--token "${{ inputs.vercel-token }}"
)

if [ "${{ inputs.production-staged }}" = "true" ]; then
deploy_args+=(--prod --skip-domain)
fi

pnpx vercel deploy \
--prebuilt \
--token ${{ inputs.vercel-token }} \
"${deploy_args[@]}" \
2> >(tee info.txt >&2) | tee domain.txt

echo "domain=$(cat ./domain.txt)" >> $GITHUB_OUTPUT
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/vercel-deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
ref-name: ${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
sha: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
environment: ${{ matrix.environment }}
production-staged: ${{ github.event_name == 'push' && matrix.environment == 'staging' && startsWith(github.ref_name, 'release-') && endsWith(github.ref_name, '.staging') }}
PUBLIC_COLLAB_RELAY_URL: ${{ vars.PUBLIC_COLLAB_RELAY_URL }}

- name: Debug Vercel Outputs
Expand Down
2 changes: 1 addition & 1 deletion apps/builder/app/builder/builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import type { TokenPermissions } from "@webstudio-is/authorization-token";
import { useToastErrors } from "~/shared/error/toast-error";
import { initBuilderApi } from "~/shared/builder-api";
import { updateWebstudioData } from "~/shared/instance-utils";
import { migrateWebstudioDataMutable } from "@webstudio-is/sdk/migrations/webstudio-data";
import { migrateWebstudioDataMutable } from "@webstudio-is/project-migrations";
import { Loading, LoadingBackground } from "./shared/loading";
import { mergeRefs } from "@react-aria/utils";
import { CommandPanel } from "./features/command-panel";
Expand Down
6 changes: 4 additions & 2 deletions apps/builder/app/builder/features/marketplace/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
getStyleDeclKey,
migratePages,
type Asset,
type SerializedPages,
type WebstudioData,
} from "@webstudio-is/sdk";
import {
migratePages,
type SerializedPages,
} from "@webstudio-is/project-migrations/pages";
import type { CompactBuild } from "@webstudio-is/project-build";

const getPair = <Item extends { id: string }>(item: Item) =>
Expand Down
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
38 changes: 38 additions & 0 deletions apps/builder/app/routes/rest.$.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
ApiCompatibilityTarget,
apiClientHeader,
createApiCompatibilityPayload,
} from "@webstudio-is/trpc-interface/api-compatibility";

const getTarget = (request: Request) => {
const client = ApiCompatibilityTarget.safeParse(
request.headers.get(apiClientHeader)
);
if (client.success) {
return client.data;
}

return "browser";
};

const createResponse = (request: Request) => {
const payload = createApiCompatibilityPayload({
reason: "apiRouteNotFound",
target: getTarget(request),
});

return new Response(JSON.stringify({ success: false, error: payload }), {
status: 426,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
};

export const loader = ({ request }: LoaderFunctionArgs) => {
return createResponse(request);
};

export const action = loader;
2 changes: 1 addition & 1 deletion apps/builder/app/routes/rest.data.$projectId.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import * as projectApi from "@webstudio-is/project/index.server";
import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server";
import { serializePages } from "@webstudio-is/sdk";
import { serializePages } from "@webstudio-is/project-migrations/pages";
import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server";
import { createContext } from "~/shared/context.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
Expand Down
5 changes: 4 additions & 1 deletion apps/builder/app/services/auth-strategy/ws.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const asyncLocalStorage = new AsyncLocalStorage<
>();

// remix-auth-oauth2 logs OAuth state, PKCE verifier, and full callback URLs.
createDebugRaw.enable(`${createDebugRaw.disable()},-OAuth2Strategy`);
// Match both the exact namespace and any prefixed namespace enabled by DEBUG=*.
createDebugRaw.enable(
`${createDebugRaw.disable()},-OAuth2Strategy,-*OAuth2Strategy*`
);

/**
* The main issue with OAuth2Strategy is that it forces us to define authorizationEndpoint, tokenEndpoint, and redirectURI
Expand Down
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
1 change: 1 addition & 0 deletions apps/builder/app/services/trpc.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const trpcSharedClient = createTrpcProxyServiceClient(
url: TRPC_SERVER_URL,
token: TRPC_SERVER_API_TOKEN,
branchName: GITHUB_REF_NAME,
clientVersion: staticEnv.GITHUB_SHA ?? "local",
}
: undefined
);
7 changes: 2 additions & 5 deletions apps/builder/app/shared/builder-data.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {
getStyleDeclKey,
migratePages,
type WebstudioData,
} from "@webstudio-is/sdk";
import { getStyleDeclKey, type WebstudioData } from "@webstudio-is/sdk";
import { migratePages } from "@webstudio-is/project-migrations/pages";
import type { MarketplaceProduct } from "@webstudio-is/project-build";
import type { Project } from "@webstudio-is/project";
import type { loader } from "~/routes/rest.data.$projectId";
Expand Down
23 changes: 23 additions & 0 deletions apps/builder/app/shared/context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { readLoginSessionBloomFilter } from "~/services/session.server";
import type { BloomFilter } from "~/services/bloom-filter.server";
import { isBuilder, isCanvas } from "./router-utils";
import { parseBuilderUrl } from "@webstudio-is/http-client";
import {
ApiClient,
apiClientHeader,
apiClientVersionHeader,
} from "@webstudio-is/trpc-interface/api-compatibility";

export const extractAuthFromRequest = async (request: Request) => {
if (isCanvas(request)) {
Expand Down Expand Up @@ -180,6 +185,21 @@ const createTrpcCache = () => {
};
};

const createApiClientContext = (request: Request): AppContext["apiClient"] => {
const client = ApiClient.safeParse(request.headers.get(apiClientHeader));
if (client.success === false) {
return {
type: "unknown",
version: undefined,
};
}

return {
type: client.data,
version: request.headers.get(apiClientVersionHeader) ?? undefined,
};
};

export const createPostgrestContext = () => {
return { client: createClient(env.POSTGREST_URL, env.POSTGREST_API_KEY) };
};
Expand Down Expand Up @@ -212,6 +232,7 @@ export const createContext = async (request: Request): Promise<AppContext> => {
const entri = createEntriContext();
const { planFeatures, purchases } = await resolvePlanInfo(authorization);
const trpcCache = createTrpcCache();
const apiClient = createApiClientContext(request);

const getOwnerPlanFeatures = async (userId: string) => {
const results = await getPlanInfo([userId], { postgrest });
Expand All @@ -233,6 +254,7 @@ export const createContext = async (request: Request): Promise<AppContext> => {
entri,
planFeatures,
purchases,
apiClient,
trpcCache,
postgrest,
createTokenContext,
Expand All @@ -247,6 +269,7 @@ export const createContext = async (request: Request): Promise<AppContext> => {
entri,
planFeatures,
purchases,
apiClient,
trpcCache,
postgrest,
createTokenContext,
Expand Down
2 changes: 1 addition & 1 deletion apps/builder/app/shared/db/canvas.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
findPageByIdOrPath,
getAllPages,
getStyleDeclKey,
serializePages,
} from "@webstudio-is/sdk";
import { serializePages } from "@webstudio-is/project-migrations/pages";
import * as projectApi from "@webstudio-is/project/index.server";
import { getUserById, type User } from "./user.server";

Expand Down
34 changes: 32 additions & 2 deletions apps/builder/app/shared/fetch.client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { toast } from "@webstudio-is/design-system";
import {
apiClientHeader,
apiClientVersionHeader,
getApiCompatibilityPayload,
} from "@webstudio-is/trpc-interface/api-compatibility";
import { csrfToken } from "./csrf.client";
import { $authToken } from "./nano-states";
import { publicStaticEnv } from "~/env/env.static";

/**
* To avoid fetch interception from the canvas, i.e., `globalThis.fetch = () => console.log('INTERCEPTED');`,
Expand All @@ -11,7 +17,10 @@ const _fetch = globalThis.fetch;
* To avoid fetch interception from the canvas, i.e., `globalThis.fetch = () => console.log('INTERCEPTED');`,
* To add csrf token to the headers.
*/
export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => {
export const fetch: typeof globalThis.fetch = async (
requestInfo,
requestInit
) => {
if (csrfToken === undefined) {
toast.error("CSRF token is not set.");
throw new Error("CSRF token is not set.");
Expand All @@ -20,6 +29,8 @@ export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => {
const headers = new Headers(requestInit?.headers);

headers.set("X-CSRF-Token", csrfToken);
headers.set(apiClientHeader, "browser");
headers.set(apiClientVersionHeader, publicStaticEnv.VERSION);

const authToken = $authToken.get();

Expand All @@ -34,5 +45,24 @@ export const fetch: typeof globalThis.fetch = (requestInfo, requestInit) => {
headers,
};

return _fetch(requestInfo, modifiedInit);
const response = await _fetch(requestInfo, modifiedInit);
if (response.ok === false) {
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
const body: unknown = await response
.clone()
.json()
.catch(() => undefined);
const payload = getApiCompatibilityPayload(body);
if (payload?.action.type === "reloadBrowser") {
toast.error(payload.message, {
id: "api-compatibility",
duration: Number.POSITIVE_INFINITY,
});
throw new Error(payload.message, { cause: payload });
}
}
}

return response;
};
2 changes: 1 addition & 1 deletion apps/builder/app/shared/marketplace/db.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
import type { Project } from "@webstudio-is/project";
import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server";
import { serializePages } from "@webstudio-is/sdk";
import { serializePages } from "@webstudio-is/project-migrations/pages";

export const getBuildProdData = async (
{ projectId }: { projectId: Project["id"] },
Expand Down
Loading
Loading