@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,8 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Storage resolution helpers for admin API routes.
|
|
3
|
+
*
|
|
4
|
+
* Two entry points:
|
|
5
|
+
*
|
|
6
|
+
* - {@link resolveStorageConfigForRequest} (default for content routes) —
|
|
7
|
+
* resolves the **active website** for a request. In single mode this
|
|
8
|
+
* is always the integration's website; in multi mode it is the website
|
|
9
|
+
* selected by the X-SK-Website header.
|
|
10
|
+
*
|
|
11
|
+
* - {@link resolveStorageConfig} (build-time only, used by auth-guard
|
|
12
|
+
* for the editors file) — returns the integration's build-time storage
|
|
13
|
+
* target. In single mode this is the website repo; in multi mode it
|
|
14
|
+
* is the config repo (where `_editors.json` lives next to
|
|
15
|
+
* `websites.json`).
|
|
16
|
+
*
|
|
17
|
+
* The lookup chain inside `resolveStorageConfig`:
|
|
18
|
+
* 1. Request body override (explicit `{ owner, repo, branch }` payload)
|
|
19
|
+
* 2. `__SETZKASTEN_STORAGE__` Vite define (embedded at build time)
|
|
20
|
+
* 3. `globalThis.__SETZKASTEN_CONFIG__` (injectScript, SSR-only fallback)
|
|
6
21
|
*/
|
|
7
22
|
|
|
8
23
|
declare const __SETZKASTEN_STORAGE__: {
|
|
@@ -23,6 +38,13 @@ export interface StorageConfig {
|
|
|
23
38
|
projectPrefix: string
|
|
24
39
|
}
|
|
25
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Returns the integration's build-time storage target. Used by the
|
|
43
|
+
* auth-guard to read the editors file (which lives next to the build-
|
|
44
|
+
* time storage in both single and multi mode) and by tests that want
|
|
45
|
+
* to bypass the per-request resolver. Most content routes should use
|
|
46
|
+
* {@link resolveStorageConfigForRequest} instead.
|
|
47
|
+
*/
|
|
26
48
|
export function resolveStorageConfig(body?: {
|
|
27
49
|
owner?: string
|
|
28
50
|
repo?: string
|
|
@@ -52,3 +74,41 @@ export function prefixPath(filePath: string, projectPrefix: string): string {
|
|
|
52
74
|
if (!projectPrefix) return filePath
|
|
53
75
|
return `${projectPrefix}/${filePath}`
|
|
54
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Standalone-aware variant: resolves storage from the active website
|
|
80
|
+
* (X-SK-Website header → WebsitesRegistry) before falling back to the
|
|
81
|
+
* single-repo build-time configuration.
|
|
82
|
+
*
|
|
83
|
+
* Body overrides still win over both — useful for routes that take an
|
|
84
|
+
* explicit `{ owner, repo, branch }` payload.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveStorageConfigForRequest(
|
|
87
|
+
request: Request,
|
|
88
|
+
body?: { owner?: string; repo?: string; branch?: string },
|
|
89
|
+
): Promise<StorageConfig | null> {
|
|
90
|
+
if (body?.owner && body.repo) {
|
|
91
|
+
return {
|
|
92
|
+
owner: body.owner,
|
|
93
|
+
repo: body.repo,
|
|
94
|
+
branch: body.branch ?? 'main',
|
|
95
|
+
projectPrefix: '',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { resolveCurrentWebsite } = await import('./_website-resolver.js')
|
|
100
|
+
const resolved = await resolveCurrentWebsite(request)
|
|
101
|
+
if (resolved.ok) {
|
|
102
|
+
const [owner, repo] = resolved.value.repo.split('/')
|
|
103
|
+
if (owner && repo) {
|
|
104
|
+
return {
|
|
105
|
+
owner,
|
|
106
|
+
repo,
|
|
107
|
+
branch: resolved.value.branch,
|
|
108
|
+
projectPrefix: '',
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return resolveStorageConfig(body)
|
|
114
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives the real public origin on Vercel (and any reverse-proxy setup).
|
|
3
|
+
*
|
|
4
|
+
* Problem: Inside a Vercel serverless function, both `request.url` and
|
|
5
|
+
* Astro's `url` context resolve to the internal function host
|
|
6
|
+
* (e.g. "https://localhost"), not the public hostname.
|
|
7
|
+
*
|
|
8
|
+
* Solution: Read the x-forwarded-host and x-forwarded-proto headers that
|
|
9
|
+
* Vercel's edge layer sets on every inbound request.
|
|
10
|
+
*
|
|
11
|
+
* Falls back gracefully to the `host` header and `https` for local dev
|
|
12
|
+
* or non-Vercel environments.
|
|
13
|
+
*/
|
|
14
|
+
export function getPublicOrigin(request: Request): string {
|
|
15
|
+
const host =
|
|
16
|
+
request.headers.get('x-forwarded-host') ??
|
|
17
|
+
request.headers.get('host') ??
|
|
18
|
+
'localhost'
|
|
19
|
+
const proto =
|
|
20
|
+
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
|
|
21
|
+
return `${proto}://${host}`
|
|
22
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Result,
|
|
3
|
+
type WebsiteEntry,
|
|
4
|
+
type WebsitesRegistryProvider,
|
|
5
|
+
err,
|
|
6
|
+
notFoundError,
|
|
7
|
+
ok,
|
|
8
|
+
validationError,
|
|
9
|
+
} from '@setzkasten-cms/core'
|
|
10
|
+
import { GitHubAppClient, GitHubWebsitesRegistry } from '@setzkasten-cms/github-adapter'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-request resolver for the active website.
|
|
14
|
+
*
|
|
15
|
+
* Standalone mode: looks up the entry matching the X-SK-Website request
|
|
16
|
+
* header in the websites registry (config-repo).
|
|
17
|
+
*
|
|
18
|
+
* Single-repo mode (backward compat): returns a synthesized WebsiteEntry
|
|
19
|
+
* built from the integration's build-time storage + GitHub-App ENV vars.
|
|
20
|
+
* The header is ignored.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface MultiState {
|
|
24
|
+
readonly mode: 'multi'
|
|
25
|
+
readonly registry: WebsitesRegistryProvider
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SingleRepoState {
|
|
29
|
+
readonly mode: 'single'
|
|
30
|
+
readonly synthesized: WebsiteEntry
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ResolverState = MultiState | SingleRepoState | null
|
|
34
|
+
|
|
35
|
+
let state: ResolverState = null
|
|
36
|
+
|
|
37
|
+
/** Test/admin hook: install or clear the resolver state. */
|
|
38
|
+
export function __resetWebsiteResolverForTests(next: ResolverState): void {
|
|
39
|
+
state = next
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configure the resolver state explicitly. Currently only the test suite
|
|
44
|
+
* uses this — production wiring runs lazily through
|
|
45
|
+
* {@link bootstrapResolverFromGlobals} on the first request, since the
|
|
46
|
+
* Astro integration doesn't have an obvious "initialize once" hook
|
|
47
|
+
* (`astro:server:start` runs in dev only). The export stays available so
|
|
48
|
+
* a future integration-startup wire-up has a typed entry point.
|
|
49
|
+
*/
|
|
50
|
+
export function configureWebsiteResolver(next: ResolverState): void {
|
|
51
|
+
state = next
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FullConfig {
|
|
55
|
+
storage?:
|
|
56
|
+
| {
|
|
57
|
+
// 'single' is canonical; 'github-app' is the legacy alias.
|
|
58
|
+
kind: 'single' | 'github-app' | 'local'
|
|
59
|
+
repo?: string
|
|
60
|
+
appId?: string
|
|
61
|
+
installationId?: string
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
// 'multi' is canonical; 'standalone' is the legacy alias.
|
|
65
|
+
kind: 'multi' | 'standalone'
|
|
66
|
+
configRepo: string
|
|
67
|
+
configBranch?: string
|
|
68
|
+
appId: string
|
|
69
|
+
installationId: string
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isMultiKind(kind: unknown): boolean {
|
|
74
|
+
return kind === 'multi' || kind === 'standalone'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface BuildTimeStorage {
|
|
78
|
+
owner: string
|
|
79
|
+
repo: string
|
|
80
|
+
branch: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Vite define-injected literals. The integration (`packages/astro/src/
|
|
84
|
+
// integration.ts`) sets them via the build-time Vite define plugin, so by
|
|
85
|
+
// the time this code runs in a compiled API route the literals are
|
|
86
|
+
// substituted with their JSON values. globalThis-style reads break on
|
|
87
|
+
// cold-start serverless functions because the integration's
|
|
88
|
+
// page-ssr injectScript only fires for SSR pages, never for API-only
|
|
89
|
+
// invocations.
|
|
90
|
+
declare const __SETZKASTEN_FULL_CONFIG__: FullConfig | null | undefined
|
|
91
|
+
declare const __SETZKASTEN_STORAGE__: BuildTimeStorage | null | undefined
|
|
92
|
+
declare const __SETZKASTEN_WEBSITE_URL__: string | undefined
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lazy bootstrap from build-time globals. Reads `__SETZKASTEN_FULL_CONFIG__`
|
|
96
|
+
* and `__SETZKASTEN_STORAGE__` (set by the Astro integration via Vite
|
|
97
|
+
* define — literals only, NOT globalThis) to derive single-repo or
|
|
98
|
+
* standalone state. Idempotent — does nothing if the resolver is already
|
|
99
|
+
* configured.
|
|
100
|
+
*/
|
|
101
|
+
export function bootstrapResolverFromGlobals(): void {
|
|
102
|
+
if (state !== null) return
|
|
103
|
+
|
|
104
|
+
const fullConfig =
|
|
105
|
+
(typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null) ??
|
|
106
|
+
((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
|
|
107
|
+
| FullConfig
|
|
108
|
+
| undefined)
|
|
109
|
+
const buildStorage =
|
|
110
|
+
(typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null) ??
|
|
111
|
+
((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as
|
|
112
|
+
| BuildTimeStorage
|
|
113
|
+
| undefined)
|
|
114
|
+
|
|
115
|
+
const storageKind = fullConfig?.storage?.kind
|
|
116
|
+
if (isMultiKind(storageKind)) {
|
|
117
|
+
const standalone = fullConfig?.storage as {
|
|
118
|
+
kind: 'multi' | 'standalone'
|
|
119
|
+
configRepo: string
|
|
120
|
+
configBranch?: string
|
|
121
|
+
appId: string
|
|
122
|
+
installationId: string
|
|
123
|
+
}
|
|
124
|
+
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
|
|
125
|
+
if (!privateKey) {
|
|
126
|
+
// Without the private key the resolver can't mint installation tokens;
|
|
127
|
+
// every Multi-Mode request would 400 with a confusing "X-SK-Website
|
|
128
|
+
// header missing" message instead of the actual root cause.
|
|
129
|
+
console.error(
|
|
130
|
+
'[setzkasten] Multi-mode resolver bootstrap failed: GITHUB_APP_PRIVATE_KEY is not set in env. ' +
|
|
131
|
+
'Set it on the standalone-admin deployment so the registry can be loaded.',
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const [owner, repo] = standalone.configRepo.split('/')
|
|
136
|
+
if (!owner || !repo) {
|
|
137
|
+
console.error(
|
|
138
|
+
`[setzkasten] Multi-mode resolver bootstrap failed: storage.configRepo "${standalone.configRepo}" is not in "owner/repo" form.`,
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const client = new GitHubAppClient(
|
|
144
|
+
{ appId: standalone.appId, installationId: standalone.installationId, privateKey },
|
|
145
|
+
{ owner, repo, branch: standalone.configBranch ?? 'main' },
|
|
146
|
+
)
|
|
147
|
+
const registry = new GitHubWebsitesRegistry({
|
|
148
|
+
reader: { read: (path) => client.getFileContent(path) },
|
|
149
|
+
path: 'websites.json',
|
|
150
|
+
ttlMs: 60_000,
|
|
151
|
+
})
|
|
152
|
+
state = { mode: 'multi', registry }
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!buildStorage) return
|
|
157
|
+
|
|
158
|
+
const appId = process.env.GITHUB_APP_ID ?? ''
|
|
159
|
+
const installationId = process.env.GITHUB_APP_INSTALLATION_ID ?? ''
|
|
160
|
+
// Source of truth: __SETZKASTEN_WEBSITE_URL__ Vite define (mirrors
|
|
161
|
+
// astro.config.mjs#site, dev-server origin in dev) — same value the
|
|
162
|
+
// updater registers licenses with. As a Vite literal it survives
|
|
163
|
+
// cold-start API-only function invocations (unlike __SETZKASTEN_CONFIG__
|
|
164
|
+
// which is only set via injectScript on page-ssr renders).
|
|
165
|
+
// PUBLIC_SITE_URL stays as an escape hatch for setups without `site:`.
|
|
166
|
+
const websiteUrlLiteral =
|
|
167
|
+
typeof __SETZKASTEN_WEBSITE_URL__ !== 'undefined' ? __SETZKASTEN_WEBSITE_URL__ : ''
|
|
168
|
+
const previewOrigin =
|
|
169
|
+
websiteUrlLiteral ||
|
|
170
|
+
process.env.PUBLIC_SITE_URL ||
|
|
171
|
+
'http://localhost:4321'
|
|
172
|
+
|
|
173
|
+
state = {
|
|
174
|
+
mode: 'single',
|
|
175
|
+
synthesized: {
|
|
176
|
+
id: 'default',
|
|
177
|
+
name: buildStorage.repo,
|
|
178
|
+
repo: `${buildStorage.owner}/${buildStorage.repo}`,
|
|
179
|
+
branch: buildStorage.branch,
|
|
180
|
+
previewOrigin,
|
|
181
|
+
githubApp: { appId, installationId },
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const HEADER = 'x-sk-website'
|
|
187
|
+
|
|
188
|
+
export async function resolveCurrentWebsite(request: Request): Promise<Result<WebsiteEntry>> {
|
|
189
|
+
if (state === null) bootstrapResolverFromGlobals()
|
|
190
|
+
if (state === null) {
|
|
191
|
+
return err(
|
|
192
|
+
validationError(
|
|
193
|
+
['websiteResolver'],
|
|
194
|
+
'not-configured',
|
|
195
|
+
'Website resolver not configured — call configureWebsiteResolver() at integration startup.',
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (state.mode === 'single') {
|
|
201
|
+
return ok(state.synthesized)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const requested = request.headers.get(HEADER)?.trim() ?? ''
|
|
205
|
+
|
|
206
|
+
if (!requested) {
|
|
207
|
+
const list = await state.registry.list()
|
|
208
|
+
if (!list.ok) return list
|
|
209
|
+
|
|
210
|
+
const sole = list.value.length === 1 ? list.value[0] : undefined
|
|
211
|
+
if (sole) return ok(sole)
|
|
212
|
+
|
|
213
|
+
return err(
|
|
214
|
+
validationError(
|
|
215
|
+
[HEADER],
|
|
216
|
+
'required',
|
|
217
|
+
'Standalone mode requires the X-SK-Website request header (registry has multiple entries).',
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const found = await state.registry.get(requested)
|
|
223
|
+
if (!found.ok) return found
|
|
224
|
+
if (!found.value) return err(notFoundError(`website:${requested}`))
|
|
225
|
+
|
|
226
|
+
return ok(found.value)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Lists all websites known to the resolver.
|
|
231
|
+
* Standalone mode: defers to the registry. Single-repo mode: returns the
|
|
232
|
+
* synthesized entry as a one-element list.
|
|
233
|
+
*/
|
|
234
|
+
export async function listAllWebsites(): Promise<Result<readonly WebsiteEntry[]>> {
|
|
235
|
+
if (state === null) bootstrapResolverFromGlobals()
|
|
236
|
+
if (state === null) {
|
|
237
|
+
return err(
|
|
238
|
+
validationError(['websiteResolver'], 'not-configured', 'Website resolver not configured.'),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (state.mode === 'single') return ok([state.synthesized])
|
|
242
|
+
return state.registry.list()
|
|
243
|
+
}
|
|
@@ -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,11 +1,10 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
-
import {
|
|
3
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* OAuth callback handler.
|
|
7
|
-
*
|
|
8
|
-
* session via the auth adapter, and sets a signed session cookie.
|
|
6
|
+
* GitHub OAuth callback handler.
|
|
7
|
+
* Google uses the GIS flow (POST /api/setzkasten/auth/google) — no callback needed there.
|
|
9
8
|
*/
|
|
10
9
|
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
10
|
const code = url.searchParams.get('code')
|
|
@@ -15,91 +14,60 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
15
14
|
return new Response('Missing authorization code', { status: 400 })
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
// Parse stored state (may contain provider info)
|
|
19
17
|
const storedRaw = cookies.get('setzkasten_oauth_state')?.value
|
|
20
18
|
let storedState: string | undefined
|
|
21
|
-
|
|
19
|
+
|
|
22
20
|
if (storedRaw) {
|
|
23
21
|
try {
|
|
24
|
-
|
|
25
|
-
storedState = parsed.state
|
|
26
|
-
provider = parsed.provider ?? 'github'
|
|
22
|
+
storedState = JSON.parse(storedRaw).state
|
|
27
23
|
} catch {
|
|
28
24
|
storedState = storedRaw
|
|
29
25
|
}
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
|
|
33
|
-
if (state && storedState && state !== storedState) {
|
|
28
|
+
if (!storedState || state !== storedState) {
|
|
34
29
|
return new Response('Invalid state parameter', { status: 400 })
|
|
35
30
|
}
|
|
36
31
|
|
|
37
|
-
// Clear the state cookie
|
|
38
32
|
cookies.delete('setzkasten_oauth_state', { path: '/' })
|
|
39
33
|
|
|
40
34
|
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
41
35
|
| { adminPath: string }
|
|
42
36
|
| undefined
|
|
43
|
-
|
|
44
37
|
const adminPath = config?.adminPath ?? '/admin'
|
|
45
38
|
|
|
46
|
-
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
47
39
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
48
40
|
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
49
|
-
const
|
|
50
|
-
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
41
|
+
const redirectUri = `${protocol}://${host}/api/setzkasten/auth/callback`
|
|
51
42
|
|
|
52
|
-
// Read allowed emails from env (comma-separated)
|
|
53
43
|
const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
|
|
54
44
|
const allowedEmails = allowedEmailsRaw
|
|
55
45
|
? allowedEmailsRaw.split(',').map((e: string) => e.trim())
|
|
56
46
|
: undefined
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
const auth = createGitHubAuth({
|
|
49
|
+
clientId: import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? '',
|
|
50
|
+
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
|
|
51
|
+
redirectUri,
|
|
52
|
+
allowedEmails,
|
|
53
|
+
})
|
|
60
54
|
|
|
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
|
-
}
|
|
55
|
+
try {
|
|
56
|
+
const sessionResult = await auth.handleCallback(code)
|
|
78
57
|
|
|
79
58
|
if (!sessionResult.ok) {
|
|
80
|
-
console.error('[setzkasten] Auth failed:', sessionResult.error.message)
|
|
81
59
|
return new Response(`Authentication failed: ${sessionResult.error.message}`, { status: 403 })
|
|
82
60
|
}
|
|
83
61
|
|
|
84
62
|
const session = sessionResult.value
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
cookies.set('setzkasten_session', sessionPayload, {
|
|
93
|
-
httpOnly: true,
|
|
94
|
-
secure: import.meta.env.PROD,
|
|
95
|
-
sameSite: 'lax',
|
|
96
|
-
path: '/',
|
|
97
|
-
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
98
|
-
})
|
|
63
|
+
cookies.set(
|
|
64
|
+
'setzkasten_session',
|
|
65
|
+
JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
|
|
66
|
+
sessionCookieOptions(import.meta.env.PROD),
|
|
67
|
+
)
|
|
99
68
|
|
|
100
69
|
return redirect(adminPath)
|
|
101
|
-
} catch
|
|
102
|
-
console.error('[setzkasten] Auth callback error:', error)
|
|
70
|
+
} catch {
|
|
103
71
|
return new Response('Authentication failed', { status: 500 })
|
|
104
72
|
}
|
|
105
73
|
}
|