diff --git a/.gitmodules b/.gitmodules index 6251a291e9..76cab76f46 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "packages/sdk-components-animation/private-src"] path = packages/sdk-components-animation/private-src - url = https://github.com/webstudio-is/sdk-components-animation.git + url = git@github.com:webstudio-is/sdk-components-animation.git branch = main diff --git a/apps/builder/app/dashboard/search/search-results.test.ts b/apps/builder/app/dashboard/search/search-results.test.ts new file mode 100644 index 0000000000..1452c5ddce --- /dev/null +++ b/apps/builder/app/dashboard/search/search-results.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import type { DashboardProject } from "@webstudio-is/dashboard"; +import { searchProjects } from "./search-results"; + +const createProject = ( + overrides: Partial +): DashboardProject => + ({ + id: "project-1", + createdAt: "2024-01-01T00:00:00.000Z", + title: "Default Site", + domain: "default", + userId: "user-1", + isDeleted: false, + isPublished: false, + latestBuild: null, + previewImageAsset: null, + previewImageAssetId: null, + latestBuildVirtual: null, + marketplaceApprovalStatus: "UNLISTED", + tags: [], + domainsVirtual: [], + workspaceId: null, + ...overrides, + }) as DashboardProject; + +describe("searchProjects", () => { + test.each([ + ["id", "83e97c09dcce", { id: "d845c167-ea07-4875-b08d-83e97c09dcce" }], + ["title", "Marketing", { title: "Marketing Site" }], + ["domain", "docs", { domain: "docs" }], + [ + "custom domain", + "client.example.com", + { + domainsVirtual: [ + { domain: "client.example.com", status: "ACTIVE", verified: true }, + ], + }, + ], + [ + "latest build id", + "aca9e9574587", + { + latestBuildVirtual: { + buildId: "f565d527-32e7-4731-bc71-aca9e9574587", + projectId: "project-1", + domainsVirtualId: "", + domain: "fixture", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + publishStatus: "PUBLISHED", + }, + }, + ], + ] satisfies Array<[string, string, Partial]>)( + "matches projects by %s", + (_field, search, overrides) => { + const projects = [ + createProject(overrides), + createProject({ id: "project-other", title: "Other Project" }), + ]; + + expect(searchProjects(projects, search)).toEqual([projects[0]]); + } + ); + + test("does not return projects without matching fields", () => { + const projects = [ + createProject({ title: "Marketing Site" }), + createProject({ title: "Other Project" }), + ]; + + expect(searchProjects(projects, "missing")).toEqual([]); + }); +}); diff --git a/apps/builder/app/dashboard/search/search-results.tsx b/apps/builder/app/dashboard/search/search-results.tsx index 11315e61b6..3ac25505dc 100644 --- a/apps/builder/app/dashboard/search/search-results.tsx +++ b/apps/builder/app/dashboard/search/search-results.tsx @@ -16,6 +16,22 @@ const initialSearchResults: SearchResults = { projects: [], } as const; +const searchProjectKeys = [ + "id", + "title", + "domain", + (project: DashboardProject) => + project.domainsVirtual.map(({ domain }) => domain), + (project: DashboardProject) => project.latestBuildVirtual?.buildId ?? "", +]; + +export const searchProjects = ( + projects: Array, + search: string +) => { + return matchSorter(projects, search, { keys: searchProjectKeys }); +}; + export const SearchResults = (props: DashboardData) => { const [searchParams] = useSearchParams(); const { projects, publisherHost } = props; @@ -25,9 +41,8 @@ export const SearchResults = (props: DashboardData) => { if (!search || !projects) { return initialSearchResults; } - const keys = ["title", "domain"]; return { - projects: matchSorter(projects, search, { keys }), + projects: searchProjects(projects, search), }; }, [projects, search]); diff --git a/apps/builder/app/routes/trpc.$.ts b/apps/builder/app/routes/trpc.$.ts index 45647884f8..0fb8ab7f4c 100644 --- a/apps/builder/app/routes/trpc.$.ts +++ b/apps/builder/app/routes/trpc.$.ts @@ -5,6 +5,7 @@ import { appRouter } from "~/services/trcp-router.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; import { getTrpcResponseMeta } from "~/services/trpc-response-meta.server"; +import { isCliApiRequest } from "~/services/trpc-request.server"; const isServiceRequest = (request: Request) => { return isServiceAuthorization(request.headers.get("Authorization")); @@ -15,7 +16,10 @@ const isAuthTokenRequest = (request: Request) => { }; export const action = async ({ request }: ActionFunctionArgs) => { - if (isServiceRequest(request) === false) { + if ( + isServiceRequest(request) === false && + isCliApiRequest(request) === false + ) { preventCrossOriginCookie(request); if (isAuthTokenRequest(request) === false) { await checkCsrf(request); diff --git a/apps/builder/app/services/trpc-request.server.test.ts b/apps/builder/app/services/trpc-request.server.test.ts new file mode 100644 index 0000000000..886bcd2a75 --- /dev/null +++ b/apps/builder/app/services/trpc-request.server.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility"; +import { isCliApiRequest } from "./trpc-request.server"; + +describe("isCliApiRequest", () => { + test("accepts CLI API requests", () => { + const request = new Request("https://webstudio.is/trpc", { + headers: { + [apiClientHeader]: "cli", + }, + }); + + expect(isCliApiRequest(request)).toBe(true); + }); + + test("rejects browser and unknown API requests", () => { + const browserRequest = new Request("https://webstudio.is/trpc", { + headers: { + [apiClientHeader]: "browser", + }, + }); + const unknownRequest = new Request("https://webstudio.is/trpc"); + + expect(isCliApiRequest(browserRequest)).toBe(false); + expect(isCliApiRequest(unknownRequest)).toBe(false); + }); +}); diff --git a/apps/builder/app/services/trpc-request.server.ts b/apps/builder/app/services/trpc-request.server.ts new file mode 100644 index 0000000000..c3bf36cfbd --- /dev/null +++ b/apps/builder/app/services/trpc-request.server.ts @@ -0,0 +1,5 @@ +import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility"; + +export const isCliApiRequest = (request: Pick) => { + return request.headers.get(apiClientHeader) === "cli"; +}; diff --git a/packages/cli/src/commands/sync.test.ts b/packages/cli/src/commands/sync.test.ts index cd453b7887..47c2cf228a 100644 --- a/packages/cli/src/commands/sync.test.ts +++ b/packages/cli/src/commands/sync.test.ts @@ -10,6 +10,7 @@ import { import { createFileIfNotExists, isFileExists } from "../fs-utils"; import { resolveApiConnection } from "../api-connection"; import { sync } from "./sync"; +import { apiCompatibilityHeaders } from "./api"; const originalCwd = process.cwd(); let tempDir: string; @@ -138,6 +139,31 @@ test("downloads project bundle asset files into local project bundle", async () expect(indicator.message).toHaveBeenCalledWith("Downloading 1 asset files"); }); +test("sends linked share token when synchronizing by build id", async () => { + const resolveApiConnection = vi.fn(async () => ({ + authToken: "share-token", + origin: "https://example.com", + projectId: "project-id", + })); + + await sync( + { + buildId: "build-1", + }, + { + ...dependencies, + resolveApiConnection, + } + ); + + expect(loadProjectBundleByBuildId).toHaveBeenCalledWith({ + buildId: "build-1", + authToken: "share-token", + origin: "https://example.com", + headers: apiCompatibilityHeaders, + }); +}); + test("does not write local data when synchronized asset download fails", async () => { const assets = [createImageAssetFixture()]; loadProjectBundleByBuildId.mockResolvedValue(createProjectBundle({ assets })); diff --git a/packages/http-client/src/index.test.ts b/packages/http-client/src/index.test.ts index cae1f309f6..b939af7057 100644 --- a/packages/http-client/src/index.test.ts +++ b/packages/http-client/src/index.test.ts @@ -70,6 +70,7 @@ import { listResources, listTexts, listVariables, + loadProjectBundleByBuildId, loadProjectBundleByProjectId, moveInstance, publish, @@ -1012,6 +1013,35 @@ test("uploads assets as binary requests", async () => { ); }); +test("loads project bundle by build id without auth headers", async () => { + const project = createPublishedProjectBundleFixture(); + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify([{ result: { data: project } }]), { + headers: { + "content-type": "application/json", + }, + }) + ); + vi.stubGlobal("fetch", fetch); + + await loadProjectBundleByBuildId({ + buildId: project.build.id, + origin: "https://p-090e6e14-ae50-4b2e-bd22-71733cec05bb.apps.webstudio.is", + }); + + expect(fetch).toHaveBeenCalledOnce(); + const [_url, init] = fetch.mock.calls[0] as [URL, RequestInit]; + expect(init.headers).toMatchObject({ + "content-type": "application/json", + }); + expect((init.headers as Record)["x-auth-token"]).toBe( + undefined + ); + expect((init.headers as Record).authorization).toBe( + undefined + ); +}); + test("reports asset upload errors", async () => { vi.stubGlobal( "fetch", diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts index ae76878ca7..eb27ecc9b0 100644 --- a/packages/http-client/src/index.ts +++ b/packages/http-client/src/index.ts @@ -571,12 +571,15 @@ export const loadProjectBundleByBuildId = async ( serviceToken: string; } | { authToken: string } + | { authToken?: undefined; serviceToken?: undefined } ) ): Promise => { const headers: RequestHeaders = "serviceToken" in params ? { Authorization: params.serviceToken } - : { "x-auth-token": params.authToken }; + : "authToken" in params + ? { "x-auth-token": params.authToken } + : {}; const data = await createTrpcClient(params.origin, { ...params.headers, diff --git a/packages/sdk-components-animation/private-src b/packages/sdk-components-animation/private-src index 60076fa043..54f9145cb2 160000 --- a/packages/sdk-components-animation/private-src +++ b/packages/sdk-components-animation/private-src @@ -1 +1 @@ -Subproject commit 60076fa0430cb32866ca2bc0f16bae46fcae3262 +Subproject commit 54f9145cb2c761f868fba4324e5031416d706bec