diff --git a/.dockerignore b/.dockerignore index 318bd7d39..5baf6c890 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,54 @@ +# Dependencies & build outputs node_modules .next -.github +out +build +dist +coverage +.vitest + +# Source control & CI .git +.github .gitignore +.gitattributes +.husky -coverage +# IDE & Editor settings +.vscode +.idea +*.swp +*.swo +.DS_Store + +# Logs & debugging *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +eslint_report.json +eslint_errors.txt +tsc_report.txt -.env.local +# Environment files .env +.env*.local +.env.production +.env.development -Dockerfile +# Local Docker & documentation +Dockerfile* +docker-compose*.yml +.dockerignore README.md - -.vscode -.idea +docs +CHANGELOG.md +CODE_OF_CONDUCT.md +CONTRIBUTING.md +LICENSE +SECURITY.md +THEMES.md +THEME_DEVELOPMENT.md !.env.local.example \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 379bb3082..ac5983045 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,47 @@ -#base image +# Base stage for common dependencies and environment setup FROM node:22-alpine AS base +# Install libc6-compat for compatibility with native libraries on Alpine Linux +RUN apk add --no-cache libc6-compat WORKDIR /app +# Dependencies stage: Install dependencies cleanly based on package-lock.json FROM base AS deps COPY package*.json ./ RUN npm ci +# Builder stage: Build Next.js application in standalone mode FROM base AS builder +WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + RUN npm run build -#production image +# Runner stage: Production image containing only runtime dependencies FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public + +# Set up runtime permissions for Next.js cache directory +RUN mkdir .next && chown nextjs:nodejs .next + COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 -CMD [ "node", "server.js" ] \ No newline at end of file +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 5ec644faf..1a9cf8139 100644 --- a/README.md +++ b/README.md @@ -239,16 +239,23 @@ npm run dev Then visit: `http://localhost:3000/api/streak?user=YOUR_USERNAME` -## 🐳 Docker +## 🐳 Docker Multi-Stage Deployment -CommitPulse includes Docker support for consistent local development and production deployments. +CommitPulse features lightweight, optimized multi-stage Docker builds to streamline deployment and minimize container image size. + +### Build Stages Architecture + +- **`base`**: Node 22 Alpine base image with `libc6-compat` native library support. +- **`deps`**: Installs application dependencies cleanly via `npm ci`. +- **`builder`**: Compiles Next.js into a production standalone bundle (`.next/standalone`). +- **`runner`**: Minimal production environment running as an unprivileged non-root (`nextjs`) user listening on `0.0.0.0:3000`. ### Prerequisites -- Docker -- Docker Compose +- Docker Engine 20.10+ +- Docker Compose v2+ -### Local Development +### Local Development with Docker Compose 1. Copy the example environment file: @@ -256,7 +263,7 @@ CommitPulse includes Docker support for consistent local development and product cp .env.local.example .env.local ``` -2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and any optional integrations you plan to use). +2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and optional integrations). 3. Start the application and MongoDB: @@ -264,29 +271,24 @@ cp .env.local.example .env.local docker compose up --build ``` -The application will be available at: - -```text -http://localhost:3000 -``` - -MongoDB is automatically provisioned through Docker Compose. The `MONGODB_URI` is overridden to use the local MongoDB container, so no additional database configuration is required. +The application will be available at `http://localhost:3000`. MongoDB is automatically provisioned and linked (`MONGODB_URI=mongodb://mongodb:27017/commitpulse`). -### Production +### Multi-Stage Production Build -Build the production image: +Build the optimized production image using the `runner` target: ```bash -docker build -t commitpulse . +docker build --target runner -t commitpulse:latest . ``` -Run the container: +Run the production container: ```bash -docker run \ +docker run -d \ + --name commitpulse \ --env-file .env.local \ -p 3000:3000 \ - commitpulse + commitpulse:latest ``` ### 🌐 Deploy to Vercel diff --git a/app/(root)/dashboard/DashboardPageWrapper.tsx b/app/(root)/dashboard/DashboardPageWrapper.tsx index db4246562..31003e3f5 100644 --- a/app/(root)/dashboard/DashboardPageWrapper.tsx +++ b/app/(root)/dashboard/DashboardPageWrapper.tsx @@ -1,47 +1,13 @@ 'use client'; -import { useState, useSyncExternalStore } from 'react'; -import { createPortal } from 'react-dom'; -import LoadingScreen from './LoadingScreen'; - interface DashboardPageWrapperProps { children: React.ReactNode; } /** - * Wraps dashboard page content and guarantees LoadingScreen plays its full - * 3500ms animation regardless of how fast Next.js receives API data. - * - * The overlay is rendered via a React Portal directly into document.body — - * this means it escapes ALL stacking contexts (navbar, layout wrappers, etc.) - * and is unconditionally on top of everything on the page. + * Wraps dashboard page content. + * Now renders instantly to support React Suspense streaming. */ export default function DashboardPageWrapper({ children }: DashboardPageWrapperProps) { - const [ready, setReady] = useState(false); - - const mounted = useSyncExternalStore( - () => () => {}, - () => true, - () => false - ); - - return ( - <> - {/* Real page — renders immediately but stays invisible until animation ends */} -
- {children} -
- - {/* Overlay portalled into document.body — escapes every stacking context */} - {mounted && - !ready && - createPortal( setReady(true)} />, document.body)} - - ); + return <>{children}; } diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index 4ed6853c0..4ab49e919 100644 --- a/app/(root)/dashboard/[username]/page.test.tsx +++ b/app/(root)/dashboard/[username]/page.test.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { render, screen } from '@testing-library/react'; +import { Suspense } from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import DashboardPage, { generateMetadata } from './page'; -import { getFullDashboardData } from '@/lib/github'; +import { getFullDashboardData, fetchUserProfile } from '@/lib/github'; const { mockNotFound } = vi.hoisted(() => ({ mockNotFound: vi.fn(), @@ -21,7 +22,41 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/lib/github', () => ({ - getFullDashboardData: vi.fn(), + getFullDashboardData: vi.fn().mockReturnValue( + Promise.resolve({ + profile: { + login: 'octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4', + html_url: 'https://github.com/octocat', + name: 'The Octocat', + bio: null, + company: '@github', + blog: 'https://github.blog', + location: 'San Francisco', + email: null, + hireable: null, + twitter_username: null, + public_repos: 8, + public_gists: 8, + followers: 3938, + following: 9, + created_at: '2011-01-25T18:44:36Z', + updated_at: '2023-01-22T12:13:14Z', + }, + stats: { + currentStreak: 5, + peakStreak: 15, + totalContributions: 500, + }, + activity: [], + languages: [], + commitTimes: [], + achievements: [], + recommendations: [], + }) + ), + fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }), + fetchUserRepos: vi.fn().mockResolvedValue([]), })); // --- Mocking Core UI Blocks --- @@ -222,7 +257,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -258,7 +296,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -279,7 +320,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -300,14 +344,17 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); const trendView = screen.getByTestId('historical-trend-view'); expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity); }); - it('calls notFound when dashboard data fetch throws an error', async () => { - vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('User not found')); + it('calls notFound when fetchUserProfile throws an error', async () => { + vi.mocked(fetchUserProfile).mockRejectedValueOnce(new Error('Fetch failed')); const SuspenseTree = await DashboardPage({ params: Promise.resolve({ username: 'missing-user' }), diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index 7063302b7..a1eda1ecc 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -136,36 +136,29 @@ async function DashboardContent({ const session = await auth(); const sessionUsername = (session?.user as { username?: string })?.username ?? null; - let data; - + let fallbackProfile; try { - data = await getFullDashboardData(username, { + fallbackProfile = await fetchUserProfile(username, { bypassCache, - from: period.from, - to: period.to, - rangeLabel: period.label, token: userToken, - excludeBots, }); - } catch (error) { - if (error instanceof Error && error.message.includes('not found')) { - let fallbackProfile; - try { - fallbackProfile = await fetchUserProfile(username, { - bypassCache, - token: userToken, - }); - } catch { - return notFound(); - } - if (fallbackProfile.type === 'Organization') { - redirect(`/dashboard/org/${username}`); - } - return notFound(); - } - throw error; + } catch { + return notFound(); } + if (fallbackProfile.type === 'Organization') { + redirect(`/dashboard/org/${username}`); + } + + const data = getFullDashboardData(username, { + bypassCache, + from: period.from, + to: period.to, + rangeLabel: period.label, + token: userToken, + excludeBots, + }); + let allRepos: RepoActivityInfo[] = []; try { const reposData = await fetchUserRepos(username, { bypassCache, token: userToken }); @@ -196,7 +189,7 @@ async function DashboardContent({ { try { const body = await request.json(); - const { identifier, password } = body as { - identifier?: string; - password?: string; - }; + const result = loginSchema.safeParse(body); - if (!identifier || !password) { + if (!result.success) { return NextResponse.json( - { error: 'Email or Username and Password are required.' }, + { error: 'Email or Username and Password are required and must be valid.' }, { status: 400 } ); } + const { identifier } = result.data; + + // Sanitize and validate input to prevent SQL/NoSQL injection payloads + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier); + const isUsername = /^[a-zA-Z0-9_-]{3,39}$/.test(identifier); + + if (!isEmail && !isUsername) { + return NextResponse.json({ error: 'Invalid Email or Username format.' }, { status: 400 }); + } + return NextResponse.json( { success: true, diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index a763e85d9..723db85e3 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -1,4 +1,11 @@ import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const signupSchema = z.object({ + fullName: z.string().min(2).max(100), + email: z.string().email(), + password: z.string().min(1).max(255), +}); /** * Handles user registration requests for frontend signup. @@ -7,19 +14,23 @@ import { NextResponse } from 'next/server'; export async function POST(request: Request): Promise { try { const body = await request.json(); - const { fullName, email, password } = body as { - fullName?: string; - email?: string; - password?: string; - }; + const result = signupSchema.safeParse(body); - if (!fullName || !email || !password) { + if (!result.success) { return NextResponse.json( - { error: 'Full Name, Email, and Password are required.' }, + { error: 'Full Name, Email, and Password are required and must be valid.' }, { status: 400 } ); } + const { fullName, email } = result.data; + + // Sanitize and validate input to prevent SQL/NoSQL injection payloads + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + if (!isEmail) { + return NextResponse.json({ error: 'Invalid Email format.' }, { status: 400 }); + } + return NextResponse.json( { success: true, diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index bc3786984..728b52321 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -197,6 +197,26 @@ describe('GET /api/streak', () => { expect(response.headers.get('Content-Type')).toContain('image/svg+xml'); }); + it('returns JSON 400 when the user parameter is missing and format=json', async () => { + const response = await GET(makeRequest({ format: 'json' })); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(body.error).toContain('Missing user parameter'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + + it('returns 404 JSON for a nonexistent user when format=json', async () => { + vi.mocked(fetchGitHubContributions).mockRejectedValue( + new Error("Could not resolve to a User with the login of 'nonexistentuser'") + ); + const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' })); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + it('returns 400 when org parameter contains spaces and invalid characters', async () => { const response = await GET( makeRequest({ user: 'octocat', org: 'invalid_org_name_with_spaces' }) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 44e5e205c..cb45c205c 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -101,6 +101,21 @@ export async function GET(request: Request) { Object.values(fieldErrors.fieldErrors).flat()[0] ?? fieldErrors.formErrors[0] ?? 'Invalid parameters'; + + if (searchParams.get('format') === 'json') { + return NextResponse.json( + { error: firstError }, + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Request-ID': requestId, + }, + } + ); + } + const errTheme = resolveErrorTheme(searchParams); const errorSvg = buildInlineErrorSVG(firstError, { bg: errTheme.bg, @@ -1035,7 +1050,7 @@ function buildErrorResponse( } if (isNotFound) { - const match = message.match(/"([^"]+)"|login of '([^']+)'/); + const match = rawMessage.match(/"([^"]+)"|login of '([^']+)'/); const fallbackTarget = parseResult.success ? parseResult.data.org || parseResult.data.user : 'unknown'; diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index 6ee7ee08c..eb7b7c089 100644 --- a/app/burnout-analyzer/page.test.tsx +++ b/app/burnout-analyzer/page.test.tsx @@ -20,8 +20,17 @@ vi.mock('next/navigation', () => ({ }), })); +vi.mock('framer-motion', async () => { + const actual = await vi.importActual('framer-motion'); + return { + ...(actual as object), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + }; +}); + describe('BurnoutAnalyzerPage repository input handling', () => { beforeEach(() => { + vi.restoreAllMocks(); vi.clearAllMocks(); mockHistoryBack.mockReset(); mockRouterPush.mockReset(); @@ -115,14 +124,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }), }); vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(window, 'history', { - value: { length: 2, back: mockHistoryBack }, - configurable: true, - }); - Object.defineProperty(document, 'referrer', { - value: 'http://localhost/burnout-analyzer', - configurable: true, - }); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack); + vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); + vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer'); render(); fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), { @@ -154,14 +159,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }), }); vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(window, 'history', { - value: { length: 1, back: mockHistoryBack }, - configurable: true, - }); - Object.defineProperty(document, 'referrer', { - value: 'http://localhost/another-page', - configurable: true, - }); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(1); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack); + vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); + vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page'); render(); fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), { diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx index 0b60ee194..77e07b68b 100644 --- a/components/dashboard/DashboardClient.tsx +++ b/components/dashboard/DashboardClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { copyToClipboard } from '@/utils/clipboard'; -import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react'; +import { useState, useRef, useEffect, useCallback, useSyncExternalStore, use } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import DashboardSkeleton from './DashboardSkeleton'; @@ -106,8 +106,9 @@ export interface DashboardData { rawCommits?: string[]; } -interface DashboardClientProps { - initialData: DashboardData; +export interface DashboardClientProps { + initialDataPromise?: Promise; + initialData?: DashboardData; allRepoActivity?: RepoActivityInfo[]; username: string; compareData?: DashboardData | null; @@ -330,7 +331,8 @@ function getPersonalityTags( } export default function DashboardClient({ - initialData, + initialDataPromise, + initialData: initialDataProp, allRepoActivity = [], username, compareData = null, @@ -342,6 +344,10 @@ export default function DashboardClient({ () => false, () => (process.env.NODE_ENV === 'test' ? false : true) ); + + // Use React.use() if a promise is provided, otherwise use the direct object (for tests) + const initialData = initialDataProp || use(initialDataPromise!); + const [secondUserData, setSecondUserData] = useState(compareData); const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>( 'overview' diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx index a9d0bcb87..249911acd 100644 --- a/components/dashboard/DashboardClient.type-compiler.test.tsx +++ b/components/dashboard/DashboardClient.type-compiler.test.tsx @@ -1,6 +1,10 @@ import { describe, it, expectTypeOf } from 'vitest'; import React, { ComponentProps } from 'react'; -import DashboardClient, { ProfileMetrics, CoderProfile } from './DashboardClient'; +import DashboardClient, { + ProfileMetrics, + CoderProfile, + DashboardClientProps, +} from './DashboardClient'; import type { DashboardPeriod } from '@/utils/dashboardPeriod'; describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => { @@ -16,29 +20,31 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints }); it('Use type-testing assertions (expectTypeOf) to enforce field property configurations: Infer unexported props', () => { - // Extracting the props from the component - type Props = ComponentProps; + type Props = DashboardClientProps; // Assert the required fields expectTypeOf().toHaveProperty('username').toBeString(); expectTypeOf().toHaveProperty('period').toEqualTypeOf(); // Test the internal initialData structure - expectTypeOf().toHaveProperty('profile').toBeObject(); - expectTypeOf().toHaveProperty('username').toBeString(); + expectTypeOf>().toHaveProperty('profile').toBeObject(); + expectTypeOf['profile']>() + .toHaveProperty('username') + .toBeString(); }); it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => { - type Props = ComponentProps; + type Props = DashboardClientProps; // Missing 'username', 'initialData', 'period' expectTypeOf>().not.toMatchTypeOf(); - // 'username' must be a string, not a number + // 'initialData' must not be missing critical nested keys expectTypeOf<{ - username: number; - initialData: Props['initialData']; + username: string; + initialData: Omit, 'profile'>; period: DashboardPeriod; + allRepoActivity: Props['allRepoActivity']; }>().not.toMatchTypeOf(); }); @@ -58,6 +64,11 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints /> ); void validComponent; + + // Testing the extraction logic in edge cases + expectTypeOf['activity']>().toMatchTypeOf< + Array<{ date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4 }> + >(); }); it('Verify schema validation constraints return strict validation reports: CoderProfile strict unions', () => { @@ -66,9 +77,8 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints 'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃‍♂️' >(); - // intensity in activity array has strict union 0 | 1 | 2 | 3 | 4 - type Props = ComponentProps; - type ActivityIntensity = Props['initialData']['activity'][0]['intensity']; + type Props = DashboardClientProps; + type ActivityIntensity = NonNullable['activity'][0]['intensity']; expectTypeOf().toEqualTypeOf<0 | 1 | 2 | 3 | 4>(); }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 60098dadb..fd814af01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: . dockerfile: Dockerfile + target: runner container_name: commitpulse-app ports: - '3000:3000' diff --git a/docs/self_hosting.md b/docs/self_hosting.md index 79ac8bfcd..2fd412e79 100644 --- a/docs/self_hosting.md +++ b/docs/self_hosting.md @@ -79,6 +79,39 @@ Badge/SVG contribution data is cached and refreshes automatically once the cache This step is entirely optional — without it, badges still update on their own once the cache expires. +## 🐳 Containerized Self-Hosting (Docker Multi-Stage) + +CommitPulse includes a production-grade multi-stage `Dockerfile` (`base` → `deps` → `builder` → `runner`) to deliver a lightweight container footprint and secure execution as an unprivileged user (`nextjs`). + +### Option 1: Docker Compose (Recommended) + +1. Ensure `.env.local` exists with your `GITHUB_TOKEN`. +2. Start the full stack (CommitPulse application + MongoDB): + +```bash +docker compose up -d --build +``` + +3. Access the application at `http://localhost:3000`. + +### Option 2: Standalone Multi-Stage Docker Image + +1. Build the production image targeting the `runner` stage: + +```bash +docker build --target runner -t commitpulse:latest . +``` + +2. Run the container: + +```bash +docker run -d \ + --name commitpulse \ + --env-file .env.local \ + -p 3000:3000 \ + commitpulse:latest +``` + --- ## 🌐 Deploy Your Own diff --git a/lib/validations.streakParamsSchema.test.ts b/lib/validations.streakParamsSchema.test.ts index 75c8fb777..85d645fda 100644 --- a/lib/validations.streakParamsSchema.test.ts +++ b/lib/validations.streakParamsSchema.test.ts @@ -65,15 +65,24 @@ describe('streakParamsSchema', () => { expect(result.success).toBe(true); }); + it('accepts input with org but missing user', () => { + const result = streakParamsSchema.safeParse({ org: 'github' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.org).toBe('github'); + expect(result.data.user).toBe(''); // default empty string + } + }); + // ── Invalid / negative cases ────────────────────────────────────────────── - it('fails when user is missing', () => { + it('fails when both user and org are missing', () => { const result = streakParamsSchema.safeParse({}); expect(result.success).toBe(false); }); - it('fails when user is an empty string', () => { - const result = streakParamsSchema.safeParse({ user: '' }); + it('fails when user and org are empty strings', () => { + const result = streakParamsSchema.safeParse({ user: '', org: '' }); expect(result.success).toBe(false); }); diff --git a/lib/validations.ts b/lib/validations.ts index b1834c1d1..32734bcbb 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -233,8 +233,9 @@ const baseStreakParamsSchema = z.object({ // Required — missing user surfaces as "Missing" to match existing tests user: z .string({ error: 'Missing user parameter' }) - .min(1, { message: 'Missing user parameter' }) + .default('') .superRefine((val, ctx) => { + if (val === '') return; const users = val.split(',').map((u) => u.trim()); if (users.length === 0) { ctx.addIssue({ @@ -693,6 +694,11 @@ const baseStreakParamsSchema = z.object({ const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000; export const streakParamsSchema = baseStreakParamsSchema + .refine( + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'], @@ -1240,6 +1246,11 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema .optional() .transform((val) => val || 'rise'), }) + .refine( + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'], diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts index ef4760bee..0973faaf6 100644 --- a/services/github/webhook-handler.ts +++ b/services/github/webhook-handler.ts @@ -1,6 +1,5 @@ import { DistributedCache } from '@/lib/cache'; import { redactSecrets } from '@/lib/secretScanner'; -import type { CIWorkflowRun, CIInsights } from '@/types/ci-analytics'; interface WebhookPayload { action?: string; @@ -226,7 +225,7 @@ ${Object.entries(event.details) .join('\n')} `; - console.log(`Email alert would be sent to ${email}:`, { subject, body }); + console.info(`Email alert would be sent to ${email}:`, { subject, body }); } catch (error) { console.error('Failed to send email alert:', error); } diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts index e0279d289..ba8313006 100644 --- a/test-utils/timezone-mock.ts +++ b/test-utils/timezone-mock.ts @@ -4,8 +4,6 @@ // Ensures that timezone-sensitive tests produce consistent results // regardless of the system timezone (e.g., UTC on CI vs local dev timezone). -import { vi } from 'vitest'; - /** * A map of IANA timezone identifiers to their UTC offsets in minutes. * Positive values are east of UTC (ahead), negative values are west (behind). diff --git a/tests/dockerfile.test.ts b/tests/dockerfile.test.ts new file mode 100644 index 000000000..c31cd4286 --- /dev/null +++ b/tests/dockerfile.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Docker Multi-Stage Containerization Setup', () => { + const rootDir = path.resolve(__dirname, '..'); + const dockerfilePath = path.join(rootDir, 'Dockerfile'); + const dockerignorePath = path.join(rootDir, '.dockerignore'); + const dockerComposePath = path.join(rootDir, 'docker-compose.yml'); + const nextConfigPath = path.join(rootDir, 'next.config.ts'); + + it('should have Dockerfile configured with multi-stage build stages', () => { + expect(fs.existsSync(dockerfilePath)).toBe(true); + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + // Multi-stage stages + expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+base/i); + expect(content).toMatch(/FROM\s+base\s+AS\s+deps/i); + expect(content).toMatch(/FROM\s+base\s+AS\s+builder/i); + expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+runner/i); + }); + + it('should include necessary Alpine compatibility libraries and environment variables', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('libc6-compat'); + expect(content).toContain('ENV HOSTNAME="0.0.0.0"'); + expect(content).toContain('ENV NODE_ENV=production'); + expect(content).toContain('ENV NEXT_TELEMETRY_DISABLED=1'); + }); + + it('should run as non-root user for security compliance', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('addgroup'); + expect(content).toContain('adduser'); + expect(content).toContain('USER nextjs'); + }); + + it('should leverage Next.js standalone build artifacts', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('.next/standalone'); + expect(content).toContain('.next/static'); + expect(content).toContain('public'); + }); + + it('should exclude unnecessary build files and secrets in .dockerignore', () => { + expect(fs.existsSync(dockerignorePath)).toBe(true); + const content = fs.readFileSync(dockerignorePath, 'utf-8'); + + expect(content).toContain('node_modules'); + expect(content).toContain('.next'); + expect(content).toContain('.git'); + expect(content).toContain('.env*.local'); + expect(content).toContain('coverage'); + }); + + it('should target the runner stage in docker-compose.yml', () => { + expect(fs.existsSync(dockerComposePath)).toBe(true); + const content = fs.readFileSync(dockerComposePath, 'utf-8'); + + expect(content).toContain('dockerfile: Dockerfile'); + expect(content).toContain('target: runner'); + }); + + it('should have next.config.ts set to standalone output mode', () => { + expect(fs.existsSync(nextConfigPath)).toBe(true); + const content = fs.readFileSync(nextConfigPath, 'utf-8'); + + expect(content).toContain("output: 'standalone'"); + }); +}); diff --git a/utils/copyToClipboard.test.ts b/utils/copyToClipboard.test.ts index ba4ec054a..baca7f3f1 100644 --- a/utils/copyToClipboard.test.ts +++ b/utils/copyToClipboard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { copyToClipboard } from './clipboard'; // ---------------------------------------------------------------------------