@aravindc26/velu 0.13.5 → 0.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -141,15 +141,10 @@ function prefixMdxComponentLinks(
|
|
|
141
141
|
export default async function PreviewPage({ params }: PageProps) {
|
|
142
142
|
const { sessionId, slug } = await params;
|
|
143
143
|
|
|
144
|
-
console.log(`[PREVIEW:page] START session=${sessionId} slug=${slug.join('/')}`);
|
|
145
|
-
|
|
146
144
|
const src = await getSessionSource(sessionId);
|
|
147
145
|
const page = src.getPage(slug);
|
|
148
146
|
|
|
149
|
-
if (!page)
|
|
150
|
-
console.log(`[PREVIEW:page] Page NOT FOUND for slug=${slug.join('/')}`);
|
|
151
|
-
notFound();
|
|
152
|
-
}
|
|
147
|
+
if (!page) notFound();
|
|
153
148
|
|
|
154
149
|
const pageDataRecord = page.data as unknown as Record<string, unknown>;
|
|
155
150
|
|
|
@@ -159,22 +154,16 @@ export default async function PreviewPage({ params }: PageProps) {
|
|
|
159
154
|
let pageToc: any;
|
|
160
155
|
|
|
161
156
|
if (typeof loadFn === 'function') {
|
|
162
|
-
console.log(`[PREVIEW:page] Calling load() for slug=${slug.join('/')}`);
|
|
163
157
|
const loaded = await loadFn();
|
|
164
158
|
MDX = loaded.body;
|
|
165
159
|
pageToc = loaded.toc;
|
|
166
|
-
console.log(`[PREVIEW:page] load() returned MDX=${typeof MDX} toc=${typeof pageToc}`);
|
|
167
160
|
} else {
|
|
168
161
|
// Fallback: pre-compiled entry (shouldn't happen in preview mode, but safe)
|
|
169
|
-
console.log(`[PREVIEW:page] No load() — using pre-compiled body for slug=${slug.join('/')}`);
|
|
170
162
|
MDX = pageDataRecord.body as any;
|
|
171
163
|
pageToc = pageDataRecord.toc as any;
|
|
172
164
|
}
|
|
173
165
|
|
|
174
|
-
if (typeof MDX !== 'function')
|
|
175
|
-
console.log(`[PREVIEW:page] MDX is not a function (type=${typeof MDX}), returning 404`);
|
|
176
|
-
notFound();
|
|
177
|
-
}
|
|
166
|
+
if (typeof MDX !== 'function') notFound();
|
|
178
167
|
|
|
179
168
|
const configSource = loadSessionConfigSource(sessionId);
|
|
180
169
|
const footerSocials = configSource ? getFooterSocials(configSource) : [];
|
|
@@ -186,7 +175,6 @@ export default async function PreviewPage({ params }: PageProps) {
|
|
|
186
175
|
if (pageInfo?.fullPath) {
|
|
187
176
|
try {
|
|
188
177
|
effectiveMarkdown = readFileSync(pageInfo.fullPath, 'utf-8');
|
|
189
|
-
console.log(`[PREVIEW:page] Read markdown from ${pageInfo.fullPath} (${effectiveMarkdown.length} chars, first 120: ${JSON.stringify(effectiveMarkdown.slice(0, 120))})`);
|
|
190
178
|
} catch { /* file may not exist */ }
|
|
191
179
|
}
|
|
192
180
|
|
|
@@ -1,16 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared API authentication via PREVIEW_API_SECRET.
|
|
3
3
|
*/
|
|
4
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
4
5
|
import { NextRequest } from 'next/server';
|
|
5
6
|
|
|
6
7
|
const PREVIEW_API_SECRET = process.env.PREVIEW_API_SECRET || '';
|
|
7
8
|
|
|
9
|
+
export function getPreviewSecret(): string {
|
|
10
|
+
return PREVIEW_API_SECRET;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export function verifyApiSecret(request: NextRequest): boolean {
|
|
9
14
|
if (!PREVIEW_API_SECRET) return true; // No secret configured — allow all
|
|
10
15
|
const header = request.headers.get('x-preview-secret') || '';
|
|
11
16
|
return header === PREVIEW_API_SECRET;
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Verify an HMAC-signed preview token.
|
|
21
|
+
*
|
|
22
|
+
* Token format: `{base64url_hmac}.{sessionId}:{unix_expiry}`
|
|
23
|
+
*
|
|
24
|
+
* Returns `{ valid: true, expiry }` when the token is authentic, matches the
|
|
25
|
+
* expected session ID, and has not expired. Returns `{ valid: false, expiry: 0 }`
|
|
26
|
+
* otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export function verifyPreviewToken(
|
|
29
|
+
token: string,
|
|
30
|
+
expectedSessionId: string,
|
|
31
|
+
): { valid: boolean; expiry: number } {
|
|
32
|
+
const fail = { valid: false, expiry: 0 };
|
|
33
|
+
if (!PREVIEW_API_SECRET) return { valid: true, expiry: 0 }; // No secret — allow all
|
|
34
|
+
|
|
35
|
+
const dotIdx = token.indexOf('.');
|
|
36
|
+
if (dotIdx === -1) return fail;
|
|
37
|
+
|
|
38
|
+
const sigB64 = token.slice(0, dotIdx);
|
|
39
|
+
const payload = token.slice(dotIdx + 1);
|
|
40
|
+
|
|
41
|
+
// Payload must be "{sessionId}:{expiry}"
|
|
42
|
+
const colonIdx = payload.indexOf(':');
|
|
43
|
+
if (colonIdx === -1) return fail;
|
|
44
|
+
|
|
45
|
+
const sessionId = payload.slice(0, colonIdx);
|
|
46
|
+
const expiryStr = payload.slice(colonIdx + 1);
|
|
47
|
+
|
|
48
|
+
if (sessionId !== expectedSessionId) return fail;
|
|
49
|
+
|
|
50
|
+
const expiry = parseInt(expiryStr, 10);
|
|
51
|
+
if (isNaN(expiry) || expiry < Math.floor(Date.now() / 1000)) return fail;
|
|
52
|
+
|
|
53
|
+
// Recompute HMAC and compare in constant time
|
|
54
|
+
const expectedSig = createHmac('sha256', PREVIEW_API_SECRET)
|
|
55
|
+
.update(payload)
|
|
56
|
+
.digest('base64url')
|
|
57
|
+
// Strip padding to match Python's rstrip(b"=")
|
|
58
|
+
.replace(/=+$/, '');
|
|
59
|
+
|
|
60
|
+
// Ensure both are the same length before timingSafeEqual
|
|
61
|
+
const sigBuf = Buffer.from(sigB64, 'utf8');
|
|
62
|
+
const expectedBuf = Buffer.from(expectedSig, 'utf8');
|
|
63
|
+
if (sigBuf.length !== expectedBuf.length) return fail;
|
|
64
|
+
|
|
65
|
+
if (!timingSafeEqual(sigBuf, expectedBuf)) return fail;
|
|
66
|
+
|
|
67
|
+
return { valid: true, expiry };
|
|
68
|
+
}
|
|
69
|
+
|
|
14
70
|
export function unauthorizedResponse() {
|
|
15
71
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
72
|
}
|
|
@@ -755,10 +755,9 @@ export function syncSessionFile(
|
|
|
755
755
|
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
756
756
|
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
757
757
|
|
|
758
|
-
console.log(`[PREVIEW:syncFile] session=${sessionId} file=${filePath}
|
|
758
|
+
console.log(`[PREVIEW:syncFile] session=${sessionId} file=${filePath}`);
|
|
759
759
|
|
|
760
760
|
if (filePath === PRIMARY_CONFIG_NAME || filePath === LEGACY_CONFIG_NAME) {
|
|
761
|
-
console.log(`[PREVIEW:syncFile] Config file changed — regenerating all content`);
|
|
762
761
|
generateSessionContent(sessionId);
|
|
763
762
|
return { synced: true };
|
|
764
763
|
}
|
|
@@ -780,13 +779,9 @@ export function syncSessionFile(
|
|
|
780
779
|
srcPath = join(workspaceDir, `${stripped}.md`);
|
|
781
780
|
}
|
|
782
781
|
if (!existsSync(srcPath)) {
|
|
783
|
-
console.log(`[PREVIEW:syncFile] Source file NOT FOUND at any candidate path for ${filePath}`);
|
|
784
782
|
return { synced: false };
|
|
785
783
|
}
|
|
786
784
|
|
|
787
|
-
const srcContent = readFileSync(srcPath, 'utf-8');
|
|
788
|
-
console.log(`[PREVIEW:syncFile] Source file: ${srcPath} (${srcContent.length} chars, first 120: ${JSON.stringify(srcContent.slice(0, 120))})`);
|
|
789
|
-
|
|
790
785
|
try {
|
|
791
786
|
const { config } = loadConfig(workspaceDir);
|
|
792
787
|
const artifacts = buildArtifacts(config, workspaceDir);
|
|
@@ -796,22 +791,15 @@ export function syncSessionFile(
|
|
|
796
791
|
|
|
797
792
|
if (mapping) {
|
|
798
793
|
const destPath = join(outputDir, `${mapping.dest}.mdx`);
|
|
799
|
-
console.log(`[PREVIEW:syncFile] Mapped: ${stripped} -> ${mapping.dest}, writing to ${destPath}`);
|
|
800
794
|
processPage(srcPath, destPath, stripped, variables, sessionId);
|
|
801
|
-
const written = readFileSync(destPath, 'utf-8');
|
|
802
|
-
console.log(`[PREVIEW:syncFile] Written dest (${written.length} chars, first 120: ${JSON.stringify(written.slice(0, 120))})`);
|
|
803
795
|
return { synced: true };
|
|
804
796
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
console.log(`[PREVIEW:syncFile] Mapping lookup failed, falling through to direct copy:`, err instanceof Error ? err.message : err);
|
|
797
|
+
} catch {
|
|
798
|
+
// Fall through to direct copy
|
|
808
799
|
}
|
|
809
800
|
|
|
810
801
|
const destPath = join(outputDir, `${stripped}.mdx`);
|
|
811
|
-
console.log(`[PREVIEW:syncFile] Direct copy: ${srcPath} -> ${destPath}`);
|
|
812
802
|
processPage(srcPath, destPath, stripped, variables, sessionId);
|
|
813
|
-
const written = readFileSync(destPath, 'utf-8');
|
|
814
|
-
console.log(`[PREVIEW:syncFile] Written dest (${written.length} chars, first 120: ${JSON.stringify(written.slice(0, 120))})`);
|
|
815
803
|
return { synced: true };
|
|
816
804
|
}
|
|
817
805
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Used only in preview mode (PREVIEW_MODE=true) — production builds still use
|
|
10
10
|
* the standard `source.ts` with build-time collections.
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, readFileSync, readdirSync,
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
13
13
|
import { join, relative } from 'node:path';
|
|
14
14
|
import { loader } from 'fumadocs-core/source';
|
|
15
15
|
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
|
|
@@ -31,6 +31,30 @@ function log(tag: string, msg: string, data?: Record<string, unknown>) {
|
|
|
31
31
|
|
|
32
32
|
// ── Cache ──────────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
34
|
+
// File-based invalidation signal. Next.js bundles API routes and page routes
|
|
35
|
+
// into separate module instances, so in-memory invalidation from the sync/init
|
|
36
|
+
// route handler does NOT clear the cache seen by the page renderer. Instead,
|
|
37
|
+
// the invalidation writes a timestamp to a file on disk which both sides can see.
|
|
38
|
+
const INVALIDATION_DIR = join(PREVIEW_CONTENT_DIR, '.invalidation');
|
|
39
|
+
|
|
40
|
+
function getInvalidationPath(sessionId: string): string {
|
|
41
|
+
return join(INVALIDATION_DIR, `${sessionId}.stamp`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readInvalidationStamp(sessionId: string): number {
|
|
45
|
+
const p = getInvalidationPath(sessionId);
|
|
46
|
+
try {
|
|
47
|
+
return Number(readFileSync(p, 'utf-8').trim()) || 0;
|
|
48
|
+
} catch {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeInvalidationStamp(sessionId: string): void {
|
|
54
|
+
mkdirSync(INVALIDATION_DIR, { recursive: true });
|
|
55
|
+
writeFileSync(getInvalidationPath(sessionId), String(Date.now()), 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
59
|
const sourceCache = new Map<string, { source: any; createdAt: number }>();
|
|
36
60
|
|
|
@@ -154,11 +178,17 @@ function scanContentDir(sessionDir: string): {
|
|
|
154
178
|
export async function getSessionSource(sessionId: string) {
|
|
155
179
|
const cached = sourceCache.get(sessionId);
|
|
156
180
|
if (cached) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
181
|
+
const stamp = readInvalidationStamp(sessionId);
|
|
182
|
+
if (stamp <= cached.createdAt) {
|
|
183
|
+
const age = Date.now() - cached.createdAt;
|
|
184
|
+
log('source', `Cache HIT for session ${sessionId}`, { ageMs: age });
|
|
185
|
+
return cached.source;
|
|
186
|
+
}
|
|
187
|
+
log('source', `Cache STALE for session ${sessionId} (stamp=${stamp} > created=${cached.createdAt}), rebuilding`);
|
|
188
|
+
sourceCache.delete(sessionId);
|
|
189
|
+
} else {
|
|
190
|
+
log('source', `Cache MISS for session ${sessionId}`);
|
|
160
191
|
}
|
|
161
|
-
log('source', `Cache MISS for session ${sessionId}`);
|
|
162
192
|
|
|
163
193
|
const sessionDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
164
194
|
if (!existsSync(sessionDir)) {
|
|
@@ -176,15 +206,6 @@ export async function getSessionSource(sessionId: string) {
|
|
|
176
206
|
log('source', `Scanned ${sessionDir}`, {
|
|
177
207
|
entryCount: entries.length,
|
|
178
208
|
metaFileCount: Object.keys(metaFiles).length,
|
|
179
|
-
files: entries.map((e) => {
|
|
180
|
-
const stat = statSync(e.info.fullPath, { throwIfNoEntry: false });
|
|
181
|
-
return {
|
|
182
|
-
path: e.info.path,
|
|
183
|
-
title: e.data.title,
|
|
184
|
-
sizeBytes: stat?.size,
|
|
185
|
-
mtimeMs: stat?.mtimeMs,
|
|
186
|
-
};
|
|
187
|
-
}),
|
|
188
209
|
});
|
|
189
210
|
|
|
190
211
|
const collection = await dyn.docs('docs', sessionDir, metaFiles, entries);
|
|
@@ -222,12 +243,15 @@ export async function getSessionSource(sessionId: string) {
|
|
|
222
243
|
|
|
223
244
|
/**
|
|
224
245
|
* Invalidate the cached source for a session.
|
|
225
|
-
*
|
|
246
|
+
* Writes a file-based timestamp so that ALL Next.js module instances
|
|
247
|
+
* (API routes AND page routes) see the invalidation, even though they
|
|
248
|
+
* run in separate bundles with separate in-memory Maps.
|
|
226
249
|
*/
|
|
227
250
|
export function invalidateSessionSource(sessionId: string): void {
|
|
228
251
|
const had = sourceCache.has(sessionId);
|
|
229
252
|
sourceCache.delete(sessionId);
|
|
230
|
-
|
|
253
|
+
writeInvalidationStamp(sessionId);
|
|
254
|
+
log('invalidate', `session=${sessionId} hadCache=${had} wroteStamp=true`);
|
|
231
255
|
}
|
|
232
256
|
|
|
233
257
|
/**
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getPreviewSecret, verifyPreviewToken } from './lib/preview-auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Next.js middleware that enforces HMAC-signed token authentication for
|
|
6
|
+
* preview page routes.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Requests with `x-preview-secret` header pass through (server-to-server API calls).
|
|
10
|
+
* 2. Extract sessionId from the URL path (`/{sessionId}/...`).
|
|
11
|
+
* 3. If `?token=` query param is present: validate HMAC, set an HttpOnly cookie,
|
|
12
|
+
* redirect to the same URL without the token (keeps browser history clean).
|
|
13
|
+
* 4. If a valid `__velu_preview_{sessionId}` cookie exists: allow.
|
|
14
|
+
* 5. Otherwise: 403.
|
|
15
|
+
*/
|
|
16
|
+
export function middleware(request: NextRequest) {
|
|
17
|
+
const secret = getPreviewSecret();
|
|
18
|
+
|
|
19
|
+
// No secret configured — allow everything (dev / local)
|
|
20
|
+
if (!secret) return NextResponse.next();
|
|
21
|
+
|
|
22
|
+
// Server-to-server API calls use the x-preview-secret header — skip page auth
|
|
23
|
+
if (request.headers.get('x-preview-secret')) {
|
|
24
|
+
return NextResponse.next();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { pathname } = request.nextUrl;
|
|
28
|
+
|
|
29
|
+
// Extract sessionId: first path segment after leading slash
|
|
30
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
31
|
+
if (segments.length === 0) return NextResponse.next();
|
|
32
|
+
|
|
33
|
+
// Skip Next.js internal and static paths
|
|
34
|
+
const first = segments[0];
|
|
35
|
+
if (first === '_next' || first === 'api') return NextResponse.next();
|
|
36
|
+
|
|
37
|
+
// sessionId should be numeric
|
|
38
|
+
const sessionId = first;
|
|
39
|
+
if (!/^\d+$/.test(sessionId)) return NextResponse.next();
|
|
40
|
+
|
|
41
|
+
// --- Token in query string: validate, set cookie, redirect to clean URL ---
|
|
42
|
+
const tokenParam = request.nextUrl.searchParams.get('token');
|
|
43
|
+
if (tokenParam) {
|
|
44
|
+
const { valid, expiry } = verifyPreviewToken(tokenParam, sessionId);
|
|
45
|
+
if (!valid) {
|
|
46
|
+
return new NextResponse('Forbidden', { status: 403 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Redirect to the same URL without the token
|
|
50
|
+
const cleanUrl = request.nextUrl.clone();
|
|
51
|
+
cleanUrl.searchParams.delete('token');
|
|
52
|
+
const response = NextResponse.redirect(cleanUrl);
|
|
53
|
+
|
|
54
|
+
// Compute remaining TTL for cookie maxAge
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const maxAge = expiry > 0 ? expiry - now : 86400;
|
|
57
|
+
|
|
58
|
+
response.cookies.set(`__velu_preview_${sessionId}`, tokenParam, {
|
|
59
|
+
httpOnly: true,
|
|
60
|
+
secure: true,
|
|
61
|
+
sameSite: 'none', // Required for cross-origin iframe
|
|
62
|
+
path: `/${sessionId}`,
|
|
63
|
+
maxAge,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Cookie: validate existing token stored in cookie ---
|
|
70
|
+
const cookie = request.cookies.get(`__velu_preview_${sessionId}`);
|
|
71
|
+
if (cookie) {
|
|
72
|
+
const { valid } = verifyPreviewToken(cookie.value, sessionId);
|
|
73
|
+
if (valid) return NextResponse.next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new NextResponse('Forbidden', { status: 403 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Use Node.js runtime so we can access the crypto module for HMAC verification
|
|
80
|
+
export const runtime = 'nodejs';
|
|
81
|
+
|
|
82
|
+
export const config = {
|
|
83
|
+
matcher: [
|
|
84
|
+
/*
|
|
85
|
+
* Match all request paths except:
|
|
86
|
+
* - _next (Next.js internals)
|
|
87
|
+
* - favicon.ico, robots.txt, sitemap.xml (static meta)
|
|
88
|
+
*/
|
|
89
|
+
'/((?!_next|favicon\\.ico|robots\\.txt|sitemap\\.xml).*)',
|
|
90
|
+
],
|
|
91
|
+
};
|