@setzkasten-cms/astro-admin 0.6.0 → 1.1.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 +23 -6
- package/src/admin-page.astro +9 -8
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
- package/src/api-routes/__tests__/github-cache.test.ts +100 -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__/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__/pages.test.ts +72 -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 +145 -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__/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 +153 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -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/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +64 -4
- package/src/api-routes/_vercel-origin.ts +22 -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 +21 -53
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +71 -0
- package/src/api-routes/catalog-add.ts +18 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +17 -5
- package/src/api-routes/editors.ts +205 -0
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +149 -0
- package/src/api-routes/init-add-section.ts +21 -10
- package/src/api-routes/init-apply.ts +7 -4
- package/src/api-routes/init-migrate.ts +9 -6
- 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 +138 -6
- package/src/api-routes/section-add.ts +23 -5
- package/src/api-routes/section-commit-pending.ts +28 -5
- package/src/api-routes/section-delete.ts +24 -5
- package/src/api-routes/section-duplicate.ts +25 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +12 -4
- 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 +53 -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-check.ts +49 -0
- package/src/api-routes/updater-register.ts +90 -0
- package/src/api-routes/updater-transfer.ts +51 -0
- package/src/api-routes/updater-unbind.ts +59 -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__/page-level.test.ts +47 -0
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/__tests__/section-pipeline.test.ts +3 -1
- package/src/init/astro-section-analyzer-v2.ts +29 -2
- package/src/init/template-patcher-v2.ts +100 -0
- package/LICENSE +0 -37
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { parseSession } from './_auth-guard'
|
|
3
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
5
|
+
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
6
|
+
import { withTrailers } from './_commit-trailers'
|
|
7
|
+
|
|
8
|
+
const GLOBAL_CONFIG_FILE = (contentPath: string) => `${contentPath}/_global_config.json`
|
|
9
|
+
|
|
10
|
+
export interface GlobalConfig {
|
|
11
|
+
firebaseConfig?: {
|
|
12
|
+
apiKey: string
|
|
13
|
+
authDomain: string
|
|
14
|
+
projectId: string
|
|
15
|
+
}
|
|
16
|
+
theme?: {
|
|
17
|
+
primaryColor?: string
|
|
18
|
+
brandName?: string
|
|
19
|
+
logo?: string
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// GET /api/setzkasten/global-config — any authenticated user
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
28
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
29
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
30
|
+
|
|
31
|
+
const cfg = await readGlobalConfig()
|
|
32
|
+
return Response.json(cfg ?? {})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// PUT /api/setzkasten/global-config — admin only
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
40
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
41
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
42
|
+
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
43
|
+
|
|
44
|
+
let patch: Partial<GlobalConfig>
|
|
45
|
+
try {
|
|
46
|
+
patch = (await request.json()) as Partial<GlobalConfig>
|
|
47
|
+
} catch {
|
|
48
|
+
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const current = (await readGlobalConfig()) ?? {}
|
|
52
|
+
const next: GlobalConfig = { ...current }
|
|
53
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
54
|
+
if (v === null) delete (next as Record<string, unknown>)[k]
|
|
55
|
+
else (next as Record<string, unknown>)[k] = v
|
|
56
|
+
}
|
|
57
|
+
await writeGlobalConfig(next)
|
|
58
|
+
return Response.json({ ok: true })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
//
|
|
64
|
+
// Global config is a Setzkasten-instance-level file that lives in the
|
|
65
|
+
// config-repo regardless of which website the request is targeting:
|
|
66
|
+
// - Single-Mode: config-repo == website-repo, so this is fine
|
|
67
|
+
// - Multi-Mode: config-repo holds editors + global config + websites.json
|
|
68
|
+
// We deliberately ignore the request and X-SK-Website here; otherwise
|
|
69
|
+
// global config would ping-pong between per-website locations as users
|
|
70
|
+
// switch active sites.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
async function getStorageParams() {
|
|
74
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
75
|
+
.__SETZKASTEN_CONFIG__
|
|
76
|
+
const storage = resolveStorageConfig()
|
|
77
|
+
if (!storage) return null
|
|
78
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
79
|
+
if (!tokenResult.ok) return null
|
|
80
|
+
return {
|
|
81
|
+
owner: storage.owner,
|
|
82
|
+
repo: storage.repo,
|
|
83
|
+
branch: storage.branch,
|
|
84
|
+
contentPath: serverConfig?.storage?.contentPath ?? 'content',
|
|
85
|
+
token: tokenResult.value,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
|
90
|
+
const params = await getStorageParams()
|
|
91
|
+
if (!params) return null
|
|
92
|
+
const { owner, repo, branch, contentPath, token } = params
|
|
93
|
+
const key = `global-config:${owner}/${repo}:${branch}`
|
|
94
|
+
return cachedFetch(key, 5 * 60_000, async () => {
|
|
95
|
+
const res = await fetch(
|
|
96
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
|
|
97
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
98
|
+
)
|
|
99
|
+
if (!res.ok) return null
|
|
100
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
101
|
+
const raw = data.encoding === 'base64'
|
|
102
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
103
|
+
: data.content
|
|
104
|
+
return JSON.parse(raw) as GlobalConfig
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
|
|
109
|
+
const params = await getStorageParams()
|
|
110
|
+
if (!params) throw new Error('Storage not configured')
|
|
111
|
+
const { owner, repo, branch, contentPath, token } = params
|
|
112
|
+
invalidateCache(`global-config:${owner}/${repo}:${branch}`)
|
|
113
|
+
const filePath = GLOBAL_CONFIG_FILE(contentPath)
|
|
114
|
+
const headers = {
|
|
115
|
+
Authorization: `Bearer ${token}`,
|
|
116
|
+
Accept: 'application/vnd.github+json',
|
|
117
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Get current SHA if file exists
|
|
122
|
+
let sha: string | undefined
|
|
123
|
+
try {
|
|
124
|
+
const existing = await fetch(
|
|
125
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
|
|
126
|
+
{ headers },
|
|
127
|
+
)
|
|
128
|
+
if (existing.ok) {
|
|
129
|
+
const data = await existing.json() as { sha: string }
|
|
130
|
+
sha = data.sha
|
|
131
|
+
}
|
|
132
|
+
} catch { /* file doesn't exist yet */ }
|
|
133
|
+
|
|
134
|
+
const body: Record<string, unknown> = {
|
|
135
|
+
message: withTrailers('chore(config): update global config'),
|
|
136
|
+
content: Buffer.from(JSON.stringify(config, null, 2)).toString('base64'),
|
|
137
|
+
branch,
|
|
138
|
+
}
|
|
139
|
+
if (sha) body.sha = sha
|
|
140
|
+
|
|
141
|
+
const res = await fetch(
|
|
142
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
|
|
143
|
+
{ method: 'PUT', headers, body: JSON.stringify(body) },
|
|
144
|
+
)
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const text = await res.text()
|
|
147
|
+
throw new Error(`GitHub write failed: ${text}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import type { InferredSection } from '@setzkasten-cms/core/init'
|
|
3
3
|
import { addSectionToConfig } from '@setzkasten-cms/core/init'
|
|
4
|
-
import {
|
|
4
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
5
5
|
import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
|
|
6
6
|
import type { RepeatedGroup } from '../init/analyzer-types'
|
|
7
|
+
import { withTrailers } from './_commit-trailers'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* POST /api/setzkasten/init/add-section
|
|
@@ -20,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
20
22
|
return new Response('Unauthorized', { status: 401 })
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
25
|
-
return new Response(
|
|
25
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
26
|
+
if (!tokenResult.ok) {
|
|
27
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
26
28
|
}
|
|
29
|
+
const githubToken = tokenResult.value
|
|
27
30
|
|
|
28
31
|
try {
|
|
29
32
|
const body = await request.json() as {
|
|
@@ -37,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
37
40
|
contentPath?: string
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
const storage =
|
|
43
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
41
44
|
if (!storage) {
|
|
42
45
|
return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
|
|
43
46
|
}
|
|
@@ -84,7 +87,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
84
87
|
// Only add values for fields that don't already exist
|
|
85
88
|
for (const field of section.fields) {
|
|
86
89
|
if (!(field.key in sectionData)) {
|
|
87
|
-
|
|
90
|
+
let value = field.defaultValue ?? getDefaultValue(field.type)
|
|
91
|
+
if (Array.isArray(value)) value = value.filter((item: unknown) => item != null)
|
|
92
|
+
sectionData[field.key] = value
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -208,7 +213,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
208
213
|
for (const g of repeatedGroups) {
|
|
209
214
|
const topField = fields.find(f => f.key === g.fieldKey)
|
|
210
215
|
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
211
|
-
sectionData[g.fieldKey] = topField.defaultValue
|
|
216
|
+
sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(item => item != null)
|
|
212
217
|
const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
|
|
213
218
|
if (jsonIdx !== -1) {
|
|
214
219
|
filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
|
|
@@ -232,7 +237,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
232
237
|
for (const g of repeatedGroups) {
|
|
233
238
|
const topField = fields.find(f => f.key === g.fieldKey)
|
|
234
239
|
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
235
|
-
const items = topField.defaultValue as Array<Record<string, unknown>>
|
|
240
|
+
const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(item => item != null)
|
|
236
241
|
|
|
237
242
|
// Update sectionData with the enriched items array
|
|
238
243
|
sectionData[g.fieldKey] = items
|
|
@@ -279,9 +284,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
279
284
|
repo,
|
|
280
285
|
branch,
|
|
281
286
|
filesToCommit,
|
|
282
|
-
existingSectionJson
|
|
287
|
+
withTrailers(existingSectionJson
|
|
283
288
|
? `content: update ${section.key} section — add new fields`
|
|
284
|
-
: `content: add ${section.key} section to Setzkasten
|
|
289
|
+
: `content: add ${section.key} section to Setzkasten`),
|
|
285
290
|
headers,
|
|
286
291
|
)
|
|
287
292
|
|
|
@@ -289,6 +294,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
289
294
|
return Response.json({ error: commitResult.error }, { status: 500 })
|
|
290
295
|
}
|
|
291
296
|
|
|
297
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
298
|
+
await recordPageEdit(
|
|
299
|
+
{ owner, repo, branch, contentPath, token: tokenResult.value },
|
|
300
|
+
pageKey,
|
|
301
|
+
).catch(() => {})
|
|
302
|
+
|
|
292
303
|
return Response.json({
|
|
293
304
|
success: true,
|
|
294
305
|
commitSha: commitResult.sha,
|
|
@@ -2,6 +2,8 @@ import type { APIRoute } from 'astro'
|
|
|
2
2
|
import { generateConfigFile, type InferredSection, type ConfigGeneratorInput } from '@setzkasten-cms/core/init'
|
|
3
3
|
import { patchAstroConfig } from '../init/astro-config-patcher'
|
|
4
4
|
import { patchTemplateForFields } from '../init/template-patcher-v2'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
5
7
|
|
|
6
8
|
interface ApplyRequest {
|
|
7
9
|
owner: string
|
|
@@ -35,10 +37,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
35
37
|
return new Response('Unauthorized', { status: 401 })
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
40
|
-
return new Response(
|
|
40
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
41
|
+
if (!tokenResult.ok) {
|
|
42
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
41
43
|
}
|
|
44
|
+
const githubToken = tokenResult.value
|
|
42
45
|
|
|
43
46
|
try {
|
|
44
47
|
const body = await request.json() as ApplyRequest
|
|
@@ -115,7 +118,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
115
118
|
repo,
|
|
116
119
|
branch,
|
|
117
120
|
filesToCommit,
|
|
118
|
-
'feat: initialize Setzkasten CMS',
|
|
121
|
+
withTrailers('feat: initialize Setzkasten CMS'),
|
|
119
122
|
githubToken,
|
|
120
123
|
)
|
|
121
124
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import type { SetzKastenConfig } from '@setzkasten-cms/core'
|
|
3
|
-
import {
|
|
3
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
4
4
|
import { patchTemplateForFields } from '../init/template-patcher-v2'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* POST /api/setzkasten/init/migrate
|
|
@@ -20,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
20
22
|
return new Response('Unauthorized', { status: 401 })
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
25
|
-
return new Response(
|
|
25
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
26
|
+
if (!tokenResult.ok) {
|
|
27
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
26
28
|
}
|
|
29
|
+
const githubToken = tokenResult.value
|
|
27
30
|
|
|
28
31
|
try {
|
|
29
32
|
const body = await request.json() as {
|
|
@@ -34,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
34
37
|
componentPath?: string
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
const storage =
|
|
40
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
38
41
|
if (!storage) {
|
|
39
42
|
return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
|
|
40
43
|
}
|
|
@@ -108,7 +111,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
108
111
|
repo,
|
|
109
112
|
branch,
|
|
110
113
|
[{ path: componentPath, content: patched }],
|
|
111
|
-
`chore: add live-preview bindings to ${sectionKey} section
|
|
114
|
+
withTrailers(`chore: add live-preview bindings to ${sectionKey} section`),
|
|
112
115
|
headers,
|
|
113
116
|
)
|
|
114
117
|
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import type { SetzKastenConfig } from '@setzkasten-cms/core'
|
|
3
|
-
import {
|
|
3
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
4
4
|
import { extractSectionImports, extractLayoutImport } from '../init/astro-detector'
|
|
5
5
|
import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
|
|
6
6
|
import type { RepoFile } from '@setzkasten-cms/core/init'
|
|
7
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
8
|
+
|
|
9
|
+
// Build-time constant injected by the Vite define plugin — always available in
|
|
10
|
+
// compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
|
|
11
|
+
declare const __SETZKASTEN_FULL_CONFIG__: SetzKastenConfig | null | undefined
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the full Setzkasten config.
|
|
15
|
+
* Reads the Vite build-time constant first; falls back to globalThis for
|
|
16
|
+
* local dev / test environments where the define is not applied.
|
|
17
|
+
*
|
|
18
|
+
* Without the build-time fallback, cold-start Vercel function invocations of
|
|
19
|
+
* this API route see managedSections={} and offer every adopted section
|
|
20
|
+
* (including _layout_header / _layout_footer) for re-adoption.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveFullConfig(): SetzKastenConfig | undefined {
|
|
23
|
+
const buildConfig = typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null
|
|
24
|
+
return (buildConfig ?? (globalThis as any).__SETZKASTEN_FULL_CONFIG__) as SetzKastenConfig | undefined
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
28
|
* POST /api/setzkasten/init/scan-page
|
|
@@ -21,10 +40,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
21
40
|
return new Response('Unauthorized', { status: 401 })
|
|
22
41
|
}
|
|
23
42
|
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
-
return new Response(
|
|
43
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
44
|
+
if (!tokenResult.ok) {
|
|
45
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
27
46
|
}
|
|
47
|
+
const githubToken = tokenResult.value
|
|
28
48
|
|
|
29
49
|
try {
|
|
30
50
|
const body = await request.json() as {
|
|
@@ -35,7 +55,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
35
55
|
projectRoot?: string
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
const storage =
|
|
58
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
39
59
|
if (!storage) {
|
|
40
60
|
return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
|
|
41
61
|
}
|
|
@@ -47,7 +67,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
// Get current schema to know which sections are already managed + their fields
|
|
50
|
-
const config = (
|
|
70
|
+
const config = resolveFullConfig()
|
|
51
71
|
const managedSections = new Map<string, Set<string>>() // key → field keys
|
|
52
72
|
if (config) {
|
|
53
73
|
for (const product of Object.values(config.products)) {
|
|
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'
|
|
|
2
2
|
import { analyzeProject, type RepoFile } from '@setzkasten-cms/core/init'
|
|
3
3
|
import { findAstroPages, extractSectionImports } from '../init/astro-detector'
|
|
4
4
|
import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
|
|
5
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* POST /api/setzkasten/init/scan
|
|
@@ -16,10 +17,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
16
17
|
return new Response('Unauthorized', { status: 401 })
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
21
|
-
return new Response(
|
|
20
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
21
|
+
if (!tokenResult.ok) {
|
|
22
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
22
23
|
}
|
|
24
|
+
const githubToken = tokenResult.value
|
|
23
25
|
|
|
24
26
|
try {
|
|
25
27
|
const body = await request.json() as { owner: string; repo: string; branch?: string }
|
|
@@ -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
|
+
}
|