@soloworks/smking-next 0.10.0 → 0.12.1
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 +77 -0
- package/README.md +65 -0
- package/bin/{install.ts → install.mjs} +47 -33
- package/package.json +10 -32
- package/src/components/smking-cms.tsx +96 -29
- package/src/lib/client.ts +6 -3
- package/src/lib/cms-client.ts +5 -4
- package/src/lib/crawlers.ts +136 -0
- package/src/lib/path.ts +1 -1
- package/src/lib/proxy.ts +147 -0
- package/src/lib/webhook-route.ts +126 -0
- package/src/types.ts +62 -5
- package/src/components/cms-nodes/gallery-extension.ts +0 -32
- package/src/components/cms-nodes/gallery-node.tsx +0 -68
- package/src/lib/cms-webhook-route.ts +0 -107
- package/src/route.ts +0 -112
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { revalidateTag } from "next/cache";
|
|
2
|
-
import { Buffer } from "node:buffer";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* SmKing CMS publish webhook handler for Next.js.
|
|
7
|
-
*
|
|
8
|
-
* Drop-in install:
|
|
9
|
-
*
|
|
10
|
-
* ```ts
|
|
11
|
-
* // app/api/smking/webhook/route.ts
|
|
12
|
-
* export { POST } from "@soloworks/smking-next/cms-webhook";
|
|
13
|
-
* ```
|
|
14
|
-
*
|
|
15
|
-
* Then set `SMKING_WEBHOOK_SECRET` in your env and paste this route's
|
|
16
|
-
* full URL (e.g. `https://your-site.com/api/smking/webhook`) into the
|
|
17
|
-
* SmKing dashboard's site settings webhook field.
|
|
18
|
-
*
|
|
19
|
-
* On a verified `cms.page.published` event the handler calls
|
|
20
|
-
* `revalidateTag("smking:cms:<slug>", "default")` — invalidates the
|
|
21
|
-
* cached `getCmsPage` fetch tagged with that slug so the next page
|
|
22
|
-
* render reads fresh content from SaaS.
|
|
23
|
-
*
|
|
24
|
-
* Wire shape matches @smking-saas/features/cms/lib/webhook.ts and the
|
|
25
|
-
* Laravel SDK WebhookController:
|
|
26
|
-
*
|
|
27
|
-
* POST /api/smking/webhook
|
|
28
|
-
* X-Smking-Signature: sha256=<hex>
|
|
29
|
-
* X-Smking-Event: cms.page.published
|
|
30
|
-
* { event, siteId, slug, publishedAt, deliveredAt }
|
|
31
|
-
*
|
|
32
|
-
* HMAC-SHA256 sig verification runs constant-time via `timingSafeEqual`
|
|
33
|
-
* to prevent secret-extraction via response latency. Unknown event
|
|
34
|
-
* types accept (200) without action so SaaS doesn't retry — forward-
|
|
35
|
-
* compat for future event types (cms.page.unpublished / deleted).
|
|
36
|
-
*/
|
|
37
|
-
export async function POST(request: Request): Promise<Response> {
|
|
38
|
-
const secret = process.env.SMKING_WEBHOOK_SECRET;
|
|
39
|
-
if (!secret) {
|
|
40
|
-
return Response.json(
|
|
41
|
-
{ error: "webhook_secret_missing" },
|
|
42
|
-
{ status: 503 },
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Raw body for HMAC verify — re-encoding via JSON.parse + stringify
|
|
47
|
-
// would change byte order / spacing and invalidate the signature.
|
|
48
|
-
const rawBody = await request.text();
|
|
49
|
-
const providedSig = request.headers.get("x-smking-signature");
|
|
50
|
-
if (!verifySignature(rawBody, providedSig, secret)) {
|
|
51
|
-
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let payload: { event?: string; slug?: string };
|
|
55
|
-
try {
|
|
56
|
-
payload = JSON.parse(rawBody) as { event?: string; slug?: string };
|
|
57
|
-
} catch {
|
|
58
|
-
return Response.json({ error: "invalid_payload" }, { status: 400 });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
payload.event !== "cms.page.published" ||
|
|
63
|
-
typeof payload.slug !== "string" ||
|
|
64
|
-
payload.slug.length === 0
|
|
65
|
-
) {
|
|
66
|
-
// Forward-compat: unknown event types ack-without-action so SaaS
|
|
67
|
-
// doesn't retry. Future event types (cms.page.unpublished) branch
|
|
68
|
-
// here without breaking older customer SDKs.
|
|
69
|
-
return Response.json({ ok: true, note: "no_action_taken" });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
// Next.js 16 requires explicit cache profile; "default" matches the
|
|
74
|
-
// profile a normal `'use cache'` block uses.
|
|
75
|
-
revalidateTag(`smking:cms:${payload.slug}`, "default");
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.warn(
|
|
78
|
-
`[@soloworks/smking-next/cms-webhook] revalidateTag failed for slug "${payload.slug}":`,
|
|
79
|
-
err,
|
|
80
|
-
);
|
|
81
|
-
return Response.json({ error: "revalidate_failed" }, { status: 500 });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return Response.json({ ok: true, evicted: payload.slug });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function verifySignature(
|
|
88
|
-
rawBody: string,
|
|
89
|
-
signatureHeader: string | null,
|
|
90
|
-
secret: string,
|
|
91
|
-
): boolean {
|
|
92
|
-
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
|
|
93
|
-
const provided = signatureHeader.slice("sha256=".length);
|
|
94
|
-
const expected = crypto
|
|
95
|
-
.createHmac("sha256", secret)
|
|
96
|
-
.update(rawBody)
|
|
97
|
-
.digest("hex");
|
|
98
|
-
if (expected.length !== provided.length) return false;
|
|
99
|
-
try {
|
|
100
|
-
return crypto.timingSafeEqual(
|
|
101
|
-
Buffer.from(expected, "hex"),
|
|
102
|
-
Buffer.from(provided, "hex"),
|
|
103
|
-
);
|
|
104
|
-
} catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
package/src/route.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { revalidateTag } from "next/cache";
|
|
2
|
-
import { Buffer } from "node:buffer";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
|
|
5
|
-
interface WebhookPayload {
|
|
6
|
-
paths?: string[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const BEARER_PREFIX = "Bearer ";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Webhook handler for SaaS-pushed cache invalidation. Drop-in install:
|
|
13
|
-
*
|
|
14
|
-
* ```ts
|
|
15
|
-
* // app/api/smking-revalidate/route.ts
|
|
16
|
-
* export { POST, GET } from '@soloworks/smking-next/route';
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Then set `SMKING_WEBHOOK_TOKEN` in your environment and configure the
|
|
20
|
-
* SaaS to POST `{ paths: [...] }` with `Authorization: Bearer <token>`.
|
|
21
|
-
*
|
|
22
|
-
* Bearer-token auth (not HMAC body signing) — sufficient for v1.
|
|
23
|
-
* HMAC-signed body verification can layer on later if attack surface
|
|
24
|
-
* justifies it. Token compare runs through `crypto.timingSafeEqual` to
|
|
25
|
-
* avoid leaking length / prefix info via response time.
|
|
26
|
-
*/
|
|
27
|
-
export async function POST(request: Request): Promise<Response> {
|
|
28
|
-
const expected = process.env.SMKING_WEBHOOK_TOKEN;
|
|
29
|
-
if (!expected) {
|
|
30
|
-
return Response.json(
|
|
31
|
-
{ error: "webhook_not_configured" },
|
|
32
|
-
{ status: 503 },
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const auth = request.headers.get("authorization");
|
|
37
|
-
if (!auth || !verifyBearer(auth, expected)) {
|
|
38
|
-
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let payload: WebhookPayload;
|
|
42
|
-
try {
|
|
43
|
-
payload = (await request.json()) as WebhookPayload;
|
|
44
|
-
} catch {
|
|
45
|
-
return Response.json({ error: "invalid_json" }, { status: 400 });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let revalidated = 0;
|
|
49
|
-
let errors = 0;
|
|
50
|
-
for (const p of payload.paths ?? []) {
|
|
51
|
-
try {
|
|
52
|
-
// Next.js 16 requires a cache profile; 'default' matches the
|
|
53
|
-
// implicit profile a normal `'use cache'` block uses.
|
|
54
|
-
revalidateTag(`smking:path:${p}`, "default");
|
|
55
|
-
revalidated++;
|
|
56
|
-
} catch (err) {
|
|
57
|
-
errors++;
|
|
58
|
-
console.warn(
|
|
59
|
-
`[@soloworks/smking-next] revalidateTag failed for path "${p}":`,
|
|
60
|
-
err,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return Response.json({ revalidated, errors });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Liveness probe — POST-only public endpoint, but `GET` returns 401 with
|
|
70
|
-
* a `WWW-Authenticate` header so customers can verify the route is wired
|
|
71
|
-
* up by sending a token. Discourages anonymous probes from confirming
|
|
72
|
-
* the endpoint exists.
|
|
73
|
-
*/
|
|
74
|
-
export async function GET(request: Request): Promise<Response> {
|
|
75
|
-
const expected = process.env.SMKING_WEBHOOK_TOKEN;
|
|
76
|
-
if (!expected) {
|
|
77
|
-
return Response.json(
|
|
78
|
-
{ error: "webhook_not_configured" },
|
|
79
|
-
{ status: 503 },
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
const auth = request.headers.get("authorization");
|
|
83
|
-
if (auth && verifyBearer(auth, expected)) {
|
|
84
|
-
return new Response("OK", { status: 200 });
|
|
85
|
-
}
|
|
86
|
-
return new Response("Unauthorized", {
|
|
87
|
-
status: 401,
|
|
88
|
-
headers: { "WWW-Authenticate": 'Bearer realm="smking-webhook"' },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Constant-time bearer-token compare. Returns false on:
|
|
94
|
-
* - missing or non-Bearer header
|
|
95
|
-
* - presented token has different length than expected (early-out;
|
|
96
|
-
* the lengths themselves leak nothing because the secret is set by
|
|
97
|
-
* the customer with a known length)
|
|
98
|
-
* - byte-different but same-length token
|
|
99
|
-
*/
|
|
100
|
-
function verifyBearer(authHeader: string, expected: string): boolean {
|
|
101
|
-
if (!authHeader.startsWith(BEARER_PREFIX)) return false;
|
|
102
|
-
const presented = authHeader.slice(BEARER_PREFIX.length);
|
|
103
|
-
if (presented.length !== expected.length) return false;
|
|
104
|
-
try {
|
|
105
|
-
return crypto.timingSafeEqual(
|
|
106
|
-
Buffer.from(presented),
|
|
107
|
-
Buffer.from(expected),
|
|
108
|
-
);
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|