@setzkasten-cms/astro-admin 0.6.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -6
- package/src/admin-page.astro +9 -8
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
- package/src/api-routes/__tests__/github-cache.test.ts +100 -0
- package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/pages.test.ts +72 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +153 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +64 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +21 -53
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +71 -0
- package/src/api-routes/catalog-add.ts +18 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +17 -5
- package/src/api-routes/editors.ts +205 -0
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +149 -0
- package/src/api-routes/init-add-section.ts +21 -10
- package/src/api-routes/init-apply.ts +7 -4
- package/src/api-routes/init-migrate.ts +9 -6
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +138 -6
- package/src/api-routes/section-add.ts +23 -5
- package/src/api-routes/section-commit-pending.ts +28 -5
- package/src/api-routes/section-delete.ts +24 -5
- package/src/api-routes/section-duplicate.ts +25 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +12 -4
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +53 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-check.ts +49 -0
- package/src/api-routes/updater-register.ts +90 -0
- package/src/api-routes/updater-transfer.ts +51 -0
- package/src/api-routes/updater-unbind.ts +59 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/page-level.test.ts +47 -0
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/__tests__/section-pipeline.test.ts +3 -1
- package/src/init/astro-section-analyzer-v2.ts +29 -2
- package/src/init/template-patcher-v2.ts +100 -0
- package/LICENSE +0 -37
package/src/api-routes/pages.ts
CHANGED
|
@@ -1,17 +1,149 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
3
|
+
import { readPagesMeta, type PagesMetaTarget } from './_pages-meta-store'
|
|
4
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
5
|
+
import { cachedFetch } from './_github-cache'
|
|
6
|
+
|
|
7
|
+
interface PageInfo {
|
|
8
|
+
path: string
|
|
9
|
+
pageKey: string
|
|
10
|
+
label: string
|
|
11
|
+
hasConfig: boolean
|
|
12
|
+
/** Unix-ms timestamp of the page's last Setzkasten-driven commit, when
|
|
13
|
+
* `_pages-meta.json` knows about the page. */
|
|
14
|
+
lastModified?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Build-time constant injected by the Vite define plugin — always available in
|
|
18
|
+
// compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
|
|
19
|
+
declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
|
|
2
20
|
|
|
3
21
|
/**
|
|
4
|
-
* Returns the list of pages
|
|
5
|
-
*
|
|
6
|
-
*
|
|
22
|
+
* Returns the list of pages scanned at build time.
|
|
23
|
+
* Reads the Vite build-time constant first; falls back to globalThis for
|
|
24
|
+
* local dev / test environments where the define is not applied.
|
|
7
25
|
*
|
|
26
|
+
* Only valid in single-mode where the admin and the website share the same
|
|
27
|
+
* Astro project. Multi-mode has to fetch the page list per website at
|
|
28
|
+
* runtime (see {@link fetchPagesFromGitHub}).
|
|
29
|
+
*/
|
|
30
|
+
export function resolvePages(): PageInfo[] {
|
|
31
|
+
const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
|
|
32
|
+
return buildPages ?? (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[] ?? []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
8
36
|
* GET /api/setzkasten/pages
|
|
37
|
+
*
|
|
38
|
+
* Single-mode: returns the build-time scan from the admin's own Astro
|
|
39
|
+
* project, enriched with `_pages-meta.json` timestamps from the
|
|
40
|
+
* (single) repo.
|
|
41
|
+
*
|
|
42
|
+
* Multi-mode: the X-SK-Website header selects one of the registered
|
|
43
|
+
* websites. The admin doesn't have build-time access to that website's
|
|
44
|
+
* `src/pages/` directory, so we fetch it via the GitHub Contents API
|
|
45
|
+
* (cached for 5 min), then enrich with the per-website
|
|
46
|
+
* `_pages-meta.json`. Without this branch, every website in Multi-Mode
|
|
47
|
+
* would see the admin's own page list — typically a single "index"
|
|
48
|
+
* stub — instead of its real pages.
|
|
9
49
|
*/
|
|
10
|
-
export const GET: APIRoute = async () => {
|
|
11
|
-
const
|
|
50
|
+
export const GET: APIRoute = async ({ request }) => {
|
|
51
|
+
const isMulti = request.headers.get('x-sk-website') !== null
|
|
52
|
+
const pages = isMulti
|
|
53
|
+
? await fetchPagesFromGitHub(request).catch(() => [])
|
|
54
|
+
: resolvePages()
|
|
55
|
+
|
|
56
|
+
const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
|
|
12
57
|
|
|
13
|
-
return new Response(JSON.stringify({ pages }), {
|
|
58
|
+
return new Response(JSON.stringify({ pages: enriched }), {
|
|
14
59
|
status: 200,
|
|
15
60
|
headers: { 'Content-Type': 'application/json' },
|
|
16
61
|
})
|
|
17
62
|
}
|
|
63
|
+
|
|
64
|
+
interface ContentsApiEntry {
|
|
65
|
+
type: 'file' | 'dir' | 'symlink' | 'submodule'
|
|
66
|
+
name: string
|
|
67
|
+
path: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lists the website's `src/pages/` directory via the GitHub Contents API
|
|
72
|
+
* and turns it into PageInfo entries. Only top-level `.astro` files
|
|
73
|
+
* (excluding `_layout.astro` and other underscore-prefixed privates and
|
|
74
|
+
* dynamic `[slug].astro` routes) become editable pages.
|
|
75
|
+
*
|
|
76
|
+
* Cached for 5 minutes per (owner, repo, branch) — the page list is a
|
|
77
|
+
* structural change that rarely happens during a normal editing session.
|
|
78
|
+
*/
|
|
79
|
+
async function fetchPagesFromGitHub(request: Request): Promise<PageInfo[]> {
|
|
80
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
81
|
+
if (!storage) return []
|
|
82
|
+
|
|
83
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
84
|
+
if (!tokenResult.ok) return []
|
|
85
|
+
|
|
86
|
+
const { owner, repo, branch } = storage
|
|
87
|
+
const cacheKey = `pages-list:${owner}/${repo}:${branch}`
|
|
88
|
+
return cachedFetch(cacheKey, 5 * 60_000, async () => {
|
|
89
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/contents/src/pages?ref=${branch}`
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
93
|
+
Accept: 'application/vnd.github+json',
|
|
94
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) return []
|
|
98
|
+
const entries = (await res.json()) as ContentsApiEntry[]
|
|
99
|
+
if (!Array.isArray(entries)) return []
|
|
100
|
+
|
|
101
|
+
const pages: PageInfo[] = []
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry.type !== 'file') continue
|
|
104
|
+
if (!entry.name.endsWith('.astro')) continue
|
|
105
|
+
// Skip privates (_layout.astro etc.) and dynamic routes ([slug].astro).
|
|
106
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('[')) continue
|
|
107
|
+
const pageKey = entry.name.slice(0, -'.astro'.length)
|
|
108
|
+
pages.push({
|
|
109
|
+
path: entry.path,
|
|
110
|
+
pageKey,
|
|
111
|
+
label: pageKey === 'index' ? 'Startseite' : pageKey,
|
|
112
|
+
hasConfig: true,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return pages
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function enrichWithLastModified(
|
|
120
|
+
pages: PageInfo[],
|
|
121
|
+
request: Request,
|
|
122
|
+
): Promise<PageInfo[]> {
|
|
123
|
+
if (pages.length === 0) return pages
|
|
124
|
+
|
|
125
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
126
|
+
if (!storage) return pages
|
|
127
|
+
|
|
128
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
129
|
+
if (!tokenResult.ok) return pages
|
|
130
|
+
|
|
131
|
+
const serverConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
132
|
+
| { storage?: { contentPath?: string } }
|
|
133
|
+
| undefined
|
|
134
|
+
const target: PagesMetaTarget = {
|
|
135
|
+
owner: storage.owner,
|
|
136
|
+
repo: storage.repo,
|
|
137
|
+
branch: storage.branch,
|
|
138
|
+
contentPath: serverConfig?.storage?.contentPath ?? 'content',
|
|
139
|
+
token: tokenResult.value,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const meta = await readPagesMeta(target)
|
|
143
|
+
if (!meta.ok) return pages
|
|
144
|
+
|
|
145
|
+
return pages.map((p) => {
|
|
146
|
+
const ts = meta.value.meta.pages[p.pageKey]?.lastModified
|
|
147
|
+
return ts !== undefined ? { ...p, lastModified: ts } : p
|
|
148
|
+
})
|
|
149
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
|
-
import {
|
|
2
|
+
import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
|
|
3
3
|
import { generateAddKey, addToPageConfig } from './section-management'
|
|
4
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* POST /api/setzkasten/sections/add
|
|
@@ -19,8 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
19
22
|
const session = cookies.get('setzkasten_session')?.value
|
|
20
23
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
24
|
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
25
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
26
|
+
if (!tokenResult.ok) {
|
|
27
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
28
|
+
}
|
|
29
|
+
const githubToken = tokenResult.value
|
|
24
30
|
|
|
25
31
|
try {
|
|
26
32
|
const body = await request.json() as {
|
|
@@ -34,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
34
40
|
contentPath?: string
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
const storage =
|
|
43
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
38
44
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
39
45
|
const { owner, repo, branch, projectPrefix } = storage
|
|
40
46
|
|
|
@@ -47,6 +53,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
47
53
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
57
|
+
if (denied) return denied
|
|
58
|
+
|
|
50
59
|
const headers = {
|
|
51
60
|
Authorization: `Bearer ${githubToken}`,
|
|
52
61
|
Accept: 'application/vnd.github+json',
|
|
@@ -97,12 +106,21 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
97
106
|
{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
|
|
98
107
|
{ path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
|
|
99
108
|
],
|
|
100
|
-
|
|
109
|
+
withTrailers(
|
|
110
|
+
`content: add ${sectionType} section "${newKey}" to ${pageKey}`,
|
|
111
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
112
|
+
),
|
|
101
113
|
headers,
|
|
102
114
|
)
|
|
103
115
|
|
|
104
116
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
105
117
|
|
|
118
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
119
|
+
await recordPageEdit(
|
|
120
|
+
{ owner, repo, branch, contentPath, token: tokenResult.value },
|
|
121
|
+
pageKey,
|
|
122
|
+
).catch(() => {})
|
|
123
|
+
|
|
106
124
|
return Response.json({ success: true, newKey, commitSha: commitResult.sha })
|
|
107
125
|
} catch (error) {
|
|
108
126
|
console.error('[setzkasten] section-add error:', error)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
5
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
6
|
+
import { withTrailers } from './_commit-trailers'
|
|
7
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* POST /api/setzkasten/sections/commit-pending
|
|
@@ -20,8 +23,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
20
23
|
const session = cookies.get('setzkasten_session')?.value
|
|
21
24
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
22
25
|
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
26
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
27
|
+
if (!tokenResult.ok) {
|
|
28
|
+
return new Response(tokenResult.error.message, { status: 500 })
|
|
29
|
+
}
|
|
30
|
+
const githubToken = tokenResult.value
|
|
25
31
|
|
|
26
32
|
try {
|
|
27
33
|
const body = await request.json() as {
|
|
@@ -35,11 +41,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
35
41
|
contentPath?: string
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
const storage =
|
|
44
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
39
45
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
40
46
|
const { owner, repo, branch } = storage
|
|
41
47
|
|
|
42
48
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
49
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
43
50
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
44
51
|
const { pageKey, pageConfig, sections, edits = [] } = body
|
|
45
52
|
|
|
@@ -47,6 +54,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
47
54
|
return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
58
|
+
if (denied) return denied
|
|
59
|
+
|
|
50
60
|
const headers = {
|
|
51
61
|
Authorization: `Bearer ${githubToken}`,
|
|
52
62
|
Accept: 'application/vnd.github+json',
|
|
@@ -79,7 +89,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
79
89
|
const keys = edits.map(s => s.key).join(', ')
|
|
80
90
|
parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
|
|
81
91
|
}
|
|
82
|
-
const
|
|
92
|
+
const editorEmail = parseSession(cookies.get('setzkasten_session')?.value)?.user?.email
|
|
93
|
+
const commitMessage = withTrailers(
|
|
94
|
+
`content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`,
|
|
95
|
+
editorEmail,
|
|
96
|
+
)
|
|
83
97
|
const commitResult = await batchCommit(owner, repo, branch, files, commitMessage, headers)
|
|
84
98
|
|
|
85
99
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
@@ -96,6 +110,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
96
110
|
)
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
// Best-effort recency tracking. Metadata write must not derail the
|
|
114
|
+
// primary save — surface failures via the trailing return only.
|
|
115
|
+
const { recordPageEdit } = await import('./_pages-meta-store.js')
|
|
116
|
+
const metaContentPath: string = serverConfig?.storage?.contentPath ?? 'content'
|
|
117
|
+
await recordPageEdit(
|
|
118
|
+
{ owner, repo, branch, contentPath: metaContentPath, token: tokenResult.value },
|
|
119
|
+
pageKey,
|
|
120
|
+
).catch(() => {})
|
|
121
|
+
|
|
99
122
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
100
123
|
} catch (error) {
|
|
101
124
|
console.error('[setzkasten] section-commit-pending error:', error)
|
|
@@ -1,6 +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
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* DELETE /api/setzkasten/sections
|
|
@@ -16,8 +19,11 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
16
19
|
const session = cookies.get('setzkasten_session')?.value
|
|
17
20
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
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
|
|
21
27
|
|
|
22
28
|
try {
|
|
23
29
|
const body = await request.json() as {
|
|
@@ -29,11 +35,12 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
29
35
|
contentPath?: string
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
const storage =
|
|
38
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
33
39
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
34
40
|
const { owner, repo, branch, projectPrefix } = storage
|
|
35
41
|
|
|
36
42
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
43
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
37
44
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
38
45
|
const { pageKey, sectionKey } = body
|
|
39
46
|
|
|
@@ -41,6 +48,9 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
41
48
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
52
|
+
if (denied) return denied
|
|
53
|
+
|
|
44
54
|
const headers = {
|
|
45
55
|
Authorization: `Bearer ${githubToken}`,
|
|
46
56
|
Accept: 'application/vnd.github+json',
|
|
@@ -65,12 +75,21 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
|
|
|
65
75
|
owner, repo, branch,
|
|
66
76
|
[{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
|
|
67
77
|
[sectionJsonPath],
|
|
68
|
-
|
|
78
|
+
withTrailers(
|
|
79
|
+
`content: remove ${sectionKey} section from ${pageKey}`,
|
|
80
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
81
|
+
),
|
|
69
82
|
headers,
|
|
70
83
|
)
|
|
71
84
|
|
|
72
85
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
73
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
|
+
|
|
74
93
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
75
94
|
} catch (error) {
|
|
76
95
|
console.error('[setzkasten] section-delete error:', error)
|
|
@@ -1,6 +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
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { withTrailers } from './_commit-trailers'
|
|
6
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* POST /api/setzkasten/sections/duplicate
|
|
@@ -16,8 +19,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
16
19
|
const session = cookies.get('setzkasten_session')?.value
|
|
17
20
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
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
|
|
21
27
|
|
|
22
28
|
try {
|
|
23
29
|
const body = await request.json() as {
|
|
@@ -29,11 +35,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
29
35
|
contentPath?: string
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
const storage =
|
|
38
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
33
39
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
34
40
|
const { owner, repo, branch, projectPrefix } = storage
|
|
35
41
|
|
|
36
42
|
const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
|
|
43
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
|
|
37
44
|
const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
|
|
38
45
|
const { pageKey, sectionKey } = body
|
|
39
46
|
|
|
@@ -41,6 +48,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
41
48
|
return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
52
|
+
if (denied) return denied
|
|
53
|
+
|
|
44
54
|
const headers = {
|
|
45
55
|
Authorization: `Bearer ${githubToken}`,
|
|
46
56
|
Accept: 'application/vnd.github+json',
|
|
@@ -77,10 +87,20 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
|
|
80
|
-
|
|
90
|
+
withTrailers(
|
|
91
|
+
`content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`,
|
|
92
|
+
parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
|
|
93
|
+
),
|
|
94
|
+
headers)
|
|
81
95
|
|
|
82
96
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
83
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
|
+
|
|
84
104
|
return Response.json({ success: true, newKey, commitSha: commitResult.sha })
|
|
85
105
|
} catch (error) {
|
|
86
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,6 +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
|
+
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
5
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* POST /api/setzkasten/sections/prepare
|
|
@@ -19,8 +21,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
19
21
|
const session = cookies.get('setzkasten_session')?.value
|
|
20
22
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
21
23
|
|
|
22
|
-
const
|
|
23
|
-
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
|
|
24
29
|
|
|
25
30
|
try {
|
|
26
31
|
const body = await request.json() as {
|
|
@@ -32,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
32
37
|
contentPath?: string
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
const storage =
|
|
40
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
36
41
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
37
42
|
const { owner, repo, branch } = storage
|
|
38
43
|
|
|
@@ -45,6 +50,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
45
50
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
54
|
+
if (denied) return denied
|
|
55
|
+
|
|
48
56
|
// 1. Read current page config from GitHub to determine existing keys
|
|
49
57
|
const configKey = '_' + pageKey.replace(/\//g, '_')
|
|
50
58
|
const pageConfigPath = `${contentPath}/pages/${configKey}.json`
|
|
@@ -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
|
+
}
|