Fix repo images in authed instance case and add manifest json (#332)

* wip fix repo images

* fix config imports

* add manifest json

* more logos for manifest

* add properly padded icon

* support old gitlab token case, simplify getImage action, feedback

* add changelog entry

* fix build error
This commit is contained in:
Michael Sukkarieh 2025-06-06 10:50:13 -07:00 committed by GitHub
parent 397262ecf7
commit 8dc41a22b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 216 additions and 50 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331)
- Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332)
- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335)
## [4.1.1] - 2025-06-03

View file

@ -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";
@ -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;

View file

@ -8,6 +8,8 @@
"postinstall": "yarn build"
},
"dependencies": {
"@sourcebot/db": "*",
"@sourcebot/schemas": "*",
"dotenv": "^16.4.5"
},
"devDependencies": {

View file

@ -91,3 +91,5 @@ export function verifySignature(data: string, signature: string, publicKeyPath:
return false;
}
}
export { getTokenFromConfig } from './tokenUtils.js';

View file

@ -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');
}
};

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"lib": ["ES6"],
"target": "ES2022",
"module": "Node16",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
@ -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"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,14 @@
{
"name": "Sourcebot",
"short_name": "Sourcebot",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/logo_512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -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";
@ -1712,6 +1715,76 @@ export const getSearchContexts = async (domain: string) => sew(() =>
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
));
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => {
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();
}
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 (!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);
}, /* allowSingleTenantUnauthedAccess = */ true);
});
////// Helpers ///////

View file

@ -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";
@ -46,14 +46,16 @@ export const RepoListItem = ({
}
}, [status]);
const imageSrc = getRepoImageSrc(imageUrl, repoId, domain);
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}

View file

@ -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: {
@ -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}

View file

@ -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,
@ -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,

View file

@ -0,0 +1,27 @@
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 new Response(result, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
}

View file

@ -13,6 +13,7 @@ import { getEntitlements } from "@/features/entitlements/server";
export const metadata: Metadata = {
title: "Sourcebot",
description: "Sourcebot",
manifest: "/manifest.json",
};
export default function RootLayout({

View file

@ -409,3 +409,32 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se
}
return value;
}
export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, domain: string): string | undefined => {
if (!imageUrl) return undefined;
try {
const url = new URL(imageUrl);
// List of known public instances that don't require authentication
const publicHostnames = [
'github.com',
'gitlab.com',
'avatars.githubusercontent.com',
'gitea.com',
'bitbucket.org',
];
const isPublicInstance = publicHostnames.includes(url.hostname);
if (isPublicInstance) {
return imageUrl;
} else {
// Use the proxied route for self-hosted instances
return `/api/${domain}/repos/${repoId}/image`;
}
} catch {
// If URL parsing fails, use the original URL
return imageUrl;
}
};

View file

@ -33,6 +33,6 @@ export async function middleware(request: NextRequest) {
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: [
'/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|sb_logo_light_large.png|arrow.png|placeholder_avatar.png).*)',
'/((?!api|_next/static|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|manifest.json|logo_192.png|logo_512.png|sb_logo_light_large.png|arrow.png|placeholder_avatar.png).*)',
],
}

View file

@ -5803,13 +5803,15 @@ __metadata:
version: 0.0.0-use.local
resolution: "@sourcebot/crypto@workspace:packages/crypto"
dependencies:
"@sourcebot/db": "npm:*"
"@sourcebot/schemas": "npm:*"
"@types/node": "npm:^22.7.5"
dotenv: "npm:^16.4.5"
typescript: "npm:^5.7.3"
languageName: unknown
linkType: soft
"@sourcebot/db@workspace:*, @sourcebot/db@workspace:packages/db":
"@sourcebot/db@npm:*, @sourcebot/db@workspace:*, @sourcebot/db@workspace:packages/db":
version: 0.0.0-use.local
resolution: "@sourcebot/db@workspace:packages/db"
dependencies:
@ -5869,7 +5871,7 @@ __metadata:
languageName: unknown
linkType: soft
"@sourcebot/schemas@workspace:*, @sourcebot/schemas@workspace:packages/schemas":
"@sourcebot/schemas@npm:*, @sourcebot/schemas@workspace:*, @sourcebot/schemas@workspace:packages/schemas":
version: 0.0.0-use.local
resolution: "@sourcebot/schemas@workspace:packages/schemas"
dependencies: