Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/builder/app/env/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions apps/builder/app/services/build-router.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 5 additions & 5 deletions apps/builder/app/services/trpc.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
24 changes: 24 additions & 0 deletions apps/builder/app/shared/context.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand All @@ -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" },
Expand Down
22 changes: 18 additions & 4 deletions apps/builder/app/shared/context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
};

Expand Down Expand Up @@ -264,6 +277,7 @@ export const createContext = async (request: Request): Promise<AppContext> => {

return {
authorization,
authorizationHeader: request.headers.get("Authorization"),
domain,
deployment,
entri,
Expand Down
71 changes: 70 additions & 1 deletion apps/builder/app/shared/project-settings/section-publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) => {
Expand Down Expand Up @@ -54,6 +70,59 @@ export const SectionPublish = () => {
</Label>
</CheckboxAndLabel>
</Grid>
<Grid gap={2} css={sectionSpacing}>
<Text variant="labels">Custom publishing</Text>
<Grid gap={1}>
<Label htmlFor={ids.publisherEndpoint}>Publisher endpoint</Label>
<InputErrorsTooltip
errors={
publisherEndpointError ? [publisherEndpointError] : undefined
}
>
<InputField
id={ids.publisherEndpoint}
color={publisherEndpointError ? "error" : undefined}
placeholder="https://api.github.com/repos/org/repo/actions/workflows/publish.yml/dispatches"
value={publisherEndpoint}
onChange={(event) => {
const nextSettings = {
...settings,
publisherEndpoint: event.target.value,
publisherToken,
};
setSettings(nextSettings);
if (validateUrl(nextSettings.publisherEndpoint) === undefined) {
handleSave(nextSettings);
}
}}
/>
</InputErrorsTooltip>
</Grid>
<Grid gap={1}>
<Label htmlFor={ids.publisherToken}>Publisher token</Label>
<InputField
id={ids.publisherToken}
type="password"
placeholder="GitHub token or publisher secret"
value={publisherToken}
onChange={(event) => {
const nextSettings = {
...settings,
publisherEndpoint,
publisherToken: event.target.value,
};
setSettings(nextSettings);
if (publisherEndpointError === undefined) {
handleSave(nextSettings);
}
}}
/>
</Grid>
<Text color="subtle">
When configured, publishing dispatches this endpoint instead of the
default Webstudio deployment pipeline.
</Text>
</Grid>
</Grid>
);
};
Loading
Loading