@setzkasten-cms/astro-admin 0.8.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 +16 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -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__/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 +134 -13
- 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 +6 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +21 -10
- 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 +79 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- 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 +18 -5
- package/src/api-routes/section-delete.ts +15 -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 +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-register.ts +6 -23
- package/src/api-routes/updater-transfer.ts +1 -12
- 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-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +33 -0
- package/LICENSE +0 -37
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { type WebsiteEntry, canAddWebsite, isMultiModeAvailable } from '@setzkasten-cms/core'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { parseSession } from './_auth-guard'
|
|
4
|
+
import { withTrailers } from './_commit-trailers'
|
|
5
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
6
|
+
import { resolveLicenseTier } from './_license-tier'
|
|
7
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/setzkasten/migrate/to-multi
|
|
11
|
+
*
|
|
12
|
+
* Body: { configRepo: 'owner/repo', configInstallationId: string,
|
|
13
|
+
* previewOrigin?: string }
|
|
14
|
+
*
|
|
15
|
+
* Admin-only. Expects the deployer to have already (1) created the
|
|
16
|
+
* config repo and (2) installed the GitHub App on it. The endpoint then:
|
|
17
|
+
*
|
|
18
|
+
* 1. Reads `_editors.json` and `_global_config.json` from the current
|
|
19
|
+
* single-mode website repo.
|
|
20
|
+
* 2. Writes both files into `<config-repo>/content/`.
|
|
21
|
+
* 3. Initialises `<config-repo>/websites.json` with a single entry that
|
|
22
|
+
* snapshots the current single-mode setup (repo, branch, preview
|
|
23
|
+
* origin, App-Installation-ID).
|
|
24
|
+
*
|
|
25
|
+
* After a 200 response the deployer still has to update
|
|
26
|
+
* `setzkasten.config.ts` (kind: 'single' → 'multi') and the ENV
|
|
27
|
+
* variables and redeploy. The wizard surfaces a copy-ready diff for
|
|
28
|
+
* those manual steps.
|
|
29
|
+
*/
|
|
30
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
31
|
+
const session = parseSession(cookies.get('setzkasten_session')?.value)
|
|
32
|
+
if (!session) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
|
33
|
+
if (session.user.role !== 'admin')
|
|
34
|
+
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
|
35
|
+
|
|
36
|
+
const tier = resolveLicenseTier()
|
|
37
|
+
if (!isMultiModeAvailable(tier)) {
|
|
38
|
+
return new Response(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
error: 'Multi-Mode ist nur mit Pro- oder Enterprise-Lizenz verfügbar.',
|
|
41
|
+
tier,
|
|
42
|
+
}),
|
|
43
|
+
{ status: 402, headers: { 'Content-Type': 'application/json' } },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let body: { configRepo?: string; configInstallationId?: string; previewOrigin?: string } = {}
|
|
48
|
+
try {
|
|
49
|
+
body = (await request.json()) as typeof body
|
|
50
|
+
} catch {
|
|
51
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!body.configRepo || typeof body.configRepo !== 'string' || !body.configRepo.includes('/')) {
|
|
55
|
+
return new Response(JSON.stringify({ error: 'configRepo (owner/repo) ist erforderlich' }), {
|
|
56
|
+
status: 400,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
if (!body.configInstallationId || typeof body.configInstallationId !== 'string') {
|
|
60
|
+
return new Response(JSON.stringify({ error: 'configInstallationId ist erforderlich' }), {
|
|
61
|
+
status: 400,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
|
|
66
|
+
| { storage?: { kind?: string } }
|
|
67
|
+
| undefined
|
|
68
|
+
if (
|
|
69
|
+
fullConfig?.storage?.kind &&
|
|
70
|
+
fullConfig.storage.kind !== 'single' &&
|
|
71
|
+
fullConfig.storage.kind !== 'github-app'
|
|
72
|
+
) {
|
|
73
|
+
return new Response(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
error: `Migration nur aus dem Single-Mode möglich. Aktueller storage.kind: ${fullConfig.storage.kind ?? 'unbekannt'}`,
|
|
76
|
+
}),
|
|
77
|
+
{ status: 400 },
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// We need the source-tier slot just in case the config repo already has
|
|
82
|
+
// entries — defensively preflight the limit so we never half-migrate.
|
|
83
|
+
const allowed = canAddWebsite(tier, 0)
|
|
84
|
+
if (!allowed.ok) {
|
|
85
|
+
return new Response(JSON.stringify({ error: allowed.reason }), { status: 402 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sourceStorage = resolveStorageConfig()
|
|
89
|
+
if (!sourceStorage) {
|
|
90
|
+
return new Response(
|
|
91
|
+
JSON.stringify({ error: 'Single-Mode Storage konnte nicht aufgelöst werden' }),
|
|
92
|
+
{ status: 500 },
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
97
|
+
if (!tokenResult.ok) {
|
|
98
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
99
|
+
}
|
|
100
|
+
const token = tokenResult.value
|
|
101
|
+
|
|
102
|
+
const [configOwner, configRepo] = body.configRepo.split('/')
|
|
103
|
+
if (!configOwner || !configRepo) {
|
|
104
|
+
return new Response(JSON.stringify({ error: 'configRepo muss "owner/repo" sein' }), {
|
|
105
|
+
status: 400,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sourceContentPath: string =
|
|
110
|
+
(
|
|
111
|
+
(globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
|
|
112
|
+
| { storage?: { contentPath?: string } }
|
|
113
|
+
| undefined
|
|
114
|
+
)?.storage?.contentPath ?? 'content'
|
|
115
|
+
|
|
116
|
+
const headers = {
|
|
117
|
+
Authorization: `Bearer ${token}`,
|
|
118
|
+
Accept: 'application/vnd.github+json',
|
|
119
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ghBase = (owner: string, repo: string, path: string) =>
|
|
124
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`
|
|
125
|
+
|
|
126
|
+
// 1. Read editors + global from website repo (best-effort — both files
|
|
127
|
+
// are optional in single-mode setups).
|
|
128
|
+
const sourceEditors = await readOptional(
|
|
129
|
+
ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_editors.json`),
|
|
130
|
+
`?ref=${sourceStorage.branch}`,
|
|
131
|
+
headers,
|
|
132
|
+
)
|
|
133
|
+
const sourceGlobal = await readOptional(
|
|
134
|
+
ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_global_config.json`),
|
|
135
|
+
`?ref=${sourceStorage.branch}`,
|
|
136
|
+
headers,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// 2. Write copies into config repo. (Branch defaults to 'main' since the
|
|
140
|
+
// user just created the repo; we don't expose a branch override here.)
|
|
141
|
+
const configBranch = 'main'
|
|
142
|
+
|
|
143
|
+
const editorsCommit = sourceEditors
|
|
144
|
+
? await putFile(
|
|
145
|
+
ghBase(configOwner, configRepo, 'content/_editors.json'),
|
|
146
|
+
sourceEditors,
|
|
147
|
+
configBranch,
|
|
148
|
+
'chore(migrate): copy editors from website repo',
|
|
149
|
+
headers,
|
|
150
|
+
)
|
|
151
|
+
: true
|
|
152
|
+
|
|
153
|
+
const globalCommit = sourceGlobal
|
|
154
|
+
? await putFile(
|
|
155
|
+
ghBase(configOwner, configRepo, 'content/_global_config.json'),
|
|
156
|
+
sourceGlobal,
|
|
157
|
+
configBranch,
|
|
158
|
+
'chore(migrate): copy global config from website repo',
|
|
159
|
+
headers,
|
|
160
|
+
)
|
|
161
|
+
: true
|
|
162
|
+
|
|
163
|
+
// 3. Initialise websites.json with the current setup as the first entry.
|
|
164
|
+
const previewOrigin = body.previewOrigin ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:4321'
|
|
165
|
+
|
|
166
|
+
const initialEntry: WebsiteEntry = {
|
|
167
|
+
id: 'main',
|
|
168
|
+
name: sourceStorage.repo,
|
|
169
|
+
repo: `${sourceStorage.owner}/${sourceStorage.repo}`,
|
|
170
|
+
branch: sourceStorage.branch,
|
|
171
|
+
previewOrigin,
|
|
172
|
+
githubApp: {
|
|
173
|
+
appId: process.env.GITHUB_APP_ID ?? '',
|
|
174
|
+
installationId: process.env.GITHUB_APP_INSTALLATION_ID ?? '',
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const websitesContent = JSON.stringify({ websites: [initialEntry] }, null, 2)
|
|
179
|
+
|
|
180
|
+
const websitesCommit = await putFile(
|
|
181
|
+
ghBase(configOwner, configRepo, 'websites.json'),
|
|
182
|
+
websitesContent,
|
|
183
|
+
configBranch,
|
|
184
|
+
'feat(migrate): initialise websites.json with current single-mode setup',
|
|
185
|
+
headers,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return new Response(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
ok: true,
|
|
191
|
+
committed: {
|
|
192
|
+
editors: editorsCommit,
|
|
193
|
+
globalConfig: globalCommit,
|
|
194
|
+
websites: websitesCommit,
|
|
195
|
+
},
|
|
196
|
+
// Echo back the values the user still has to set themselves.
|
|
197
|
+
manual: {
|
|
198
|
+
configRepo: body.configRepo,
|
|
199
|
+
configInstallationId: body.configInstallationId,
|
|
200
|
+
configBranch,
|
|
201
|
+
envChanges: {
|
|
202
|
+
add: {
|
|
203
|
+
SETZKASTEN_CONFIG_REPO: body.configRepo,
|
|
204
|
+
SETZKASTEN_CONFIG_BRANCH: configBranch,
|
|
205
|
+
GITHUB_APP_CONFIG_INSTALLATION_ID: body.configInstallationId,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readOptional(
|
|
215
|
+
url: string,
|
|
216
|
+
qs: string,
|
|
217
|
+
headers: Record<string, string>,
|
|
218
|
+
): Promise<string | null> {
|
|
219
|
+
const res = await fetch(url + qs, { headers })
|
|
220
|
+
if (!res.ok) return null
|
|
221
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
222
|
+
return data.encoding === 'base64'
|
|
223
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
224
|
+
: data.content
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function putFile(
|
|
228
|
+
url: string,
|
|
229
|
+
content: string,
|
|
230
|
+
branch: string,
|
|
231
|
+
message: string,
|
|
232
|
+
headers: Record<string, string>,
|
|
233
|
+
): Promise<boolean> {
|
|
234
|
+
// Read existing SHA so we can update an existing file rather than 422.
|
|
235
|
+
let sha: string | undefined
|
|
236
|
+
try {
|
|
237
|
+
const existing = await fetch(`${url}?ref=${branch}`, { headers })
|
|
238
|
+
if (existing.ok) {
|
|
239
|
+
const data = (await existing.json()) as { sha?: string }
|
|
240
|
+
sha = data.sha
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* file doesn't exist — fine */
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const body: Record<string, unknown> = {
|
|
247
|
+
message: withTrailers(message),
|
|
248
|
+
content: Buffer.from(content).toString('base64'),
|
|
249
|
+
branch,
|
|
250
|
+
}
|
|
251
|
+
if (sha) body.sha = sha
|
|
252
|
+
|
|
253
|
+
const res = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(body) })
|
|
254
|
+
return res.ok
|
|
255
|
+
}
|
package/src/api-routes/pages.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
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'
|
|
2
6
|
|
|
3
7
|
interface PageInfo {
|
|
4
8
|
path: string
|
|
5
9
|
pageKey: string
|
|
6
10
|
label: string
|
|
7
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
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
// Build-time constant injected by the Vite define plugin — always available in
|
|
@@ -15,6 +22,10 @@ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
|
|
|
15
22
|
* Returns the list of pages scanned at build time.
|
|
16
23
|
* Reads the Vite build-time constant first; falls back to globalThis for
|
|
17
24
|
* local dev / test environments where the define is not applied.
|
|
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}).
|
|
18
29
|
*/
|
|
19
30
|
export function resolvePages(): PageInfo[] {
|
|
20
31
|
const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
|
|
@@ -23,13 +34,116 @@ export function resolvePages(): PageInfo[] {
|
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
36
|
* GET /api/setzkasten/pages
|
|
26
|
-
*
|
|
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.
|
|
27
49
|
*/
|
|
28
|
-
export const GET: APIRoute = async () => {
|
|
29
|
-
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()
|
|
30
55
|
|
|
31
|
-
|
|
56
|
+
const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
|
|
57
|
+
|
|
58
|
+
return new Response(JSON.stringify({ pages: enriched }), {
|
|
32
59
|
status: 200,
|
|
33
60
|
headers: { 'Content-Type': 'application/json' },
|
|
34
61
|
})
|
|
35
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,8 +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
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/add
|
|
@@ -21,8 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
21
22
|
const session = cookies.get('setzkasten_session')?.value
|
|
22
23
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
-
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
|
|
26
30
|
|
|
27
31
|
try {
|
|
28
32
|
const body = await request.json() as {
|
|
@@ -36,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
36
40
|
contentPath?: string
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const storage =
|
|
43
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
40
44
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
41
45
|
const { owner, repo, branch, projectPrefix } = storage
|
|
42
46
|
|
|
@@ -49,7 +53,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
49
53
|
return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
56
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
53
57
|
if (denied) return denied
|
|
54
58
|
|
|
55
59
|
const headers = {
|
|
@@ -111,6 +115,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
111
115
|
|
|
112
116
|
if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
|
|
113
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
|
+
|
|
114
124
|
return Response.json({ success: true, newKey, commitSha: commitResult.sha })
|
|
115
125
|
} catch (error) {
|
|
116
126
|
console.error('[setzkasten] section-add error:', error)
|
|
@@ -1,9 +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
5
|
import { parseSession, guardPageAccess } from './_auth-guard'
|
|
6
6
|
import { withTrailers } from './_commit-trailers'
|
|
7
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* POST /api/setzkasten/sections/commit-pending
|
|
@@ -22,8 +23,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
22
23
|
const session = cookies.get('setzkasten_session')?.value
|
|
23
24
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
-
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
|
|
27
31
|
|
|
28
32
|
try {
|
|
29
33
|
const body = await request.json() as {
|
|
@@ -37,7 +41,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
37
41
|
contentPath?: string
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
const storage =
|
|
44
|
+
const storage = await resolveStorageConfigForRequest(request, body)
|
|
41
45
|
if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
|
|
42
46
|
const { owner, repo, branch } = storage
|
|
43
47
|
|
|
@@ -50,7 +54,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
50
54
|
return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
|
|
57
|
+
const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
|
|
54
58
|
if (denied) return denied
|
|
55
59
|
|
|
56
60
|
const headers = {
|
|
@@ -106,6 +110,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
106
110
|
)
|
|
107
111
|
}
|
|
108
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
|
+
|
|
109
122
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
110
123
|
} catch (error) {
|
|
111
124
|
console.error('[setzkasten] section-commit-pending 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 { 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,12 @@ 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
|
+
|
|
83
93
|
return Response.json({ success: true, commitSha: commitResult.sha })
|
|
84
94
|
} catch (error) {
|
|
85
95
|
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
|
|