diff --git a/apps/builder/app/env/env.server.ts b/apps/builder/app/env/env.server.ts index 1599cae10f3a..62383abafe78 100644 --- a/apps/builder/app/env/env.server.ts +++ b/apps/builder/app/env/env.server.ts @@ -20,6 +20,8 @@ const envSchema = z.object({ // Trpc on SaaS TRPC_SERVER_URL: z.string().url().optional(), TRPC_SERVER_API_TOKEN: z.string().optional(), + PUBLISHER_ENDPOINT: z.string().url().optional(), + PUBLISHER_TOKEN: z.string().optional(), PORT: z .string() @@ -92,6 +94,8 @@ const rawEnv = { DEPLOYMENT_URL: process.env.DEPLOYMENT_URL, TRPC_SERVER_URL: process.env.TRPC_SERVER_URL, TRPC_SERVER_API_TOKEN: process.env.TRPC_SERVER_API_TOKEN, + PUBLISHER_ENDPOINT: process.env.PUBLISHER_ENDPOINT, + PUBLISHER_TOKEN: process.env.PUBLISHER_TOKEN, PORT: process.env.PORT, MAX_UPLOAD_SIZE: process.env.MAX_UPLOAD_SIZE, S3_ENDPOINT: process.env.S3_ENDPOINT, diff --git a/apps/builder/app/services/build-router.server.ts b/apps/builder/app/services/build-router.server.ts index c35a9c7befcb..ee9a0b004161 100644 --- a/apps/builder/app/services/build-router.server.ts +++ b/apps/builder/app/services/build-router.server.ts @@ -20,6 +20,7 @@ import { normalizePatchRequest } from "~/shared/sync/patch/patch-normalize.serve import * as projectApi from "@webstudio-is/project/index.server"; import type { BuildPatchTransaction } from "@webstudio-is/project/index.server"; import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server"; +import { loadBuildById } from "@webstudio-is/project-build/index.server"; import { serializePages } from "@webstudio-is/project-migrations/pages"; import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; import { @@ -54,6 +55,22 @@ const relayPatchInput = z.object({ ), }); +const PublishStatusInput = z.object({ + buildId: z.string(), + status: z.enum(["PUBLISHED", "FAILED"]), +}); + +const getAuthorizationToken = (authorizationHeader: string | null) => { + if (authorizationHeader === null) { + return; + } + const [type, token] = authorizationHeader.split(" "); + if (type === "Bearer" && token) { + return token; + } + return authorizationHeader; +}; + const loadBuildProjectId = async (ctx: AppContext, buildId: string) => { const build = await ctx.postgrest.client .from("Build") @@ -143,6 +160,38 @@ export const buildRouter = router({ return await loadPublishedProjectDataByProjectId(input.projectId, ctx); }), + publishStatus: procedure + .input(PublishStatusInput) + .mutation(async ({ ctx, input }) => { + const build = await loadBuildById(ctx, input.buildId); + const publisherToken = build.pages.compiler?.publisherToken; + const authorizationToken = getAuthorizationToken( + ctx.authorizationHeader ?? null + ); + + const isAuthorized = + ctx.authorization.type === "service" || + (publisherToken !== undefined && + publisherToken.length > 0 && + authorizationToken === publisherToken); + + if (isAuthorized === false) { + throw new AuthorizationError("Unauthorized"); + } + + const result = await ctx.postgrest.client + .from("Build") + .update({ publishStatus: input.status }) + .eq("id", input.buildId) + .eq("projectId", build.projectId); + + if (result.error) { + throw result.error; + } + + return { success: true }; + }), + createCollabToken: procedure .input( z.object({ diff --git a/apps/builder/app/services/trpc.server.ts b/apps/builder/app/services/trpc.server.ts index 37b198237601..22ace39e1f1d 100644 --- a/apps/builder/app/services/trpc.server.ts +++ b/apps/builder/app/services/trpc.server.ts @@ -2,15 +2,15 @@ import { createTrpcProxyServiceClient } from "@webstudio-is/trpc-interface/index import env from "~/env/env.server"; import { staticEnv } from "~/env/env.static.server"; -const TRPC_SERVER_URL = env.TRPC_SERVER_URL ?? ""; -const TRPC_SERVER_API_TOKEN = env.TRPC_SERVER_API_TOKEN ?? ""; +const PUBLISHER_ENDPOINT = env.PUBLISHER_ENDPOINT ?? env.TRPC_SERVER_URL ?? ""; +const PUBLISHER_TOKEN = env.PUBLISHER_TOKEN ?? env.TRPC_SERVER_API_TOKEN ?? ""; const GITHUB_REF_NAME = staticEnv.GITHUB_REF_NAME; export const trpcSharedClient = createTrpcProxyServiceClient( - TRPC_SERVER_URL !== "" && TRPC_SERVER_API_TOKEN !== "" + PUBLISHER_ENDPOINT !== "" && PUBLISHER_TOKEN !== "" ? { - url: TRPC_SERVER_URL, - token: TRPC_SERVER_API_TOKEN, + url: PUBLISHER_ENDPOINT, + token: PUBLISHER_TOKEN, branchName: GITHUB_REF_NAME, clientVersion: staticEnv.GITHUB_SHA ?? "local", } diff --git a/apps/builder/app/shared/context.server.test.ts b/apps/builder/app/shared/context.server.test.ts index c304643330a4..be02a8ccaabe 100644 --- a/apps/builder/app/shared/context.server.test.ts +++ b/apps/builder/app/shared/context.server.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const env = vi.hoisted(() => ({ TRPC_SERVER_API_TOKEN: undefined as string | undefined, + PUBLISHER_TOKEN: undefined as string | undefined, POSTGREST_URL: "http://localhost:3000", POSTGREST_API_KEY: "", PUBLISHER_HOST: "wstd.work", @@ -74,6 +75,7 @@ import { extractAuthFromRequest } from "./context.server"; describe("extractAuthFromRequest", () => { beforeEach(() => { env.TRPC_SERVER_API_TOKEN = undefined; + env.PUBLISHER_TOKEN = undefined; authenticator.isAuthenticated.mockResolvedValue(undefined); builderAuthenticator.isAuthenticated.mockResolvedValue(undefined); isBuilder.mockReturnValue(false); @@ -92,6 +94,28 @@ describe("extractAuthFromRequest", () => { }); test("accepts a matching non-empty service token", async () => { + env.PUBLISHER_TOKEN = "service-token"; + const request = new Request("https://webstudio.is/trpc", { + headers: { Authorization: "service-token" }, + }); + + const auth = await extractAuthFromRequest(request); + + expect(auth.isServiceCall).toBe(true); + }); + + test("accepts a matching bearer service token", async () => { + env.PUBLISHER_TOKEN = "service-token"; + const request = new Request("https://webstudio.is/trpc", { + headers: { Authorization: "Bearer service-token" }, + }); + + const auth = await extractAuthFromRequest(request); + + expect(auth.isServiceCall).toBe(true); + }); + + test("accepts legacy trpc service token", async () => { env.TRPC_SERVER_API_TOKEN = "service-token"; const request = new Request("https://webstudio.is/trpc", { headers: { Authorization: "service-token" }, diff --git a/apps/builder/app/shared/context.server.ts b/apps/builder/app/shared/context.server.ts index 6765d9e531ae..102dbe1360f3 100644 --- a/apps/builder/app/shared/context.server.ts +++ b/apps/builder/app/shared/context.server.ts @@ -48,12 +48,25 @@ export const extractAuthFromRequest = async (request: Request) => { }; }; +const getAuthorizationToken = (authorizationHeader: string | null) => { + if (authorizationHeader === null) { + return; + } + const [type, token] = authorizationHeader.split(" "); + if (type === "Bearer" && token) { + return token; + } + return authorizationHeader; +}; + export const isServiceAuthorization = (authorizationHeader: string | null) => { + const serviceToken = env.PUBLISHER_TOKEN ?? env.TRPC_SERVER_API_TOKEN; + const authorizationToken = getAuthorizationToken(authorizationHeader); return ( - authorizationHeader != null && - env.TRPC_SERVER_API_TOKEN !== undefined && - env.TRPC_SERVER_API_TOKEN.length > 0 && - authorizationHeader === env.TRPC_SERVER_API_TOKEN + authorizationToken !== undefined && + serviceToken !== undefined && + serviceToken.length > 0 && + authorizationToken === serviceToken ); }; @@ -264,6 +277,7 @@ export const createContext = async (request: Request): Promise => { return { authorization, + authorizationHeader: request.headers.get("Authorization"), domain, deployment, entri, diff --git a/apps/builder/app/shared/project-settings/section-publish.tsx b/apps/builder/app/shared/project-settings/section-publish.tsx index 8c18c1a3f2d9..56d9a48c0214 100644 --- a/apps/builder/app/shared/project-settings/section-publish.tsx +++ b/apps/builder/app/shared/project-settings/section-publish.tsx @@ -5,6 +5,8 @@ import { CheckboxAndLabel, Checkbox, Text, + InputField, + InputErrorsTooltip, } from "@webstudio-is/design-system"; import type { CompilerSettings } from "@webstudio-is/sdk"; import { $pages } from "~/shared/sync/data-stores"; @@ -16,11 +18,25 @@ const defaultPublishSettings: CompilerSettings = { atomicStyles: true, }; +const validateUrl = (url: string) => { + if (url.trim() === "") { + return undefined; + } + try { + new URL(url); + } catch { + return "URL is invalid"; + } +}; + export const SectionPublish = () => { - const ids = useIds(["atomicStyles"]); + const ids = useIds(["atomicStyles", "publisherEndpoint", "publisherToken"]); const [settings, setSettings] = useState( () => $pages.get()?.compiler ?? defaultPublishSettings ); + const publisherEndpoint = settings.publisherEndpoint ?? ""; + const publisherToken = settings.publisherToken ?? ""; + const publisherEndpointError = validateUrl(publisherEndpoint); const handleSave = (settings: CompilerSettings) => { serverSyncStore.createTransaction([$pages], (pages) => { @@ -54,6 +70,59 @@ export const SectionPublish = () => { + + Custom publishing + + + + { + const nextSettings = { + ...settings, + publisherEndpoint: event.target.value, + publisherToken, + }; + setSettings(nextSettings); + if (validateUrl(nextSettings.publisherEndpoint) === undefined) { + handleSave(nextSettings); + } + }} + /> + + + + + { + const nextSettings = { + ...settings, + publisherEndpoint, + publisherToken: event.target.value, + }; + setSettings(nextSettings); + if (publisherEndpointError === undefined) { + handleSave(nextSettings); + } + }} + /> + + + When configured, publishing dispatches this endpoint instead of the + default Webstudio deployment pipeline. + + ); }; diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 97fd20d059e4..445b7af95e7d 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -3,6 +3,7 @@ import { nanoid } from "nanoid"; import * as projectApi from "@webstudio-is/project/index.server"; import { createProductionBuild, + loadDevBuildByProjectId, unpublishBuild, } from "@webstudio-is/project-build/index.server"; import { @@ -17,6 +18,75 @@ import { Templates } from "@webstudio-is/sdk"; import { db } from "../db"; import { isDomainUsingCloudflareNameservers } from "../rdap"; +const dispatchPublisherEndpoint = async ({ + endpoint, + token, + buildId, + builderOrigin, + publisherCallbackUrl, + githubSha, + branchName, + destination, + logProjectName, +}: { + endpoint: string; + token?: string; + buildId: string; + builderOrigin: string; + publisherCallbackUrl: string; + githubSha?: string; + branchName: string; + destination: "saas" | "static"; + logProjectName: string; +}) => { + let publisherEndpoint: URL; + try { + publisherEndpoint = new URL(endpoint); + } catch { + return { + success: false as const, + error: "Publisher endpoint is invalid", + }; + } + publisherEndpoint.searchParams.set( + "publisherCallbackUrl", + publisherCallbackUrl + ); + + const response = await fetch(publisherEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + ref: branchName, + inputs: { + buildId, + builderOrigin, + publisherCallbackUrl, + githubSha: githubSha ?? "", + destination, + logProjectName, + }, + }), + }); + + if (response.ok) { + return { + success: true as const, + }; + } + + return { + success: false as const, + error: `Publisher endpoint failed with ${response.status} ${response.statusText}`, + }; +}; + +const getPublisherCallbackUrl = (builderOrigin: string) => + `${builderOrigin}/trpc/build.publishStatus`; + export const domainRouter = router({ getEntriToken: procedure.query(async ({ ctx }) => { try { @@ -134,9 +204,29 @@ export const domainRouter = router({ throw new Error("Missing env.BUILDER_ORIGIN"); } + const devBuild = await loadDevBuildByProjectId(ctx, input.projectId); + const publisherEndpoint = + devBuild.pages.compiler?.publisherEndpoint?.trim(); + const publisherToken = devBuild.pages.compiler?.publisherToken?.trim(); + + if (publisherEndpoint) { + return await dispatchPublisherEndpoint({ + endpoint: publisherEndpoint, + token: publisherToken, + builderOrigin: env.BUILDER_ORIGIN, + publisherCallbackUrl: getPublisherCallbackUrl(env.BUILDER_ORIGIN), + githubSha: env.GITHUB_SHA, + buildId: build.id, + branchName: env.GITHUB_REF_NAME, + destination: input.destination, + logProjectName: `${project.title} - ${project.id}`, + }); + } + const result = await deploymentTrpc.publish.mutate({ // used to load build data from the builder with build.loadProjectDataByBuildId builderOrigin: env.BUILDER_ORIGIN, + publisherCallbackUrl: getPublisherCallbackUrl(env.BUILDER_ORIGIN), githubSha: env.GITHUB_SHA, buildId: build.id, // preview support diff --git a/packages/sdk/src/schema/pages.ts b/packages/sdk/src/schema/pages.ts index b0825f8b3500..e97e9f57edff 100644 --- a/packages/sdk/src/schema/pages.ts +++ b/packages/sdk/src/schema/pages.ts @@ -242,6 +242,8 @@ export type PageRedirect = z.infer; export const CompilerSettings = z.object({ // All fields are optional to ensure consistency and allow for the addition of new fields without requiring migration atomicStyles: z.boolean().optional(), + publisherEndpoint: z.string().optional(), + publisherToken: z.string().optional(), }); export type CompilerSettings = z.infer; diff --git a/packages/trpc-interface/src/context/context.server.ts b/packages/trpc-interface/src/context/context.server.ts index 7b26783570f2..4da5e8be54cf 100644 --- a/packages/trpc-interface/src/context/context.server.ts +++ b/packages/trpc-interface/src/context/context.server.ts @@ -92,6 +92,7 @@ type ApiClientContext = */ export type AppContext = { authorization: AuthorizationContext; + authorizationHeader?: string | null; domain: DomainContext; deployment: DeploymentContext; entri: EntriContext; diff --git a/packages/trpc-interface/src/shared/deployment.ts b/packages/trpc-interface/src/shared/deployment.ts index fa81839eb654..0daa7583e58c 100644 --- a/packages/trpc-interface/src/shared/deployment.ts +++ b/packages/trpc-interface/src/shared/deployment.ts @@ -6,6 +6,12 @@ export const PublishInput = z.object({ // used to load build data from the builder with build.loadProjectDataByBuildId buildId: z.string(), builderOrigin: z.string(), + publisherCallbackUrl: z + .string({ + required_error: + "publisherCallbackUrl is required. Deploy a builder version that passes the publisher callback URL to the publisher service.", + }) + .url("publisherCallbackUrl must be a valid URL."), githubSha: z.string().optional(), destination: z.enum(["saas", "static"]),