mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
Invalid or revoked share links now return an actionable 401 response instead of an unexpected server error. Missing project and workspace records receive distinct responses at their lookup sites. Token, project, and workspace lookups use `.maybeSingle()` and rely on database uniqueness. Missing projects have an explicit `ProjectNotFoundError`; the loader no longer interprets `PGRST116`. The API preserves `UNAUTHORIZED` and `INVALID_AUTH_TOKEN` when authorization fails during context creation, so CLI and MCP clients can offer relinking guidance without inspecting error text. Tokens without API access carry `API_ACCESS_DISABLED`. A repository audit replaces error-message matching with typed errors, stable codes, or structured diagnostics in CLI argument/configuration handling, session conflicts, preview cleanup, import retries, domain unpublishing, asset validation, JSX/expression diagnostics, CSS validation, and publish diagnostics. `AGENTS.md` now explicitly prohibits message-based error classification and recovery. The existing build-permission preflight now advertises structured import-error support. Clients upload assets before importing into older servers that do not advertise it; newer servers keep selective retries using structured missing-file details. The internal template-validation error class remains outside public JSX bindings and input descriptions. Shared Builder `parseError` normalizes tRPC and local validation errors, so UI components only add display context instead of decoding transport payloads. Simplification removes three redundant error classes, catch-and-ignore handlers, an unused formatter, and nested loader error handling. Unpublishing an already unpublished domain succeeds after authorization. CLI configuration failures use the existing shared error handling, and plan parsing catches only JSON parsing failures. Validation: affected suites passed (271 tests across 8 suites in the latest simplification checks), affected package typechecks passed, and lint, package-boundary checks, and `git diff --check` passed. The audit replaced synthetic parser branches and private-method assertions with actual tRPC round trips, PostgREST responses, and public VM behavior. Controlled regressions confirmed that removing serialized validation details and exposing the internal JSX error class both fail the revised tests. Astra reviewed the production changes at medium effort; its compatibility finding and the GitHub JSX-binding comment are fixed, and follow-up review found no remaining substantive issues.
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
/**
|
||
* The file is named _ui.(builder) instead of _ui._index due to an issue with Vercel.
|
||
* The _ui._index route isn’t recognized on Vercel, even though it works perfectly in other environments.
|
||
*/
|
||
|
||
import { lazy } from "react";
|
||
import { useLoaderData } from "@remix-run/react";
|
||
import type { MetaFunction, ShouldRevalidateFunction } from "@remix-run/react";
|
||
import {
|
||
json,
|
||
type HeadersArgs,
|
||
type LoaderFunctionArgs,
|
||
} from "@remix-run/server-runtime";
|
||
import * as projectApi from "@webstudio-is/project/index.server";
|
||
import { defaultRole, type Role } from "@webstudio-is/project";
|
||
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
|
||
|
||
import {
|
||
AuthorizationError,
|
||
authorizeProject,
|
||
} from "@webstudio-is/trpc-interface/index.server";
|
||
import { createContext, InvalidAuthTokenError } from "~/shared/context.server";
|
||
import { getPlanInfo } from "@webstudio-is/plans/index.server";
|
||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||
import { dashboardPath, isBuilder, isDashboard } from "~/shared/router-utils";
|
||
import { areBuilderDataUrlsEqual } from "~/shared/router-utils/path-utils";
|
||
|
||
import env from "~/env/env.server";
|
||
|
||
import builderStyles from "~/builder/builder.css?url";
|
||
import { ClientOnly } from "~/shared/client-only";
|
||
import { parseBuilderUrl } from "@webstudio-is/protocol";
|
||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||
import { redirect } from "~/services/no-store-redirect";
|
||
import { builderSessionStorage } from "~/services/builder-session.server";
|
||
import {
|
||
allowedDestinations,
|
||
isFetchDestination,
|
||
} from "~/services/destinations.server";
|
||
import { loader as authWsLoader } from "./auth.ws";
|
||
import { getUserById } from "~/shared/db/user.server";
|
||
import {
|
||
createPrivateNoStoreHeaders,
|
||
privateNoStoreResponseHeaders,
|
||
} from "~/services/cache-control.server";
|
||
export { ErrorBoundary } from "~/shared/error/error-boundary";
|
||
|
||
export const links = () => {
|
||
return [{ rel: "stylesheet", href: builderStyles }];
|
||
};
|
||
|
||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||
const metas: ReturnType<MetaFunction> = [];
|
||
|
||
if (data === undefined) {
|
||
return metas;
|
||
}
|
||
|
||
// Project title will be set dynamically after data loads
|
||
return metas;
|
||
};
|
||
|
||
export const loader = async (loaderArgs: LoaderFunctionArgs) => {
|
||
const { request } = loaderArgs;
|
||
preventCrossOriginCookie(request);
|
||
allowedDestinations(request, ["document", "empty"]);
|
||
|
||
if (isDashboard(request)) {
|
||
throw redirect(dashboardPath());
|
||
}
|
||
|
||
if (false === isBuilder(request)) {
|
||
throw new Response("Not Found", {
|
||
status: 404,
|
||
});
|
||
}
|
||
|
||
if (isFetchDestination(request)) {
|
||
// Remix does not provide a built-in way to add CSRF tokens to data fetches,
|
||
// such as client-side navigation or data refreshes.
|
||
// Therefore, ensure that all data fetched here is not sensitive and does not require CSRF protection.
|
||
// await checkCsrf(request);
|
||
}
|
||
|
||
let context: Awaited<ReturnType<typeof createContext>>;
|
||
try {
|
||
context = await createContext(request);
|
||
} catch (error) {
|
||
if (error instanceof InvalidAuthTokenError) {
|
||
throw new Response(
|
||
"This share link is invalid or expired. Ask the project owner for a new link.",
|
||
{ status: 401, statusText: "Unauthorized" }
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
if (context.authorization.type === "service") {
|
||
throw new AuthorizationError("Service calls are not allowed");
|
||
}
|
||
|
||
if (context.authorization.type === "anonymous") {
|
||
throw await authWsLoader(loaderArgs);
|
||
}
|
||
|
||
if (
|
||
context.authorization.type === "user" &&
|
||
request.headers.get("sec-fetch-mode") === "navigate"
|
||
) {
|
||
// If logout fails, or the session cookie in the dashboard is deleted or expired,
|
||
// enforce reauthorization on builder reload or navigation (sec-fetch-mode === 'navigate') after a timeout.
|
||
const RELOAD_ON_NAVIGATE_TIMEOUT =
|
||
env.DEPLOYMENT_ENVIRONMENT === "production"
|
||
? 1000 * 60 * 60 * 24 * 7 // 1 week
|
||
: 1000 * 60 * 60 * 1; // 1 hour
|
||
|
||
if (
|
||
Date.now() - context.authorization.sessionCreatedAt >
|
||
RELOAD_ON_NAVIGATE_TIMEOUT
|
||
) {
|
||
throw await authWsLoader(loaderArgs);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const url = new URL(request.url);
|
||
|
||
const { projectId } = parseBuilderUrl(request.url);
|
||
|
||
if (projectId === undefined) {
|
||
throw new Response("Project ID is not defined", {
|
||
status: 404,
|
||
});
|
||
}
|
||
|
||
const start = Date.now();
|
||
const project = await projectApi.loadById(projectId, context);
|
||
|
||
const authPermit =
|
||
(await authorizeProject.getProjectPermit(
|
||
{
|
||
projectId: project.id,
|
||
// At this point we already knew that if project loaded we have at least "view" permit
|
||
// having that getProjectPermit is heavy operation we can skip check "view" permit
|
||
permits: ["own", "admin", "build", "edit"] as const,
|
||
},
|
||
context
|
||
)) ?? "view";
|
||
|
||
const end = Date.now();
|
||
|
||
const diff = end - start;
|
||
|
||
// we need to log timings to figure out how to speed up loading
|
||
|
||
console.info(`Project ${project.id} is loaded in ${diff}ms`);
|
||
|
||
const authToken = url.searchParams.get("authToken") ?? undefined;
|
||
|
||
const authTokenPermissions =
|
||
authPermit !== "own" && authToken !== undefined
|
||
? await authDb.getTokenPermissions(
|
||
{
|
||
projectId: project.id,
|
||
token: authToken,
|
||
},
|
||
context
|
||
)
|
||
: authDb.tokenDefaultPermissions;
|
||
|
||
// When the project belongs to a workspace, resolve plan from the workspace owner
|
||
// so the owner's subscription governs all projects in the workspace
|
||
let role: Role | "own" = "own";
|
||
|
||
if (project.workspaceId !== null) {
|
||
const currentUserId =
|
||
context.authorization.type === "user"
|
||
? context.authorization.userId
|
||
: undefined;
|
||
|
||
// Fetch workspace owner and current user's membership in a single query.
|
||
// When currentUserId is undefined (token auth), filter with a UUID that
|
||
// can never match so the members array comes back empty.
|
||
const noMatchId = "00000000-0000-0000-0000-000000000000";
|
||
const workspace = await context.postgrest.client
|
||
.from("Workspace")
|
||
.select("userId, members:WorkspaceMember(relation)")
|
||
.eq("id", project.workspaceId)
|
||
.eq("isDeleted", false)
|
||
.eq("members.userId", currentUserId ?? noMatchId)
|
||
.is("members.removedAt", null)
|
||
.maybeSingle();
|
||
|
||
if (workspace.error) {
|
||
throw workspace.error;
|
||
}
|
||
|
||
if (workspace.data === null) {
|
||
throw new Response(
|
||
"This project's workspace is unavailable. Contact the project owner for help.",
|
||
{ status: 404, statusText: "Workspace not found" }
|
||
);
|
||
}
|
||
|
||
const planResult = await getPlanInfo([workspace.data.userId], context);
|
||
const ownerPlan = planResult.get(workspace.data.userId) ?? {
|
||
planFeatures: defaultPlanFeatures,
|
||
purchases: [],
|
||
};
|
||
context.planFeatures = ownerPlan.planFeatures;
|
||
context.purchases = ownerPlan.purchases;
|
||
|
||
// Determine the current user's relation to the workspace
|
||
if (
|
||
currentUserId !== undefined &&
|
||
workspace.data.userId !== currentUserId
|
||
) {
|
||
const membership = workspace.data.members[0];
|
||
role = (membership?.relation as Role) ?? defaultRole;
|
||
|
||
// When the workspace owner has downgraded, members lose access.
|
||
// Data stays intact but permissions are suspended.
|
||
if (ownerPlan.planFeatures.maxWorkspaces <= 1) {
|
||
throw new AuthorizationError(
|
||
"The workspace owner's plan no longer supports workspace access"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const { planFeatures, purchases } = context;
|
||
const user =
|
||
context.authorization.type === "user"
|
||
? await getUserById(context, context.authorization.userId)
|
||
: undefined;
|
||
|
||
if (project.userId === null) {
|
||
throw new AuthorizationError("Project must have project userId defined");
|
||
}
|
||
|
||
const headers = createPrivateNoStoreHeaders();
|
||
|
||
if (context.authorization.type === "token") {
|
||
// To protect against cookie overwrites, we set a null session cookie if a user is using an authToken.
|
||
// This ensures that any existing HttpOnly, secure session cookies cannot be overwritten by client-side scripts
|
||
|
||
// See Storage model https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
|
||
// If the cookie store contains a cookie with the same name,
|
||
// domain, and path as the newly created cookie:
|
||
// ...
|
||
// If the newly created cookie was received from a "non-HTTP"
|
||
// API and the old-cookie's http-only-flag is set, abort these
|
||
// steps and ignore the newly created cookie entirely.
|
||
const builderSession = await builderSessionStorage.getSession(null);
|
||
headers.set(
|
||
"Set-Cookie",
|
||
await builderSessionStorage.commitSession(builderSession)
|
||
);
|
||
}
|
||
|
||
headers.set(
|
||
// Disallowing iframes from loading any content except the canvas
|
||
// Still possible create iframes on canvas itself (but we use credentialless attribute)
|
||
// Still possible create iframe without src attribute
|
||
// Allow blob: workers so hdr-color-input can spawn its inline canvas-rendering worker.
|
||
// blob: workers can only be created from JS already running in this page, so the
|
||
// attack surface is no wider than allowing eval.
|
||
"Content-Security-Policy",
|
||
`frame-src ${url.origin}/canvas https://app.goentri.com/ https://help.webstudio.is/; worker-src blob:`
|
||
);
|
||
|
||
return json(
|
||
{
|
||
projectId: project.id,
|
||
authToken,
|
||
authTokenPermissions,
|
||
authPermit,
|
||
user,
|
||
role,
|
||
planFeatures,
|
||
purchases,
|
||
stagingUsername: env.STAGING_USERNAME,
|
||
stagingPassword: env.STAGING_PASSWORD,
|
||
} as const,
|
||
{
|
||
headers,
|
||
}
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof projectApi.ProjectNotFoundError) {
|
||
throw new Response(error.message, {
|
||
status: 404,
|
||
statusText: "Project not found",
|
||
});
|
||
}
|
||
if (error instanceof AuthorizationError) {
|
||
// try to re-login user if he has no access to the project
|
||
throw await authWsLoader(loaderArgs);
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* When doing changes in a project, then navigating to a dashboard then pressing the back button,
|
||
* the builder page may display stale data because it’s being retrieved from the browser’s back/forward cache (bfcache).
|
||
*
|
||
* https://web.dev/articles/bfcache
|
||
*
|
||
*/
|
||
export const headers = ({ loaderHeaders }: HeadersArgs) => {
|
||
const contentSecurityPolicy = loaderHeaders.get("Content-Security-Policy");
|
||
|
||
return {
|
||
...privateNoStoreResponseHeaders,
|
||
...(contentSecurityPolicy === null
|
||
? {}
|
||
: { "Content-Security-Policy": contentSecurityPolicy }),
|
||
};
|
||
};
|
||
|
||
const Builder = lazy(async () => {
|
||
const { Builder } = await import("~/builder/index.client");
|
||
return { default: Builder };
|
||
});
|
||
|
||
const BuilderRoute = () => {
|
||
const data = useLoaderData<typeof loader>();
|
||
|
||
return (
|
||
<ClientOnly>
|
||
{/* Using a key here ensures that certain effects are re-executed inside the builder,
|
||
especially in cases like cloning a project */}
|
||
<Builder key={data.projectId} {...data} />
|
||
</ClientOnly>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* We do not want trpc and other mutations that use the Remix useFetcher hook
|
||
* to cause a reload of all builder data.
|
||
*/
|
||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||
currentUrl,
|
||
nextUrl,
|
||
defaultShouldRevalidate,
|
||
}) => {
|
||
return areBuilderDataUrlsEqual(currentUrl, nextUrl)
|
||
? false
|
||
: defaultShouldRevalidate;
|
||
};
|
||
|
||
export default BuilderRoute;
|