@xexr/blog-platform 1.0.7 → 1.0.8

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/app/blog/[slug]/page.d.ts +68 -0
  3. package/dist/app/blog/[slug]/page.tsx +155 -113
  4. package/dist/components/ArticleReadingProgress.tsx +208 -0
  5. package/dist/components/ArticleTopActions.tsx +179 -0
  6. package/dist/components/AuthorByline.tsx +569 -0
  7. package/dist/components/ExpandableArticleImage.tsx +79 -0
  8. package/dist/components/Pagination.tsx +236 -0
  9. package/dist/components/TopicArchivePages.tsx +204 -0
  10. package/dist/components/ui/badge.tsx +36 -0
  11. package/dist/components/ui/button.tsx +59 -0
  12. package/dist/components/ui/card.tsx +92 -0
  13. package/dist/components/ui/pagination.tsx +127 -0
  14. package/dist/components/ui/separator.tsx +29 -0
  15. package/dist/index.d.mts +59 -69
  16. package/dist/index.d.ts +59 -69
  17. package/dist/index.js +83 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +83 -8
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/lib/article-ui.ts +158 -0
  22. package/dist/lib/auth.ts +354 -0
  23. package/dist/lib/cors.ts +243 -0
  24. package/dist/lib/database.ts +277 -0
  25. package/dist/lib/init.ts +186 -0
  26. package/dist/lib/mdx.ts +73 -0
  27. package/dist/lib/metadata.ts +558 -0
  28. package/dist/lib/ratelimit.ts +224 -0
  29. package/dist/lib/security-headers.ts +322 -0
  30. package/dist/lib/seo.ts +295 -0
  31. package/dist/lib/sitemap.ts +719 -0
  32. package/dist/lib/storage/drizzle.ts +400 -0
  33. package/dist/lib/structured-data.ts +608 -0
  34. package/dist/lib/topics.ts +56 -0
  35. package/dist/lib/urls.ts +29 -0
  36. package/dist/lib/utils.ts +6 -0
  37. package/dist/lib/version.ts +154 -0
  38. package/dist/types/contracts.ts +256 -0
  39. package/dist/types/index.d.mts +18 -1
  40. package/dist/types/index.d.ts +18 -1
  41. package/dist/types/index.ts +16 -0
  42. package/package.json +14 -9
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Rate limiting for blog platform API endpoints
3
+ *
4
+ * This implements rate limiting using Upstash Redis to prevent abuse
5
+ * and ensure fair usage across all API consumers.
6
+ */
7
+
8
+ import { Ratelimit } from "@upstash/ratelimit";
9
+ import { Redis } from "@upstash/redis";
10
+ import type { PublishResult } from "@/types/contracts";
11
+
12
+ /**
13
+ * Rate limit configuration interface
14
+ * Allows customers to customize rate limiting behavior
15
+ */
16
+ export interface RateLimitConfig {
17
+ /** Upstash Redis REST URL */
18
+ redisUrl: string;
19
+
20
+ /** Upstash Redis REST token */
21
+ redisToken: string;
22
+
23
+ /** Requests per minute limit (default: 5) */
24
+ requestsPerMinute?: number;
25
+
26
+ /** Redis key prefix for rate limiting (default: "blog-platform-ratelimit") */
27
+ keyPrefix?: string;
28
+
29
+ /** Enable analytics (default: true) */
30
+ analytics?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Rate limit result
35
+ */
36
+ export interface RateLimitResult {
37
+ /** Whether the request is allowed */
38
+ success: boolean;
39
+
40
+ /** Number of requests remaining in current window */
41
+ remaining: number;
42
+
43
+ /** When the rate limit window resets (Unix timestamp) */
44
+ reset: number;
45
+
46
+ /** Total requests allowed per window */
47
+ limit: number;
48
+
49
+ /** Current request count in window */
50
+ count: number;
51
+ }
52
+
53
+ /**
54
+ * Rate limiter instance
55
+ */
56
+ type GlobalWithRateLimiter = typeof globalThis & {
57
+ __BLOG_PLATFORM_RATE_LIMITER__?: Ratelimit | null;
58
+ };
59
+
60
+ function getRateLimiter(): Ratelimit | null {
61
+ const globalRef = globalThis as GlobalWithRateLimiter;
62
+ return globalRef.__BLOG_PLATFORM_RATE_LIMITER__ ?? null;
63
+ }
64
+
65
+ function setRateLimiter(instance: Ratelimit | null): void {
66
+ const globalRef = globalThis as GlobalWithRateLimiter;
67
+ globalRef.__BLOG_PLATFORM_RATE_LIMITER__ = instance;
68
+ }
69
+
70
+ /**
71
+ * Configure the rate limiter
72
+ * This must be called during app initialization
73
+ */
74
+ export function configureRateLimit(config: RateLimitConfig): void {
75
+ const {
76
+ redisUrl,
77
+ redisToken,
78
+ requestsPerMinute = 5,
79
+ keyPrefix = "blog-platform-ratelimit",
80
+ analytics = true,
81
+ } = config;
82
+
83
+ const redis = new Redis({
84
+ url: redisUrl,
85
+ token: redisToken,
86
+ });
87
+
88
+ const limiter = new Ratelimit({
89
+ redis,
90
+ analytics,
91
+ prefix: keyPrefix,
92
+ // 5 requests per minute sliding window
93
+ limiter: Ratelimit.slidingWindow(requestsPerMinute, "1m"),
94
+ });
95
+
96
+ setRateLimiter(limiter);
97
+ }
98
+
99
+ /**
100
+ * Auto-configure rate limiting from environment variables
101
+ * Uses UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
102
+ */
103
+ export function autoConfigureRateLimit(): void {
104
+ const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
105
+ const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
106
+
107
+ if (!redisUrl || !redisToken) {
108
+ console.warn("Blog platform: No Upstash Redis configuration found. Rate limiting disabled.");
109
+ return;
110
+ }
111
+
112
+ configureRateLimit({
113
+ redisUrl,
114
+ redisToken,
115
+ });
116
+
117
+ console.log("Blog platform: Rate limiting configured (5 requests per minute per token)");
118
+ }
119
+
120
+ /**
121
+ * Check rate limit for a given identifier (typically an auth token)
122
+ * @param identifier - Unique identifier (usually the authentication token)
123
+ * @returns Rate limit result
124
+ */
125
+ export async function checkRateLimit(identifier: string): Promise<RateLimitResult> {
126
+ const limiter = getRateLimiter();
127
+ if (!limiter) {
128
+ // Rate limiting not configured - allow all requests
129
+ console.warn("Blog platform: Rate limiter not configured. All requests allowed.");
130
+ return {
131
+ success: true,
132
+ remaining: 999,
133
+ reset: Date.now() + 60000,
134
+ limit: 5,
135
+ count: 0,
136
+ };
137
+ }
138
+
139
+ try {
140
+ const result = await limiter.limit(identifier);
141
+
142
+ return {
143
+ success: result.success,
144
+ remaining: result.remaining,
145
+ reset: result.reset,
146
+ limit: result.limit,
147
+ count: result.limit - result.remaining,
148
+ };
149
+ } catch (error) {
150
+ console.error("Blog platform: Rate limit check failed:", error);
151
+ // On error, allow the request but log the issue
152
+ return {
153
+ success: true,
154
+ remaining: 0,
155
+ reset: Date.now() + 60000,
156
+ limit: 5,
157
+ count: 0,
158
+ };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Create a rate limit error response
164
+ * @param rateLimitResult - The rate limit result that failed
165
+ * @param idempotencyKey - Optional idempotency key from request
166
+ * @returns PublishResult with rate limit error
167
+ */
168
+ export function createRateLimitErrorResponse(
169
+ rateLimitResult: RateLimitResult,
170
+ idempotencyKey = ""
171
+ ): PublishResult {
172
+ const resetMinutes = Math.ceil((rateLimitResult.reset - Date.now()) / 60000);
173
+
174
+ return {
175
+ success: false,
176
+ error: {
177
+ code: "PUB011",
178
+ message: `Rate limit exceeded. You can make ${rateLimitResult.limit} requests per minute. Try again in ${resetMinutes} minute(s).`,
179
+ details: {
180
+ rateLimit: rateLimitResult,
181
+ },
182
+ },
183
+ idempotencyKey,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Rate limiting middleware for API routes
189
+ * @param identifier - Unique identifier for rate limiting (typically auth token)
190
+ * @param idempotencyKey - Optional idempotency key from request
191
+ * @returns Rate limit result or error response
192
+ */
193
+ export async function enforceRateLimit(
194
+ identifier: string,
195
+ idempotencyKey = ""
196
+ ): Promise<{ allowed: true; rateLimitResult: RateLimitResult } | { allowed: false; errorResponse: PublishResult }> {
197
+ const rateLimitResult = await checkRateLimit(identifier);
198
+
199
+ if (!rateLimitResult.success) {
200
+ return {
201
+ allowed: false,
202
+ errorResponse: createRateLimitErrorResponse(rateLimitResult, idempotencyKey),
203
+ };
204
+ }
205
+
206
+ return {
207
+ allowed: true,
208
+ rateLimitResult,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Get rate limit headers for HTTP responses
214
+ * @param rateLimitResult - Rate limit result
215
+ * @returns Headers object for HTTP response
216
+ */
217
+ export function getRateLimitHeaders(rateLimitResult: RateLimitResult): Record<string, string> {
218
+ return {
219
+ "X-RateLimit-Limit": rateLimitResult.limit.toString(),
220
+ "X-RateLimit-Remaining": rateLimitResult.remaining.toString(),
221
+ "X-RateLimit-Reset": rateLimitResult.reset.toString(),
222
+ "Retry-After": Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
223
+ };
224
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Security headers for blog platform API endpoints
3
+ *
4
+ * This provides comprehensive security headers to protect against common
5
+ * web vulnerabilities including XSS, clickjacking, and content injection.
6
+ */
7
+
8
+ import type { NextRequest } from "next/server";
9
+
10
+ /**
11
+ * Security headers configuration interface
12
+ */
13
+ export interface SecurityConfig {
14
+ /** Enable Content Security Policy (default: true) */
15
+ enableCSP?: boolean;
16
+
17
+ /** Custom CSP directives (merges with defaults) */
18
+ cspDirectives?: Record<string, string>;
19
+
20
+ /** Enable X-Frame-Options (default: true) */
21
+ enableFrameOptions?: boolean;
22
+
23
+ /** X-Frame-Options value (default: "DENY") */
24
+ frameOptions?: string;
25
+
26
+ /** Enable X-Content-Type-Options (default: true) */
27
+ enableContentTypeOptions?: boolean;
28
+
29
+ /** Enable X-XSS-Protection (default: true) */
30
+ enableXSSProtection?: boolean;
31
+
32
+ /** Enable Referrer-Policy (default: true) */
33
+ enableReferrerPolicy?: boolean;
34
+
35
+ /** Referrer policy value (default: "strict-origin-when-cross-origin") */
36
+ referrerPolicy?: string;
37
+
38
+ /** Enable Strict-Transport-Security for HTTPS (default: true) */
39
+ enableHSTS?: boolean;
40
+
41
+ /** HSTS max-age in seconds (default: 31536000 = 1 year) */
42
+ hstsMaxAge?: number;
43
+
44
+ /** Include HSTS subdomains (default: true) */
45
+ hstsIncludeSubdomains?: boolean;
46
+
47
+ /** Enable Permissions-Policy (default: true) */
48
+ enablePermissionsPolicy?: boolean;
49
+
50
+ /** Custom permissions policy directives */
51
+ permissionsPolicyDirectives?: Record<string, string>;
52
+ }
53
+
54
+ /**
55
+ * Default security configuration for blog platform APIs
56
+ */
57
+ const DEFAULT_SECURITY_CONFIG: Required<SecurityConfig> = {
58
+ enableCSP: true,
59
+ cspDirectives: {
60
+ "default-src": "'self'",
61
+ "script-src": "'self' 'unsafe-inline'", // Allow inline scripts for API responses
62
+ "style-src": "'self' 'unsafe-inline'", // Allow inline styles for blog rendering
63
+ "img-src": "'self' data: https:", // Allow images from self, data URIs, and HTTPS
64
+ "font-src": "'self' https:", // Allow fonts from self and HTTPS
65
+ "connect-src": "'self'", // API connections to same origin
66
+ "media-src": "'self'", // Media from same origin only
67
+ "object-src": "'none'", // Disable object/embed elements
68
+ "base-uri": "'self'", // Restrict base URIs to same origin
69
+ "form-action": "'self'", // Forms can only submit to same origin
70
+ "frame-ancestors": "'none'", // Prevent embedding in frames
71
+ "upgrade-insecure-requests": "", // Upgrade HTTP to HTTPS
72
+ },
73
+ enableFrameOptions: true,
74
+ frameOptions: "DENY",
75
+ enableContentTypeOptions: true,
76
+ enableXSSProtection: true,
77
+ enableReferrerPolicy: true,
78
+ referrerPolicy: "strict-origin-when-cross-origin",
79
+ enableHSTS: true,
80
+ hstsMaxAge: 31536000, // 1 year
81
+ hstsIncludeSubdomains: true,
82
+ enablePermissionsPolicy: true,
83
+ permissionsPolicyDirectives: {
84
+ "camera": "()", // Disable camera access
85
+ "microphone": "()", // Disable microphone access
86
+ "geolocation": "()", // Disable geolocation
87
+ "payment": "()", // Disable payment API
88
+ "usb": "()", // Disable USB access
89
+ "accelerometer": "()", // Disable accelerometer
90
+ "gyroscope": "()", // Disable gyroscope
91
+ "magnetometer": "()", // Disable magnetometer
92
+ },
93
+ };
94
+
95
+ /**
96
+ * Get security configuration from environment or defaults
97
+ */
98
+ function getSecurityConfig(): Required<SecurityConfig> {
99
+ const envCSPDisabled = process.env.BLOG_PLATFORM_DISABLE_CSP === "true";
100
+ const envFrameOptions = process.env.BLOG_PLATFORM_FRAME_OPTIONS;
101
+ const envHSTSDisabled = process.env.BLOG_PLATFORM_DISABLE_HSTS === "true";
102
+
103
+ return {
104
+ ...DEFAULT_SECURITY_CONFIG,
105
+ enableCSP: !envCSPDisabled,
106
+ frameOptions: envFrameOptions ?? DEFAULT_SECURITY_CONFIG.frameOptions,
107
+ enableHSTS: !envHSTSDisabled,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Build Content Security Policy header value
113
+ */
114
+ function buildCSPHeader(directives: Record<string, string>): string {
115
+ return Object.entries(directives)
116
+ .map(([directive, value]) =>
117
+ value ? `${directive} ${value}` : directive
118
+ )
119
+ .join("; ");
120
+ }
121
+
122
+ /**
123
+ * Build Permissions Policy header value
124
+ */
125
+ function buildPermissionsPolicyHeader(directives: Record<string, string>): string {
126
+ return Object.entries(directives)
127
+ .map(([feature, allowlist]) => `${feature}=${allowlist}`)
128
+ .join(", ");
129
+ }
130
+
131
+ /**
132
+ * Get security headers for HTTP responses
133
+ */
134
+ export function getSecurityHeaders(
135
+ request: NextRequest,
136
+ config: SecurityConfig = {}
137
+ ): Record<string, string> {
138
+ const securityConfig = { ...getSecurityConfig(), ...config };
139
+ const headers: Record<string, string> = {};
140
+
141
+ // Content Security Policy
142
+ if (securityConfig.enableCSP) {
143
+ const cspDirectives = {
144
+ ...securityConfig.cspDirectives,
145
+ ...(config.cspDirectives ?? {}),
146
+ };
147
+ headers["Content-Security-Policy"] = buildCSPHeader(cspDirectives);
148
+ }
149
+
150
+ // X-Frame-Options (prevent clickjacking)
151
+ if (securityConfig.enableFrameOptions) {
152
+ headers["X-Frame-Options"] = securityConfig.frameOptions;
153
+ }
154
+
155
+ // X-Content-Type-Options (prevent MIME sniffing)
156
+ if (securityConfig.enableContentTypeOptions) {
157
+ headers["X-Content-Type-Options"] = "nosniff";
158
+ }
159
+
160
+ // X-XSS-Protection (legacy XSS protection)
161
+ if (securityConfig.enableXSSProtection) {
162
+ headers["X-XSS-Protection"] = "1; mode=block";
163
+ }
164
+
165
+ // Referrer-Policy (control referrer information)
166
+ if (securityConfig.enableReferrerPolicy) {
167
+ headers["Referrer-Policy"] = securityConfig.referrerPolicy;
168
+ }
169
+
170
+ // Strict-Transport-Security (HTTPS enforcement)
171
+ if (securityConfig.enableHSTS && isHTTPS(request)) {
172
+ const hstsValue = [
173
+ `max-age=${securityConfig.hstsMaxAge}`,
174
+ securityConfig.hstsIncludeSubdomains ? "includeSubDomains" : "",
175
+ ].filter(Boolean).join("; ");
176
+
177
+ headers["Strict-Transport-Security"] = hstsValue;
178
+ }
179
+
180
+ // Permissions-Policy (feature control)
181
+ if (securityConfig.enablePermissionsPolicy) {
182
+ const permissionsDirectives = {
183
+ ...securityConfig.permissionsPolicyDirectives,
184
+ ...(config.permissionsPolicyDirectives ?? {}),
185
+ };
186
+ headers["Permissions-Policy"] = buildPermissionsPolicyHeader(permissionsDirectives);
187
+ }
188
+
189
+ return headers;
190
+ }
191
+
192
+ /**
193
+ * Check if request is over HTTPS
194
+ */
195
+ function isHTTPS(request: NextRequest): boolean {
196
+ // Check protocol from URL
197
+ if (request.url.startsWith("https://")) {
198
+ return true;
199
+ }
200
+
201
+ // Check forwarded protocol headers (for proxies/load balancers)
202
+ const forwardedProto = request.headers.get("x-forwarded-proto");
203
+ if (forwardedProto === "https") {
204
+ return true;
205
+ }
206
+
207
+ // Check if we're in a secure context (Cloudflare, Vercel, etc.)
208
+ const cfVisitor = request.headers.get("cf-visitor");
209
+ if (cfVisitor?.includes('"scheme":"https"')) {
210
+ return true;
211
+ }
212
+
213
+ return false;
214
+ }
215
+
216
+ /**
217
+ * Create security headers optimized for API endpoints
218
+ */
219
+ export function getAPISecurityHeaders(
220
+ request: NextRequest,
221
+ config: SecurityConfig = {}
222
+ ): Record<string, string> {
223
+ // API-specific CSP that's less restrictive for JSON responses
224
+ const apiCSPDirectives = {
225
+ "default-src": "'none'", // APIs don't need default sources
226
+ "frame-ancestors": "'none'", // Prevent embedding
227
+ "base-uri": "'none'", // No base URI needed for APIs
228
+ };
229
+
230
+ return getSecurityHeaders(request, {
231
+ ...config,
232
+ cspDirectives: {
233
+ ...apiCSPDirectives,
234
+ ...(config.cspDirectives ?? {}),
235
+ },
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Create security headers optimized for blog pages
241
+ */
242
+ export function getBlogSecurityHeaders(
243
+ request: NextRequest,
244
+ config: SecurityConfig = {}
245
+ ): Record<string, string> {
246
+ // Blog-specific CSP that allows images, fonts, and styles
247
+ const blogCSPDirectives = {
248
+ "default-src": "'self'",
249
+ "script-src": "'self' 'unsafe-inline'",
250
+ "style-src": "'self' 'unsafe-inline' https:",
251
+ "img-src": "'self' data: https:",
252
+ "font-src": "'self' https:",
253
+ "connect-src": "'self'",
254
+ "frame-src": "'none'", // No iframes in blog posts
255
+ "object-src": "'none'", // No plugins/objects
256
+ };
257
+
258
+ return getSecurityHeaders(request, {
259
+ ...config,
260
+ frameOptions: "SAMEORIGIN", // Allow embedding from same origin
261
+ cspDirectives: {
262
+ ...blogCSPDirectives,
263
+ ...(config.cspDirectives ?? {}),
264
+ },
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Security header presets for different use cases
270
+ */
271
+ export const SECURITY_PRESETS = {
272
+ /** Strict security for API endpoints */
273
+ API: {
274
+ enableCSP: true,
275
+ frameOptions: "DENY",
276
+ cspDirectives: {
277
+ "default-src": "'none'",
278
+ "frame-ancestors": "'none'",
279
+ "base-uri": "'none'",
280
+ },
281
+ },
282
+
283
+ /** Balanced security for blog pages */
284
+ BLOG: {
285
+ enableCSP: true,
286
+ frameOptions: "SAMEORIGIN",
287
+ cspDirectives: {
288
+ "default-src": "'self'",
289
+ "script-src": "'self' 'unsafe-inline'",
290
+ "style-src": "'self' 'unsafe-inline' https:",
291
+ "img-src": "'self' data: https:",
292
+ "font-src": "'self' https:",
293
+ },
294
+ },
295
+
296
+ /** Development-friendly settings */
297
+ DEVELOPMENT: {
298
+ enableCSP: false,
299
+ enableHSTS: false,
300
+ frameOptions: "SAMEORIGIN",
301
+ },
302
+ } as const;
303
+
304
+ /**
305
+ * Validate and sanitize CSP directive values
306
+ */
307
+ export function sanitizeCSPDirective(directive: string, value: string): string {
308
+ // Remove potentially dangerous values
309
+ const sanitized = value
310
+ .replace(/javascript:/gi, "") // Remove javascript: URIs
311
+ .replace(/data:/gi, "data:") // Normalize data: URIs
312
+ .replace(/\s+/g, " ") // Normalize whitespace
313
+ .trim();
314
+
315
+ // Validate that value doesn't contain injection attempts
316
+ if (sanitized.includes(";") || sanitized.includes("\n") || sanitized.includes("\r")) {
317
+ console.warn(`Potentially dangerous CSP value for ${directive}: ${value}`);
318
+ return "'none'"; // Safe fallback
319
+ }
320
+
321
+ return sanitized;
322
+ }