Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
44 changes: 10 additions & 34 deletions packages/backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { Logger } from "winston";
import { AppContext } from "./types.js";
import path from 'path';
import { PrismaClient, Repo } from "@sourcebot/db";
import { decrypt } from "@sourcebot/crypto";
import { Token } from "@sourcebot/schemas/v3/shared.type";
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";

Expand All @@ -25,44 +24,21 @@ export const isRemotePath = (path: string) => {
return path.startsWith('https://') || path.startsWith('http://');
}

export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient, logger?: Logger) => {
if ('secret' in token) {
const secretKey = token.secret;
const secret = await db.secret.findUnique({
where: {
orgId_key: {
key: secretKey,
orgId
}
}
});

if (!secret) {
export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => {
try {
return await getTokenFromConfigBase(token, orgId, db);
} catch (error: unknown) {
if (error instanceof Error) {
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: `Secret with key ${secretKey} not found for org ${orgId}`,
message: error.message,
});
Sentry.captureException(e);
logger?.error(e.metadata.message);
logger?.error(error.message);
throw e;
}

const decryptedToken = decrypt(secret.iv, secret.encryptedValue);
return decryptedToken;
} else {
const envToken = process.env[token.env];
if (!envToken) {
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: `Environment variable ${token.env} not found.`,
});
Sentry.captureException(e);
logger?.error(e.metadata.message);
throw e;
}

return envToken;
throw error;
}
}

};

export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => {
let absolutePath = localPath;
Expand Down
2 changes: 2 additions & 0 deletions packages/crypto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"postinstall": "yarn build"
},
"dependencies": {
"@sourcebot/db": "*",
"@sourcebot/schemas": "*",
"dotenv": "^16.4.5"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions packages/crypto/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ export function decrypt(iv: string, encryptedText: string): string {

return decrypted;
}

// Export token utilities
export { getTokenFromConfig } from './tokenUtils.js';
33 changes: 33 additions & 0 deletions packages/crypto/src/tokenUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PrismaClient } from "@sourcebot/db";
import { Token } from "@sourcebot/schemas/v3/shared.type";
import { decrypt } from "./index.js";

export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient) => {
if ('secret' in token) {
const secretKey = token.secret;
const secret = await db.secret.findUnique({
where: {
orgId_key: {
key: secretKey,
orgId
}
}
});

if (!secret) {
throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`);
}

const decryptedToken = decrypt(secret.iv, secret.encryptedValue);
return decryptedToken;
} else if ('env' in token) {
const envToken = process.env[token.env];
if (!envToken) {
throw new Error(`Environment variable ${token.env} not found.`);
}

return envToken;
} else {
throw new Error('Invalid token configuration');
}
};
11 changes: 6 additions & 5 deletions packages/crypto/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"lib": ["ES6"],
"target": "ES2022",
"module": "Node16",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
Expand All @@ -11,11 +11,12 @@
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "node",
"moduleResolution": "Node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
Expand Down
Binary file added packages/web/public/logo_512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/web/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "Sourcebot",
"short_name": "Sourcebot",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/logo_512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

98 changes: 97 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { CodeHostType, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma";
import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs';
import { decrypt, encrypt, generateApiKey, hashSecret } from "@sourcebot/crypto";
import { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto";
import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.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 { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import Ajv from "ajv";
import { StatusCodes } from "http-status-codes";
import { cookies, headers } from "next/headers";
Expand Down Expand Up @@ -1712,6 +1715,99 @@ export const getSearchContexts = async (domain: string) => sew(() =>
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
));

export const getRepoImage = async (repoId: number, domain: string): Promise<Response | ServiceError> => sew(async () => {
Comment thread
msukkari marked this conversation as resolved.
Outdated
return await withAuth(async (userId) => {
return await withOrgMembership(userId, domain, async ({ org }) => {
const repo = await prisma.repo.findUnique({
where: {
id: repoId,
orgId: org.id,
},
include: {
connections: {
include: {
connection: true,
}
}
}
});

if (!repo || !repo.imageUrl) {
return notFound();
}

// Only proxy images from self-hosted instances that might require authentication
const imageUrl = new URL(repo.imageUrl);

const publicHostnames = [
'github.com',
'gitlab.com',
'avatars.githubusercontent.com',
'gitea.com',
'bitbucket.org',
];
const isPublicInstance = publicHostnames.includes(imageUrl.hostname);

if (isPublicInstance) {
return Response.redirect(repo.imageUrl);
}

let 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['Authorization'] = `Bearer ${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 contentType = response.headers.get('content-type') || 'image/png';
const imageBuffer = await response.arrayBuffer();

return new Response(imageBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
logger.error(`Error proxying image for repo ${repoId}:`, error);
return notFound();
}
}, /* minRequiredRole = */ OrgRole.GUEST);
}, /* allowSingleTenantUnauthedAccess = */ true);
});

////// Helpers ///////

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { getDisplayTime } from "@/lib/utils";
import { getDisplayTime, getRepoImageSrc } from "@/lib/utils";
import Image from "next/image";
import { StatusIcon } from "../../components/statusIcon";
import { RepoIndexingStatus } from "@sourcebot/db";
Expand Down Expand Up @@ -46,14 +46,16 @@ export const RepoListItem = ({
}
}, [status]);

const imageSrc = getRepoImageSrc(imageUrl, repoId, domain);
Comment thread
msukkari marked this conversation as resolved.

return (
<div
className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between"
>
<div className="flex flex-row items-center gap-2">
{imageUrl ? (
{imageSrc ? (
<Image
src={imageUrl}
src={imageSrc}
alt={name}
width={32}
height={32}
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/app/[domain]/repos/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { ArrowUpDown, ExternalLink, Clock, Loader2, CheckCircle2, XCircle, Trash
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { cn, getRepoImageSrc } from "@/lib/utils"
import { RepoIndexingStatus } from "@sourcebot/db";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { AddRepoButton } from "./addRepoButton"

export type RepositoryColumnInfo = {
repoId: number
name: string
imageUrl?: string
connections: {
Expand Down Expand Up @@ -112,7 +113,7 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
<div className="relative h-8 w-8 overflow-hidden rounded-md border bg-muted">
{repo.imageUrl ? (
<Image
src={repo.imageUrl || "/placeholder.svg"}
src={getRepoImageSrc(repo.imageUrl, repo.repoId, domain) || "/placeholder.svg"}
alt={`${repo.name} logo`}
width={32}
height={32}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/app/[domain]/repos/repositoryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const RepositoryTable = () => {

const tableRepos = useMemo(() => {
if (reposLoading) return Array(4).fill(null).map(() => ({
repoId: 0,
name: "",
connections: [],
repoIndexingStatus: RepoIndexingStatus.NEW,
Expand All @@ -35,6 +36,7 @@ export const RepositoryTable = () => {

if (!repos) return [];
return repos.map((repo): RepositoryColumnInfo => ({
repoId: repo.repoId,
name: repo.repoDisplayName ?? repo.repoName,
imageUrl: repo.imageUrl,
connections: repo.linkedConnections,
Expand Down
23 changes: 23 additions & 0 deletions packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getRepoImage } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";

export async function GET(
request: NextRequest,
{ params }: { params: { domain: string; repoId: string } }
) {
const { domain, repoId } = params;
const repoIdNum = parseInt(repoId);

if (isNaN(repoIdNum)) {
return new Response("Invalid repo ID", { status: 400 });
}

const result = await getRepoImage(repoIdNum, domain);

if (isServiceError(result)) {
return new Response(result.message, { status: result.statusCode });
}

return result;
}
3 changes: 3 additions & 0 deletions packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export default function RootLayout({
// @see : https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
suppressHydrationWarning
>
<head>
<link rel="manifest" href="/manifest.json" />
</head>
Comment thread
msukkari marked this conversation as resolved.
Outdated
<body>
<Toaster />
<SessionProvider>
Expand Down
Loading
Loading