@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setzkasten-cms/astro-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"headless-cms"
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
|
+
"./auth-setzkasten-login": "./src/api-routes/auth-setzkasten-login.ts",
|
|
25
|
+
"./global-config": "./src/api-routes/global-config.ts",
|
|
24
26
|
"./auth-login": "./src/api-routes/auth-login.ts",
|
|
25
27
|
"./auth-callback": "./src/api-routes/auth-callback.ts",
|
|
26
28
|
"./auth-logout": "./src/api-routes/auth-logout.ts",
|
|
@@ -44,6 +46,11 @@
|
|
|
44
46
|
"./section-commit-pending": "./src/api-routes/section-commit-pending.ts",
|
|
45
47
|
"./section-delete": "./src/api-routes/section-delete.ts",
|
|
46
48
|
"./section-duplicate": "./src/api-routes/section-duplicate.ts",
|
|
49
|
+
"./updater-register": "./src/api-routes/updater-register.ts",
|
|
50
|
+
"./updater-check": "./src/api-routes/updater-check.ts",
|
|
51
|
+
"./updater-transfer": "./src/api-routes/updater-transfer.ts",
|
|
52
|
+
"./updater-unbind": "./src/api-routes/updater-unbind.ts",
|
|
53
|
+
"./editors": "./src/api-routes/editors.ts",
|
|
47
54
|
"./admin-page": "./src/admin-page.astro"
|
|
48
55
|
},
|
|
49
56
|
"devDependencies": {
|
|
@@ -51,11 +58,11 @@
|
|
|
51
58
|
},
|
|
52
59
|
"dependencies": {
|
|
53
60
|
"@astrojs/compiler": "^3.0.0",
|
|
54
|
-
"@setzkasten-cms/auth": "0.
|
|
55
|
-
"@setzkasten-cms/catalog": "0.
|
|
56
|
-
"@setzkasten-cms/core": "0.
|
|
57
|
-
"@setzkasten-cms/
|
|
58
|
-
"@setzkasten-cms/
|
|
61
|
+
"@setzkasten-cms/auth": "0.8.0",
|
|
62
|
+
"@setzkasten-cms/catalog": "0.8.0",
|
|
63
|
+
"@setzkasten-cms/core": "0.8.0",
|
|
64
|
+
"@setzkasten-cms/github-adapter": "0.8.0",
|
|
65
|
+
"@setzkasten-cms/ui": "0.8.0"
|
|
59
66
|
},
|
|
60
67
|
"peerDependencies": {
|
|
61
68
|
"astro": "^5.0.0",
|
package/src/admin-page.astro
CHANGED
|
@@ -69,10 +69,7 @@
|
|
|
69
69
|
try {
|
|
70
70
|
const injected = (globalThis as any).__SETZKASTEN_CONFIG__ ?? {}
|
|
71
71
|
|
|
72
|
-
const providers: Array<'github' | 'google'> = []
|
|
73
|
-
if (injected.hasGitHub) providers.push('github')
|
|
74
|
-
if (injected.hasGoogle) providers.push('google')
|
|
75
|
-
if (providers.length === 0) providers.push('github')
|
|
72
|
+
const providers: Array<'github' | 'google'> = ['github']
|
|
76
73
|
|
|
77
74
|
// Fetch the full user config from server
|
|
78
75
|
let userConfig: any = null
|
|
@@ -81,12 +78,15 @@
|
|
|
81
78
|
if (res.ok) userConfig = await res.json()
|
|
82
79
|
} catch {}
|
|
83
80
|
|
|
84
|
-
|
|
81
|
+
// providers is always derived from injected flags (license-aware),
|
|
82
|
+
// so we override auth.providers regardless of what userConfig says.
|
|
83
|
+
const skConfig = {
|
|
85
84
|
storage: { kind: 'github' as const },
|
|
86
|
-
auth: { providers },
|
|
87
85
|
theme: {},
|
|
88
86
|
products: {},
|
|
89
87
|
collections: {},
|
|
88
|
+
...(userConfig ?? {}),
|
|
89
|
+
auth: { ...(userConfig?.auth ?? {}), providers },
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Storage params come from the config API (server-injected via SSR)
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
repo,
|
|
112
112
|
branch,
|
|
113
113
|
assetsPath,
|
|
114
|
-
publicUrlPrefix
|
|
114
|
+
// publicUrlPrefix is auto-derived from assetsPath by ProxyAssetStore
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
const auth = {
|
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
createElement(AdminApp)
|
|
132
132
|
)
|
|
133
133
|
)
|
|
134
|
+
|
|
134
135
|
} catch (error) {
|
|
135
136
|
console.error('[setzkasten] Boot failed:', error)
|
|
136
137
|
root.innerHTML = `
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for commit message trailer helpers.
|
|
3
|
+
* @vitest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { withTrailers, SETZKASTEN_CO_AUTHOR } from '../_commit-trailers'
|
|
8
|
+
|
|
9
|
+
describe('SETZKASTEN_CO_AUTHOR', () => {
|
|
10
|
+
it('is a valid Co-authored-by trailer', () => {
|
|
11
|
+
expect(SETZKASTEN_CO_AUTHOR).toMatch(/^Co-authored-by: .+ <.+>$/)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('withTrailers', () => {
|
|
16
|
+
it('appends the Setzkasten co-author trailer to any message', () => {
|
|
17
|
+
const result = withTrailers('content: update hero section on index')
|
|
18
|
+
expect(result).toContain(SETZKASTEN_CO_AUTHOR)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('separates the trailer block with a blank line', () => {
|
|
22
|
+
const result = withTrailers('content: update hero section on index')
|
|
23
|
+
expect(result).toContain('\n\n')
|
|
24
|
+
const [body, trailers] = result.split('\n\n')
|
|
25
|
+
expect(body).toBe('content: update hero section on index')
|
|
26
|
+
expect(trailers).toContain('Co-authored-by:')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not add editor trailer when editorEmail is not provided', () => {
|
|
30
|
+
const result = withTrailers('chore: something')
|
|
31
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
32
|
+
expect(lines).toHaveLength(1)
|
|
33
|
+
expect(lines[0]).toBe(SETZKASTEN_CO_AUTHOR)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not add editor trailer when editorEmail is null', () => {
|
|
37
|
+
const result = withTrailers('chore: something', null)
|
|
38
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
39
|
+
expect(lines).toHaveLength(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('adds editor Co-authored-by when editorEmail is provided', () => {
|
|
43
|
+
const result = withTrailers('content: update', 'jane.doe@example.com')
|
|
44
|
+
const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
|
|
45
|
+
expect(lines).toHaveLength(2)
|
|
46
|
+
expect(lines[1]).toContain('jane.doe@example.com')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('derives a readable name from the editor email local-part', () => {
|
|
50
|
+
const result = withTrailers('content: update', 'jane.doe@example.com')
|
|
51
|
+
expect(result).toContain('Co-authored-by: Jane Doe <jane.doe@example.com>')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('handles hyphen-separated names in email', () => {
|
|
55
|
+
const result = withTrailers('content: update', 'anna-lena@example.com')
|
|
56
|
+
expect(result).toContain('Co-authored-by: Anna Lena <anna-lena@example.com>')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('handles underscore-separated names in email', () => {
|
|
60
|
+
const result = withTrailers('content: update', 'max_mustermann@example.com')
|
|
61
|
+
expect(result).toContain('Co-authored-by: Max Mustermann <max_mustermann@example.com>')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('preserves the original message as the first line', () => {
|
|
65
|
+
const msg = 'content: add new section on about'
|
|
66
|
+
const result = withTrailers(msg, 'editor@example.com')
|
|
67
|
+
expect(result.startsWith(msg)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the GitHub file read cache.
|
|
3
|
+
* @vitest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
7
|
+
import { cachedFetch, invalidateCache } from '../_github-cache'
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
invalidateCache('test-key')
|
|
11
|
+
vi.useRealTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('cachedFetch', () => {
|
|
15
|
+
it('calls fetcher on first access (cache miss)', async () => {
|
|
16
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
17
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
18
|
+
expect(result).toBe('data-v1')
|
|
19
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns cached value without calling fetcher again (cache hit)', async () => {
|
|
23
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
24
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
25
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
26
|
+
expect(result).toBe('data-v1')
|
|
27
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('re-fetches after TTL expires', async () => {
|
|
31
|
+
vi.useFakeTimers()
|
|
32
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
33
|
+
await cachedFetch('test-key', 1_000, fetcher)
|
|
34
|
+
|
|
35
|
+
vi.advanceTimersByTime(1_001)
|
|
36
|
+
fetcher.mockResolvedValue('data-v2')
|
|
37
|
+
const result = await cachedFetch('test-key', 1_000, fetcher)
|
|
38
|
+
|
|
39
|
+
expect(result).toBe('data-v2')
|
|
40
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns stale value on fetch error (stale-on-error)', async () => {
|
|
44
|
+
vi.useFakeTimers()
|
|
45
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
46
|
+
await cachedFetch('test-key', 1_000, fetcher)
|
|
47
|
+
|
|
48
|
+
vi.advanceTimersByTime(1_001)
|
|
49
|
+
fetcher.mockRejectedValue(new Error('GitHub API down'))
|
|
50
|
+
const result = await cachedFetch('test-key', 1_000, fetcher)
|
|
51
|
+
|
|
52
|
+
expect(result).toBe('data-v1')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('throws when fetcher fails and no stale value exists', async () => {
|
|
56
|
+
const fetcher = vi.fn().mockRejectedValue(new Error('GitHub API down'))
|
|
57
|
+
await expect(cachedFetch('test-key', 60_000, fetcher)).rejects.toThrow('GitHub API down')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('caches null values (file not found is a valid result)', async () => {
|
|
61
|
+
const fetcher = vi.fn().mockResolvedValue(null)
|
|
62
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
63
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
64
|
+
expect(result).toBeNull()
|
|
65
|
+
expect(fetcher).toHaveBeenCalledOnce()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('uses separate cache entries per key', async () => {
|
|
69
|
+
invalidateCache('key-a')
|
|
70
|
+
invalidateCache('key-b')
|
|
71
|
+
const fetcherA = vi.fn().mockResolvedValue('a')
|
|
72
|
+
const fetcherB = vi.fn().mockResolvedValue('b')
|
|
73
|
+
const [a, b] = await Promise.all([
|
|
74
|
+
cachedFetch('key-a', 60_000, fetcherA),
|
|
75
|
+
cachedFetch('key-b', 60_000, fetcherB),
|
|
76
|
+
])
|
|
77
|
+
expect(a).toBe('a')
|
|
78
|
+
expect(b).toBe('b')
|
|
79
|
+
invalidateCache('key-a')
|
|
80
|
+
invalidateCache('key-b')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('invalidateCache', () => {
|
|
85
|
+
it('forces re-fetch after invalidation', async () => {
|
|
86
|
+
const fetcher = vi.fn().mockResolvedValue('data-v1')
|
|
87
|
+
await cachedFetch('test-key', 60_000, fetcher)
|
|
88
|
+
|
|
89
|
+
invalidateCache('test-key')
|
|
90
|
+
fetcher.mockResolvedValue('data-v2')
|
|
91
|
+
const result = await cachedFetch('test-key', 60_000, fetcher)
|
|
92
|
+
|
|
93
|
+
expect(result).toBe('data-v2')
|
|
94
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('is a no-op for unknown keys', () => {
|
|
98
|
+
expect(() => invalidateCache('nonexistent-key')).not.toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pages API route resolver.
|
|
3
|
+
*
|
|
4
|
+
* The pages endpoint must work in API-route context (serverless) where
|
|
5
|
+
* `page-ssr` injectScript does NOT run and globalThis.__SETZKASTEN_PAGES__
|
|
6
|
+
* is undefined. The fix is a Vite build-time define (__SETZKASTEN_PAGES__)
|
|
7
|
+
* that is always available in compiled modules.
|
|
8
|
+
*
|
|
9
|
+
* In the test environment the Vite define is not applied, so we test the
|
|
10
|
+
* globalThis fallback path — which is what we'd use in a cold-start serverless
|
|
11
|
+
* invocation before the define was introduced.
|
|
12
|
+
*
|
|
13
|
+
* @vitest-environment node
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
17
|
+
import { resolvePages } from '../pages'
|
|
18
|
+
|
|
19
|
+
interface PageInfo {
|
|
20
|
+
path: string
|
|
21
|
+
pageKey: string
|
|
22
|
+
label: string
|
|
23
|
+
hasConfig: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SAMPLE_PAGES: PageInfo[] = [
|
|
27
|
+
{ path: '/', pageKey: 'index', label: 'Startseite', hasConfig: true },
|
|
28
|
+
{ path: '/impressum', pageKey: 'impressum', label: 'Impressum', hasConfig: false },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
delete (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('resolvePages', () => {
|
|
40
|
+
it('returns empty array when globalThis.__SETZKASTEN_PAGES__ is not set', () => {
|
|
41
|
+
expect(resolvePages()).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('reads pages from globalThis.__SETZKASTEN_PAGES__', () => {
|
|
45
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = SAMPLE_PAGES
|
|
46
|
+
expect(resolvePages()).toEqual(SAMPLE_PAGES)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns all page fields (path, pageKey, label, hasConfig)', () => {
|
|
50
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = SAMPLE_PAGES
|
|
51
|
+
const result = resolvePages()
|
|
52
|
+
expect(result[0]).toHaveProperty('path', '/')
|
|
53
|
+
expect(result[0]).toHaveProperty('pageKey', 'index')
|
|
54
|
+
expect(result[0]).toHaveProperty('label', 'Startseite')
|
|
55
|
+
expect(result[0]).toHaveProperty('hasConfig', true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty array when globalThis.__SETZKASTEN_PAGES__ is explicitly empty', () => {
|
|
59
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = []
|
|
60
|
+
expect(resolvePages()).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('handles multiple pages including nested paths', () => {
|
|
64
|
+
const pages: PageInfo[] = [
|
|
65
|
+
{ path: '/', pageKey: 'index', label: 'Startseite', hasConfig: true },
|
|
66
|
+
{ path: '/docs/architecture', pageKey: 'docs/architecture', label: 'Architecture', hasConfig: false },
|
|
67
|
+
]
|
|
68
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ = pages
|
|
69
|
+
expect(resolvePages()).toHaveLength(2)
|
|
70
|
+
expect(resolvePages()[1]!.pageKey).toBe('docs/architecture')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { canEditPage } from '@setzkasten-cms/auth'
|
|
2
|
+
import type { AuthSession, SetzKastenConfig } from '@setzkasten-cms/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parses the raw session cookie value.
|
|
6
|
+
* Returns null if missing or invalid.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSession(raw: string | undefined): AuthSession | null {
|
|
9
|
+
if (!raw) return null
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(raw) as AuthSession
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns a 403 Response if the session user may NOT edit the given page.
|
|
19
|
+
* Returns null (= allowed) otherwise.
|
|
20
|
+
*
|
|
21
|
+
* Admins always pass. Editors are checked against config.auth.editors.
|
|
22
|
+
*/
|
|
23
|
+
export function guardPageAccess(
|
|
24
|
+
session: AuthSession | null,
|
|
25
|
+
pageKey: string,
|
|
26
|
+
fullConfig: SetzKastenConfig | undefined,
|
|
27
|
+
): Response | null {
|
|
28
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
29
|
+
if (!fullConfig) return null // no config = no restrictions
|
|
30
|
+
if (canEditPage(session, pageKey, fullConfig)) return null
|
|
31
|
+
return new Response('Forbidden: you do not have access to this page', { status: 403 })
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SETZKASTEN_CO_AUTHOR = 'Co-authored-by: Setzkasten <setzkasten@setzkasten.dev>'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appends git commit trailers to a commit message.
|
|
5
|
+
* Always includes the Setzkasten co-author; optionally includes the editor.
|
|
6
|
+
*/
|
|
7
|
+
export function withTrailers(message: string, editorEmail?: string | null): string {
|
|
8
|
+
const trailers = [SETZKASTEN_CO_AUTHOR]
|
|
9
|
+
if (editorEmail) {
|
|
10
|
+
const name = editorEmail.split('@')[0]!
|
|
11
|
+
.replace(/[._-]+/g, ' ')
|
|
12
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
13
|
+
trailers.push(`Co-authored-by: ${name} <${editorEmail}>`)
|
|
14
|
+
}
|
|
15
|
+
return `${message}\n\n${trailers.join('\n')}`
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface CacheEntry<T> {
|
|
2
|
+
value: T
|
|
3
|
+
fetchedAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const cache = new Map<string, CacheEntry<unknown>>()
|
|
7
|
+
|
|
8
|
+
export async function cachedFetch<T>(
|
|
9
|
+
key: string,
|
|
10
|
+
ttlMs: number,
|
|
11
|
+
fetcher: () => Promise<T>,
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
const entry = cache.get(key) as CacheEntry<T> | undefined
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
|
|
16
|
+
if (entry !== undefined && now - entry.fetchedAt < ttlMs) {
|
|
17
|
+
return entry.value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const value = await fetcher()
|
|
22
|
+
cache.set(key, { value, fetchedAt: now })
|
|
23
|
+
return value
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (entry !== undefined) return entry.value
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function invalidateCache(key: string): void {
|
|
31
|
+
cache.delete(key)
|
|
32
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
-
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* OAuth callback handler.
|
|
7
|
-
*
|
|
8
|
-
* session via the auth adapter, and sets a signed session cookie.
|
|
5
|
+
* GitHub OAuth callback handler.
|
|
6
|
+
* Google uses the GIS flow (POST /api/setzkasten/auth/google) — no callback needed there.
|
|
9
7
|
*/
|
|
10
8
|
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
9
|
const code = url.searchParams.get('code')
|
|
@@ -15,91 +13,62 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
15
13
|
return new Response('Missing authorization code', { status: 400 })
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
// Parse stored state (may contain provider info)
|
|
19
16
|
const storedRaw = cookies.get('setzkasten_oauth_state')?.value
|
|
20
17
|
let storedState: string | undefined
|
|
21
|
-
|
|
18
|
+
|
|
22
19
|
if (storedRaw) {
|
|
23
20
|
try {
|
|
24
|
-
|
|
25
|
-
storedState = parsed.state
|
|
26
|
-
provider = parsed.provider ?? 'github'
|
|
21
|
+
storedState = JSON.parse(storedRaw).state
|
|
27
22
|
} catch {
|
|
28
23
|
storedState = storedRaw
|
|
29
24
|
}
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
if (state && storedState && state !== storedState) {
|
|
27
|
+
if (!storedState || state !== storedState) {
|
|
34
28
|
return new Response('Invalid state parameter', { status: 400 })
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
// Clear the state cookie
|
|
38
31
|
cookies.delete('setzkasten_oauth_state', { path: '/' })
|
|
39
32
|
|
|
40
33
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
41
34
|
| { adminPath: string }
|
|
42
35
|
| undefined
|
|
43
|
-
|
|
44
36
|
const adminPath = config?.adminPath ?? '/admin'
|
|
45
37
|
|
|
46
|
-
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
47
38
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
48
39
|
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
49
|
-
const
|
|
50
|
-
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
40
|
+
const redirectUri = `${protocol}://${host}/api/setzkasten/auth/callback`
|
|
51
41
|
|
|
52
|
-
// Read allowed emails from env (comma-separated)
|
|
53
42
|
const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
|
|
54
43
|
const allowedEmails = allowedEmailsRaw
|
|
55
44
|
? allowedEmailsRaw.split(',').map((e: string) => e.trim())
|
|
56
45
|
: undefined
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
const auth = createGitHubAuth({
|
|
48
|
+
clientId: import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? '',
|
|
49
|
+
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
|
|
50
|
+
redirectUri,
|
|
51
|
+
allowedEmails,
|
|
52
|
+
})
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
}
|
|
54
|
+
try {
|
|
55
|
+
const sessionResult = await auth.handleCallback(code)
|
|
78
56
|
|
|
79
57
|
if (!sessionResult.ok) {
|
|
80
|
-
console.error('[setzkasten] Auth failed:', sessionResult.error.message)
|
|
81
58
|
return new Response(`Authentication failed: ${sessionResult.error.message}`, { status: 403 })
|
|
82
59
|
}
|
|
83
60
|
|
|
84
61
|
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, {
|
|
62
|
+
cookies.set('setzkasten_session', JSON.stringify({ user: session.user, expiresAt: session.expiresAt }), {
|
|
93
63
|
httpOnly: true,
|
|
94
64
|
secure: import.meta.env.PROD,
|
|
95
65
|
sameSite: 'lax',
|
|
96
66
|
path: '/',
|
|
97
|
-
maxAge: 60 * 60 * 24 * 7,
|
|
67
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
98
68
|
})
|
|
99
69
|
|
|
100
70
|
return redirect(adminPath)
|
|
101
|
-
} catch
|
|
102
|
-
console.error('[setzkasten] Auth callback error:', error)
|
|
71
|
+
} catch {
|
|
103
72
|
return new Response('Authentication failed', { status: 500 })
|
|
104
73
|
}
|
|
105
74
|
}
|
|
@@ -1,87 +1,40 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
-
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* Login initiation – redirects the user to
|
|
5
|
+
* Login initiation – redirects the user to GitHub OAuth.
|
|
6
|
+
* Google uses GIS (POST /api/setzkasten/auth/google) instead of this redirect flow.
|
|
7
7
|
*
|
|
8
|
-
* GET /api/setzkasten/auth/login?provider=github
|
|
8
|
+
* GET /api/setzkasten/auth/login?provider=github
|
|
9
9
|
*/
|
|
10
10
|
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
|
-
const provider = url.searchParams.get('provider') ?? 'github'
|
|
12
|
-
|
|
13
11
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
14
|
-
| { adminPath: string
|
|
12
|
+
| { adminPath: string }
|
|
15
13
|
| undefined
|
|
16
14
|
|
|
17
|
-
const adminPath = config?.adminPath ?? '/admin'
|
|
18
|
-
|
|
19
15
|
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
20
16
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
21
17
|
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
22
18
|
const origin = `${protocol}://${host}`
|
|
23
19
|
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
24
20
|
|
|
25
|
-
|
|
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
|
-
})
|
|
21
|
+
const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
|
|
22
|
+
const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
|
|
40
23
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
})
|
|
24
|
+
if (!ghClientId || !ghClientSecret) {
|
|
25
|
+
return new Response('GitHub OAuth not configured', { status: 500 })
|
|
26
|
+
}
|
|
70
27
|
|
|
71
|
-
|
|
28
|
+
const auth = createGitHubAuth({ clientId: ghClientId, clientSecret: ghClientSecret, redirectUri })
|
|
29
|
+
const { url: loginUrl, state } = auth.getLoginUrl()
|
|
72
30
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
path: '/',
|
|
81
|
-
maxAge: 600,
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
}
|
|
31
|
+
cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'github' }), {
|
|
32
|
+
httpOnly: true,
|
|
33
|
+
secure: true,
|
|
34
|
+
sameSite: 'lax',
|
|
35
|
+
path: '/',
|
|
36
|
+
maxAge: 600,
|
|
37
|
+
})
|
|
85
38
|
|
|
86
39
|
return redirect(loginUrl)
|
|
87
40
|
}
|