@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
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
let data: { id: number; slug: string; pem: string } | null = null
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { Accept: 'application/vnd.github.v3+json' },
|
|
35
|
+
signal: AbortSignal.timeout(8000),
|
|
36
|
+
})
|
|
37
|
+
if (!response.ok) throw new Error(`GitHub returned ${response.status}`)
|
|
38
|
+
data = (await response.json()) as typeof data
|
|
39
|
+
} catch {
|
|
40
|
+
adminUrl.searchParams.set('github-app-error', 'exchange_failed')
|
|
41
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Set cookie before constructing the redirect response —
|
|
45
|
+
// Response.redirect() is immutable and blocks subsequent header writes.
|
|
46
|
+
cookies.set(
|
|
47
|
+
COOKIE_NAME,
|
|
48
|
+
JSON.stringify({ appId: String(data!.id), slug: data!.slug, privateKey: data!.pem }),
|
|
49
|
+
{ httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight update check without re-registration.
|
|
5
|
+
*
|
|
6
|
+
* GET /api/setzkasten/updater/check?instanceId=X
|
|
7
|
+
*/
|
|
8
|
+
export const GET: APIRoute = async ({ cookies, url }) => {
|
|
9
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
10
|
+
if (!session) {
|
|
11
|
+
return new Response('Unauthorized', { status: 401 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
15
|
+
updaterUrl?: string
|
|
16
|
+
version?: string
|
|
17
|
+
} | undefined
|
|
18
|
+
|
|
19
|
+
const updaterUrl = config?.updaterUrl
|
|
20
|
+
if (!updaterUrl) {
|
|
21
|
+
return Response.json({
|
|
22
|
+
updateAvailable: false,
|
|
23
|
+
latestVersion: config?.version ?? 'unknown',
|
|
24
|
+
releaseNotesUrl: '',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const instanceId = url.searchParams.get('instanceId') ?? ''
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(
|
|
32
|
+
`${updaterUrl}/api/check-update?instanceId=${encodeURIComponent(instanceId)}`,
|
|
33
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP ${response.status}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await response.json()
|
|
41
|
+
return Response.json(data)
|
|
42
|
+
} catch {
|
|
43
|
+
return Response.json({
|
|
44
|
+
updateAvailable: false,
|
|
45
|
+
latestVersion: config?.version ?? 'unknown',
|
|
46
|
+
releaseNotesUrl: '',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registers this Setzkasten instance with the central updater backend.
|
|
5
|
+
* Called on every Dashboard load. Returns update status and license tier.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/updater/register
|
|
8
|
+
*
|
|
9
|
+
* Body (optional — for UI activation flow):
|
|
10
|
+
* { licenseEmail: string, licenseKey: string }
|
|
11
|
+
*/
|
|
12
|
+
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
13
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
14
|
+
if (!session) {
|
|
15
|
+
return new Response('Unauthorized', { status: 401 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
19
|
+
updaterUrl?: string
|
|
20
|
+
version?: string
|
|
21
|
+
websiteUrl?: string
|
|
22
|
+
storage?: { owner?: string; repo?: string }
|
|
23
|
+
} | undefined
|
|
24
|
+
|
|
25
|
+
const currentVersion = config?.version ?? '0.0.0'
|
|
26
|
+
const updaterUrl = config?.updaterUrl
|
|
27
|
+
if (!updaterUrl) {
|
|
28
|
+
return Response.json({
|
|
29
|
+
instanceId: null,
|
|
30
|
+
updateAvailable: false,
|
|
31
|
+
currentVersion,
|
|
32
|
+
latestVersion: null,
|
|
33
|
+
releaseNotesUrl: '',
|
|
34
|
+
licenseTier: 'free',
|
|
35
|
+
licenseValid: false,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const owner = config?.storage?.owner ?? ''
|
|
40
|
+
const repo = config?.storage?.repo ?? ''
|
|
41
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
42
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
43
|
+
|
|
44
|
+
let licenseEmail: string | undefined
|
|
45
|
+
let licenseKey: string | undefined
|
|
46
|
+
try {
|
|
47
|
+
if (request.headers.get('content-type')?.includes('application/json')) {
|
|
48
|
+
const parsed = await request.json() as { licenseEmail?: string; licenseKey?: string }
|
|
49
|
+
licenseEmail = parsed.licenseEmail?.trim() || undefined
|
|
50
|
+
licenseKey = parsed.licenseKey?.trim() || undefined
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Empty / malformed body — treat as no UI input
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${updaterUrl}/api/register`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
repoUrl,
|
|
62
|
+
websiteUrl,
|
|
63
|
+
currentVersion,
|
|
64
|
+
licenseEmail,
|
|
65
|
+
licenseKey,
|
|
66
|
+
telemetryEnabled: true,
|
|
67
|
+
managedWebsites: [],
|
|
68
|
+
}),
|
|
69
|
+
signal: AbortSignal.timeout(5000),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`HTTP ${response.status}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const data = await response.json() as { firebaseConfig?: { apiKey: string; authDomain: string; projectId: string }; [key: string]: unknown }
|
|
77
|
+
|
|
78
|
+
return Response.json({ ...data, currentVersion, _firebaseConfig: data.firebaseConfig ?? null })
|
|
79
|
+
} catch {
|
|
80
|
+
return Response.json({
|
|
81
|
+
instanceId: null,
|
|
82
|
+
updateAvailable: false,
|
|
83
|
+
currentVersion,
|
|
84
|
+
latestVersion: null,
|
|
85
|
+
releaseNotesUrl: '',
|
|
86
|
+
licenseTier: 'free',
|
|
87
|
+
licenseValid: false,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transfer a license to the current Setzkasten instance.
|
|
6
|
+
* The backend identifies the license by instanceId (computed from repoUrl + websiteUrl).
|
|
7
|
+
*
|
|
8
|
+
* POST /api/setzkasten/updater/transfer
|
|
9
|
+
*/
|
|
10
|
+
export const POST: APIRoute = async ({ cookies }) => {
|
|
11
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
12
|
+
if (!session) {
|
|
13
|
+
return new Response('Unauthorized', { status: 401 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
17
|
+
updaterUrl?: string
|
|
18
|
+
websiteUrl?: string
|
|
19
|
+
storage?: { owner?: string; repo?: string }
|
|
20
|
+
} | undefined
|
|
21
|
+
|
|
22
|
+
const updaterUrl = config?.updaterUrl
|
|
23
|
+
if (!updaterUrl) {
|
|
24
|
+
return Response.json({ error: 'Updater not configured' }, { status: 400 })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const owner = config?.storage?.owner ?? ''
|
|
28
|
+
const repo = config?.storage?.repo ?? ''
|
|
29
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
30
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
31
|
+
|
|
32
|
+
const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
|
|
33
|
+
const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`${updaterUrl}/api/transfer`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
toInstanceId: instanceId,
|
|
41
|
+
toWebsiteUrl: websiteUrl,
|
|
42
|
+
}),
|
|
43
|
+
signal: AbortSignal.timeout(5000),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const data = await response.json()
|
|
47
|
+
return Response.json(data, { status: response.status })
|
|
48
|
+
} catch {
|
|
49
|
+
return Response.json({ error: 'Transfer failed' }, { status: 500 })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Release the license binding for this Setzkasten instance.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/setzkasten/updater/unbind
|
|
8
|
+
*
|
|
9
|
+
* Reads license credentials from either:
|
|
10
|
+
* 1. `setzkasten.config.ts` → license.{email,key}
|
|
11
|
+
* 2. Request body (UI removal flow): { licenseEmail, licenseKey }
|
|
12
|
+
*
|
|
13
|
+
* On success, Firebase clears `instance.licenseKey` and removes this
|
|
14
|
+
* instance from `license.boundTo` / `license.boundInstances`.
|
|
15
|
+
*/
|
|
16
|
+
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
17
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
18
|
+
if (!session) {
|
|
19
|
+
return new Response('Unauthorized', { status: 401 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
|
|
23
|
+
updaterUrl?: string
|
|
24
|
+
websiteUrl?: string
|
|
25
|
+
storage?: { owner?: string; repo?: string }
|
|
26
|
+
} | undefined
|
|
27
|
+
|
|
28
|
+
const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
|
|
29
|
+
license?: { email?: string; key?: string }
|
|
30
|
+
} | undefined
|
|
31
|
+
|
|
32
|
+
const updaterUrl = config?.updaterUrl
|
|
33
|
+
if (!updaterUrl) {
|
|
34
|
+
return Response.json({ error: 'Updater not configured' }, { status: 400 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const owner = config?.storage?.owner ?? ''
|
|
38
|
+
const repo = config?.storage?.repo ?? ''
|
|
39
|
+
const repoUrl = owner && repo ? `${owner}/${repo}` : ''
|
|
40
|
+
const websiteUrl = config?.websiteUrl ?? ''
|
|
41
|
+
|
|
42
|
+
// Compute deterministic instanceId (same as backend register)
|
|
43
|
+
const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
|
|
44
|
+
const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(`${updaterUrl}/api/unbind`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ instanceId }),
|
|
51
|
+
signal: AbortSignal.timeout(5000),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const data = await response.json()
|
|
55
|
+
return Response.json(data, { status: response.status })
|
|
56
|
+
} catch {
|
|
57
|
+
return Response.json({ error: 'Unbind failed' }, { status: 500 })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type WebsiteEntry,
|
|
3
|
+
addWebsiteToRegistry,
|
|
4
|
+
canAddWebsite,
|
|
5
|
+
parseWebsitesRegistry,
|
|
6
|
+
} from '@setzkasten-cms/core'
|
|
7
|
+
import type { APIRoute } from 'astro'
|
|
8
|
+
import { requireAdmin } from './_auth-guard'
|
|
9
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
10
|
+
import { resolveLicenseTier } from './_license-tier'
|
|
11
|
+
import {
|
|
12
|
+
readWebsitesRegistryFromGitHub,
|
|
13
|
+
resolveConfigRepoTargetFromGlobals,
|
|
14
|
+
writeWebsitesRegistryToGitHub,
|
|
15
|
+
} from './_websites-store'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST /api/setzkasten/websites/add
|
|
19
|
+
*
|
|
20
|
+
* Body: { entry: WebsiteEntry }
|
|
21
|
+
*
|
|
22
|
+
* Reads the current websites.json from the config-repo, adds the entry
|
|
23
|
+
* (validated via parseWebsitesRegistry to share one truth on what makes
|
|
24
|
+
* a valid WebsiteEntry), and commits the new file back via the GitHub
|
|
25
|
+
* Contents API.
|
|
26
|
+
*/
|
|
27
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
28
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
29
|
+
if (denied) return denied
|
|
30
|
+
|
|
31
|
+
let parsed: { entry?: WebsiteEntry } = {}
|
|
32
|
+
try {
|
|
33
|
+
parsed = (await request.json()) as { entry?: WebsiteEntry }
|
|
34
|
+
} catch {
|
|
35
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
if (!parsed.entry || typeof parsed.entry !== 'object') {
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Missing "entry" in body' }), { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The form leaves githubApp.appId blank when the user wants to fall back
|
|
42
|
+
// to GITHUB_APP_ID from the env. Inject it before validation so the
|
|
43
|
+
// registry sees a complete entry. The App-ID itself is not sensitive
|
|
44
|
+
// (it's public on every App's settings page on github.com), so writing
|
|
45
|
+
// it into websites.json is fine — but doing it server-side keeps the
|
|
46
|
+
// client form simpler.
|
|
47
|
+
const entry = parsed.entry as unknown as {
|
|
48
|
+
githubApp?: { appId?: string; installationId?: string }
|
|
49
|
+
}
|
|
50
|
+
if (entry.githubApp && !entry.githubApp.appId) {
|
|
51
|
+
const envAppId = process.env.GITHUB_APP_ID
|
|
52
|
+
if (envAppId) entry.githubApp.appId = envAppId
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Reuse parseWebsitesRegistry's per-entry validation by stuffing the new
|
|
56
|
+
// entry into a one-element registry — same rules everywhere.
|
|
57
|
+
const validation = parseWebsitesRegistry(JSON.stringify({ websites: [parsed.entry] }))
|
|
58
|
+
if (!validation.ok) {
|
|
59
|
+
return new Response(JSON.stringify({ error: validation.error.message }), { status: 400 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
63
|
+
if (!tokenResult.ok) {
|
|
64
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
|
|
68
|
+
if (!target) {
|
|
69
|
+
return new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
error: 'Multi-mode storage not configured (storage.kind must be "multi").',
|
|
72
|
+
}),
|
|
73
|
+
{ status: 400 },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const current = await readWebsitesRegistryFromGitHub(target)
|
|
78
|
+
if (!current.ok) {
|
|
79
|
+
return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// License gate: enforce the per-tier website limit before mutating GitHub.
|
|
83
|
+
// 402 Payment Required is the most accurate code here — the registry would
|
|
84
|
+
// accept the entry, only the license disagrees.
|
|
85
|
+
const tier = resolveLicenseTier()
|
|
86
|
+
const allowed = canAddWebsite(tier, current.value.registry.websites.length)
|
|
87
|
+
if (!allowed.ok) {
|
|
88
|
+
return new Response(
|
|
89
|
+
JSON.stringify({ error: allowed.reason, tier: allowed.tier, limit: allowed.limit }),
|
|
90
|
+
{ status: 402, headers: { 'Content-Type': 'application/json' } },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const merged = addWebsiteToRegistry(current.value.registry, parsed.entry)
|
|
95
|
+
if (!merged.ok) {
|
|
96
|
+
return new Response(JSON.stringify({ error: merged.error.message }), { status: 409 })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const written = await writeWebsitesRegistryToGitHub(
|
|
100
|
+
target,
|
|
101
|
+
merged.value,
|
|
102
|
+
current.value.sha,
|
|
103
|
+
`feat(websites): add "${parsed.entry.id}" to registry`,
|
|
104
|
+
)
|
|
105
|
+
if (!written.ok) {
|
|
106
|
+
return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Response(JSON.stringify({ ok: true, websites: merged.value.websites }), {
|
|
110
|
+
status: 200,
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro'
|
|
2
|
+
import { listAllWebsites } from './_website-resolver.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/setzkasten/websites
|
|
6
|
+
*
|
|
7
|
+
* Returns the list of managed websites visible to the admin SPA.
|
|
8
|
+
* Strips private fields (`githubApp` installation refs, `allowedEmails`)
|
|
9
|
+
* — the client only needs ids/names/repos for the switcher.
|
|
10
|
+
*/
|
|
11
|
+
export const GET: APIRoute = async ({ cookies }) => {
|
|
12
|
+
const session = cookies.get('setzkasten_session')?.value
|
|
13
|
+
if (!session) {
|
|
14
|
+
return new Response(JSON.stringify({ authenticated: false }), {
|
|
15
|
+
status: 401,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = await listAllWebsites()
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
return new Response(JSON.stringify({ error: result.error.message }), {
|
|
23
|
+
status: 500,
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const websites = result.value.map((entry) => ({
|
|
29
|
+
id: entry.id,
|
|
30
|
+
name: entry.name,
|
|
31
|
+
repo: entry.repo,
|
|
32
|
+
branch: entry.branch,
|
|
33
|
+
previewOrigin: entry.previewOrigin,
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
return new Response(JSON.stringify({ websites }), {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { removeWebsiteFromRegistry } from '@setzkasten-cms/core'
|
|
2
|
+
import type { APIRoute } from 'astro'
|
|
3
|
+
import { requireAdmin } from './_auth-guard'
|
|
4
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
5
|
+
import {
|
|
6
|
+
readWebsitesRegistryFromGitHub,
|
|
7
|
+
resolveConfigRepoTargetFromGlobals,
|
|
8
|
+
writeWebsitesRegistryToGitHub,
|
|
9
|
+
} from './_websites-store'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST /api/setzkasten/websites/remove
|
|
13
|
+
*
|
|
14
|
+
* Body: { id: string }
|
|
15
|
+
*
|
|
16
|
+
* Drops the matching entry from websites.json and commits the new file.
|
|
17
|
+
* Admin-only — editors must not be able to detach websites from the
|
|
18
|
+
* registry. Returns 404 when the id is not present, 502 when GitHub I/O
|
|
19
|
+
* fails.
|
|
20
|
+
*/
|
|
21
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
22
|
+
const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
|
|
23
|
+
if (denied) return denied
|
|
24
|
+
|
|
25
|
+
let body: { id?: string } = {}
|
|
26
|
+
try {
|
|
27
|
+
body = (await request.json()) as { id?: string }
|
|
28
|
+
} catch {
|
|
29
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
if (!body.id || typeof body.id !== 'string') {
|
|
32
|
+
return new Response(JSON.stringify({ error: 'Missing "id" in body' }), { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
36
|
+
if (!tokenResult.ok) {
|
|
37
|
+
return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
|
|
41
|
+
if (!target) {
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
error: 'Multi-mode storage not configured (storage.kind must be "multi").',
|
|
45
|
+
}),
|
|
46
|
+
{ status: 400 },
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = await readWebsitesRegistryFromGitHub(target)
|
|
51
|
+
if (!current.ok) {
|
|
52
|
+
return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = removeWebsiteFromRegistry(current.value.registry, body.id)
|
|
56
|
+
if (!next.ok) {
|
|
57
|
+
return new Response(JSON.stringify({ error: next.error.message }), { status: 404 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const written = await writeWebsitesRegistryToGitHub(
|
|
61
|
+
target,
|
|
62
|
+
next.value,
|
|
63
|
+
current.value.sha,
|
|
64
|
+
`chore(websites): remove "${body.id}" from registry`,
|
|
65
|
+
)
|
|
66
|
+
if (!written.ok) {
|
|
67
|
+
return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new Response(JSON.stringify({ ok: true, websites: next.value.websites }), {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
})
|
|
74
|
+
}
|