@xexr/blog-platform 1.0.16 → 1.1.0
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/CHANGELOG.md +6 -0
- package/README.md +94 -5
- package/dist/app/api/blog/config/route.ts +259 -0
- package/dist/app/api/blog/publish/route.ts +1 -1
- package/dist/app/api/blog/slugs/[slug]/route.ts +1 -1
- package/dist/app/api/blog/unpublish/route.ts +1 -1
- package/dist/app/api/blog/version/route.ts +1 -1
- package/dist/app/blog/[slug]/page.mdx-security.test.ts +135 -0
- package/dist/app/blog/[slug]/page.tsx +2 -2
- package/dist/app/blog/authors/[slug]/page.tsx +2 -2
- package/dist/app/blog/page.tsx +2 -2
- package/dist/app/bloggy-indexnow-key.txt/route.ts +22 -0
- package/dist/components/AuthorByline.tsx +1 -1
- package/dist/components/AuthorPages.tsx +2 -2
- package/dist/components/TopicArchivePages.tsx +2 -2
- package/dist/index.d.mts +605 -5
- package/dist/index.d.ts +605 -5
- package/dist/index.js +18 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -10
- package/dist/index.mjs.map +1 -1
- package/dist/lib/auth.ts +1 -1
- package/dist/lib/cors.ts +2 -2
- package/dist/lib/database.ts +20 -0
- package/dist/lib/mdx.ts +46 -2
- package/dist/lib/security-headers.ts +1 -1
- package/dist/lib/storage/drizzle.ts +210 -12
- package/dist/sitemap.js +5 -5
- package/dist/sitemap.mjs +5 -5
- package/dist/types/contracts.ts +21 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add authenticated config sync and public IndexNow key route support for Bloggy-managed search engine submission settings. Expose package-owned Drizzle schema exports plus setup/check helpers so host apps can generate or apply Bloggy table upgrades without copying table definitions.
|
|
8
|
+
|
|
3
9
|
## 1.0.15
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -328,6 +328,27 @@ export const OPTIONS = slugExistsOptionsHandler;
|
|
|
328
328
|
|
|
329
329
|
> 🛈 When this route is missing, Bloggy’s dry-run validation falls back to a warning and your platform logs will show `GET /api/blog/slugs/... 404`. Adding the handler keeps the validation flow clean.
|
|
330
330
|
|
|
331
|
+
**`app/api/blog/config/route.ts`** - Authenticated config sync used for search submission settings:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
import "@/lib/blog-platform";
|
|
335
|
+
import { configHandler, configOptionsHandler } from "@xexr/blog-platform";
|
|
336
|
+
|
|
337
|
+
export const POST = configHandler;
|
|
338
|
+
export const OPTIONS = configOptionsHandler;
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**`app/bloggy-indexnow-key.txt/route.ts`** - Public IndexNow key file route:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
import "@/lib/blog-platform";
|
|
345
|
+
import { indexNowKeyRoute } from "@xexr/blog-platform";
|
|
346
|
+
|
|
347
|
+
export const GET = indexNowKeyRoute;
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Bloggy generates the IndexNow key, stores it against the site, and syncs it to this package through `/api/blog/config` using the same publishing token. The downstream app does not need an IndexNow env var.
|
|
351
|
+
|
|
331
352
|
### 3. Configure Durable Storage (Drizzle + Turso)
|
|
332
353
|
|
|
333
354
|
By default, the package uses in-memory storage (not persistent). To persist published articles, plug in the Drizzle adapter.
|
|
@@ -338,12 +359,76 @@ By default, the package uses in-memory storage (not persistent). To persist publ
|
|
|
338
359
|
pnpm add drizzle-orm @libsql/client
|
|
339
360
|
```
|
|
340
361
|
|
|
341
|
-
2.
|
|
362
|
+
2. Add the package-owned schema to your Drizzle schema exports.
|
|
363
|
+
|
|
364
|
+
This is the recommended path for apps that use Drizzle Kit. The host app still owns generated migration files, but the table definitions stay package-owned so you do not copy/fork `bp_*` table shapes.
|
|
365
|
+
|
|
366
|
+
**`src/db/schema/blogPlatform.ts`**:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
export {
|
|
370
|
+
bpArticles,
|
|
371
|
+
bpMetadata,
|
|
372
|
+
bpOperations,
|
|
373
|
+
} from "@xexr/blog-platform";
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Then make sure your Drizzle config includes that schema file. For example:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// drizzle.config.ts
|
|
380
|
+
import { defineConfig } from "drizzle-kit";
|
|
381
|
+
|
|
382
|
+
export default defineConfig({
|
|
383
|
+
schema: "./src/db/schema/!(*.test).ts",
|
|
384
|
+
out: "drizzle",
|
|
385
|
+
dialect: "turso",
|
|
386
|
+
dbCredentials: {
|
|
387
|
+
url: process.env.BLOG_DATABASE_URL!,
|
|
388
|
+
authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Run your normal migration flow after upgrading the package:
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
pnpm drizzle-kit generate
|
|
397
|
+
pnpm drizzle-kit migrate
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
3. Manual setup or upgrade path.
|
|
401
|
+
|
|
402
|
+
For apps that do not generate Drizzle migrations from schema files, use the ordered SQL helper. It is safe to run repeatedly because each statement uses `IF NOT EXISTS`.
|
|
342
403
|
|
|
343
404
|
```ts
|
|
344
405
|
// e.g., scripts/createBlogPlatformTables.ts or inside your migration
|
|
345
406
|
import { getDrizzleAdapterSql } from "@xexr/blog-platform";
|
|
346
407
|
import { createClient } from "@libsql/client";
|
|
408
|
+
|
|
409
|
+
const client = createClient({
|
|
410
|
+
url: process.env.BLOG_DATABASE_URL!,
|
|
411
|
+
authToken: process.env.BLOG_DATABASE_AUTH_TOKEN!,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const { createAll } = getDrizzleAdapterSql();
|
|
415
|
+
for (const statement of createAll) {
|
|
416
|
+
await client.execute(statement);
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Existing installs upgrading from a package version before IndexNow/config sync can run the same `createAll` loop, or just `getDrizzleAdapterSql().createMetadata`, to add the `bp_metadata` table.
|
|
421
|
+
|
|
422
|
+
4. Optional startup/schema health check.
|
|
423
|
+
|
|
424
|
+
Use `ensureDrizzleAdapterSchema` during setup checks or deployment smoke tests when you want a clear package-level error for missing Bloggy tables.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import {
|
|
428
|
+
ensureDrizzleAdapterSchema,
|
|
429
|
+
getDrizzleAdapterSql,
|
|
430
|
+
} from "@xexr/blog-platform";
|
|
431
|
+
import { createClient } from "@libsql/client";
|
|
347
432
|
import { drizzle } from "drizzle-orm/libsql";
|
|
348
433
|
|
|
349
434
|
const client = createClient({
|
|
@@ -352,12 +437,16 @@ const client = createClient({
|
|
|
352
437
|
});
|
|
353
438
|
const db = drizzle(client);
|
|
354
439
|
|
|
355
|
-
|
|
356
|
-
await db
|
|
357
|
-
|
|
440
|
+
try {
|
|
441
|
+
await ensureDrizzleAdapterSchema(db);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error(error);
|
|
444
|
+
console.info(getDrizzleAdapterSql().createAll.join("\n\n"));
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
358
447
|
```
|
|
359
448
|
|
|
360
|
-
|
|
449
|
+
5. Wire the adapter at app startup:
|
|
361
450
|
|
|
362
451
|
```ts
|
|
363
452
|
// app/(root)/layout.tsx or middleware.ts (server-only)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { NextRequest, NextResponse } from "next/server.js";
|
|
2
|
+
|
|
3
|
+
import { getStorage } from "@/lib/database";
|
|
4
|
+
import {
|
|
5
|
+
authenticateRequest,
|
|
6
|
+
createTokenFingerprint,
|
|
7
|
+
requireAuth,
|
|
8
|
+
} from "@/lib/auth";
|
|
9
|
+
import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
|
|
10
|
+
import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
|
|
11
|
+
import type { BlogPlatformConfigPayload } from "@/types/contracts";
|
|
12
|
+
|
|
13
|
+
interface ConfigResponse {
|
|
14
|
+
success: boolean;
|
|
15
|
+
updated?: string[];
|
|
16
|
+
error?: {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
details?: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const INDEXNOW_KEY_PATTERN = /^[A-Za-z0-9-]{8,128}$/;
|
|
24
|
+
|
|
25
|
+
export async function POST(
|
|
26
|
+
request: NextRequest,
|
|
27
|
+
): Promise<NextResponse<ConfigResponse>> {
|
|
28
|
+
const auth = await authenticateRequest(request);
|
|
29
|
+
if (!auth.authenticated) {
|
|
30
|
+
const authCheck = await requireAuth(request);
|
|
31
|
+
return createCorsResponse(
|
|
32
|
+
request,
|
|
33
|
+
{
|
|
34
|
+
success: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: authCheck.error?.error?.code ?? "PUB005",
|
|
37
|
+
message: authCheck.error?.error?.message ?? "Authentication required",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ status: 401 },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
|
|
45
|
+
const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
|
|
46
|
+
if (!rateLimitCheck.allowed) {
|
|
47
|
+
const rateLimitHeaders = getRateLimitHeaders({
|
|
48
|
+
success: false,
|
|
49
|
+
remaining: 0,
|
|
50
|
+
reset: Date.now() + 60_000,
|
|
51
|
+
limit: 5,
|
|
52
|
+
count: 5,
|
|
53
|
+
});
|
|
54
|
+
return createCorsResponse(
|
|
55
|
+
request,
|
|
56
|
+
{
|
|
57
|
+
success: false,
|
|
58
|
+
error: rateLimitCheck.errorResponse.error ?? {
|
|
59
|
+
code: "PUB011",
|
|
60
|
+
message: "Rate limit exceeded",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{ status: 429, headers: rateLimitHeaders },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const contentType = request.headers.get("content-type");
|
|
68
|
+
if (!contentType?.includes("application/json")) {
|
|
69
|
+
return createCorsResponse(request, invalidConfig("Expected JSON body"), {
|
|
70
|
+
status: 400,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let payload: unknown;
|
|
75
|
+
try {
|
|
76
|
+
payload = await request.json();
|
|
77
|
+
} catch {
|
|
78
|
+
return createCorsResponse(request, invalidConfig("Invalid JSON body"), {
|
|
79
|
+
status: 400,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parsed = parseConfigPayload(payload);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
return createCorsResponse(request, invalidConfig(parsed.message), {
|
|
86
|
+
status: 400,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const updated: string[] = [];
|
|
91
|
+
if (parsed.config.searchSubmission) {
|
|
92
|
+
await getStorage().setMetadata(
|
|
93
|
+
"searchSubmission",
|
|
94
|
+
parsed.config.searchSubmission,
|
|
95
|
+
);
|
|
96
|
+
updated.push("searchSubmission");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return createCorsResponse(
|
|
100
|
+
request,
|
|
101
|
+
{
|
|
102
|
+
success: true,
|
|
103
|
+
updated,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
status: 200,
|
|
107
|
+
headers: getRateLimitHeaders(rateLimitCheck.rateLimitResult),
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
|
|
113
|
+
return handleCorsOptions(request, ["POST"], {
|
|
114
|
+
allowedHeaders: [
|
|
115
|
+
"Content-Type",
|
|
116
|
+
"X-Bloggy-Version",
|
|
117
|
+
"Authorization",
|
|
118
|
+
"X-Requested-With",
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function invalidConfig(message: string): ConfigResponse {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: "PUB001",
|
|
128
|
+
message,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseConfigPayload(payload: unknown):
|
|
134
|
+
| { success: true; config: BlogPlatformConfigPayload }
|
|
135
|
+
| {
|
|
136
|
+
success: false;
|
|
137
|
+
message: string;
|
|
138
|
+
} {
|
|
139
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
140
|
+
return { success: false, message: "Request body must be an object" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const record = payload as Record<string, unknown>;
|
|
144
|
+
const allowedKeys = new Set(["version", "searchSubmission"]);
|
|
145
|
+
const unknownKeys = Object.keys(record).filter(
|
|
146
|
+
(key) => !allowedKeys.has(key),
|
|
147
|
+
);
|
|
148
|
+
if (unknownKeys.length > 0) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
message: `Unsupported config section: ${unknownKeys.join(", ")}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (record.version !== 1) {
|
|
156
|
+
return { success: false, message: "Config version must be 1" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const config: BlogPlatformConfigPayload = { version: 1 };
|
|
160
|
+
if (record.searchSubmission !== undefined) {
|
|
161
|
+
const searchSubmission = parseSearchSubmission(record.searchSubmission);
|
|
162
|
+
if (!searchSubmission.success) {
|
|
163
|
+
return searchSubmission;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
config: {
|
|
168
|
+
...config,
|
|
169
|
+
searchSubmission: searchSubmission.config,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
message: "At least one supported config section is required",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseSearchSubmission(value: unknown):
|
|
181
|
+
| {
|
|
182
|
+
success: true;
|
|
183
|
+
config: NonNullable<BlogPlatformConfigPayload["searchSubmission"]>;
|
|
184
|
+
}
|
|
185
|
+
| {
|
|
186
|
+
success: false;
|
|
187
|
+
message: string;
|
|
188
|
+
} {
|
|
189
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
190
|
+
return { success: false, message: "searchSubmission must be an object" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const record = value as Record<string, unknown>;
|
|
194
|
+
const allowedKeys = new Set(["indexNow"]);
|
|
195
|
+
const unknownKeys = Object.keys(record).filter(
|
|
196
|
+
(key) => !allowedKeys.has(key),
|
|
197
|
+
);
|
|
198
|
+
if (unknownKeys.length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
message: `Unsupported searchSubmission section: ${unknownKeys.join(", ")}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (record.indexNow === undefined) {
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
message: "searchSubmission.indexNow is required",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const indexNow = record.indexNow;
|
|
213
|
+
if (!indexNow || typeof indexNow !== "object" || Array.isArray(indexNow)) {
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
message: "searchSubmission.indexNow must be an object",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const indexNowRecord = indexNow as Record<string, unknown>;
|
|
221
|
+
if (typeof indexNowRecord.enabled !== "boolean") {
|
|
222
|
+
return { success: false, message: "indexNow.enabled must be a boolean" };
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
typeof indexNowRecord.key !== "string" ||
|
|
226
|
+
!INDEXNOW_KEY_PATTERN.test(indexNowRecord.key)
|
|
227
|
+
) {
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
message:
|
|
231
|
+
"indexNow.key must be 8 to 128 characters using letters, numbers, or dashes",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (typeof indexNowRecord.keyLocation !== "string") {
|
|
235
|
+
return { success: false, message: "indexNow.keyLocation must be a URL" };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const keyLocation = new URL(indexNowRecord.keyLocation);
|
|
239
|
+
if (keyLocation.protocol !== "https:" && keyLocation.protocol !== "http:") {
|
|
240
|
+
return {
|
|
241
|
+
success: false,
|
|
242
|
+
message: "indexNow.keyLocation must use HTTP or HTTPS",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
return { success: false, message: "indexNow.keyLocation must be a URL" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
success: true,
|
|
251
|
+
config: {
|
|
252
|
+
indexNow: {
|
|
253
|
+
enabled: indexNowRecord.enabled,
|
|
254
|
+
key: indexNowRecord.key,
|
|
255
|
+
keyLocation: indexNowRecord.keyLocation,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToPipeableStream } from "react-dom/server";
|
|
3
|
+
import { PassThrough } from "stream";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import ArticlePage from "./page";
|
|
7
|
+
import type { PublishedArticle } from "../../../types/contracts";
|
|
8
|
+
|
|
9
|
+
const article: PublishedArticle = {
|
|
10
|
+
id: "article-security",
|
|
11
|
+
slug: "mdx-security",
|
|
12
|
+
title: "MDX security",
|
|
13
|
+
excerpt: "Renderer hardening fixtures.",
|
|
14
|
+
content: "Safe content.",
|
|
15
|
+
readingSeconds: 60,
|
|
16
|
+
wordCount: 120,
|
|
17
|
+
publishedAt: "2026-05-13T09:00:00.000Z",
|
|
18
|
+
updatedAt: "2026-05-13T09:00:00.000Z",
|
|
19
|
+
author: null,
|
|
20
|
+
site: {
|
|
21
|
+
id: "site-1",
|
|
22
|
+
name: "Example Blog",
|
|
23
|
+
tagline: "Useful writing",
|
|
24
|
+
description: "Articles about useful systems.",
|
|
25
|
+
domain: "example.com",
|
|
26
|
+
toneAdjectives: [],
|
|
27
|
+
locale: "en",
|
|
28
|
+
},
|
|
29
|
+
topics: [],
|
|
30
|
+
meta: {
|
|
31
|
+
canonicalUrl: "https://example.com/blog/mdx-security",
|
|
32
|
+
keywords: [],
|
|
33
|
+
},
|
|
34
|
+
version: 1,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("ArticlePage MDX security", () => {
|
|
38
|
+
it("renders expected markdown and figure HTML through the real article renderer", async () => {
|
|
39
|
+
const html = await renderToHtml(
|
|
40
|
+
React.createElement(ArticlePage, {
|
|
41
|
+
article: {
|
|
42
|
+
...article,
|
|
43
|
+
content: [
|
|
44
|
+
"## Safe section",
|
|
45
|
+
"",
|
|
46
|
+
"<figure>",
|
|
47
|
+
"",
|
|
48
|
+
"",
|
|
49
|
+
"",
|
|
50
|
+
'<figcaption>Dashboard caption <span class="blog-image-credit">Credit: Example</span></figcaption>',
|
|
51
|
+
"",
|
|
52
|
+
"</figure>",
|
|
53
|
+
"",
|
|
54
|
+
"[Read more](https://example.com/read-more)",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
},
|
|
57
|
+
showShareLinks: false,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(html).toContain("Safe section");
|
|
62
|
+
expect(html).toContain("<figure>");
|
|
63
|
+
expect(html).toContain("<figcaption>");
|
|
64
|
+
expect(html).toContain("Dashboard caption");
|
|
65
|
+
expect(html).toContain('src="https://example.com/dashboard.png"');
|
|
66
|
+
expect(html).toContain('href="https://example.com/read-more"');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("removes executable raw HTML and unsafe URL protocols from rendered articles", async () => {
|
|
70
|
+
const html = await renderToHtml(
|
|
71
|
+
React.createElement(ArticlePage, {
|
|
72
|
+
article: {
|
|
73
|
+
...article,
|
|
74
|
+
content: [
|
|
75
|
+
"Safe paragraph.",
|
|
76
|
+
"",
|
|
77
|
+
"<script>alert('script')</script>",
|
|
78
|
+
'<img src="https://example.com/safe.png" onerror="alert(1)" alt="event handler" />',
|
|
79
|
+
'<a href="javascript:alert(2)">unsafe link</a>',
|
|
80
|
+
'<img src="data:text/html,<script>alert(3)</script>" alt="data url" />',
|
|
81
|
+
'<iframe src="https://example.com/embed"></iframe>',
|
|
82
|
+
'<object data="https://example.com/payload"></object>',
|
|
83
|
+
'<embed src="https://example.com/payload" />',
|
|
84
|
+
].join("\n"),
|
|
85
|
+
},
|
|
86
|
+
showShareLinks: false,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const articleBodyHtml = extractArticleBodyHtml(html);
|
|
91
|
+
|
|
92
|
+
expect(articleBodyHtml).toContain("Safe paragraph.");
|
|
93
|
+
expect(articleBodyHtml).toContain("unsafe link");
|
|
94
|
+
expect(articleBodyHtml).not.toContain("<script");
|
|
95
|
+
expect(articleBodyHtml).not.toContain("onerror");
|
|
96
|
+
expect(articleBodyHtml).not.toContain("javascript:");
|
|
97
|
+
expect(articleBodyHtml).not.toContain("data:text/html");
|
|
98
|
+
expect(articleBodyHtml).not.toContain("<iframe");
|
|
99
|
+
expect(articleBodyHtml).not.toContain("<object");
|
|
100
|
+
expect(articleBodyHtml).not.toContain("<embed");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function extractArticleBodyHtml(html: string): string {
|
|
105
|
+
const start = html.indexOf('id="article-body"');
|
|
106
|
+
const end = html.indexOf('<script type="application/ld+json"', start);
|
|
107
|
+
|
|
108
|
+
if (start === -1) {
|
|
109
|
+
throw new Error("Article body not found in rendered HTML");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return end === -1 ? html.slice(start) : html.slice(start, end);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderToHtml(element: React.ReactElement): Promise<string> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let html = "";
|
|
118
|
+
const stream = new PassThrough();
|
|
119
|
+
stream.setEncoding("utf8");
|
|
120
|
+
stream.on("data", (chunk: string) => {
|
|
121
|
+
html += chunk;
|
|
122
|
+
});
|
|
123
|
+
stream.on("end", () => resolve(html));
|
|
124
|
+
stream.on("error", reject);
|
|
125
|
+
|
|
126
|
+
const { pipe } = renderToPipeableStream(element, {
|
|
127
|
+
onAllReady() {
|
|
128
|
+
pipe(stream);
|
|
129
|
+
},
|
|
130
|
+
onError(error) {
|
|
131
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import Image from "next/image";
|
|
3
|
-
import Link from "next/link";
|
|
2
|
+
import Image from "next/image.js";
|
|
3
|
+
import Link from "next/link.js";
|
|
4
4
|
import { ArrowLeft, Globe, Mail } from "lucide-react";
|
|
5
5
|
import { formatDistanceToNow } from "date-fns";
|
|
6
6
|
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server.js";
|
|
2
|
+
|
|
3
|
+
import { getStorage } from "@/lib/database";
|
|
4
|
+
import type { SearchSubmissionConfig } from "@/types/contracts";
|
|
5
|
+
|
|
6
|
+
export async function GET(_request: NextRequest): Promise<NextResponse> {
|
|
7
|
+
const searchSubmission =
|
|
8
|
+
await getStorage().getMetadata<SearchSubmissionConfig>("searchSubmission");
|
|
9
|
+
const indexNow = searchSubmission?.indexNow;
|
|
10
|
+
|
|
11
|
+
if (!indexNow?.enabled || !indexNow.key) {
|
|
12
|
+
return new NextResponse("Not found", { status: 404 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return new NextResponse(indexNow.key, {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
19
|
+
"Cache-Control": "public, max-age=300, stale-while-revalidate=60",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import Image from "next/image";
|
|
2
|
+
import Image from "next/image.js";
|
|
3
3
|
import { Globe, Mail } from "lucide-react";
|
|
4
4
|
import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
|
|
5
5
|
import { type PublishedAuthor } from "../types/contracts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import Image from "next/image";
|
|
3
|
-
import Link from "next/link";
|
|
2
|
+
import Image from "next/image.js";
|
|
3
|
+
import Link from "next/link.js";
|
|
4
4
|
|
|
5
5
|
import type { PublishedArticle, PublishedAuthor } from "../types/contracts";
|
|
6
6
|
import type { BlogTheme } from "../lib/article-ui";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import Link from "next/link";
|
|
3
|
-
import Image from "next/image";
|
|
2
|
+
import Link from "next/link.js";
|
|
3
|
+
import Image from "next/image.js";
|
|
4
4
|
import { formatDistanceToNow } from "date-fns";
|
|
5
5
|
|
|
6
6
|
import type { PublishedArticle, PublishedTopic } from "../types/contracts";
|