@setzkasten-cms/astro-admin 1.1.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 +12 -6
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -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__/setup-github-app-callback.test.ts +7 -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/_feature-gate.ts +39 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_storage-config.ts +15 -2
- 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/auth-callback.ts +2 -0
- package/src/api-routes/auth-setzkasten-login.ts +16 -1
- package/src/api-routes/editors.ts +15 -0
- 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/section-commit-pending.ts +108 -9
- package/src/api-routes/section-delete.ts +14 -0
- package/src/api-routes/setup-github-app-callback.ts +20 -2
- package/src/api-routes/updater-register.ts +31 -2
- 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/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/template-patcher-v2.ts +9 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
3
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
|
+
import { parseSession, requireAdmin } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { invalidateCache } from './_github-cache'
|
|
7
|
+
|
|
8
|
+
interface RollbackBody {
|
|
9
|
+
path?: string
|
|
10
|
+
sha?: string
|
|
11
|
+
/** Optional ETag-style guard: client sends the SHA they think is HEAD;
|
|
12
|
+
* if HEAD has moved, we 409 to prevent stomping live edits. */
|
|
13
|
+
expectedHeadSha?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* POST /api/setzkasten/history/rollback
|
|
18
|
+
*
|
|
19
|
+
* Body: { path, sha, expectedHeadSha? }
|
|
20
|
+
*
|
|
21
|
+
* Restores `path` to the contents from `sha` by writing a new commit
|
|
22
|
+
* (no `git revert` — JSON content is set wholesale). The original SHA
|
|
23
|
+
* stays in history so users can roll forward again.
|
|
24
|
+
*
|
|
25
|
+
* Conflict semantics: the client passes the SHA they currently render in
|
|
26
|
+
* the file picker. If the file's HEAD has moved between page-load and the
|
|
27
|
+
* rollback click, we return 409 with `code: 'head-moved'` so the UI can
|
|
28
|
+
* tell the user to refresh.
|
|
29
|
+
*
|
|
30
|
+
* Admin-only — editors can edit, but rollback is destructive enough to
|
|
31
|
+
* warrant the audit-log control.
|
|
32
|
+
*/
|
|
33
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
34
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
35
|
+
if (denied) return denied
|
|
36
|
+
|
|
37
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
38
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
39
|
+
|
|
40
|
+
let body: RollbackBody
|
|
41
|
+
try {
|
|
42
|
+
body = (await request.json()) as RollbackBody
|
|
43
|
+
} catch {
|
|
44
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
45
|
+
}
|
|
46
|
+
const { path, sha, expectedHeadSha } = body
|
|
47
|
+
if (!path || !sha) {
|
|
48
|
+
return Response.json({ error: 'path and sha are required' }, { status: 400 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
52
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
53
|
+
|
|
54
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
55
|
+
if (!storage) {
|
|
56
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
57
|
+
}
|
|
58
|
+
const { owner, repo, branch } = storage
|
|
59
|
+
|
|
60
|
+
const headers = {
|
|
61
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
62
|
+
Accept: 'application/vnd.github+json',
|
|
63
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 1. Fetch contents of the file at the target sha
|
|
68
|
+
const versionRes = await fetch(
|
|
69
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${sha}`,
|
|
70
|
+
{ headers },
|
|
71
|
+
)
|
|
72
|
+
if (versionRes.status === 404) {
|
|
73
|
+
return Response.json(
|
|
74
|
+
{ error: 'File did not exist at the requested sha', code: 'version-not-found' },
|
|
75
|
+
{ status: 404 },
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
if (!versionRes.ok) {
|
|
79
|
+
return Response.json(
|
|
80
|
+
{ error: `Failed to read version: ${versionRes.status}` },
|
|
81
|
+
{ status: 502 },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const versionData = (await versionRes.json()) as {
|
|
85
|
+
content: string
|
|
86
|
+
encoding: string
|
|
87
|
+
}
|
|
88
|
+
const targetContent =
|
|
89
|
+
versionData.encoding === 'base64'
|
|
90
|
+
? Buffer.from(versionData.content, 'base64').toString('utf-8')
|
|
91
|
+
: versionData.content
|
|
92
|
+
|
|
93
|
+
// 2. Fetch current HEAD SHA of the file (for conflict detection + PUT sha param)
|
|
94
|
+
const headRes = await fetch(
|
|
95
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
96
|
+
{ headers },
|
|
97
|
+
)
|
|
98
|
+
let currentSha: string | null = null
|
|
99
|
+
if (headRes.ok) {
|
|
100
|
+
const data = (await headRes.json()) as { sha: string }
|
|
101
|
+
currentSha = data.sha
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (expectedHeadSha && currentSha && expectedHeadSha !== currentSha) {
|
|
105
|
+
return Response.json(
|
|
106
|
+
{
|
|
107
|
+
error: 'Datei wurde inzwischen geändert. Bitte den Verlauf neu laden.',
|
|
108
|
+
code: 'head-moved',
|
|
109
|
+
},
|
|
110
|
+
{ status: 409 },
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Write new commit with the historical content
|
|
115
|
+
const shortSha = sha.slice(0, 7)
|
|
116
|
+
const fileName = path.split('/').pop() ?? path
|
|
117
|
+
const message = withTrailers(
|
|
118
|
+
`revert(${fileName}): rollback to ${shortSha}`,
|
|
119
|
+
session.user.email,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const putBody: Record<string, unknown> = {
|
|
123
|
+
message,
|
|
124
|
+
content: Buffer.from(targetContent).toString('base64'),
|
|
125
|
+
branch,
|
|
126
|
+
}
|
|
127
|
+
if (currentSha) putBody.sha = currentSha
|
|
128
|
+
|
|
129
|
+
const putRes = await fetch(
|
|
130
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
|
|
131
|
+
{ method: 'PUT', headers, body: JSON.stringify(putBody) },
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if (!putRes.ok) {
|
|
135
|
+
const text = await putRes.text()
|
|
136
|
+
return Response.json({ error: `Rollback write failed: ${text}` }, { status: 502 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Invalidate history cache for this path — fresh commit shows up in lists.
|
|
140
|
+
invalidateCache(`history:${owner}/${repo}:${branch}:${path}:head`)
|
|
141
|
+
|
|
142
|
+
const putData = (await putRes.json()) as { commit: { sha: string } }
|
|
143
|
+
return Response.json({ ok: true, commitSha: putData.commit.sha })
|
|
144
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
3
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
|
+
import { requireAdmin } from './_auth-guard'
|
|
5
|
+
import { cachedFetch } from './_github-cache'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/setzkasten/history/version?path=<file>&sha=<commit-sha>
|
|
9
|
+
*
|
|
10
|
+
* Returns the file content at a specific commit (for diff rendering).
|
|
11
|
+
* Cached per (path, sha) for 5 minutes — historical content is immutable.
|
|
12
|
+
*/
|
|
13
|
+
export const GET: APIRoute = async ({ request, url, cookies }) => {
|
|
14
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
15
|
+
if (denied) return denied
|
|
16
|
+
|
|
17
|
+
const path = url.searchParams.get('path')
|
|
18
|
+
const sha = url.searchParams.get('sha')
|
|
19
|
+
if (!path || !sha) {
|
|
20
|
+
return Response.json({ error: 'Missing required `path` or `sha`.' }, { status: 400 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
24
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
25
|
+
|
|
26
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
27
|
+
if (!storage) {
|
|
28
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
29
|
+
}
|
|
30
|
+
const { owner, repo } = storage
|
|
31
|
+
|
|
32
|
+
const cacheKey = `history-version:${owner}/${repo}:${path}:${sha}`
|
|
33
|
+
const result = await cachedFetch(cacheKey, 5 * 60_000, async () => {
|
|
34
|
+
const u = new URL(
|
|
35
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
|
|
36
|
+
)
|
|
37
|
+
u.searchParams.set('ref', sha)
|
|
38
|
+
const res = await fetch(u, {
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
41
|
+
Accept: 'application/vnd.github+json',
|
|
42
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
if (res.status === 404) return { ok: false as const, status: 404, error: 'File not found at given sha' }
|
|
46
|
+
if (!res.ok) return { ok: false as const, status: 502, error: `GitHub returned ${res.status}` }
|
|
47
|
+
const data = (await res.json()) as { content: string; encoding: string; sha: string }
|
|
48
|
+
const raw =
|
|
49
|
+
data.encoding === 'base64'
|
|
50
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
51
|
+
: data.content
|
|
52
|
+
return { ok: true as const, value: { content: raw, sha: data.sha } }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!result.ok) return Response.json({ error: result.error }, { status: result.status })
|
|
56
|
+
return Response.json(result.value)
|
|
57
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
3
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
|
+
import { requireAdmin } from './_auth-guard'
|
|
5
|
+
import { parseCoAuthorTrailers, type CommitInfo } from '@setzkasten-cms/core'
|
|
6
|
+
import { cachedFetch } from './_github-cache'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/setzkasten/history?path=<contentPath>&before=<sha>
|
|
10
|
+
*
|
|
11
|
+
* Returns up to 5 most recent commits affecting the given file. Pagination
|
|
12
|
+
* via `before=<sha>` returns 10 more older commits — clients call this on
|
|
13
|
+
* "Mehr laden". Admin-only — editors can read content but not the audit
|
|
14
|
+
* trail (and certainly not roll back).
|
|
15
|
+
*/
|
|
16
|
+
export const GET: APIRoute = async ({ request, url, cookies }) => {
|
|
17
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
18
|
+
if (denied) return denied
|
|
19
|
+
|
|
20
|
+
const path = url.searchParams.get('path')
|
|
21
|
+
const before = url.searchParams.get('before')
|
|
22
|
+
if (!path) {
|
|
23
|
+
return Response.json({ error: 'Missing required `path` parameter.' }, { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
27
|
+
if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
|
|
28
|
+
|
|
29
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
30
|
+
if (!storage) {
|
|
31
|
+
return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
32
|
+
}
|
|
33
|
+
const { owner, repo, branch } = storage
|
|
34
|
+
const perPage = before ? 10 : 5
|
|
35
|
+
|
|
36
|
+
// Cache history per (path, before) for 60s — invalidated by rollback.
|
|
37
|
+
const cacheKey = `history:${owner}/${repo}:${branch}:${path}:${before ?? 'head'}`
|
|
38
|
+
const commits = await cachedFetch(cacheKey, 60_000, () =>
|
|
39
|
+
fetchCommits(owner, repo, branch, path, perPage, before, tokenResult.value),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (!commits.ok) {
|
|
43
|
+
return Response.json({ error: commits.error }, { status: commits.status })
|
|
44
|
+
}
|
|
45
|
+
return Response.json({ commits: commits.value })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CommitsListSuccess {
|
|
49
|
+
ok: true
|
|
50
|
+
value: readonly CommitInfo[]
|
|
51
|
+
}
|
|
52
|
+
interface CommitsListFailure {
|
|
53
|
+
ok: false
|
|
54
|
+
status: number
|
|
55
|
+
error: string
|
|
56
|
+
}
|
|
57
|
+
type CommitsResult = CommitsListSuccess | CommitsListFailure
|
|
58
|
+
|
|
59
|
+
async function fetchCommits(
|
|
60
|
+
owner: string,
|
|
61
|
+
repo: string,
|
|
62
|
+
branch: string,
|
|
63
|
+
path: string,
|
|
64
|
+
perPage: number,
|
|
65
|
+
before: string | null,
|
|
66
|
+
token: string,
|
|
67
|
+
): Promise<CommitsResult> {
|
|
68
|
+
// GitHub paginates by `?sha=<commit>` — passing `before` as `sha` gets
|
|
69
|
+
// commits older than (and including) that SHA. We start one before
|
|
70
|
+
// requested SHA so the same commit doesn't appear twice. The simplest
|
|
71
|
+
// way: pass sha=<before>, request perPage+1, and skip the first.
|
|
72
|
+
const sha = before ?? branch
|
|
73
|
+
const u = new URL(`https://api.github.com/repos/${owner}/${repo}/commits`)
|
|
74
|
+
u.searchParams.set('path', path)
|
|
75
|
+
u.searchParams.set('sha', sha)
|
|
76
|
+
u.searchParams.set('per_page', String(before ? perPage + 1 : perPage))
|
|
77
|
+
|
|
78
|
+
const res = await fetch(u, {
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: `Bearer ${token}`,
|
|
81
|
+
Accept: 'application/vnd.github+json',
|
|
82
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (res.status === 404) return { ok: true, value: [] }
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
return { ok: false, status: 502, error: `GitHub returned ${res.status}` }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = (await res.json()) as Array<{
|
|
92
|
+
sha: string
|
|
93
|
+
commit: {
|
|
94
|
+
author: { name: string; email: string; date: string }
|
|
95
|
+
message: string
|
|
96
|
+
}
|
|
97
|
+
author: { avatar_url?: string } | null
|
|
98
|
+
}>
|
|
99
|
+
|
|
100
|
+
// Skip first if we paginated (the `before` SHA itself).
|
|
101
|
+
const start = before ? 1 : 0
|
|
102
|
+
const slice = data.slice(start, start + perPage)
|
|
103
|
+
const commits: CommitInfo[] = slice.map((c) => {
|
|
104
|
+
const [firstLine, ...rest] = c.commit.message.split('\n')
|
|
105
|
+
const body = rest.join('\n')
|
|
106
|
+
return {
|
|
107
|
+
sha: c.sha,
|
|
108
|
+
shortSha: c.sha.slice(0, 7),
|
|
109
|
+
authoredAt: c.commit.author.date,
|
|
110
|
+
authorName: c.commit.author.name,
|
|
111
|
+
authorEmail: c.commit.author.email,
|
|
112
|
+
authorAvatarUrl: c.author?.avatar_url,
|
|
113
|
+
coAuthors: parseCoAuthorTrailers(body),
|
|
114
|
+
message: firstLine ?? '',
|
|
115
|
+
body,
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
return { ok: true, value: commits }
|
|
119
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
4
|
+
import { resolveStorageConfigForRequest, prefixPath } from './_storage-config'
|
|
5
5
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
6
6
|
import { withTrailers } from './_commit-trailers'
|
|
7
7
|
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
8
|
+
import { convertToSetHtml } from '../init/template-patcher-v2'
|
|
9
|
+
import { readPagesMeta } from './_pages-meta-store'
|
|
10
|
+
import { setPageLastModified } from '@setzkasten-cms/core'
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* POST /api/setzkasten/sections/commit-pending
|
|
@@ -80,6 +83,45 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
80
83
|
})),
|
|
81
84
|
]
|
|
82
85
|
|
|
86
|
+
// Auto-upgrade plain-text fields to set:html when the user introduces
|
|
87
|
+
// formatting via the inline RTE. Without this, Astro's `{value}` escapes
|
|
88
|
+
// tags and the published page shows literal `<strong>…</strong>`. We
|
|
89
|
+
// detect HTML in any committed string value, fetch the section template,
|
|
90
|
+
// run convertToSetHtml (idempotent — no-op if already converted), and
|
|
91
|
+
// include the patched template in the same batch commit.
|
|
92
|
+
const sectionsWithHtml = [...sections, ...edits]
|
|
93
|
+
.filter(s => containsHtmlValue(s.content))
|
|
94
|
+
.map(s => s.key)
|
|
95
|
+
const projectPrefix = (storage as { projectPrefix?: string }).projectPrefix
|
|
96
|
+
for (const sectionKey of sectionsWithHtml) {
|
|
97
|
+
const componentPath = prefixPath(
|
|
98
|
+
`src/components/sections/${pascalCase(sectionKey)}Section.astro`,
|
|
99
|
+
projectPrefix ?? '',
|
|
100
|
+
)
|
|
101
|
+
if (files.some(f => f.path === componentPath)) continue
|
|
102
|
+
const original = await fetchFileContent(owner, repo, branch, componentPath, githubToken)
|
|
103
|
+
if (!original) continue
|
|
104
|
+
const patched = convertToSetHtml(original)
|
|
105
|
+
if (patched !== original) {
|
|
106
|
+
files.push({ path: componentPath, content: patched })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fold the recency-meta update into this same batch commit. Previously
|
|
111
|
+
// we issued a follow-up PUT via recordPageEdit, which produced a second
|
|
112
|
+
// commit ("chore(meta): update _pages-meta.json") and a second deploy
|
|
113
|
+
// for every save — visible noise in history and wasted CI minutes.
|
|
114
|
+
const metaContentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
115
|
+
const metaTarget = { owner, repo, branch, contentPath: metaContentPath, token: githubToken }
|
|
116
|
+
const metaSnapshot = await readPagesMeta(metaTarget)
|
|
117
|
+
if (metaSnapshot.ok) {
|
|
118
|
+
const nextMeta = setPageLastModified(metaSnapshot.value.meta, pageKey, Date.now())
|
|
119
|
+
files.push({
|
|
120
|
+
path: `${metaContentPath}/_pages-meta.json`,
|
|
121
|
+
content: JSON.stringify(nextMeta, null, 2),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
83
125
|
const parts: string[] = []
|
|
84
126
|
if (sections.length > 0) {
|
|
85
127
|
const keys = sections.map(s => s.key).join(', ')
|
|
@@ -110,14 +152,25 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
110
152
|
)
|
|
111
153
|
}
|
|
112
154
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
const {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
// Fire content.save webhooks. Best-effort, fire-and-forget — does
|
|
156
|
+
// not block the response.
|
|
157
|
+
const { fireWebhooks } = await import('./_webhook-dispatcher.js')
|
|
158
|
+
const parsedSession = parseSession(cookies.get('setzkasten_session')?.value)
|
|
159
|
+
void fireWebhooks(
|
|
160
|
+
'content.save',
|
|
161
|
+
{
|
|
162
|
+
website: { id: owner, repo: `${owner}/${repo}`, branch },
|
|
163
|
+
user: {
|
|
164
|
+
email: parsedSession?.user?.email ?? 'unknown',
|
|
165
|
+
name: parsedSession?.user?.name,
|
|
166
|
+
},
|
|
167
|
+
commit: { sha: commitResult.sha, message: `Commit on ${pageKey}` },
|
|
168
|
+
files: sections.map((s: { key: string }) => ({
|
|
169
|
+
path: `${metaContentPath}/_sections/${s.key}.json`,
|
|
170
|
+
})),
|
|
171
|
+
},
|
|
172
|
+
request,
|
|
173
|
+
)
|
|
121
174
|
|
|
122
175
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
123
176
|
} catch (error) {
|
|
@@ -129,6 +182,52 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
129
182
|
}
|
|
130
183
|
}
|
|
131
184
|
|
|
185
|
+
/** Recursively scan a section content tree for any string value containing
|
|
186
|
+
* inline HTML markup (a `<` followed by an ASCII letter or `/`). Used to
|
|
187
|
+
* decide whether the section template needs upgrading to set:html. */
|
|
188
|
+
function containsHtmlValue(value: unknown): boolean {
|
|
189
|
+
if (typeof value === 'string') return /<\/?[a-z]/i.test(value)
|
|
190
|
+
if (Array.isArray(value)) return value.some(containsHtmlValue)
|
|
191
|
+
if (value && typeof value === 'object') return Object.values(value).some(containsHtmlValue)
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pascalCase(input: string): string {
|
|
196
|
+
return input
|
|
197
|
+
.split(/[-_\s]+/)
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
200
|
+
.join('')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fetchFileContent(
|
|
204
|
+
owner: string,
|
|
205
|
+
repo: string,
|
|
206
|
+
branch: string,
|
|
207
|
+
path: string,
|
|
208
|
+
token: string,
|
|
209
|
+
): Promise<string | null> {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(
|
|
212
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
213
|
+
{
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: `Bearer ${token}`,
|
|
216
|
+
Accept: 'application/vnd.github+json',
|
|
217
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
if (!res.ok) return null
|
|
222
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
223
|
+
return data.encoding === 'base64'
|
|
224
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
225
|
+
: data.content
|
|
226
|
+
} catch {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
132
231
|
async function batchCommit(
|
|
133
232
|
owner: string, repo: string, branch: string,
|
|
134
233
|
files: Array<{ path: string; content: string }>,
|
|
@@ -90,6 +90,20 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
90
90
|
pageKey,
|
|
91
91
|
).catch(() => {})
|
|
92
92
|
|
|
93
|
+
// Fire content.delete webhooks (fire-and-forget).
|
|
94
|
+
const { fireWebhooks } = await import('./_webhook-dispatcher.js')
|
|
95
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
96
|
+
void fireWebhooks(
|
|
97
|
+
'content.delete',
|
|
98
|
+
{
|
|
99
|
+
website: { id: owner, repo: `${owner}/${repo}`, branch },
|
|
100
|
+
user: { email: session?.user?.email ?? 'unknown', name: session?.user?.name },
|
|
101
|
+
commit: { sha: commitResult.sha, message: `Delete ${sectionKey} from ${pageKey}` },
|
|
102
|
+
files: [{ path: sectionJsonPath }],
|
|
103
|
+
},
|
|
104
|
+
request,
|
|
105
|
+
)
|
|
106
|
+
|
|
93
107
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
94
108
|
} catch (error) {
|
|
95
109
|
console.error('[setzkasten] section-delete error:', error)
|
|
@@ -27,7 +27,19 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
|
|
|
27
27
|
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// GitHub returns a lot more than these five fields (permissions,
|
|
31
|
+
// events, html_url, …) but these are the only ones we persist for
|
|
32
|
+
// the wizard. `client_id` + `client_secret` since v1.2: the GH-App
|
|
33
|
+
// doubles as the OAuth provider for admin login, so the Manifest
|
|
34
|
+
// exchange replaces what was previously a separate OAuth-App that
|
|
35
|
+
// the user had to create on github.com/settings/developers.
|
|
36
|
+
let data: {
|
|
37
|
+
id: number
|
|
38
|
+
slug: string
|
|
39
|
+
pem: string
|
|
40
|
+
client_id: string
|
|
41
|
+
client_secret: string
|
|
42
|
+
} | null = null
|
|
31
43
|
try {
|
|
32
44
|
const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
|
|
33
45
|
method: 'POST',
|
|
@@ -45,7 +57,13 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
|
|
|
45
57
|
// Response.redirect() is immutable and blocks subsequent header writes.
|
|
46
58
|
cookies.set(
|
|
47
59
|
COOKIE_NAME,
|
|
48
|
-
JSON.stringify({
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
appId: String(data!.id),
|
|
62
|
+
slug: data!.slug,
|
|
63
|
+
privateKey: data!.pem,
|
|
64
|
+
clientId: data!.client_id,
|
|
65
|
+
clientSecret: data!.client_secret,
|
|
66
|
+
}),
|
|
49
67
|
{ httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
|
|
50
68
|
)
|
|
51
69
|
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
|
|
3
|
+
// Vite-define injected by @setzkasten-cms/astro at build time. Available
|
|
4
|
+
// in API-only Vercel cold-starts where the page-ssr injectScript that
|
|
5
|
+
// writes globalThis.__SETZKASTEN_CONFIG__ does not fire.
|
|
6
|
+
declare const __SETZKASTEN_BUILD_CONFIG__: {
|
|
7
|
+
adminPath?: string
|
|
8
|
+
updaterUrl?: string
|
|
9
|
+
version?: string
|
|
10
|
+
websiteUrl?: string
|
|
11
|
+
hasGitHub?: boolean
|
|
12
|
+
storage?: {
|
|
13
|
+
owner?: string
|
|
14
|
+
repo?: string
|
|
15
|
+
branch?: string
|
|
16
|
+
contentPath?: string
|
|
17
|
+
assetsPath?: string
|
|
18
|
+
}
|
|
19
|
+
} | null | undefined
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* Registers this Setzkasten instance with the central updater backend.
|
|
5
23
|
* Called on every Dashboard load. Returns update status and license tier.
|
|
@@ -15,12 +33,23 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
15
33
|
return new Response('Unauthorized', { status: 401 })
|
|
16
34
|
}
|
|
17
35
|
|
|
18
|
-
|
|
36
|
+
// Two layers: the page-ssr injectScript writes __SETZKASTEN_CONFIG__ on
|
|
37
|
+
// globalThis (only fires for SSR-rendered pages). The Vite-define
|
|
38
|
+
// __SETZKASTEN_BUILD_CONFIG__ ships the same shape baked into the bundle
|
|
39
|
+
// and is therefore visible in API-only Vercel cold-starts where the
|
|
40
|
+
// injectScript never runs. We prefer the runtime globalThis value (it
|
|
41
|
+
// can pick up later overrides) but fall back to the build constant.
|
|
42
|
+
type ConfigShape = {
|
|
19
43
|
updaterUrl?: string
|
|
20
44
|
version?: string
|
|
21
45
|
websiteUrl?: string
|
|
22
46
|
storage?: { owner?: string; repo?: string }
|
|
23
|
-
}
|
|
47
|
+
}
|
|
48
|
+
const buildConfig = (typeof __SETZKASTEN_BUILD_CONFIG__ !== 'undefined'
|
|
49
|
+
? __SETZKASTEN_BUILD_CONFIG__
|
|
50
|
+
: null) as ConfigShape | null
|
|
51
|
+
const runtimeConfig = (globalThis as any).__SETZKASTEN_CONFIG__ as ConfigShape | undefined
|
|
52
|
+
const config: ConfigShape | null = runtimeConfig ?? buildConfig
|
|
24
53
|
|
|
25
54
|
const currentVersion = config?.version ?? '0.0.0'
|
|
26
55
|
const updaterUrl = config?.updaterUrl
|
|
@@ -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
|
+
}
|