@setzkasten-cms/astro-admin 1.3.0 → 1.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten-cms/astro-admin",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -37,6 +37,7 @@
37
37
  "./init-add-section": "./src/api-routes/init-add-section.ts",
38
38
  "./init-migrate": "./src/api-routes/init-migrate.ts",
39
39
  "./deploy-hook": "./src/api-routes/deploy-hook.ts",
40
+ "./icons-local": "./src/api-routes/icons-local.ts",
40
41
  "./catalog": "./src/api-routes/catalog-list.ts",
41
42
  "./catalog-add": "./src/api-routes/catalog-add.ts",
42
43
  "./catalog-export": "./src/api-routes/catalog-export.ts",
@@ -74,11 +75,11 @@
74
75
  },
75
76
  "dependencies": {
76
77
  "@astrojs/compiler": "^3.0.0",
77
- "@setzkasten-cms/catalog": "1.3.0",
78
- "@setzkasten-cms/auth": "1.3.0",
79
- "@setzkasten-cms/core": "1.3.0",
80
- "@setzkasten-cms/github-adapter": "1.3.0",
81
- "@setzkasten-cms/ui": "1.3.0"
78
+ "@setzkasten-cms/auth": "1.4.0",
79
+ "@setzkasten-cms/catalog": "1.4.0",
80
+ "@setzkasten-cms/core": "1.4.0",
81
+ "@setzkasten-cms/ui": "1.4.0",
82
+ "@setzkasten-cms/github-adapter": "1.4.0"
82
83
  },
83
84
  "peerDependencies": {
84
85
  "astro": "^5.0.0",
@@ -0,0 +1,169 @@
1
+ import type { APIRoute } from 'astro'
2
+ import {
3
+ LOCAL_ICONS_DISCOVERY_PATHS,
4
+ resolveLocalIconsPaths,
5
+ sanitizeSvg,
6
+ } from '@setzkasten-cms/core'
7
+ import { resolveStorageConfigForRequest, prefixPath } from './_storage-config'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
9
+
10
+ /**
11
+ * GET /api/setzkasten/icons/local
12
+ *
13
+ * Lists `.svg` files from the website's local icons folder(s) so the admin
14
+ * picker can render them as a "Lokal" tab.
15
+ *
16
+ * Path resolution:
17
+ * 1. If `icons.localPath` is set in the website config, scan exactly the
18
+ * listed folders (string or string[]). Whatever the user wrote wins.
19
+ * 2. Otherwise, scan `LOCAL_ICONS_DISCOVERY_PATHS` until a folder yields
20
+ * at least one SVG. Lets projects with `public/icons/` or
21
+ * `src/assets/svg/` work without touching their config — and surfaces
22
+ * the discovered path so the admin can copy it into setzkasten.config.ts.
23
+ *
24
+ * Response:
25
+ * {
26
+ * icons: Array<{ name, svg, source }>,
27
+ * paths: string[], // paths that contributed icons
28
+ * discovered: boolean, // true when discovery fallback ran
29
+ * }
30
+ *
31
+ * Each `svg` value is server-side sanitized.
32
+ */
33
+ export const GET: APIRoute = async ({ request, cookies }) => {
34
+ if (!cookies.get('setzkasten_session')?.value) {
35
+ return new Response('Unauthorized', { status: 401 })
36
+ }
37
+
38
+ const tokenResult = await resolveGitHubTokenForRequest(request)
39
+ if (!tokenResult.ok) {
40
+ return new Response(tokenResult.error.message, { status: 500 })
41
+ }
42
+ const token = tokenResult.value
43
+
44
+ const storage = await resolveStorageConfigForRequest(request)
45
+ if (!storage) {
46
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
47
+ }
48
+ const { owner, repo, branch, projectPrefix } = storage
49
+
50
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as
51
+ | { icons?: { localPath?: string | readonly string[] } }
52
+ | undefined
53
+ const configured = resolveLocalIconsPaths(fullConfig?.icons ?? null)
54
+ const discovered = configured === null
55
+ const candidatePaths = configured ?? LOCAL_ICONS_DISCOVERY_PATHS
56
+
57
+ const headers = {
58
+ Authorization: `Bearer ${token}`,
59
+ Accept: 'application/vnd.github+json',
60
+ 'X-GitHub-Api-Version': '2022-11-28',
61
+ }
62
+
63
+ // Scan paths in order. In configured mode, every path contributes —
64
+ // brand icons + product icons can happily coexist. In discovery mode,
65
+ // we stop after the first hit so we don't show e.g. raster assets in
66
+ // `public/icons` next to the real icons in `src/icons`.
67
+ const MAX_ICONS = 200
68
+ const yieldedPaths: string[] = []
69
+ type DirEntry = { name: string; type: 'file' | 'dir'; download_url?: string | null }
70
+ const allEntries: Array<{ path: string; entry: DirEntry }> = []
71
+
72
+ for (const relativePath of candidatePaths) {
73
+ if (allEntries.length >= MAX_ICONS) break
74
+ const path = prefixPath(relativePath, projectPrefix)
75
+ const listRes = await fetch(
76
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
77
+ { headers },
78
+ )
79
+ if (listRes.status === 404) continue
80
+ if (!listRes.ok) continue
81
+ const json = await listRes.json().catch(() => null)
82
+ if (!Array.isArray(json)) continue
83
+
84
+ const svgs = (json as DirEntry[]).filter(
85
+ (e) => e.type === 'file' && e.name.toLowerCase().endsWith('.svg'),
86
+ )
87
+ if (svgs.length === 0) continue
88
+
89
+ yieldedPaths.push(relativePath)
90
+ const remaining = MAX_ICONS - allEntries.length
91
+ for (const entry of svgs.slice(0, remaining)) {
92
+ allEntries.push({ path: relativePath, entry })
93
+ }
94
+
95
+ if (discovered) break
96
+ }
97
+
98
+ // Bounded parallel fetch. Without this cap, hundreds of simultaneous
99
+ // requests against raw.githubusercontent.com carrying the user's token
100
+ // would burn rate limits on the first picker open.
101
+ const PARALLELISM = 8
102
+ type Entry = { path: string; entry: DirEntry }
103
+ type Result = { id: string; name: string; svg: string; source: string }
104
+ const results: Array<Result | null> = new Array(allEntries.length).fill(null)
105
+ let cursor = 0
106
+
107
+ async function worker() {
108
+ while (true) {
109
+ const idx = cursor++
110
+ if (idx >= allEntries.length) return
111
+ const { path, entry } = allEntries[idx]!
112
+ results[idx] = await fetchAndSanitize(path, entry, token)
113
+ }
114
+ }
115
+
116
+ await Promise.all(Array.from({ length: Math.min(PARALLELISM, allEntries.length) }, worker))
117
+
118
+ // De-dupe by stable id ("path/basename") so colliding basenames across
119
+ // folders don't corrupt React keys or storage. Stored value uses the
120
+ // namespaced id; the bare basename is kept as a label for the picker.
121
+ const seen = new Set<string>()
122
+ const icons: Result[] = []
123
+ for (const r of results) {
124
+ if (!r) continue
125
+ if (seen.has(r.id)) continue
126
+ seen.add(r.id)
127
+ icons.push(r)
128
+ }
129
+
130
+ return Response.json({
131
+ icons,
132
+ paths: yieldedPaths,
133
+ discovered,
134
+ })
135
+ }
136
+
137
+ async function fetchAndSanitize(
138
+ path: string,
139
+ entry: { name: string; download_url?: string | null },
140
+ token: string,
141
+ ): Promise<{ id: string; name: string; svg: string; source: string } | null> {
142
+ const baseName = entry.name.replace(/\.svg$/i, '')
143
+ const url = entry.download_url
144
+ if (!url) return null
145
+
146
+ // Token only goes to GitHub-controlled hosts. The Contents API can in
147
+ // principle return any download_url; refusing other hosts means a
148
+ // future API change can't quietly leak the token to third parties.
149
+ let parsed: URL
150
+ try {
151
+ parsed = new URL(url)
152
+ } catch {
153
+ return null
154
+ }
155
+ const host = parsed.hostname
156
+ const githubHost = host === 'raw.githubusercontent.com' || host.endsWith('.githubusercontent.com')
157
+ if (!githubHost) return null
158
+
159
+ try {
160
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
161
+ if (!res.ok) return null
162
+ const raw = await res.text()
163
+ const svg = sanitizeSvg(raw)
164
+ if (!svg) return null
165
+ return { id: `${path}/${baseName}`, name: baseName, svg, source: path }
166
+ } catch {
167
+ return null
168
+ }
169
+ }