@setzkasten-cms/astro-admin 0.8.0 → 1.3.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/package.json +22 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
- package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +152 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +134 -13
- package/src/api-routes/_feature-gate.ts +39 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +77 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +8 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +37 -11
- package/src/api-routes/catalog-add.ts +9 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +12 -5
- package/src/api-routes/editors.ts +94 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/init-add-section.ts +13 -5
- package/src/api-routes/init-apply.ts +5 -3
- package/src/api-routes/init-migrate.ts +7 -5
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +118 -4
- package/src/api-routes/section-add.ts +15 -5
- package/src/api-routes/section-commit-pending.ts +117 -5
- package/src/api-routes/section-delete.ts +29 -5
- package/src/api-routes/section-duplicate.ts +15 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +9 -5
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +71 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-register.ts +37 -25
- package/src/api-routes/updater-transfer.ts +1 -12
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +42 -4
- package/LICENSE +0 -37
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Transfer a license to the current Setzkasten instance.
|
|
6
|
+
* The backend identifies the license by instanceId (computed from repoUrl + websiteUrl).
|
|
6
7
|
*
|
|
7
8
|
* POST /api/setzkasten/updater/transfer
|
|
8
9
|
*/
|
|
@@ -18,26 +19,16 @@ export const POST: APIRoute = async ({ cookies }) => {
|
|
|
18
19
|
storage?: { owner?: string; repo?: string }
|
|
19
20
|
} | undefined
|
|
20
21
|
|
|
21
|
-
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
|
|
22
|
-
license?: { email?: string; key?: string }
|
|
23
|
-
} | undefined
|
|
24
|
-
|
|
25
22
|
const updaterUrl = config?.updaterUrl
|
|
26
23
|
if (!updaterUrl) {
|
|
27
24
|
return Response.json({ error: 'Updater not configured' }, { status: 400 })
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
const license = fullConfig?.license
|
|
31
|
-
if (!license?.key || !license?.email) {
|
|
32
|
-
return Response.json({ error: 'No license configured' }, { status: 400 })
|
|
33
|
-
}
|
|
34
|
-
|
|
35
27
|
const owner = config?.storage?.owner ?? ''
|
|
36
28
|
const repo = config?.storage?.repo ?? ''
|
|
37
29
|
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
38
30
|
const websiteUrl = config?.websiteUrl ?? ''
|
|
39
31
|
|
|
40
|
-
// Compute deterministic instanceId (same as backend register)
|
|
41
32
|
const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
|
|
42
33
|
const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
|
|
43
34
|
|
|
@@ -46,8 +37,6 @@ export const POST: APIRoute = async ({ cookies }) => {
|
|
|
46
37
|
method: 'POST',
|
|
47
38
|
headers: { 'Content-Type': 'application/json' },
|
|
48
39
|
body: JSON.stringify({
|
|
49
|
-
licenseKey: license.key,
|
|
50
|
-
licenseEmail: license.email,
|
|
51
40
|
toInstanceId: instanceId,
|
|
52
41
|
toWebsiteUrl: websiteUrl,
|
|
53
42
|
}),
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { requireAdmin } from './_auth-guard'
|
|
3
|
+
import { getWebhookStatus } from './_webhook-status-store'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/setzkasten/webhooks/status
|
|
7
|
+
*
|
|
8
|
+
* Returns the in-memory status map (lastFiredAt + lastStatus per webhook
|
|
9
|
+
* id). Empty for cold-started instances; the UI handles that gracefully
|
|
10
|
+
* with "noch nie gefeuert".
|
|
11
|
+
*/
|
|
12
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
13
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
14
|
+
if (denied) return denied
|
|
15
|
+
|
|
16
|
+
return Response.json({ status: getWebhookStatus() })
|
|
17
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import {
|
|
3
|
+
parseWebhooksFile,
|
|
4
|
+
type WebhookConfig,
|
|
5
|
+
type WebhookPayload,
|
|
6
|
+
} from '@setzkasten-cms/core'
|
|
7
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
9
|
+
import { parseSession, requireAdmin } from './_auth-guard'
|
|
10
|
+
import { gateFeature } from './_feature-gate'
|
|
11
|
+
import { recordWebhookFire } from './_webhook-status-store'
|
|
12
|
+
import { signPayload } from './_webhook-signing'
|
|
13
|
+
|
|
14
|
+
const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
|
|
15
|
+
const TEST_TIMEOUT_MS = 10_000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST /api/setzkasten/webhooks/test
|
|
19
|
+
* Body: { id: string }
|
|
20
|
+
*
|
|
21
|
+
* Fires a synthetic test payload at the configured URL and returns
|
|
22
|
+
* { ok, status, latencyMs }. Admin-only, Pro-gated. Used by the UI
|
|
23
|
+
* "Test-Fire"-Button to verify a webhook config end-to-end.
|
|
24
|
+
*/
|
|
25
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
26
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
27
|
+
if (denied) return denied
|
|
28
|
+
|
|
29
|
+
const gate = gateFeature('webhooks')
|
|
30
|
+
if (gate) return gate
|
|
31
|
+
|
|
32
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
33
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
34
|
+
|
|
35
|
+
let body: { id?: string }
|
|
36
|
+
try {
|
|
37
|
+
body = (await request.json()) as { id?: string }
|
|
38
|
+
} catch {
|
|
39
|
+
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
|
|
40
|
+
}
|
|
41
|
+
if (!body.id) {
|
|
42
|
+
return Response.json({ error: 'id is required' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
46
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
47
|
+
|
|
48
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
49
|
+
if (!storage) {
|
|
50
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
51
|
+
}
|
|
52
|
+
const { owner, repo, branch } = storage
|
|
53
|
+
|
|
54
|
+
const serverConfig = (globalThis as {
|
|
55
|
+
__SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
|
|
56
|
+
}).__SETZKASTEN_CONFIG__
|
|
57
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
58
|
+
|
|
59
|
+
// Read webhooks file directly (no cache — we want the freshly-saved value)
|
|
60
|
+
const fileRes = await fetch(
|
|
61
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
|
|
62
|
+
{
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
65
|
+
Accept: 'application/vnd.github+json',
|
|
66
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
if (fileRes.status === 404) {
|
|
71
|
+
return Response.json({ error: 'No webhooks configured' }, { status: 404 })
|
|
72
|
+
}
|
|
73
|
+
if (!fileRes.ok) {
|
|
74
|
+
return Response.json({ error: 'Could not read webhooks file' }, { status: 502 })
|
|
75
|
+
}
|
|
76
|
+
const data = (await fileRes.json()) as { content: string; encoding: string }
|
|
77
|
+
const raw =
|
|
78
|
+
data.encoding === 'base64'
|
|
79
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
80
|
+
: data.content
|
|
81
|
+
const parsed = parseWebhooksFile(raw)
|
|
82
|
+
if (!parsed.ok) {
|
|
83
|
+
return Response.json({ error: 'Webhooks file is malformed' }, { status: 502 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const target: WebhookConfig | undefined = parsed.value.webhooks.find((w) => w.id === body.id)
|
|
87
|
+
if (!target) {
|
|
88
|
+
return Response.json({ error: `Webhook "${body.id}" not found` }, { status: 404 })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build test payload — flagged as a test so receivers can ignore it
|
|
92
|
+
const payload: WebhookPayload & { test: boolean } = {
|
|
93
|
+
event: 'content.save',
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
website: { id: owner, repo: `${owner}/${repo}`, branch },
|
|
96
|
+
user: { email: session.user.email, name: session.user.name },
|
|
97
|
+
commit: { sha: '0'.repeat(40), message: 'test webhook fire' },
|
|
98
|
+
files: [],
|
|
99
|
+
test: true,
|
|
100
|
+
}
|
|
101
|
+
const payloadBody = JSON.stringify(payload)
|
|
102
|
+
|
|
103
|
+
const headers: Record<string, string> = {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'X-Setzkasten-Event': 'content.save',
|
|
106
|
+
'X-Setzkasten-Delivery': crypto.randomUUID(),
|
|
107
|
+
'X-Setzkasten-Test': 'true',
|
|
108
|
+
}
|
|
109
|
+
if (target.secret) {
|
|
110
|
+
headers['X-Setzkasten-Signature'] = `sha256=${signPayload(payloadBody, target.secret)}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const startedAt = Date.now()
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(target.url, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers,
|
|
118
|
+
body: payloadBody,
|
|
119
|
+
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
|
120
|
+
})
|
|
121
|
+
const latencyMs = Date.now() - startedAt
|
|
122
|
+
recordWebhookFire(target.id, res.status)
|
|
123
|
+
return Response.json({ ok: res.ok, status: res.status, latencyMs })
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const latencyMs = Date.now() - startedAt
|
|
126
|
+
recordWebhookFire(target.id, 'error')
|
|
127
|
+
return Response.json({
|
|
128
|
+
ok: false,
|
|
129
|
+
status: 'error',
|
|
130
|
+
latencyMs,
|
|
131
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import {
|
|
3
|
+
parseWebhooksFile,
|
|
4
|
+
validateWebhookConfig,
|
|
5
|
+
type WebhookConfig,
|
|
6
|
+
} from '@setzkasten-cms/core'
|
|
7
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
9
|
+
import { parseSession, requireAdmin } from './_auth-guard'
|
|
10
|
+
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
11
|
+
import { withTrailers } from './_commit-trailers'
|
|
12
|
+
import { gateFeature } from './_feature-gate'
|
|
13
|
+
|
|
14
|
+
const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// GET /api/setzkasten/webhooks — list (admin only, cached 60s)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const GET: APIRoute = async ({ request, cookies }) => {
|
|
21
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
22
|
+
if (denied) return denied
|
|
23
|
+
|
|
24
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
25
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
26
|
+
|
|
27
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
28
|
+
if (!storage) {
|
|
29
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
const { owner, repo, branch } = storage
|
|
32
|
+
const contentPath = resolveContentPath()
|
|
33
|
+
|
|
34
|
+
const cacheKey = `webhooks:${owner}/${repo}:${branch}`
|
|
35
|
+
const webhooks = await cachedFetch(cacheKey, 60_000, async () => {
|
|
36
|
+
const res = await fetch(
|
|
37
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
|
|
38
|
+
{
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
41
|
+
Accept: 'application/vnd.github+json',
|
|
42
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
if (res.status === 404) return [] as readonly WebhookConfig[]
|
|
47
|
+
if (!res.ok) return null
|
|
48
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
49
|
+
const raw =
|
|
50
|
+
data.encoding === 'base64'
|
|
51
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
52
|
+
: data.content
|
|
53
|
+
const parsed = parseWebhooksFile(raw)
|
|
54
|
+
return parsed.ok ? parsed.value.webhooks : null
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (webhooks === null) {
|
|
58
|
+
return Response.json({ error: 'Could not read webhooks file' }, { status: 502 })
|
|
59
|
+
}
|
|
60
|
+
return Response.json({ webhooks })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// PUT /api/setzkasten/webhooks — replace whole list (admin + Pro-gated)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
68
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
69
|
+
if (denied) return denied
|
|
70
|
+
|
|
71
|
+
const gate = gateFeature('webhooks')
|
|
72
|
+
if (gate) return gate
|
|
73
|
+
|
|
74
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
75
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
76
|
+
|
|
77
|
+
let body: { webhooks?: unknown }
|
|
78
|
+
try {
|
|
79
|
+
body = (await request.json()) as { webhooks?: unknown }
|
|
80
|
+
} catch {
|
|
81
|
+
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(body.webhooks)) {
|
|
84
|
+
return Response.json({ error: 'webhooks must be an array' }, { status: 400 })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const validated: WebhookConfig[] = []
|
|
88
|
+
const seenIds = new Set<string>()
|
|
89
|
+
for (let i = 0; i < body.webhooks.length; i++) {
|
|
90
|
+
const result = validateWebhookConfig(body.webhooks[i])
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
return Response.json(
|
|
93
|
+
{ error: result.error.message, index: i },
|
|
94
|
+
{ status: 400 },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (seenIds.has(result.value.id)) {
|
|
98
|
+
return Response.json(
|
|
99
|
+
{ error: `Duplicate webhook id: ${result.value.id}`, index: i },
|
|
100
|
+
{ status: 400 },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
seenIds.add(result.value.id)
|
|
104
|
+
validated.push(result.value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
108
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
109
|
+
|
|
110
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
111
|
+
if (!storage) {
|
|
112
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
113
|
+
}
|
|
114
|
+
const { owner, repo, branch } = storage
|
|
115
|
+
const contentPath = resolveContentPath()
|
|
116
|
+
const filePath = WEBHOOKS_FILE(contentPath)
|
|
117
|
+
|
|
118
|
+
const headers = {
|
|
119
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
120
|
+
Accept: 'application/vnd.github+json',
|
|
121
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read current SHA for in-place updates
|
|
126
|
+
let currentSha: string | null = null
|
|
127
|
+
const existingRes = await fetch(
|
|
128
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
|
|
129
|
+
{ headers },
|
|
130
|
+
)
|
|
131
|
+
if (existingRes.ok) {
|
|
132
|
+
const data = (await existingRes.json()) as { sha: string }
|
|
133
|
+
currentSha = data.sha
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fileBody = JSON.stringify({ version: 1, webhooks: validated }, null, 2)
|
|
137
|
+
const putBody: Record<string, unknown> = {
|
|
138
|
+
message: withTrailers('chore(webhooks): update webhook configuration', session.user.email),
|
|
139
|
+
content: Buffer.from(fileBody).toString('base64'),
|
|
140
|
+
branch,
|
|
141
|
+
}
|
|
142
|
+
if (currentSha) putBody.sha = currentSha
|
|
143
|
+
|
|
144
|
+
const putRes = await fetch(
|
|
145
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
|
|
146
|
+
{ method: 'PUT', headers, body: JSON.stringify(putBody) },
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if (!putRes.ok) {
|
|
150
|
+
const text = await putRes.text()
|
|
151
|
+
return Response.json({ error: `Webhook write failed: ${text}` }, { status: 502 })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
invalidateCache(`webhooks:${owner}/${repo}:${branch}`)
|
|
155
|
+
return Response.json({ ok: true, webhooks: validated })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveContentPath(): string {
|
|
159
|
+
const serverConfig = (globalThis as {
|
|
160
|
+
__SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
|
|
161
|
+
}).__SETZKASTEN_CONFIG__
|
|
162
|
+
return serverConfig?.storage?.contentPath ?? 'content'
|
|
163
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type WebsiteEntry,
|
|
3
|
+
addWebsiteToRegistry,
|
|
4
|
+
canAddWebsite,
|
|
5
|
+
parseWebsitesRegistry,
|
|
6
|
+
} from '@setzkasten-cms/core'
|
|
7
|
+
import type { APIRoute } from 'astro'
|
|
8
|
+
import { requireAdmin } from './_auth-guard'
|
|
9
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
10
|
+
import { resolveLicenseTier } from './_license-tier'
|
|
11
|
+
import {
|
|
12
|
+
readWebsitesRegistryFromGitHub,
|
|
13
|
+
resolveConfigRepoTargetFromGlobals,
|
|
14
|
+
writeWebsitesRegistryToGitHub,
|
|
15
|
+
} from './_websites-store'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST /api/setzkasten/websites/add
|
|
19
|
+
*
|
|
20
|
+
* Body: { entry: WebsiteEntry }
|
|
21
|
+
*
|
|
22
|
+
* Reads the current websites.json from the config-repo, adds the entry
|
|
23
|
+
* (validated via parseWebsitesRegistry to share one truth on what makes
|
|
24
|
+
* a valid WebsiteEntry), and commits the new file back via the GitHub
|
|
25
|
+
* Contents API.
|
|
26
|
+
*/
|
|
27
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
28
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
29
|
+
if (denied) return denied
|
|
30
|
+
|
|
31
|
+
let parsed: { entry?: WebsiteEntry } = {}
|
|
32
|
+
try {
|
|
33
|
+
parsed = (await request.json()) as { entry?: WebsiteEntry }
|
|
34
|
+
} catch {
|
|
35
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
if (!parsed.entry || typeof parsed.entry !== 'object') {
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Missing "entry" in body' }), { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The form leaves githubApp.appId blank when the user wants to fall back
|
|
42
|
+
// to GITHUB_APP_ID from the env. Inject it before validation so the
|
|
43
|
+
// registry sees a complete entry. The App-ID itself is not sensitive
|
|
44
|
+
// (it's public on every App's settings page on github.com), so writing
|
|
45
|
+
// it into websites.json is fine — but doing it server-side keeps the
|
|
46
|
+
// client form simpler.
|
|
47
|
+
const entry = parsed.entry as unknown as {
|
|
48
|
+
githubApp?: { appId?: string; installationId?: string }
|
|
49
|
+
}
|
|
50
|
+
if (entry.githubApp && !entry.githubApp.appId) {
|
|
51
|
+
const envAppId = process.env.GITHUB_APP_ID
|
|
52
|
+
if (envAppId) entry.githubApp.appId = envAppId
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Reuse parseWebsitesRegistry's per-entry validation by stuffing the new
|
|
56
|
+
// entry into a one-element registry — same rules everywhere.
|
|
57
|
+
const validation = parseWebsitesRegistry(JSON.stringify({ websites: [parsed.entry] }))
|
|
58
|
+
if (!validation.ok) {
|
|
59
|
+
return new Response(JSON.stringify({ error: validation.error.message }), { status: 400 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
63
|
+
if (!tokenResult.ok) {
|
|
64
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
|
|
68
|
+
if (!target) {
|
|
69
|
+
return new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
error: 'Multi-mode storage not configured (storage.kind must be "multi").',
|
|
72
|
+
}),
|
|
73
|
+
{ status: 400 },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const current = await readWebsitesRegistryFromGitHub(target)
|
|
78
|
+
if (!current.ok) {
|
|
79
|
+
return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// License gate: enforce the per-tier website limit before mutating GitHub.
|
|
83
|
+
// 402 Payment Required is the most accurate code here — the registry would
|
|
84
|
+
// accept the entry, only the license disagrees.
|
|
85
|
+
const tier = resolveLicenseTier()
|
|
86
|
+
const allowed = canAddWebsite(tier, current.value.registry.websites.length)
|
|
87
|
+
if (!allowed.ok) {
|
|
88
|
+
return new Response(
|
|
89
|
+
JSON.stringify({ error: allowed.reason, tier: allowed.tier, limit: allowed.limit }),
|
|
90
|
+
{ status: 402, headers: { 'Content-Type': 'application/json' } },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const merged = addWebsiteToRegistry(current.value.registry, parsed.entry)
|
|
95
|
+
if (!merged.ok) {
|
|
96
|
+
return new Response(JSON.stringify({ error: merged.error.message }), { status: 409 })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const written = await writeWebsitesRegistryToGitHub(
|
|
100
|
+
target,
|
|
101
|
+
merged.value,
|
|
102
|
+
current.value.sha,
|
|
103
|
+
`feat(websites): add "${parsed.entry.id}" to registry`,
|
|
104
|
+
)
|
|
105
|
+
if (!written.ok) {
|
|
106
|
+
return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Response(JSON.stringify({ ok: true, websites: merged.value.websites }), {
|
|
110
|
+
status: 200,
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { listAllWebsites } from './_website-resolver.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/setzkasten/websites
|
|
6
|
+
*
|
|
7
|
+
* Returns the list of managed websites visible to the admin SPA.
|
|
8
|
+
* Strips private fields (`githubApp` installation refs, `allowedEmails`)
|
|
9
|
+
* — the client only needs ids/names/repos for the switcher.
|
|
10
|
+
*/
|
|
11
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
12
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
13
|
+
if (!session) {
|
|
14
|
+
return new Response(JSON.stringify({ authenticated: false }), {
|
|
15
|
+
status: 401,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = await listAllWebsites()
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
return new Response(JSON.stringify({ error: result.error.message }), {
|
|
23
|
+
status: 500,
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const websites = result.value.map((entry) => ({
|
|
29
|
+
id: entry.id,
|
|
30
|
+
name: entry.name,
|
|
31
|
+
repo: entry.repo,
|
|
32
|
+
branch: entry.branch,
|
|
33
|
+
previewOrigin: entry.previewOrigin,
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
return new Response(JSON.stringify({ websites }), {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { removeWebsiteFromRegistry } from '@setzkasten-cms/core'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { requireAdmin } from './_auth-guard'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
5
|
+
import {
|
|
6
|
+
readWebsitesRegistryFromGitHub,
|
|
7
|
+
resolveConfigRepoTargetFromGlobals,
|
|
8
|
+
writeWebsitesRegistryToGitHub,
|
|
9
|
+
} from './_websites-store'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST /api/setzkasten/websites/remove
|
|
13
|
+
*
|
|
14
|
+
* Body: { id: string }
|
|
15
|
+
*
|
|
16
|
+
* Drops the matching entry from websites.json and commits the new file.
|
|
17
|
+
* Admin-only — editors must not be able to detach websites from the
|
|
18
|
+
* registry. Returns 404 when the id is not present, 502 when GitHub I/O
|
|
19
|
+
* fails.
|
|
20
|
+
*/
|
|
21
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
22
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
23
|
+
if (denied) return denied
|
|
24
|
+
|
|
25
|
+
let body: { id?: string } = {}
|
|
26
|
+
try {
|
|
27
|
+
body = (await request.json()) as { id?: string }
|
|
28
|
+
} catch {
|
|
29
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
if (!body.id || typeof body.id !== 'string') {
|
|
32
|
+
return new Response(JSON.stringify({ error: 'Missing "id" in body' }), { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
36
|
+
if (!tokenResult.ok) {
|
|
37
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
|
|
41
|
+
if (!target) {
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
error: 'Multi-mode storage not configured (storage.kind must be "multi").',
|
|
45
|
+
}),
|
|
46
|
+
{ status: 400 },
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = await readWebsitesRegistryFromGitHub(target)
|
|
51
|
+
if (!current.ok) {
|
|
52
|
+
return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = removeWebsiteFromRegistry(current.value.registry, body.id)
|
|
56
|
+
if (!next.ok) {
|
|
57
|
+
return new Response(JSON.stringify({ error: next.error.message }), { status: 404 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const written = await writeWebsitesRegistryToGitHub(
|
|
61
|
+
target,
|
|
62
|
+
next.value,
|
|
63
|
+
current.value.sha,
|
|
64
|
+
`chore(websites): remove "${body.id}" from registry`,
|
|
65
|
+
)
|
|
66
|
+
if (!written.ok) {
|
|
67
|
+
return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new Response(JSON.stringify({ ok: true, websites: next.value.websites }), {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -25,9 +25,42 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { describe, it, expect } from 'vitest'
|
|
28
|
-
import { patchTemplateForFields, stripTemplateFallbacks } from '../../init/template-patcher-v2'
|
|
28
|
+
import { patchTemplateForFields, stripTemplateFallbacks, convertToSetHtml } from '../../init/template-patcher-v2'
|
|
29
29
|
import type { PatchField } from '../../init/template-patcher-v2'
|
|
30
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// convertToSetHtml: auto-upgrade `<tag>{x?.field}</tag>` → `<tag set:html=…>`
|
|
33
|
+
// when the inline RTE introduces HTML markup. Regression: previously the
|
|
34
|
+
// regex only matched skData?.field / item.field, missing hand-rolled section
|
|
35
|
+
// components (apps/website/...) that destructure `data` from Astro.props.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('convertToSetHtml — variable-name flexibility', () => {
|
|
39
|
+
it('converts data?.field bindings (Astro.props destructure pattern)', () => {
|
|
40
|
+
const source = `<h2 data-sk-field="how.heading">{data?.heading}</h2>`
|
|
41
|
+
expect(convertToSetHtml(source)).toBe(
|
|
42
|
+
`<h2 data-sk-field="how.heading" set:html={data?.heading}></h2>`,
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('still converts skData?.field bindings (canonical pattern)', () => {
|
|
47
|
+
const source = `<h2 data-sk-field="how.heading">{skData?.heading}</h2>`
|
|
48
|
+
expect(convertToSetHtml(source)).toBe(
|
|
49
|
+
`<h2 data-sk-field="how.heading" set:html={skData?.heading}></h2>`,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is idempotent — already-converted templates pass through unchanged', () => {
|
|
54
|
+
const source = `<h2 data-sk-field="how.heading" set:html={data?.heading}></h2>`
|
|
55
|
+
expect(convertToSetHtml(source)).toBe(source)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('leaves data-sk-field elements with non-binding content alone', () => {
|
|
59
|
+
const source = `<h2 data-sk-field="how.heading">Static</h2>`
|
|
60
|
+
expect(convertToSetHtml(source)).toBe(source)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
31
64
|
// ---------------------------------------------------------------------------
|
|
32
65
|
// Edge Case 1: UTF-8 / multi-byte characters
|
|
33
66
|
//
|