Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { projectUpdateRoutes } from './routes/projects-updates.js';
import { projectBuzzRoutes } from './routes/projects-buzz.js';
import { helpWantedRoutes } from './routes/projects-help-wanted.js';
import { projectMembershipRoutes } from './routes/projects-members.js';
import { previewRoutes } from './routes/preview.js';

declare module 'fastify' {
interface FastifyInstance {
Expand Down Expand Up @@ -147,6 +148,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(projectBuzzRoutes);
await fastify.register(helpWantedRoutes);
await fastify.register(projectMembershipRoutes);
await fastify.register(previewRoutes);

// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
Expand Down
48 changes: 48 additions & 0 deletions apps/api/src/routes/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Server-side markdown preview.
*
* POST /api/_preview { source: string } → { html: string }
*
* Used by the shared <MarkdownEditor> on every authoring screen so that the
* client never invokes a markdown library directly. See
* specs/behaviors/markdown-rendering.md for the rule and pipeline; this route
* is the lone exception to "rendering happens at serialize time" — it's the
* editor preview path.
*/
import type { FastifyInstance } from 'fastify';
import { renderMarkdown } from '@cfp/shared';
import { ok } from '../lib/response.js';
import { ApiValidationError } from '../lib/errors.js';

const MAX_PREVIEW_LENGTH = 50_000;

export async function previewRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/api/_preview',
{
schema: {
tags: ['preview'],
summary: 'Render a markdown source string to sanitized HTML',
body: {
type: 'object',
properties: { source: { type: 'string' } },
required: ['source'],
additionalProperties: false,
},
},
},
async (request) => {
const { source } = (request.body ?? {}) as { source: string };
if (typeof source !== 'string') {
throw new ApiValidationError('source must be a string', { source: 'required' });
}
if (source.length > MAX_PREVIEW_LENGTH) {
throw new ApiValidationError('source too long for preview', {
source: `must be ≤ ${MAX_PREVIEW_LENGTH} chars`,
});
}
const { html } = renderMarkdown(source);
return ok({ html });
},
);
}
87 changes: 87 additions & 0 deletions apps/api/tests/preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Tests for POST /api/_preview — the markdown editor's server-side preview
* endpoint, per specs/behaviors/markdown-rendering.md.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/app.js';
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';

let dataRepo: { path: string; cleanup: () => Promise<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
let app: FastifyInstance | undefined;

beforeEach(async () => {
dataRepo = await createFullDataRepo();
privateStore = await createPrivateStorageDir();
app = await buildApp({
serverOptions: { logger: false },
overrideEnv: {
CFP_DATA_REPO_PATH: dataRepo.path,
STORAGE_BACKEND: 'filesystem',
CFP_PRIVATE_STORAGE_PATH: privateStore.path,
CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!',
NODE_ENV: 'test',
},
});
});

afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
await dataRepo.cleanup();
await privateStore.cleanup();
});

describe('POST /api/_preview', () => {
it('renders markdown source to sanitized HTML', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: { source: '# Hello\n\n**bold** and `code`.' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// h1 demotes to h3 per the pipeline; bold/code preserved
expect(body.data.html).toContain('<h3>Hello</h3>');
expect(body.data.html).toContain('<strong>bold</strong>');
expect(body.data.html).toContain('<code>code</code>');
});

it('strips dangerous HTML (script / on-attributes)', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: {
source: '<script>alert(1)</script>\n\n<a href="javascript:void(0)" onclick="x()">x</a>',
},
});
expect(res.statusCode).toBe(200);
const html = res.json().data.html as string;
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('onclick');
});

it('rejects missing source with 422', async () => {
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: {},
});
expect(res.statusCode).toBe(422);
});

it('rejects oversized source with 422', async () => {
const giant = 'a '.repeat(30_000); // ~60k chars, exceeds 50k cap
const res = await app!.inject({
method: 'POST',
url: '/api/_preview',
payload: { source: giant },
});
expect(res.statusCode).toBe(422);
});
});
6 changes: 5 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.100.10",
"class-variance-authority": "^0.7.1",
Expand All @@ -31,10 +32,13 @@
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.0",
"react-router": "^7.15.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}
}
13 changes: 9 additions & 4 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
import { Toaster } from 'sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppShell } from '@/components/AppShell';
import { NetworkErrorProvider } from '@/components/NetworkErrorBanner';
Expand All @@ -7,8 +8,11 @@ import { ApiQueryClientProvider } from '@/lib/queryClient';
import { Home } from '@/screens/Home';
import { ProjectsIndex } from '@/screens/ProjectsIndex';
import { ProjectDetail } from '@/screens/ProjectDetail';
import { ProjectEdit } from '@/screens/ProjectEdit';
import { PeopleIndex } from '@/screens/PeopleIndex';
import { PersonDetail } from '@/screens/PersonDetail';
import { ProfileEdit } from '@/screens/ProfileEdit';
import { Account } from '@/screens/Account';
import { HelpWantedIndex } from '@/screens/HelpWantedIndex';
import { ProjectUpdatesFeed } from '@/screens/ProjectUpdatesFeed';
import { ProjectBuzzFeed } from '@/screens/ProjectBuzzFeed';
Expand All @@ -27,25 +31,25 @@ const router = createBrowserRouter([
children: [
{ path: '/', element: <Home /> },
{ path: '/projects', element: <ProjectsIndex /> },
{ path: '/projects/create', element: <ComingSoon /> },
{ path: '/projects/create', element: <ProjectEdit mode="create" /> },
{ path: '/projects/:slug', element: <ProjectDetail /> },
{ path: '/projects/:slug/edit', element: <ComingSoon /> },
{ path: '/projects/:slug/edit', element: <ProjectEdit mode="edit" /> },
{ path: '/projects/:slug/updates/:number', element: <ProjectDetail anchor="update" /> },
{ path: '/projects/:slug/buzz/:buzzSlug', element: <ProjectDetail anchor="buzz" /> },
{ path: '/projects/:slug/buzz/new', element: <ComingSoon /> },
{ path: '/help-wanted', element: <HelpWantedIndex /> },
{ path: '/people', element: <Navigate to="/members" replace /> },
{ path: '/members', element: <PeopleIndex /> },
{ path: '/members/:slug', element: <PersonDetail /> },
{ path: '/members/:slug/edit', element: <ComingSoon /> },
{ path: '/members/:slug/edit', element: <ProfileEdit /> },
{ path: '/project-updates', element: <ProjectUpdatesFeed /> },
{ path: '/project-buzz', element: <ProjectBuzzFeed /> },
{ path: '/tags', element: <TagsOverview /> },
{ path: '/tags/:namespace', element: <TagsNamespace /> },
{ path: '/tags/:namespace/:slug', element: <TagDetail /> },
{ path: '/volunteer', element: <Volunteer /> },
{ path: '/sponsor', element: <Sponsor /> },
{ path: '/account', element: <ComingSoon /> },
{ path: '/account', element: <Account /> },
{ path: '/search', element: <SearchRedirect /> },
{ path: '/pages/:slug', element: <ComingSoon /> },
{ path: '/contact', element: <ComingSoon /> },
Expand All @@ -68,6 +72,7 @@ export function App() {
<ApiQueryClientProvider>
<AuthProvider>
<RouterProvider router={router} />
<Toaster richColors closeButton position="bottom-right" />
</AuthProvider>
</ApiQueryClientProvider>
</NetworkErrorProvider>
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/components/HelpWantedCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { Button } from '@/components/ui/button';
import { TagChip } from '@/components/TagChip';
import { PersonAvatar } from '@/components/PersonAvatar';
import { MarkdownView } from '@/components/MarkdownView';
import { ExpressInterestModal } from '@/components/modals/ExpressInterestModal';
import { useAuth } from '@/hooks/useAuth';
import { formatRelativeTime } from '@/lib/time';
import type { HelpWantedRoleResponse } from '@/lib/api';
Expand All @@ -20,6 +22,7 @@ function commitmentLabel(hours: number | null): string {
export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardProps) {
const { person } = useAuth();
const isSignedIn = person !== null;
const [modalOpen, setModalOpen] = useState(false);

return (
<article className="rounded-lg border border-border bg-card p-4">
Expand Down Expand Up @@ -70,7 +73,11 @@ export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardP
Interest Sent ✓
</Button>
) : (
<Button size="sm" disabled={!role.permissions.canExpressInterest}>
<Button
size="sm"
disabled={!role.permissions.canExpressInterest}
onClick={() => setModalOpen(true)}
>
Express Interest
</Button>
)
Expand All @@ -82,6 +89,13 @@ export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardP
</Button>
)}
</div>
<ExpressInterestModal
open={modalOpen}
onOpenChange={setModalOpen}
projectSlug={role.project.slug}
roleId={role.id}
roleTitle={role.title}
/>
</article>
);
}
Loading