diff --git a/CHANGELOG.md b/CHANGELOG.md index 33499cb0..eac72664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 1d95f0fb..5157c022 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -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; diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 2a6a185e..abccd406 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -8,6 +8,8 @@ "postinstall": "yarn build" }, "dependencies": { + "@sourcebot/db": "*", + "@sourcebot/schemas": "*", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 7e5f4196..8f6ca211 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -79,7 +79,7 @@ export function verifySignature(data: string, signature: string, publicKeyPath: publicKey = fs.readFileSync(publicKeyPath, 'utf8'); publicKeyCache.set(publicKeyPath, publicKey); } - + // Convert base64url signature to base64 if needed const base64Signature = signature.replace(/-/g, '+').replace(/_/g, '/'); const paddedSignature = base64Signature + '='.repeat((4 - base64Signature.length % 4) % 4); @@ -91,3 +91,5 @@ export function verifySignature(data: string, signature: string, publicKeyPath: return false; } } + +export { getTokenFromConfig } from './tokenUtils.js'; \ No newline at end of file diff --git a/packages/crypto/src/tokenUtils.ts b/packages/crypto/src/tokenUtils.ts new file mode 100644 index 00000000..be5a064d --- /dev/null +++ b/packages/crypto/src/tokenUtils.ts @@ -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'); + } +}; \ No newline at end of file diff --git a/packages/crypto/tsconfig.json b/packages/crypto/tsconfig.json index 39b3533d..b364feca 100644 --- a/packages/crypto/tsconfig.json +++ b/packages/crypto/tsconfig.json @@ -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"] diff --git a/packages/web/public/logo_512.png b/packages/web/public/logo_512.png new file mode 100644 index 00000000..6028d763 Binary files /dev/null and b/packages/web/public/logo_512.png differ diff --git a/packages/web/public/manifest.json b/packages/web/public/manifest.json new file mode 100644 index 00000000..acfd2f05 --- /dev/null +++ b/packages/web/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Sourcebot", + "short_name": "Sourcebot", + "display": "standalone", + "start_url": "/", + "icons": [ + { + "src": "/logo_512.png", + "sizes": "512x512", + "type": "image/png" + } + ] + } + \ No newline at end of file diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 9cd3ff55..9f60373a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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 => 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 = {}; + 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 /////// diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx index 6fdc1c51..e2f8c55c 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx @@ -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 (
- {imageUrl ? ( + {imageSrc ? ( {name}[] => [
{repo.imageUrl ? ( {`${repo.name} { 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, diff --git a/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts new file mode 100644 index 00000000..6d967adf --- /dev/null +++ b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts @@ -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', + }, + }); +} \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 55925c89..9b213695 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -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({ diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 4e416ac7..6d3913ef 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -408,4 +408,33 @@ export const requiredQueryParamGuard = (request: NextRequest, param: string): Se }; } return value; -} \ No newline at end of file +} + +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; + } +}; \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index d20c6c5c..b373ff2f 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -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).*)', ], } diff --git a/yarn.lock b/yarn.lock index b379f320..76773d74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: