@setzkasten-cms/astro-admin 1.4.2 → 1.5.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.
Files changed (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +150 -48
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +218 -88
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +126 -0
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +102 -16
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +490 -56
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { parseSession } from './_auth-guard'
3
+ import { cachedFetch } from './_github-cache'
2
4
  import { resolveGitHubTokenForRequest } from './_github-token'
3
- import { readPagesMeta, type PagesMetaTarget } from './_pages-meta-store'
5
+ import { type PagesMetaTarget, readPagesMeta } from './_pages-meta-store'
4
6
  import { resolveStorageConfigForRequest } from './_storage-config'
5
- import { cachedFetch } from './_github-cache'
6
7
 
7
8
  interface PageInfo {
8
9
  path: string
@@ -29,7 +30,9 @@ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
29
30
  */
30
31
  export function resolvePages(): PageInfo[] {
31
32
  const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
32
- return buildPages ?? (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[] ?? []
33
+ return (
34
+ buildPages ?? ((globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[]) ?? []
35
+ )
33
36
  }
34
37
 
35
38
  /**
@@ -47,11 +50,16 @@ export function resolvePages(): PageInfo[] {
47
50
  * would see the admin's own page list — typically a single "index"
48
51
  * stub — instead of its real pages.
49
52
  */
50
- export const GET: APIRoute = async ({ request }) => {
53
+ export const GET: APIRoute = async ({ request, cookies }) => {
54
+ // Authenticated users only — pre-fix this was an unauthenticated
55
+ // reconnaissance endpoint that also minted an installation token to
56
+ // crawl `src/pages/` of any website the App could reach.
57
+ if (!parseSession(cookies.get('setzkasten_session')?.value)) {
58
+ return new Response('Unauthorized', { status: 401 })
59
+ }
60
+
51
61
  const isMulti = request.headers.get('x-sk-website') !== null
52
- const pages = isMulti
53
- ? await fetchPagesFromGitHub(request).catch(() => [])
54
- : resolvePages()
62
+ const pages = isMulti ? await fetchPagesFromGitHub(request).catch(() => []) : resolvePages()
55
63
 
56
64
  const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
57
65
 
@@ -116,10 +124,7 @@ async function fetchPagesFromGitHub(request: Request): Promise<PageInfo[]> {
116
124
  })
117
125
  }
118
126
 
119
- async function enrichWithLastModified(
120
- pages: PageInfo[],
121
- request: Request,
122
- ): Promise<PageInfo[]> {
127
+ async function enrichWithLastModified(pages: PageInfo[], request: Request): Promise<PageInfo[]> {
123
128
  if (pages.length === 0) return pages
124
129
 
125
130
  const storage = await resolveStorageConfigForRequest(request)
@@ -1,9 +1,10 @@
1
+ import { isSafeKey } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
- import { generateAddKey, addToPageConfig } from './section-management'
4
- import { parseSession, guardPageAccess } from './_auth-guard'
3
+ import { guardPageAccess, parseSession } from './_auth-guard'
5
4
  import { withTrailers } from './_commit-trailers'
6
5
  import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
7
+ import { addToPageConfig, generateAddKey } from './section-management'
7
8
 
8
9
  /**
9
10
  * POST /api/setzkasten/sections/add
@@ -29,7 +30,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
29
30
  const githubToken = tokenResult.value
30
31
 
31
32
  try {
32
- const body = await request.json() as {
33
+ const body = (await request.json()) as {
33
34
  pageKey: string
34
35
  sectionType: string
35
36
  sectionKey?: string
@@ -52,8 +53,25 @@ export const POST: APIRoute = async ({ request, cookies }) => {
52
53
  if (!pageKey || !sectionType) {
53
54
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
54
55
  }
56
+ if (!isSafeKey(pageKey)) {
57
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
58
+ }
59
+ if (!isSafeKey(sectionType)) {
60
+ return Response.json({ error: 'invalid sectionType' }, { status: 400 })
61
+ }
62
+ if (body.sectionKey !== undefined && !isSafeKey(body.sectionKey)) {
63
+ return Response.json({ error: 'invalid sectionKey' }, { status: 400 })
64
+ }
65
+ if (sourcePage !== undefined && !isSafeKey(sourcePage)) {
66
+ return Response.json({ error: 'invalid sourcePage' }, { status: 400 })
67
+ }
55
68
 
56
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
69
+ const denied = await guardPageAccess(
70
+ parseSession(cookies.get('setzkasten_session')?.value),
71
+ pageKey,
72
+ fullConfig,
73
+ request,
74
+ )
57
75
  if (denied) return denied
58
76
 
59
77
  const headers = {
@@ -76,7 +94,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
76
94
  // 2. Determine key
77
95
  const newKey = body.sectionKey ?? generateAddKey(existingKeys, sectionType)
78
96
  if (existingKeys.includes(newKey)) {
79
- return Response.json({ error: `Key "${newKey}" already exists on this page` }, { status: 409 })
97
+ return Response.json(
98
+ { error: `Key "${newKey}" already exists on this page` },
99
+ { status: 409 },
100
+ )
80
101
  }
81
102
 
82
103
  // 3. Determine initial content: sourcePage seed → schema defaults → empty
@@ -88,7 +109,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
88
109
  const sourceJsonPath = `${contentPath}/_sections/${sourceKey}.json`
89
110
  const sourceContent = await fetchFileContent(owner, repo, branch, sourceJsonPath, githubToken)
90
111
  if (sourceContent) {
91
- try { defaultContent = JSON.parse(sourceContent) } catch { /* fallback to schema */ }
112
+ try {
113
+ defaultContent = JSON.parse(sourceContent)
114
+ } catch {
115
+ /* fallback to schema */
116
+ }
92
117
  }
93
118
  }
94
119
  if (Object.keys(defaultContent).length === 0) {
@@ -101,7 +126,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
101
126
  const sectionJsonPath = `${contentPath}/_sections/${newKey}.json`
102
127
 
103
128
  const commitResult = await batchCommit(
104
- owner, repo, branch,
129
+ owner,
130
+ repo,
131
+ branch,
105
132
  [
106
133
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
107
134
  { path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
@@ -154,50 +181,94 @@ function buildDefaultContent(fields: Record<string, any>): Record<string, unknow
154
181
  return result
155
182
  }
156
183
 
157
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
184
+ async function fetchFileContent(
185
+ owner: string,
186
+ repo: string,
187
+ branch: string,
188
+ path: string,
189
+ token: string,
190
+ ): Promise<string | null> {
158
191
  try {
159
192
  const res = await fetch(
160
193
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
161
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
194
+ {
195
+ headers: {
196
+ Authorization: `Bearer ${token}`,
197
+ Accept: 'application/vnd.github+json',
198
+ 'X-GitHub-Api-Version': '2022-11-28',
199
+ },
200
+ },
162
201
  )
163
202
  if (!res.ok) return null
164
- const data = await res.json() as { content: string; encoding: string }
165
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
166
- } catch { return null }
203
+ const data = (await res.json()) as { content: string; encoding: string }
204
+ return data.encoding === 'base64'
205
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
206
+ : data.content
207
+ } catch {
208
+ return null
209
+ }
167
210
  }
168
211
 
169
212
  async function batchCommit(
170
- owner: string, repo: string, branch: string,
213
+ owner: string,
214
+ repo: string,
215
+ branch: string,
171
216
  files: Array<{ path: string; content: string }>,
172
217
  message: string,
173
218
  headers: Record<string, string>,
174
219
  ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
175
220
  try {
176
- const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
221
+ const refRes = await fetch(
222
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
223
+ { headers },
224
+ )
177
225
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
178
- const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
226
+ const {
227
+ object: { sha: headSha },
228
+ } = (await refRes.json()) as { object: { sha: string } }
179
229
 
180
- const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
230
+ const commitRes = await fetch(
231
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
232
+ { headers },
233
+ )
181
234
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
182
- const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
235
+ const {
236
+ tree: { sha: baseSha },
237
+ } = (await commitRes.json()) as { tree: { sha: string } }
183
238
 
184
239
  const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
185
- method: 'POST', headers,
186
- body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
240
+ method: 'POST',
241
+ headers,
242
+ body: JSON.stringify({
243
+ base_tree: baseSha,
244
+ tree: files.map((f) => ({
245
+ path: f.path,
246
+ mode: '100644',
247
+ type: 'blob',
248
+ content: f.content,
249
+ })),
250
+ }),
187
251
  })
188
252
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
189
- const { sha: treeSha } = await treeRes.json() as { sha: string }
253
+ const { sha: treeSha } = (await treeRes.json()) as { sha: string }
190
254
 
191
255
  const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
192
- method: 'POST', headers,
256
+ method: 'POST',
257
+ headers,
193
258
  body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
194
259
  })
195
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
196
- const { sha: newSha } = await newCommitRes.json() as { sha: string }
260
+ if (!newCommitRes.ok)
261
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
262
+ const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
197
263
 
198
- const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
199
- method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
200
- })
264
+ const updateRes = await fetch(
265
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
266
+ {
267
+ method: 'PATCH',
268
+ headers,
269
+ body: JSON.stringify({ sha: newSha }),
270
+ },
271
+ )
201
272
  if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
202
273
 
203
274
  return { ok: true, sha: newSha }
@@ -1,13 +1,13 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { writeFile } from 'node:fs/promises'
3
2
  import { join } from 'node:path'
4
- import { resolveStorageConfigForRequest, prefixPath } from './_storage-config'
5
- import { parseSession, guardPageAccess } from './_auth-guard'
3
+ import { isSafeKey, setPageLastModified } from '@setzkasten-cms/core'
4
+ import type { APIRoute } from 'astro'
5
+ import { convertToSetHtml } from '../init/template-patcher-v2'
6
+ import { guardPageAccess, parseSession } from './_auth-guard'
6
7
  import { withTrailers } from './_commit-trailers'
7
8
  import { resolveGitHubTokenForRequest } from './_github-token'
8
- import { convertToSetHtml } from '../init/template-patcher-v2'
9
9
  import { readPagesMeta } from './_pages-meta-store'
10
- import { setPageLastModified } from '@setzkasten-cms/core'
10
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
11
11
 
12
12
  /**
13
13
  * POST /api/setzkasten/sections/commit-pending
@@ -33,7 +33,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
33
33
  const githubToken = tokenResult.value
34
34
 
35
35
  try {
36
- const body = await request.json() as {
36
+ const body = (await request.json()) as {
37
37
  pageKey: string
38
38
  pageConfig: Record<string, unknown>
39
39
  sections: Array<{ key: string; content: Record<string, unknown> }>
@@ -54,10 +54,33 @@ export const POST: APIRoute = async ({ request, cookies }) => {
54
54
  const { pageKey, pageConfig, sections, edits = [] } = body
55
55
 
56
56
  if (!pageKey || !pageConfig || !Array.isArray(sections)) {
57
- return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
57
+ return Response.json(
58
+ { error: 'pageKey, pageConfig, and sections are required' },
59
+ { status: 400 },
60
+ )
61
+ }
62
+ if (!isSafeKey(pageKey)) {
63
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
64
+ }
65
+ // Every section/edit key composes a file path. Reject anything that
66
+ // could escape the _sections/ folder before we hit the GitHub API.
67
+ for (const s of sections) {
68
+ if (!isSafeKey(s?.key)) {
69
+ return Response.json({ error: `invalid section key: ${s?.key}` }, { status: 400 })
70
+ }
71
+ }
72
+ for (const e of edits) {
73
+ if (!isSafeKey(e?.key)) {
74
+ return Response.json({ error: `invalid edit key: ${e?.key}` }, { status: 400 })
75
+ }
58
76
  }
59
77
 
60
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
78
+ const denied = await guardPageAccess(
79
+ parseSession(cookies.get('setzkasten_session')?.value),
80
+ pageKey,
81
+ fullConfig,
82
+ request,
83
+ )
61
84
  if (denied) return denied
62
85
 
63
86
  const headers = {
@@ -73,11 +96,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
73
96
 
74
97
  const files: Array<{ path: string; content: string }> = [
75
98
  { path: pageConfigPath, content: JSON.stringify(pageConfig, null, 2) },
76
- ...sections.map(s => ({
99
+ ...sections.map((s) => ({
77
100
  path: `${contentPath}/_sections/${s.key}.json`,
78
101
  content: JSON.stringify(s.content, null, 2),
79
102
  })),
80
- ...edits.map(s => ({
103
+ ...edits.map((s) => ({
81
104
  path: `${contentPath}/_sections/${s.key}.json`,
82
105
  content: JSON.stringify(s.content, null, 2),
83
106
  })),
@@ -90,15 +113,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
90
113
  // run convertToSetHtml (idempotent — no-op if already converted), and
91
114
  // include the patched template in the same batch commit.
92
115
  const sectionsWithHtml = [...sections, ...edits]
93
- .filter(s => containsHtmlValue(s.content))
94
- .map(s => s.key)
116
+ .filter((s) => containsHtmlValue(s.content))
117
+ .map((s) => s.key)
95
118
  const projectPrefix = (storage as { projectPrefix?: string }).projectPrefix
96
119
  for (const sectionKey of sectionsWithHtml) {
97
120
  const componentPath = prefixPath(
98
121
  `src/components/sections/${pascalCase(sectionKey)}Section.astro`,
99
122
  projectPrefix ?? '',
100
123
  )
101
- if (files.some(f => f.path === componentPath)) continue
124
+ if (files.some((f) => f.path === componentPath)) continue
102
125
  const original = await fetchFileContent(owner, repo, branch, componentPath, githubToken)
103
126
  if (!original) continue
104
127
  const patched = convertToSetHtml(original)
@@ -124,11 +147,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
124
147
 
125
148
  const parts: string[] = []
126
149
  if (sections.length > 0) {
127
- const keys = sections.map(s => s.key).join(', ')
150
+ const keys = sections.map((s) => s.key).join(', ')
128
151
  parts.push(`add ${sections.length} section${sections.length > 1 ? 's' : ''} (${keys})`)
129
152
  }
130
153
  if (edits.length > 0) {
131
- const keys = edits.map(s => s.key).join(', ')
154
+ const keys = edits.map((s) => s.key).join(', ')
132
155
  parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
133
156
  }
134
157
  const editorEmail = parseSession(cookies.get('setzkasten_session')?.value)?.user?.email
@@ -146,9 +169,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
146
169
  const repoRoot: string | undefined = serverConfig?.repoRoot
147
170
  if (repoRoot) {
148
171
  await Promise.all(
149
- files.map(f => writeFile(join(repoRoot, f.path), f.content, 'utf-8').catch(() => {
150
- // Non-fatal: local sync is best-effort (e.g. read-only FS in some CI envs)
151
- })),
172
+ files.map((f) =>
173
+ writeFile(join(repoRoot, f.path), f.content, 'utf-8').catch(() => {
174
+ // Non-fatal: local sync is best-effort (e.g. read-only FS in some CI envs)
175
+ }),
176
+ ),
152
177
  )
153
178
  }
154
179
 
@@ -196,7 +221,7 @@ function pascalCase(input: string): string {
196
221
  return input
197
222
  .split(/[-_\s]+/)
198
223
  .filter(Boolean)
199
- .map(s => s.charAt(0).toUpperCase() + s.slice(1))
224
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
200
225
  .join('')
201
226
  }
202
227
 
@@ -219,7 +244,7 @@ async function fetchFileContent(
219
244
  },
220
245
  )
221
246
  if (!res.ok) return null
222
- const data = await res.json() as { content: string; encoding: string }
247
+ const data = (await res.json()) as { content: string; encoding: string }
223
248
  return data.encoding === 'base64'
224
249
  ? Buffer.from(data.content, 'base64').toString('utf-8')
225
250
  : data.content
@@ -229,37 +254,65 @@ async function fetchFileContent(
229
254
  }
230
255
 
231
256
  async function batchCommit(
232
- owner: string, repo: string, branch: string,
257
+ owner: string,
258
+ repo: string,
259
+ branch: string,
233
260
  files: Array<{ path: string; content: string }>,
234
261
  message: string,
235
262
  headers: Record<string, string>,
236
263
  ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
237
264
  try {
238
- const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
265
+ const refRes = await fetch(
266
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
267
+ { headers },
268
+ )
239
269
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
240
- const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
270
+ const {
271
+ object: { sha: headSha },
272
+ } = (await refRes.json()) as { object: { sha: string } }
241
273
 
242
- const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
274
+ const commitRes = await fetch(
275
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
276
+ { headers },
277
+ )
243
278
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
244
- const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
279
+ const {
280
+ tree: { sha: baseSha },
281
+ } = (await commitRes.json()) as { tree: { sha: string } }
245
282
 
246
283
  const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
247
- method: 'POST', headers,
248
- body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
284
+ method: 'POST',
285
+ headers,
286
+ body: JSON.stringify({
287
+ base_tree: baseSha,
288
+ tree: files.map((f) => ({
289
+ path: f.path,
290
+ mode: '100644',
291
+ type: 'blob',
292
+ content: f.content,
293
+ })),
294
+ }),
249
295
  })
250
296
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
251
- const { sha: treeSha } = await treeRes.json() as { sha: string }
297
+ const { sha: treeSha } = (await treeRes.json()) as { sha: string }
252
298
 
253
299
  const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
254
- method: 'POST', headers,
300
+ method: 'POST',
301
+ headers,
255
302
  body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
256
303
  })
257
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
258
- const { sha: newSha } = await newCommitRes.json() as { sha: string }
304
+ if (!newCommitRes.ok)
305
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
306
+ const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
259
307
 
260
- const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
261
- method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
262
- })
308
+ const updateRes = await fetch(
309
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
310
+ {
311
+ method: 'PATCH',
312
+ headers,
313
+ body: JSON.stringify({ sha: newSha }),
314
+ },
315
+ )
263
316
  if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
264
317
 
265
318
  return { ok: true, sha: newSha }
@@ -1,9 +1,10 @@
1
+ import { isSafeKey } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
- import { removeFromPageConfig } from './section-management'
4
- import { parseSession, guardPageAccess } from './_auth-guard'
3
+ import { guardPageAccess, parseSession } from './_auth-guard'
5
4
  import { withTrailers } from './_commit-trailers'
6
5
  import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
7
+ import { removeFromPageConfig } from './section-management'
7
8
 
8
9
  /**
9
10
  * DELETE /api/setzkasten/sections
@@ -26,7 +27,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
26
27
  const githubToken = tokenResult.value
27
28
 
28
29
  try {
29
- const body = await request.json() as {
30
+ const body = (await request.json()) as {
30
31
  pageKey: string
31
32
  sectionKey: string
32
33
  owner?: string
@@ -47,8 +48,19 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
47
48
  if (!pageKey || !sectionKey) {
48
49
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
49
50
  }
51
+ if (!isSafeKey(pageKey)) {
52
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
53
+ }
54
+ if (!isSafeKey(sectionKey)) {
55
+ return Response.json({ error: 'invalid sectionKey' }, { status: 400 })
56
+ }
50
57
 
51
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
58
+ const denied = await guardPageAccess(
59
+ parseSession(cookies.get('setzkasten_session')?.value),
60
+ pageKey,
61
+ fullConfig,
62
+ request,
63
+ )
52
64
  if (denied) return denied
53
65
 
54
66
  const headers = {
@@ -72,7 +84,9 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
72
84
  const sectionJsonPath = `${contentPath}/_sections/${sectionKey}.json`
73
85
 
74
86
  const commitResult = await batchCommitWithDeletions(
75
- owner, repo, branch,
87
+ owner,
88
+ repo,
89
+ branch,
76
90
  [{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
77
91
  [sectionJsonPath],
78
92
  withTrailers(
@@ -114,57 +128,92 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
114
128
  }
115
129
  }
116
130
 
117
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
131
+ async function fetchFileContent(
132
+ owner: string,
133
+ repo: string,
134
+ branch: string,
135
+ path: string,
136
+ token: string,
137
+ ): Promise<string | null> {
118
138
  try {
119
139
  const res = await fetch(
120
140
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
121
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
141
+ {
142
+ headers: {
143
+ Authorization: `Bearer ${token}`,
144
+ Accept: 'application/vnd.github+json',
145
+ 'X-GitHub-Api-Version': '2022-11-28',
146
+ },
147
+ },
122
148
  )
123
149
  if (!res.ok) return null
124
- const data = await res.json() as { content: string; encoding: string }
125
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
126
- } catch { return null }
150
+ const data = (await res.json()) as { content: string; encoding: string }
151
+ return data.encoding === 'base64'
152
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
153
+ : data.content
154
+ } catch {
155
+ return null
156
+ }
127
157
  }
128
158
 
129
159
  async function batchCommitWithDeletions(
130
- owner: string, repo: string, branch: string,
160
+ owner: string,
161
+ repo: string,
162
+ branch: string,
131
163
  upserts: Array<{ path: string; content: string }>,
132
164
  deletions: string[],
133
165
  message: string,
134
166
  headers: Record<string, string>,
135
167
  ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
136
168
  try {
137
- const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
169
+ const refRes = await fetch(
170
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
171
+ { headers },
172
+ )
138
173
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
139
- const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
174
+ const {
175
+ object: { sha: headSha },
176
+ } = (await refRes.json()) as { object: { sha: string } }
140
177
 
141
- const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
178
+ const commitRes = await fetch(
179
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
180
+ { headers },
181
+ )
142
182
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
143
- const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
183
+ const {
184
+ tree: { sha: baseSha },
185
+ } = (await commitRes.json()) as { tree: { sha: string } }
144
186
 
145
187
  const tree = [
146
- ...upserts.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
147
- ...deletions.map(path => ({ path, mode: '100644', type: 'blob', sha: null })),
188
+ ...upserts.map((f) => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
189
+ ...deletions.map((path) => ({ path, mode: '100644', type: 'blob', sha: null })),
148
190
  ]
149
191
 
150
192
  const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
151
- method: 'POST', headers,
193
+ method: 'POST',
194
+ headers,
152
195
  body: JSON.stringify({ base_tree: baseSha, tree }),
153
196
  })
154
197
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
155
- const { sha: treeSha } = await treeRes.json() as { sha: string }
198
+ const { sha: treeSha } = (await treeRes.json()) as { sha: string }
156
199
 
157
200
  const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
158
- method: 'POST', headers,
201
+ method: 'POST',
202
+ headers,
159
203
  body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
160
204
  })
161
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
162
- const { sha: newSha } = await newCommitRes.json() as { sha: string }
205
+ if (!newCommitRes.ok)
206
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
207
+ const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
163
208
 
164
- const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
165
- method: 'PATCH', headers,
166
- body: JSON.stringify({ sha: newSha }),
167
- })
209
+ const updateRes = await fetch(
210
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
211
+ {
212
+ method: 'PATCH',
213
+ headers,
214
+ body: JSON.stringify({ sha: newSha }),
215
+ },
216
+ )
168
217
  if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
169
218
 
170
219
  return { ok: true, sha: newSha }