@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 +7 -6
- package/src/api-routes/icons-local.ts +169 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@setzkasten-cms/astro-admin",
|
|
3
|
-
"version": "1.
|
|
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/
|
|
78
|
-
"@setzkasten-cms/
|
|
79
|
-
"@setzkasten-cms/core": "1.
|
|
80
|
-
"@setzkasten-cms/
|
|
81
|
-
"@setzkasten-cms/
|
|
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
|
+
}
|