@setzkasten-cms/astro-admin 0.8.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
- package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +152 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +134 -13
- package/src/api-routes/_feature-gate.ts +39 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +77 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +8 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +37 -11
- package/src/api-routes/catalog-add.ts +9 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +12 -5
- package/src/api-routes/editors.ts +94 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/init-add-section.ts +13 -5
- package/src/api-routes/init-apply.ts +5 -3
- package/src/api-routes/init-migrate.ts +7 -5
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +118 -4
- package/src/api-routes/section-add.ts +15 -5
- package/src/api-routes/section-commit-pending.ts +117 -5
- package/src/api-routes/section-delete.ts +29 -5
- package/src/api-routes/section-duplicate.ts +15 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +9 -5
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +71 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-register.ts +37 -25
- package/src/api-routes/updater-transfer.ts +1 -12
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +42 -4
- package/LICENSE +0 -37
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { removeFromPageConfig } from './section-management'
|
|
4
4
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
5
|
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* DELETE /api/setzkasten/sections
|
|
@@ -18,8 +19,11 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
18
19
|
const session = cookies.get('setzkasten_session')?.value
|
|
19
20
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
22
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
23
|
+
if (!tokenResult.ok) {
|
|
24
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
25
|
+
}
|
|
26
|
+
const githubToken = tokenResult.value
|
|
23
27
|
|
|
24
28
|
try {
|
|
25
29
|
const body = await request.json() as {
|
|
@@ -31,7 +35,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
31
35
|
contentPath?: string
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const storage =
|
|
38
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
35
39
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
36
40
|
const { owner, repo, branch, projectPrefix } = storage
|
|
37
41
|
|
|
@@ -44,7 +48,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
44
48
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
51
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
48
52
|
if (denied) return denied
|
|
49
53
|
|
|
50
54
|
const headers = {
|
|
@@ -80,6 +84,26 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
80
84
|
|
|
81
85
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
82
86
|
|
|
87
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
88
|
+
await recordPageEdit(
|
|
89
|
+
{ owner, repo, branch, contentPath, token: tokenResult.value },
|
|
90
|
+
pageKey,
|
|
91
|
+
).catch(() => {})
|
|
92
|
+
|
|
93
|
+
// Fire content.delete webhooks (fire-and-forget).
|
|
94
|
+
const { fireWebhooks } = await import('./_webhook-dispatcher.js')
|
|
95
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
96
|
+
void fireWebhooks(
|
|
97
|
+
'content.delete',
|
|
98
|
+
{
|
|
99
|
+
website: { id: owner, repo: `${owner}/${repo}`, branch },
|
|
100
|
+
user: { email: session?.user?.email ?? 'unknown', name: session?.user?.name },
|
|
101
|
+
commit: { sha: commitResult.sha, message: `Delete ${sectionKey} from ${pageKey}` },
|
|
102
|
+
files: [{ path: sectionJsonPath }],
|
|
103
|
+
},
|
|
104
|
+
request,
|
|
105
|
+
)
|
|
106
|
+
|
|
83
107
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
84
108
|
} catch (error) {
|
|
85
109
|
console.error('[setzkasten] section-delete error:', error)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
|
|
4
4
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
5
|
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/setzkasten/sections/duplicate
|
|
@@ -18,8 +19,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
18
19
|
const session = cookies.get('setzkasten_session')?.value
|
|
19
20
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
22
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
23
|
+
if (!tokenResult.ok) {
|
|
24
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
25
|
+
}
|
|
26
|
+
const githubToken = tokenResult.value
|
|
23
27
|
|
|
24
28
|
try {
|
|
25
29
|
const body = await request.json() as {
|
|
@@ -31,7 +35,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
31
35
|
contentPath?: string
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const storage =
|
|
38
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
35
39
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
36
40
|
const { owner, repo, branch, projectPrefix } = storage
|
|
37
41
|
|
|
@@ -44,7 +48,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
44
48
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
51
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
48
52
|
if (denied) return denied
|
|
49
53
|
|
|
50
54
|
const headers = {
|
|
@@ -91,6 +95,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
91
95
|
|
|
92
96
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
93
97
|
|
|
98
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
99
|
+
await recordPageEdit(
|
|
100
|
+
{ owner, repo, branch, contentPath, token: tokenResult.value },
|
|
101
|
+
pageKey,
|
|
102
|
+
).catch(() => {})
|
|
103
|
+
|
|
94
104
|
return Response.json({ success: true, newKey, commitSha: commitResult.sha })
|
|
95
105
|
} catch (error) {
|
|
96
106
|
console.error('[setzkasten] section-duplicate error:', error)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
|
|
4
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* POST /api/setzkasten/sections/prepare-copy
|
|
@@ -13,14 +14,24 @@ import { generateDuplicateKey, duplicateInPageConfig } from './section-managemen
|
|
|
13
14
|
* The client uses this to update local state + preview draft immediately.
|
|
14
15
|
* Only committed to GitHub when the user presses "Live setzen".
|
|
15
16
|
*
|
|
17
|
+
* Note: this route intentionally does NOT call recordPageEdit. The
|
|
18
|
+
* page-recency spec lists it as a "mutating route", but in practice it
|
|
19
|
+
* only reads and returns — the real GitHub commit happens later in
|
|
20
|
+
* commit-pending, which records the edit. Bumping the timestamp here
|
|
21
|
+
* would mark a page as recently-modified even when the user opens the
|
|
22
|
+
* duplicate dialog and then cancels without committing.
|
|
23
|
+
*
|
|
16
24
|
* Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
|
|
17
25
|
*/
|
|
18
26
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
19
27
|
const session = cookies.get('setzkasten_session')?.value
|
|
20
28
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
29
|
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
30
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
31
|
+
if (!tokenResult.ok) {
|
|
32
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
33
|
+
}
|
|
34
|
+
const githubToken = tokenResult.value
|
|
24
35
|
|
|
25
36
|
try {
|
|
26
37
|
const body = await request.json() as {
|
|
@@ -32,7 +43,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
32
43
|
contentPath?: string
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
const storage =
|
|
46
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
36
47
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
48
|
const { owner, repo, branch } = storage
|
|
38
49
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { generateAddKey } from './section-management'
|
|
4
4
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* POST /api/setzkasten/sections/prepare
|
|
@@ -20,8 +21,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
20
21
|
const session = cookies.get('setzkasten_session')?.value
|
|
21
22
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
24
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
25
|
+
if (!tokenResult.ok) {
|
|
26
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
27
|
+
}
|
|
28
|
+
const githubToken = tokenResult.value
|
|
25
29
|
|
|
26
30
|
try {
|
|
27
31
|
const body = await request.json() as {
|
|
@@ -33,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
33
37
|
contentPath?: string
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const storage =
|
|
40
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
37
41
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
38
42
|
const { owner, repo, branch } = storage
|
|
39
43
|
|
|
@@ -46,7 +50,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
46
50
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
53
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
50
54
|
if (denied) return denied
|
|
51
55
|
|
|
52
56
|
// 1. Read current page config from GitHub to determine existing keys
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { getPublicOrigin } from './_vercel-origin.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server-side manifest bounce for the GitHub App Manifest flow.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/setup/github-app/bounce?name=my-app
|
|
8
|
+
*
|
|
9
|
+
* Generates the GitHub App manifest JSON using the server-known origin,
|
|
10
|
+
* then returns a minimal HTML page that auto-submits a form to GitHub.
|
|
11
|
+
*/
|
|
12
|
+
export const GET: APIRoute = async ({ url, request }) => {
|
|
13
|
+
const name = url.searchParams.get('name')?.trim() || 'Setzkasten CMS'
|
|
14
|
+
const origin = getPublicOrigin(request)
|
|
15
|
+
|
|
16
|
+
const manifest = JSON.stringify({
|
|
17
|
+
name,
|
|
18
|
+
url: origin,
|
|
19
|
+
redirect_url: `${origin}/api/setzkasten/setup/github-app/callback`,
|
|
20
|
+
setup_url: `${origin}/api/setzkasten/setup/github-app/installed`,
|
|
21
|
+
setup_on_update: false,
|
|
22
|
+
public: false,
|
|
23
|
+
default_permissions: { contents: 'write' },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const safeManifest = manifest.replace(/&/g, '&').replace(/"/g, '"')
|
|
27
|
+
|
|
28
|
+
const html = `<!DOCTYPE html>
|
|
29
|
+
<html lang="de">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="UTF-8">
|
|
32
|
+
<title>Weiterleitung zu GitHub…</title>
|
|
33
|
+
<style>
|
|
34
|
+
body { font-family: sans-serif; display: flex; align-items: center;
|
|
35
|
+
justify-content: center; height: 100vh; margin: 0;
|
|
36
|
+
background: #0d1117; color: #e6edf3; }
|
|
37
|
+
p { opacity: .6; }
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<p>Weiterleitung zu GitHub…</p>
|
|
42
|
+
<form id="f" method="POST" action="https://github.com/settings/apps/new">
|
|
43
|
+
<input type="hidden" name="manifest" value="${safeManifest}">
|
|
44
|
+
</form>
|
|
45
|
+
<script>document.getElementById('f').submit()</script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>`
|
|
48
|
+
|
|
49
|
+
return new Response(html, {
|
|
50
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { listRepoBranches } from '@setzkasten-cms/github-adapter'
|
|
3
|
+
import { requireAdmin } from './_auth-guard'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/setzkasten/setup/github-app/branches?installation=<id>&repo=<owner/repo>
|
|
7
|
+
*
|
|
8
|
+
* Returns the list of branches for one repo, fetched via the installation
|
|
9
|
+
* token of the given installation. Used by the WebsitesView form so the
|
|
10
|
+
* Branch field becomes a dropdown after the user picks a repo.
|
|
11
|
+
*
|
|
12
|
+
* Admin-only — same reasoning as /setup/github-app/repos.
|
|
13
|
+
*/
|
|
14
|
+
export const GET: APIRoute = async ({ cookies, url }) => {
|
|
15
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
16
|
+
if (denied) return denied
|
|
17
|
+
|
|
18
|
+
const appId = process.env.GITHUB_APP_ID
|
|
19
|
+
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
|
|
20
|
+
if (!appId || !privateKey) {
|
|
21
|
+
return new Response(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
error:
|
|
24
|
+
'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
|
|
25
|
+
}),
|
|
26
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const installationId = url.searchParams.get('installation')
|
|
31
|
+
const repoFull = url.searchParams.get('repo')
|
|
32
|
+
if (!installationId || !repoFull) {
|
|
33
|
+
return new Response(
|
|
34
|
+
JSON.stringify({ error: 'Both ?installation and ?repo (owner/name) are required.' }),
|
|
35
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const slash = repoFull.indexOf('/')
|
|
40
|
+
if (slash <= 0 || slash === repoFull.length - 1) {
|
|
41
|
+
return new Response(
|
|
42
|
+
JSON.stringify({ error: '?repo must be in "owner/name" format.' }),
|
|
43
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
const owner = repoFull.slice(0, slash)
|
|
47
|
+
const repo = repoFull.slice(slash + 1)
|
|
48
|
+
|
|
49
|
+
const result = await listRepoBranches({ appId, privateKey }, installationId, owner, repo)
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
const status =
|
|
52
|
+
result.error.type === 'auth' ? 401 : result.error.type === 'not-found' ? 404 : 502
|
|
53
|
+
return new Response(JSON.stringify({ error: result.error.message }), {
|
|
54
|
+
status,
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return new Response(JSON.stringify({ branches: result.value }), {
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { getPublicOrigin } from './_vercel-origin.js'
|
|
3
|
+
|
|
4
|
+
const COOKIE_NAME = 'sk_app_setup'
|
|
5
|
+
const COOKIE_MAX_AGE = 600 // 10 minutes
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Receives the code from GitHub after a GitHub App Manifest creation.
|
|
9
|
+
* Exchanges it for the full app credentials (App ID, private key, slug).
|
|
10
|
+
*
|
|
11
|
+
* GET /api/setzkasten/setup/github-app/callback?code=xxx
|
|
12
|
+
*
|
|
13
|
+
* Note: uses mutable new Response() instead of Response.redirect() —
|
|
14
|
+
* Response.redirect() is immutable and prevents Astro from appending
|
|
15
|
+
* the Set-Cookie header (TypeError: immutable).
|
|
16
|
+
*/
|
|
17
|
+
export const GET: APIRoute = async ({ url, request, cookies }) => {
|
|
18
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
19
|
+
| { adminPath?: string }
|
|
20
|
+
| undefined
|
|
21
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
22
|
+
const adminUrl = new URL(adminPath, getPublicOrigin(request))
|
|
23
|
+
const code = url.searchParams.get('code')
|
|
24
|
+
|
|
25
|
+
if (!code) {
|
|
26
|
+
adminUrl.searchParams.set('github-app-error', 'missing_code')
|
|
27
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// GitHub returns a lot more than these five fields (permissions,
|
|
31
|
+
// events, html_url, …) but these are the only ones we persist for
|
|
32
|
+
// the wizard. `client_id` + `client_secret` since v1.2: the GH-App
|
|
33
|
+
// doubles as the OAuth provider for admin login, so the Manifest
|
|
34
|
+
// exchange replaces what was previously a separate OAuth-App that
|
|
35
|
+
// the user had to create on github.com/settings/developers.
|
|
36
|
+
let data: {
|
|
37
|
+
id: number
|
|
38
|
+
slug: string
|
|
39
|
+
pem: string
|
|
40
|
+
client_id: string
|
|
41
|
+
client_secret: string
|
|
42
|
+
} | null = null
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { Accept: 'application/vnd.github.v3+json' },
|
|
47
|
+
signal: AbortSignal.timeout(8000),
|
|
48
|
+
})
|
|
49
|
+
if (!response.ok) throw new Error(`GitHub returned ${response.status}`)
|
|
50
|
+
data = (await response.json()) as typeof data
|
|
51
|
+
} catch {
|
|
52
|
+
adminUrl.searchParams.set('github-app-error', 'exchange_failed')
|
|
53
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Set cookie before constructing the redirect response —
|
|
57
|
+
// Response.redirect() is immutable and blocks subsequent header writes.
|
|
58
|
+
cookies.set(
|
|
59
|
+
COOKIE_NAME,
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
appId: String(data!.id),
|
|
62
|
+
slug: data!.slug,
|
|
63
|
+
privateKey: data!.pem,
|
|
64
|
+
clientId: data!.client_id,
|
|
65
|
+
clientSecret: data!.client_secret,
|
|
66
|
+
}),
|
|
67
|
+
{ httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
71
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { getPublicOrigin } from './_vercel-origin.js'
|
|
3
|
+
|
|
4
|
+
const COOKIE_NAME = 'sk_app_setup'
|
|
5
|
+
const COOKIE_MAX_AGE = 600
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Receives the installation_id from GitHub after the user installs the App.
|
|
9
|
+
* Merges it into the existing setup cookie and redirects back to the admin.
|
|
10
|
+
*
|
|
11
|
+
* GET /api/setzkasten/setup/github-app/installed?installation_id=xxx
|
|
12
|
+
*
|
|
13
|
+
* Note: uses mutable new Response() instead of Response.redirect() —
|
|
14
|
+
* Response.redirect() is immutable and prevents Astro from appending
|
|
15
|
+
* the Set-Cookie header (TypeError: immutable).
|
|
16
|
+
*/
|
|
17
|
+
export const GET: APIRoute = async ({ url, request, cookies }) => {
|
|
18
|
+
const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
19
|
+
| { adminPath?: string }
|
|
20
|
+
| undefined
|
|
21
|
+
const adminPath = config?.adminPath ?? '/admin'
|
|
22
|
+
const adminUrl = new URL(adminPath, getPublicOrigin(request))
|
|
23
|
+
const installationId = url.searchParams.get('installation_id')
|
|
24
|
+
const existing = cookies.get(COOKIE_NAME)?.value
|
|
25
|
+
|
|
26
|
+
if (!installationId || !existing) {
|
|
27
|
+
adminUrl.searchParams.set('github-app-error', 'missing_installation')
|
|
28
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(existing) as Record<string, string>
|
|
33
|
+
cookies.set(
|
|
34
|
+
COOKIE_NAME,
|
|
35
|
+
JSON.stringify({ ...data, installationId }),
|
|
36
|
+
{ httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
|
|
37
|
+
)
|
|
38
|
+
} catch {
|
|
39
|
+
adminUrl.searchParams.set('github-app-error', 'invalid_session')
|
|
40
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { listAccessibleRepos } from '@setzkasten-cms/github-adapter'
|
|
3
|
+
import { requireAdmin } from './_auth-guard'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/setzkasten/setup/github-app/repos
|
|
7
|
+
*
|
|
8
|
+
* Returns every repository the configured GitHub App can access, flattened
|
|
9
|
+
* across all installations. Each entry includes the installationId so the
|
|
10
|
+
* "Neue Website hinzufügen" form can record which installation owns the
|
|
11
|
+
* repo without a follow-up lookup.
|
|
12
|
+
*
|
|
13
|
+
* Admin-only — editors must not be able to enumerate repos across the
|
|
14
|
+
* organization. The endpoint uses the global App credentials from the env
|
|
15
|
+
* (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY) so the user never has to type them.
|
|
16
|
+
*/
|
|
17
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
18
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
19
|
+
if (denied) return denied
|
|
20
|
+
|
|
21
|
+
const appId = process.env.GITHUB_APP_ID
|
|
22
|
+
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
|
|
23
|
+
if (!appId || !privateKey) {
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
error:
|
|
27
|
+
'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
|
|
28
|
+
}),
|
|
29
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await listAccessibleRepos({ appId, privateKey })
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
const status = result.error.type === 'auth' ? 401 : 502
|
|
36
|
+
return new Response(JSON.stringify({ error: result.error.message }), {
|
|
37
|
+
status,
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new Response(JSON.stringify({ repos: result.value }), {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Setup-Wizard: GitHub App Integration
|
|
6
|
+
*
|
|
7
|
+
* GET /api/setzkasten/setup/github-app – Status abfragen
|
|
8
|
+
* POST /api/setzkasten/setup/github-app – Verbindung testen
|
|
9
|
+
*
|
|
10
|
+
* Credentials werden NICHT persistiert – der Nutzer setzt die env vars manuell.
|
|
11
|
+
* Der POST-Endpunkt validiert die Verbindung durch einen echten Token-Request.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const GET: APIRoute = async () => {
|
|
15
|
+
const appId = process.env.GITHUB_APP_ID
|
|
16
|
+
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
|
|
17
|
+
const installationId = process.env.GITHUB_APP_INSTALLATION_ID
|
|
18
|
+
|
|
19
|
+
const configured = Boolean(appId && privateKey && installationId)
|
|
20
|
+
|
|
21
|
+
return Response.json({ configured, ...(configured ? { appId } : {}) })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
25
|
+
let body: unknown
|
|
26
|
+
try {
|
|
27
|
+
body = await request.json()
|
|
28
|
+
} catch {
|
|
29
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { appId, privateKey, installationId } =
|
|
33
|
+
(body as Record<string, unknown>) ?? {}
|
|
34
|
+
|
|
35
|
+
if (!appId || !privateKey || !installationId) {
|
|
36
|
+
return Response.json(
|
|
37
|
+
{ error: 'appId, privateKey and installationId are required' },
|
|
38
|
+
{ status: 400 },
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const client = new GitHubAppClient(
|
|
43
|
+
{
|
|
44
|
+
appId: String(appId),
|
|
45
|
+
privateKey: String(privateKey),
|
|
46
|
+
installationId: String(installationId),
|
|
47
|
+
},
|
|
48
|
+
{ owner: '', repo: '', branch: '' },
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const result = await client.getInstallationToken()
|
|
52
|
+
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
return Response.json({ ok: false, error: result.error.message }, { status: 400 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return Response.json({ ok: true })
|
|
58
|
+
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
|
|
3
|
+
// Vite-define injected by @setzkasten-cms/astro at build time. Available
|
|
4
|
+
// in API-only Vercel cold-starts where the page-ssr injectScript that
|
|
5
|
+
// writes globalThis.__SETZKASTEN_CONFIG__ does not fire.
|
|
6
|
+
declare const __SETZKASTEN_BUILD_CONFIG__: {
|
|
7
|
+
adminPath?: string
|
|
8
|
+
updaterUrl?: string
|
|
9
|
+
version?: string
|
|
10
|
+
websiteUrl?: string
|
|
11
|
+
hasGitHub?: boolean
|
|
12
|
+
storage?: {
|
|
13
|
+
owner?: string
|
|
14
|
+
repo?: string
|
|
15
|
+
branch?: string
|
|
16
|
+
contentPath?: string
|
|
17
|
+
assetsPath?: string
|
|
18
|
+
}
|
|
19
|
+
} | null | undefined
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* Registers this Setzkasten instance with the central updater backend.
|
|
5
23
|
* Called on every Dashboard load. Returns update status and license tier.
|
|
@@ -8,11 +26,6 @@ import type { APIRoute } from 'astro'
|
|
|
8
26
|
*
|
|
9
27
|
* Body (optional — for UI activation flow):
|
|
10
28
|
* { licenseEmail: string, licenseKey: string }
|
|
11
|
-
*
|
|
12
|
-
* Priority for credentials:
|
|
13
|
-
* 1. Config (`setzkasten.config.ts` → license.{email,key}) — always wins if set
|
|
14
|
-
* 2. Request body — UI activation flow, one-time
|
|
15
|
-
* 3. Firebase instance fallback — stored binding from previous activation
|
|
16
29
|
*/
|
|
17
30
|
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
18
31
|
const session = cookies.get('setzkasten_session')?.value
|
|
@@ -20,16 +33,23 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
20
33
|
return new Response('Unauthorized', { status: 401 })
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
|
|
36
|
+
// Two layers: the page-ssr injectScript writes __SETZKASTEN_CONFIG__ on
|
|
37
|
+
// globalThis (only fires for SSR-rendered pages). The Vite-define
|
|
38
|
+
// __SETZKASTEN_BUILD_CONFIG__ ships the same shape baked into the bundle
|
|
39
|
+
// and is therefore visible in API-only Vercel cold-starts where the
|
|
40
|
+
// injectScript never runs. We prefer the runtime globalThis value (it
|
|
41
|
+
// can pick up later overrides) but fall back to the build constant.
|
|
42
|
+
type ConfigShape = {
|
|
24
43
|
updaterUrl?: string
|
|
25
44
|
version?: string
|
|
26
45
|
websiteUrl?: string
|
|
27
46
|
storage?: { owner?: string; repo?: string }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
}
|
|
48
|
+
const buildConfig = (typeof __SETZKASTEN_BUILD_CONFIG__ !== 'undefined'
|
|
49
|
+
? __SETZKASTEN_BUILD_CONFIG__
|
|
50
|
+
: null) as ConfigShape | null
|
|
51
|
+
const runtimeConfig = (globalThis as any).__SETZKASTEN_CONFIG__ as ConfigShape | undefined
|
|
52
|
+
const config: ConfigShape | null = runtimeConfig ?? buildConfig
|
|
33
53
|
|
|
34
54
|
const currentVersion = config?.version ?? '0.0.0'
|
|
35
55
|
const updaterUrl = config?.updaterUrl
|
|
@@ -49,25 +69,19 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
49
69
|
const repo = config?.storage?.repo ?? ''
|
|
50
70
|
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
51
71
|
const websiteUrl = config?.websiteUrl ?? ''
|
|
52
|
-
const configLicense = fullConfig?.license
|
|
53
72
|
|
|
54
|
-
|
|
55
|
-
let
|
|
56
|
-
let uiKey: string | undefined
|
|
73
|
+
let licenseEmail: string | undefined
|
|
74
|
+
let licenseKey: string | undefined
|
|
57
75
|
try {
|
|
58
76
|
if (request.headers.get('content-type')?.includes('application/json')) {
|
|
59
77
|
const parsed = await request.json() as { licenseEmail?: string; licenseKey?: string }
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
licenseEmail = parsed.licenseEmail?.trim() || undefined
|
|
79
|
+
licenseKey = parsed.licenseKey?.trim() || undefined
|
|
62
80
|
}
|
|
63
81
|
} catch {
|
|
64
|
-
// Empty / malformed body
|
|
82
|
+
// Empty / malformed body — treat as no UI input
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
// Config wins over UI. UI only applies if config has no license.
|
|
68
|
-
const licenseEmail = configLicense?.email ?? uiEmail
|
|
69
|
-
const licenseKey = configLicense?.key ?? uiKey
|
|
70
|
-
|
|
71
85
|
try {
|
|
72
86
|
const response = await fetch(`${updaterUrl}/api/register`, {
|
|
73
87
|
method: 'POST',
|
|
@@ -78,7 +92,7 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
78
92
|
currentVersion,
|
|
79
93
|
licenseEmail,
|
|
80
94
|
licenseKey,
|
|
81
|
-
telemetryEnabled:
|
|
95
|
+
telemetryEnabled: true,
|
|
82
96
|
managedWebsites: [],
|
|
83
97
|
}),
|
|
84
98
|
signal: AbortSignal.timeout(5000),
|
|
@@ -90,8 +104,6 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
90
104
|
|
|
91
105
|
const data = await response.json() as { firebaseConfig?: { apiKey: string; authDomain: string; projectId: string }; [key: string]: unknown }
|
|
92
106
|
|
|
93
|
-
// Pass firebaseConfig through if the Updater returned one (Pro/Enterprise licenses).
|
|
94
|
-
// Writing to _global_config.json is done explicitly via the GlobalConfigView activation flow.
|
|
95
107
|
return Response.json({ ...data, currentVersion, _firebaseConfig: data.firebaseConfig ?? null })
|
|
96
108
|
} catch {
|
|
97
109
|
return Response.json({
|