wip: move permissions check to Prisma extension

This commit is contained in:
bkellam 2025-09-17 18:31:42 -07:00
parent 0b03f94f67
commit 671fd78360
14 changed files with 697 additions and 737 deletions

View file

@ -74,7 +74,7 @@ await repoManager.validateIndexedReposHaveShards();
const connectionManagerInterval = connectionManager.startScheduler(); const connectionManagerInterval = connectionManager.startScheduler();
const repoManagerInterval = repoManager.startScheduler(); const repoManagerInterval = repoManager.startScheduler();
const permissionSyncerInterval = env.EXPERIMENT_PERMISSION_SYNC_ENABLED ? permissionSyncer.startScheduler() : null; const permissionSyncerInterval = env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? permissionSyncer.startScheduler() : null;
const cleanup = async (signal: string) => { const cleanup = async (signal: string) => {

View file

@ -59,7 +59,7 @@ export class RepoPermissionSyncer {
} }
// @todo: make this configurable // @todo: make this configurable
}, 1000 * 5); }, 1000 * 60);
} }
public dispose() { public dispose() {

View file

@ -1,46 +1,45 @@
'use server'; 'use server';
import { getAuditService } from "@/ee/features/audit/factory";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
import { CodeHostType, isHttpError, isServiceError } from "@/lib/utils"; import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto"; import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db"; import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger";
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { getPlan, hasEntitlement } from "@sourcebot/shared";
import Ajv from "ajv"; import Ajv from "ajv";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { auth } from "./auth";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
import { auth } from "./auth";
import { getConnection } from "./data/connection"; import { getConnection } from "./data/connection";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail"; import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { TenancyMode, ApiKeyPayload } from "./lib/types"; import { ApiKeyPayload, TenancyMode } from "./lib/types";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { getPlan, hasEntitlement } from "@sourcebot/shared";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { getOrgMetadata } from "@/lib/utils";
import { getOrgFromDomain } from "./data/org";
import { withOptionalAuthV2 } from "./withAuthV2"; import { withOptionalAuthV2 } from "./withAuthV2";
const ajv = new Ajv({ const ajv = new Ajv({
@ -640,7 +639,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
}))); })));
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() => export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
withOptionalAuthV2(async ({ org, user }) => { withOptionalAuthV2(async ({ org, prisma }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
where: { where: {
orgId: org.id, orgId: org.id,
@ -654,13 +653,6 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
} }
} }
} : {}), } : {}),
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: user?.id,
}
}
} : {})
}, },
include: { include: {
connections: { connections: {
@ -688,74 +680,65 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
})) }))
})); }));
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => export const getRepoInfoByName = async (repoName: string) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { // @note: repo names are represented by their remote url
// @note: repo names are represented by their remote url // on the code host. E.g.,:
// on the code host. E.g.,: // - github.com/sourcebot-dev/sourcebot
// - github.com/sourcebot-dev/sourcebot // - gitlab.com/gitlab-org/gitlab
// - gitlab.com/gitlab-org/gitlab // - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
// - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor // etc.
// etc. //
// // For most purposes, repo names are unique within an org, so using
// For most purposes, repo names are unique within an org, so using // findFirst is equivalent to findUnique. Duplicates _can_ occur when
// findFirst is equivalent to findUnique. Duplicates _can_ occur when // a repository is specified by its remote url in a generic `git`
// a repository is specified by its remote url in a generic `git` // connection. For example:
// connection. For example: //
// // ```json
// ```json // {
// { // "connections": {
// "connections": { // "connection-1": {
// "connection-1": { // "type": "github",
// "type": "github", // "repos": [
// "repos": [ // "sourcebot-dev/sourcebot"
// "sourcebot-dev/sourcebot" // ]
// ] // },
// }, // "connection-2": {
// "connection-2": { // "type": "git",
// "type": "git", // "url": "file:///tmp/repos/sourcebot"
// "url": "file:///tmp/repos/sourcebot" // }
// } // }
// } // }
// } // ```
// ``` //
// // In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
// In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot". // We will leave this as an edge case for now since it's unlikely to happen in practice.
// We will leave this as an edge case for now since it's unlikely to happen in practice. //
// // @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
// @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate // these cases.
// these cases. // @see: repoCompileUtils.ts
// @see: repoCompileUtils.ts const repo = await prisma.repo.findFirst({
const repo = await prisma.repo.findFirst({ where: {
where: { name: repoName,
name: repoName, orgId: org.id,
orgId: org.id, },
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { });
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
},
});
if (!repo) { if (!repo) {
return notFound(); return notFound();
} }
return { return {
id: repo.id, id: repo.id,
name: repo.name, name: repo.name,
displayName: repo.displayName ?? undefined, displayName: repo.displayName ?? undefined,
codeHostType: repo.external_codeHostType, codeHostType: repo.external_codeHostType,
webUrl: repo.webUrl ?? undefined, webUrl: repo.webUrl ?? undefined,
imageUrl: repo.imageUrl ?? undefined, imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined, indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus, repoIndexingStatus: repo.repoIndexingStatus,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true }));
));
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withAuth((userId) => withAuth((userId) =>
@ -805,150 +788,141 @@ export const createConnection = async (name: string, type: CodeHostType, connect
}, OrgRole.OWNER) }, OrgRole.OWNER)
)); ));
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string, domain: string): Promise<{ connectionId: number } | ServiceError> => sew(() => export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') { return {
return { statusCode: StatusCodes.BAD_REQUEST,
statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY,
errorCode: ErrorCode.INVALID_REQUEST_BODY, message: "This feature is not enabled.",
message: "This feature is not enabled.", } satisfies ServiceError;
} satisfies ServiceError; }
}
// Parse repository URL to extract owner/repo // Parse repository URL to extract owner/repo
const repoInfo = (() => { const repoInfo = (() => {
const url = repositoryUrl.trim(); const url = repositoryUrl.trim();
// Handle various GitHub URL formats // Handle various GitHub URL formats
const patterns = [ const patterns = [
// https://github.com/owner/repo or https://github.com/owner/repo.git // https://github.com/owner/repo or https://github.com/owner/repo.git
/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/, /^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
// github.com/owner/repo // github.com/owner/repo
/^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/, /^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
// owner/repo // owner/repo
/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/ /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
]; ];
for (const pattern of patterns) { for (const pattern of patterns) {
const match = url.match(pattern); const match = url.match(pattern);
if (match) { if (match) {
return {
owner: match[1],
repo: match[2]
};
}
}
return null;
})();
if (!repoInfo) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
} satisfies ServiceError;
}
const { owner, repo } = repoInfo;
// Use GitHub API to fetch repository information and get the external_id
const octokit = new Octokit({
auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
});
let githubRepo;
try {
const response = await octokit.rest.repos.get({
owner,
repo,
});
githubRepo = response.data;
} catch (error) {
if (isHttpError(error, 404)) {
return { return {
statusCode: StatusCodes.NOT_FOUND, owner: match[1],
errorCode: ErrorCode.INVALID_REQUEST_BODY, repo: match[2]
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`, };
} satisfies ServiceError;
} }
if (isHttpError(error, 403)) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
} satisfies ServiceError;
}
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
} satisfies ServiceError;
} }
if (githubRepo.private) { return null;
return { })();
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Only public repositories can be added.",
} satisfies ServiceError;
}
// Check if this repository is already connected using the external_id if (!repoInfo) {
const existingRepo = await prisma.repo.findFirst({ return {
where: { statusCode: StatusCodes.BAD_REQUEST,
orgId: org.id, errorCode: ErrorCode.INVALID_REQUEST_BODY,
external_id: githubRepo.id.toString(), message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
external_codeHostType: 'github', } satisfies ServiceError;
external_codeHostUrl: 'https://github.com', }
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: { const { owner, repo } = repoInfo;
some: {
userId: userId, // Use GitHub API to fetch repository information and get the external_id
} const octokit = new Octokit({
} auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
} : {}) });
}
let githubRepo;
try {
const response = await octokit.rest.repos.get({
owner,
repo,
}); });
githubRepo = response.data;
if (existingRepo) { } catch (error) {
if (isHttpError(error, 404)) {
return { return {
statusCode: StatusCodes.BAD_REQUEST, statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS, errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "This repository already exists.", message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
} satisfies ServiceError; } satisfies ServiceError;
} }
const connectionName = `${owner}-${repo}-${Date.now()}`; if (isHttpError(error, 403)) {
return {
// Create GitHub connection config statusCode: StatusCodes.FORBIDDEN,
const connectionConfig: GithubConnectionConfig = { errorCode: ErrorCode.INVALID_REQUEST_BODY,
type: "github" as const, message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
repos: [`${owner}/${repo}`], } satisfies ServiceError;
...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? { }
token: {
env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
}
} : {})
};
const connection = await prisma.connection.create({
data: {
orgId: org.id,
name: connectionName,
config: connectionConfig as unknown as Prisma.InputJsonValue,
connectionType: 'github',
}
});
return { return {
connectionId: connection.id, statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
} satisfies ServiceError;
}
if (githubRepo.private) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Only public repositories can be added.",
} satisfies ServiceError;
}
// Check if this repository is already connected using the external_id
const existingRepo = await prisma.repo.findFirst({
where: {
orgId: org.id,
external_id: githubRepo.id.toString(),
external_codeHostType: 'github',
external_codeHostUrl: 'https://github.com',
} }
}, OrgRole.GUEST), /* allowAnonymousAccess = */ true });
));
if (existingRepo) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
message: "This repository already exists.",
} satisfies ServiceError;
}
const connectionName = `${owner}-${repo}-${Date.now()}`;
// Create GitHub connection config
const connectionConfig: GithubConnectionConfig = {
type: "github" as const,
repos: [`${owner}/${repo}`],
...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? {
token: {
env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
}
} : {})
};
const connection = await prisma.connection.create({
data: {
orgId: org.id,
name: connectionName,
config: connectionConfig as unknown as Prisma.InputJsonValue,
connectionType: 'github',
}
});
return {
connectionId: connection.id,
}
}));
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) => withAuth((userId) =>
@ -2043,82 +2017,73 @@ export const getSearchContexts = async (domain: string) => sew(() =>
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => { export const getRepoImage = async (repoId: number): Promise<ArrayBuffer | ServiceError> => sew(async () => {
return await withAuth(async (userId) => { return await withOptionalAuthV2(async ({ org, prisma }) => {
return await withOrgMembership(userId, domain, async ({ org }) => { const repo = await prisma.repo.findUnique({
const repo = await prisma.repo.findUnique({ where: {
where: { id: repoId,
id: repoId, orgId: org.id,
orgId: org.id, },
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { include: {
permittedUsers: { connections: {
some: { include: {
userId: userId, connection: true,
}
}
} : {})
},
include: {
connections: {
include: {
connection: true,
}
} }
}, }
},
});
if (!repo || !repo.imageUrl) {
return notFound();
}
const authHeaders: Record<string, string> = {};
for (const { connection } of repo.connections) {
try {
if (connection.connectionType === 'github') {
const config = connection.config as unknown as GithubConnectionConfig;
if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
authHeaders['Authorization'] = `token ${token}`;
break;
}
} else if (connection.connectionType === 'gitlab') {
const config = connection.config as unknown as GitlabConnectionConfig;
if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
authHeaders['PRIVATE-TOKEN'] = token;
break;
}
} else if (connection.connectionType === 'gitea') {
const config = connection.config as unknown as GiteaConnectionConfig;
if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
authHeaders['Authorization'] = `token ${token}`;
break;
}
}
} catch (error) {
logger.warn(`Failed to get token for connection ${connection.id}:`, error);
}
}
try {
const response = await fetch(repo.imageUrl, {
headers: authHeaders,
}); });
if (!repo || !repo.imageUrl) { if (!response.ok) {
logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
return notFound(); return notFound();
} }
const authHeaders: Record<string, string> = {}; const imageBuffer = await response.arrayBuffer();
for (const { connection } of repo.connections) { return imageBuffer;
try { } catch (error) {
if (connection.connectionType === 'github') { logger.error(`Error proxying image for repo ${repoId}:`, error);
const config = connection.config as unknown as GithubConnectionConfig; return notFound();
if (config.token) { }
const token = await getTokenFromConfig(config.token, connection.orgId, prisma); })
authHeaders['Authorization'] = `token ${token}`;
break;
}
} else if (connection.connectionType === 'gitlab') {
const config = connection.config as unknown as GitlabConnectionConfig;
if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
authHeaders['PRIVATE-TOKEN'] = token;
break;
}
} else if (connection.connectionType === 'gitea') {
const config = connection.config as unknown as GiteaConnectionConfig;
if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
authHeaders['Authorization'] = `token ${token}`;
break;
}
}
} catch (error) {
logger.warn(`Failed to get token for connection ${connection.id}:`, error);
}
}
try {
const response = await fetch(repo.imageUrl, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
return notFound();
}
const imageBuffer = await response.arrayBuffer();
return imageBuffer;
} catch (error) {
logger.error(`Error proxying image for repo ${repoId}:`, error);
return notFound();
}
}, /* minRequiredRole = */ OrgRole.GUEST);
}, /* allowAnonymousAccess = */ true);
}); });
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => { export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {

View file

@ -20,7 +20,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }:
repository: repoName, repository: repoName,
branch: revisionName, branch: revisionName,
}, domain), }, domain),
getRepoInfoByName(repoName, domain), getRepoInfoByName(repoName),
]); ]);
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) { if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {

View file

@ -10,17 +10,16 @@ interface TreePreviewPanelProps {
path: string; path: string;
repoName: string; repoName: string;
revisionName?: string; revisionName?: string;
domain: string;
} }
export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => { export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePreviewPanelProps) => {
const [repoInfoResponse, folderContentsResponse] = await Promise.all([ const [repoInfoResponse, folderContentsResponse] = await Promise.all([
getRepoInfoByName(repoName, domain), getRepoInfoByName(repoName),
getFolderContents({ getFolderContents({
repoName, repoName,
revisionName: revisionName ?? 'HEAD', revisionName: revisionName ?? 'HEAD',
path, path,
}, domain) })
]); ]);
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) { if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {

View file

@ -42,7 +42,6 @@ export default async function BrowsePage(props: BrowsePageProps) {
path={path} path={path}
repoName={repoName} repoName={repoName}
revisionName={revisionName} revisionName={revisionName}
domain={domain}
/> />
)} )}
</Suspense> </Suspense>

View file

@ -6,7 +6,6 @@ import { useHotkeys } from "react-hotkeys-hook";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils"; import { unwrapServiceError } from "@/lib/utils";
import { FileTreeItem, getFiles } from "@/features/fileTree/actions"; import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation"; import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState"; import { useBrowseState } from "../hooks/useBrowseState";
@ -28,7 +27,6 @@ type SearchResult = {
export const FileSearchCommandDialog = () => { export const FileSearchCommandDialog = () => {
const { repoName, revisionName } = useBrowseParams(); const { repoName, revisionName } = useBrowseParams();
const domain = useDomain();
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState(); const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
const commandListRef = useRef<HTMLDivElement>(null); const commandListRef = useRef<HTMLDivElement>(null);
@ -57,8 +55,8 @@ export const FileSearchCommandDialog = () => {
}, [isFileSearchOpen]); }, [isFileSearchOpen]);
const { data: files, isLoading, isError } = useQuery({ const { data: files, isLoading, isError } = useQuery({
queryKey: ['files', repoName, revisionName, domain], queryKey: ['files', repoName, revisionName],
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' })),
enabled: isFileSearchOpen, enabled: isFileSearchOpen,
}); });

View file

@ -8,7 +8,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { experimental_addGithubRepositoryByUrl } from "@/actions"; import { experimental_addGithubRepositoryByUrl } from "@/actions";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -37,7 +36,6 @@ const formSchema = z.object({
}); });
export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => { export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
const domain = useDomain();
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
@ -52,7 +50,7 @@ export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialo
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim(), domain); const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim());
if (isServiceError(result)) { if (isServiceError(result)) {
toast({ toast({
title: "Error adding repository", title: "Error adding repository",

View file

@ -3,18 +3,18 @@ import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
export async function GET( export async function GET(
request: NextRequest, _request: NextRequest,
props: { params: Promise<{ domain: string; repoId: string }> } props: { params: Promise<{ domain: string; repoId: string }> }
) { ) {
const params = await props.params; const params = await props.params;
const { domain, repoId } = params; const { repoId } = params;
const repoIdNum = parseInt(repoId); const repoIdNum = parseInt(repoId);
if (isNaN(repoIdNum)) { if (isNaN(repoIdNum)) {
return new Response("Invalid repo ID", { status: 400 }); return new Response("Invalid repo ID", { status: 400 });
} }
const result = await getRepoImage(repoIdNum, domain); const result = await getRepoImage(repoIdNum);
if (isServiceError(result)) { if (isServiceError(result)) {
return new Response(result.message, { status: result.statusCode }); return new Response(result.message, { status: result.statusCode });
} }

View file

@ -1,13 +1,13 @@
'use server'; 'use server';
import { sew, withAuth, withOrgMembership } from '@/actions'; import { sew } from '@/actions';
import { env } from '@/env.mjs'; import { env } from '@/env.mjs';
import { OrgRole, Repo } from '@sourcebot/db';
import { prisma } from '@/prisma';
import { notFound, unexpectedError } from '@/lib/serviceError'; import { notFound, unexpectedError } from '@/lib/serviceError';
import { simpleGit } from 'simple-git'; import { withOptionalAuthV2 } from '@/withAuthV2';
import path from 'path'; import { Repo } from '@sourcebot/db';
import { createLogger } from '@sourcebot/logger'; import { createLogger } from '@sourcebot/logger';
import path from 'path';
import { simpleGit } from 'simple-git';
const logger = createLogger('file-tree'); const logger = createLogger('file-tree');
@ -25,209 +25,182 @@ export type FileTreeNode = FileTreeItem & {
* Returns the tree of files (blobs) and directories (trees) for a given repository, * Returns the tree of files (blobs) and directories (trees) for a given repository,
* at a given revision. * at a given revision.
*/ */
export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() => export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const { repoName, revisionName } = params;
const { repoName, revisionName } = params; const repo = await prisma.repo.findFirst({
const repo = await prisma.repo.findFirst({ where: {
where: { name: repoName,
name: repoName, orgId: org.id,
orgId: org.id, },
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { });
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
},
});
if (!repo) { if (!repo) {
return notFound(); return notFound();
} }
const { path: repoPath } = getRepoPath(repo); const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath); const git = simpleGit().cwd(repoPath);
let result: string; let result: string;
try { try {
result = await git.raw([ result = await git.raw([
'ls-tree', 'ls-tree',
revisionName, revisionName,
// recursive // recursive
'-r', '-r',
// include trees when recursing // include trees when recursing
'-t', '-t',
// format as output as {type},{path} // format as output as {type},{path}
'--format=%(objecttype),%(path)', '--format=%(objecttype),%(path)',
]); ]);
} catch (error) { } catch (error) {
logger.error('git ls-tree failed.', { error }); logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.'); return unexpectedError('git ls-tree command failed.');
} }
const lines = result.split('\n').filter(line => line.trim()); const lines = result.split('\n').filter(line => line.trim());
const flatList = lines.map(line => {
const [type, path] = line.split(',');
return {
type,
path,
}
});
const tree = buildFileTree(flatList);
const flatList = lines.map(line => {
const [type, path] = line.split(',');
return { return {
tree, type,
path,
} }
});
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) const tree = buildFileTree(flatList);
);
return {
tree,
}
}));
/** /**
* Returns the contents of a folder at a given path in a given repository, * Returns the contents of a folder at a given path in a given repository,
* at a given revision. * at a given revision.
*/ */
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() => export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }) => sew(() =>
withAuth((userId) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const { repoName, revisionName, path } = params;
const { repoName, revisionName, path } = params; const repo = await prisma.repo.findFirst({
const repo = await prisma.repo.findFirst({ where: {
where: { name: repoName,
name: repoName, orgId: org.id,
orgId: org.id, },
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { });
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
},
});
if (!repo) { if (!repo) {
return notFound(); return notFound();
}
const { path: repoPath } = getRepoPath(repo);
// @note: we don't allow directory traversal
// or null bytes in the path.
if (path.includes('..') || path.includes('\0')) {
return notFound();
}
// Normalize the path by...
let normalizedPath = path;
// ... adding a trailing slash if it doesn't have one.
// This is important since ls-tree won't return the contents
// of a directory if it doesn't have a trailing slash.
if (!normalizedPath.endsWith('/')) {
normalizedPath = `${normalizedPath}/`;
}
// ... removing any leading slashes. This is needed since
// the path is relative to the repository's root, so we
// need a relative path.
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.slice(1);
}
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// format as output as {type},{path}
'--format=%(objecttype),%(path)',
...(normalizedPath.length === 0 ? [] : [normalizedPath]),
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const lines = result.split('\n').filter(line => line.trim());
const contents: FileTreeItem[] = lines.map(line => {
const [type, path] = line.split(',');
const name = path.split('/').pop() ?? '';
return {
type,
path,
name,
} }
});
const { path: repoPath } = getRepoPath(repo); return contents;
}));
// @note: we don't allow directory traversal export const getFiles = async (params: { repoName: string, revisionName: string }) => sew(() =>
// or null bytes in the path. withOptionalAuthV2(async ({ org, prisma }) => {
if (path.includes('..') || path.includes('\0')) { const { repoName, revisionName } = params;
return notFound();
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});
if (!repo) {
return notFound();
}
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// recursive
'-r',
// only return the names of the files
'--name-only',
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const paths = result.split('\n').filter(line => line.trim());
const files: FileTreeItem[] = paths.map(path => {
const name = path.split('/').pop() ?? '';
return {
type: 'blob',
path,
name,
} }
});
// Normalize the path by... return files;
let normalizedPath = path;
// ... adding a trailing slash if it doesn't have one. }));
// This is important since ls-tree won't return the contents
// of a directory if it doesn't have a trailing slash.
if (!normalizedPath.endsWith('/')) {
normalizedPath = `${normalizedPath}/`;
}
// ... removing any leading slashes. This is needed since
// the path is relative to the repository's root, so we
// need a relative path.
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.slice(1);
}
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// format as output as {type},{path}
'--format=%(objecttype),%(path)',
...(normalizedPath.length === 0 ? [] : [normalizedPath]),
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const lines = result.split('\n').filter(line => line.trim());
const contents: FileTreeItem[] = lines.map(line => {
const [type, path] = line.split(',');
const name = path.split('/').pop() ?? '';
return {
type,
path,
name,
}
});
return contents;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
);
export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const { repoName, revisionName } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
},
});
if (!repo) {
return notFound();
}
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// recursive
'-r',
// only return the names of the files
'--name-only',
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const paths = result.split('\n').filter(line => line.trim());
const files: FileTreeItem[] = paths.map(path => {
const name = path.split('/').pop() ?? '';
return {
type: 'blob',
path,
name,
}
});
return files;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
);
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
const root: FileTreeNode = { const root: FileTreeNode = {

View file

@ -3,7 +3,6 @@
import { getTree } from "../actions"; import { getTree } from "../actions";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils"; import { unwrapServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
import { ResizablePanel } from "@/components/ui/resizable"; import { ResizablePanel } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
@ -41,17 +40,16 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
updateBrowseState, updateBrowseState,
} = useBrowseState(); } = useBrowseState();
const domain = useDomain();
const { repoName, revisionName, path } = useBrowseParams(); const { repoName, revisionName, path } = useBrowseParams();
const fileTreePanelRef = useRef<ImperativePanelHandle>(null); const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
const { data, isPending, isError } = useQuery({ const { data, isPending, isError } = useQuery({
queryKey: ['tree', repoName, revisionName, domain], queryKey: ['tree', repoName, revisionName],
queryFn: () => unwrapServiceError( queryFn: () => unwrapServiceError(
getTree({ getTree({
repoName, repoName,
revisionName: revisionName ?? 'HEAD', revisionName: revisionName ?? 'HEAD',
}, domain) })
), ),
}); });

View file

@ -4,14 +4,14 @@ import { env } from "@/env.mjs";
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError"; import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
import { isServiceError } from "../../lib/utils"; import { isServiceError } from "../../lib/utils";
import { zoektFetch } from "./zoektClient"; import { zoektFetch } from "./zoektClient";
import { prisma } from "@/prisma";
import { ErrorCode } from "../../lib/errorCodes"; import { ErrorCode } from "../../lib/errorCodes";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { zoektSearchResponseSchema } from "./zoektSchema"; import { zoektSearchResponseSchema } from "./zoektSchema";
import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { SearchRequest, SearchResponse, SourceRange } from "./types";
import { OrgRole, Repo } from "@sourcebot/db"; import { PrismaClient, Repo } from "@sourcebot/db";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew } from "@/actions";
import { base64Decode } from "@sourcebot/shared"; import { base64Decode } from "@sourcebot/shared";
import { withOptionalAuthV2 } from "@/withAuthV2";
// List of supported query prefixes in zoekt. // List of supported query prefixes in zoekt.
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417 // @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
@ -36,7 +36,7 @@ enum zoektPrefixes {
reposet = "reposet:", reposet = "reposet:",
} }
const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => { const transformZoektQuery = async (query: string, orgId: number, prisma: PrismaClient): Promise<string | ServiceError> => {
const prevQueryParts = query.split(" "); const prevQueryParts = query.split(" ");
const newQueryParts = []; const newQueryParts = [];
@ -127,235 +127,219 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
return encodeURI(url + optionalQueryParams); return encodeURI(url + optionalQueryParams);
} }
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() => export const search = async ({ query, matches, contextLines, whole }: SearchRequest) => sew(() =>
withAuth((userId, _apiKeyHash) => withOptionalAuthV2(async ({ org, prisma }) => {
withOrgMembership(userId, domain, async ({ org }) => { const transformedQuery = await transformZoektQuery(query, org.id, prisma);
const transformedQuery = await transformZoektQuery(query, org.id); if (isServiceError(transformedQuery)) {
if (isServiceError(transformedQuery)) { return transformedQuery;
return transformedQuery; }
query = transformedQuery;
const isBranchFilteringEnabled = (
query.includes(zoektPrefixes.branch) ||
query.includes(zoektPrefixes.branchShort)
);
// We only want to show matches for the default branch when
// the user isn't explicitly filtering by branch.
if (!isBranchFilteringEnabled) {
query = query.concat(` branch:HEAD`);
}
const body = JSON.stringify({
q: query,
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
opts: {
ChunkMatches: true,
MaxMatchDisplayCount: matches,
NumContextLines: contextLines,
Whole: !!whole,
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT,
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT,
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds
} }
query = transformedQuery; });
const isBranchFilteringEnabled = ( let header: Record<string, string> = {};
query.includes(zoektPrefixes.branch) || header = {
query.includes(zoektPrefixes.branchShort) "X-Tenant-ID": org.id.toString()
); };
// We only want to show matches for the default branch when const searchResponse = await zoektFetch({
// the user isn't explicitly filtering by branch. path: "/api/search",
if (!isBranchFilteringEnabled) { body,
query = query.concat(` branch:HEAD`); header,
} method: "POST",
});
const body = JSON.stringify({ if (!searchResponse.ok) {
q: query, return invalidZoektResponse(searchResponse);
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892 }
opts: {
ChunkMatches: true, const searchBody = await searchResponse.json();
MaxMatchDisplayCount: matches,
NumContextLines: contextLines, const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
Whole: !!whole, // @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT, // which corresponds to the `id` in the Repo table. In order to efficiently fetch repository
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT, // metadata when transforming (potentially thousands) of file matches, we aggregate a unique
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds // set of repository ids* and map them to their corresponding Repo record.
//
// *Q: Why is `RepositoryID` optional? And why are we falling back to `Repository`?
// A: Prior to this change, the repository id was not plumbed into zoekt, so RepositoryID was
// always undefined. To make this a non-breaking change, we fallback to using the repository's name
// (`Repository`) as the identifier in these cases. This is not guaranteed to be unique, but in
// practice it is since the repository name includes the host and path (e.g., 'github.com/org/repo',
// 'gitea.com/org/repo', etc.).
//
// Note: When a repository is re-indexed (every hour) this ID will be populated.
// @see: https://github.com/sourcebot-dev/zoekt/pull/6
const repoIdentifiers = new Set(Result.Files?.map((file) => file.RepositoryID ?? file.Repository) ?? []);
const repos = new Map<string | number, Repo>();
(await prisma.repo.findMany({
where: {
id: {
in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"),
},
orgId: org.id,
} }
}); })).forEach(repo => repos.set(repo.id, repo));
let header: Record<string, string> = {}; (await prisma.repo.findMany({
header = { where: {
"X-Tenant-ID": org.id.toString() name: {
}; in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"),
},
orgId: org.id,
}
})).forEach(repo => repos.set(repo.name, repo));
const searchResponse = await zoektFetch({ const files = Result.Files?.map((file) => {
path: "/api/search", const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
body,
header,
method: "POST",
});
if (!searchResponse.ok) { const webUrl = (() => {
return invalidZoektResponse(searchResponse); const template: string | undefined = Result.RepoURLs[file.Repository];
} if (!template) {
const searchBody = await searchResponse.json();
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
// @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
// which corresponds to the `id` in the Repo table. In order to efficiently fetch repository
// metadata when transforming (potentially thousands) of file matches, we aggregate a unique
// set of repository ids* and map them to their corresponding Repo record.
//
// *Q: Why is `RepositoryID` optional? And why are we falling back to `Repository`?
// A: Prior to this change, the repository id was not plumbed into zoekt, so RepositoryID was
// always undefined. To make this a non-breaking change, we fallback to using the repository's name
// (`Repository`) as the identifier in these cases. This is not guaranteed to be unique, but in
// practice it is since the repository name includes the host and path (e.g., 'github.com/org/repo',
// 'gitea.com/org/repo', etc.).
//
// Note: When a repository is re-indexed (every hour) this ID will be populated.
// @see: https://github.com/sourcebot-dev/zoekt/pull/6
const repoIdentifiers = new Set(Result.Files?.map((file) => file.RepositoryID ?? file.Repository) ?? []);
const repos = new Map<string | number, Repo>();
(await prisma.repo.findMany({
where: {
id: {
in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"),
},
orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId,
}
}
} : {})
}
})).forEach(repo => repos.set(repo.id, repo));
(await prisma.repo.findMany({
where: {
name: {
in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"),
},
orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId,
}
}
} : {})
}
})).forEach(repo => repos.set(repo.name, repo));
const files = Result.Files?.map((file) => {
const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
const webUrl = (() => {
const template: string | undefined = Result.RepoURLs[file.Repository];
if (!template) {
return undefined;
}
// If there are multiple branches pointing to the same revision of this file, it doesn't
// matter which branch we use here, so use the first one.
const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD";
return getFileWebUrl(template, branch, file.FileName);
})();
const identifier = file.RepositoryID ?? file.Repository;
const repo = repos.get(identifier);
// This can happen if the user doesn't have access to the repository.
if (!repo) {
return undefined; return undefined;
} }
return { // If there are multiple branches pointing to the same revision of this file, it doesn't
fileName: { // matter which branch we use here, so use the first one.
text: file.FileName, const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD";
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({ return getFileWebUrl(template, branch, file.FileName);
start: { })();
byteOffset: range.Start.ByteOffset,
column: range.Start.Column, const identifier = file.RepositoryID ?? file.Repository;
lineNumber: range.Start.LineNumber, const repo = repos.get(identifier);
},
end: { // This can happen if the user doesn't have access to the repository.
byteOffset: range.End.ByteOffset, if (!repo) {
column: range.End.Column, return undefined;
lineNumber: range.End.LineNumber, }
}
})) : [],
},
repository: repo.name,
repositoryId: repo.id,
webUrl: webUrl,
language: file.Language,
chunks: file.ChunkMatches
.filter((chunk) => !chunk.FileName) // Filter out filename chunks.
.map((chunk) => {
return {
content: base64Decode(chunk.Content),
matchRanges: chunk.Ranges.map((range) => ({
start: {
byteOffset: range.Start.ByteOffset,
column: range.Start.Column,
lineNumber: range.Start.LineNumber,
},
end: {
byteOffset: range.End.ByteOffset,
column: range.End.Column,
lineNumber: range.End.LineNumber,
}
}) satisfies SourceRange),
contentStart: {
byteOffset: chunk.ContentStart.ByteOffset,
column: chunk.ContentStart.Column,
lineNumber: chunk.ContentStart.LineNumber,
},
symbols: chunk.SymbolInfo?.map((symbol) => {
return {
symbol: symbol.Sym,
kind: symbol.Kind,
parent: symbol.Parent.length > 0 ? {
symbol: symbol.Parent,
kind: symbol.ParentKind,
} : undefined,
}
}) ?? undefined,
}
}),
branches: file.Branches,
content: file.Content ? base64Decode(file.Content) : undefined,
}
}).filter((file) => file !== undefined) ?? [];
return { return {
zoektStats: { fileName: {
duration: Result.Duration, text: file.FileName,
fileCount: Result.FileCount, matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({
matchCount: Result.MatchCount, start: {
filesSkipped: Result.FilesSkipped, byteOffset: range.Start.ByteOffset,
contentBytesLoaded: Result.ContentBytesLoaded, column: range.Start.Column,
indexBytesLoaded: Result.IndexBytesLoaded, lineNumber: range.Start.LineNumber,
crashes: Result.Crashes, },
shardFilesConsidered: Result.ShardFilesConsidered, end: {
filesConsidered: Result.FilesConsidered, byteOffset: range.End.ByteOffset,
filesLoaded: Result.FilesLoaded, column: range.End.Column,
shardsScanned: Result.ShardsScanned, lineNumber: range.End.LineNumber,
shardsSkipped: Result.ShardsSkipped, }
shardsSkippedFilter: Result.ShardsSkippedFilter, })) : [],
ngramMatches: Result.NgramMatches,
ngramLookups: Result.NgramLookups,
wait: Result.Wait,
matchTreeConstruction: Result.MatchTreeConstruction,
matchTreeSearch: Result.MatchTreeSearch,
regexpsConsidered: Result.RegexpsConsidered,
flushReason: Result.FlushReason,
}, },
files, repository: repo.name,
repositoryInfo: Array.from(repos.values()).map((repo) => ({ repositoryId: repo.id,
id: repo.id, webUrl: webUrl,
codeHostType: repo.external_codeHostType, language: file.Language,
name: repo.name, chunks: file.ChunkMatches
displayName: repo.displayName ?? undefined, .filter((chunk) => !chunk.FileName) // Filter out filename chunks.
webUrl: repo.webUrl ?? undefined, .map((chunk) => {
})), return {
isBranchFilteringEnabled: isBranchFilteringEnabled, content: base64Decode(chunk.Content),
stats: { matchRanges: chunk.Ranges.map((range) => ({
matchCount: files.reduce( start: {
(acc, file) => byteOffset: range.Start.ByteOffset,
acc + file.chunks.reduce( column: range.Start.Column,
(acc, chunk) => acc + chunk.matchRanges.length, lineNumber: range.Start.LineNumber,
0, },
), end: {
0, byteOffset: range.End.ByteOffset,
) column: range.End.Column,
} lineNumber: range.End.LineNumber,
} satisfies SearchResponse; }
}); }) satisfies SourceRange),
contentStart: {
byteOffset: chunk.ContentStart.ByteOffset,
column: chunk.ContentStart.Column,
lineNumber: chunk.ContentStart.LineNumber,
},
symbols: chunk.SymbolInfo?.map((symbol) => {
return {
symbol: symbol.Sym,
kind: symbol.Kind,
parent: symbol.Parent.length > 0 ? {
symbol: symbol.Parent,
kind: symbol.ParentKind,
} : undefined,
}
}) ?? undefined,
}
}),
branches: file.Branches,
content: file.Content ? base64Decode(file.Content) : undefined,
}
}).filter((file) => file !== undefined) ?? [];
return parser.parseAsync(searchBody); return {
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) zoektStats: {
); duration: Result.Duration,
fileCount: Result.FileCount,
matchCount: Result.MatchCount,
filesSkipped: Result.FilesSkipped,
contentBytesLoaded: Result.ContentBytesLoaded,
indexBytesLoaded: Result.IndexBytesLoaded,
crashes: Result.Crashes,
shardFilesConsidered: Result.ShardFilesConsidered,
filesConsidered: Result.FilesConsidered,
filesLoaded: Result.FilesLoaded,
shardsScanned: Result.ShardsScanned,
shardsSkipped: Result.ShardsSkipped,
shardsSkippedFilter: Result.ShardsSkippedFilter,
ngramMatches: Result.NgramMatches,
ngramLookups: Result.NgramLookups,
wait: Result.Wait,
matchTreeConstruction: Result.MatchTreeConstruction,
matchTreeSearch: Result.MatchTreeSearch,
regexpsConsidered: Result.RegexpsConsidered,
flushReason: Result.FlushReason,
},
files,
repositoryInfo: Array.from(repos.values()).map((repo) => ({
id: repo.id,
codeHostType: repo.external_codeHostType,
name: repo.name,
displayName: repo.displayName ?? undefined,
webUrl: repo.webUrl ?? undefined,
})),
isBranchFilteringEnabled: isBranchFilteringEnabled,
stats: {
matchCount: files.reduce(
(acc, file) =>
acc + file.chunks.reduce(
(acc, chunk) => acc + chunk.matchRanges.length,
0,
),
0,
)
}
} satisfies SearchResponse;
});
return parser.parseAsync(searchBody);
}));

View file

@ -1,7 +1,48 @@
import 'server-only'; import 'server-only';
import { PrismaClient } from "@sourcebot/db"; import { env } from "@/env.mjs";
import { Prisma, PrismaClient } from "@sourcebot/db";
// @see: https://authjs.dev/getting-started/adapters/prisma // @see: https://authjs.dev/getting-started/adapters/prisma
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
// @NOTE: In almost all cases, the userScopedPrismaClientExtension should be used
// (since actions & queries are scoped to a particular user). There are some exceptions
// (e.g., in initialize.ts).
//
// @todo: we can mark this as `__unsafePrisma` in the future once we've migrated
// all of the actions & queries to use the userScopedPrismaClientExtension to avoid
// accidental misuse.
export const prisma = globalForPrisma.prisma || new PrismaClient() export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
/**
* Creates a prisma client extension that scopes queries to striclty information
* a given user should be able to access.
*/
export const userScopedPrismaClientExtension = (userId?: string) => {
return Prisma.defineExtension(
(prisma) => {
return prisma.$extends({
query: {
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
repo: {
$allOperations({ args, query }) {
if ('where' in args) {
args.where = {
...args.where,
permittedUsers: {
some: {
userId,
}
}
}
}
return query(args);
}
}
} : {})
}
})
})
}

View file

@ -1,6 +1,6 @@
import { prisma } from "@/prisma"; import { prisma as __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma";
import { hashSecret } from "@sourcebot/crypto"; import { hashSecret } from "@sourcebot/crypto";
import { ApiKey, Org, OrgRole, User } from "@sourcebot/db"; import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "./auth"; import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError"; import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
@ -14,12 +14,14 @@ interface OptionalAuthContext {
user?: User; user?: User;
org: Org; org: Org;
role: OrgRole; role: OrgRole;
prisma: PrismaClient;
} }
interface RequiredAuthContext { interface RequiredAuthContext {
user: User; user: User;
org: Org; org: Org;
role: Omit<OrgRole, 'GUEST'>; role: Omit<OrgRole, 'GUEST'>;
prisma: PrismaClient;
} }
export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise<T>) => { export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise<T>) => {
@ -29,13 +31,13 @@ export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise
return authContext; return authContext;
} }
const { user, org, role } = authContext; const { user, org, role, prisma } = authContext;
if (!user || role === OrgRole.GUEST) { if (!user || role === OrgRole.GUEST) {
return notAuthenticated(); return notAuthenticated();
} }
return fn({ user, org, role }); return fn({ user, org, role, prisma });
}; };
export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => { export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => {
@ -44,7 +46,7 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
return authContext; return authContext;
} }
const { user, org, role } = authContext; const { user, org, role, prisma } = authContext;
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
const orgMetadata = getOrgMetadata(org); const orgMetadata = getOrgMetadata(org);
@ -61,13 +63,13 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
return notAuthenticated(); return notAuthenticated();
} }
return fn({ user, org, role }); return fn({ user, org, role, prisma });
}; };
export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => { export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => {
const user = await getAuthenticatedUser(); const user = await getAuthenticatedUser();
const org = await prisma.org.findUnique({ const org = await __unsafePrisma.org.findUnique({
where: { where: {
id: SINGLE_TENANT_ORG_ID, id: SINGLE_TENANT_ORG_ID,
} }
@ -77,7 +79,7 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
return notFound("Organization not found"); return notFound("Organization not found");
} }
const membership = user ? await prisma.userToOrg.findUnique({ const membership = user ? await __unsafePrisma.userToOrg.findUnique({
where: { where: {
orgId_userId: { orgId_userId: {
orgId: org.id, orgId: org.id,
@ -86,10 +88,13 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
}, },
}) : null; }) : null;
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(user?.id)) as PrismaClient;
return { return {
user: user ?? undefined, user: user ?? undefined,
org, org,
role: membership?.role ?? OrgRole.GUEST, role: membership?.role ?? OrgRole.GUEST,
prisma,
}; };
}; };
@ -98,7 +103,7 @@ export const getAuthenticatedUser = async () => {
const session = await auth(); const session = await auth();
if (session) { if (session) {
const userId = session.user.id; const userId = session.user.id;
const user = await prisma.user.findUnique({ const user = await __unsafePrisma.user.findUnique({
where: { where: {
id: userId, id: userId,
} }
@ -116,7 +121,7 @@ export const getAuthenticatedUser = async () => {
} }
// Attempt to find the user associated with this api key. // Attempt to find the user associated with this api key.
const user = await prisma.user.findUnique({ const user = await __unsafePrisma.user.findUnique({
where: { where: {
id: apiKey.createdById, id: apiKey.createdById,
}, },
@ -127,7 +132,7 @@ export const getAuthenticatedUser = async () => {
} }
// Update the last used at timestamp for this api key. // Update the last used at timestamp for this api key.
await prisma.apiKey.update({ await __unsafePrisma.apiKey.update({
where: { where: {
hash: apiKey.hash, hash: apiKey.hash,
}, },
@ -152,7 +157,7 @@ const getVerifiedApiObject = async (apiKeyString: string): Promise<ApiKey | unde
} }
const hash = hashSecret(parts[1]); const hash = hashSecret(parts[1]);
const apiKey = await prisma.apiKey.findUnique({ const apiKey = await __unsafePrisma.apiKey.findUnique({
where: { where: {
hash, hash,
}, },