@setzkasten-cms/astro-admin 0.6.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/LICENSE +37 -0
- package/package.json +70 -0
- package/src/admin-page.astro +148 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
- package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
- package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
- package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
- package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
- package/src/api-routes/__tests__/section-management.test.ts +284 -0
- package/src/api-routes/_storage-config.ts +54 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/catalog-add.ts +151 -0
- package/src/api-routes/catalog-export.ts +86 -0
- package/src/api-routes/catalog-helpers.ts +83 -0
- package/src/api-routes/catalog-list.ts +12 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/deploy-hook.ts +69 -0
- package/src/api-routes/github-proxy.ts +111 -0
- package/src/api-routes/init-add-section.ts +511 -0
- package/src/api-routes/init-apply.ts +270 -0
- package/src/api-routes/init-migrate.ts +262 -0
- package/src/api-routes/init-scan-page.ts +336 -0
- package/src/api-routes/init-scan.ts +162 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/api-routes/section-add.ts +189 -0
- package/src/api-routes/section-commit-pending.ts +147 -0
- package/src/api-routes/section-delete.ts +141 -0
- package/src/api-routes/section-duplicate.ts +144 -0
- package/src/api-routes/section-management.ts +95 -0
- package/src/api-routes/section-prepare-copy.ts +93 -0
- package/src/api-routes/section-prepare.ts +121 -0
- package/src/env.d.ts +7 -0
- package/src/init/__tests__/page-level.test.ts +1033 -0
- package/src/init/__tests__/page-list-coverage.test.ts +474 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
- package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
- package/src/init/__tests__/section-pipeline.test.ts +393 -0
- package/src/init/analyzer-types.ts +92 -0
- package/src/init/astro-config-patcher.ts +98 -0
- package/src/init/astro-detector.ts +207 -0
- package/src/init/astro-section-analyzer-v2.ts +1663 -0
- package/src/init/field-label-enricher.ts +72 -0
- package/src/init/template-patcher-v2.ts +1957 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
+
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Login initiation – redirects the user to the chosen OAuth provider.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/setzkasten/auth/login?provider=github|google
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
|
+
const provider = url.searchParams.get('provider') ?? 'github'
|
|
12
|
+
|
|
13
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
14
|
+
| { adminPath: string; hasGitHub: boolean; hasGoogle: boolean }
|
|
15
|
+
| undefined
|
|
16
|
+
|
|
17
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
18
|
+
|
|
19
|
+
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
20
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
21
|
+
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
22
|
+
const origin = `${protocol}://${host}`
|
|
23
|
+
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
24
|
+
|
|
25
|
+
let loginUrl: string
|
|
26
|
+
|
|
27
|
+
if (provider === 'google') {
|
|
28
|
+
const googleClientId = import.meta.env.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID ?? ''
|
|
29
|
+
const googleClientSecret = import.meta.env.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET ?? ''
|
|
30
|
+
|
|
31
|
+
if (!googleClientId || !googleClientSecret) {
|
|
32
|
+
return new Response('Google OAuth not configured', { status: 500 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const auth = createGoogleAuth({
|
|
36
|
+
clientId: googleClientId,
|
|
37
|
+
clientSecret: googleClientSecret,
|
|
38
|
+
redirectUri,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
loginUrl = auth.getLoginUrl('google')
|
|
42
|
+
|
|
43
|
+
// Store the PKCE code verifier in a cookie for the callback
|
|
44
|
+
// Arctic generates it internally – we extract it from the URL
|
|
45
|
+
const urlObj = new URL(loginUrl)
|
|
46
|
+
const state = urlObj.searchParams.get('state')
|
|
47
|
+
if (state) {
|
|
48
|
+
cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'google' }), {
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
secure: true,
|
|
51
|
+
sameSite: 'lax',
|
|
52
|
+
path: '/',
|
|
53
|
+
maxAge: 600, // 10 min
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// Default: GitHub
|
|
58
|
+
const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
|
|
59
|
+
const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
|
|
60
|
+
|
|
61
|
+
if (!ghClientId || !ghClientSecret) {
|
|
62
|
+
return new Response('GitHub OAuth not configured', { status: 500 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const auth = createGitHubAuth({
|
|
66
|
+
clientId: ghClientId,
|
|
67
|
+
clientSecret: ghClientSecret,
|
|
68
|
+
redirectUri,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
loginUrl = auth.getLoginUrl('github')
|
|
72
|
+
|
|
73
|
+
const urlObj = new URL(loginUrl)
|
|
74
|
+
const state = urlObj.searchParams.get('state')
|
|
75
|
+
if (state) {
|
|
76
|
+
cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'github' }), {
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
secure: true,
|
|
79
|
+
sameSite: 'lax',
|
|
80
|
+
path: '/',
|
|
81
|
+
maxAge: 600,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return redirect(loginUrl)
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session check – returns current user info or 401.
|
|
5
|
+
* Used by the admin SPA to check if the user is logged in.
|
|
6
|
+
*/
|
|
7
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
8
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
9
|
+
if (!session) {
|
|
10
|
+
return new Response(JSON.stringify({ authenticated: false }), {
|
|
11
|
+
status: 401,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(session) as { user: unknown; expiresAt: number }
|
|
18
|
+
|
|
19
|
+
if (parsed.expiresAt < Date.now()) {
|
|
20
|
+
cookies.delete('setzkasten_session', { path: '/' })
|
|
21
|
+
return new Response(JSON.stringify({ authenticated: false, reason: 'expired' }), {
|
|
22
|
+
status: 401,
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return new Response(JSON.stringify({ authenticated: true, user: parsed.user }), {
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
})
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response(JSON.stringify({ authenticated: false }), {
|
|
32
|
+
status: 401,
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { registry } from '@setzkasten-cms/catalog'
|
|
3
|
+
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
4
|
+
import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
|
|
5
|
+
import { generateAddKey, addToPageConfig } from './section-management'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/setzkasten/catalog/add
|
|
9
|
+
*
|
|
10
|
+
* Adds a catalog template to a page:
|
|
11
|
+
* - Creates content JSON with template's defaultContent (_sections/{key}.json)
|
|
12
|
+
* - Appends new entry to the page config (pages/_{pageKey}.json)
|
|
13
|
+
*
|
|
14
|
+
* Body: { templateName, pageKey, sectionKey? (override), owner?, repo?, branch?, contentPath? }
|
|
15
|
+
*/
|
|
16
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
17
|
+
const session = 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
|
+
try {
|
|
24
|
+
const body = await request.json() as Record<string, unknown>
|
|
25
|
+
|
|
26
|
+
let validated: ReturnType<typeof validateCatalogAddBody>
|
|
27
|
+
try {
|
|
28
|
+
validated = validateCatalogAddBody(body)
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return Response.json({ error: e instanceof Error ? e.message : 'Invalid request' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { templateName, pageKey } = validated
|
|
34
|
+
|
|
35
|
+
const storage = resolveStorageConfig(body)
|
|
36
|
+
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
|
+
const { owner, repo, branch, projectPrefix } = storage
|
|
38
|
+
|
|
39
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
40
|
+
const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
|
|
41
|
+
|
|
42
|
+
const headers = {
|
|
43
|
+
Authorization: `Bearer ${githubToken}`,
|
|
44
|
+
Accept: 'application/vnd.github+json',
|
|
45
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 1. Read page config
|
|
50
|
+
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
51
|
+
const rawPageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
52
|
+
const pageConfigPath = prefixPath(rawPageConfigPath, projectPrefix)
|
|
53
|
+
const pageConfigRaw = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
|
|
54
|
+
if (!pageConfigRaw) return Response.json({ error: 'Page config not found' }, { status: 404 })
|
|
55
|
+
|
|
56
|
+
const pageConfig = JSON.parse(pageConfigRaw)
|
|
57
|
+
const existingKeys: string[] = (pageConfig.sections ?? []).map((s: { key: string }) => s.key)
|
|
58
|
+
|
|
59
|
+
// 2. Determine section key
|
|
60
|
+
const sectionKey = validated.sectionKey ?? generateAddKey(existingKeys, templateName)
|
|
61
|
+
if (existingKeys.includes(sectionKey)) {
|
|
62
|
+
return Response.json({ error: `Key "${sectionKey}" already exists on this page` }, { status: 409 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Get template + default content
|
|
66
|
+
const template = registry.get(templateName)
|
|
67
|
+
const { sectionJsonPath } = buildCatalogAddCommit({
|
|
68
|
+
contentPath,
|
|
69
|
+
projectPrefix,
|
|
70
|
+
pageKey,
|
|
71
|
+
sectionKey,
|
|
72
|
+
templateName,
|
|
73
|
+
pageConfigPath: rawPageConfigPath,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// 4. Commit: content JSON + updated page config
|
|
77
|
+
const updatedConfig = addToPageConfig(pageConfig, sectionKey, templateName)
|
|
78
|
+
|
|
79
|
+
const commitResult = await batchCommit(
|
|
80
|
+
owner, repo, branch,
|
|
81
|
+
[
|
|
82
|
+
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
83
|
+
{ path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
|
|
84
|
+
],
|
|
85
|
+
`content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
|
|
86
|
+
headers,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
90
|
+
|
|
91
|
+
return Response.json({ success: true, sectionKey, commitSha: commitResult.sha })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('[setzkasten] catalog-add error:', error)
|
|
94
|
+
return Response.json(
|
|
95
|
+
{ error: error instanceof Error ? error.message : 'Catalog add failed' },
|
|
96
|
+
{ status: 500 },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(
|
|
104
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
105
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
106
|
+
)
|
|
107
|
+
if (!res.ok) return null
|
|
108
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
109
|
+
return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
110
|
+
} catch { return null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function batchCommit(
|
|
114
|
+
owner: string, repo: string, branch: string,
|
|
115
|
+
files: Array<{ path: string; content: string }>,
|
|
116
|
+
message: string,
|
|
117
|
+
headers: Record<string, string>,
|
|
118
|
+
): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
|
|
119
|
+
try {
|
|
120
|
+
const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
|
|
121
|
+
if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
|
|
122
|
+
const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
|
|
123
|
+
|
|
124
|
+
const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
|
|
125
|
+
if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
|
|
126
|
+
const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
|
|
127
|
+
|
|
128
|
+
const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
|
|
129
|
+
method: 'POST', headers,
|
|
130
|
+
body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
|
|
131
|
+
})
|
|
132
|
+
if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
|
|
133
|
+
const { sha: treeSha } = await treeRes.json() as { sha: string }
|
|
134
|
+
|
|
135
|
+
const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
|
|
136
|
+
method: 'POST', headers,
|
|
137
|
+
body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
|
|
138
|
+
})
|
|
139
|
+
if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
|
|
140
|
+
const { sha: newSha } = await newCommitRes.json() as { sha: string }
|
|
141
|
+
|
|
142
|
+
const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
|
|
143
|
+
method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
|
|
144
|
+
})
|
|
145
|
+
if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
|
|
146
|
+
|
|
147
|
+
return { ok: true, sha: newSha }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { exportTemplate } from '@setzkasten-cms/catalog'
|
|
3
|
+
import { resolveStorageConfig, prefixPath } from './_storage-config'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/setzkasten/catalog/export
|
|
7
|
+
*
|
|
8
|
+
* Exports a managed section as a `.setzkasten-template` JSON string.
|
|
9
|
+
* The caller can save this to a file or upload it to a shared catalog.
|
|
10
|
+
*
|
|
11
|
+
* Body: { sectionKey, owner?, repo?, branch?, contentPath? }
|
|
12
|
+
*/
|
|
13
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
14
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
15
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
16
|
+
|
|
17
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
18
|
+
if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const body = await request.json() as {
|
|
22
|
+
sectionKey: string
|
|
23
|
+
owner?: string
|
|
24
|
+
repo?: string
|
|
25
|
+
branch?: string
|
|
26
|
+
contentPath?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!body.sectionKey) {
|
|
30
|
+
return Response.json({ error: 'sectionKey is required' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const storage = resolveStorageConfig(body)
|
|
34
|
+
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
35
|
+
const { owner, repo, branch, projectPrefix } = storage
|
|
36
|
+
|
|
37
|
+
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
38
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
39
|
+
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
40
|
+
const { sectionKey } = body
|
|
41
|
+
|
|
42
|
+
// 1. Read section content JSON
|
|
43
|
+
const sectionJsonPath = prefixPath(`${contentPath}/_sections/${sectionKey}.json`, projectPrefix)
|
|
44
|
+
const contentRaw = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
|
|
45
|
+
if (!contentRaw) return Response.json({ error: `Section content not found: ${sectionKey}` }, { status: 404 })
|
|
46
|
+
|
|
47
|
+
const content = JSON.parse(contentRaw) as Record<string, unknown>
|
|
48
|
+
|
|
49
|
+
// 2. Find section definition from full config
|
|
50
|
+
const sectionDef = findSectionDef(fullConfig, sectionKey)
|
|
51
|
+
if (!sectionDef) {
|
|
52
|
+
return Response.json({ error: `Section definition not found for key: ${sectionKey}` }, { status: 404 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Export to template format
|
|
56
|
+
const templateJson = exportTemplate(sectionKey, sectionDef, content)
|
|
57
|
+
|
|
58
|
+
return Response.json({ success: true, template: templateJson })
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('[setzkasten] catalog-export error:', error)
|
|
61
|
+
return Response.json(
|
|
62
|
+
{ error: error instanceof Error ? error.message : 'Export failed' },
|
|
63
|
+
{ status: 500 },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findSectionDef(fullConfig: any, sectionKey: string): any {
|
|
69
|
+
if (!fullConfig?.products) return null
|
|
70
|
+
for (const product of Object.values(fullConfig.products) as any[]) {
|
|
71
|
+
if (product?.sections?.[sectionKey]) return product.sections[sectionKey]
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(
|
|
79
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?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
|
+
return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
85
|
+
} catch { return null }
|
|
86
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper functions for catalog API routes.
|
|
3
|
+
* No GitHub API, no Astro runtime — fully unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { registry } from '@setzkasten-cms/catalog'
|
|
7
|
+
import { prefixPath } from './_storage-config'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// GET /api/setzkasten/catalog
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Returns the full catalog list (all built-in templates) for the API response. */
|
|
14
|
+
export function buildCatalogResponse() {
|
|
15
|
+
return registry.list()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// POST /api/setzkasten/catalog/add
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface CatalogAddBody {
|
|
23
|
+
templateName?: unknown
|
|
24
|
+
pageKey?: unknown
|
|
25
|
+
sectionKey?: unknown
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validates the request body for POST /api/setzkasten/catalog/add.
|
|
31
|
+
* Throws with a descriptive message on any validation error.
|
|
32
|
+
*/
|
|
33
|
+
export function validateCatalogAddBody(body: CatalogAddBody): {
|
|
34
|
+
templateName: string
|
|
35
|
+
pageKey: string
|
|
36
|
+
sectionKey?: string
|
|
37
|
+
} {
|
|
38
|
+
if (!body.templateName || typeof body.templateName !== 'string') {
|
|
39
|
+
throw new Error('templateName is required')
|
|
40
|
+
}
|
|
41
|
+
if (!body.pageKey || typeof body.pageKey !== 'string') {
|
|
42
|
+
throw new Error('pageKey is required')
|
|
43
|
+
}
|
|
44
|
+
// Validate templateName exists in registry (throws if not)
|
|
45
|
+
registry.get(body.templateName)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
templateName: body.templateName,
|
|
49
|
+
pageKey: body.pageKey,
|
|
50
|
+
sectionKey: typeof body.sectionKey === 'string' ? body.sectionKey : undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Commit path builder
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface CatalogAddCommitOpts {
|
|
59
|
+
contentPath: string
|
|
60
|
+
projectPrefix: string
|
|
61
|
+
pageKey: string
|
|
62
|
+
sectionKey: string
|
|
63
|
+
templateName: string
|
|
64
|
+
pageConfigPath: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CatalogAddCommitPaths {
|
|
68
|
+
sectionJsonPath: string
|
|
69
|
+
pageConfigPath: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds the file paths that need to be committed when adding a catalog template to a page.
|
|
74
|
+
*/
|
|
75
|
+
export function buildCatalogAddCommit(opts: CatalogAddCommitOpts): CatalogAddCommitPaths {
|
|
76
|
+
const sectionJsonPath = prefixPath(
|
|
77
|
+
`${opts.contentPath}/_sections/${opts.sectionKey}.json`,
|
|
78
|
+
opts.projectPrefix,
|
|
79
|
+
)
|
|
80
|
+
const pageConfigPath = prefixPath(opts.pageConfigPath, opts.projectPrefix)
|
|
81
|
+
|
|
82
|
+
return { sectionJsonPath, pageConfigPath }
|
|
83
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { buildCatalogResponse } from './catalog-helpers'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/setzkasten/catalog
|
|
6
|
+
*
|
|
7
|
+
* Returns all available catalog templates (built-in registry).
|
|
8
|
+
* No authentication required — catalog is read-only metadata.
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async () => {
|
|
11
|
+
return Response.json({ templates: buildCatalogResponse() })
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the full SetzKastenConfig as JSON.
|
|
5
|
+
* The config is injected into globalThis by the integration at build time.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/config
|
|
8
|
+
*/
|
|
9
|
+
export const GET: APIRoute = async () => {
|
|
10
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
|
|
11
|
+
const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
|
|
12
|
+
|
|
13
|
+
const result = {
|
|
14
|
+
storage: { kind: 'github' },
|
|
15
|
+
auth: { providers: ['github'] },
|
|
16
|
+
theme: {},
|
|
17
|
+
products: {},
|
|
18
|
+
collections: {},
|
|
19
|
+
...config,
|
|
20
|
+
// Include storage params so the client can create ProxyContentRepository
|
|
21
|
+
_storage: ssrConfig?.storage ?? undefined,
|
|
22
|
+
_hasGitHub: ssrConfig?.hasGitHub ?? false,
|
|
23
|
+
_hasGoogle: ssrConfig?.hasGoogle ?? false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Response(JSON.stringify(result), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Triggers the configured deploy hook URL after a content commit.
|
|
5
|
+
* Called by the CMS UI after every successful GitHub commit.
|
|
6
|
+
*
|
|
7
|
+
* The deploy hook URL is set via:
|
|
8
|
+
* setzkasten({ deployHook: { url: 'https://api.vercel.com/v1/integrations/deploy/...' } })
|
|
9
|
+
*
|
|
10
|
+
* Compatible with Vercel, Netlify, Cloudflare Pages and any URL that accepts a POST.
|
|
11
|
+
*/
|
|
12
|
+
export const POST: APIRoute = async ({ cookies }) => {
|
|
13
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
14
|
+
if (!session) {
|
|
15
|
+
return new Response('Unauthorized', { status: 401 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
19
|
+
deployHook?: { url: string; secret?: string }
|
|
20
|
+
} | undefined
|
|
21
|
+
|
|
22
|
+
if (!config?.deployHook?.url) {
|
|
23
|
+
return new Response(JSON.stringify({ skipped: true, reason: 'Kein deployHook konfiguriert' }), {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { url, secret } = config.deployHook
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const headers: Record<string, string> = {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'User-Agent': 'setzkasten-cms',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (secret) {
|
|
38
|
+
headers['X-Setzkasten-Secret'] = secret
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const response = await fetch(url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers,
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
event: 'content.commit',
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
console.warn(`[setzkasten] Deploy hook antwortete mit ${response.status}: ${url}`)
|
|
52
|
+
return new Response(
|
|
53
|
+
JSON.stringify({ ok: false, status: response.status }),
|
|
54
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
59
|
+
status: 200,
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('[setzkasten] Deploy hook fehlgeschlagen:', error)
|
|
64
|
+
return new Response(
|
|
65
|
+
JSON.stringify({ ok: false, error: String(error) }),
|
|
66
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-side proxy for GitHub API calls.
|
|
7
|
+
* The GitHub App token stays server-side (never exposed to browser).
|
|
8
|
+
*
|
|
9
|
+
* Client calls: POST /api/setzkasten/github/repos/{owner}/{repo}/...
|
|
10
|
+
* Proxy forwards to: https://api.github.com/repos/{owner}/{repo}/...
|
|
11
|
+
*/
|
|
12
|
+
export const ALL: APIRoute = async ({ params, request, cookies }) => {
|
|
13
|
+
// Verify session
|
|
14
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
15
|
+
if (!session) {
|
|
16
|
+
return new Response('Unauthorized', { status: 401 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const githubPath = params.path
|
|
20
|
+
if (!githubPath) {
|
|
21
|
+
return new Response('Missing path', { status: 400 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// GitHub App token from environment (never sent to client)
|
|
25
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
26
|
+
|
|
27
|
+
if (!githubToken) {
|
|
28
|
+
return new Response('GitHub token not configured', { status: 500 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const githubUrl = `https://api.github.com/${githubPath}`
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Forward the request to GitHub API
|
|
35
|
+
const headers: Record<string, string> = {
|
|
36
|
+
Authorization: `Bearer ${githubToken}`,
|
|
37
|
+
Accept: 'application/vnd.github+json',
|
|
38
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Forward Content-Type for write operations
|
|
42
|
+
const contentType = request.headers.get('content-type')
|
|
43
|
+
if (contentType) {
|
|
44
|
+
headers['Content-Type'] = contentType
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const body =
|
|
48
|
+
request.method !== 'GET' && request.method !== 'HEAD'
|
|
49
|
+
? await request.text()
|
|
50
|
+
: undefined
|
|
51
|
+
|
|
52
|
+
const response = await fetch(githubUrl, {
|
|
53
|
+
method: request.method,
|
|
54
|
+
headers,
|
|
55
|
+
body,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Forward response with rate limit headers
|
|
59
|
+
const responseHeaders = new Headers()
|
|
60
|
+
responseHeaders.set('Content-Type', response.headers.get('content-type') ?? 'application/json')
|
|
61
|
+
|
|
62
|
+
const rateLimitHeaders = [
|
|
63
|
+
'x-ratelimit-limit',
|
|
64
|
+
'x-ratelimit-remaining',
|
|
65
|
+
'x-ratelimit-reset',
|
|
66
|
+
]
|
|
67
|
+
for (const header of rateLimitHeaders) {
|
|
68
|
+
const value = response.headers.get(header)
|
|
69
|
+
if (value) responseHeaders.set(header, value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Forward ETag for caching
|
|
73
|
+
const etag = response.headers.get('etag')
|
|
74
|
+
if (etag) responseHeaders.set('etag', etag)
|
|
75
|
+
|
|
76
|
+
const responseText = await response.text()
|
|
77
|
+
|
|
78
|
+
// Mirror successful PUT writes to local filesystem (dev-server sync).
|
|
79
|
+
// GitHub path pattern: repos/{owner}/{repo}/contents/{filePath}
|
|
80
|
+
// We extract {filePath} and write it relative to repoRoot so the
|
|
81
|
+
// Vite virtual content module picks it up on next HMR cycle.
|
|
82
|
+
if (request.method === 'PUT' && response.ok && body) {
|
|
83
|
+
try {
|
|
84
|
+
const repoRoot: string | undefined = (globalThis as any).__SETZKASTEN_CONFIG__?.repoRoot
|
|
85
|
+
if (repoRoot) {
|
|
86
|
+
// githubPath = "repos/{owner}/{repo}/contents/{filePath}"
|
|
87
|
+
const contentsMatch = githubPath.match(/^repos\/[^/]+\/[^/]+\/contents\/(.+)$/)
|
|
88
|
+
if (contentsMatch) {
|
|
89
|
+
const filePath = contentsMatch[1]!
|
|
90
|
+
const parsed = JSON.parse(body) as { content?: string }
|
|
91
|
+
if (parsed.content) {
|
|
92
|
+
// GitHub API sends base64 with possible line breaks
|
|
93
|
+
const decoded = Buffer.from(parsed.content.replace(/\s/g, ''), 'base64').toString('utf-8')
|
|
94
|
+
await writeFile(join(repoRoot, filePath), decoded, 'utf-8').catch(() => {})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Non-fatal — local sync is best-effort
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new Response(responseText, {
|
|
104
|
+
status: response.status,
|
|
105
|
+
headers: responseHeaders,
|
|
106
|
+
})
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('[setzkasten] GitHub proxy error:', error)
|
|
109
|
+
return new Response('GitHub API request failed', { status: 502 })
|
|
110
|
+
}
|
|
111
|
+
}
|