@setzkasten-cms/astro-admin 0.6.0 → 0.8.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 +13 -6
- package/src/admin-page.astro +8 -7
- 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__/pages.test.ts +72 -0
- package/src/api-routes/_auth-guard.ts +32 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -0
- package/src/api-routes/auth-callback.ts +17 -48
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-setzkasten-login.ts +60 -0
- package/src/api-routes/catalog-add.ts +10 -1
- package/src/api-routes/config.ts +5 -0
- package/src/api-routes/editors.ts +136 -0
- package/src/api-routes/global-config.ts +132 -0
- package/src/api-routes/init-add-section.ts +8 -5
- package/src/api-routes/init-apply.ts +2 -1
- package/src/api-routes/init-migrate.ts +2 -1
- package/src/api-routes/pages.ts +23 -5
- package/src/api-routes/section-add.ts +9 -1
- package/src/api-routes/section-commit-pending.ts +11 -1
- package/src/api-routes/section-delete.ts +10 -1
- package/src/api-routes/section-duplicate.ts +11 -1
- package/src/api-routes/section-prepare.ts +4 -0
- package/src/api-routes/updater-check.ts +49 -0
- package/src/api-routes/updater-register.ts +107 -0
- package/src/api-routes/updater-transfer.ts +62 -0
- package/src/api-routes/updater-unbind.ts +59 -0
- package/src/init/__tests__/page-level.test.ts +47 -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 +67 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
|
|
3
|
+
import { readEditorsFile } from './editors'
|
|
4
|
+
import { readGlobalConfig } from './global-config'
|
|
5
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/setzkasten/auth/setzkasten-login
|
|
9
|
+
* Body: { idToken: string } — Firebase ID token from signInWithPopup
|
|
10
|
+
*
|
|
11
|
+
* Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
|
|
12
|
+
* Access is gated exclusively by _editors.json (fail-closed).
|
|
13
|
+
*/
|
|
14
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
15
|
+
const body = await request.json().catch(() => null)
|
|
16
|
+
const idToken = body?.idToken as string | undefined
|
|
17
|
+
|
|
18
|
+
if (!idToken) {
|
|
19
|
+
return new Response('Missing idToken', { status: 400 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const storage = resolveStorageConfig()
|
|
23
|
+
if (!storage) {
|
|
24
|
+
return new Response('Storage not configured', { status: 500 })
|
|
25
|
+
}
|
|
26
|
+
const { owner, repo, branch } = storage
|
|
27
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
28
|
+
const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
29
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
|
|
30
|
+
|
|
31
|
+
// Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
|
|
32
|
+
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
33
|
+
if (!globalCfg?.firebaseConfig) {
|
|
34
|
+
return new Response('SetzKastenLogin not configured', { status: 500 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read editors list — fail-closed: if unreadable, deny all logins
|
|
38
|
+
const editors = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
|
|
39
|
+
if (editors === null) {
|
|
40
|
+
return new Response('Editors list unavailable — no access granted', { status: 503 })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allowedEmails = editors.map((e) => e.email)
|
|
44
|
+
const result = await verifyFirebaseJwt(idToken, allowedEmails)
|
|
45
|
+
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
return new Response(result.error.message, { status: 403 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const session = result.value
|
|
51
|
+
cookies.set('setzkasten_session', JSON.stringify({ user: session.user, expiresAt: session.expiresAt }), {
|
|
52
|
+
httpOnly: true,
|
|
53
|
+
secure: import.meta.env.PROD,
|
|
54
|
+
sameSite: 'lax',
|
|
55
|
+
path: '/',
|
|
56
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return Response.json({ ok: true })
|
|
60
|
+
}
|
|
@@ -3,6 +3,8 @@ import { registry } from '@setzkasten-cms/catalog'
|
|
|
3
3
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
4
4
|
import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
|
|
5
5
|
import { generateAddKey, addToPageConfig } from './section-management'
|
|
6
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
7
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* POST /api/setzkasten/catalog/add
|
|
@@ -37,8 +39,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
37
39
|
const { owner, repo, branch, projectPrefix } = storage
|
|
38
40
|
|
|
39
41
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
42
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
40
43
|
const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
|
|
41
44
|
|
|
45
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
46
|
+
if (denied) return denied
|
|
47
|
+
|
|
42
48
|
const headers = {
|
|
43
49
|
Authorization: `Bearer ${githubToken}`,
|
|
44
50
|
Accept: 'application/vnd.github+json',
|
|
@@ -82,7 +88,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
82
88
|
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
83
89
|
{ path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
|
|
84
90
|
],
|
|
85
|
-
|
|
91
|
+
withTrailers(
|
|
92
|
+
`content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
|
|
93
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
94
|
+
),
|
|
86
95
|
headers,
|
|
87
96
|
)
|
|
88
97
|
|
package/src/api-routes/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { readGlobalConfig } from './global-config'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Returns the full SetzKastenConfig as JSON.
|
|
@@ -10,6 +11,8 @@ export const GET: APIRoute = async () => {
|
|
|
10
11
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
|
|
11
12
|
const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
|
|
12
13
|
|
|
14
|
+
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
15
|
+
|
|
13
16
|
const result = {
|
|
14
17
|
storage: { kind: 'github' },
|
|
15
18
|
auth: { providers: ['github'] },
|
|
@@ -21,6 +24,8 @@ export const GET: APIRoute = async () => {
|
|
|
21
24
|
_storage: ssrConfig?.storage ?? undefined,
|
|
22
25
|
_hasGitHub: ssrConfig?.hasGitHub ?? false,
|
|
23
26
|
_hasGoogle: ssrConfig?.hasGoogle ?? false,
|
|
27
|
+
// SetzKastenLogin Firebase config (present only when license is valid)
|
|
28
|
+
_firebaseConfig: globalCfg?.firebaseConfig ?? null,
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
return new Response(JSON.stringify(result), {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
3
|
+
import { parseSession } from './_auth-guard'
|
|
4
|
+
import type { ContentEditorConfig } from '@setzkasten-cms/core'
|
|
5
|
+
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
6
|
+
import { withTrailers } from './_commit-trailers'
|
|
7
|
+
|
|
8
|
+
const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// GET /api/setzkasten/editors
|
|
12
|
+
// Returns the current editors list from _editors.json.
|
|
13
|
+
// Any authenticated user may read this (needed for the page-filter in the UI).
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
17
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
18
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
19
|
+
|
|
20
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
21
|
+
if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
|
|
22
|
+
|
|
23
|
+
const storage = resolveStorageConfig()
|
|
24
|
+
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
25
|
+
|
|
26
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
27
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
28
|
+
const { owner, repo, branch } = storage
|
|
29
|
+
|
|
30
|
+
const raw = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
|
|
31
|
+
return Response.json(raw ?? [])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// PUT /api/setzkasten/editors
|
|
36
|
+
// Replaces the editors list. Admin-only.
|
|
37
|
+
// Body: ContentEditorConfig[]
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
41
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
42
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
43
|
+
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
44
|
+
|
|
45
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
46
|
+
if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
|
|
47
|
+
|
|
48
|
+
const storage = resolveStorageConfig()
|
|
49
|
+
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
50
|
+
|
|
51
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
52
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
53
|
+
const { owner, repo, branch } = storage
|
|
54
|
+
|
|
55
|
+
let editors: ContentEditorConfig[]
|
|
56
|
+
try {
|
|
57
|
+
editors = (await request.json()) as ContentEditorConfig[]
|
|
58
|
+
if (!Array.isArray(editors)) throw new Error('Expected array')
|
|
59
|
+
} catch {
|
|
60
|
+
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filePath = EDITORS_FILE(contentPath)
|
|
64
|
+
const fileContent = JSON.stringify(editors, null, 2)
|
|
65
|
+
const headers = {
|
|
66
|
+
Authorization: `Bearer ${githubToken}`,
|
|
67
|
+
Accept: 'application/vnd.github+json',
|
|
68
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Get current SHA if the file already exists (needed for updates)
|
|
73
|
+
const existing = await fetchFileSha(owner, repo, branch, filePath, headers)
|
|
74
|
+
|
|
75
|
+
const body: Record<string, unknown> = {
|
|
76
|
+
message: withTrailers('chore(editors): update content editor permissions'),
|
|
77
|
+
content: Buffer.from(fileContent).toString('base64'),
|
|
78
|
+
branch,
|
|
79
|
+
}
|
|
80
|
+
if (existing) body.sha = existing
|
|
81
|
+
|
|
82
|
+
const res = await fetch(
|
|
83
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
|
|
84
|
+
{ method: 'PUT', headers, body: JSON.stringify(body) },
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const text = await res.text()
|
|
89
|
+
return Response.json({ error: `GitHub write failed: ${text}` }, { status: 502 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
invalidateCache(`editors:${owner}/${repo}:${branch}`)
|
|
93
|
+
return Response.json({ ok: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async function fetchFileSha(
|
|
101
|
+
owner: string,
|
|
102
|
+
repo: string,
|
|
103
|
+
branch: string,
|
|
104
|
+
path: string,
|
|
105
|
+
headers: Record<string, string>,
|
|
106
|
+
): Promise<string | null> {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(
|
|
109
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
110
|
+
{ headers },
|
|
111
|
+
)
|
|
112
|
+
if (!res.ok) return null
|
|
113
|
+
const data = await res.json() as { sha: string }
|
|
114
|
+
return data.sha ?? null
|
|
115
|
+
} catch { return null }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function readEditorsFile(
|
|
119
|
+
owner: string,
|
|
120
|
+
repo: string,
|
|
121
|
+
branch: string,
|
|
122
|
+
contentPath: string,
|
|
123
|
+
token: string,
|
|
124
|
+
): Promise<ContentEditorConfig[] | null> {
|
|
125
|
+
const key = `editors:${owner}/${repo}:${branch}`
|
|
126
|
+
return cachedFetch(key, 2 * 60_000, async () => {
|
|
127
|
+
const res = await fetch(
|
|
128
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
|
|
129
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
130
|
+
)
|
|
131
|
+
if (!res.ok) return null
|
|
132
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
133
|
+
const raw = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
134
|
+
return JSON.parse(raw) as ContentEditorConfig[]
|
|
135
|
+
})
|
|
136
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { parseSession } from './_auth-guard'
|
|
3
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
4
|
+
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
|
|
7
|
+
const GLOBAL_CONFIG_FILE = (contentPath: string) => `${contentPath}/_global_config.json`
|
|
8
|
+
|
|
9
|
+
export interface GlobalConfig {
|
|
10
|
+
firebaseConfig?: {
|
|
11
|
+
apiKey: string
|
|
12
|
+
authDomain: string
|
|
13
|
+
projectId: string
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// GET /api/setzkasten/global-config — any authenticated user
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
22
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
23
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
24
|
+
|
|
25
|
+
const cfg = await readGlobalConfig()
|
|
26
|
+
return Response.json(cfg ?? {})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// PUT /api/setzkasten/global-config — admin only
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
34
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
35
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
36
|
+
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
37
|
+
|
|
38
|
+
let patch: Partial<GlobalConfig>
|
|
39
|
+
try {
|
|
40
|
+
patch = (await request.json()) as Partial<GlobalConfig>
|
|
41
|
+
} catch {
|
|
42
|
+
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const current = await readGlobalConfig() ?? {}
|
|
46
|
+
const next: GlobalConfig = { ...current }
|
|
47
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
48
|
+
if (v === null) delete (next as Record<string, unknown>)[k]
|
|
49
|
+
else (next as Record<string, unknown>)[k] = v
|
|
50
|
+
}
|
|
51
|
+
await writeGlobalConfig(next)
|
|
52
|
+
return Response.json({ ok: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function getStorageParams() {
|
|
60
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
61
|
+
const storage = resolveStorageConfig()
|
|
62
|
+
if (!storage) return null
|
|
63
|
+
return {
|
|
64
|
+
owner: storage.owner,
|
|
65
|
+
repo: storage.repo,
|
|
66
|
+
branch: storage.branch,
|
|
67
|
+
contentPath: serverConfig?.storage?.contentPath ?? 'content',
|
|
68
|
+
token: (import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? '') as string,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
|
73
|
+
const params = getStorageParams()
|
|
74
|
+
if (!params) return null
|
|
75
|
+
const { owner, repo, branch, contentPath, token } = params
|
|
76
|
+
const key = `global-config:${owner}/${repo}:${branch}`
|
|
77
|
+
return cachedFetch(key, 5 * 60_000, async () => {
|
|
78
|
+
const res = await fetch(
|
|
79
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
|
|
80
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
81
|
+
)
|
|
82
|
+
if (!res.ok) return null
|
|
83
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
84
|
+
const raw = data.encoding === 'base64'
|
|
85
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
86
|
+
: data.content
|
|
87
|
+
return JSON.parse(raw) as GlobalConfig
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
|
|
92
|
+
const params = getStorageParams()
|
|
93
|
+
if (!params) throw new Error('Storage not configured')
|
|
94
|
+
const { owner, repo, branch, contentPath, token } = params
|
|
95
|
+
invalidateCache(`global-config:${owner}/${repo}:${branch}`)
|
|
96
|
+
const filePath = GLOBAL_CONFIG_FILE(contentPath)
|
|
97
|
+
const headers = {
|
|
98
|
+
Authorization: `Bearer ${token}`,
|
|
99
|
+
Accept: 'application/vnd.github+json',
|
|
100
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Get current SHA if file exists
|
|
105
|
+
let sha: string | undefined
|
|
106
|
+
try {
|
|
107
|
+
const existing = await fetch(
|
|
108
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
|
|
109
|
+
{ headers },
|
|
110
|
+
)
|
|
111
|
+
if (existing.ok) {
|
|
112
|
+
const data = await existing.json() as { sha: string }
|
|
113
|
+
sha = data.sha
|
|
114
|
+
}
|
|
115
|
+
} catch { /* file doesn't exist yet */ }
|
|
116
|
+
|
|
117
|
+
const body: Record<string, unknown> = {
|
|
118
|
+
message: withTrailers('chore(config): update global config'),
|
|
119
|
+
content: Buffer.from(JSON.stringify(config, null, 2)).toString('base64'),
|
|
120
|
+
branch,
|
|
121
|
+
}
|
|
122
|
+
if (sha) body.sha = sha
|
|
123
|
+
|
|
124
|
+
const res = await fetch(
|
|
125
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
|
|
126
|
+
{ method: 'PUT', headers, body: JSON.stringify(body) },
|
|
127
|
+
)
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
const text = await res.text()
|
|
130
|
+
throw new Error(`GitHub write failed: ${text}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -4,6 +4,7 @@ import { addSectionToConfig } from '@setzkasten-cms/core/init'
|
|
|
4
4
|
import { resolveStorageConfig, prefixPath } 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'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* POST /api/setzkasten/init/add-section
|
|
@@ -84,7 +85,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
84
85
|
// Only add values for fields that don't already exist
|
|
85
86
|
for (const field of section.fields) {
|
|
86
87
|
if (!(field.key in sectionData)) {
|
|
87
|
-
|
|
88
|
+
let value = field.defaultValue ?? getDefaultValue(field.type)
|
|
89
|
+
if (Array.isArray(value)) value = value.filter((item: unknown) => item != null)
|
|
90
|
+
sectionData[field.key] = value
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
|
|
@@ -208,7 +211,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
208
211
|
for (const g of repeatedGroups) {
|
|
209
212
|
const topField = fields.find(f => f.key === g.fieldKey)
|
|
210
213
|
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
211
|
-
sectionData[g.fieldKey] = topField.defaultValue
|
|
214
|
+
sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(item => item != null)
|
|
212
215
|
const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
|
|
213
216
|
if (jsonIdx !== -1) {
|
|
214
217
|
filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
|
|
@@ -232,7 +235,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
232
235
|
for (const g of repeatedGroups) {
|
|
233
236
|
const topField = fields.find(f => f.key === g.fieldKey)
|
|
234
237
|
if (!topField || !Array.isArray(topField.defaultValue)) continue
|
|
235
|
-
const items = topField.defaultValue as Array<Record<string, unknown>>
|
|
238
|
+
const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(item => item != null)
|
|
236
239
|
|
|
237
240
|
// Update sectionData with the enriched items array
|
|
238
241
|
sectionData[g.fieldKey] = items
|
|
@@ -279,9 +282,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
279
282
|
repo,
|
|
280
283
|
branch,
|
|
281
284
|
filesToCommit,
|
|
282
|
-
existingSectionJson
|
|
285
|
+
withTrailers(existingSectionJson
|
|
283
286
|
? `content: update ${section.key} section — add new fields`
|
|
284
|
-
: `content: add ${section.key} section to Setzkasten
|
|
287
|
+
: `content: add ${section.key} section to Setzkasten`),
|
|
285
288
|
headers,
|
|
286
289
|
)
|
|
287
290
|
|
|
@@ -2,6 +2,7 @@ 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'
|
|
5
6
|
|
|
6
7
|
interface ApplyRequest {
|
|
7
8
|
owner: string
|
|
@@ -115,7 +116,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
115
116
|
repo,
|
|
116
117
|
branch,
|
|
117
118
|
filesToCommit,
|
|
118
|
-
'feat: initialize Setzkasten CMS',
|
|
119
|
+
withTrailers('feat: initialize Setzkasten CMS'),
|
|
119
120
|
githubToken,
|
|
120
121
|
)
|
|
121
122
|
|
|
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'
|
|
|
2
2
|
import type { SetzKastenConfig } from '@setzkasten-cms/core'
|
|
3
3
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
4
4
|
import { patchTemplateForFields } from '../init/template-patcher-v2'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* POST /api/setzkasten/init/migrate
|
|
@@ -108,7 +109,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
108
109
|
repo,
|
|
109
110
|
branch,
|
|
110
111
|
[{ path: componentPath, content: patched }],
|
|
111
|
-
`chore: add live-preview bindings to ${sectionKey} section
|
|
112
|
+
withTrailers(`chore: add live-preview bindings to ${sectionKey} section`),
|
|
112
113
|
headers,
|
|
113
114
|
)
|
|
114
115
|
|
package/src/api-routes/pages.ts
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
|
|
3
|
+
interface PageInfo {
|
|
4
|
+
path: string
|
|
5
|
+
pageKey: string
|
|
6
|
+
label: string
|
|
7
|
+
hasConfig: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Build-time constant injected by the Vite define plugin — always available in
|
|
11
|
+
// compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
|
|
12
|
+
declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the list of pages scanned at build time.
|
|
16
|
+
* Reads the Vite build-time constant first; falls back to globalThis for
|
|
17
|
+
* local dev / test environments where the define is not applied.
|
|
18
|
+
*/
|
|
19
|
+
export function resolvePages(): PageInfo[] {
|
|
20
|
+
const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
|
|
21
|
+
return buildPages ?? (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[] ?? []
|
|
22
|
+
}
|
|
23
|
+
|
|
3
24
|
/**
|
|
4
|
-
* Returns the list of pages detected at build time.
|
|
5
|
-
* The pages are scanned from src/pages/ by the integration hook
|
|
6
|
-
* and injected into globalThis.__SETZKASTEN_PAGES__.
|
|
7
|
-
*
|
|
8
25
|
* GET /api/setzkasten/pages
|
|
26
|
+
* Returns the list of pages detected at build time.
|
|
9
27
|
*/
|
|
10
28
|
export const GET: APIRoute = async () => {
|
|
11
|
-
const pages = (
|
|
29
|
+
const pages = resolvePages()
|
|
12
30
|
|
|
13
31
|
return new Response(JSON.stringify({ pages }), {
|
|
14
32
|
status: 200,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
3
3
|
import { generateAddKey, addToPageConfig } from './section-management'
|
|
4
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* POST /api/setzkasten/sections/add
|
|
@@ -47,6 +49,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
47
49
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
53
|
+
if (denied) return denied
|
|
54
|
+
|
|
50
55
|
const headers = {
|
|
51
56
|
Authorization: `Bearer ${githubToken}`,
|
|
52
57
|
Accept: 'application/vnd.github+json',
|
|
@@ -97,7 +102,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
97
102
|
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
98
103
|
{ path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
|
|
99
104
|
],
|
|
100
|
-
|
|
105
|
+
withTrailers(
|
|
106
|
+
`content: add ${sectionType} section "${newKey}" to ${pageKey}`,
|
|
107
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
108
|
+
),
|
|
101
109
|
headers,
|
|
102
110
|
)
|
|
103
111
|
|
|
@@ -2,6 +2,8 @@ import type { APIRoute } from 'astro'
|
|
|
2
2
|
import { writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { resolveStorageConfig } from './_storage-config'
|
|
5
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
6
|
+
import { withTrailers } from './_commit-trailers'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* POST /api/setzkasten/sections/commit-pending
|
|
@@ -40,6 +42,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
40
42
|
const { owner, repo, branch } = storage
|
|
41
43
|
|
|
42
44
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
45
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
43
46
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
44
47
|
const { pageKey, pageConfig, sections, edits = [] } = body
|
|
45
48
|
|
|
@@ -47,6 +50,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
47
50
|
return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
54
|
+
if (denied) return denied
|
|
55
|
+
|
|
50
56
|
const headers = {
|
|
51
57
|
Authorization: `Bearer ${githubToken}`,
|
|
52
58
|
Accept: 'application/vnd.github+json',
|
|
@@ -79,7 +85,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
79
85
|
const keys = edits.map(s => s.key).join(', ')
|
|
80
86
|
parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
|
|
81
87
|
}
|
|
82
|
-
const
|
|
88
|
+
const editorEmail = parseSession(cookies.get('setzkasten_session')?.value)?.user?.email
|
|
89
|
+
const commitMessage = withTrailers(
|
|
90
|
+
`content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`,
|
|
91
|
+
editorEmail,
|
|
92
|
+
)
|
|
83
93
|
const commitResult = await batchCommit(owner, repo, branch, files, commitMessage, headers)
|
|
84
94
|
|
|
85
95
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
3
3
|
import { removeFromPageConfig } from './section-management'
|
|
4
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* DELETE /api/setzkasten/sections
|
|
@@ -34,6 +36,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
34
36
|
const { owner, repo, branch, projectPrefix } = storage
|
|
35
37
|
|
|
36
38
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
39
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
37
40
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
38
41
|
const { pageKey, sectionKey } = body
|
|
39
42
|
|
|
@@ -41,6 +44,9 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
41
44
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
48
|
+
if (denied) return denied
|
|
49
|
+
|
|
44
50
|
const headers = {
|
|
45
51
|
Authorization: `Bearer ${githubToken}`,
|
|
46
52
|
Accept: 'application/vnd.github+json',
|
|
@@ -65,7 +71,10 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
65
71
|
owner, repo, branch,
|
|
66
72
|
[{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
|
|
67
73
|
[sectionJsonPath],
|
|
68
|
-
|
|
74
|
+
withTrailers(
|
|
75
|
+
`content: remove ${sectionKey} section from ${pageKey}`,
|
|
76
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
77
|
+
),
|
|
69
78
|
headers,
|
|
70
79
|
)
|
|
71
80
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
3
3
|
import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
|
|
4
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* POST /api/setzkasten/sections/duplicate
|
|
@@ -34,6 +36,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
34
36
|
const { owner, repo, branch, projectPrefix } = storage
|
|
35
37
|
|
|
36
38
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
39
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
37
40
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
38
41
|
const { pageKey, sectionKey } = body
|
|
39
42
|
|
|
@@ -41,6 +44,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
41
44
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
48
|
+
if (denied) return denied
|
|
49
|
+
|
|
44
50
|
const headers = {
|
|
45
51
|
Authorization: `Bearer ${githubToken}`,
|
|
46
52
|
Accept: 'application/vnd.github+json',
|
|
@@ -77,7 +83,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
|
|
80
|
-
|
|
86
|
+
withTrailers(
|
|
87
|
+
`content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`,
|
|
88
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
89
|
+
),
|
|
90
|
+
headers)
|
|
81
91
|
|
|
82
92
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
83
93
|
|