@setzkasten-cms/astro-admin 0.8.0 → 1.3.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 +22 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -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__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -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__/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 +152 -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__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -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 +134 -13
- package/src/api-routes/_feature-gate.ts +39 -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/_role-resolver.ts +60 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +77 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -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 +8 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +37 -11
- package/src/api-routes/catalog-add.ts +9 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +12 -5
- package/src/api-routes/editors.ts +94 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/init-add-section.ts +13 -5
- package/src/api-routes/init-apply.ts +5 -3
- package/src/api-routes/init-migrate.ts +7 -5
- 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 +118 -4
- package/src/api-routes/section-add.ts +15 -5
- package/src/api-routes/section-commit-pending.ts +117 -5
- package/src/api-routes/section-delete.ts +29 -5
- package/src/api-routes/section-duplicate.ts +15 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +9 -5
- 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 +71 -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-register.ts +37 -25
- package/src/api-routes/updater-transfer.ts +1 -12
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -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__/patcher-edge-cases.test.ts +34 -1
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +42 -4
- package/LICENSE +0 -37
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read/write the standalone admin's `websites.json` from the config repo.
|
|
3
|
+
* Pure HTTP — no caching here; the GitHub-side cache lives in
|
|
4
|
+
* GitHubWebsitesRegistry, which API routes invalidate explicitly after a
|
|
5
|
+
* successful write.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type Result,
|
|
10
|
+
type WebsitesRegistry,
|
|
11
|
+
err,
|
|
12
|
+
networkError,
|
|
13
|
+
ok,
|
|
14
|
+
parseWebsitesRegistry,
|
|
15
|
+
} from '@setzkasten-cms/core'
|
|
16
|
+
import { withTrailers } from './_commit-trailers'
|
|
17
|
+
|
|
18
|
+
interface ConfigRepoTarget {
|
|
19
|
+
readonly owner: string
|
|
20
|
+
readonly repo: string
|
|
21
|
+
readonly branch: string
|
|
22
|
+
readonly path: string
|
|
23
|
+
readonly token: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ReadResult {
|
|
27
|
+
readonly registry: WebsitesRegistry
|
|
28
|
+
readonly sha: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const githubHeaders = (token: string) => ({
|
|
32
|
+
Authorization: `Bearer ${token}`,
|
|
33
|
+
Accept: 'application/vnd.github+json',
|
|
34
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export async function readWebsitesRegistryFromGitHub(
|
|
39
|
+
target: ConfigRepoTarget,
|
|
40
|
+
): Promise<Result<ReadResult>> {
|
|
41
|
+
try {
|
|
42
|
+
const url = `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${target.path}?ref=${target.branch}`
|
|
43
|
+
const response = await fetch(url, { headers: githubHeaders(target.token) })
|
|
44
|
+
|
|
45
|
+
if (response.status === 404) {
|
|
46
|
+
return ok({ registry: { websites: [] }, sha: null })
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
return err(networkError(`GitHub returned ${response.status} reading ${target.path}`))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = (await response.json()) as { content: string; sha: string }
|
|
53
|
+
const decoded = Buffer.from(data.content, 'base64').toString('utf-8')
|
|
54
|
+
const parsed = parseWebsitesRegistry(decoded)
|
|
55
|
+
if (!parsed.ok) return parsed
|
|
56
|
+
|
|
57
|
+
return ok({ registry: parsed.value, sha: data.sha })
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
const message = cause instanceof Error ? cause.message : 'Unknown error'
|
|
60
|
+
return err(networkError(`Failed to read ${target.path}: ${message}`, cause))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeWebsitesRegistryToGitHub(
|
|
65
|
+
target: ConfigRepoTarget,
|
|
66
|
+
registry: WebsitesRegistry,
|
|
67
|
+
previousSha: string | null,
|
|
68
|
+
commitMessage: string,
|
|
69
|
+
): Promise<Result<void>> {
|
|
70
|
+
const body: Record<string, unknown> = {
|
|
71
|
+
message: withTrailers(commitMessage),
|
|
72
|
+
content: Buffer.from(JSON.stringify(registry, null, 2)).toString('base64'),
|
|
73
|
+
branch: target.branch,
|
|
74
|
+
}
|
|
75
|
+
if (previousSha) body.sha = previousSha
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(
|
|
79
|
+
`https://api.github.com/repos/${target.owner}/${target.repo}/contents/${target.path}`,
|
|
80
|
+
{ method: 'PUT', headers: githubHeaders(target.token), body: JSON.stringify(body) },
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const text = await response.text()
|
|
85
|
+
return err(networkError(`GitHub PUT failed: ${response.status} ${text}`))
|
|
86
|
+
}
|
|
87
|
+
return ok(undefined)
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
const message = cause instanceof Error ? cause.message : 'Unknown error'
|
|
90
|
+
return err(networkError(`Failed to write ${target.path}: ${message}`, cause))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads the standalone-admin storage config out of the build-time
|
|
96
|
+
* `__SETZKASTEN_FULL_CONFIG__` global. Returns null when the deployment
|
|
97
|
+
* is not in standalone mode (single-repo setups have no websites.json).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveConfigRepoTargetFromGlobals(token: string): ConfigRepoTarget | null {
|
|
100
|
+
const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
|
|
101
|
+
| { storage?: { kind: string; configRepo?: string; configBranch?: string } }
|
|
102
|
+
| undefined
|
|
103
|
+
|
|
104
|
+
// Accept the canonical 'multi' as well as the legacy 'standalone' alias.
|
|
105
|
+
// defineConfig() rewrites legacy values, but tests and direct setters of
|
|
106
|
+
// __SETZKASTEN_FULL_CONFIG__ may pass either form.
|
|
107
|
+
const storage = fullConfig?.storage
|
|
108
|
+
if (!storage || (storage.kind !== 'multi' && storage.kind !== 'standalone')) return null
|
|
109
|
+
if (!storage.configRepo) return null
|
|
110
|
+
const [owner, repo] = storage.configRepo.split('/')
|
|
111
|
+
if (!owner || !repo) return null
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
owner,
|
|
115
|
+
repo,
|
|
116
|
+
branch: storage.configBranch ?? 'main',
|
|
117
|
+
path: 'websites.json',
|
|
118
|
+
token,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Asset proxy – serves images from the private GitHub repo.
|
|
@@ -7,7 +8,7 @@ import type { APIRoute } from 'astro'
|
|
|
7
8
|
* GET /api/setzkasten/asset/public/images/about/LP_Logo.png
|
|
8
9
|
* → fetches from GitHub API and returns the raw binary with correct Content-Type.
|
|
9
10
|
*/
|
|
10
|
-
export const GET: APIRoute = async ({ params, cookies }) => {
|
|
11
|
+
export const GET: APIRoute = async ({ params, request, cookies }) => {
|
|
11
12
|
const session = cookies.get('setzkasten_session')?.value
|
|
12
13
|
if (!session) {
|
|
13
14
|
return new Response('Unauthorized', { status: 401 })
|
|
@@ -18,10 +19,11 @@ export const GET: APIRoute = async ({ params, cookies }) => {
|
|
|
18
19
|
return new Response('Missing path', { status: 400 })
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
23
|
-
return new Response(
|
|
22
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
23
|
+
if (!tokenResult.ok) {
|
|
24
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
24
25
|
}
|
|
26
|
+
const githubToken = tokenResult.value
|
|
25
27
|
|
|
26
28
|
const config = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
27
29
|
if (!config?.storage) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
4
|
+
import { makeRoleResolver } from './_role-resolver'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* GitHub OAuth callback handler.
|
|
@@ -49,6 +51,7 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
49
51
|
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
|
|
50
52
|
redirectUri,
|
|
51
53
|
allowedEmails,
|
|
54
|
+
resolveRole: makeRoleResolver('github', allowedEmails),
|
|
52
55
|
})
|
|
53
56
|
|
|
54
57
|
try {
|
|
@@ -59,13 +62,11 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
const session = sessionResult.value
|
|
62
|
-
cookies.set(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
maxAge: 60 * 60 * 24 * 7,
|
|
68
|
-
})
|
|
65
|
+
cookies.set(
|
|
66
|
+
'setzkasten_session',
|
|
67
|
+
JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
|
|
68
|
+
sessionCookieOptions(import.meta.env.PROD),
|
|
69
|
+
)
|
|
69
70
|
|
|
70
71
|
return redirect(adminPath)
|
|
71
72
|
} catch {
|
|
@@ -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
|
}
|
|
@@ -3,6 +3,10 @@ import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
|
|
|
3
3
|
import { readEditorsFile } from './editors'
|
|
4
4
|
import { readGlobalConfig } from './global-config'
|
|
5
5
|
import { resolveStorageConfig } from './_storage-config'
|
|
6
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
7
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
8
|
+
import { makeRoleResolver } from './_role-resolver'
|
|
9
|
+
import { gateFeature } from './_feature-gate'
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* POST /api/setzkasten/auth/setzkasten-login
|
|
@@ -10,8 +14,20 @@ import { resolveStorageConfig } from './_storage-config'
|
|
|
10
14
|
*
|
|
11
15
|
* Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
|
|
12
16
|
* Access is gated exclusively by _editors.json (fail-closed).
|
|
17
|
+
*
|
|
18
|
+
* Editors live in the build-time-configured repo regardless of which website
|
|
19
|
+
* the request is targeting — in single-mode that's the website's repo, in
|
|
20
|
+
* multi-mode it's the config-repo. The per-request resolver and X-SK-Website
|
|
21
|
+
* header are intentionally NOT consulted here, because login predates any
|
|
22
|
+
* website selection.
|
|
13
23
|
*/
|
|
14
24
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
25
|
+
// Feature-gate: Setzkasten-Login is the Firebase-based path that lets
|
|
26
|
+
// editors who don't have a GitHub account log in via Google. That entire
|
|
27
|
+
// flow is gated behind Pro/Enterprise.
|
|
28
|
+
const gate = gateFeature('google-auth')
|
|
29
|
+
if (gate) return gate
|
|
30
|
+
|
|
15
31
|
const body = await request.json().catch(() => null)
|
|
16
32
|
const idToken = body?.idToken as string | undefined
|
|
17
33
|
|
|
@@ -24,9 +40,14 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
24
40
|
return new Response('Storage not configured', { status: 500 })
|
|
25
41
|
}
|
|
26
42
|
const { owner, repo, branch } = storage
|
|
27
|
-
const serverConfig = (globalThis as
|
|
43
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
44
|
+
.__SETZKASTEN_CONFIG__
|
|
28
45
|
const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
29
|
-
|
|
46
|
+
|
|
47
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
48
|
+
if (!tokenResult.ok) {
|
|
49
|
+
return new Response(`GitHub token unavailable: ${tokenResult.error.message}`, { status: 503 })
|
|
50
|
+
}
|
|
30
51
|
|
|
31
52
|
// Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
|
|
32
53
|
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
@@ -35,26 +56,31 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
// Read editors list — fail-closed: if unreadable, deny all logins
|
|
38
|
-
const editors = await readEditorsFile(owner, repo, branch, contentPath,
|
|
59
|
+
const editors = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
|
|
39
60
|
if (editors === null) {
|
|
40
61
|
return new Response('Editors list unavailable — no access granted', { status: 503 })
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
const allowedEmails = editors.map((e) => e.email)
|
|
44
|
-
|
|
65
|
+
// Setzkasten-Login uses Firebase as the identity backend but logically
|
|
66
|
+
// grants the same role-resolution semantics as Google OAuth — both give
|
|
67
|
+
// an editors-file-listed user the role from that file.
|
|
68
|
+
const result = await verifyFirebaseJwt(
|
|
69
|
+
idToken,
|
|
70
|
+
allowedEmails,
|
|
71
|
+
makeRoleResolver('google', allowedEmails),
|
|
72
|
+
)
|
|
45
73
|
|
|
46
74
|
if (!result.ok) {
|
|
47
75
|
return new Response(result.error.message, { status: 403 })
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
const session = result.value
|
|
51
|
-
cookies.set(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
maxAge: 60 * 60 * 24 * 7,
|
|
57
|
-
})
|
|
79
|
+
cookies.set(
|
|
80
|
+
'setzkasten_session',
|
|
81
|
+
JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
|
|
82
|
+
sessionCookieOptions(import.meta.env.PROD),
|
|
83
|
+
)
|
|
58
84
|
|
|
59
85
|
return Response.json({ ok: true })
|
|
60
86
|
}
|
|
@@ -1,10 +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
6
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
7
7
|
import { withTrailers } from './_commit-trailers'
|
|
8
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* POST /api/setzkasten/catalog/add
|
|
@@ -19,8 +20,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
19
20
|
const session = cookies.get('setzkasten_session')?.value
|
|
20
21
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
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
|
|
24
28
|
|
|
25
29
|
try {
|
|
26
30
|
const body = await request.json() as Record<string, unknown>
|
|
@@ -34,7 +38,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
34
38
|
|
|
35
39
|
const { templateName, pageKey } = validated
|
|
36
40
|
|
|
37
|
-
const storage =
|
|
41
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
38
42
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
39
43
|
const { owner, repo, branch, projectPrefix } = storage
|
|
40
44
|
|
|
@@ -42,7 +46,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
42
46
|
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
43
47
|
const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
|
|
44
48
|
|
|
45
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
49
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
46
50
|
if (denied) return denied
|
|
47
51
|
|
|
48
52
|
const headers = {
|
|
@@ -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
|
@@ -2,10 +2,11 @@ import type { APIRoute } from 'astro'
|
|
|
2
2
|
import { readGlobalConfig } from './global-config'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Returns the full SetzKastenConfig as JSON.
|
|
6
|
-
* The config is injected into globalThis by the integration at build time.
|
|
7
|
-
*
|
|
8
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.
|
|
9
10
|
*/
|
|
10
11
|
export const GET: APIRoute = async () => {
|
|
11
12
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
|
|
@@ -13,13 +14,19 @@ export const GET: APIRoute = async () => {
|
|
|
13
14
|
|
|
14
15
|
const globalCfg = await readGlobalConfig().catch(() => null)
|
|
15
16
|
|
|
17
|
+
const staticTheme = (config as any).theme ?? {}
|
|
18
|
+
const globalTheme = globalCfg?.theme ?? {}
|
|
19
|
+
|
|
16
20
|
const result = {
|
|
17
|
-
|
|
21
|
+
// Default fallback when no config is injected at build time. Real
|
|
22
|
+
// values are spread from `config` below.
|
|
23
|
+
storage: { kind: 'local' },
|
|
18
24
|
auth: { providers: ['github'] },
|
|
19
|
-
theme: {},
|
|
20
25
|
products: {},
|
|
21
26
|
collections: {},
|
|
22
27
|
...config,
|
|
28
|
+
// Global config theme overrides static config theme field by field
|
|
29
|
+
theme: { ...staticTheme, ...globalTheme },
|
|
23
30
|
// Include storage params so the client can create ProxyContentRepository
|
|
24
31
|
_storage: ssrConfig?.storage ?? undefined,
|
|
25
32
|
_hasGitHub: ssrConfig?.hasGitHub ?? false,
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { resolveStorageConfig } from './_storage-config'
|
|
3
3
|
import { parseSession } from './_auth-guard'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
4
5
|
import type { ContentEditorConfig } from '@setzkasten-cms/core'
|
|
6
|
+
import { validateEditorsUpdate } from '@setzkasten-cms/core'
|
|
5
7
|
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
6
8
|
import { withTrailers } from './_commit-trailers'
|
|
9
|
+
import { gateFeature } from './_feature-gate'
|
|
7
10
|
|
|
8
11
|
const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
|
|
9
12
|
|
|
13
|
+
// In Multi-Mode editors are global across all websites and live in the
|
|
14
|
+
// config-repo; in Single-Mode the config-repo IS the website-repo. Either
|
|
15
|
+
// way the answer is "the build-time-configured storage" — never the
|
|
16
|
+
// per-website storage that the X-SK-Website header would route to.
|
|
17
|
+
function configRepoStorage(): { owner: string; repo: string; branch: string } | null {
|
|
18
|
+
const storage = resolveStorageConfig()
|
|
19
|
+
if (!storage) return null
|
|
20
|
+
return { owner: storage.owner, repo: storage.repo, branch: storage.branch }
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
// ---------------------------------------------------------------------------
|
|
11
24
|
// GET /api/setzkasten/editors
|
|
12
25
|
// Returns the current editors list from _editors.json.
|
|
@@ -17,17 +30,18 @@ export const GET: APIRoute = async ({ cookies }) => {
|
|
|
17
30
|
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
18
31
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
19
32
|
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
33
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
34
|
+
if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
|
|
22
35
|
|
|
23
|
-
const storage =
|
|
36
|
+
const storage = configRepoStorage()
|
|
24
37
|
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
25
38
|
|
|
26
|
-
const serverConfig = (globalThis as
|
|
39
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
40
|
+
.__SETZKASTEN_CONFIG__
|
|
27
41
|
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
28
42
|
const { owner, repo, branch } = storage
|
|
29
43
|
|
|
30
|
-
const raw = await readEditorsFile(owner, repo, branch, contentPath,
|
|
44
|
+
const raw = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
|
|
31
45
|
return Response.json(raw ?? [])
|
|
32
46
|
}
|
|
33
47
|
|
|
@@ -42,13 +56,19 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
42
56
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
43
57
|
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
// Feature-gate: editor management is a Pro feature. Free-tier admins
|
|
60
|
+
// can read the editors list (GET) but not write it.
|
|
61
|
+
const gate = gateFeature('editors')
|
|
62
|
+
if (gate) return gate
|
|
47
63
|
|
|
48
|
-
const
|
|
64
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
65
|
+
if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
|
|
66
|
+
|
|
67
|
+
const storage = configRepoStorage()
|
|
49
68
|
if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
|
|
50
69
|
|
|
51
|
-
const serverConfig = (globalThis as
|
|
70
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
71
|
+
.__SETZKASTEN_CONFIG__
|
|
52
72
|
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
53
73
|
const { owner, repo, branch } = storage
|
|
54
74
|
|
|
@@ -60,10 +80,18 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
60
80
|
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
const validation = validateEditorsUpdate(editors, session.user.email)
|
|
84
|
+
if (!validation.ok) {
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ error: validation.message, code: validation.code },
|
|
87
|
+
{ status: 400 },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
const filePath = EDITORS_FILE(contentPath)
|
|
64
92
|
const fileContent = JSON.stringify(editors, null, 2)
|
|
65
93
|
const headers = {
|
|
66
|
-
Authorization: `Bearer ${
|
|
94
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
67
95
|
Accept: 'application/vnd.github+json',
|
|
68
96
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
69
97
|
'Content-Type': 'application/json',
|
|
@@ -134,3 +162,59 @@ export async function readEditorsFile(
|
|
|
134
162
|
return JSON.parse(raw) as ContentEditorConfig[]
|
|
135
163
|
})
|
|
136
164
|
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Discriminated result for the editors-file fetch. Lets callers decide the
|
|
168
|
+
* fail-mode policy: the auth-guard wants to ALLOW when the file is genuinely
|
|
169
|
+
* absent (no restrictions configured) but DENY when the fetch errors out.
|
|
170
|
+
* The basic readEditorsFile() above returns null for both cases, which is
|
|
171
|
+
* unsafe for authorization checks.
|
|
172
|
+
*
|
|
173
|
+
* Caller is responsible for caching — this function never reads from or
|
|
174
|
+
* writes to the shared cache, because caching an "error" state would
|
|
175
|
+
* silently extend privilege-escalation windows.
|
|
176
|
+
*/
|
|
177
|
+
export type EditorsStatus =
|
|
178
|
+
| { kind: 'absent' }
|
|
179
|
+
| { kind: 'present'; editors: ContentEditorConfig[] }
|
|
180
|
+
| { kind: 'error'; message: string }
|
|
181
|
+
|
|
182
|
+
export async function readEditorsFileStatus(
|
|
183
|
+
owner: string,
|
|
184
|
+
repo: string,
|
|
185
|
+
branch: string,
|
|
186
|
+
contentPath: string,
|
|
187
|
+
token: string,
|
|
188
|
+
): Promise<EditorsStatus> {
|
|
189
|
+
let res: Response
|
|
190
|
+
try {
|
|
191
|
+
res = await fetch(
|
|
192
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
|
|
193
|
+
{
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Bearer ${token}`,
|
|
196
|
+
Accept: 'application/vnd.github+json',
|
|
197
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return { kind: 'error', message: err instanceof Error ? err.message : 'network error' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (res.status === 404) return { kind: 'absent' }
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
return { kind: 'error', message: `GitHub returned ${res.status}` }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
212
|
+
const raw =
|
|
213
|
+
data.encoding === 'base64'
|
|
214
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
215
|
+
: data.content
|
|
216
|
+
return { kind: 'present', editors: JSON.parse(raw) as ContentEditorConfig[] }
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { kind: 'error', message: err instanceof Error ? err.message : 'parse error' }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -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
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { parseSession } from './_auth-guard'
|
|
3
3
|
import { resolveStorageConfig } from './_storage-config'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
4
5
|
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
5
6
|
import { withTrailers } from './_commit-trailers'
|
|
6
7
|
|
|
@@ -12,6 +13,11 @@ export interface GlobalConfig {
|
|
|
12
13
|
authDomain: string
|
|
13
14
|
projectId: string
|
|
14
15
|
}
|
|
16
|
+
theme?: {
|
|
17
|
+
primaryColor?: string
|
|
18
|
+
brandName?: string
|
|
19
|
+
logo?: string
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
// ---------------------------------------------------------------------------
|
|
@@ -42,7 +48,7 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
42
48
|
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
const current = await readGlobalConfig() ?? {}
|
|
51
|
+
const current = (await readGlobalConfig()) ?? {}
|
|
46
52
|
const next: GlobalConfig = { ...current }
|
|
47
53
|
for (const [k, v] of Object.entries(patch)) {
|
|
48
54
|
if (v === null) delete (next as Record<string, unknown>)[k]
|
|
@@ -54,23 +60,34 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
54
60
|
|
|
55
61
|
// ---------------------------------------------------------------------------
|
|
56
62
|
// Helpers
|
|
63
|
+
//
|
|
64
|
+
// Global config is a Setzkasten-instance-level file that lives in the
|
|
65
|
+
// config-repo regardless of which website the request is targeting:
|
|
66
|
+
// - Single-Mode: config-repo == website-repo, so this is fine
|
|
67
|
+
// - Multi-Mode: config-repo holds editors + global config + websites.json
|
|
68
|
+
// We deliberately ignore the request and X-SK-Website here; otherwise
|
|
69
|
+
// global config would ping-pong between per-website locations as users
|
|
70
|
+
// switch active sites.
|
|
57
71
|
// ---------------------------------------------------------------------------
|
|
58
72
|
|
|
59
|
-
function getStorageParams() {
|
|
60
|
-
const serverConfig = (globalThis as
|
|
73
|
+
async function getStorageParams() {
|
|
74
|
+
const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
|
|
75
|
+
.__SETZKASTEN_CONFIG__
|
|
61
76
|
const storage = resolveStorageConfig()
|
|
62
77
|
if (!storage) return null
|
|
78
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
79
|
+
if (!tokenResult.ok) return null
|
|
63
80
|
return {
|
|
64
81
|
owner: storage.owner,
|
|
65
82
|
repo: storage.repo,
|
|
66
83
|
branch: storage.branch,
|
|
67
84
|
contentPath: serverConfig?.storage?.contentPath ?? 'content',
|
|
68
|
-
token:
|
|
85
|
+
token: tokenResult.value,
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
|
73
|
-
const params = getStorageParams()
|
|
90
|
+
const params = await getStorageParams()
|
|
74
91
|
if (!params) return null
|
|
75
92
|
const { owner, repo, branch, contentPath, token } = params
|
|
76
93
|
const key = `global-config:${owner}/${repo}:${branch}`
|
|
@@ -89,7 +106,7 @@ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
|
|
92
|
-
const params = getStorageParams()
|
|
109
|
+
const params = await getStorageParams()
|
|
93
110
|
if (!params) throw new Error('Storage not configured')
|
|
94
111
|
const { owner, repo, branch, contentPath, token } = params
|
|
95
112
|
invalidateCache(`global-config:${owner}/${repo}:${branch}`)
|