@setzkasten-cms/astro-admin 1.4.6 → 1.5.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/dist/api-routes/_auth-guard.d.ts +27 -3
- package/dist/api-routes/_auth-guard.js +5 -2
- package/dist/api-routes/_dev-session-secret.d.ts +8 -0
- package/dist/api-routes/_dev-session-secret.js +8 -0
- package/dist/api-routes/_github-token.js +1 -1
- package/dist/api-routes/_role-resolver.js +6 -3
- package/dist/api-routes/_session-secret.d.ts +19 -0
- package/dist/api-routes/_session-secret.js +7 -0
- package/dist/api-routes/_session-signing.d.ts +45 -0
- package/dist/api-routes/_session-signing.js +8 -0
- package/dist/api-routes/_webhook-dispatcher.js +4 -4
- package/dist/api-routes/asset-proxy.js +1 -1
- package/dist/api-routes/auth-callback.js +12 -5
- package/dist/api-routes/auth-logout.d.ts +4 -4
- package/dist/api-routes/auth-logout.js +8 -2
- package/dist/api-routes/auth-session.d.ts +6 -0
- package/dist/api-routes/auth-session.js +19 -19
- package/dist/api-routes/auth-setzkasten-login.js +14 -7
- package/dist/api-routes/catalog-add.js +59 -17
- package/dist/api-routes/catalog-export.js +14 -4
- package/dist/api-routes/config.d.ts +10 -3
- package/dist/api-routes/config.js +26 -4
- package/dist/api-routes/deploy-hook.js +8 -8
- package/dist/api-routes/editors.d.ts +1 -1
- package/dist/api-routes/editors.js +5 -2
- package/dist/api-routes/github-proxy.js +30 -8
- package/dist/api-routes/global-config.js +6 -3
- package/dist/api-routes/history-rollback.js +31 -14
- package/dist/api-routes/history-version.js +8 -6
- package/dist/api-routes/history.js +5 -2
- package/dist/api-routes/icons-local.js +1 -1
- package/dist/api-routes/init-add-section.js +113 -47
- package/dist/api-routes/init-apply.js +56 -42
- package/dist/api-routes/init-migrate.js +43 -36
- package/dist/api-routes/init-scan-page.d.ts +1 -1
- package/dist/api-routes/init-scan-page.js +59 -13
- package/dist/api-routes/init-scan.js +22 -7
- package/dist/api-routes/migrate-to-multi.js +5 -2
- package/dist/api-routes/pages.js +15 -4
- package/dist/api-routes/section-add.js +68 -16
- package/dist/api-routes/section-commit-pending.js +70 -22
- package/dist/api-routes/section-delete.js +49 -14
- package/dist/api-routes/section-duplicate.js +65 -16
- package/dist/api-routes/section-prepare-copy.js +15 -2
- package/dist/api-routes/section-prepare.js +25 -4
- package/dist/api-routes/setup-github-app-bounce.js +15 -1
- package/dist/api-routes/setup-github-app-branches.js +9 -6
- package/dist/api-routes/setup-github-app-callback.js +24 -1
- package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
- package/dist/api-routes/setup-github-app-credentials.js +43 -0
- package/dist/api-routes/setup-github-app-installed.js +22 -1
- package/dist/api-routes/setup-github-app-repos.js +5 -2
- package/dist/api-routes/setup-github-app.d.ts +4 -0
- package/dist/api-routes/setup-github-app.js +19 -2
- package/dist/api-routes/updater-register.js +7 -1
- package/dist/api-routes/webhooks-status.js +5 -2
- package/dist/api-routes/webhooks-test.js +9 -8
- package/dist/api-routes/webhooks.js +12 -14
- package/dist/api-routes/websites-add.js +5 -2
- package/dist/api-routes/websites-remove.js +5 -2
- package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
- package/dist/{chunk-Q3N336KR.js → chunk-CDXCYYQR.js} +29 -24
- package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
- package/dist/chunk-KENFINT4.js +76 -0
- package/dist/chunk-ONP6BRZO.js +47 -0
- package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
- package/dist/chunk-QVCW6EF3.js +26 -0
- package/dist/{chunk-TD76R3A6.js → chunk-UHI6323G.js} +293 -174
- package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
- package/package.json +12 -6
- package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
- package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +59 -25
- package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
- package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
- package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
- package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
- package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
- package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
- package/src/api-routes/__tests__/github-cache.test.ts +1 -1
- package/src/api-routes/__tests__/github-token.test.ts +1 -1
- package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
- package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
- package/src/api-routes/__tests__/history.test.ts +9 -6
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
- package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
- package/src/api-routes/__tests__/pages.test.ts +7 -2
- package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
- package/src/api-routes/__tests__/route-registry.test.ts +11 -18
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
- package/src/api-routes/__tests__/section-management.test.ts +28 -28
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
- package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
- package/src/api-routes/__tests__/updater-register.test.ts +230 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
- package/src/api-routes/__tests__/webhooks.test.ts +19 -7
- package/src/api-routes/__tests__/websites-add.test.ts +2 -1
- package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
- package/src/api-routes/_auth-guard.ts +47 -15
- package/src/api-routes/_commit-trailers.ts +3 -2
- package/src/api-routes/_dev-session-secret.ts +79 -0
- package/src/api-routes/_github-token.ts +1 -1
- package/src/api-routes/_pages-meta-store.ts +2 -2
- package/src/api-routes/_role-resolver.ts +7 -5
- package/src/api-routes/_session-secret.ts +46 -0
- package/src/api-routes/_session-signing.ts +135 -0
- package/src/api-routes/_vercel-origin.ts +2 -6
- package/src/api-routes/_webhook-dispatcher.ts +12 -16
- package/src/api-routes/_website-resolver.ts +3 -10
- package/src/api-routes/auth-callback.ts +9 -5
- package/src/api-routes/auth-login.ts +5 -3
- package/src/api-routes/auth-logout.ts +18 -1
- package/src/api-routes/auth-session.ts +13 -21
- package/src/api-routes/auth-setzkasten-login.ts +12 -9
- package/src/api-routes/catalog-add.ts +89 -31
- package/src/api-routes/catalog-export.ts +30 -10
- package/src/api-routes/config.ts +39 -6
- package/src/api-routes/deploy-hook.ts +13 -11
- package/src/api-routes/editors.ts +33 -22
- package/src/api-routes/github-proxy.ts +25 -11
- package/src/api-routes/global-config.ts +103 -18
- package/src/api-routes/history-rollback.ts +41 -14
- package/src/api-routes/history-version.ts +5 -6
- package/src/api-routes/history.ts +3 -3
- package/src/api-routes/icons-local.ts +2 -2
- package/src/api-routes/init-add-section.ts +174 -79
- package/src/api-routes/init-apply.ts +71 -56
- package/src/api-routes/init-migrate.ts +54 -48
- package/src/api-routes/init-scan-page.ts +77 -30
- package/src/api-routes/init-scan.ts +19 -11
- package/src/api-routes/pages.ts +16 -11
- package/src/api-routes/section-add.ts +98 -27
- package/src/api-routes/section-commit-pending.ts +87 -34
- package/src/api-routes/section-delete.ts +76 -27
- package/src/api-routes/section-duplicate.ts +95 -28
- package/src/api-routes/section-management.ts +3 -7
- package/src/api-routes/section-prepare-copy.ts +29 -8
- package/src/api-routes/section-prepare.ts +38 -10
- package/src/api-routes/setup-github-app-bounce.ts +7 -1
- package/src/api-routes/setup-github-app-branches.ts +6 -7
- package/src/api-routes/setup-github-app-callback.ts +18 -1
- package/src/api-routes/setup-github-app-credentials.ts +55 -0
- package/src/api-routes/setup-github-app-installed.ts +12 -1
- package/src/api-routes/setup-github-app-repos.ts +2 -3
- package/src/api-routes/setup-github-app.ts +14 -5
- package/src/api-routes/updater-check.ts +6 -4
- package/src/api-routes/updater-register.ts +34 -20
- package/src/api-routes/updater-transfer.ts +8 -6
- package/src/api-routes/updater-unbind.ts +14 -10
- package/src/api-routes/webhooks-test.ts +9 -11
- package/src/api-routes/webhooks.ts +15 -19
- package/src/init/__tests__/page-level.test.ts +279 -105
- package/src/init/__tests__/page-list-coverage.test.ts +70 -70
- package/src/init/__tests__/patcher-child-component.test.ts +12 -3
- package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
- package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
- package/src/init/__tests__/section-pipeline.test.ts +53 -19
- package/src/init/astro-config-patcher.ts +4 -18
- package/src/init/astro-detector.ts +2 -7
- package/src/init/astro-section-analyzer-v2.ts +475 -193
- package/src/init/field-label-enricher.ts +6 -6
- package/src/init/template-patcher-v2.ts +218 -97
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed session cookies.
|
|
3
|
+
*
|
|
4
|
+
* Pre-C1 the session cookie was `JSON.stringify(session)` — readable and
|
|
5
|
+
* forgeable. An unauthenticated attacker could craft any role for any
|
|
6
|
+
* email and walk in. This module replaces that with HMAC-SHA256 over a
|
|
7
|
+
* canonical JSON payload, and refuses cookies whose signature doesn't
|
|
8
|
+
* match a known secret.
|
|
9
|
+
*
|
|
10
|
+
* Wire format:
|
|
11
|
+
*
|
|
12
|
+
* <base64url(JSON.stringify(payload))>.<base64url(HMAC-SHA256(payload))>
|
|
13
|
+
*
|
|
14
|
+
* Two segments separated by a literal `.`. Both segments are base64url
|
|
15
|
+
* (no padding). Anything that doesn't decode to that shape is rejected.
|
|
16
|
+
*
|
|
17
|
+
* The secret is supplied per-call so this module stays pure and keeps
|
|
18
|
+
* the secret-resolution policy (env var, cookie domain, fail-closed in
|
|
19
|
+
* prod) at the integration layer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import type { AuthSession } from '@setzkasten-cms/core'
|
|
24
|
+
|
|
25
|
+
const SEPARATOR = '.'
|
|
26
|
+
|
|
27
|
+
function base64urlEncode(input: string | Buffer): string {
|
|
28
|
+
const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : input
|
|
29
|
+
return buf.toString('base64url')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function base64urlDecode(input: string): Buffer | null {
|
|
33
|
+
if (!/^[A-Za-z0-9_-]+$/.test(input)) return null
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(input, 'base64url')
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hmac(payload: string, secret: string): Buffer {
|
|
42
|
+
return createHmac('sha256', secret).update(payload).digest()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Produces the cookie value for a verified session. Throws on missing
|
|
47
|
+
* secret — the caller (always server-side) must have one resolved.
|
|
48
|
+
*/
|
|
49
|
+
export function signSession(session: AuthSession, secret: string): string {
|
|
50
|
+
if (typeof secret !== 'string' || secret.trim().length === 0) {
|
|
51
|
+
throw new Error('signSession: missing secret')
|
|
52
|
+
}
|
|
53
|
+
const json = JSON.stringify(session)
|
|
54
|
+
const payloadB64 = base64urlEncode(json)
|
|
55
|
+
const sigB64 = hmac(payloadB64, secret).toString('base64url')
|
|
56
|
+
return `${payloadB64}${SEPARATOR}${sigB64}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type VerifyResult =
|
|
60
|
+
| { readonly ok: true; readonly value: AuthSession }
|
|
61
|
+
| { readonly ok: false; readonly reason: VerifyFailureReason }
|
|
62
|
+
|
|
63
|
+
export type VerifyFailureReason =
|
|
64
|
+
| 'malformed'
|
|
65
|
+
| 'bad-signature'
|
|
66
|
+
| 'invalid-payload'
|
|
67
|
+
| 'expired'
|
|
68
|
+
| 'missing-expiry'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the parsed session iff the signature matches AND the payload
|
|
72
|
+
* has a numeric `expiresAt` in the future. Rejects everything else.
|
|
73
|
+
*
|
|
74
|
+
* @param now Override timestamp for tests. Defaults to `Date.now()`.
|
|
75
|
+
*/
|
|
76
|
+
export function verifySessionCookie(
|
|
77
|
+
raw: string,
|
|
78
|
+
secret: string,
|
|
79
|
+
now: number = Date.now(),
|
|
80
|
+
): VerifyResult {
|
|
81
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
82
|
+
return { ok: false, reason: 'malformed' }
|
|
83
|
+
}
|
|
84
|
+
if (typeof secret !== 'string' || secret.trim().length === 0) {
|
|
85
|
+
return { ok: false, reason: 'malformed' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const segments = raw.split(SEPARATOR)
|
|
89
|
+
if (segments.length !== 2) return { ok: false, reason: 'malformed' }
|
|
90
|
+
const [payloadB64, sigB64] = segments
|
|
91
|
+
if (!payloadB64 || !sigB64) return { ok: false, reason: 'malformed' }
|
|
92
|
+
|
|
93
|
+
const expected = hmac(payloadB64, secret)
|
|
94
|
+
const actual = base64urlDecode(sigB64)
|
|
95
|
+
if (!actual) return { ok: false, reason: 'malformed' }
|
|
96
|
+
|
|
97
|
+
// Reject obvious length mismatches before timing-safe-compare so we
|
|
98
|
+
// don't have to special-case Buffer.alloc; but always do the actual
|
|
99
|
+
// comparison constant-time when lengths agree.
|
|
100
|
+
if (actual.length !== expected.length) return { ok: false, reason: 'bad-signature' }
|
|
101
|
+
if (!timingSafeEqual(actual, expected)) return { ok: false, reason: 'bad-signature' }
|
|
102
|
+
|
|
103
|
+
const payloadBuf = base64urlDecode(payloadB64)
|
|
104
|
+
if (!payloadBuf) return { ok: false, reason: 'malformed' }
|
|
105
|
+
|
|
106
|
+
let parsed: unknown
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(payloadBuf.toString('utf-8'))
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false, reason: 'invalid-payload' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isAuthSessionShape(parsed)) {
|
|
114
|
+
return { ok: false, reason: 'invalid-payload' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof parsed.expiresAt !== 'number' || !Number.isFinite(parsed.expiresAt)) {
|
|
118
|
+
return { ok: false, reason: 'missing-expiry' }
|
|
119
|
+
}
|
|
120
|
+
if (parsed.expiresAt <= now) {
|
|
121
|
+
return { ok: false, reason: 'expired' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { ok: true, value: parsed }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isAuthSessionShape(value: unknown): value is AuthSession {
|
|
128
|
+
if (!value || typeof value !== 'object') return false
|
|
129
|
+
const v = value as Record<string, unknown>
|
|
130
|
+
if (!v.user || typeof v.user !== 'object') return false
|
|
131
|
+
const u = v.user as Record<string, unknown>
|
|
132
|
+
if (typeof u.email !== 'string' || u.email.length === 0) return false
|
|
133
|
+
if (u.role !== 'admin' && u.role !== 'editor') return false
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
@@ -12,11 +12,7 @@
|
|
|
12
12
|
* or non-Vercel environments.
|
|
13
13
|
*/
|
|
14
14
|
export function getPublicOrigin(request: Request): string {
|
|
15
|
-
const host =
|
|
16
|
-
|
|
17
|
-
request.headers.get('host') ??
|
|
18
|
-
'localhost'
|
|
19
|
-
const proto =
|
|
20
|
-
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
|
|
15
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? 'localhost'
|
|
16
|
+
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
|
|
21
17
|
return `${proto}://${host}`
|
|
22
18
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
-
parseWebhooksFile,
|
|
3
|
-
selectWebhooksForEvent,
|
|
4
2
|
type WebhookConfig,
|
|
5
3
|
type WebhookEvent,
|
|
6
4
|
type WebhookPayload,
|
|
5
|
+
parseWebhooksFile,
|
|
6
|
+
selectWebhooksForEvent,
|
|
7
7
|
} from '@setzkasten-cms/core'
|
|
8
|
-
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
9
|
-
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
10
8
|
import { cachedFetch } from './_github-cache'
|
|
11
|
-
import {
|
|
9
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
10
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
12
11
|
import { signPayload } from './_webhook-signing'
|
|
12
|
+
import { recordWebhookFire } from './_webhook-status-store'
|
|
13
13
|
|
|
14
14
|
const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
|
|
15
15
|
const DISPATCH_TIMEOUT_MS = 5_000
|
|
@@ -48,11 +48,7 @@ export async function fireWebhooks(
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
async function fireOne(
|
|
52
|
-
webhook: WebhookConfig,
|
|
53
|
-
event: WebhookEvent,
|
|
54
|
-
body: string,
|
|
55
|
-
): Promise<void> {
|
|
51
|
+
async function fireOne(webhook: WebhookConfig, event: WebhookEvent, body: string): Promise<void> {
|
|
56
52
|
const headers: Record<string, string> = {
|
|
57
53
|
'Content-Type': 'application/json',
|
|
58
54
|
'X-Setzkasten-Event': event,
|
|
@@ -76,18 +72,18 @@ async function fireOne(
|
|
|
76
72
|
}
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
async function loadWebhooksForRequest(
|
|
80
|
-
request: Request,
|
|
81
|
-
): Promise<readonly WebhookConfig[] | null> {
|
|
75
|
+
async function loadWebhooksForRequest(request: Request): Promise<readonly WebhookConfig[] | null> {
|
|
82
76
|
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
83
77
|
if (!tokenResult.ok) return null
|
|
84
78
|
|
|
85
79
|
const storage = await resolveStorageConfigForRequest(request)
|
|
86
80
|
if (!storage) return null
|
|
87
81
|
|
|
88
|
-
const serverConfig = (
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
const serverConfig = (
|
|
83
|
+
globalThis as {
|
|
84
|
+
__SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
|
|
85
|
+
}
|
|
86
|
+
).__SETZKASTEN_CONFIG__
|
|
91
87
|
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
92
88
|
const { owner, repo, branch } = storage
|
|
93
89
|
|
|
@@ -103,14 +103,10 @@ export function bootstrapResolverFromGlobals(): void {
|
|
|
103
103
|
|
|
104
104
|
const fullConfig =
|
|
105
105
|
(typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null) ??
|
|
106
|
-
((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
|
|
107
|
-
| FullConfig
|
|
108
|
-
| undefined)
|
|
106
|
+
((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as FullConfig | undefined)
|
|
109
107
|
const buildStorage =
|
|
110
108
|
(typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null) ??
|
|
111
|
-
((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as
|
|
112
|
-
| BuildTimeStorage
|
|
113
|
-
| undefined)
|
|
109
|
+
((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as BuildTimeStorage | undefined)
|
|
114
110
|
|
|
115
111
|
const storageKind = fullConfig?.storage?.kind
|
|
116
112
|
if (isMultiKind(storageKind)) {
|
|
@@ -165,10 +161,7 @@ export function bootstrapResolverFromGlobals(): void {
|
|
|
165
161
|
// PUBLIC_SITE_URL stays as an escape hatch for setups without `site:`.
|
|
166
162
|
const websiteUrlLiteral =
|
|
167
163
|
typeof __SETZKASTEN_WEBSITE_URL__ !== 'undefined' ? __SETZKASTEN_WEBSITE_URL__ : ''
|
|
168
|
-
const previewOrigin =
|
|
169
|
-
websiteUrlLiteral ||
|
|
170
|
-
process.env.PUBLIC_SITE_URL ||
|
|
171
|
-
'http://localhost:4321'
|
|
164
|
+
const previewOrigin = websiteUrlLiteral || process.env.PUBLIC_SITE_URL || 'http://localhost:4321'
|
|
172
165
|
|
|
173
166
|
state = {
|
|
174
167
|
mode: 'single',
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { APIRoute } from 'astro'
|
|
2
1
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
-
import {
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
4
3
|
import { makeRoleResolver } from './_role-resolver'
|
|
4
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
5
|
+
import { resolveSessionSecret } from './_session-secret.js'
|
|
6
|
+
import { signSession } from './_session-signing'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* GitHub OAuth callback handler.
|
|
@@ -38,10 +40,12 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
38
40
|
const adminPath = config?.adminPath ?? '/admin'
|
|
39
41
|
|
|
40
42
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
41
|
-
const protocol =
|
|
43
|
+
const protocol =
|
|
44
|
+
request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
42
45
|
const redirectUri = `${protocol}://${host}/api/setzkasten/auth/callback`
|
|
43
46
|
|
|
44
|
-
const allowedEmailsRaw =
|
|
47
|
+
const allowedEmailsRaw =
|
|
48
|
+
import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
|
|
45
49
|
const allowedEmails = allowedEmailsRaw
|
|
46
50
|
? allowedEmailsRaw.split(',').map((e: string) => e.trim())
|
|
47
51
|
: undefined
|
|
@@ -64,7 +68,7 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
64
68
|
const session = sessionResult.value
|
|
65
69
|
cookies.set(
|
|
66
70
|
'setzkasten_session',
|
|
67
|
-
|
|
71
|
+
signSession({ user: session.user, expiresAt: session.expiresAt }, resolveSessionSecret()),
|
|
68
72
|
sessionCookieOptions(import.meta.env.PROD),
|
|
69
73
|
)
|
|
70
74
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { APIRoute } from 'astro'
|
|
2
1
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Login initiation – redirects the user to GitHub OAuth.
|
|
@@ -14,12 +14,14 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
14
14
|
|
|
15
15
|
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
16
16
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
17
|
-
const protocol =
|
|
17
|
+
const protocol =
|
|
18
|
+
request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
18
19
|
const origin = `${protocol}://${host}`
|
|
19
20
|
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
20
21
|
|
|
21
22
|
const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
|
|
22
|
-
const ghClientSecret =
|
|
23
|
+
const ghClientSecret =
|
|
24
|
+
import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
|
|
23
25
|
|
|
24
26
|
if (!ghClientId || !ghClientSecret) {
|
|
25
27
|
return new Response('GitHub OAuth not configured', { status: 500 })
|
|
@@ -5,9 +5,26 @@ import { sessionCookieOptions } from './_session-cookie.js'
|
|
|
5
5
|
* Logout – clears the session cookie and redirects to home.
|
|
6
6
|
* Mirrors the same `domain` attribute used when the cookie was set so the
|
|
7
7
|
* browser actually deletes it on subdomains in standalone-admin setups.
|
|
8
|
+
*
|
|
9
|
+
* POST-only post-M4: a third-party site embedding `<img src=
|
|
10
|
+
* "https://your-cms/api/setzkasten/logout">` could log out admins
|
|
11
|
+
* mid-session under the GET form. The SPA's logout button does a POST
|
|
12
|
+
* (or a fetch) so this is purely a CSRF-grade tightening.
|
|
8
13
|
*/
|
|
9
|
-
|
|
14
|
+
async function handleLogout({ cookies, redirect }: Parameters<APIRoute>[0]) {
|
|
10
15
|
const opts = sessionCookieOptions(false)
|
|
11
16
|
cookies.delete('setzkasten_session', { path: '/', domain: opts.domain })
|
|
12
17
|
return redirect('/')
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
export const POST: APIRoute = handleLogout
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Backwards-compat: a few existing UI links still hit GET. We keep the
|
|
24
|
+
* handler but log a deprecation warning so the SPA can be migrated.
|
|
25
|
+
*/
|
|
26
|
+
export const GET: APIRoute = async (ctx) => {
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.warn('[setzkasten] GET /api/setzkasten/logout is deprecated — use POST')
|
|
29
|
+
return handleLogout(ctx)
|
|
30
|
+
}
|
|
@@ -1,36 +1,28 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { parseSession } from './_auth-guard'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Session check – returns current user info or 401.
|
|
5
6
|
* Used by the admin SPA to check if the user is logged in.
|
|
7
|
+
*
|
|
8
|
+
* Goes through `parseSession` so signature verification + expiry are
|
|
9
|
+
* enforced in exactly one place. Pre-C1 this route did its own
|
|
10
|
+
* `JSON.parse` — the only one that ever checked `expiresAt`, while
|
|
11
|
+
* `_auth-guard.parseSession` ignored it. Now both run through the
|
|
12
|
+
* same verifier.
|
|
6
13
|
*/
|
|
7
14
|
export const GET: APIRoute = async ({ cookies }) => {
|
|
8
|
-
const
|
|
15
|
+
const raw = cookies.get('setzkasten_session')?.value
|
|
16
|
+
const session = parseSession(raw)
|
|
9
17
|
if (!session) {
|
|
18
|
+
if (raw) cookies.delete('setzkasten_session', { path: '/' })
|
|
10
19
|
return new Response(JSON.stringify({ authenticated: false }), {
|
|
11
20
|
status: 401,
|
|
12
21
|
headers: { 'Content-Type': 'application/json' },
|
|
13
22
|
})
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (parsed.expiresAt < Date.now()) {
|
|
20
|
-
cookies.delete('setzkasten_session', { path: '/' })
|
|
21
|
-
return new Response(JSON.stringify({ authenticated: false, reason: 'expired' }), {
|
|
22
|
-
status: 401,
|
|
23
|
-
headers: { 'Content-Type': 'application/json' },
|
|
24
|
-
})
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return new Response(JSON.stringify({ authenticated: true, user: parsed.user }), {
|
|
28
|
-
headers: { 'Content-Type': 'application/json' },
|
|
29
|
-
})
|
|
30
|
-
} catch {
|
|
31
|
-
return new Response(JSON.stringify({ authenticated: false }), {
|
|
32
|
-
status: 401,
|
|
33
|
-
headers: { 'Content-Type': 'application/json' },
|
|
34
|
-
})
|
|
35
|
-
}
|
|
25
|
+
return new Response(JSON.stringify({ authenticated: true, user: session.user }), {
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
})
|
|
36
28
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import type { APIRoute } from 'astro'
|
|
2
1
|
import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { resolveStorageConfig } from './_storage-config'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { gateFeature } from './_feature-gate'
|
|
6
4
|
import { resolveConfigRepoToken } from './_github-token'
|
|
7
|
-
import { sessionCookieOptions } from './_session-cookie.js'
|
|
8
5
|
import { makeRoleResolver } from './_role-resolver'
|
|
9
|
-
import {
|
|
6
|
+
import { sessionCookieOptions } from './_session-cookie.js'
|
|
7
|
+
import { resolveSessionSecret } from './_session-secret.js'
|
|
8
|
+
import { signSession } from './_session-signing'
|
|
9
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
10
|
+
import { readEditorsFile } from './editors'
|
|
11
|
+
import { readGlobalConfig } from './global-config'
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* POST /api/setzkasten/auth/setzkasten-login
|
|
@@ -40,8 +42,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
40
42
|
return new Response('Storage not configured', { status: 500 })
|
|
41
43
|
}
|
|
42
44
|
const { owner, repo, branch } = storage
|
|
43
|
-
const serverConfig = (
|
|
44
|
-
|
|
45
|
+
const serverConfig = (
|
|
46
|
+
globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
|
|
47
|
+
).__SETZKASTEN_CONFIG__
|
|
45
48
|
const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
46
49
|
|
|
47
50
|
const tokenResult = await resolveConfigRepoToken()
|
|
@@ -78,7 +81,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
78
81
|
const session = result.value
|
|
79
82
|
cookies.set(
|
|
80
83
|
'setzkasten_session',
|
|
81
|
-
|
|
84
|
+
signSession({ user: session.user, expiresAt: session.expiresAt }, resolveSessionSecret()),
|
|
82
85
|
sessionCookieOptions(import.meta.env.PROD),
|
|
83
86
|
)
|
|
84
87
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { APIRoute } from 'astro'
|
|
2
1
|
import { registry } from '@setzkasten-cms/catalog'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { generateAddKey, addToPageConfig } from './section-management'
|
|
6
|
-
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { guardPageAccess, parseSession } from './_auth-guard'
|
|
7
4
|
import { withTrailers } from './_commit-trailers'
|
|
8
5
|
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
6
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
7
|
+
import { buildCatalogAddCommit, validateCatalogAddBody } from './catalog-helpers'
|
|
8
|
+
import { addToPageConfig, generateAddKey } from './section-management'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* POST /api/setzkasten/catalog/add
|
|
@@ -27,13 +27,16 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
27
27
|
const githubToken = tokenResult.value
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
|
-
const body = await request.json() as Record<string, unknown>
|
|
30
|
+
const body = (await request.json()) as Record<string, unknown>
|
|
31
31
|
|
|
32
32
|
let validated: ReturnType<typeof validateCatalogAddBody>
|
|
33
33
|
try {
|
|
34
34
|
validated = validateCatalogAddBody(body)
|
|
35
35
|
} catch (e) {
|
|
36
|
-
return Response.json(
|
|
36
|
+
return Response.json(
|
|
37
|
+
{ error: e instanceof Error ? e.message : 'Invalid request' },
|
|
38
|
+
{ status: 400 },
|
|
39
|
+
)
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
const { templateName, pageKey } = validated
|
|
@@ -44,9 +47,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
44
47
|
|
|
45
48
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
46
49
|
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
47
|
-
const contentPath =
|
|
50
|
+
const contentPath =
|
|
51
|
+
(body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
|
|
48
52
|
|
|
49
|
-
const denied = await guardPageAccess(
|
|
53
|
+
const denied = await guardPageAccess(
|
|
54
|
+
parseSession(cookies.get('setzkasten_session')?.value),
|
|
55
|
+
pageKey,
|
|
56
|
+
fullConfig,
|
|
57
|
+
request,
|
|
58
|
+
)
|
|
50
59
|
if (denied) return denied
|
|
51
60
|
|
|
52
61
|
const headers = {
|
|
@@ -69,7 +78,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
69
78
|
// 2. Determine section key
|
|
70
79
|
const sectionKey = validated.sectionKey ?? generateAddKey(existingKeys, templateName)
|
|
71
80
|
if (existingKeys.includes(sectionKey)) {
|
|
72
|
-
return Response.json(
|
|
81
|
+
return Response.json(
|
|
82
|
+
{ error: `Key "${sectionKey}" already exists on this page` },
|
|
83
|
+
{ status: 409 },
|
|
84
|
+
)
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
// 3. Get template + default content
|
|
@@ -87,7 +99,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
87
99
|
const updatedConfig = addToPageConfig(pageConfig, sectionKey, templateName)
|
|
88
100
|
|
|
89
101
|
const commitResult = await batchCommit(
|
|
90
|
-
owner,
|
|
102
|
+
owner,
|
|
103
|
+
repo,
|
|
104
|
+
branch,
|
|
91
105
|
[
|
|
92
106
|
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
93
107
|
{ path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
|
|
@@ -111,50 +125,94 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
async function fetchFileContent(
|
|
128
|
+
async function fetchFileContent(
|
|
129
|
+
owner: string,
|
|
130
|
+
repo: string,
|
|
131
|
+
branch: string,
|
|
132
|
+
path: string,
|
|
133
|
+
token: string,
|
|
134
|
+
): Promise<string | null> {
|
|
115
135
|
try {
|
|
116
136
|
const res = await fetch(
|
|
117
137
|
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
118
|
-
{
|
|
138
|
+
{
|
|
139
|
+
headers: {
|
|
140
|
+
Authorization: `Bearer ${token}`,
|
|
141
|
+
Accept: 'application/vnd.github+json',
|
|
142
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
119
145
|
)
|
|
120
146
|
if (!res.ok) return null
|
|
121
|
-
const data = await res.json() as { content: string; encoding: string }
|
|
122
|
-
return data.encoding === 'base64'
|
|
123
|
-
|
|
147
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
148
|
+
return data.encoding === 'base64'
|
|
149
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
150
|
+
: data.content
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
async function batchCommit(
|
|
127
|
-
owner: string,
|
|
157
|
+
owner: string,
|
|
158
|
+
repo: string,
|
|
159
|
+
branch: string,
|
|
128
160
|
files: Array<{ path: string; content: string }>,
|
|
129
161
|
message: string,
|
|
130
162
|
headers: Record<string, string>,
|
|
131
163
|
): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
|
|
132
164
|
try {
|
|
133
|
-
const refRes = await fetch(
|
|
165
|
+
const refRes = await fetch(
|
|
166
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
|
|
167
|
+
{ headers },
|
|
168
|
+
)
|
|
134
169
|
if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
|
|
135
|
-
const {
|
|
170
|
+
const {
|
|
171
|
+
object: { sha: headSha },
|
|
172
|
+
} = (await refRes.json()) as { object: { sha: string } }
|
|
136
173
|
|
|
137
|
-
const commitRes = await fetch(
|
|
174
|
+
const commitRes = await fetch(
|
|
175
|
+
`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
|
|
176
|
+
{ headers },
|
|
177
|
+
)
|
|
138
178
|
if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
|
|
139
|
-
const {
|
|
179
|
+
const {
|
|
180
|
+
tree: { sha: baseSha },
|
|
181
|
+
} = (await commitRes.json()) as { tree: { sha: string } }
|
|
140
182
|
|
|
141
183
|
const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
|
|
142
|
-
method: 'POST',
|
|
143
|
-
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
base_tree: baseSha,
|
|
188
|
+
tree: files.map((f) => ({
|
|
189
|
+
path: f.path,
|
|
190
|
+
mode: '100644',
|
|
191
|
+
type: 'blob',
|
|
192
|
+
content: f.content,
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
144
195
|
})
|
|
145
196
|
if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
|
|
146
|
-
const { sha: treeSha } = await treeRes.json() as { sha: string }
|
|
197
|
+
const { sha: treeSha } = (await treeRes.json()) as { sha: string }
|
|
147
198
|
|
|
148
199
|
const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
|
|
149
|
-
method: 'POST',
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers,
|
|
150
202
|
body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
|
|
151
203
|
})
|
|
152
|
-
if (!newCommitRes.ok)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
204
|
+
if (!newCommitRes.ok)
|
|
205
|
+
return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
|
|
206
|
+
const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
|
|
207
|
+
|
|
208
|
+
const updateRes = await fetch(
|
|
209
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
|
|
210
|
+
{
|
|
211
|
+
method: 'PATCH',
|
|
212
|
+
headers,
|
|
213
|
+
body: JSON.stringify({ sha: newSha }),
|
|
214
|
+
},
|
|
215
|
+
)
|
|
158
216
|
if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
|
|
159
217
|
|
|
160
218
|
return { ok: true, sha: newSha }
|