@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.
Files changed (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. 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,9 @@
1
+ import type { APIRoute } from 'astro'
2
+
3
+ /**
4
+ * Logout – clears the session cookie and redirects to home.
5
+ */
6
+ export const GET: APIRoute = async ({ cookies, redirect }) => {
7
+ cookies.delete('setzkasten_session', { path: '/' })
8
+ return redirect('/')
9
+ }
@@ -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
+ }