@setzkasten-cms/astro 0.4.2
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/LICENSE +37 -0
- package/package.json +33 -0
- package/src/admin-page.astro +146 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/draft.ts +61 -0
- package/src/api-routes/github-proxy.ts +82 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/draft-store.ts +61 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +11 -0
- package/src/integration.ts +378 -0
- package/src/preview-middleware.ts +184 -0
- package/tsconfig.json +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Setzkasten Community License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lilapixel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to use,
|
|
7
|
+
copy, modify, merge, publish, and distribute the Software, subject to the
|
|
8
|
+
following conditions:
|
|
9
|
+
|
|
10
|
+
1. The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
2. The Software may not be used for commercial purposes without a separate
|
|
14
|
+
commercial license from the copyright holder. "Commercial purposes" means
|
|
15
|
+
any use of the Software that is primarily intended for or directed toward
|
|
16
|
+
commercial advantage or monetary compensation. This includes, but is not
|
|
17
|
+
limited to:
|
|
18
|
+
- Using the Software to manage content for a commercial website or product
|
|
19
|
+
- Offering the Software as part of a paid service
|
|
20
|
+
- Using the Software within a for-profit organization
|
|
21
|
+
|
|
22
|
+
3. Non-commercial use is permitted without restriction. This includes:
|
|
23
|
+
- Personal projects
|
|
24
|
+
- Open source projects
|
|
25
|
+
- Educational and academic use
|
|
26
|
+
- Non-profit organizations
|
|
27
|
+
|
|
28
|
+
4. A commercial license ("Enterprise License") may be obtained by contacting
|
|
29
|
+
Lilapixel at hello@lilapixel.de.
|
|
30
|
+
|
|
31
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
32
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
33
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
34
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
35
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
36
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
37
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@setzkasten-cms/astro",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./auth-login": "./src/api-routes/auth-login.ts",
|
|
8
|
+
"./auth-callback": "./src/api-routes/auth-callback.ts",
|
|
9
|
+
"./auth-logout": "./src/api-routes/auth-logout.ts",
|
|
10
|
+
"./auth-session": "./src/api-routes/auth-session.ts",
|
|
11
|
+
"./github-proxy": "./src/api-routes/github-proxy.ts",
|
|
12
|
+
"./asset-proxy": "./src/api-routes/asset-proxy.ts",
|
|
13
|
+
"./draft": "./src/api-routes/draft.ts",
|
|
14
|
+
"./preview-middleware": "./src/preview-middleware.ts",
|
|
15
|
+
"./config": "./src/api-routes/config.ts",
|
|
16
|
+
"./pages": "./src/api-routes/pages.ts",
|
|
17
|
+
"./admin-page": "./src/admin-page.astro"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@setzkasten-cms/core": "0.4.2",
|
|
21
|
+
"@setzkasten-cms/ui": "0.4.2",
|
|
22
|
+
"@setzkasten-cms/github-adapter": "0.4.2",
|
|
23
|
+
"@setzkasten-cms/auth": "0.4.2"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"astro": "^5.0.0",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"react-dom": "^19.0.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Admin SPA shell – served at /admin/[...path]
|
|
4
|
+
* Mounts the React-based AdminApp as a client-side SPA.
|
|
5
|
+
*/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<!doctype html>
|
|
9
|
+
<html lang="de">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="UTF-8" />
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
13
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
14
|
+
<title>Setzkasten Admin</title>
|
|
15
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
16
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
17
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
|
+
<style>
|
|
20
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21
|
+
body {
|
|
22
|
+
font-family: 'DM Sans', system-ui, sans-serif;
|
|
23
|
+
color: #1a1a2e;
|
|
24
|
+
background: #faf9f7;
|
|
25
|
+
-webkit-font-smoothing: antialiased;
|
|
26
|
+
}
|
|
27
|
+
.sk-boot-loading {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
gap: 16px;
|
|
34
|
+
}
|
|
35
|
+
.sk-boot-spinner {
|
|
36
|
+
width: 32px;
|
|
37
|
+
height: 32px;
|
|
38
|
+
border: 3px solid #e2ddd7;
|
|
39
|
+
border-top-color: #c45d3e;
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
animation: sk-boot-spin 0.8s linear infinite;
|
|
42
|
+
}
|
|
43
|
+
@keyframes sk-boot-spin {
|
|
44
|
+
to { transform: rotate(360deg); }
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
|
|
49
|
+
<body>
|
|
50
|
+
<div id="setzkasten-admin">
|
|
51
|
+
<div class="sk-boot-loading">
|
|
52
|
+
<div class="sk-boot-spinner"></div>
|
|
53
|
+
<div style="font-size: 14px; color: #64748b;">Lade Setzkasten...</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
import { createElement } from 'react'
|
|
59
|
+
import { createRoot } from 'react-dom/client'
|
|
60
|
+
import { SetzKastenProvider, AdminApp, ProxyContentRepository, ProxyAssetStore } from '@setzkasten-cms/ui'
|
|
61
|
+
import '@setzkasten-cms/ui/styles/admin.css'
|
|
62
|
+
|
|
63
|
+
async function boot() {
|
|
64
|
+
const root = document.getElementById('setzkasten-admin')
|
|
65
|
+
if (!root) return
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const injected = (globalThis as any).__SETZKASTEN_CONFIG__ ?? {}
|
|
69
|
+
|
|
70
|
+
const providers: Array<'github' | 'google'> = []
|
|
71
|
+
if (injected.hasGitHub) providers.push('github')
|
|
72
|
+
if (injected.hasGoogle) providers.push('google')
|
|
73
|
+
if (providers.length === 0) providers.push('github')
|
|
74
|
+
|
|
75
|
+
// Fetch the full user config from server
|
|
76
|
+
let userConfig: any = null
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch('/api/setzkasten/config')
|
|
79
|
+
if (res.ok) userConfig = await res.json()
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
82
|
+
const skConfig = userConfig ?? {
|
|
83
|
+
storage: { kind: 'github' as const },
|
|
84
|
+
auth: { providers },
|
|
85
|
+
theme: {},
|
|
86
|
+
products: {},
|
|
87
|
+
collections: {},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Storage params come from the config API (server-injected via SSR)
|
|
91
|
+
const storage = userConfig?._storage ?? injected.storage ?? {}
|
|
92
|
+
const owner = storage.owner ?? ''
|
|
93
|
+
const repo = storage.repo ?? ''
|
|
94
|
+
const branch = storage.branch ?? 'main'
|
|
95
|
+
const contentPath = storage.contentPath ?? 'content'
|
|
96
|
+
|
|
97
|
+
const repository = new ProxyContentRepository({
|
|
98
|
+
proxyBaseUrl: '/api/setzkasten/github',
|
|
99
|
+
owner,
|
|
100
|
+
repo,
|
|
101
|
+
branch,
|
|
102
|
+
contentPath,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const assetsPath = storage.assetsPath ?? 'public/images'
|
|
106
|
+
const assets = new ProxyAssetStore({
|
|
107
|
+
proxyBaseUrl: '/api/setzkasten/github',
|
|
108
|
+
owner,
|
|
109
|
+
repo,
|
|
110
|
+
branch,
|
|
111
|
+
assetsPath,
|
|
112
|
+
publicUrlPrefix: '/images',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const auth = {
|
|
116
|
+
async login() { window.location.href = '/api/setzkasten/auth/login?provider=github' },
|
|
117
|
+
async logout() { window.location.href = '/api/setzkasten/auth/logout' },
|
|
118
|
+
async getSession() {
|
|
119
|
+
const res = await fetch('/api/setzkasten/auth/session')
|
|
120
|
+
return res.json()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reactRoot = createRoot(root)
|
|
125
|
+
reactRoot.render(
|
|
126
|
+
createElement(
|
|
127
|
+
SetzKastenProvider,
|
|
128
|
+
{ config: skConfig, repository, auth, assets },
|
|
129
|
+
createElement(AdminApp)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error('[setzkasten] Boot failed:', error)
|
|
134
|
+
root.innerHTML = `
|
|
135
|
+
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;">
|
|
136
|
+
<p style="color:#ef4444;font-size:14px;">Fehler beim Laden des Admin-Panels.</p>
|
|
137
|
+
<a href="/" style="color:#64748b;font-size:13px;">Zur Startseite</a>
|
|
138
|
+
</div>
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
boot()
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asset proxy – serves images from the private GitHub repo.
|
|
5
|
+
* Used by the admin UI to display image thumbnails without exposing the GitHub token.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/asset/public/images/about/LP_Logo.png
|
|
8
|
+
* → fetches from GitHub API and returns the raw binary with correct Content-Type.
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ params, cookies }) => {
|
|
11
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
12
|
+
if (!session) {
|
|
13
|
+
return new Response('Unauthorized', { status: 401 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const assetPath = params.path
|
|
17
|
+
if (!assetPath) {
|
|
18
|
+
return new Response('Missing path', { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
|
|
22
|
+
if (!githubToken) {
|
|
23
|
+
return new Response('GitHub token not configured', { status: 500 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
27
|
+
if (!config?.storage) {
|
|
28
|
+
return new Response('Storage not configured', { status: 500 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { owner, repo, branch } = config.storage
|
|
32
|
+
const githubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${assetPath}?ref=${branch}`
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(githubUrl, {
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${githubToken}`,
|
|
38
|
+
Accept: 'application/vnd.github.raw+json',
|
|
39
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return new Response('Asset not found', { status: response.status })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const contentType = guessMimeType(assetPath)
|
|
48
|
+
const body = await response.arrayBuffer()
|
|
49
|
+
|
|
50
|
+
return new Response(body, {
|
|
51
|
+
status: 200,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': contentType,
|
|
54
|
+
'Cache-Control': 'private, max-age=300',
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('[setzkasten] Asset proxy error:', error)
|
|
59
|
+
return new Response('Failed to fetch asset', { status: 502 })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function guessMimeType(path: string): string {
|
|
64
|
+
const ext = path.split('.').pop()?.toLowerCase()
|
|
65
|
+
const types: Record<string, string> = {
|
|
66
|
+
jpg: 'image/jpeg',
|
|
67
|
+
jpeg: 'image/jpeg',
|
|
68
|
+
png: 'image/png',
|
|
69
|
+
gif: 'image/gif',
|
|
70
|
+
webp: 'image/webp',
|
|
71
|
+
avif: 'image/avif',
|
|
72
|
+
svg: 'image/svg+xml',
|
|
73
|
+
pdf: 'application/pdf',
|
|
74
|
+
}
|
|
75
|
+
return types[ext ?? ''] ?? 'application/octet-stream'
|
|
76
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
+
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OAuth callback handler.
|
|
7
|
+
* Receives the authorization code from GitHub/Google, exchanges it for a
|
|
8
|
+
* session via the auth adapter, and sets a signed session cookie.
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
|
+
const code = url.searchParams.get('code')
|
|
12
|
+
const state = url.searchParams.get('state')
|
|
13
|
+
|
|
14
|
+
if (!code) {
|
|
15
|
+
return new Response('Missing authorization code', { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Parse stored state (may contain provider info)
|
|
19
|
+
const storedRaw = cookies.get('setzkasten_oauth_state')?.value
|
|
20
|
+
let storedState: string | undefined
|
|
21
|
+
let provider = 'github'
|
|
22
|
+
if (storedRaw) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(storedRaw)
|
|
25
|
+
storedState = parsed.state
|
|
26
|
+
provider = parsed.provider ?? 'github'
|
|
27
|
+
} catch {
|
|
28
|
+
storedState = storedRaw
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CSRF: verify state matches stored state
|
|
33
|
+
if (state && storedState && state !== storedState) {
|
|
34
|
+
return new Response('Invalid state parameter', { status: 400 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clear the state cookie
|
|
38
|
+
cookies.delete('setzkasten_oauth_state', { path: '/' })
|
|
39
|
+
|
|
40
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
41
|
+
| { adminPath: string }
|
|
42
|
+
| undefined
|
|
43
|
+
|
|
44
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
45
|
+
|
|
46
|
+
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
47
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
48
|
+
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
49
|
+
const origin = `${protocol}://${host}`
|
|
50
|
+
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
51
|
+
|
|
52
|
+
// Read allowed emails from env (comma-separated)
|
|
53
|
+
const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
|
|
54
|
+
const allowedEmails = allowedEmailsRaw
|
|
55
|
+
? allowedEmailsRaw.split(',').map((e: string) => e.trim())
|
|
56
|
+
: undefined
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
let sessionResult
|
|
60
|
+
|
|
61
|
+
if (provider === 'google') {
|
|
62
|
+
const auth = createGoogleAuth({
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!sessionResult.ok) {
|
|
80
|
+
console.error('[setzkasten] Auth failed:', sessionResult.error.message)
|
|
81
|
+
return new Response(`Authentication failed: ${sessionResult.error.message}`, { status: 403 })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const session = sessionResult.value
|
|
85
|
+
|
|
86
|
+
// Set session cookie with user info (HMAC-signed in production)
|
|
87
|
+
const sessionPayload = JSON.stringify({
|
|
88
|
+
user: session.user,
|
|
89
|
+
expiresAt: session.expiresAt,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
cookies.set('setzkasten_session', sessionPayload, {
|
|
93
|
+
httpOnly: true,
|
|
94
|
+
secure: import.meta.env.PROD,
|
|
95
|
+
sameSite: 'lax',
|
|
96
|
+
path: '/',
|
|
97
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return redirect(adminPath)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('[setzkasten] Auth callback error:', error)
|
|
103
|
+
return new Response('Authentication failed', { status: 500 })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
|
+
import { createGoogleAuth } from '@setzkasten-cms/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Login initiation – redirects the user to the chosen OAuth provider.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/setzkasten/auth/login?provider=github|google
|
|
9
|
+
*/
|
|
10
|
+
export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
11
|
+
const provider = url.searchParams.get('provider') ?? 'github'
|
|
12
|
+
|
|
13
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
14
|
+
| { adminPath: string; hasGitHub: boolean; hasGoogle: boolean }
|
|
15
|
+
| undefined
|
|
16
|
+
|
|
17
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
18
|
+
|
|
19
|
+
// On Vercel, url.origin may resolve to localhost. Use the Host header instead.
|
|
20
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
21
|
+
const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
|
|
22
|
+
const origin = `${protocol}://${host}`
|
|
23
|
+
const redirectUri = `${origin}/api/setzkasten/auth/callback`
|
|
24
|
+
|
|
25
|
+
let loginUrl: string
|
|
26
|
+
|
|
27
|
+
if (provider === 'google') {
|
|
28
|
+
const googleClientId = import.meta.env.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID ?? ''
|
|
29
|
+
const googleClientSecret = import.meta.env.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET ?? ''
|
|
30
|
+
|
|
31
|
+
if (!googleClientId || !googleClientSecret) {
|
|
32
|
+
return new Response('Google OAuth not configured', { status: 500 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const auth = createGoogleAuth({
|
|
36
|
+
clientId: googleClientId,
|
|
37
|
+
clientSecret: googleClientSecret,
|
|
38
|
+
redirectUri,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
loginUrl = auth.getLoginUrl('google')
|
|
42
|
+
|
|
43
|
+
// Store the PKCE code verifier in a cookie for the callback
|
|
44
|
+
// Arctic generates it internally – we extract it from the URL
|
|
45
|
+
const urlObj = new URL(loginUrl)
|
|
46
|
+
const state = urlObj.searchParams.get('state')
|
|
47
|
+
if (state) {
|
|
48
|
+
cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'google' }), {
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
secure: true,
|
|
51
|
+
sameSite: 'lax',
|
|
52
|
+
path: '/',
|
|
53
|
+
maxAge: 600, // 10 min
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// Default: GitHub
|
|
58
|
+
const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
|
|
59
|
+
const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
|
|
60
|
+
|
|
61
|
+
if (!ghClientId || !ghClientSecret) {
|
|
62
|
+
return new Response('GitHub OAuth not configured', { status: 500 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const auth = createGitHubAuth({
|
|
66
|
+
clientId: ghClientId,
|
|
67
|
+
clientSecret: ghClientSecret,
|
|
68
|
+
redirectUri,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
loginUrl = auth.getLoginUrl('github')
|
|
72
|
+
|
|
73
|
+
const urlObj = new URL(loginUrl)
|
|
74
|
+
const state = urlObj.searchParams.get('state')
|
|
75
|
+
if (state) {
|
|
76
|
+
cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'github' }), {
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
secure: true,
|
|
79
|
+
sameSite: 'lax',
|
|
80
|
+
path: '/',
|
|
81
|
+
maxAge: 600,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return redirect(loginUrl)
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session check – returns current user info or 401.
|
|
5
|
+
* Used by the admin SPA to check if the user is logged in.
|
|
6
|
+
*/
|
|
7
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
8
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
9
|
+
if (!session) {
|
|
10
|
+
return new Response(JSON.stringify({ authenticated: false }), {
|
|
11
|
+
status: 401,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(session) as { user: unknown; expiresAt: number }
|
|
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
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the full SetzKastenConfig as JSON.
|
|
5
|
+
* The config is injected into globalThis by the integration at build time.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/config
|
|
8
|
+
*/
|
|
9
|
+
export const GET: APIRoute = async () => {
|
|
10
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
|
|
11
|
+
const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
|
|
12
|
+
|
|
13
|
+
const result = {
|
|
14
|
+
storage: { kind: 'github' },
|
|
15
|
+
auth: { providers: ['github'] },
|
|
16
|
+
theme: {},
|
|
17
|
+
products: {},
|
|
18
|
+
collections: {},
|
|
19
|
+
...config,
|
|
20
|
+
// Include storage params so the client can create ProxyContentRepository
|
|
21
|
+
_storage: ssrConfig?.storage ?? undefined,
|
|
22
|
+
_hasGitHub: ssrConfig?.hasGitHub ?? false,
|
|
23
|
+
_hasGoogle: ssrConfig?.hasGoogle ?? false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Response(JSON.stringify(result), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { setDraft, clearDraft } from '../draft-store'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Draft content API for Live SSR Preview.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/draft
|
|
8
|
+
* Body: { section: string, values: Record<string, unknown> }
|
|
9
|
+
* Stores draft section content for the current session.
|
|
10
|
+
*
|
|
11
|
+
* DELETE /api/setzkasten/draft
|
|
12
|
+
* Body: { section?: string }
|
|
13
|
+
* Clears draft(s) for the current session.
|
|
14
|
+
*/
|
|
15
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
16
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
17
|
+
if (!session) {
|
|
18
|
+
return new Response('Unauthorized', { status: 401 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const body = (await request.json()) as {
|
|
23
|
+
section: string
|
|
24
|
+
values: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!body.section || typeof body.values !== 'object') {
|
|
28
|
+
return new Response('Bad Request: section and values required', { status: 400 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setDraft(session, body.section, body.values)
|
|
32
|
+
|
|
33
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
})
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('[setzkasten] Draft API error:', error)
|
|
39
|
+
return new Response('Internal error', { status: 500 })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
44
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
45
|
+
if (!session) {
|
|
46
|
+
return new Response('Unauthorized', { status: 401 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const body = (await request.json().catch(() => ({}))) as { section?: string }
|
|
51
|
+
clearDraft(session, body.section)
|
|
52
|
+
|
|
53
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('[setzkasten] Draft clear error:', error)
|
|
59
|
+
return new Response('Internal error', { status: 500 })
|
|
60
|
+
}
|
|
61
|
+
}
|