@setzkasten-cms/astro-admin 0.6.0 → 1.1.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 +23 -6
- package/src/admin-page.astro +9 -8
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- 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__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/pages.test.ts +72 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +153 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +64 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +21 -53
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +71 -0
- package/src/api-routes/catalog-add.ts +18 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +17 -5
- package/src/api-routes/editors.ts +205 -0
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +149 -0
- package/src/api-routes/init-add-section.ts +21 -10
- package/src/api-routes/init-apply.ts +7 -4
- package/src/api-routes/init-migrate.ts +9 -6
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +138 -6
- package/src/api-routes/section-add.ts +23 -5
- package/src/api-routes/section-commit-pending.ts +28 -5
- package/src/api-routes/section-delete.ts +24 -5
- package/src/api-routes/section-duplicate.ts +25 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +12 -4
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +53 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-check.ts +49 -0
- package/src/api-routes/updater-register.ts +90 -0
- package/src/api-routes/updater-transfer.ts +51 -0
- package/src/api-routes/updater-unbind.ts +59 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/page-level.test.ts +47 -0
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -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 +100 -0
- package/LICENSE +0 -37
|
@@ -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
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Logout – clears the session cookie and redirects to home.
|
|
6
|
+
* Mirrors the same `domain` attribute used when the cookie was set so the
|
|
7
|
+
* browser actually deletes it on subdomains in standalone-admin setups.
|
|
5
8
|
*/
|
|
6
9
|
export const GET: APIRoute = async ({ cookies, redirect }) => {
|
|
7
|
-
|
|
10
|
+
const opts = sessionCookieOptions(false)
|
|
11
|
+
cookies.delete('setzkasten_session', { path: '/', domain: opts.domain })
|
|
8
12
|
return redirect('/')
|
|
9
13
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
|
|
3
|
+
import { readEditorsFile } from './editors'
|
|
4
|
+
import { readGlobalConfig } from './global-config'
|
|
5
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
6
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
7
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/setzkasten/auth/setzkasten-login
|
|
11
|
+
* Body: { idToken: string } — Firebase ID token from signInWithPopup
|
|
12
|
+
*
|
|
13
|
+
* Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
|
|
14
|
+
* Access is gated exclusively by _editors.json (fail-closed).
|
|
15
|
+
*
|
|
16
|
+
* Editors live in the build-time-configured repo regardless of which website
|
|
17
|
+
* the request is targeting — in single-mode that's the website's repo, in
|
|
18
|
+
* multi-mode it's the config-repo. The per-request resolver and X-SK-Website
|
|
19
|
+
* header are intentionally NOT consulted here, because login predates any
|
|
20
|
+
* website selection.
|
|
21
|
+
*/
|
|
22
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
23
|
+
const body = await request.json().catch(() => null)
|
|
24
|
+
const idToken = body?.idToken as string | undefined
|
|
25
|
+
|
|
26
|
+
if (!idToken) {
|
|
27
|
+
return new Response('Missing idToken', { status: 400 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const storage = resolveStorageConfig()
|
|
31
|
+
if (!storage) {
|
|
32
|
+
return new Response('Storage not configured', { status: 500 })
|
|
33
|
+
}
|
|
34
|
+
const { owner, repo, branch } = storage
|
|
35
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
36
|
+
.__SETZKASTEN_CONFIG__
|
|
37
|
+
const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
38
|
+
|
|
39
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
40
|
+
if (!tokenResult.ok) {
|
|
41
|
+
return new Response(`GitHub token unavailable: ${tokenResult.error.message}`, { status: 503 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
|
|
45
|
+
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
46
|
+
if (!globalCfg?.firebaseConfig) {
|
|
47
|
+
return new Response('SetzKastenLogin not configured', { status: 500 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read editors list — fail-closed: if unreadable, deny all logins
|
|
51
|
+
const editors = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
|
|
52
|
+
if (editors === null) {
|
|
53
|
+
return new Response('Editors list unavailable — no access granted', { status: 503 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const allowedEmails = editors.map((e) => e.email)
|
|
57
|
+
const result = await verifyFirebaseJwt(idToken, allowedEmails)
|
|
58
|
+
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
return new Response(result.error.message, { status: 403 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const session = result.value
|
|
64
|
+
cookies.set(
|
|
65
|
+
'setzkasten_session',
|
|
66
|
+
JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
|
|
67
|
+
sessionCookieOptions(import.meta.env.PROD),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return Response.json({ ok: true })
|
|
71
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { registry } from '@setzkasten-cms/catalog'
|
|
3
|
-
import {
|
|
3
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
4
4
|
import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
|
|
5
5
|
import { generateAddKey, addToPageConfig } from './section-management'
|
|
6
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
7
|
+
import { withTrailers } from './_commit-trailers'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* POST /api/setzkasten/catalog/add
|
|
@@ -17,8 +20,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
17
20
|
const session = cookies.get('setzkasten_session')?.value
|
|
18
21
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
19
22
|
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
23
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
24
|
+
if (!tokenResult.ok) {
|
|
25
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
26
|
+
}
|
|
27
|
+
const githubToken = tokenResult.value
|
|
22
28
|
|
|
23
29
|
try {
|
|
24
30
|
const body = await request.json() as Record<string, unknown>
|
|
@@ -32,13 +38,17 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
32
38
|
|
|
33
39
|
const { templateName, pageKey } = validated
|
|
34
40
|
|
|
35
|
-
const storage =
|
|
41
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
36
42
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
43
|
const { owner, repo, branch, projectPrefix } = storage
|
|
38
44
|
|
|
39
45
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
46
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
40
47
|
const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
|
|
41
48
|
|
|
49
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
50
|
+
if (denied) return denied
|
|
51
|
+
|
|
42
52
|
const headers = {
|
|
43
53
|
Authorization: `Bearer ${githubToken}`,
|
|
44
54
|
Accept: 'application/vnd.github+json',
|
|
@@ -82,7 +92,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
82
92
|
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
83
93
|
{ path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
|
|
84
94
|
],
|
|
85
|
-
|
|
95
|
+
withTrailers(
|
|
96
|
+
`content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
|
|
97
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
98
|
+
),
|
|
86
99
|
headers,
|
|
87
100
|
)
|
|
88
101
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { exportTemplate } from '@setzkasten-cms/catalog'
|
|
3
|
-
import {
|
|
3
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
4
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* POST /api/setzkasten/catalog/export
|
|
@@ -14,8 +15,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
14
15
|
const session = cookies.get('setzkasten_session')?.value
|
|
15
16
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
18
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
19
|
+
if (!tokenResult.ok) {
|
|
20
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
21
|
+
}
|
|
22
|
+
const githubToken = tokenResult.value
|
|
19
23
|
|
|
20
24
|
try {
|
|
21
25
|
const body = await request.json() as {
|
|
@@ -30,7 +34,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
30
34
|
return Response.json({ error: 'sectionKey is required' }, { status: 400 })
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
const storage =
|
|
37
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
34
38
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
35
39
|
const { owner, repo, branch, projectPrefix } = storage
|
|
36
40
|
|
package/src/api-routes/config.ts
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { readGlobalConfig } from './global-config'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Returns the full SetzKastenConfig as JSON.
|
|
5
|
-
* The config is injected into globalThis by the integration at build time.
|
|
6
|
-
*
|
|
7
5
|
* GET /api/setzkasten/config
|
|
6
|
+
*
|
|
7
|
+
* Returns the full SetzKastenConfig as JSON. Fields from GlobalConfig
|
|
8
|
+
* (stored in _global_config.json) are merged over the static config so
|
|
9
|
+
* admins can change theme and Firebase settings without a code deployment.
|
|
8
10
|
*/
|
|
9
11
|
export const GET: APIRoute = async () => {
|
|
10
12
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
|
|
11
13
|
const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
|
|
12
14
|
|
|
15
|
+
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
16
|
+
|
|
17
|
+
const staticTheme = (config as any).theme ?? {}
|
|
18
|
+
const globalTheme = globalCfg?.theme ?? {}
|
|
19
|
+
|
|
13
20
|
const result = {
|
|
14
|
-
|
|
21
|
+
// Default fallback when no config is injected at build time. Real
|
|
22
|
+
// values are spread from `config` below.
|
|
23
|
+
storage: { kind: 'local' },
|
|
15
24
|
auth: { providers: ['github'] },
|
|
16
|
-
theme: {},
|
|
17
25
|
products: {},
|
|
18
26
|
collections: {},
|
|
19
27
|
...config,
|
|
28
|
+
// Global config theme overrides static config theme field by field
|
|
29
|
+
theme: { ...staticTheme, ...globalTheme },
|
|
20
30
|
// Include storage params so the client can create ProxyContentRepository
|
|
21
31
|
_storage: ssrConfig?.storage ?? undefined,
|
|
22
32
|
_hasGitHub: ssrConfig?.hasGitHub ?? false,
|
|
23
33
|
_hasGoogle: ssrConfig?.hasGoogle ?? false,
|
|
34
|
+
// SetzKastenLogin Firebase config (present only when license is valid)
|
|
35
|
+
_firebaseConfig: globalCfg?.firebaseConfig ?? null,
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
return new Response(JSON.stringify(result), {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
3
|
+
import { parseSession } from './_auth-guard'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
5
|
+
import type { ContentEditorConfig } from '@setzkasten-cms/core'
|
|
6
|
+
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
7
|
+
import { withTrailers } from './_commit-trailers'
|
|
8
|
+
|
|
9
|
+
const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
|
|
10
|
+
|
|
11
|
+
// In Multi-Mode editors are global across all websites and live in the
|
|
12
|
+
// config-repo; in Single-Mode the config-repo IS the website-repo. Either
|
|
13
|
+
// way the answer is "the build-time-configured storage" — never the
|
|
14
|
+
// per-website storage that the X-SK-Website header would route to.
|
|
15
|
+
function configRepoStorage(): { owner: string; repo: string; branch: string } | null {
|
|
16
|
+
const storage = resolveStorageConfig()
|
|
17
|
+
if (!storage) return null
|
|
18
|
+
return { owner: storage.owner, repo: storage.repo, branch: storage.branch }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// GET /api/setzkasten/editors
|
|
23
|
+
// Returns the current editors list from _editors.json.
|
|
24
|
+
// Any authenticated user may read this (needed for the page-filter in the UI).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
28
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
29
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
30
|
+
|
|
31
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
32
|
+
if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
|
|
33
|
+
|
|
34
|
+
const storage = configRepoStorage()
|
|
35
|
+
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
36
|
+
|
|
37
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
38
|
+
.__SETZKASTEN_CONFIG__
|
|
39
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
40
|
+
const { owner, repo, branch } = storage
|
|
41
|
+
|
|
42
|
+
const raw = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
|
|
43
|
+
return Response.json(raw ?? [])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// PUT /api/setzkasten/editors
|
|
48
|
+
// Replaces the editors list. Admin-only.
|
|
49
|
+
// Body: ContentEditorConfig[]
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
53
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
54
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
55
|
+
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
56
|
+
|
|
57
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
58
|
+
if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
|
|
59
|
+
|
|
60
|
+
const storage = configRepoStorage()
|
|
61
|
+
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
62
|
+
|
|
63
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
64
|
+
.__SETZKASTEN_CONFIG__
|
|
65
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
66
|
+
const { owner, repo, branch } = storage
|
|
67
|
+
|
|
68
|
+
let editors: ContentEditorConfig[]
|
|
69
|
+
try {
|
|
70
|
+
editors = (await request.json()) as ContentEditorConfig[]
|
|
71
|
+
if (!Array.isArray(editors)) throw new Error('Expected array')
|
|
72
|
+
} catch {
|
|
73
|
+
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const filePath = EDITORS_FILE(contentPath)
|
|
77
|
+
const fileContent = JSON.stringify(editors, null, 2)
|
|
78
|
+
const headers = {
|
|
79
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
80
|
+
Accept: 'application/vnd.github+json',
|
|
81
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get current SHA if the file already exists (needed for updates)
|
|
86
|
+
const existing = await fetchFileSha(owner, repo, branch, filePath, headers)
|
|
87
|
+
|
|
88
|
+
const body: Record<string, unknown> = {
|
|
89
|
+
message: withTrailers('chore(editors): update content editor permissions'),
|
|
90
|
+
content: Buffer.from(fileContent).toString('base64'),
|
|
91
|
+
branch,
|
|
92
|
+
}
|
|
93
|
+
if (existing) body.sha = existing
|
|
94
|
+
|
|
95
|
+
const res = await fetch(
|
|
96
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
|
|
97
|
+
{ method: 'PUT', headers, body: JSON.stringify(body) },
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await res.text()
|
|
102
|
+
return Response.json({ error: `GitHub write failed: ${text}` }, { status: 502 })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
invalidateCache(`editors:${owner}/${repo}:${branch}`)
|
|
106
|
+
return Response.json({ ok: true })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Helpers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function fetchFileSha(
|
|
114
|
+
owner: string,
|
|
115
|
+
repo: string,
|
|
116
|
+
branch: string,
|
|
117
|
+
path: string,
|
|
118
|
+
headers: Record<string, string>,
|
|
119
|
+
): Promise<string | null> {
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(
|
|
122
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
123
|
+
{ headers },
|
|
124
|
+
)
|
|
125
|
+
if (!res.ok) return null
|
|
126
|
+
const data = await res.json() as { sha: string }
|
|
127
|
+
return data.sha ?? null
|
|
128
|
+
} catch { return null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function readEditorsFile(
|
|
132
|
+
owner: string,
|
|
133
|
+
repo: string,
|
|
134
|
+
branch: string,
|
|
135
|
+
contentPath: string,
|
|
136
|
+
token: string,
|
|
137
|
+
): Promise<ContentEditorConfig[] | null> {
|
|
138
|
+
const key = `editors:${owner}/${repo}:${branch}`
|
|
139
|
+
return cachedFetch(key, 2 * 60_000, async () => {
|
|
140
|
+
const res = await fetch(
|
|
141
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
|
|
142
|
+
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
|
|
143
|
+
)
|
|
144
|
+
if (!res.ok) return null
|
|
145
|
+
const data = await res.json() as { content: string; encoding: string }
|
|
146
|
+
const raw = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
|
|
147
|
+
return JSON.parse(raw) as ContentEditorConfig[]
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Discriminated result for the editors-file fetch. Lets callers decide the
|
|
153
|
+
* fail-mode policy: the auth-guard wants to ALLOW when the file is genuinely
|
|
154
|
+
* absent (no restrictions configured) but DENY when the fetch errors out.
|
|
155
|
+
* The basic readEditorsFile() above returns null for both cases, which is
|
|
156
|
+
* unsafe for authorization checks.
|
|
157
|
+
*
|
|
158
|
+
* Caller is responsible for caching — this function never reads from or
|
|
159
|
+
* writes to the shared cache, because caching an "error" state would
|
|
160
|
+
* silently extend privilege-escalation windows.
|
|
161
|
+
*/
|
|
162
|
+
export type EditorsStatus =
|
|
163
|
+
| { kind: 'absent' }
|
|
164
|
+
| { kind: 'present'; editors: ContentEditorConfig[] }
|
|
165
|
+
| { kind: 'error'; message: string }
|
|
166
|
+
|
|
167
|
+
export async function readEditorsFileStatus(
|
|
168
|
+
owner: string,
|
|
169
|
+
repo: string,
|
|
170
|
+
branch: string,
|
|
171
|
+
contentPath: string,
|
|
172
|
+
token: string,
|
|
173
|
+
): Promise<EditorsStatus> {
|
|
174
|
+
let res: Response
|
|
175
|
+
try {
|
|
176
|
+
res = await fetch(
|
|
177
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
|
|
178
|
+
{
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${token}`,
|
|
181
|
+
Accept: 'application/vnd.github+json',
|
|
182
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { kind: 'error', message: err instanceof Error ? err.message : 'network error' }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (res.status === 404) return { kind: 'absent' }
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
return { kind: 'error', message: `GitHub returned ${res.status}` }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
197
|
+
const raw =
|
|
198
|
+
data.encoding === 'base64'
|
|
199
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
200
|
+
: data.content
|
|
201
|
+
return { kind: 'present', editors: JSON.parse(raw) as ContentEditorConfig[] }
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { kind: 'error', message: err instanceof Error ? err.message : 'parse error' }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Server-side proxy for GitHub API calls.
|
|
@@ -21,12 +22,11 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
|
|
|
21
22
|
return new Response('Missing path', { status: 400 })
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (!githubToken) {
|
|
28
|
-
return new Response('GitHub token not configured', { status: 500 })
|
|
25
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
26
|
+
if (!tokenResult.ok) {
|
|
27
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
29
28
|
}
|
|
29
|
+
const githubToken = tokenResult.value
|
|
30
30
|
|
|
31
31
|
const githubUrl = `https://api.github.com/${githubPath}`
|
|
32
32
|
|