@xexr/blog-platform 0.2.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +1175 -0
- package/dist/app/api/blog/publish/route.ts +360 -0
- package/dist/app/api/blog/slugs/[slug]/route.ts +166 -0
- package/dist/app/api/blog/unpublish/route.ts +301 -0
- package/dist/app/api/blog/version/route.ts +259 -0
- package/dist/app/blog/[slug]/page.tsx +304 -0
- package/dist/app/blog/page.tsx +316 -0
- package/dist/index.d.mts +1799 -0
- package/dist/index.d.ts +1799 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +31 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types/index.d.mts +142 -0
- package/dist/types/index.d.ts +142 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.mjs +2 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getStorage } from "@/lib/database";
|
|
3
|
+
import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
|
|
4
|
+
import { requireAuth, authenticateRequest } from "@/lib/auth";
|
|
5
|
+
import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
|
|
6
|
+
import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
|
|
7
|
+
import type {
|
|
8
|
+
PublishPayload,
|
|
9
|
+
PublishResult,
|
|
10
|
+
PublishedArticle,
|
|
11
|
+
} from "@/types/contracts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/blog/publish
|
|
15
|
+
*
|
|
16
|
+
* Publishes an article to the blog platform with full idempotency support.
|
|
17
|
+
* This endpoint receives content from Bloggy core and stores it for rendering.
|
|
18
|
+
*
|
|
19
|
+
* Request body should contain a PublishPayload with:
|
|
20
|
+
* - article: The complete article data
|
|
21
|
+
* - idempotencyKey: SHA-256 hash for duplicate prevention
|
|
22
|
+
* - Optional overrides for slug, meta description, etc.
|
|
23
|
+
*/
|
|
24
|
+
export async function POST(
|
|
25
|
+
request: NextRequest,
|
|
26
|
+
): Promise<NextResponse<PublishResult>> {
|
|
27
|
+
try {
|
|
28
|
+
// Authenticate the request
|
|
29
|
+
const auth = await authenticateRequest(request);
|
|
30
|
+
if (!auth.authenticated) {
|
|
31
|
+
const authCheck = await requireAuth(request);
|
|
32
|
+
return createCorsResponse(request, authCheck.error!, { status: 401 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Apply rate limiting using the authentication token
|
|
36
|
+
const token = auth.token ?? "anonymous";
|
|
37
|
+
const rateLimitCheck = await enforceRateLimit(token);
|
|
38
|
+
if (!rateLimitCheck.allowed) {
|
|
39
|
+
// Extract rate limit info from error response, with safe fallback
|
|
40
|
+
const rateLimitData =
|
|
41
|
+
rateLimitCheck.errorResponse.error?.details?.rateLimit;
|
|
42
|
+
const rateLimitResult =
|
|
43
|
+
rateLimitData &&
|
|
44
|
+
typeof rateLimitData === "object" &&
|
|
45
|
+
"limit" in rateLimitData &&
|
|
46
|
+
"remaining" in rateLimitData &&
|
|
47
|
+
"reset" in rateLimitData &&
|
|
48
|
+
"count" in rateLimitData &&
|
|
49
|
+
"success" in rateLimitData
|
|
50
|
+
? (rateLimitData as {
|
|
51
|
+
limit: number;
|
|
52
|
+
remaining: number;
|
|
53
|
+
reset: number;
|
|
54
|
+
count: number;
|
|
55
|
+
success: boolean;
|
|
56
|
+
})
|
|
57
|
+
: {
|
|
58
|
+
limit: 10,
|
|
59
|
+
remaining: 0,
|
|
60
|
+
reset: Date.now() + 60000,
|
|
61
|
+
count: 10,
|
|
62
|
+
success: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const rateLimitHeaders = getRateLimitHeaders(rateLimitResult);
|
|
66
|
+
|
|
67
|
+
return createCorsResponse(request, rateLimitCheck.errorResponse, {
|
|
68
|
+
status: 429,
|
|
69
|
+
headers: rateLimitHeaders,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate request content type
|
|
74
|
+
const contentType = request.headers.get("content-type");
|
|
75
|
+
if (!contentType?.includes("application/json")) {
|
|
76
|
+
return createCorsResponse(
|
|
77
|
+
request,
|
|
78
|
+
{
|
|
79
|
+
success: false,
|
|
80
|
+
error: {
|
|
81
|
+
code: "PUB001",
|
|
82
|
+
message: "Content-Type must be application/json",
|
|
83
|
+
},
|
|
84
|
+
idempotencyKey: "",
|
|
85
|
+
},
|
|
86
|
+
{ status: 400 },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse request body
|
|
91
|
+
let payload: PublishPayload;
|
|
92
|
+
try {
|
|
93
|
+
payload = (await request.json()) as PublishPayload;
|
|
94
|
+
} catch {
|
|
95
|
+
return createCorsResponse(
|
|
96
|
+
request,
|
|
97
|
+
{
|
|
98
|
+
success: false,
|
|
99
|
+
error: {
|
|
100
|
+
code: "PUB001",
|
|
101
|
+
message: "Invalid JSON in request body",
|
|
102
|
+
},
|
|
103
|
+
idempotencyKey: "",
|
|
104
|
+
},
|
|
105
|
+
{ status: 400 },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Validate required fields
|
|
110
|
+
const validationErrors = validatePublishPayload(payload);
|
|
111
|
+
if (validationErrors.length > 0) {
|
|
112
|
+
return createCorsResponse(
|
|
113
|
+
request,
|
|
114
|
+
{
|
|
115
|
+
success: false,
|
|
116
|
+
error: {
|
|
117
|
+
code: "PUB001",
|
|
118
|
+
message: "Missing required fields",
|
|
119
|
+
details: { errors: validationErrors },
|
|
120
|
+
},
|
|
121
|
+
idempotencyKey: payload.idempotencyKey ?? "",
|
|
122
|
+
},
|
|
123
|
+
{ status: 400 },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check version compatibility if header provided
|
|
128
|
+
const clientVersionHeader = request.headers.get("X-Bloggy-Version");
|
|
129
|
+
if (clientVersionHeader) {
|
|
130
|
+
const parsedVersions = parseVersionHeader(clientVersionHeader);
|
|
131
|
+
if (parsedVersions?.platformVersion) {
|
|
132
|
+
const platformVersion = getBlogPlatformVersion();
|
|
133
|
+
|
|
134
|
+
// Check for major version incompatibility
|
|
135
|
+
if (parsedVersions.platformVersion.major !== platformVersion.major) {
|
|
136
|
+
return createCorsResponse(
|
|
137
|
+
request,
|
|
138
|
+
{
|
|
139
|
+
success: false,
|
|
140
|
+
error: {
|
|
141
|
+
code: "PUB009",
|
|
142
|
+
message:
|
|
143
|
+
"Major version incompatibility. Please upgrade your blog platform package.",
|
|
144
|
+
details: {
|
|
145
|
+
clientVersion: parsedVersions.platformVersion.version,
|
|
146
|
+
serverVersion: platformVersion.version,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
idempotencyKey: payload.idempotencyKey,
|
|
150
|
+
},
|
|
151
|
+
{ status: 409 },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Handle dry run validation
|
|
158
|
+
if (payload.dryRun) {
|
|
159
|
+
const warnings: string[] = [];
|
|
160
|
+
|
|
161
|
+
// Validate content requirements
|
|
162
|
+
if (payload.article.wordCount < 50) {
|
|
163
|
+
warnings.push("Article has fewer than 50 words, which may affect SEO");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!payload.article.excerpt || payload.article.excerpt.length < 120) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
"Article excerpt is shorter than recommended 120 characters",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!payload.article.meta.keywords.length) {
|
|
173
|
+
warnings.push("No SEO keywords provided");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return createCorsResponse(
|
|
177
|
+
request,
|
|
178
|
+
{
|
|
179
|
+
success: true,
|
|
180
|
+
warnings,
|
|
181
|
+
idempotencyKey: payload.idempotencyKey,
|
|
182
|
+
},
|
|
183
|
+
{ status: 200 },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Prepare the article with any overrides
|
|
188
|
+
const finalArticle: PublishedArticle = {
|
|
189
|
+
...payload.article,
|
|
190
|
+
slug: payload.slug ?? payload.article.slug,
|
|
191
|
+
meta: {
|
|
192
|
+
...payload.article.meta,
|
|
193
|
+
description:
|
|
194
|
+
payload.metaDescription ?? payload.article.meta.description,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Validate slug format
|
|
199
|
+
if (!isValidSlug(finalArticle.slug)) {
|
|
200
|
+
return createCorsResponse(
|
|
201
|
+
request,
|
|
202
|
+
{
|
|
203
|
+
success: false,
|
|
204
|
+
error: {
|
|
205
|
+
code: "PUB002",
|
|
206
|
+
message:
|
|
207
|
+
"Slug can only contain lowercase letters, numbers, and hyphens",
|
|
208
|
+
},
|
|
209
|
+
idempotencyKey: payload.idempotencyKey,
|
|
210
|
+
},
|
|
211
|
+
{ status: 400 },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Get storage and attempt to save
|
|
216
|
+
const storage = getStorage();
|
|
217
|
+
const result = await storage.saveArticle(
|
|
218
|
+
finalArticle,
|
|
219
|
+
payload.idempotencyKey,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Prepare response headers
|
|
223
|
+
const platformVersion = getBlogPlatformVersion();
|
|
224
|
+
const rateLimitHeaders = getRateLimitHeaders(
|
|
225
|
+
rateLimitCheck.rateLimitResult,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const responseHeaders: Record<string, string> = {
|
|
229
|
+
"X-Bloggy-Version": `platform/${platformVersion.version}`,
|
|
230
|
+
"X-Idempotency-Key": payload.idempotencyKey,
|
|
231
|
+
...rateLimitHeaders,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (result.success) {
|
|
235
|
+
responseHeaders.Location = result.url ?? `/${finalArticle.slug}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Return appropriate status code
|
|
239
|
+
const statusCode = result.success
|
|
240
|
+
? 201
|
|
241
|
+
: result.error?.code === "PUB003"
|
|
242
|
+
? 409
|
|
243
|
+
: 400;
|
|
244
|
+
|
|
245
|
+
return createCorsResponse(request, result, {
|
|
246
|
+
status: statusCode,
|
|
247
|
+
headers: responseHeaders,
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error("Publish endpoint error:", error);
|
|
251
|
+
|
|
252
|
+
const errorResult: PublishResult = {
|
|
253
|
+
success: false,
|
|
254
|
+
error: {
|
|
255
|
+
code: "PUB007",
|
|
256
|
+
message: "Internal server error during publishing",
|
|
257
|
+
},
|
|
258
|
+
idempotencyKey: "",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return createCorsResponse(request, errorResult, { status: 500 });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* OPTIONS /api/blog/publish
|
|
267
|
+
*
|
|
268
|
+
* Handle CORS preflight requests
|
|
269
|
+
*/
|
|
270
|
+
export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
|
|
271
|
+
return handleCorsOptions(request, ["POST"], {
|
|
272
|
+
allowedHeaders: [
|
|
273
|
+
"Content-Type",
|
|
274
|
+
"X-Bloggy-Version",
|
|
275
|
+
"X-Idempotency-Key",
|
|
276
|
+
"Authorization",
|
|
277
|
+
"X-Requested-With",
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validate the publish payload structure
|
|
284
|
+
*/
|
|
285
|
+
function validatePublishPayload(payload: unknown): string[] {
|
|
286
|
+
const errors: string[] = [];
|
|
287
|
+
|
|
288
|
+
if (!payload || typeof payload !== "object") {
|
|
289
|
+
errors.push("Request body must be a valid object");
|
|
290
|
+
return errors;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const p = payload as Record<string, unknown>;
|
|
294
|
+
|
|
295
|
+
// Check required top-level fields
|
|
296
|
+
if (!p.article || typeof p.article !== "object") {
|
|
297
|
+
errors.push("Missing required field: article");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!p.idempotencyKey || typeof p.idempotencyKey !== "string") {
|
|
301
|
+
errors.push("Missing required field: idempotencyKey");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Validate article structure if present
|
|
305
|
+
if (p.article && typeof p.article === "object") {
|
|
306
|
+
const article = p.article as Record<string, unknown>;
|
|
307
|
+
|
|
308
|
+
const requiredFields = [
|
|
309
|
+
"id",
|
|
310
|
+
"slug",
|
|
311
|
+
"title",
|
|
312
|
+
"content",
|
|
313
|
+
"publishedAt",
|
|
314
|
+
"site",
|
|
315
|
+
];
|
|
316
|
+
for (const field of requiredFields) {
|
|
317
|
+
if (!article[field]) {
|
|
318
|
+
errors.push(`Missing required article field: ${field}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Validate site structure
|
|
323
|
+
if (article.site && typeof article.site === "object") {
|
|
324
|
+
const site = article.site as Record<string, unknown>;
|
|
325
|
+
const requiredSiteFields = ["id", "name", "domain"];
|
|
326
|
+
for (const field of requiredSiteFields) {
|
|
327
|
+
if (!site[field]) {
|
|
328
|
+
errors.push(`Missing required site field: ${field}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Validate meta structure
|
|
334
|
+
if (article.meta && typeof article.meta === "object") {
|
|
335
|
+
const meta = article.meta as Record<string, unknown>;
|
|
336
|
+
if (!Array.isArray(meta.keywords)) {
|
|
337
|
+
errors.push("Article meta.keywords must be an array");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return errors;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validate slug format
|
|
347
|
+
* Must be lowercase, alphanumeric, and hyphens only
|
|
348
|
+
*/
|
|
349
|
+
function isValidSlug(slug: string): boolean {
|
|
350
|
+
if (!slug || typeof slug !== "string") {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Allow lowercase letters, numbers, and hyphens
|
|
355
|
+
// Must start and end with alphanumeric character
|
|
356
|
+
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
357
|
+
return slugRegex.test(slug) && slug.length >= 1 && slug.length <= 100;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export { isValidSlug };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getStorage } from "@/lib/database";
|
|
3
|
+
import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
|
|
4
|
+
import { requireAuth, authenticateRequest } from "@/lib/auth";
|
|
5
|
+
import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
|
|
6
|
+
import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
|
|
7
|
+
import { isValidSlug } from "@/app/api/blog/publish/route"; // Reuse slug validation from publish
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /api/blog/slugs/{slug}
|
|
11
|
+
*
|
|
12
|
+
* Checks if a slug is already in use on the blog platform.
|
|
13
|
+
* Used for dry-run validation in publishing workflow.
|
|
14
|
+
*
|
|
15
|
+
* Returns: { exists: boolean, message?: string }
|
|
16
|
+
* - exists: false -> available
|
|
17
|
+
* - exists: true -> taken, with suggestion message
|
|
18
|
+
*/
|
|
19
|
+
export async function GET(
|
|
20
|
+
request: NextRequest,
|
|
21
|
+
{ params }: { params: Promise<{ slug: string }> },
|
|
22
|
+
): Promise<NextResponse> {
|
|
23
|
+
try {
|
|
24
|
+
const { slug } = await params;
|
|
25
|
+
// Authenticate (required for security)
|
|
26
|
+
const auth = await authenticateRequest(request);
|
|
27
|
+
if (!auth.authenticated) {
|
|
28
|
+
const authCheck = await requireAuth(request);
|
|
29
|
+
return NextResponse.json(authCheck.error!, { status: 401 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const token = auth.token ?? "anonymous";
|
|
33
|
+
|
|
34
|
+
// Rate limiting (same as publish: 5/min per token)
|
|
35
|
+
const rateLimitCheck = await enforceRateLimit(token);
|
|
36
|
+
if (!rateLimitCheck.allowed) {
|
|
37
|
+
const rateLimitData = (rateLimitCheck.errorResponse.error?.details
|
|
38
|
+
?.rateLimit as {
|
|
39
|
+
limit: number;
|
|
40
|
+
remaining: number;
|
|
41
|
+
reset: number;
|
|
42
|
+
count: number;
|
|
43
|
+
success: boolean;
|
|
44
|
+
}) || {
|
|
45
|
+
limit: 10,
|
|
46
|
+
remaining: 0,
|
|
47
|
+
reset: Date.now() + 60000,
|
|
48
|
+
count: 10,
|
|
49
|
+
success: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const rateLimitHeaders = getRateLimitHeaders(rateLimitData);
|
|
53
|
+
|
|
54
|
+
return createCorsResponse(request, rateLimitCheck.errorResponse, {
|
|
55
|
+
status: 429,
|
|
56
|
+
headers: rateLimitHeaders,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract slug from params
|
|
61
|
+
if (!slug || !isValidSlug(slug)) {
|
|
62
|
+
return createCorsResponse(
|
|
63
|
+
request,
|
|
64
|
+
{
|
|
65
|
+
exists: false, // Treat invalid as non-existent, but error for clarity
|
|
66
|
+
error: {
|
|
67
|
+
code: "PUB002",
|
|
68
|
+
message:
|
|
69
|
+
"Invalid slug format. Must be lowercase letters, numbers, and hyphens only.",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ status: 400 },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Version compatibility check
|
|
77
|
+
const clientVersionHeader = request.headers.get("X-Bloggy-Version");
|
|
78
|
+
if (clientVersionHeader) {
|
|
79
|
+
const parsedVersions = parseVersionHeader(clientVersionHeader);
|
|
80
|
+
if (parsedVersions?.platformVersion) {
|
|
81
|
+
const platformVersion = getBlogPlatformVersion();
|
|
82
|
+
if (parsedVersions.platformVersion.major !== platformVersion.major) {
|
|
83
|
+
return createCorsResponse(
|
|
84
|
+
request,
|
|
85
|
+
{
|
|
86
|
+
exists: false,
|
|
87
|
+
error: {
|
|
88
|
+
code: "PUB009",
|
|
89
|
+
message:
|
|
90
|
+
"Major version incompatibility. Please upgrade your blog platform package.",
|
|
91
|
+
details: {
|
|
92
|
+
clientVersion: parsedVersions.platformVersion.version,
|
|
93
|
+
serverVersion: platformVersion.version,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{ status: 409 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check existence via storage
|
|
104
|
+
const storage = getStorage();
|
|
105
|
+
const existingArticle = await storage.getArticle(slug);
|
|
106
|
+
const exists = existingArticle !== null;
|
|
107
|
+
|
|
108
|
+
const responseBody = {
|
|
109
|
+
exists,
|
|
110
|
+
...(exists && {
|
|
111
|
+
message:
|
|
112
|
+
"Slug is already in use. Please choose another (e.g., append '-v2').",
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Headers
|
|
117
|
+
const platformVersion = getBlogPlatformVersion();
|
|
118
|
+
const rateLimitHeaders = getRateLimitHeaders(
|
|
119
|
+
rateLimitCheck.rateLimitResult,
|
|
120
|
+
);
|
|
121
|
+
const responseHeaders: Record<string, string> = {
|
|
122
|
+
"X-Bloggy-Version": `platform/${platformVersion.version}`,
|
|
123
|
+
...rateLimitHeaders,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Optional idempotency log (if provided)
|
|
127
|
+
const idempotencyKey = request.headers.get("X-Idempotency-Key");
|
|
128
|
+
if (idempotencyKey) {
|
|
129
|
+
responseHeaders["X-Idempotency-Key"] = idempotencyKey;
|
|
130
|
+
// Log for debugging
|
|
131
|
+
console.log(
|
|
132
|
+
`Slug check for idempotency: ${idempotencyKey}, exists: ${exists}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return createCorsResponse(request, responseBody, {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: responseHeaders,
|
|
139
|
+
});
|
|
140
|
+
} catch (error: unknown) {
|
|
141
|
+
console.error(
|
|
142
|
+
"Slug existence check error:",
|
|
143
|
+
error instanceof Error ? error.message : String(error),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return createCorsResponse(
|
|
147
|
+
request,
|
|
148
|
+
{
|
|
149
|
+
exists: false,
|
|
150
|
+
error: {
|
|
151
|
+
code: "PUB007",
|
|
152
|
+
message: "Internal server error during slug check",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{ status: 500 },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* OPTIONS /api/blog/slugs/{slug}
|
|
162
|
+
* Handle CORS preflight
|
|
163
|
+
*/
|
|
164
|
+
export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
|
|
165
|
+
return handleCorsOptions(request, ["GET"]);
|
|
166
|
+
}
|