@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { type WebsiteEntry, canAddWebsite, isMultiModeAvailable } from '@setzkasten-cms/core'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { parseSession } from './_auth-guard'
|
|
4
|
+
import { withTrailers } from './_commit-trailers'
|
|
5
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
6
|
+
import { resolveLicenseTier } from './_license-tier'
|
|
7
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/setzkasten/migrate/to-multi
|
|
11
|
+
*
|
|
12
|
+
* Body: { configRepo: 'owner/repo', configInstallationId: string,
|
|
13
|
+
* previewOrigin?: string }
|
|
14
|
+
*
|
|
15
|
+
* Admin-only. Expects the deployer to have already (1) created the
|
|
16
|
+
* config repo and (2) installed the GitHub App on it. The endpoint then:
|
|
17
|
+
*
|
|
18
|
+
* 1. Reads `_editors.json` and `_global_config.json` from the current
|
|
19
|
+
* single-mode website repo.
|
|
20
|
+
* 2. Writes both files into `<config-repo>/content/`.
|
|
21
|
+
* 3. Initialises `<config-repo>/websites.json` with a single entry that
|
|
22
|
+
* snapshots the current single-mode setup (repo, branch, preview
|
|
23
|
+
* origin, App-Installation-ID).
|
|
24
|
+
*
|
|
25
|
+
* After a 200 response the deployer still has to update
|
|
26
|
+
* `setzkasten.config.ts` (kind: 'single' → 'multi') and the ENV
|
|
27
|
+
* variables and redeploy. The wizard surfaces a copy-ready diff for
|
|
28
|
+
* those manual steps.
|
|
29
|
+
*/
|
|
30
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
31
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
32
|
+
if (!session) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
|
33
|
+
if (session.user.role !== 'admin')
|
|
34
|
+
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
|
35
|
+
|
|
36
|
+
const tier = resolveLicenseTier()
|
|
37
|
+
if (!isMultiModeAvailable(tier)) {
|
|
38
|
+
return new Response(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
error: 'Multi-Mode ist nur mit Pro- oder Enterprise-Lizenz verfügbar.',
|
|
41
|
+
tier,
|
|
42
|
+
}),
|
|
43
|
+
{ status: 402, headers: { 'Content-Type': 'application/json' } },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let body: { configRepo?: string; configInstallationId?: string; previewOrigin?: string } = {}
|
|
48
|
+
try {
|
|
49
|
+
body = (await request.json()) as typeof body
|
|
50
|
+
} catch {
|
|
51
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!body.configRepo || typeof body.configRepo !== 'string' || !body.configRepo.includes('/')) {
|
|
55
|
+
return new Response(JSON.stringify({ error: 'configRepo (owner/repo) ist erforderlich' }), {
|
|
56
|
+
status: 400,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
if (!body.configInstallationId || typeof body.configInstallationId !== 'string') {
|
|
60
|
+
return new Response(JSON.stringify({ error: 'configInstallationId ist erforderlich' }), {
|
|
61
|
+
status: 400,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
|
|
66
|
+
| { storage?: { kind?: string } }
|
|
67
|
+
| undefined
|
|
68
|
+
if (
|
|
69
|
+
fullConfig?.storage?.kind &&
|
|
70
|
+
fullConfig.storage.kind !== 'single' &&
|
|
71
|
+
fullConfig.storage.kind !== 'github-app'
|
|
72
|
+
) {
|
|
73
|
+
return new Response(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
error: `Migration nur aus dem Single-Mode möglich. Aktueller storage.kind: ${fullConfig.storage.kind ?? 'unbekannt'}`,
|
|
76
|
+
}),
|
|
77
|
+
{ status: 400 },
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// We need the source-tier slot just in case the config repo already has
|
|
82
|
+
// entries — defensively preflight the limit so we never half-migrate.
|
|
83
|
+
const allowed = canAddWebsite(tier, 0)
|
|
84
|
+
if (!allowed.ok) {
|
|
85
|
+
return new Response(JSON.stringify({ error: allowed.reason }), { status: 402 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sourceStorage = resolveStorageConfig()
|
|
89
|
+
if (!sourceStorage) {
|
|
90
|
+
return new Response(
|
|
91
|
+
JSON.stringify({ error: 'Single-Mode Storage konnte nicht aufgelöst werden' }),
|
|
92
|
+
{ status: 500 },
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
97
|
+
if (!tokenResult.ok) {
|
|
98
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
99
|
+
}
|
|
100
|
+
const token = tokenResult.value
|
|
101
|
+
|
|
102
|
+
const [configOwner, configRepo] = body.configRepo.split('/')
|
|
103
|
+
if (!configOwner || !configRepo) {
|
|
104
|
+
return new Response(JSON.stringify({ error: 'configRepo muss "owner/repo" sein' }), {
|
|
105
|
+
status: 400,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sourceContentPath: string =
|
|
110
|
+
(
|
|
111
|
+
(globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
112
|
+
| { storage?: { contentPath?: string } }
|
|
113
|
+
| undefined
|
|
114
|
+
)?.storage?.contentPath ?? 'content'
|
|
115
|
+
|
|
116
|
+
const headers = {
|
|
117
|
+
Authorization: `Bearer ${token}`,
|
|
118
|
+
Accept: 'application/vnd.github+json',
|
|
119
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ghBase = (owner: string, repo: string, path: string) =>
|
|
124
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`
|
|
125
|
+
|
|
126
|
+
// 1. Read editors + global from website repo (best-effort — both files
|
|
127
|
+
// are optional in single-mode setups).
|
|
128
|
+
const sourceEditors = await readOptional(
|
|
129
|
+
ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_editors.json`),
|
|
130
|
+
`?ref=${sourceStorage.branch}`,
|
|
131
|
+
headers,
|
|
132
|
+
)
|
|
133
|
+
const sourceGlobal = await readOptional(
|
|
134
|
+
ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_global_config.json`),
|
|
135
|
+
`?ref=${sourceStorage.branch}`,
|
|
136
|
+
headers,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// 2. Write copies into config repo. (Branch defaults to 'main' since the
|
|
140
|
+
// user just created the repo; we don't expose a branch override here.)
|
|
141
|
+
const configBranch = 'main'
|
|
142
|
+
|
|
143
|
+
const editorsCommit = sourceEditors
|
|
144
|
+
? await putFile(
|
|
145
|
+
ghBase(configOwner, configRepo, 'content/_editors.json'),
|
|
146
|
+
sourceEditors,
|
|
147
|
+
configBranch,
|
|
148
|
+
'chore(migrate): copy editors from website repo',
|
|
149
|
+
headers,
|
|
150
|
+
)
|
|
151
|
+
: true
|
|
152
|
+
|
|
153
|
+
const globalCommit = sourceGlobal
|
|
154
|
+
? await putFile(
|
|
155
|
+
ghBase(configOwner, configRepo, 'content/_global_config.json'),
|
|
156
|
+
sourceGlobal,
|
|
157
|
+
configBranch,
|
|
158
|
+
'chore(migrate): copy global config from website repo',
|
|
159
|
+
headers,
|
|
160
|
+
)
|
|
161
|
+
: true
|
|
162
|
+
|
|
163
|
+
// 3. Initialise websites.json with the current setup as the first entry.
|
|
164
|
+
const previewOrigin = body.previewOrigin ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:4321'
|
|
165
|
+
|
|
166
|
+
const initialEntry: WebsiteEntry = {
|
|
167
|
+
id: 'main',
|
|
168
|
+
name: sourceStorage.repo,
|
|
169
|
+
repo: `${sourceStorage.owner}/${sourceStorage.repo}`,
|
|
170
|
+
branch: sourceStorage.branch,
|
|
171
|
+
previewOrigin,
|
|
172
|
+
githubApp: {
|
|
173
|
+
appId: process.env.GITHUB_APP_ID ?? '',
|
|
174
|
+
installationId: process.env.GITHUB_APP_INSTALLATION_ID ?? '',
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const websitesContent = JSON.stringify({ websites: [initialEntry] }, null, 2)
|
|
179
|
+
|
|
180
|
+
const websitesCommit = await putFile(
|
|
181
|
+
ghBase(configOwner, configRepo, 'websites.json'),
|
|
182
|
+
websitesContent,
|
|
183
|
+
configBranch,
|
|
184
|
+
'feat(migrate): initialise websites.json with current single-mode setup',
|
|
185
|
+
headers,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return new Response(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
ok: true,
|
|
191
|
+
committed: {
|
|
192
|
+
editors: editorsCommit,
|
|
193
|
+
globalConfig: globalCommit,
|
|
194
|
+
websites: websitesCommit,
|
|
195
|
+
},
|
|
196
|
+
// Echo back the values the user still has to set themselves.
|
|
197
|
+
manual: {
|
|
198
|
+
configRepo: body.configRepo,
|
|
199
|
+
configInstallationId: body.configInstallationId,
|
|
200
|
+
configBranch,
|
|
201
|
+
envChanges: {
|
|
202
|
+
add: {
|
|
203
|
+
SETZKASTEN_CONFIG_REPO: body.configRepo,
|
|
204
|
+
SETZKASTEN_CONFIG_BRANCH: configBranch,
|
|
205
|
+
GITHUB_APP_CONFIG_INSTALLATION_ID: body.configInstallationId,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readOptional(
|
|
215
|
+
url: string,
|
|
216
|
+
qs: string,
|
|
217
|
+
headers: Record<string, string>,
|
|
218
|
+
): Promise<string | null> {
|
|
219
|
+
const res = await fetch(url + qs, { headers })
|
|
220
|
+
if (!res.ok) return null
|
|
221
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
222
|
+
return data.encoding === 'base64'
|
|
223
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
224
|
+
: data.content
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function putFile(
|
|
228
|
+
url: string,
|
|
229
|
+
content: string,
|
|
230
|
+
branch: string,
|
|
231
|
+
message: string,
|
|
232
|
+
headers: Record<string, string>,
|
|
233
|
+
): Promise<boolean> {
|
|
234
|
+
// Read existing SHA so we can update an existing file rather than 422.
|
|
235
|
+
let sha: string | undefined
|
|
236
|
+
try {
|
|
237
|
+
const existing = await fetch(`${url}?ref=${branch}`, { headers })
|
|
238
|
+
if (existing.ok) {
|
|
239
|
+
const data = (await existing.json()) as { sha?: string }
|
|
240
|
+
sha = data.sha
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* file doesn't exist — fine */
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const body: Record<string, unknown> = {
|
|
247
|
+
message: withTrailers(message),
|
|
248
|
+
content: Buffer.from(content).toString('base64'),
|
|
249
|
+
branch,
|
|
250
|
+
}
|
|
251
|
+
if (sha) body.sha = sha
|
|
252
|
+
|
|
253
|
+
const res = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(body) })
|
|
254
|
+
return res.ok
|
|
255
|
+
}
|
package/src/api-routes/pages.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
3
|
+
import { readPagesMeta, type PagesMetaTarget } from './_pages-meta-store'
|
|
4
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
5
|
+
import { cachedFetch } from './_github-cache'
|
|
2
6
|
|
|
3
7
|
interface PageInfo {
|
|
4
8
|
path: string
|
|
5
9
|
pageKey: string
|
|
6
10
|
label: string
|
|
7
11
|
hasConfig: boolean
|
|
12
|
+
/** Unix-ms timestamp of the page's last Setzkasten-driven commit, when
|
|
13
|
+
* `_pages-meta.json` knows about the page. */
|
|
14
|
+
lastModified?: number
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
// Build-time constant injected by the Vite define plugin — always available in
|
|
@@ -15,6 +22,10 @@ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
|
|
|
15
22
|
* Returns the list of pages scanned at build time.
|
|
16
23
|
* Reads the Vite build-time constant first; falls back to globalThis for
|
|
17
24
|
* local dev / test environments where the define is not applied.
|
|
25
|
+
*
|
|
26
|
+
* Only valid in single-mode where the admin and the website share the same
|
|
27
|
+
* Astro project. Multi-mode has to fetch the page list per website at
|
|
28
|
+
* runtime (see {@link fetchPagesFromGitHub}).
|
|
18
29
|
*/
|
|
19
30
|
export function resolvePages(): PageInfo[] {
|
|
20
31
|
const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
|
|
@@ -23,13 +34,116 @@ export function resolvePages(): PageInfo[] {
|
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
36
|
* GET /api/setzkasten/pages
|
|
26
|
-
*
|
|
37
|
+
*
|
|
38
|
+
* Single-mode: returns the build-time scan from the admin's own Astro
|
|
39
|
+
* project, enriched with `_pages-meta.json` timestamps from the
|
|
40
|
+
* (single) repo.
|
|
41
|
+
*
|
|
42
|
+
* Multi-mode: the X-SK-Website header selects one of the registered
|
|
43
|
+
* websites. The admin doesn't have build-time access to that website's
|
|
44
|
+
* `src/pages/` directory, so we fetch it via the GitHub Contents API
|
|
45
|
+
* (cached for 5 min), then enrich with the per-website
|
|
46
|
+
* `_pages-meta.json`. Without this branch, every website in Multi-Mode
|
|
47
|
+
* would see the admin's own page list — typically a single "index"
|
|
48
|
+
* stub — instead of its real pages.
|
|
27
49
|
*/
|
|
28
|
-
export const GET: APIRoute = async () => {
|
|
29
|
-
const
|
|
50
|
+
export const GET: APIRoute = async ({ request }) => {
|
|
51
|
+
const isMulti = request.headers.get('x-sk-website') !== null
|
|
52
|
+
const pages = isMulti
|
|
53
|
+
? await fetchPagesFromGitHub(request).catch(() => [])
|
|
54
|
+
: resolvePages()
|
|
30
55
|
|
|
31
|
-
|
|
56
|
+
const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
|
|
57
|
+
|
|
58
|
+
return new Response(JSON.stringify({ pages: enriched }), {
|
|
32
59
|
status: 200,
|
|
33
60
|
headers: { 'Content-Type': 'application/json' },
|
|
34
61
|
})
|
|
35
62
|
}
|
|
63
|
+
|
|
64
|
+
interface ContentsApiEntry {
|
|
65
|
+
type: 'file' | 'dir' | 'symlink' | 'submodule'
|
|
66
|
+
name: string
|
|
67
|
+
path: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lists the website's `src/pages/` directory via the GitHub Contents API
|
|
72
|
+
* and turns it into PageInfo entries. Only top-level `.astro` files
|
|
73
|
+
* (excluding `_layout.astro` and other underscore-prefixed privates and
|
|
74
|
+
* dynamic `[slug].astro` routes) become editable pages.
|
|
75
|
+
*
|
|
76
|
+
* Cached for 5 minutes per (owner, repo, branch) — the page list is a
|
|
77
|
+
* structural change that rarely happens during a normal editing session.
|
|
78
|
+
*/
|
|
79
|
+
async function fetchPagesFromGitHub(request: Request): Promise<PageInfo[]> {
|
|
80
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
81
|
+
if (!storage) return []
|
|
82
|
+
|
|
83
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
84
|
+
if (!tokenResult.ok) return []
|
|
85
|
+
|
|
86
|
+
const { owner, repo, branch } = storage
|
|
87
|
+
const cacheKey = `pages-list:${owner}/${repo}:${branch}`
|
|
88
|
+
return cachedFetch(cacheKey, 5 * 60_000, async () => {
|
|
89
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/contents/src/pages?ref=${branch}`
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
93
|
+
Accept: 'application/vnd.github+json',
|
|
94
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) return []
|
|
98
|
+
const entries = (await res.json()) as ContentsApiEntry[]
|
|
99
|
+
if (!Array.isArray(entries)) return []
|
|
100
|
+
|
|
101
|
+
const pages: PageInfo[] = []
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry.type !== 'file') continue
|
|
104
|
+
if (!entry.name.endsWith('.astro')) continue
|
|
105
|
+
// Skip privates (_layout.astro etc.) and dynamic routes ([slug].astro).
|
|
106
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('[')) continue
|
|
107
|
+
const pageKey = entry.name.slice(0, -'.astro'.length)
|
|
108
|
+
pages.push({
|
|
109
|
+
path: entry.path,
|
|
110
|
+
pageKey,
|
|
111
|
+
label: pageKey === 'index' ? 'Startseite' : pageKey,
|
|
112
|
+
hasConfig: true,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return pages
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function enrichWithLastModified(
|
|
120
|
+
pages: PageInfo[],
|
|
121
|
+
request: Request,
|
|
122
|
+
): Promise<PageInfo[]> {
|
|
123
|
+
if (pages.length === 0) return pages
|
|
124
|
+
|
|
125
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
126
|
+
if (!storage) return pages
|
|
127
|
+
|
|
128
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
129
|
+
if (!tokenResult.ok) return pages
|
|
130
|
+
|
|
131
|
+
const serverConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
132
|
+
| { storage?: { contentPath?: string } }
|
|
133
|
+
| undefined
|
|
134
|
+
const target: PagesMetaTarget = {
|
|
135
|
+
owner: storage.owner,
|
|
136
|
+
repo: storage.repo,
|
|
137
|
+
branch: storage.branch,
|
|
138
|
+
contentPath: serverConfig?.storage?.contentPath ?? 'content',
|
|
139
|
+
token: tokenResult.value,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const meta = await readPagesMeta(target)
|
|
143
|
+
if (!meta.ok) return pages
|
|
144
|
+
|
|
145
|
+
return pages.map((p) => {
|
|
146
|
+
const ts = meta.value.meta.pages[p.pageKey]?.lastModified
|
|
147
|
+
return ts !== undefined ? { ...p, lastModified: ts } : p
|
|
148
|
+
})
|
|
149
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { generateAddKey, addToPageConfig } from './section-management'
|
|
4
4
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
5
|
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/setzkasten/sections/add
|
|
@@ -21,8 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
21
22
|
const session = cookies.get('setzkasten_session')?.value
|
|
22
23
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
25
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
26
|
+
if (!tokenResult.ok) {
|
|
27
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
28
|
+
}
|
|
29
|
+
const githubToken = tokenResult.value
|
|
26
30
|
|
|
27
31
|
try {
|
|
28
32
|
const body = await request.json() as {
|
|
@@ -36,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
36
40
|
contentPath?: string
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const storage =
|
|
43
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
40
44
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
41
45
|
const { owner, repo, branch, projectPrefix } = storage
|
|
42
46
|
|
|
@@ -49,7 +53,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
49
53
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
56
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
53
57
|
if (denied) return denied
|
|
54
58
|
|
|
55
59
|
const headers = {
|
|
@@ -111,6 +115,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
111
115
|
|
|
112
116
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
113
117
|
|
|
118
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
119
|
+
await recordPageEdit(
|
|
120
|
+
{ owner, repo, branch, contentPath, token: tokenResult.value },
|
|
121
|
+
pageKey,
|
|
122
|
+
).catch(() => {})
|
|
123
|
+
|
|
114
124
|
return Response.json({ success: true, newKey, commitSha: commitResult.sha })
|
|
115
125
|
} catch (error) {
|
|
116
126
|
console.error('[setzkasten] section-add error:', error)
|
|
@@ -1,9 +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 {
|
|
4
|
+
import { resolveStorageConfigForRequest, prefixPath } from './_storage-config'
|
|
5
5
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
6
6
|
import { withTrailers } from './_commit-trailers'
|
|
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'
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* POST /api/setzkasten/sections/commit-pending
|
|
@@ -22,8 +26,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
22
26
|
const session = cookies.get('setzkasten_session')?.value
|
|
23
27
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
24
28
|
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
29
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
30
|
+
if (!tokenResult.ok) {
|
|
31
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
32
|
+
}
|
|
33
|
+
const githubToken = tokenResult.value
|
|
27
34
|
|
|
28
35
|
try {
|
|
29
36
|
const body = await request.json() as {
|
|
@@ -37,7 +44,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
37
44
|
contentPath?: string
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
const storage =
|
|
47
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
41
48
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
42
49
|
const { owner, repo, branch } = storage
|
|
43
50
|
|
|
@@ -50,7 +57,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
50
57
|
return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
60
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
54
61
|
if (denied) return denied
|
|
55
62
|
|
|
56
63
|
const headers = {
|
|
@@ -76,6 +83,45 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
76
83
|
})),
|
|
77
84
|
]
|
|
78
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
|
+
|
|
79
125
|
const parts: string[] = []
|
|
80
126
|
if (sections.length > 0) {
|
|
81
127
|
const keys = sections.map(s => s.key).join(', ')
|
|
@@ -106,6 +152,26 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
106
152
|
)
|
|
107
153
|
}
|
|
108
154
|
|
|
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
|
+
)
|
|
174
|
+
|
|
109
175
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
110
176
|
} catch (error) {
|
|
111
177
|
console.error('[setzkasten] section-commit-pending error:', error)
|
|
@@ -116,6 +182,52 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
116
182
|
}
|
|
117
183
|
}
|
|
118
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
|
+
|
|
119
231
|
async function batchCommit(
|
|
120
232
|
owner: string, repo: string, branch: string,
|
|
121
233
|
files: Array<{ path: string; content: string }>,
|