Files
webstudio/apps/builder/app/shared/context.server.ts
T
Ivan StarkovandOleg Isonen 50ff477c97 fix: Project Cloning Broken for Share Links (#4071)
## Description

closes #4068

<img width="267" alt="image"
src="https://github.com/user-attachments/assets/ba0d6287-1391-4f03-b513-cdd07a180a11">

AuthorizationContext - splitted on 4 types, user, token, service,
anonymous (before was a mix making it hard to understand permissions)

Clone - needs 2 contexts source and destination.



## Steps for reproduction

- **Log in as "Vasya Pupkin".** 
  `5de6:vasya@pupkin.com`
- **Create a project.**
- **Generate a ShareLink.**
- **Copy the ShareLink.**

`https://p-636d568a-0a42-43d7-9509-8b23607cd5e8-dot-clone-project.development.webstudio.is/?authToken=da7ef507-c91b-4393-a1fe-91848ba533c9&mode=preview`
- **Log out.**

---

- **Log in as another user.**
- **Paste the ShareLink then clone the project.**
- **See title etc in clone dialog**
<img width="588" alt="image"
src="https://github.com/user-attachments/assets/0a04ae59-548e-48ca-871c-d01c91778194">
- **Log out.**

---

- **Paste the ShareLink then clone the project.**
- **Login process**
- **See title etc in clone dialog after the login**
<img width="588" alt="image"
src="https://github.com/user-attachments/assets/0a04ae59-548e-48ca-871c-d01c91778194">
- **Log out.**

---

- **Paste ShareLink in the, go to the menu and copy the clone link.**
<img width="417" alt="image"
src="https://github.com/user-attachments/assets/5f2911c1-721c-4c36-8ad7-b91052c9f9c1">


`https://clone-project.development.webstudio.is/dashboard?projectToCloneAuthToken=da7ef507-c91b-4393-a1fe-91848ba533c9`

---

- **Log in as "Vasya Pupkin".**
- **Remove cloning permission for the project.**
- **Log out.**

---

Paste
`https://clone-project.development.webstudio.is/dashboard?projectToCloneAuthToken=da7ef507-c91b-4393-a1fe-91848ba533c9`
See  "unexpected server error"

---

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file

---------

Co-authored-by: Oleg Isonen <oleg008@gmail.com>
2024-09-07 09:18:40 +03:00

232 lines
5.9 KiB
TypeScript

import { type AppContext } from "@webstudio-is/trpc-interface/index.server";
import env from "~/env/env.server";
import { authenticator } from "~/services/auth.server";
import { trpcSharedClient } from "~/services/trpc.server";
import { entryApi } from "./entri/entri-api.server";
import { getUserPlanFeatures } from "./db/user-plan-features.server";
import { staticEnv } from "~/env/env.static.server";
import { createClient } from "@webstudio-is/postrest/index.server";
import { prisma } from "@webstudio-is/prisma-client";
import { builderAuthenticator } from "~/services/builder-auth.server";
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";
export const extractAuthFromRequest = async (request: Request) => {
if (isCanvas(request)) {
throw new Error("Canvas requests can't have authorization context");
}
const url = new URL(request.url);
const authToken =
url.searchParams.get("authToken") ??
request.headers.get("x-auth-token") ??
undefined;
const sessionData = isBuilder(request)
? await builderAuthenticator.isAuthenticated(request)
: await authenticator.isAuthenticated(request);
const isServiceCall =
request.headers.has("Authorization") &&
request.headers.get("Authorization") === env.TRPC_SERVER_API_TOKEN;
return {
authToken,
sessionData,
isServiceCall,
};
};
const createTokenAuthorizationContext = async (authToken: string) => {
const projectOwnerIdByToken = await prisma.authorizationToken.findUnique({
where: {
token: authToken,
},
select: {
project: {
select: {
id: true,
userId: true,
},
},
},
});
if (projectOwnerIdByToken === null) {
throw new Error(`Project owner can't be found for token ${authToken}`);
}
const ownerId = projectOwnerIdByToken.project.userId;
if (ownerId === null) {
throw new Error(
`Project ${projectOwnerIdByToken.project.id} has null userId`
);
}
return {
type: "token" as const,
authToken,
ownerId,
};
};
const createAuthorizationContext = async (
request: Request
): Promise<AppContext["authorization"]> => {
const { authToken, isServiceCall, sessionData } =
await extractAuthFromRequest(request);
if (isServiceCall) {
return {
type: "service",
isServiceCall: true,
};
}
if (authToken != null) {
return await createTokenAuthorizationContext(authToken);
}
if (sessionData?.userId != null) {
const userId = sessionData.userId;
let loginBloomFilter: BloomFilter | undefined = undefined;
let isLoggedInToBuilder = async (projectId: string) => {
if (loginBloomFilter === undefined) {
loginBloomFilter = await readLoginSessionBloomFilter(request);
}
return loginBloomFilter.has(projectId);
};
if (isBuilder(request)) {
isLoggedInToBuilder = async (projectId: string) => {
const parsedUrl = parseBuilderUrl(request.url);
return parsedUrl.projectId === projectId;
};
}
return {
type: "user",
userId,
sessionCreatedAt: sessionData.createdAt,
isLoggedInToBuilder,
};
}
return { type: "anonymous" };
};
const createDomainContext = () => {
const context: AppContext["domain"] = {
domainTrpc: trpcSharedClient.domain,
};
return context;
};
const getRequestOrigin = (urlStr: string) => {
const url = new URL(urlStr);
return url.origin;
};
const createDeploymentContext = (builderOrigin: string) => {
const context: AppContext["deployment"] = {
deploymentTrpc: trpcSharedClient.deployment,
env: {
BUILDER_ORIGIN: getRequestOrigin(builderOrigin),
GITHUB_REF_NAME: staticEnv.GITHUB_REF_NAME ?? "undefined",
GITHUB_SHA: staticEnv.GITHUB_SHA ?? undefined,
},
};
return context;
};
const createEntriContext = () => {
return {
entryApi,
};
};
const createUserPlanContext = async (
authorization: AppContext["authorization"]
) => {
const ownerId =
authorization.type === "token"
? authorization.ownerId
: authorization.type === "user"
? authorization.userId
: undefined;
const planFeatures = ownerId ? await getUserPlanFeatures(ownerId) : undefined;
return planFeatures;
};
const createTrpcCache = () => {
const proceduresMaxAge = new Map<string, number>();
const setMaxAge = (path: string, value: number) => {
proceduresMaxAge.set(
path,
Math.min(proceduresMaxAge.get(path) ?? Number.MAX_SAFE_INTEGER, value)
);
};
const getMaxAge = (path: string) => proceduresMaxAge.get(path);
return {
setMaxAge,
getMaxAge,
};
};
export const createPostrestContext = () => {
return { client: createClient(env.POSTGREST_URL, env.POSTGREST_API_KEY) };
};
/**
* argument buildEnv==="prod" only if we are loading project with production build
*/
export const createContext = async (request: Request): Promise<AppContext> => {
const authorization = await createAuthorizationContext(request);
const domain = createDomainContext();
const deployment = createDeploymentContext(getRequestOrigin(request.url));
const entri = createEntriContext();
const userPlanFeatures = await createUserPlanContext(authorization);
const trpcCache = createTrpcCache();
const postgrest = createPostrestContext();
const createTokenContext = async (authToken: string) => {
const authorization = await createTokenAuthorizationContext(authToken);
const userPlanFeatures = await createUserPlanContext(authorization);
return {
authorization,
domain,
deployment,
entri,
userPlanFeatures,
trpcCache,
postgrest,
createTokenContext,
};
};
return {
authorization,
domain,
deployment,
entri,
userPlanFeatures,
trpcCache,
postgrest,
createTokenContext,
};
};