ghcr-manager-visualizer 0.0.1-dev.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/README.md +45 -0
- package/dist/public/app.js +839 -0
- package/dist/public/index.html +116 -0
- package/dist/public/styles.css +235 -0
- package/dist/src/_graph-repository.d.ts +18 -0
- package/dist/src/_graph-repository.js +399 -0
- package/dist/src/_server.d.ts +14 -0
- package/dist/src/_server.js +158 -0
- package/dist/src/_sql-placeholders.d.ts +1 -0
- package/dist/src/_sql-placeholders.js +6 -0
- package/dist/src/_types.d.ts +62 -0
- package/dist/src/_types.js +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +60 -0
- package/package.json +47 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { placeholders } from "./_sql-placeholders.js";
|
|
2
|
+
export class GraphRepository {
|
|
3
|
+
#database;
|
|
4
|
+
constructor(database) {
|
|
5
|
+
this.#database = database;
|
|
6
|
+
}
|
|
7
|
+
listOwners() {
|
|
8
|
+
return this.#database
|
|
9
|
+
.prepare(`
|
|
10
|
+
SELECT DISTINCT owner
|
|
11
|
+
FROM package_scans
|
|
12
|
+
WHERE status = 'completed'
|
|
13
|
+
ORDER BY owner
|
|
14
|
+
`)
|
|
15
|
+
.all();
|
|
16
|
+
}
|
|
17
|
+
listPackages(owner) {
|
|
18
|
+
return this.#database
|
|
19
|
+
.prepare(`
|
|
20
|
+
SELECT DISTINCT package_name AS packageName
|
|
21
|
+
FROM package_scans
|
|
22
|
+
WHERE status = 'completed'
|
|
23
|
+
AND owner = ?
|
|
24
|
+
ORDER BY package_name
|
|
25
|
+
`)
|
|
26
|
+
.all(owner);
|
|
27
|
+
}
|
|
28
|
+
listScans(owner, packageName) {
|
|
29
|
+
return this.#database
|
|
30
|
+
.prepare(`
|
|
31
|
+
SELECT scan_id AS scanId, scan_completed_at AS scanCompletedAt
|
|
32
|
+
FROM package_scans
|
|
33
|
+
WHERE status = 'completed'
|
|
34
|
+
AND owner = ?
|
|
35
|
+
AND package_name = ?
|
|
36
|
+
ORDER BY scan_completed_at DESC, scan_id DESC
|
|
37
|
+
`)
|
|
38
|
+
.all(owner, packageName);
|
|
39
|
+
}
|
|
40
|
+
listTags(owner, packageName, scanId, compareScanId, query, limit) {
|
|
41
|
+
const resolvedScans = this.#resolveScans(owner, packageName, scanId, compareScanId);
|
|
42
|
+
const normalizedLimit = Math.max(1, Math.min(limit, 50));
|
|
43
|
+
const normalizedQuery = query.trim();
|
|
44
|
+
if (normalizedQuery === "") {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const scanInClause = placeholders(resolvedScans.scanIds.length);
|
|
48
|
+
return this.#database
|
|
49
|
+
.prepare(`
|
|
50
|
+
SELECT DISTINCT tag AS tagName
|
|
51
|
+
FROM tags
|
|
52
|
+
WHERE scan_id IN (${scanInClause})
|
|
53
|
+
AND is_digest_tag = 0
|
|
54
|
+
AND tag LIKE ? ESCAPE '\\'
|
|
55
|
+
ORDER BY tag
|
|
56
|
+
LIMIT ?
|
|
57
|
+
`)
|
|
58
|
+
.all(...resolvedScans.scanIds, `%${_escapeLikeValue(normalizedQuery)}%`, normalizedLimit);
|
|
59
|
+
}
|
|
60
|
+
resolveLatestScanId(owner, packageName) {
|
|
61
|
+
const row = this.#database
|
|
62
|
+
.prepare(`
|
|
63
|
+
SELECT scan_id
|
|
64
|
+
FROM v_latest_scan_per_package
|
|
65
|
+
WHERE owner = ?
|
|
66
|
+
AND package_name = ?
|
|
67
|
+
LIMIT 1
|
|
68
|
+
`)
|
|
69
|
+
.get(owner, packageName);
|
|
70
|
+
if (!row) {
|
|
71
|
+
throw new Error(`database does not contain completed package scan for ${owner}/${packageName}`);
|
|
72
|
+
}
|
|
73
|
+
return row.scan_id;
|
|
74
|
+
}
|
|
75
|
+
resolveScanId(owner, packageName, scanId) {
|
|
76
|
+
if (scanId === undefined) {
|
|
77
|
+
return this.resolveLatestScanId(owner, packageName);
|
|
78
|
+
}
|
|
79
|
+
const row = this.#database
|
|
80
|
+
.prepare(`
|
|
81
|
+
SELECT scan_id
|
|
82
|
+
FROM package_scans
|
|
83
|
+
WHERE scan_id = ?
|
|
84
|
+
AND owner = ?
|
|
85
|
+
AND package_name = ?
|
|
86
|
+
AND status = 'completed'
|
|
87
|
+
LIMIT 1
|
|
88
|
+
`)
|
|
89
|
+
.get(scanId, owner, packageName);
|
|
90
|
+
if (!row) {
|
|
91
|
+
throw new Error(`scan ${scanId} is not a completed scan for ${owner}/${packageName}`);
|
|
92
|
+
}
|
|
93
|
+
return row.scan_id;
|
|
94
|
+
}
|
|
95
|
+
resolveManifest(owner, packageName, scanId, compareScanId, args) {
|
|
96
|
+
const resolvedScans = this.#resolveScans(owner, packageName, scanId, compareScanId);
|
|
97
|
+
const digest = args.digest ??
|
|
98
|
+
this.#resolveDigestByTag(resolvedScans.scanIds, args.tag, resolvedScans.scanId, resolvedScans.compareScanId);
|
|
99
|
+
const node = this.#readManifestMap(resolvedScans.scanIds, [digest], true, resolvedScans.newerScanId, resolvedScans.olderScanId).get(digest);
|
|
100
|
+
if (!node) {
|
|
101
|
+
throw new Error(`manifest ${digest} was not found in ${owner}/${packageName}`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
owner,
|
|
105
|
+
packageName,
|
|
106
|
+
scanId: resolvedScans.scanId,
|
|
107
|
+
compareScanId: resolvedScans.compareScanId,
|
|
108
|
+
digest: node.digest,
|
|
109
|
+
versionId: node.versionId,
|
|
110
|
+
manifestKind: node.manifestKind,
|
|
111
|
+
tags: node.tags.map((tag) => tag.name)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
getManifest(owner, packageName, scanId, compareScanId, digest) {
|
|
115
|
+
const resolvedScans = this.#resolveScans(owner, packageName, scanId, compareScanId);
|
|
116
|
+
const node = this.#readManifestMap(resolvedScans.scanIds, [digest], true, resolvedScans.newerScanId, resolvedScans.olderScanId).get(digest);
|
|
117
|
+
if (!node) {
|
|
118
|
+
throw new Error(`manifest ${digest} was not found in ${owner}/${packageName}`);
|
|
119
|
+
}
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
getGraph(owner, packageName, scanId, compareScanId, centerDigest, depth) {
|
|
123
|
+
const resolvedScans = this.#resolveScans(owner, packageName, scanId, compareScanId);
|
|
124
|
+
const normalizedDepth = Math.max(0, depth);
|
|
125
|
+
const visited = new Set([centerDigest]);
|
|
126
|
+
let frontier = new Set([centerDigest]);
|
|
127
|
+
for (let currentDepth = 0; currentDepth < normalizedDepth && frontier.size > 0; currentDepth += 1) {
|
|
128
|
+
const edgeRows = this.#readAdjacentEdges(resolvedScans.scanIds, [...frontier]);
|
|
129
|
+
const nextFrontier = new Set();
|
|
130
|
+
for (const row of edgeRows) {
|
|
131
|
+
if (!visited.has(row.parent_digest)) {
|
|
132
|
+
visited.add(row.parent_digest);
|
|
133
|
+
nextFrontier.add(row.parent_digest);
|
|
134
|
+
}
|
|
135
|
+
if (!visited.has(row.child_digest)) {
|
|
136
|
+
visited.add(row.child_digest);
|
|
137
|
+
nextFrontier.add(row.child_digest);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
frontier = nextFrontier;
|
|
141
|
+
}
|
|
142
|
+
const nodes = [
|
|
143
|
+
...this.#readManifestMap(resolvedScans.scanIds, [...visited], false, resolvedScans.newerScanId, resolvedScans.olderScanId).values()
|
|
144
|
+
];
|
|
145
|
+
const edges = this.#readVisibleEdges(resolvedScans.scanIds, [...visited]).map((row) => ({
|
|
146
|
+
id: `${row.parent_digest}|${row.child_digest}|${row.edge_kind}`,
|
|
147
|
+
from: row.parent_digest,
|
|
148
|
+
to: row.child_digest,
|
|
149
|
+
kind: row.edge_kind
|
|
150
|
+
}));
|
|
151
|
+
if (!nodes.some((node) => node.digest === centerDigest)) {
|
|
152
|
+
throw new Error(`manifest ${centerDigest} was not found in ${owner}/${packageName}`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
owner,
|
|
156
|
+
packageName,
|
|
157
|
+
scanId: resolvedScans.scanId,
|
|
158
|
+
compareScanId: resolvedScans.compareScanId,
|
|
159
|
+
centerDigest,
|
|
160
|
+
depth: normalizedDepth,
|
|
161
|
+
nodes,
|
|
162
|
+
edges: edges.sort((left, right) => left.id.localeCompare(right.id))
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
#resolveScans(owner, packageName, scanId, compareScanId) {
|
|
166
|
+
const resolvedScanId = this.resolveScanId(owner, packageName, scanId);
|
|
167
|
+
if (compareScanId === undefined || compareScanId === resolvedScanId) {
|
|
168
|
+
return {
|
|
169
|
+
scanId: resolvedScanId,
|
|
170
|
+
scanIds: [resolvedScanId],
|
|
171
|
+
newerScanId: resolvedScanId
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const resolvedCompareScanId = this.resolveScanId(owner, packageName, compareScanId);
|
|
175
|
+
const rows = this.#database
|
|
176
|
+
.prepare(`
|
|
177
|
+
SELECT scan_id, scan_completed_at
|
|
178
|
+
FROM package_scans
|
|
179
|
+
WHERE owner = ?
|
|
180
|
+
AND package_name = ?
|
|
181
|
+
AND scan_id IN (?, ?)
|
|
182
|
+
ORDER BY scan_completed_at, scan_id
|
|
183
|
+
`)
|
|
184
|
+
.all(owner, packageName, resolvedScanId, resolvedCompareScanId);
|
|
185
|
+
if (rows.length !== 2) {
|
|
186
|
+
throw new Error(`failed to resolve compare scans for ${owner}/${packageName}`);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
scanId: resolvedScanId,
|
|
190
|
+
compareScanId: resolvedCompareScanId,
|
|
191
|
+
scanIds: [resolvedScanId, resolvedCompareScanId],
|
|
192
|
+
newerScanId: rows[1].scan_id,
|
|
193
|
+
olderScanId: rows[0].scan_id
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
#resolveDigestByTag(scanIds, tag, preferredScanId, fallbackScanId) {
|
|
197
|
+
if (!tag) {
|
|
198
|
+
throw new Error("either digest or tag is required");
|
|
199
|
+
}
|
|
200
|
+
const scanInClause = placeholders(scanIds.length);
|
|
201
|
+
const row = this.#database
|
|
202
|
+
.prepare(`
|
|
203
|
+
SELECT manifest.digest
|
|
204
|
+
FROM tags
|
|
205
|
+
JOIN manifests manifest
|
|
206
|
+
ON manifest.scan_id = tags.scan_id
|
|
207
|
+
AND manifest.version_id = tags.version_id
|
|
208
|
+
WHERE tags.scan_id IN (${scanInClause})
|
|
209
|
+
AND tags.tag = ?
|
|
210
|
+
ORDER BY
|
|
211
|
+
CASE
|
|
212
|
+
WHEN tags.scan_id = ? THEN 0
|
|
213
|
+
WHEN ? IS NOT NULL AND tags.scan_id = ? THEN 1
|
|
214
|
+
ELSE 2
|
|
215
|
+
END
|
|
216
|
+
LIMIT 1
|
|
217
|
+
`)
|
|
218
|
+
.get(...scanIds, tag, preferredScanId, fallbackScanId ?? null, fallbackScanId ?? -1);
|
|
219
|
+
if (!row) {
|
|
220
|
+
throw new Error(`tag ${tag} was not found in selected scan context`);
|
|
221
|
+
}
|
|
222
|
+
return row.digest;
|
|
223
|
+
}
|
|
224
|
+
#readAdjacentEdges(scanIds, digests) {
|
|
225
|
+
const scanInClause = placeholders(scanIds.length);
|
|
226
|
+
const inClause = placeholders(digests.length);
|
|
227
|
+
const sql = `
|
|
228
|
+
SELECT DISTINCT parent_digest, child_digest, edge_kind
|
|
229
|
+
FROM manifest_edges
|
|
230
|
+
WHERE scan_id IN (${scanInClause})
|
|
231
|
+
AND (parent_digest IN (${inClause}) OR child_digest IN (${inClause}))
|
|
232
|
+
ORDER BY parent_digest, child_digest, edge_kind
|
|
233
|
+
`;
|
|
234
|
+
return this.#database.prepare(sql).all(...scanIds, ...digests, ...digests);
|
|
235
|
+
}
|
|
236
|
+
#readVisibleEdges(scanIds, digests) {
|
|
237
|
+
const scanInClause = placeholders(scanIds.length);
|
|
238
|
+
const inClause = placeholders(digests.length);
|
|
239
|
+
const sql = `
|
|
240
|
+
SELECT DISTINCT parent_digest, child_digest, edge_kind
|
|
241
|
+
FROM manifest_edges
|
|
242
|
+
WHERE scan_id IN (${scanInClause})
|
|
243
|
+
AND parent_digest IN (${inClause})
|
|
244
|
+
AND child_digest IN (${inClause})
|
|
245
|
+
ORDER BY parent_digest, child_digest, edge_kind
|
|
246
|
+
`;
|
|
247
|
+
return this.#database.prepare(sql).all(...scanIds, ...digests, ...digests);
|
|
248
|
+
}
|
|
249
|
+
#readManifestMap(scanIds, digests, includePayload, newerScanId, olderScanId) {
|
|
250
|
+
const scanInClause = placeholders(scanIds.length);
|
|
251
|
+
const inClause = placeholders(digests.length);
|
|
252
|
+
const payloadColumn = includePayload ? "payload.raw_json" : "NULL";
|
|
253
|
+
const sql = `
|
|
254
|
+
WITH ranked_platforms AS (
|
|
255
|
+
SELECT
|
|
256
|
+
scan_id,
|
|
257
|
+
child_digest,
|
|
258
|
+
platform_os,
|
|
259
|
+
platform_architecture,
|
|
260
|
+
platform_variant,
|
|
261
|
+
ROW_NUMBER() OVER (
|
|
262
|
+
PARTITION BY scan_id, child_digest
|
|
263
|
+
ORDER BY parent_digest
|
|
264
|
+
) AS row_number
|
|
265
|
+
FROM manifest_descriptors
|
|
266
|
+
WHERE scan_id IN (${scanInClause})
|
|
267
|
+
AND child_digest IN (${inClause})
|
|
268
|
+
AND (
|
|
269
|
+
platform_os IS NOT NULL
|
|
270
|
+
OR platform_architecture IS NOT NULL
|
|
271
|
+
OR platform_variant IS NOT NULL
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
SELECT
|
|
275
|
+
manifest.scan_id,
|
|
276
|
+
manifest.digest,
|
|
277
|
+
manifest.version_id,
|
|
278
|
+
package_version.created_at,
|
|
279
|
+
package_version.updated_at,
|
|
280
|
+
manifest.manifest_kind,
|
|
281
|
+
manifest.media_type,
|
|
282
|
+
platform.platform_os,
|
|
283
|
+
platform.platform_architecture,
|
|
284
|
+
platform.platform_variant,
|
|
285
|
+
manifest.artifact_type,
|
|
286
|
+
manifest.subject_digest,
|
|
287
|
+
${payloadColumn} AS raw_json,
|
|
288
|
+
tag.tag
|
|
289
|
+
FROM manifests manifest
|
|
290
|
+
JOIN package_versions package_version
|
|
291
|
+
ON package_version.scan_id = manifest.scan_id
|
|
292
|
+
AND package_version.version_id = manifest.version_id
|
|
293
|
+
LEFT JOIN manifest_payloads payload
|
|
294
|
+
ON payload.scan_id = manifest.scan_id
|
|
295
|
+
AND payload.digest = manifest.digest
|
|
296
|
+
LEFT JOIN tags tag
|
|
297
|
+
ON tag.scan_id = manifest.scan_id
|
|
298
|
+
AND tag.version_id = manifest.version_id
|
|
299
|
+
AND tag.is_digest_tag = 0
|
|
300
|
+
LEFT JOIN ranked_platforms platform
|
|
301
|
+
ON platform.scan_id = manifest.scan_id
|
|
302
|
+
AND platform.child_digest = manifest.digest
|
|
303
|
+
AND platform.row_number = 1
|
|
304
|
+
WHERE manifest.scan_id IN (${scanInClause})
|
|
305
|
+
AND manifest.digest IN (${inClause})
|
|
306
|
+
ORDER BY manifest.digest, CASE WHEN manifest.scan_id = ? THEN 0 ELSE 1 END, tag.tag
|
|
307
|
+
`;
|
|
308
|
+
const rows = this.#database
|
|
309
|
+
.prepare(sql)
|
|
310
|
+
.all(...scanIds, ...digests, ...scanIds, ...digests, newerScanId);
|
|
311
|
+
const manifests = new Map();
|
|
312
|
+
const scanMemberships = new Map();
|
|
313
|
+
const tagsByDigest = new Map();
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
let scanMembership = scanMemberships.get(row.digest);
|
|
316
|
+
if (!scanMembership) {
|
|
317
|
+
scanMembership = new Set();
|
|
318
|
+
scanMemberships.set(row.digest, scanMembership);
|
|
319
|
+
}
|
|
320
|
+
scanMembership.add(row.scan_id);
|
|
321
|
+
let manifest = manifests.get(row.digest);
|
|
322
|
+
if (!manifest || row.scan_id === newerScanId) {
|
|
323
|
+
manifest = {
|
|
324
|
+
id: row.digest,
|
|
325
|
+
digest: row.digest,
|
|
326
|
+
versionId: row.version_id,
|
|
327
|
+
createdAt: row.created_at,
|
|
328
|
+
updatedAt: row.updated_at,
|
|
329
|
+
manifestKind: row.manifest_kind,
|
|
330
|
+
mediaType: row.media_type,
|
|
331
|
+
displayPlatform: _formatPlatform(row.platform_os, row.platform_architecture, row.platform_variant),
|
|
332
|
+
artifactType: row.artifact_type,
|
|
333
|
+
subjectDigest: row.subject_digest,
|
|
334
|
+
tags: [],
|
|
335
|
+
changeStatus: "unchanged",
|
|
336
|
+
rawJson: row.raw_json
|
|
337
|
+
};
|
|
338
|
+
manifests.set(row.digest, manifest);
|
|
339
|
+
}
|
|
340
|
+
if (row.tag) {
|
|
341
|
+
let tags = tagsByDigest.get(row.digest);
|
|
342
|
+
if (!tags) {
|
|
343
|
+
tags = new Map();
|
|
344
|
+
tagsByDigest.set(row.digest, tags);
|
|
345
|
+
}
|
|
346
|
+
let tagScans = tags.get(row.tag);
|
|
347
|
+
if (!tagScans) {
|
|
348
|
+
tagScans = new Set();
|
|
349
|
+
tags.set(row.tag, tagScans);
|
|
350
|
+
}
|
|
351
|
+
tagScans.add(row.scan_id);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [digest, manifest] of manifests) {
|
|
355
|
+
const tagMap = tagsByDigest.get(digest) ?? new Map();
|
|
356
|
+
manifest.changeStatus = _resolveChangeStatus(scanMemberships.get(digest) ?? new Set(), newerScanId, olderScanId);
|
|
357
|
+
manifest.tags = [...tagMap.entries()]
|
|
358
|
+
.map(([name, tagScans]) => ({
|
|
359
|
+
name,
|
|
360
|
+
changeStatus: _resolveChangeStatus(tagScans, newerScanId, olderScanId)
|
|
361
|
+
}))
|
|
362
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
363
|
+
}
|
|
364
|
+
return manifests;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function _resolveChangeStatus(scanIds, newerScanId, olderScanId) {
|
|
368
|
+
if (olderScanId === undefined) {
|
|
369
|
+
return "unchanged";
|
|
370
|
+
}
|
|
371
|
+
const hasNewer = scanIds.has(newerScanId);
|
|
372
|
+
const hasOlder = scanIds.has(olderScanId);
|
|
373
|
+
if (hasNewer && hasOlder) {
|
|
374
|
+
return "unchanged";
|
|
375
|
+
}
|
|
376
|
+
return hasNewer ? "added" : "removed";
|
|
377
|
+
}
|
|
378
|
+
function _formatPlatform(os, architecture, variant) {
|
|
379
|
+
const normalizedOs = _normalizePlatformPart(os);
|
|
380
|
+
const normalizedArchitecture = _normalizePlatformPart(architecture);
|
|
381
|
+
const normalizedVariant = _normalizePlatformPart(variant);
|
|
382
|
+
if (!normalizedOs && !normalizedArchitecture && !normalizedVariant) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const platform = [normalizedOs, normalizedArchitecture].filter((value) => value).join("/");
|
|
386
|
+
if (normalizedVariant) {
|
|
387
|
+
return platform ? `${platform}/${normalizedVariant}` : normalizedVariant;
|
|
388
|
+
}
|
|
389
|
+
return platform || null;
|
|
390
|
+
}
|
|
391
|
+
function _normalizePlatformPart(value) {
|
|
392
|
+
if (!value || value === "unknown") {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return value;
|
|
396
|
+
}
|
|
397
|
+
function _escapeLikeValue(value) {
|
|
398
|
+
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
399
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface VisualizerServerOptions {
|
|
2
|
+
databasePath: string;
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
}
|
|
6
|
+
export interface VisualizerServerHandle {
|
|
7
|
+
readonly url: string;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function startVisualizerServer(options: VisualizerServerOptions): Promise<VisualizerServerHandle>;
|
|
11
|
+
export declare function _resolveRuntimePaths(importMetaUrl: string): {
|
|
12
|
+
publicDirectory: string;
|
|
13
|
+
cytoscapePath: string;
|
|
14
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { extname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { GraphRepository } from "./_graph-repository.js";
|
|
8
|
+
const _mimeTypes = new Map([
|
|
9
|
+
[".css", "text/css; charset=utf-8"],
|
|
10
|
+
[".html", "text/html; charset=utf-8"],
|
|
11
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
12
|
+
[".json", "application/json; charset=utf-8"],
|
|
13
|
+
[".mjs", "text/javascript; charset=utf-8"]
|
|
14
|
+
]);
|
|
15
|
+
export async function startVisualizerServer(options) {
|
|
16
|
+
const database = new Database(options.databasePath, { readonly: true, fileMustExist: true });
|
|
17
|
+
const repository = new GraphRepository(database);
|
|
18
|
+
const runtimePaths = _resolveRuntimePaths(import.meta.url);
|
|
19
|
+
const server = createServer(async (request, response) => {
|
|
20
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
21
|
+
try {
|
|
22
|
+
if (url.pathname.startsWith("/api/")) {
|
|
23
|
+
_writeJson(response, 200, _handleApi(repository, url));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (url.pathname === "/vendor/cytoscape.js") {
|
|
27
|
+
await _streamFile(response, runtimePaths.cytoscapePath);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
31
|
+
const staticPath = resolve(runtimePaths.publicDirectory, `.${requestedPath}`);
|
|
32
|
+
if (!staticPath.startsWith(runtimePaths.publicDirectory)) {
|
|
33
|
+
throw Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
34
|
+
}
|
|
35
|
+
await _streamFile(response, staticPath);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code === "ENOENT") {
|
|
39
|
+
_writeJson(response, 404, { error: "not found" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const message = error instanceof Error ? error.message : "unexpected error";
|
|
43
|
+
const statusCode = message.includes("not found") || message.includes("required") ? 404 : 400;
|
|
44
|
+
_writeJson(response, statusCode, { error: message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
let url = "";
|
|
48
|
+
await new Promise((resolvePromise) => {
|
|
49
|
+
server.listen(options.port, options.host, () => {
|
|
50
|
+
const address = server.address();
|
|
51
|
+
if (typeof address === "object" && address) {
|
|
52
|
+
url = `http://${options.host}:${address.port}`;
|
|
53
|
+
console.log(`Visualizer listening at ${url}`);
|
|
54
|
+
}
|
|
55
|
+
resolvePromise();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
url,
|
|
60
|
+
async close() {
|
|
61
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
62
|
+
server.close((error) => {
|
|
63
|
+
if (error) {
|
|
64
|
+
rejectPromise(error);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
resolvePromise();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
database.close();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function _resolveRuntimePaths(importMetaUrl) {
|
|
75
|
+
const baseDirectory = resolve(fileURLToPath(new URL("..", importMetaUrl)));
|
|
76
|
+
return {
|
|
77
|
+
publicDirectory: join(baseDirectory, "public"),
|
|
78
|
+
cytoscapePath: join(baseDirectory, "..", "node_modules", "cytoscape", "dist", "cytoscape.esm.min.mjs")
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function _handleApi(repository, url) {
|
|
82
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
83
|
+
if (segments.length === 2 && segments[0] === "api" && segments[1] === "owners") {
|
|
84
|
+
return repository.listOwners();
|
|
85
|
+
}
|
|
86
|
+
if (segments.length === 4 && segments[0] === "api" && segments[1] === "owners" && segments[3] === "packages") {
|
|
87
|
+
return repository.listPackages(decodeURIComponent(segments[2]));
|
|
88
|
+
}
|
|
89
|
+
if (segments.length === 5 && segments[0] === "api" && segments[1] === "packages" && segments[4] === "scans") {
|
|
90
|
+
return repository.listScans(decodeURIComponent(segments[2]), decodeURIComponent(segments[3]));
|
|
91
|
+
}
|
|
92
|
+
if (segments.length < 4 || segments[0] !== "api" || segments[1] !== "packages") {
|
|
93
|
+
throw new Error("not found");
|
|
94
|
+
}
|
|
95
|
+
const owner = decodeURIComponent(segments[2]);
|
|
96
|
+
const packageName = decodeURIComponent(segments[3]);
|
|
97
|
+
const scanId = _parseOptionalInteger(url.searchParams.get("scan_id"));
|
|
98
|
+
const compareScanId = _parseOptionalInteger(url.searchParams.get("compare_scan_id"));
|
|
99
|
+
if (segments.length === 5 && segments[4] === "tags") {
|
|
100
|
+
return repository.listTags(owner, packageName, scanId, compareScanId, url.searchParams.get("q") ?? "", _parseOptionalInteger(url.searchParams.get("limit")) ?? 20);
|
|
101
|
+
}
|
|
102
|
+
if (segments.length === 6 && segments[4] === "scans" && segments[5] === "latest") {
|
|
103
|
+
return { scanId: repository.resolveLatestScanId(owner, packageName) };
|
|
104
|
+
}
|
|
105
|
+
if (segments.length === 5 && segments[4] === "manifests" && url.searchParams.has("digest")) {
|
|
106
|
+
return repository.resolveManifest(owner, packageName, scanId, compareScanId, {
|
|
107
|
+
digest: url.searchParams.get("digest") ?? undefined
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (segments.length === 5 && segments[4] === "manifests" && url.searchParams.has("tag")) {
|
|
111
|
+
return repository.resolveManifest(owner, packageName, scanId, compareScanId, {
|
|
112
|
+
tag: url.searchParams.get("tag") ?? undefined
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (segments.length === 6 && segments[4] === "manifests") {
|
|
116
|
+
return repository.getManifest(owner, packageName, scanId, compareScanId, decodeURIComponent(segments[5]));
|
|
117
|
+
}
|
|
118
|
+
if (segments.length === 5 && segments[4] === "graph") {
|
|
119
|
+
const centerDigest = url.searchParams.get("center_digest");
|
|
120
|
+
if (!centerDigest) {
|
|
121
|
+
throw new Error("center_digest is required");
|
|
122
|
+
}
|
|
123
|
+
return repository.getGraph(owner, packageName, scanId, compareScanId, centerDigest, _parseOptionalInteger(url.searchParams.get("depth")) ?? 1);
|
|
124
|
+
}
|
|
125
|
+
throw new Error("not found");
|
|
126
|
+
}
|
|
127
|
+
function _parseOptionalInteger(raw) {
|
|
128
|
+
if (raw === null || raw === "") {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const value = Number.parseInt(raw, 10);
|
|
132
|
+
if (!Number.isInteger(value)) {
|
|
133
|
+
throw new Error(`invalid integer value: ${raw}`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
async function _streamFile(response, path) {
|
|
138
|
+
if (!existsSync(path)) {
|
|
139
|
+
throw Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
140
|
+
}
|
|
141
|
+
const fileStat = await stat(path);
|
|
142
|
+
if (!fileStat.isFile()) {
|
|
143
|
+
throw Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
144
|
+
}
|
|
145
|
+
response.statusCode = 200;
|
|
146
|
+
response.setHeader("Content-Type", _mimeTypes.get(extname(path)) ?? "application/octet-stream");
|
|
147
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
148
|
+
const stream = createReadStream(path);
|
|
149
|
+
stream.on("error", rejectPromise);
|
|
150
|
+
stream.on("end", resolvePromise);
|
|
151
|
+
stream.pipe(response);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function _writeJson(response, statusCode, body) {
|
|
155
|
+
response.statusCode = statusCode;
|
|
156
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
157
|
+
response.end(JSON.stringify(body));
|
|
158
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function placeholders(count: number): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type GraphEdgeKind = "image-child" | "referrer" | "digest-tag-referrer";
|
|
2
|
+
export type ChangeStatus = "unchanged" | "added" | "removed";
|
|
3
|
+
export interface OwnerOption {
|
|
4
|
+
owner: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PackageOption {
|
|
7
|
+
packageName: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ScanOption {
|
|
10
|
+
scanId: number;
|
|
11
|
+
scanCompletedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TagOption {
|
|
14
|
+
tagName: string;
|
|
15
|
+
}
|
|
16
|
+
export interface GraphTag {
|
|
17
|
+
name: string;
|
|
18
|
+
changeStatus: ChangeStatus;
|
|
19
|
+
}
|
|
20
|
+
export interface GraphNode {
|
|
21
|
+
id: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
versionId: number;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
manifestKind: string | null;
|
|
27
|
+
mediaType: string;
|
|
28
|
+
displayPlatform: string | null;
|
|
29
|
+
artifactType: string | null;
|
|
30
|
+
subjectDigest: string | null;
|
|
31
|
+
tags: GraphTag[];
|
|
32
|
+
changeStatus: ChangeStatus;
|
|
33
|
+
}
|
|
34
|
+
export interface GraphEdge {
|
|
35
|
+
id: string;
|
|
36
|
+
from: string;
|
|
37
|
+
to: string;
|
|
38
|
+
kind: GraphEdgeKind;
|
|
39
|
+
}
|
|
40
|
+
export interface GraphResponse {
|
|
41
|
+
owner: string;
|
|
42
|
+
packageName: string;
|
|
43
|
+
scanId: number;
|
|
44
|
+
compareScanId?: number;
|
|
45
|
+
centerDigest: string;
|
|
46
|
+
depth: number;
|
|
47
|
+
nodes: GraphNode[];
|
|
48
|
+
edges: GraphEdge[];
|
|
49
|
+
}
|
|
50
|
+
export interface ManifestResolution {
|
|
51
|
+
owner: string;
|
|
52
|
+
packageName: string;
|
|
53
|
+
scanId: number;
|
|
54
|
+
compareScanId?: number;
|
|
55
|
+
digest: string;
|
|
56
|
+
versionId: number;
|
|
57
|
+
manifestKind: string | null;
|
|
58
|
+
tags: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface ManifestDetails extends GraphNode {
|
|
61
|
+
rawJson: string | null;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { startVisualizerServer } from "./_server.js";
|
|
2
|
+
export type { VisualizerServerHandle } from "./_server.js";
|
|
3
|
+
export interface CliOptions {
|
|
4
|
+
databasePath: string;
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function main(args: string[], startServer?: typeof startVisualizerServer): Promise<void>;
|
|
9
|
+
export declare function parseArgs(args: string[]): CliOptions;
|
|
10
|
+
export declare function resolveDatabasePath(databasePath: string): string;
|