@aravindc26/velu 0.13.6 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.13.6",
3
+ "version": "0.13.7",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  }
@@ -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
+ };