@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,162 @@
1
+ /**
2
+ * Tests for the pure helper logic in init-scan-page.ts
3
+ *
4
+ * The route handler itself requires GitHub API + session auth. We test
5
+ * the extractable pure functions directly:
6
+ *
7
+ * 1. pageKey normalisation (pagePath → sectionKey)
8
+ * The `_page_` prefix and `/` → `_` substitution must be correct,
9
+ * because the sectionKey is used to look up content JSON files.
10
+ * Wrong key = 404 in content API.
11
+ *
12
+ * 2. extractLayoutRegions()
13
+ * Extracts <header> and <footer> from a layout file. Used to
14
+ * surface global nav/footer as editable sections in the admin.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { extractLayoutRegions } from '../init-scan-page'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // 1. pageKey normalisation — re-implemented inline from the route handler
22
+ // so we can test the exact logic without importing APIRoute deps.
23
+ // IMPORTANT: keep in sync with init-scan-page.ts lines 172-176.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function pagePathToSectionKey(pagePath: string): string {
27
+ const pageKeyNorm = pagePath
28
+ .replace(/^src\/pages\//, '')
29
+ .replace(/\/(index)?\.astro$/, '')
30
+ .replace(/\.astro$/, '') || 'index'
31
+ return '_page_' + pageKeyNorm.replace(/\//g, '_')
32
+ }
33
+
34
+ describe('pagePathToSectionKey — correct key derivation', () => {
35
+ it('index page', () => {
36
+ expect(pagePathToSectionKey('src/pages/index.astro')).toBe('_page_index')
37
+ })
38
+
39
+ it('top-level page', () => {
40
+ expect(pagePathToSectionKey('src/pages/about.astro')).toBe('_page_about')
41
+ })
42
+
43
+ it('nested page', () => {
44
+ expect(pagePathToSectionKey('src/pages/docs/architecture.astro')).toBe('_page_docs_architecture')
45
+ })
46
+
47
+ it('uses underscore not double-dash for nested', () => {
48
+ expect(pagePathToSectionKey('src/pages/docs/architecture.astro')).not.toContain('--')
49
+ })
50
+
51
+ it('nested index page', () => {
52
+ // src/pages/docs/index.astro → pageKey should be 'docs'
53
+ expect(pagePathToSectionKey('src/pages/docs/index.astro')).toBe('_page_docs')
54
+ })
55
+
56
+ it('three levels deep', () => {
57
+ expect(pagePathToSectionKey('src/pages/blog/2024/intro.astro')).toBe('_page_blog_2024_intro')
58
+ })
59
+
60
+ it('fields page (real regression case)', () => {
61
+ // This was the page that broke — docs/fields.astro
62
+ expect(pagePathToSectionKey('src/pages/docs/fields.astro')).toBe('_page_docs_fields')
63
+ })
64
+ })
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 2. extractLayoutRegions
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe('extractLayoutRegions — header and footer extraction', () => {
71
+ const layoutWithBoth = `---
72
+ import { getSection } from 'setzkasten:content'
73
+ ---
74
+
75
+ <html>
76
+ <body>
77
+ <header class="sticky top-0 z-50">
78
+ <nav>
79
+ <a href="/">Home</a>
80
+ <a href="/about">About</a>
81
+ </nav>
82
+ </header>
83
+
84
+ <main>
85
+ <slot />
86
+ </main>
87
+
88
+ <footer class="py-8">
89
+ <p>© 2024 Lilapixel</p>
90
+ <a href="/impressum">Impressum</a>
91
+ </footer>
92
+ </body>
93
+ </html>
94
+ `
95
+
96
+ it('should extract header region', () => {
97
+ const regions = extractLayoutRegions(layoutWithBoth)
98
+ expect(regions.some(r => r.name === 'header')).toBe(true)
99
+ })
100
+
101
+ it('should extract footer region', () => {
102
+ const regions = extractLayoutRegions(layoutWithBoth)
103
+ expect(regions.some(r => r.name === 'footer')).toBe(true)
104
+ })
105
+
106
+ it('header region HTML should contain the nav content', () => {
107
+ const regions = extractLayoutRegions(layoutWithBoth)
108
+ const header = regions.find(r => r.name === 'header')!
109
+ expect(header.html).toContain('<nav>')
110
+ expect(header.html).toContain('Home')
111
+ })
112
+
113
+ it('footer region HTML should contain the footer content', () => {
114
+ const regions = extractLayoutRegions(layoutWithBoth)
115
+ const footer = regions.find(r => r.name === 'footer')!
116
+ expect(footer.html).toContain('Lilapixel')
117
+ })
118
+
119
+ it('should set correct tag names', () => {
120
+ const regions = extractLayoutRegions(layoutWithBoth)
121
+ for (const r of regions) {
122
+ expect(r.tag).toBe(r.name)
123
+ }
124
+ })
125
+ })
126
+
127
+ describe('extractLayoutRegions — partial layouts', () => {
128
+ it('returns only header if no footer', () => {
129
+ const source = `---\n---\n<html><header><nav>Menu</nav></header><main><slot /></main></html>`
130
+ const regions = extractLayoutRegions(source)
131
+ expect(regions).toHaveLength(1)
132
+ expect(regions[0]!.name).toBe('header')
133
+ })
134
+
135
+ it('returns only footer if no header', () => {
136
+ const source = `---\n---\n<html><main><slot /></main><footer><p>Footer</p></footer></html>`
137
+ const regions = extractLayoutRegions(source)
138
+ expect(regions).toHaveLength(1)
139
+ expect(regions[0]!.name).toBe('footer')
140
+ })
141
+
142
+ it('returns empty array if neither header nor footer', () => {
143
+ const source = `---\n---\n<html><main><slot /></main></html>`
144
+ const regions = extractLayoutRegions(source)
145
+ expect(regions).toHaveLength(0)
146
+ })
147
+
148
+ it('handles layout without frontmatter', () => {
149
+ const source = `<html><header><a href="/">Home</a></header></html>`
150
+ const regions = extractLayoutRegions(source)
151
+ expect(regions).toHaveLength(1)
152
+ })
153
+ })
154
+
155
+ describe('extractLayoutRegions — attributes on tags', () => {
156
+ it('handles header with class attributes', () => {
157
+ const source = `---\n---\n<header class="sticky top-0 bg-white z-50"><nav>nav</nav></header>`
158
+ const regions = extractLayoutRegions(source)
159
+ expect(regions).toHaveLength(1)
160
+ expect(regions[0]!.html).toContain('sticky top-0')
161
+ })
162
+ })
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Tests for section-management helpers: delete and duplicate.
3
+ *
4
+ * Pure logic — no GitHub API involved.
5
+ * Covered:
6
+ * 1. removeFromPageConfig: removes a section entry from page config
7
+ * 2. generateDuplicateKey: unique key generation with suffix
8
+ * 3. duplicateInPageConfig: adds duplicate entry after the original
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import {
13
+ removeFromPageConfig,
14
+ generateDuplicateKey,
15
+ duplicateInPageConfig,
16
+ generateAddKey,
17
+ addToPageConfig,
18
+ } from '../section-management'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Fixtures
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const pageConfig = {
25
+ sections: [
26
+ { key: 'hero', enabled: true, order: 0 },
27
+ { key: 'features', enabled: true, order: 1 },
28
+ { key: 'cta', enabled: false, order: 2 },
29
+ ],
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // removeFromPageConfig
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('removeFromPageConfig', () => {
37
+ it('removes the matching section entry', () => {
38
+ const result = removeFromPageConfig(pageConfig, 'features')
39
+ expect(result.sections.map(s => s.key)).toEqual(['hero', 'cta'])
40
+ })
41
+
42
+ it('re-numbers order after removal', () => {
43
+ const result = removeFromPageConfig(pageConfig, 'features')
44
+ expect(result.sections[0]!.order).toBe(0)
45
+ expect(result.sections[1]!.order).toBe(1)
46
+ })
47
+
48
+ it('does not mutate the original config', () => {
49
+ removeFromPageConfig(pageConfig, 'hero')
50
+ expect(pageConfig.sections).toHaveLength(3)
51
+ })
52
+
53
+ it('returns config unchanged if key does not exist', () => {
54
+ const result = removeFromPageConfig(pageConfig, 'nonexistent')
55
+ expect(result.sections).toHaveLength(3)
56
+ })
57
+
58
+ it('works when removing the only section', () => {
59
+ const single = { sections: [{ key: 'hero', enabled: true, order: 0 }] }
60
+ const result = removeFromPageConfig(single, 'hero')
61
+ expect(result.sections).toHaveLength(0)
62
+ })
63
+ })
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // generateDuplicateKey
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('generateDuplicateKey', () => {
70
+ it('appends --copy when key is free', () => {
71
+ expect(generateDuplicateKey(['hero', 'features'], 'hero')).toBe('hero--copy')
72
+ })
73
+
74
+ it('appends --copy2 when hero--copy already exists', () => {
75
+ expect(generateDuplicateKey(['hero', 'hero--copy'], 'hero')).toBe('hero--copy2')
76
+ })
77
+
78
+ it('appends --copy3 when hero--copy and hero--copy2 exist', () => {
79
+ expect(generateDuplicateKey(['hero', 'hero--copy', 'hero--copy2'], 'hero')).toBe('hero--copy3')
80
+ })
81
+
82
+ it('works for multi-instance keys (hero--about → hero--about--copy)', () => {
83
+ expect(generateDuplicateKey(['hero--about'], 'hero--about')).toBe('hero--about--copy')
84
+ })
85
+
86
+ it('does not conflict with unrelated keys containing copy', () => {
87
+ expect(generateDuplicateKey(['features', 'features--copycat'], 'features')).toBe('features--copy')
88
+ })
89
+ })
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // duplicateInPageConfig
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe('duplicateInPageConfig', () => {
96
+ it('inserts duplicate entry immediately after the original', () => {
97
+ const result = duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
98
+ const keys = result.sections.map(s => s.key)
99
+ expect(keys).toEqual(['hero', 'hero--copy', 'features', 'cta'])
100
+ })
101
+
102
+ it('new entry is enabled regardless of original enabled state', () => {
103
+ const result = duplicateInPageConfig(pageConfig, 'cta', 'cta--copy')
104
+ const copy = result.sections.find(s => s.key === 'cta--copy')!
105
+ expect(copy.enabled).toBe(true)
106
+ })
107
+
108
+ it('re-numbers order after insertion', () => {
109
+ const result = duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
110
+ result.sections.forEach((s, i) => expect(s.order).toBe(i))
111
+ })
112
+
113
+ it('preserves type field from original if present', () => {
114
+ const withType = {
115
+ sections: [
116
+ { key: 'hero--about', type: 'hero', enabled: true, order: 0 },
117
+ ],
118
+ }
119
+ const result = duplicateInPageConfig(withType, 'hero--about', 'hero--about--copy')
120
+ const copy = result.sections.find(s => s.key === 'hero--about--copy')!
121
+ expect((copy as any).type).toBe('hero')
122
+ })
123
+
124
+ it('does not mutate the original config', () => {
125
+ duplicateInPageConfig(pageConfig, 'hero', 'hero--copy')
126
+ expect(pageConfig.sections).toHaveLength(3)
127
+ })
128
+
129
+ it('returns config unchanged if original key does not exist', () => {
130
+ const result = duplicateInPageConfig(pageConfig, 'nonexistent', 'nonexistent--copy')
131
+ expect(result.sections).toHaveLength(3)
132
+ })
133
+ })
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // generateAddKey
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('generateAddKey', () => {
140
+ it('returns type as key when no existing keys', () => {
141
+ expect(generateAddKey([], 'hero')).toBe('hero')
142
+ })
143
+
144
+ it('returns type as key when type key is not taken', () => {
145
+ expect(generateAddKey(['features', 'cta'], 'hero')).toBe('hero')
146
+ })
147
+
148
+ it('appends --2 when base key is taken', () => {
149
+ expect(generateAddKey(['hero'], 'hero')).toBe('hero--2')
150
+ })
151
+
152
+ it('appends --3 when hero and hero--2 are taken', () => {
153
+ expect(generateAddKey(['hero', 'hero--2'], 'hero')).toBe('hero--3')
154
+ })
155
+
156
+ it('does not conflict with unrelated keys', () => {
157
+ expect(generateAddKey(['hero-section', 'hero--about'], 'hero')).toBe('hero')
158
+ })
159
+ })
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // delete workflow integration (helpers combined)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ describe('delete workflow', () => {
166
+ it('removes section and renumbers order', () => {
167
+ const after = removeFromPageConfig(pageConfig, 'features')
168
+ expect(after.sections.map(s => s.key)).toEqual(['hero', 'cta'])
169
+ expect(after.sections[0]!.order).toBe(0)
170
+ expect(after.sections[1]!.order).toBe(1)
171
+ })
172
+
173
+ it('removing the last section leaves empty array', () => {
174
+ let cfg = removeFromPageConfig(pageConfig, 'hero')
175
+ cfg = removeFromPageConfig(cfg, 'features')
176
+ cfg = removeFromPageConfig(cfg, 'cta')
177
+ expect(cfg.sections).toHaveLength(0)
178
+ })
179
+ })
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // duplicate workflow integration (helpers combined)
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe('duplicate workflow', () => {
186
+ it('generates key and inserts copy right after original', () => {
187
+ const existingKeys = pageConfig.sections.map(s => s.key)
188
+ const newKey = generateDuplicateKey(existingKeys, 'hero')
189
+ const after = duplicateInPageConfig(pageConfig, 'hero', newKey)
190
+ expect(newKey).toBe('hero--copy')
191
+ expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy', 'features', 'cta'])
192
+ after.sections.forEach((s, i) => expect(s.order).toBe(i))
193
+ })
194
+
195
+ it('handles second duplicate when --copy already exists', () => {
196
+ const withCopy = {
197
+ sections: [
198
+ { key: 'hero', enabled: true, order: 0 },
199
+ { key: 'hero--copy', enabled: true, order: 1 },
200
+ ],
201
+ }
202
+ const existingKeys = withCopy.sections.map(s => s.key)
203
+ const newKey = generateDuplicateKey(existingKeys, 'hero')
204
+ expect(newKey).toBe('hero--copy2')
205
+ const after = duplicateInPageConfig(withCopy, 'hero', newKey)
206
+ expect(after.sections.map(s => s.key)).toEqual(['hero', 'hero--copy2', 'hero--copy'])
207
+ })
208
+ })
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // addToPageConfig
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('addToPageConfig', () => {
215
+ it('appends new entry at the end', () => {
216
+ const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
217
+ expect(result.sections.map(s => s.key)).toEqual(['hero', 'features', 'cta', 'testimonials'])
218
+ })
219
+
220
+ it('new entry is enabled by default', () => {
221
+ const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
222
+ const entry = result.sections.find(s => s.key === 'testimonials')!
223
+ expect(entry.enabled).toBe(true)
224
+ })
225
+
226
+ it('sets type field when key differs from type', () => {
227
+ const result = addToPageConfig(pageConfig, 'hero--2', 'hero')
228
+ const entry = result.sections.find(s => s.key === 'hero--2')!
229
+ expect((entry as any).type).toBe('hero')
230
+ })
231
+
232
+ it('omits type field when key equals type', () => {
233
+ const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
234
+ const entry = result.sections.find(s => s.key === 'testimonials')!
235
+ expect((entry as any).type).toBeUndefined()
236
+ })
237
+
238
+ it('assigns correct order (after last existing)', () => {
239
+ const result = addToPageConfig(pageConfig, 'testimonials', 'testimonials')
240
+ const entry = result.sections.find(s => s.key === 'testimonials')!
241
+ expect(entry.order).toBe(3)
242
+ })
243
+
244
+ it('does not mutate the original config', () => {
245
+ addToPageConfig(pageConfig, 'testimonials', 'testimonials')
246
+ expect(pageConfig.sections).toHaveLength(3)
247
+ })
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // section-prepare: contract tests (key generation + config update, no commit)
252
+ // These test the same helpers the prepare endpoint uses, documenting the
253
+ // expected behavior of optimistic/deferred section addition.
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('section-prepare contract', () => {
257
+ it('generates key and returns updated config without touching GitHub', () => {
258
+ const existingKeys = pageConfig.sections.map(s => s.key)
259
+ const newKey = generateAddKey(existingKeys, 'features') // 'features' is taken
260
+ expect(newKey).toBe('features--2')
261
+ const updated = addToPageConfig(pageConfig, newKey, 'features')
262
+ expect(updated.sections).toHaveLength(4)
263
+ const entry = updated.sections.find(s => s.key === 'features--2')!
264
+ expect((entry as any).type).toBe('features')
265
+ expect(entry.enabled).toBe(true)
266
+ })
267
+
268
+ it('new section key is unique even after two pending adds of same type', () => {
269
+ let keys = pageConfig.sections.map(s => s.key)
270
+ const key1 = generateAddKey(keys, 'hero') // hero is taken → hero--2
271
+ expect(key1).toBe('hero--2')
272
+ keys = [...keys, key1]
273
+ const key2 = generateAddKey(keys, 'hero') // hero + hero--2 taken → hero--3
274
+ expect(key2).toBe('hero--3')
275
+ })
276
+
277
+ it('addToPageConfig preserves existing order entries', () => {
278
+ const updated = addToPageConfig(pageConfig, 'newSection', 'newSection')
279
+ expect(updated.sections[0]!.order).toBe(0)
280
+ expect(updated.sections[1]!.order).toBe(1)
281
+ expect(updated.sections[2]!.order).toBe(2)
282
+ expect(updated.sections[3]!.order).toBe(3) // new section gets next order
283
+ })
284
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Resolve storage config from all available sources:
3
+ * 1. Request body (explicit override)
4
+ * 2. __SETZKASTEN_STORAGE__ (Vite define, embedded at build time — works everywhere)
5
+ * 3. globalThis.__SETZKASTEN_CONFIG__ (injectScript, only works for rendered pages)
6
+ */
7
+
8
+ declare const __SETZKASTEN_STORAGE__: {
9
+ owner: string
10
+ repo: string
11
+ branch: string
12
+ contentPath: string
13
+ assetsPath: string
14
+ /** Monorepo prefix, e.g. 'apps/website'. Empty string for standalone projects. */
15
+ projectPrefix: string
16
+ } | null
17
+
18
+ export interface StorageConfig {
19
+ owner: string
20
+ repo: string
21
+ branch: string
22
+ /** Monorepo prefix to prepend to file paths, e.g. 'apps/website' */
23
+ projectPrefix: string
24
+ }
25
+
26
+ export function resolveStorageConfig(body?: {
27
+ owner?: string
28
+ repo?: string
29
+ branch?: string
30
+ }): StorageConfig | null {
31
+ // Build-time constant (Vite define) — available in all modules including API routes
32
+ const buildConfig = typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null
33
+
34
+ // Runtime fallback (injectScript page-ssr — only for rendered pages)
35
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
36
+
37
+ const owner = body?.owner || buildConfig?.owner || serverConfig?.storage?.owner
38
+ const repo = body?.repo || buildConfig?.repo || serverConfig?.storage?.repo
39
+ const branch = body?.branch || buildConfig?.branch || serverConfig?.storage?.branch || 'main'
40
+ const projectPrefix = buildConfig?.projectPrefix ?? ''
41
+
42
+ if (!owner || !repo) return null
43
+
44
+ return { owner, repo, branch, projectPrefix }
45
+ }
46
+
47
+ /**
48
+ * Prefix a file path with the monorepo project prefix.
49
+ * e.g. prefixPath('src/pages/index.astro', 'apps/website') → 'apps/website/src/pages/index.astro'
50
+ */
51
+ export function prefixPath(filePath: string, projectPrefix: string): string {
52
+ if (!projectPrefix) return filePath
53
+ return `${projectPrefix}/${filePath}`
54
+ }
@@ -0,0 +1,76 @@
1
+ import type { APIRoute } from 'astro'
2
+
3
+ /**
4
+ * Asset proxy – serves images from the private GitHub repo.
5
+ * Used by the admin UI to display image thumbnails without exposing the GitHub token.
6
+ *
7
+ * GET /api/setzkasten/asset/public/images/about/LP_Logo.png
8
+ * → fetches from GitHub API and returns the raw binary with correct Content-Type.
9
+ */
10
+ export const GET: APIRoute = async ({ params, cookies }) => {
11
+ const session = cookies.get('setzkasten_session')?.value
12
+ if (!session) {
13
+ return new Response('Unauthorized', { status: 401 })
14
+ }
15
+
16
+ const assetPath = params.path
17
+ if (!assetPath) {
18
+ return new Response('Missing path', { status: 400 })
19
+ }
20
+
21
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
+ if (!githubToken) {
23
+ return new Response('GitHub token not configured', { status: 500 })
24
+ }
25
+
26
+ const config = (globalThis as any).__SETZKASTEN_CONFIG__
27
+ if (!config?.storage) {
28
+ return new Response('Storage not configured', { status: 500 })
29
+ }
30
+
31
+ const { owner, repo, branch } = config.storage
32
+ const githubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${assetPath}?ref=${branch}`
33
+
34
+ try {
35
+ const response = await fetch(githubUrl, {
36
+ headers: {
37
+ Authorization: `Bearer ${githubToken}`,
38
+ Accept: 'application/vnd.github.raw+json',
39
+ 'X-GitHub-Api-Version': '2022-11-28',
40
+ },
41
+ })
42
+
43
+ if (!response.ok) {
44
+ return new Response('Asset not found', { status: response.status })
45
+ }
46
+
47
+ const contentType = guessMimeType(assetPath)
48
+ const body = await response.arrayBuffer()
49
+
50
+ return new Response(body, {
51
+ status: 200,
52
+ headers: {
53
+ 'Content-Type': contentType,
54
+ 'Cache-Control': 'private, max-age=300',
55
+ },
56
+ })
57
+ } catch (error) {
58
+ console.error('[setzkasten] Asset proxy error:', error)
59
+ return new Response('Failed to fetch asset', { status: 502 })
60
+ }
61
+ }
62
+
63
+ function guessMimeType(path: string): string {
64
+ const ext = path.split('.').pop()?.toLowerCase()
65
+ const types: Record<string, string> = {
66
+ jpg: 'image/jpeg',
67
+ jpeg: 'image/jpeg',
68
+ png: 'image/png',
69
+ gif: 'image/gif',
70
+ webp: 'image/webp',
71
+ avif: 'image/avif',
72
+ svg: 'image/svg+xml',
73
+ pdf: 'application/pdf',
74
+ }
75
+ return types[ext ?? ''] ?? 'application/octet-stream'
76
+ }
@@ -0,0 +1,105 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { createGitHubAuth } from '@setzkasten-cms/auth'
3
+ import { createGoogleAuth } from '@setzkasten-cms/auth'
4
+
5
+ /**
6
+ * OAuth callback handler.
7
+ * Receives the authorization code from GitHub/Google, exchanges it for a
8
+ * session via the auth adapter, and sets a signed session cookie.
9
+ */
10
+ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
11
+ const code = url.searchParams.get('code')
12
+ const state = url.searchParams.get('state')
13
+
14
+ if (!code) {
15
+ return new Response('Missing authorization code', { status: 400 })
16
+ }
17
+
18
+ // Parse stored state (may contain provider info)
19
+ const storedRaw = cookies.get('setzkasten_oauth_state')?.value
20
+ let storedState: string | undefined
21
+ let provider = 'github'
22
+ if (storedRaw) {
23
+ try {
24
+ const parsed = JSON.parse(storedRaw)
25
+ storedState = parsed.state
26
+ provider = parsed.provider ?? 'github'
27
+ } catch {
28
+ storedState = storedRaw
29
+ }
30
+ }
31
+
32
+ // CSRF: verify state matches stored state
33
+ if (state && storedState && state !== storedState) {
34
+ return new Response('Invalid state parameter', { status: 400 })
35
+ }
36
+
37
+ // Clear the state cookie
38
+ cookies.delete('setzkasten_oauth_state', { path: '/' })
39
+
40
+ const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
41
+ | { adminPath: string }
42
+ | undefined
43
+
44
+ const adminPath = config?.adminPath ?? '/admin'
45
+
46
+ // On Vercel, url.origin may resolve to localhost. Use the Host header instead.
47
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
48
+ const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
49
+ const origin = `${protocol}://${host}`
50
+ const redirectUri = `${origin}/api/setzkasten/auth/callback`
51
+
52
+ // Read allowed emails from env (comma-separated)
53
+ const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
54
+ const allowedEmails = allowedEmailsRaw
55
+ ? allowedEmailsRaw.split(',').map((e: string) => e.trim())
56
+ : undefined
57
+
58
+ try {
59
+ let sessionResult
60
+
61
+ if (provider === 'google') {
62
+ const auth = createGoogleAuth({
63
+ clientId: import.meta.env.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID ?? '',
64
+ clientSecret: import.meta.env.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET ?? '',
65
+ redirectUri,
66
+ allowedEmails,
67
+ })
68
+ sessionResult = await auth.handleCallback(code, 'google')
69
+ } else {
70
+ const auth = createGitHubAuth({
71
+ clientId: import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? '',
72
+ clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
73
+ redirectUri,
74
+ allowedEmails,
75
+ })
76
+ sessionResult = await auth.handleCallback(code, 'github')
77
+ }
78
+
79
+ if (!sessionResult.ok) {
80
+ console.error('[setzkasten] Auth failed:', sessionResult.error.message)
81
+ return new Response(`Authentication failed: ${sessionResult.error.message}`, { status: 403 })
82
+ }
83
+
84
+ const session = sessionResult.value
85
+
86
+ // Set session cookie with user info (HMAC-signed in production)
87
+ const sessionPayload = JSON.stringify({
88
+ user: session.user,
89
+ expiresAt: session.expiresAt,
90
+ })
91
+
92
+ cookies.set('setzkasten_session', sessionPayload, {
93
+ httpOnly: true,
94
+ secure: import.meta.env.PROD,
95
+ sameSite: 'lax',
96
+ path: '/',
97
+ maxAge: 60 * 60 * 24 * 7, // 7 days
98
+ })
99
+
100
+ return redirect(adminPath)
101
+ } catch (error) {
102
+ console.error('[setzkasten] Auth callback error:', error)
103
+ return new Response('Authentication failed', { status: 500 })
104
+ }
105
+ }