@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2AFQKRAM.js +51 -0
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KVEUXIEU.js +1 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QZ3A376W.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TY4RZD3Z.js +226 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-U6OPEXIA.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-UZAZM2SX.js +2 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XL35ZV6R.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +298 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +595 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +156 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +223 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +420 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,3472 @@
|
|
|
1
|
+
// HTTP router for the managed auth server. Mounts the GitHub OAuth flow plus
|
|
2
|
+
// the session endpoints + the mode probe; there is NO api/sync here (a managed
|
|
3
|
+
// server doesn't speak the e2e sync protocol yet). Every handler is async; the
|
|
4
|
+
// boot's `track()` keeps in-flight requests drainable on shutdown.
|
|
5
|
+
//
|
|
6
|
+
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
|
|
7
|
+
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
8
|
+
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
9
|
+
// GET /api/auth/session → { user, csrfToken } | 401
|
|
10
|
+
// GET /api/teams → the current user's teams + their reports and bundles | 401
|
|
11
|
+
// GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
|
|
12
|
+
// GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
|
|
13
|
+
// GET /api/teams/<id>/feed → SSE notifications for visible triage and comments | 401/404
|
|
14
|
+
// GET /api/reports/<id> → admin/manager report preview | 401/403/404
|
|
15
|
+
// POST /api/reports/query → admin/manager batch preview, with repository metadata | 400/401/403/404/503
|
|
16
|
+
// GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
|
|
17
|
+
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
18
|
+
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
19
|
+
// GET /api/avatar/<id> → cached avatar bytes by user id | 401/404
|
|
20
|
+
// GET /api/admin/users → admin-only user list | 401/403
|
|
21
|
+
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
22
|
+
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
23
|
+
// GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
|
|
24
|
+
// POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
|
|
25
|
+
// GET /api/admin/repositories/impact → admin attached data summary
|
|
26
|
+
// GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
|
|
27
|
+
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
28
|
+
// GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
|
|
29
|
+
// their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
|
|
30
|
+
// GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
|
|
31
|
+
// POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
|
|
32
|
+
// GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
|
|
33
|
+
// DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
|
|
34
|
+
// POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
|
|
35
|
+
// GET /api/admin/reports/<id>/location → admin|manage live location suggestion | 401/403/404/503
|
|
36
|
+
// GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
|
|
37
|
+
// GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
|
|
38
|
+
// GET /api/bundles/<id>/download → authorized original upload | 401/404
|
|
39
|
+
// GET /api/bundles/<id>/pretty?path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429
|
|
40
|
+
// GET /api/bundles/<id>/pretty?other= → the files that changed between it and another bundle, both sides' pretty-printed,
|
|
41
|
+
// Brotli-encoded ndjson | 400/401/404/422/503
|
|
42
|
+
// GET /api/bundles/<id>/rename-diff?path=&hash=&other=&otherPath=&otherHash= → a JavaScript file diffed with another
|
|
43
|
+
// bundle's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/504
|
|
44
|
+
// GET /api/bundles/<id>/rename-diff?other=&scope= → every pair of its JavaScript files and another bundle's the viewer
|
|
45
|
+
// diffs so, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
|
|
46
|
+
// GET /api/bundles/<id>/type-only?other=&scope= → the files that changed between it and another bundle in types alone, as
|
|
47
|
+
// @preventive/compare proves them, within a reason scope where given, Brotli-encoded ndjson | 400/401/404/422/503
|
|
48
|
+
// GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
|
|
49
|
+
// POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
|
|
50
|
+
// GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
|
|
51
|
+
// DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
|
|
52
|
+
// POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
|
|
53
|
+
// GET /api/admin/uploads/key → admin|manage public key for sealed upload bodies | 401/403
|
|
54
|
+
// GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
|
|
55
|
+
// POST /api/admin/teams → admin creates a team | 401/403/409
|
|
56
|
+
// POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
|
|
57
|
+
// POST /api/admin/teams/set-hidden → admin hides/restores a team | 401/403/404
|
|
58
|
+
// POST /api/admin/teams/delete → admin deletes a team | 401/403/404
|
|
59
|
+
// POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
|
|
60
|
+
// POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
|
|
61
|
+
// POST /api/admin/teams/set-dependency-review → admin toggles dependency review publication for a team | 400/401/403/404
|
|
62
|
+
// POST /api/admin/teams/set-npm-scopes → admin replaces a team's npm scopes | 400/401/403/404
|
|
63
|
+
// GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
|
|
64
|
+
// POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
|
|
65
|
+
// GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
|
|
66
|
+
// GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
|
|
67
|
+
// GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
|
|
68
|
+
// GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
|
|
69
|
+
// GET /api/npm/stats?name= → its downloads over the last year and its GitHub repository's figures | 400/401/404/429/502
|
|
70
|
+
// GET /api/npm/advisories?name= → its advisories across every published version, npm's and its repository's | 400/401/404/429/502
|
|
71
|
+
// GET /api/npm/tags?name=&version= → the tags pointing to its publish commit | 400/401/404/429/502
|
|
72
|
+
// GET /api/npm/socket?name=&version= → Socket's scores and alerts for a public version | 400/401/404/429/502
|
|
73
|
+
// GET /api/npm/provenance?name=&version= → npm's provenance for a public version | 400/401/404/429/502
|
|
74
|
+
// GET /api/npm/pretty?name=&version= → every one of its files that needs it pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
75
|
+
// GET /api/npm/pretty?name=&version=&otherVersion= → the files that changed between it and another version, both sides'
|
|
76
|
+
// pretty-printed, Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
77
|
+
// GET /api/npm/pretty?name=&version=&path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429/502/503
|
|
78
|
+
// GET /api/npm/rename-diff?name=&version=&path=&hash=&otherVersion=&otherPath=&otherHash= → a JavaScript file diffed with
|
|
79
|
+
// another version's, both pretty-printed, renamed names hidden, whitespace changes shown and hidden | 400/401/404/409/413/422/429/502/503/504
|
|
80
|
+
// GET /api/npm/rename-diff?name=&version=&otherVersion= → every pair of their JavaScript files the viewer diffs so,
|
|
81
|
+
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
82
|
+
// GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
|
|
83
|
+
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
84
|
+
// GET /api/npm/risk?name=&version=&otherVersion= → what calls for a look at a version, as risk tells it, against another where given | 400/401/404/413/429/502
|
|
85
|
+
// POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
|
|
86
|
+
// POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
|
|
87
|
+
// DELETE /api/auth/view-as → ends the view (the view's CSRF token)
|
|
88
|
+
import { mergeLinkGroups } from './link-reports.ts'
|
|
89
|
+
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
90
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
91
|
+
import { pipeline } from 'node:stream/promises'
|
|
92
|
+
import { npmVersionStore, riskLineStore } from './npm-kept.ts'
|
|
93
|
+
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
94
|
+
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
95
|
+
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
96
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, newUploadKey, openSessionUpload, putUploadPart, readUpload, uploadPublicKey, validUpload, validUploadPart } from './uploads.ts'
|
|
97
|
+
import { UPLOAD_SEAL_HEADER, maxSealedBytes } from '../common/managed/upload-seal.ts'
|
|
98
|
+
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
99
|
+
import type { CacheStorage } from './cache-storage.ts'
|
|
100
|
+
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
101
|
+
import { type CatalogCommit, backfillBundleCommits, bundleCommits, cacheCommitDetails } from './bundle-commits.ts'
|
|
102
|
+
import { contentAccess } from './content-access.ts'
|
|
103
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
104
|
+
import { Buffer } from 'node:buffer'
|
|
105
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
106
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
107
|
+
import type { BlobStore, OpenedBlob } from './blob-store.ts'
|
|
108
|
+
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
109
|
+
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
110
|
+
import type { ManagedConfig } from './config.ts'
|
|
111
|
+
import type { BundleBuildConditions, BundleProvenance, BundleReuse, ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
|
|
112
|
+
import type { OriginGate } from '../server-common/origin.ts'
|
|
113
|
+
import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
114
|
+
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
115
|
+
import { filterReportData } from '../common/managed/report-filter.ts'
|
|
116
|
+
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
117
|
+
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
118
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
119
|
+
import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
|
|
120
|
+
import type { ReportSourcesCache } from './report-sources.ts'
|
|
121
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
122
|
+
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
123
|
+
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
124
|
+
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
125
|
+
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
|
|
126
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
127
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
128
|
+
import type { ConnectedRepo } from './github-app.ts'
|
|
129
|
+
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
130
|
+
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
131
|
+
import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
|
|
132
|
+
import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
|
|
133
|
+
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
134
|
+
import { clearCookie, endSession, readSession } from './session.ts'
|
|
135
|
+
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
136
|
+
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
137
|
+
import { acceptsReportMetadata } from './report-response.ts'
|
|
138
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
139
|
+
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
140
|
+
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
141
|
+
import { lookupIssueLinks } from './github-issue-links.ts'
|
|
142
|
+
import { visibleManagedIssues } from './managed-issues.ts'
|
|
143
|
+
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
144
|
+
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
145
|
+
import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
146
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
147
|
+
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
148
|
+
import { serveUserTeamFeed } from './team-feed.ts'
|
|
149
|
+
import { teamAppKey, teamAppStates } from './team-app.ts'
|
|
150
|
+
import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
151
|
+
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
152
|
+
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
153
|
+
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
154
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
|
+
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
156
|
+
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
157
|
+
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
158
|
+
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
159
|
+
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
160
|
+
import { npmProvenance } from './npm-provenance.ts'
|
|
161
|
+
import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
162
|
+
import { NpmRiskError } from '../risk/index.js'
|
|
163
|
+
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
|
+
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
165
|
+
import { typeOnlyFiles } from './type-only.ts'
|
|
166
|
+
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
167
|
+
|
|
168
|
+
const SESSION_PATH = '/api/auth/session'
|
|
169
|
+
const AVATAR_PREFIX = '/api/avatar/'
|
|
170
|
+
const LOGOUT_PATH = '/api/auth/logout'
|
|
171
|
+
const ADMIN_USERS_PATH = '/api/admin/users'
|
|
172
|
+
const ADMIN_HISTORY_PATH = '/api/admin/history'
|
|
173
|
+
const ADMIN_SCAN_MODELS_PATH = '/api/admin/scan/models'
|
|
174
|
+
const SET_ROLE_PATH = '/api/admin/set-role'
|
|
175
|
+
const ADMIN_REPOS_PATH = '/api/admin/repositories'
|
|
176
|
+
const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
|
|
177
|
+
const REPO_REFS_PATH = '/api/admin/repositories/refs'
|
|
178
|
+
const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
|
|
179
|
+
const SELECT_REPO_PATH = '/api/admin/repositories/select'
|
|
180
|
+
const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
181
|
+
const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
|
|
182
|
+
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
183
|
+
const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
|
|
184
|
+
const REPO_RISK_PATH = '/api/admin/repositories/risk'
|
|
185
|
+
const ADMIN_REPORTS_PATH = '/api/admin/reports'
|
|
186
|
+
const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
|
|
187
|
+
const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
|
|
188
|
+
const REPORT_PREFIX = '/api/admin/reports/'
|
|
189
|
+
const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
|
|
190
|
+
const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
|
|
191
|
+
const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
|
|
192
|
+
const BUNDLE_SET_VISIBLE_PATH = '/api/admin/bundles/set-visible'
|
|
193
|
+
const BUNDLE_PREFIX = '/api/admin/bundles/'
|
|
194
|
+
const MY_TEAMS_PATH = '/api/teams'
|
|
195
|
+
const MY_REPORT_PREFIX = '/api/reports/'
|
|
196
|
+
const REPORT_QUERY_PATH = '/api/reports/query'
|
|
197
|
+
const MY_REPORT_TRIAGE_SUFFIX = '/triage'
|
|
198
|
+
const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
|
|
199
|
+
const ADMIN_TEAMS_PATH = '/api/admin/teams'
|
|
200
|
+
const TEAM_DELETE_PATH = '/api/admin/teams/delete'
|
|
201
|
+
const TEAM_RENAME_PATH = '/api/admin/teams/rename'
|
|
202
|
+
const TEAM_SET_HIDDEN_PATH = '/api/admin/teams/set-hidden'
|
|
203
|
+
const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
|
|
204
|
+
const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
|
|
205
|
+
const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
|
|
206
|
+
const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
|
|
207
|
+
const TEAM_SET_NPM_SCOPES_PATH = '/api/admin/teams/set-npm-scopes'
|
|
208
|
+
const NPM_PACKAGE_PATH = '/api/npm/package'
|
|
209
|
+
const NPM_VERSIONS_PATH = '/api/npm/versions'
|
|
210
|
+
const NPM_DOWNLOAD_PATH = '/api/npm/download'
|
|
211
|
+
const NPM_STATS_PATH = '/api/npm/stats'
|
|
212
|
+
const NPM_ADVISORIES_PATH = '/api/npm/advisories'
|
|
213
|
+
const NPM_TAGS_PATH = '/api/npm/tags'
|
|
214
|
+
const NPM_SOCKET_PATH = '/api/npm/socket'
|
|
215
|
+
const NPM_PROVENANCE_PATH = '/api/npm/provenance'
|
|
216
|
+
const NPM_PRETTY_PATH = '/api/npm/pretty'
|
|
217
|
+
const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
218
|
+
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
219
|
+
const NPM_RISK_PATH = '/api/npm/risk'
|
|
220
|
+
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
221
|
+
// having no such package or version, or a tarball unpacking past risk's
|
|
222
|
+
// bounds; npm failing otherwise a 502.
|
|
223
|
+
const NPM_RISK_STATUS: Partial<Record<string, number>> = { 'package-not-found': 404, 'version-not-found': 404, 'package-too-large': 413 }
|
|
224
|
+
// A risk failure answered with its code alone, as risk's status for it (NPM_RISK_STATUS, else 502), its cause
|
|
225
|
+
// logged where it is the server's or npm's, not the reader's (a 5xx), for the logs to say why.
|
|
226
|
+
function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
227
|
+
const status = NPM_RISK_STATUS[err.code] ?? 502
|
|
228
|
+
if (status >= 500) console.error(`managed: npm risk failed (${err.code}):`, err.message)
|
|
229
|
+
sendJson(res, status, { error: err.code })
|
|
230
|
+
}
|
|
231
|
+
const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
|
|
232
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
|
|
233
|
+
// How long an npm response may go unread before its connection is dropped.
|
|
234
|
+
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
235
|
+
const MAX_TEAM_NAME = 100
|
|
236
|
+
// Managed data routes, and with them the authentication routes, which an
|
|
237
|
+
// admin's view as another user covers. Other paths fall through to a combined
|
|
238
|
+
// e2e server, whose own authentication a view never touches.
|
|
239
|
+
const MANAGED_DATA_PREFIXES = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github', '/api/npm']
|
|
240
|
+
const VIEW_PREFIXES = [...MANAGED_DATA_PREFIXES, '/api/auth', '/api/oauth']
|
|
241
|
+
const underPrefix = (path: string, prefixes: readonly string[]) => prefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
242
|
+
|
|
243
|
+
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
244
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
245
|
+
if (!s) return
|
|
246
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
247
|
+
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
248
|
+
const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
|
|
249
|
+
const storedIssues = await deps.db.listManagedIssues(ids)
|
|
250
|
+
const issues = visibleManagedIssues(storedIssues, snapshot)
|
|
251
|
+
const visibleIds = new Set(issues.map(issue => issue.findingId))
|
|
252
|
+
const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
|
|
253
|
+
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
254
|
+
const result = issues.length > 0
|
|
255
|
+
? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
|
|
256
|
+
: { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
|
|
257
|
+
// Cold report reads, token refresh and upstream batches can outlive changes
|
|
258
|
+
// to security/links, team grants, publication, sessions or the Fix links.
|
|
259
|
+
const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
|
|
260
|
+
if (await readWorkspaceSession(res, deps, cookie) == null) return
|
|
261
|
+
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
262
|
+
if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
|
|
263
|
+
sendJson(res, 404, { error: 'workspace-changed' }); return
|
|
264
|
+
}
|
|
265
|
+
if (result.updates.length > 0 && !await deps.db.applyManagedIssueFixes(s.session.id, snapshot, result.updates)) {
|
|
266
|
+
sendJson(res, 404, { error: 'workspace-changed' }); return
|
|
267
|
+
}
|
|
268
|
+
sendJson(res, 200, { fixes: result.fixes })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
|
|
272
|
+
cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
|
|
273
|
+
if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
274
|
+
const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
275
|
+
if (!s) return
|
|
276
|
+
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
277
|
+
let body: unknown = Object.fromEntries(query)
|
|
278
|
+
if (req.method === 'POST') {
|
|
279
|
+
try { body = await readJsonBody(req, MAX_ISSUE_BODY_BYTES) }
|
|
280
|
+
catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
281
|
+
}
|
|
282
|
+
const context = parseIssueContext(body)
|
|
283
|
+
const prepared = await prepareGithubIssue(deps.config, deps.db, deps.reportStore, s.session, teamId, context)
|
|
284
|
+
await prepared.recheck()
|
|
285
|
+
if (req.method === 'POST' && prepared.mode === 'api') {
|
|
286
|
+
sendJson(res, 201, await createGithubIssue(prepared, body)); return
|
|
287
|
+
}
|
|
288
|
+
sendJson(res, 200, { mode: prepared.mode, labels: prepared.labels,
|
|
289
|
+
...(prepared.mode === 'existing' ? { url: prepared.url } : {}),
|
|
290
|
+
...(prepared.mode === 'pending' ? { repositoryUrl: prepared.repositoryUrl } : {}),
|
|
291
|
+
...(prepared.mode === 'authorize' || prepared.mode === 'permissions' ? { authorizationPath: prepared.authorizationPath } : {}) })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['kind'], action: string, context: Pick<ActivityInput, 'repo' | 'reportId' | 'bundleId' | 'report' | 'repoId' | 'repoDirectory'> = {}): Promise<void> {
|
|
295
|
+
return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
|
|
299
|
+
if (repoId == null) return null
|
|
300
|
+
return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Workspace history is authorized BEFORE filtering, counting, or paging.
|
|
304
|
+
// Managers get content activity for their currently accessible team content;
|
|
305
|
+
// even the displayed report context must come from those accessible reports.
|
|
306
|
+
async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
307
|
+
const s = await readManageSession(res, deps, cookie)
|
|
308
|
+
if (s == null) return
|
|
309
|
+
const page = Number(query.get('page') ?? 1)
|
|
310
|
+
const limit = Number(query.get('limit') ?? 100)
|
|
311
|
+
const kind = query.get('kind') ?? 'all'
|
|
312
|
+
const search = (query.get('q') ?? '').trim()
|
|
313
|
+
const repo = query.get('repo') ?? ''
|
|
314
|
+
const actor = query.get('actor') ?? ''
|
|
315
|
+
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|
|
316
|
+
|| !['all', 'triage', 'upload', 'visibility', 'access', 'repository', 'delete'].includes(kind) || search.length > 500
|
|
317
|
+
|| repo.length > 500 || actor.length > 500) {
|
|
318
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
319
|
+
}
|
|
320
|
+
let contexts: ActivityContext[] | null = null
|
|
321
|
+
if (s.user.role !== 'admin') {
|
|
322
|
+
const findings = new Map<string, ActivityContext>()
|
|
323
|
+
const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
|
|
324
|
+
for (const report of reports) {
|
|
325
|
+
for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
|
|
326
|
+
if (!findings.has(finding)) findings.set(finding, { finding, ...report })
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
contexts = [...findings.values()]
|
|
330
|
+
}
|
|
331
|
+
sendJson(res, 200, await deps.db.listActivity({ page, limit, kind, query: search, repo, actor, contexts, userId: s.user.id }))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface ManagedHttpDeps {
|
|
335
|
+
config: ManagedConfig
|
|
336
|
+
db: ManagedDb
|
|
337
|
+
avatarStore: AvatarStore
|
|
338
|
+
reportStore: BlobStore
|
|
339
|
+
bundleStore: BundleStore
|
|
340
|
+
bundleCache?: BundleCache
|
|
341
|
+
reportSourcesCache?: ReportSourcesCache
|
|
342
|
+
prettyCache?: PrettyCache
|
|
343
|
+
// The public npm cache, where risk keeps public packages' assessments (npm-risk.ts).
|
|
344
|
+
npmCacheStorage?: CacheStorage
|
|
345
|
+
uploadStore?: BlobStore
|
|
346
|
+
originGate: OriginGate
|
|
347
|
+
isShuttingDown: () => boolean
|
|
348
|
+
track: (p: Promise<unknown>) => void
|
|
349
|
+
// Combined boot delegates unmatched paths to e2e and overrides discovery.
|
|
350
|
+
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
351
|
+
next?: Handler
|
|
352
|
+
serverInfo?: ServerInfo
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
356
|
+
|
|
357
|
+
function send405(res: ServerResponse, allow: string): void {
|
|
358
|
+
sendJson(res, 405, { error: 'method-not-allowed' }, { allow })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function firstHeader(v: string | string[] | undefined): string | null {
|
|
362
|
+
if (typeof v === 'string') return v
|
|
363
|
+
if (Array.isArray(v) && v.length > 0) return v[0] ?? null
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
|
|
368
|
+
const avatar = await avatarStore.get(userId)
|
|
369
|
+
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
370
|
+
res.writeHead(200, {
|
|
371
|
+
'content-type': avatar.contentType,
|
|
372
|
+
'content-length': String(avatar.bytes.length),
|
|
373
|
+
'x-content-type-options': 'nosniff',
|
|
374
|
+
'cache-control': 'no-store',
|
|
375
|
+
})
|
|
376
|
+
res.end(avatar.bytes)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const MAX_JSON_BODY_BYTES = 4096
|
|
380
|
+
|
|
381
|
+
// Buffer the request body, aborting (and throwing 'too-large') once it exceeds
|
|
382
|
+
// `maxBytes` — bounds memory on the JSON mutations (small) and the report
|
|
383
|
+
// upload (config.maxReportBytes) alike.
|
|
384
|
+
async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
385
|
+
const chunks: Buffer[] = []
|
|
386
|
+
let size = 0
|
|
387
|
+
for await (const chunk of req) {
|
|
388
|
+
const c = chunk as Buffer
|
|
389
|
+
size += c.length
|
|
390
|
+
if (size > maxBytes) { req.destroy(); throw new Error('too-large') }
|
|
391
|
+
chunks.push(c)
|
|
392
|
+
}
|
|
393
|
+
return Buffer.concat(chunks)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// A file upload's content, opening a body the browser sealed to this session
|
|
397
|
+
// (common/managed/upload-seal.ts). Link reports are never sent in parts.
|
|
398
|
+
async function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: ManagedSession, kind: UploadKind | 'links'): Promise<Buffer> {
|
|
399
|
+
const maxBytes = kind === 'bundles' ? deps.config.maxBundleBytes : deps.config.maxReportBytes
|
|
400
|
+
const sealed = req.headers[UPLOAD_SEAL_HEADER] !== undefined
|
|
401
|
+
const limit = sealed ? maxSealedBytes(maxBytes) : maxBytes
|
|
402
|
+
let bytes
|
|
403
|
+
if (kind === 'links' || req.headers['x-upload-id'] == null) bytes = await readBodyBytes(req, limit)
|
|
404
|
+
else if (deps.uploadStore) bytes = await readUpload(deps.uploadStore, req, session.id, kind, limit)
|
|
405
|
+
else throw new Error('bad-upload')
|
|
406
|
+
return sealed ? await openSessionUpload(session.uploadKey, bytes, maxBytes) : bytes
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// GET /api/admin/uploads/key — the session's upload public key, created on
|
|
410
|
+
// first use. Only this session's server-side private key opens what is sealed to it.
|
|
411
|
+
async function handleUploadKey(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
412
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
413
|
+
const s = await readManageSession(res, deps, cookie)
|
|
414
|
+
if (!s) return
|
|
415
|
+
const key = s.session.uploadKey ?? await deps.db.ensureSessionUploadKey(s.session.id, newUploadKey())
|
|
416
|
+
if (!key) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
417
|
+
sendJson(res, 200, { key: uploadPublicKey(key) })
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
421
|
+
const cancel = req.method === 'DELETE'
|
|
422
|
+
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
423
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
424
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
425
|
+
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
426
|
+
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
427
|
+
if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
428
|
+
const id = match[2]!, kind = match[1] as UploadKind
|
|
429
|
+
if (cancel) {
|
|
430
|
+
const count = Number(req.headers['x-upload-parts'])
|
|
431
|
+
if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
432
|
+
await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
|
|
433
|
+
sendJson(res, 200, { ok: true }); return
|
|
434
|
+
}
|
|
435
|
+
const index = Number(match[3])
|
|
436
|
+
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
437
|
+
const discard = async () => {
|
|
438
|
+
if (validUpload(id, index + 1)) {
|
|
439
|
+
await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
443
|
+
let bytes
|
|
444
|
+
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxSealedBytes(maxBytes))) }
|
|
445
|
+
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
446
|
+
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
447
|
+
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
448
|
+
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
449
|
+
sendJson(res, 200, { ok: true })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// `maxBytes` overrides the small default for the one endpoint whose JSON body
|
|
453
|
+
// legitimately grows (triage entry batches).
|
|
454
|
+
async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<unknown> {
|
|
455
|
+
const buf = await readBodyBytes(req, maxBytes)
|
|
456
|
+
return JSON.parse(buf.toString('utf8') || 'null')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The router enforces same-origin for every request. A mutation also needs an
|
|
460
|
+
// authenticated session whose CSRF token matches the X-CSRF-Token header.
|
|
461
|
+
// Returns the session, or null after having already sent the 401/403. An
|
|
462
|
+
// admin's view as another user writes nothing; `view` admits only the
|
|
463
|
+
// endpoints that end it.
|
|
464
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, { view = false } = {}): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
465
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
466
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
467
|
+
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
468
|
+
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
469
|
+
if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
|
|
470
|
+
if (s.session.viewer && !view) { sendViewOnly(res); return null }
|
|
471
|
+
return s
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sendViewOnly(res: ServerResponse): void {
|
|
475
|
+
sendJson(res, 403, { error: VIEW_ONLY_ERROR })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Reads stay available while an admin views as another user. Everything else
|
|
479
|
+
// is refused before dispatch, except ending the view or signing out, and the
|
|
480
|
+
// batch report preview, a POST without effects. Issue authorization would
|
|
481
|
+
// grant GitHub access on the viewed user's behalf.
|
|
482
|
+
function viewAllows(method: string, path: string): boolean {
|
|
483
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
484
|
+
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// POST /api/auth/logout — drop the session (same-origin + CSRF), which also
|
|
488
|
+
// ends a view opened from it.
|
|
489
|
+
async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
490
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
491
|
+
const s = await checkMutation(req, res, deps, cookie, { view: true })
|
|
492
|
+
if (s == null) return
|
|
493
|
+
await endSession(deps.config, deps.db, cookie)
|
|
494
|
+
const cleared = clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure)
|
|
495
|
+
res.writeHead(204, { 'set-cookie': s.session.viewer ? [cleared, clearViewCookie(deps.config)] : cleared, 'cache-control': 'no-store' })
|
|
496
|
+
res.end()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// POST /api/auth/view-as — an admin opens a read-only view as another user
|
|
500
|
+
// (body { userId }); DELETE ends it. Viewing as oneself is refused.
|
|
501
|
+
async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
502
|
+
const method = req.method ?? 'GET'
|
|
503
|
+
if (method === 'DELETE') {
|
|
504
|
+
if (await checkMutation(req, res, deps, cookie, { view: true }) == null) return
|
|
505
|
+
await endViewSession(deps.config, deps.db, cookie)
|
|
506
|
+
res.writeHead(204, { 'set-cookie': clearViewCookie(deps.config), 'cache-control': 'no-store' })
|
|
507
|
+
res.end(); return
|
|
508
|
+
}
|
|
509
|
+
if (method !== 'POST') { send405(res, 'POST, DELETE'); return }
|
|
510
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
511
|
+
if (s == null) return
|
|
512
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
513
|
+
let body: unknown
|
|
514
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
515
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
516
|
+
if (typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
517
|
+
if (userId === s.user.id) { sendJson(res, 400, { error: 'cannot-view-as-self' }); return }
|
|
518
|
+
const target = (await deps.db.listUsers()).find(user => user.id === userId)
|
|
519
|
+
if (!target) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
520
|
+
// The insert itself rechecks the admin session and role.
|
|
521
|
+
const setCookie = await startViewSession(deps.config, deps.db, s.session, userId, Date.now())
|
|
522
|
+
if (setCookie == null) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
523
|
+
await activity(deps, s.user, 'access', `viewed as ${target.login}`)
|
|
524
|
+
res.writeHead(204, { 'set-cookie': setCookie, 'cache-control': 'no-store' })
|
|
525
|
+
res.end()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
|
|
529
|
+
// refused for the caller's own id so an admin can't drop their own admin (keeps
|
|
530
|
+
// at least one admin).
|
|
531
|
+
async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
532
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
533
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
534
|
+
if (s == null) return
|
|
535
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
536
|
+
let body: unknown
|
|
537
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
538
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
539
|
+
const role = (body as { role?: unknown } | null)?.role
|
|
540
|
+
if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
541
|
+
if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
|
|
542
|
+
const target = (await deps.db.listUsers()).find(user => user.id === userId)
|
|
543
|
+
// A caller can hold the body open while their admin access is revoked.
|
|
544
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
545
|
+
const ok = await deps.db.setUserRole(userId, role)
|
|
546
|
+
if (!ok) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
547
|
+
if (target?.role !== role) await activity(deps, s.user, 'access', `changed ${target?.login ?? userId}'s role to ${role}`)
|
|
548
|
+
sendJson(res, 200, { ok: true })
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// admin OR manage — the roles allowed into the management pages (repositories,
|
|
552
|
+
// reports), matching the sidebar account-menu gate. Sends 403 + returns false
|
|
553
|
+
// otherwise.
|
|
554
|
+
function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
|
|
555
|
+
if (user.role === 'admin' || user.role === 'manage') return true
|
|
556
|
+
sendJson(res, 403, { error: 'forbidden' })
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// A read endpoint open to admin|manage: resolve the session (401 if absent),
|
|
561
|
+
// then gate on the role (403). Returns the session, or null after the response
|
|
562
|
+
// was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
|
|
563
|
+
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
564
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
565
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
566
|
+
if (!requireManageRole(res, s.user)) return null
|
|
567
|
+
return s
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
571
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
572
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
573
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
574
|
+
return s
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
578
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
579
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
580
|
+
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
581
|
+
return s
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
585
|
+
// levels. Names are intentionally absent; the client derives them from ids.
|
|
586
|
+
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
587
|
+
const s = await readManageSession(res, deps, cookie)
|
|
588
|
+
if (s == null) return
|
|
589
|
+
sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// GET /api/admin/repositories — the connected repositories by default. The
|
|
593
|
+
// potentially large GitHub discovery lists are opt-in (`scope=installed` or
|
|
594
|
+
// `scope=public`). Return the complete catalogue for local search and organization
|
|
595
|
+
// filtering. Admin-only. No CSRF.
|
|
596
|
+
async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
|
|
597
|
+
if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
|
|
598
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
599
|
+
if (s == null) return
|
|
600
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
601
|
+
// Keep the original unparameterized response shape for older clients. The
|
|
602
|
+
// admin UI always sends an explicit scope, which opts into the connected-first
|
|
603
|
+
// discovery flow below.
|
|
604
|
+
if ([...url.searchParams.keys()].length === 0) {
|
|
605
|
+
const token = await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
|
|
606
|
+
const listing = await collectRepos(deps.config, token)
|
|
607
|
+
const selected = new Set((await deps.db.listSelectedRepos()).map((repo) => repo.repoId))
|
|
608
|
+
sendJson(res, 200, {
|
|
609
|
+
installUrl: installUrl(deps.config),
|
|
610
|
+
repositories: listing.repositories.map((repo) => ({
|
|
611
|
+
id: repo.id, fullName: repo.fullName, private: repo.private, visibility: repo.visibility, htmlUrl: repo.htmlUrl,
|
|
612
|
+
installed: repo.installationId != null, selected: selected.has(repo.id),
|
|
613
|
+
})),
|
|
614
|
+
tokenMissing: listing.tokenMissing,
|
|
615
|
+
})
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
const scope = url.searchParams.get('scope') ?? 'connected'
|
|
619
|
+
if (scope !== 'connected' && scope !== 'installed' && scope !== 'public') { sendJson(res, 400, { error: 'bad-scope' }); return }
|
|
620
|
+
const selectedRows = await deps.db.listSelectedRepos()
|
|
621
|
+
const allRows = scope === 'connected' ? await deps.db.listAllRepos() : []
|
|
622
|
+
const selected = new Set(selectedRows.map((r) => r.repoId))
|
|
623
|
+
let tokenMissing = false
|
|
624
|
+
let repositories
|
|
625
|
+
if (scope === 'connected') {
|
|
626
|
+
repositories = allRows.map((r) => ({
|
|
627
|
+
id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
|
|
628
|
+
installed: r.installationId != null, selected: r.active, active: r.active,
|
|
629
|
+
}))
|
|
630
|
+
} else {
|
|
631
|
+
const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
|
|
632
|
+
const token = showAll ? null : await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
|
|
633
|
+
let listing
|
|
634
|
+
try {
|
|
635
|
+
listing = await discovery.list(scope, s.user.id, token, showAll, url.searchParams.get('refresh') === 'true')
|
|
636
|
+
} catch (err) {
|
|
637
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
638
|
+
sendJson(res, 502, { error: err.message })
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
tokenMissing = listing.tokenMissing
|
|
642
|
+
repositories = listing.repositories
|
|
643
|
+
.map((r) => ({
|
|
644
|
+
id: r.id, fullName: r.fullName, private: r.private, visibility: r.visibility, htmlUrl: r.htmlUrl,
|
|
645
|
+
installed: r.installationId != null, selected: selected.has(r.id),
|
|
646
|
+
}))
|
|
647
|
+
}
|
|
648
|
+
repositories.sort((a, b) => a.fullName.localeCompare(b.fullName))
|
|
649
|
+
const total = repositories.length
|
|
650
|
+
sendJson(res, 200, {
|
|
651
|
+
installUrl: installUrl(deps.config),
|
|
652
|
+
canAddAnyPublicRepository: canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id)),
|
|
653
|
+
repositories,
|
|
654
|
+
connectedCount: selectedRows.length,
|
|
655
|
+
inactiveCount: allRows.filter((r) => !r.active).length,
|
|
656
|
+
total,
|
|
657
|
+
tokenMissing,
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// The select half of the toggle: re-list the caller's reachable repos to VERIFY
|
|
662
|
+
// access (never trust a client-supplied id) and capture the server-derived read
|
|
663
|
+
// context (installation id, default branch). A PRIVATE repo with no installation
|
|
664
|
+
// can't be read server-side → 409. Stores via selectRepo (upsert).
|
|
665
|
+
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
|
|
666
|
+
if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
667
|
+
const userId = user.id
|
|
668
|
+
const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
|
|
669
|
+
const { repositories } = await collectRepos(deps.config, token)
|
|
670
|
+
let repo = repositories.find((r) => r.id === repoId)
|
|
671
|
+
// A WHITEHAT addition may not appear in /user/repos. Revalidate the stored
|
|
672
|
+
// canonical name when reactivating it, with the same permission gates.
|
|
673
|
+
if (repo == null && canAddAnyPublicRepository(user, await deps.db.getUserGithubId(userId))) {
|
|
674
|
+
const stored = (await deps.db.listAllRepos()).find(r => r.repoId === repoId)
|
|
675
|
+
if (stored && !stored.private && stored.installationId == null) {
|
|
676
|
+
try { repo = await fetchPublicRepository(stored.fullName) } catch (err) {
|
|
677
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
678
|
+
sendJson(res, err.status, { error: err.message }); return
|
|
679
|
+
}
|
|
680
|
+
if (repo.id !== repoId) { sendJson(res, 409, { error: 'repo-identity-changed' }); return }
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (repo == null) { sendJson(res, 404, { error: 'repo-not-accessible' }); return }
|
|
684
|
+
// (0) Installed or explicitly public. For (3a), an admin may choose any App
|
|
685
|
+
// installation; for (3b), /user/repos is the public involvement evidence.
|
|
686
|
+
if (repo.installationId == null) {
|
|
687
|
+
if (repo.private || repo.visibility !== 'public') { sendJson(res, 409, { error: 'repo-not-readable' }); return }
|
|
688
|
+
if (!passesPublicRepositorySafeguard(await deps.db.getUserGithubId(userId), repositories.some(r => r.id === repoId))) {
|
|
689
|
+
sendJson(res, 403, { error: 'forbidden' }); return
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
693
|
+
await connectRepository(res, deps, user, repo)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repo: ConnectedRepo): Promise<void> {
|
|
697
|
+
const alreadySelected = (await deps.db.listSelectedRepos()).some(row => row.repoId === repo.id)
|
|
698
|
+
await deps.db.selectRepo({
|
|
699
|
+
repoId: repo.id, fullName: repo.fullName, private: repo.private,
|
|
700
|
+
installationId: repo.installationId, defaultBranch: repo.defaultBranch,
|
|
701
|
+
htmlUrl: repo.htmlUrl, addedBy: user.id,
|
|
702
|
+
}, Date.now())
|
|
703
|
+
if (!alreadySelected) await activity(deps, user, 'repository', 'connected a repository', { repo: repo.fullName })
|
|
704
|
+
sendJson(res, 200, { ok: true, selected: true })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Separate from ordinary discovery: WHITEHAT bypasses only public involvement.
|
|
708
|
+
// Authentication, server admin permission, origin and CSRF remain mandatory.
|
|
709
|
+
async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
710
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
711
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
712
|
+
if (s == null) return
|
|
713
|
+
if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
714
|
+
let body: unknown
|
|
715
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
716
|
+
const fullName = publicRepositoryName((body as { repository?: unknown } | null)?.repository)
|
|
717
|
+
if (fullName == null) { sendJson(res, 400, { error: 'bad-repository' }); return }
|
|
718
|
+
let repo
|
|
719
|
+
try { repo = await fetchPublicRepository(fullName) } catch (err) {
|
|
720
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
721
|
+
sendJson(res, err.status, { error: err.message }); return
|
|
722
|
+
}
|
|
723
|
+
// A GitHub lookup can outlive a role change or logout.
|
|
724
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
725
|
+
// Only public access was verified. Keep its null installation context rather
|
|
726
|
+
// than restoring a stored App installation that may no longer cover this repo.
|
|
727
|
+
await connectRepository(res, deps, s.user, repo)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Associate an existing connection with this App without changing its active
|
|
731
|
+
// state, ownership, team grants, or stored data. GitHub grants remain separate
|
|
732
|
+
// from the per-user checks required for browsing source.
|
|
733
|
+
async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
734
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
735
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
736
|
+
if (s == null) return
|
|
737
|
+
let body: unknown
|
|
738
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
739
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
740
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
741
|
+
const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
|
|
742
|
+
if (repo == null) { sendJson(res, 404, { error: 'repo-not-connected' }); return }
|
|
743
|
+
if (repo.installationId != null) { sendJson(res, 200, { connected: true }); return }
|
|
744
|
+
let installationId
|
|
745
|
+
try { installationId = await repositoryInstallation(deps.config, repo.repoId, repo.fullName) } catch (err) {
|
|
746
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
747
|
+
// These credentials belong to the server App, not the user's session.
|
|
748
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return
|
|
749
|
+
}
|
|
750
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
751
|
+
if (installationId == null) {
|
|
752
|
+
const current = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
|
|
753
|
+
if (current == null || current.fullName !== repo.fullName || current.addedAt !== repo.addedAt || current.installationId != null) {
|
|
754
|
+
sendJson(res, 409, { error: 'repo-connection-changed' }); return
|
|
755
|
+
}
|
|
756
|
+
const url = installUrl(deps.config)
|
|
757
|
+
if (url == null) { sendJson(res, 503, { error: 'github-app-not-configured' }); return }
|
|
758
|
+
sendJson(res, 200, { connected: false, installUrl: url }); return
|
|
759
|
+
}
|
|
760
|
+
if (!await deps.db.connectRepoInstallation(repo, installationId, s.session.id, Date.now())) {
|
|
761
|
+
sendJson(res, 409, { error: 'repo-connection-changed' }); return
|
|
762
|
+
}
|
|
763
|
+
await activity(deps, s.user, 'repository', 'connected GitHub App access', { repo: repo.fullName })
|
|
764
|
+
sendJson(res, 200, { connected: true })
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// POST /api/admin/repositories/select — an admin toggles whether a repo is
|
|
768
|
+
// active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
|
|
769
|
+
// selected }. selected:true verifies + records the read context; selected:false
|
|
770
|
+
// deactivates the row while retaining its reports, bundles, and metadata.
|
|
771
|
+
async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
772
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
773
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
774
|
+
if (s == null) return
|
|
775
|
+
let body: unknown
|
|
776
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
777
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
778
|
+
const selected = (body as { selected?: unknown } | null)?.selected
|
|
779
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof selected !== 'boolean') {
|
|
780
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
781
|
+
}
|
|
782
|
+
if (!selected) {
|
|
783
|
+
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
784
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
785
|
+
await deps.db.deactivateRepo(repoId)
|
|
786
|
+
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
787
|
+
sendJson(res, 200, { ok: true, selected: false }); return
|
|
788
|
+
}
|
|
789
|
+
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
793
|
+
const ids = new Set<string>()
|
|
794
|
+
for (const report of reports) {
|
|
795
|
+
const bytes = await deps.reportStore.get(report.id)
|
|
796
|
+
if (bytes == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unavailable`)
|
|
797
|
+
const parsed = await loadManagedFindings(bytes.toString('utf8'), report.filename)
|
|
798
|
+
if (parsed == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unreadable`)
|
|
799
|
+
for (const finding of parsed.findings) {
|
|
800
|
+
const id = (finding as { id?: unknown })?.id
|
|
801
|
+
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return ids
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
|
|
808
|
+
const allReports = await deps.db.listReports()
|
|
809
|
+
const reports = allReports.filter((report) => report.repoId === repoId)
|
|
810
|
+
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
811
|
+
const triageIds = await repositoryExclusiveTriageIds(deps, reports, allReports.filter((report) => report.repoId !== repoId))
|
|
812
|
+
return {
|
|
813
|
+
reports: reports.map((report) => ({ id: report.id, filename: report.filename, repoDirectory: report.repoDirectory })),
|
|
814
|
+
bundles,
|
|
815
|
+
triageCount: triageIds.length,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
|
|
820
|
+
const targetIds = await repositoryFindingIds(deps, reports)
|
|
821
|
+
const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
|
|
822
|
+
const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
|
|
823
|
+
// Removal includes every exclusive finding, including annotations created
|
|
824
|
+
// during the blob scan; impact only needs the currently annotated subset.
|
|
825
|
+
const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
|
|
826
|
+
if (annotatedIds.size === 0) return []
|
|
827
|
+
// Impact can skip unannotated findings; deletion must also cover new writes.
|
|
828
|
+
// TODO(managed): Persist finding IDs per report at upload time and maintain
|
|
829
|
+
// the index on report deletion. Use it for repository impact and triage
|
|
830
|
+
// cleanup so overlap checks do not fetch and parse every other report blob.
|
|
831
|
+
// Deferred for production; the preview still performs the blob scan below.
|
|
832
|
+
const otherIds = await repositoryFindingIds(deps, otherReports)
|
|
833
|
+
return [...annotatedIds].filter((id) => !otherIds.has(id))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// GET /api/admin/repositories/impact — show the data a permanent repository
|
|
837
|
+
// removal would destroy. This is intentionally separate from the repository
|
|
838
|
+
// list so the normal page stays cheap even with many reports.
|
|
839
|
+
async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
840
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
841
|
+
if (s == null) return
|
|
842
|
+
const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
|
|
843
|
+
if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
844
|
+
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
845
|
+
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
846
|
+
sendJson(res, 200, { repoId, fullName: repo.fullName, ...await repositoryImpact(deps, repoId) })
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// POST /api/admin/repositories/remove — permanently remove a repository and
|
|
850
|
+
// attached reports/bundles. The exact name + explicit acknowledgement are
|
|
851
|
+
// checked server-side too; the UI's dialog is a usability guard, not the policy.
|
|
852
|
+
async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
853
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
854
|
+
if (s == null) return
|
|
855
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
856
|
+
let body: unknown
|
|
857
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
858
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
859
|
+
const fullName = (body as { fullName?: unknown } | null)?.fullName
|
|
860
|
+
const acknowledge = (body as { acknowledge?: unknown } | null)?.acknowledge
|
|
861
|
+
const deleteTriage = (body as { deleteTriage?: unknown } | null)?.deleteTriage
|
|
862
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof fullName !== 'string' || acknowledge !== true || typeof deleteTriage !== 'boolean') {
|
|
863
|
+
sendJson(res, 400, { error: 'confirmation-required' }); return
|
|
864
|
+
}
|
|
865
|
+
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
866
|
+
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
867
|
+
if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
|
|
868
|
+
const allReports = deleteTriage ? await deps.db.listReports() : []
|
|
869
|
+
const triageIds = deleteTriage
|
|
870
|
+
? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
|
|
871
|
+
: []
|
|
872
|
+
const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
|
|
873
|
+
deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
|
|
874
|
+
// Remove metadata first so a blob-store failure leaves an orphaned blob for
|
|
875
|
+
// later cleanup, rather than a live row pointing at missing report data.
|
|
876
|
+
for (const report of reports) {
|
|
877
|
+
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
878
|
+
await deps.reportStore.delete(report.id).catch(() => {})
|
|
879
|
+
}
|
|
880
|
+
for (const bundle of bundles) {
|
|
881
|
+
// Best-effort derivative cleanup follows the committed metadata removal.
|
|
882
|
+
await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
883
|
+
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
884
|
+
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
885
|
+
}
|
|
886
|
+
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Strip a client-supplied upload filename to a safe display string. The bytes
|
|
890
|
+
// are keyed by a server uuid. This name is used for display, Content-Disposition,
|
|
891
|
+
// and format detection: decode URLs, remove controls/paths, and cap the basename
|
|
892
|
+
// without discarding the extension that the report reader needs.
|
|
893
|
+
// Falls back to `fallback` when nothing usable remains.
|
|
894
|
+
function sanitizeFilename(raw: string | null, fallback: string): string {
|
|
895
|
+
if (raw == null || raw === '') return fallback
|
|
896
|
+
let decoded = raw
|
|
897
|
+
try { decoded = decodeURIComponent(raw) } catch { decoded = raw }
|
|
898
|
+
// Build the cleaned name char-by-char: drop control chars, fold path
|
|
899
|
+
// separators to '_' (avoids a control-character regex).
|
|
900
|
+
let cleaned = ''
|
|
901
|
+
for (const ch of decoded) {
|
|
902
|
+
const code = ch.codePointAt(0) ?? 0
|
|
903
|
+
if (code < 0x20 || code === 0x7f) continue
|
|
904
|
+
cleaned += ch === '/' || ch === '\\' ? '_' : ch
|
|
905
|
+
}
|
|
906
|
+
cleaned = cleaned.trim()
|
|
907
|
+
if (cleaned.length <= 200) return cleaned || fallback
|
|
908
|
+
const dot = cleaned.lastIndexOf('.')
|
|
909
|
+
const suffix = dot > 0 ? cleaned.slice(dot) : ''
|
|
910
|
+
// An extension that alone exceeds the cap cannot fit; ordinary extensions
|
|
911
|
+
// (including case-sensitive display names such as .CSV) remain unchanged.
|
|
912
|
+
const extension = suffix.length < 200 ? suffix : ''
|
|
913
|
+
return cleaned.slice(0, 200 - extension.length) + extension
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// The selected repos a report / bundle can be linked to, for the upload UI's
|
|
917
|
+
// repo picker. Minimal shape (id + full name).
|
|
918
|
+
function selectableRepos(repos: { repoId: number; fullName: string }[]): { repoId: number; fullName: string }[] {
|
|
919
|
+
return repos.map((r) => ({ repoId: r.repoId, fullName: r.fullName }))
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Resolve the optional X-Repo-Id upload header to a selected repo id, or null
|
|
923
|
+
// when absent. Validates the id is currently in the selected-repos set (so the
|
|
924
|
+
// FK can't dangle); on a bad / unknown id sends 400 and returns { ok: false }.
|
|
925
|
+
async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps): Promise<{ ok: true; repoId: number | null } | { ok: false }> {
|
|
926
|
+
const raw = firstHeader(req.headers['x-repo-id'])
|
|
927
|
+
if (raw == null || raw === '') return { ok: true, repoId: null }
|
|
928
|
+
const n = Number(raw)
|
|
929
|
+
if (!Number.isSafeInteger(n)) { sendJson(res, 400, { error: 'bad-repo' }); return { ok: false } }
|
|
930
|
+
const selected = await deps.db.listSelectedRepos()
|
|
931
|
+
if (!selected.some((r) => r.repoId === n)) { sendJson(res, 400, { error: 'repo-not-selected' }); return { ok: false } }
|
|
932
|
+
return { ok: true, repoId: n }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// POST /api/admin/reports/set-repo — attach / detach a stored report's repo
|
|
936
|
+
// + directory link. Mutation: same-origin + CSRF, admin|manage. Body
|
|
937
|
+
// { reportId, repoId, directory }, where repoId is null (detach) or a
|
|
938
|
+
// currently-selected repo id. Directory is relative to the repository root.
|
|
939
|
+
async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
940
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
941
|
+
if (s == null) return
|
|
942
|
+
let body: unknown
|
|
943
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
944
|
+
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
945
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
946
|
+
const directory = (body as { directory?: unknown } | null)?.directory ?? ''
|
|
947
|
+
if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
948
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
949
|
+
const normalized = normalizeTeamPath(directory)
|
|
950
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
951
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
|
|
952
|
+
if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
|
|
953
|
+
await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
|
|
954
|
+
}
|
|
955
|
+
sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// POST /api/admin/reports/set-visible — publish or hide a stored report. New
|
|
959
|
+
// reports start hidden so an admin can inspect the parsed metadata first.
|
|
960
|
+
async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
961
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
962
|
+
if (s == null) return
|
|
963
|
+
let body: unknown
|
|
964
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
965
|
+
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
966
|
+
const visible = (body as { visible?: unknown } | null)?.visible
|
|
967
|
+
if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
968
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
|
|
969
|
+
if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
970
|
+
sendJson(res, 200, { ok: true, visible })
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
974
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
975
|
+
if (s == null) return
|
|
976
|
+
let body: unknown
|
|
977
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
978
|
+
const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
|
|
979
|
+
const visible = (body as { visible?: unknown } | null)?.visible
|
|
980
|
+
if (typeof bundleId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
981
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'visibility', visible })
|
|
982
|
+
if (bundle.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a bundle' : 'hid a bundle', { bundleId, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
983
|
+
sendJson(res, 200, { ok: true, visible })
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
987
|
+
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
988
|
+
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
989
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
990
|
+
if (s == null) return
|
|
991
|
+
let body: unknown
|
|
992
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
993
|
+
const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
|
|
994
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
995
|
+
const normalized = normalizeTeamPath((body as { directory?: unknown } | null)?.directory ?? '')
|
|
996
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
997
|
+
const directory = repoId == null ? '' : normalized.path ?? ''
|
|
998
|
+
if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
999
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
1000
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
|
|
1001
|
+
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
|
|
1002
|
+
sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// GET /api/admin/reports — the uploaded reports for the "Manage reports" page.
|
|
1006
|
+
// Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
|
|
1007
|
+
// `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
|
|
1008
|
+
// the upload repo picker.
|
|
1009
|
+
async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1010
|
+
sendJson(res, 200, {
|
|
1011
|
+
...await deps.db.getReportCatalog(session.id, Date.now()),
|
|
1012
|
+
maxBytes: deps.config.maxReportBytes,
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Resolve a report's bundle link from its embedded `bundleHashes`: the first
|
|
1017
|
+
// declared integrity that has a stored bundle wins (bundleId set). When none is
|
|
1018
|
+
// stored yet, keep the first declared integrity so a later bundle upload of it
|
|
1019
|
+
// re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
|
|
1020
|
+
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
|
|
1021
|
+
const hashes = reportBundleHashes(bytes)
|
|
1022
|
+
for (const h of hashes) {
|
|
1023
|
+
const b = await deps.db.getBundleByIntegrity(h)
|
|
1024
|
+
if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
|
|
1025
|
+
}
|
|
1026
|
+
return { bundleId: null, integrity: hashes[0] ?? null }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, report: ReportRecord, deduped: boolean): Promise<void> {
|
|
1030
|
+
const current = await checkMutation(req, res, deps, cookie)
|
|
1031
|
+
if (!current || !requireManageRole(res, current.user)) return
|
|
1032
|
+
// Uploading known bytes cannot reveal another team's report or its source
|
|
1033
|
+
// bundle. The stored location/ownership wins over the upload headers.
|
|
1034
|
+
if (!(await canViewReport(deps, current.user, report.id))) { sendJson(res, 409, { error: 'report-conflict' }); return }
|
|
1035
|
+
const bundleId = report.bundleId != null && await canAccessBundle(deps, current.user, report.bundleId) ? report.bundleId : null
|
|
1036
|
+
const { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible } = report
|
|
1037
|
+
sendJson(res, deduped ? 200 : 201, { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible, bundleId, ...(deduped ? { deduped: true } : {}) })
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// POST /api/admin/reports — upload a report. Mutation: same-origin + CSRF,
|
|
1041
|
+
// admin|manage. The body is the raw report bytes (any findings format — JSON /
|
|
1042
|
+
// markdown / CSV — archived as-is, like the e2e objstore; the server parses them
|
|
1043
|
+
// downstream). Display name rides X-Report-Filename; the repository and
|
|
1044
|
+
// directory come from the report header, or from optional repository/directory
|
|
1045
|
+
// headers when the report has no repository metadata, else from the repository
|
|
1046
|
+
// its findings name when that resolves within the uploader's access. The bundle link is
|
|
1047
|
+
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
1048
|
+
// published. Identical content reuses its stored identity and metadata. Bytes
|
|
1049
|
+
// are written first (keyed by a fresh uuid) then the metadata row. A definite
|
|
1050
|
+
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
1051
|
+
// commit retains it. 413 over the cap, 400 on empty.
|
|
1052
|
+
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1053
|
+
let s = await checkMutation(req, res, deps, cookie)
|
|
1054
|
+
if (s == null) return
|
|
1055
|
+
if (!requireManageRole(res, s.user)) return
|
|
1056
|
+
let bytes: Buffer
|
|
1057
|
+
try {
|
|
1058
|
+
bytes = await readUploadBody(req, deps, s.session, 'reports')
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
1061
|
+
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
// Shared storage reads may outlast a session or role change.
|
|
1065
|
+
s = await checkMutation(req, res, deps, cookie)
|
|
1066
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
1067
|
+
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
1068
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
|
|
1069
|
+
const parsed = readManagedReport(bytes.toString('utf8'), filename)
|
|
1070
|
+
if (parsed.data == null) {
|
|
1071
|
+
sendJson(res, 400, { error: 'invalid-report', reason: parsed.reason ?? 'Unrecognized report format' })
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
if (parsed.format === 'links' || parsed.data.source === 'links') {
|
|
1075
|
+
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
1076
|
+
sendJson(res, result.reused ? 200 : 201, { ...result.report, kind: 'links', deduped: result.reused })
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
const repoGithub = reportRepoGithub(parsed.data)
|
|
1080
|
+
// The browser splits these documents before upload. Enforce that boundary
|
|
1081
|
+
// for direct API clients too: one stored record has only one repo's ACL.
|
|
1082
|
+
if (parsed.format === 'markdown-generic' && repoGithub == null) {
|
|
1083
|
+
sendJson(res, 400, { error: 'invalid-report', reason: 'Multi-product Markdown must be split into one report per product before upload.' })
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
const repoEmbedded = repoGithub != null
|
|
1087
|
+
const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
|
|
1088
|
+
let headerDirectory = rawHeaderDirectory
|
|
1089
|
+
try { headerDirectory = decodeURIComponent(rawHeaderDirectory) } catch { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1090
|
+
// Validate the raw header, not repoDirectory()'s display/link normalization,
|
|
1091
|
+
// which trims characters and turns invalid paths into the repository root.
|
|
1092
|
+
const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
|
|
1093
|
+
const normalizedDirectory = normalizeTeamPath(requestedDirectory)
|
|
1094
|
+
if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1095
|
+
let directory = normalizedDirectory.path ?? ''
|
|
1096
|
+
const analyzer = typeof parsed.data.source === 'string' ? parsed.data.source : null
|
|
1097
|
+
const sha256 = createHash('sha256').update(bytes).digest('base64url')
|
|
1098
|
+
// Reuploads retain their stored location, including after an alias changes.
|
|
1099
|
+
const existing = await deps.db.getReportByHash(sha256, analyzer)
|
|
1100
|
+
let repoId: number | null = null
|
|
1101
|
+
if (repoGithub != null) {
|
|
1102
|
+
const location = existing ? { repoId: existing.repoId, directory: existing.repoDirectory }
|
|
1103
|
+
: await deps.db.getRepositoryImportLocation(repoGithub, directory)
|
|
1104
|
+
repoId = location.repoId; directory = location.directory
|
|
1105
|
+
if (!existing && repoId == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
|
|
1106
|
+
}
|
|
1107
|
+
// When the report has no repository header, X-Repo-Id lets the managed uploader
|
|
1108
|
+
// assign it at upload time. Clients may omit it and attach the report later.
|
|
1109
|
+
if (repoGithub == null) {
|
|
1110
|
+
const legacyRepo = await resolveUploadRepoId(req, res, deps)
|
|
1111
|
+
if (!legacyRepo.ok) return
|
|
1112
|
+
repoId = legacyRepo.repoId
|
|
1113
|
+
// Otherwise the repository its findings name assigns a new report, like
|
|
1114
|
+
// embedded metadata, when it resolves to a connected destination within
|
|
1115
|
+
// the uploader's access. X-Repo-Directory still overrides the directory.
|
|
1116
|
+
if (repoId == null && !existing) {
|
|
1117
|
+
const suggestion = await suggestReportLocation(deps, s.user, parsed.data)
|
|
1118
|
+
if (suggestion?.repoId != null) {
|
|
1119
|
+
const inferred = req.headers['x-repo-directory'] == null ? suggestion.directory ?? '' : directory
|
|
1120
|
+
if (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, suggestion.repoId, inferred)) {
|
|
1121
|
+
repoId = suggestion.repoId
|
|
1122
|
+
directory = inferred
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (repoId != null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
1128
|
+
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
1129
|
+
const id = randomUUID()
|
|
1130
|
+
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
1131
|
+
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
|
|
1132
|
+
const dataKey = await deps.reportStore.put(id, bytes)
|
|
1133
|
+
let report: ReportRecord
|
|
1134
|
+
try {
|
|
1135
|
+
report = await deps.db.insertOrReuseReport({
|
|
1136
|
+
id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
|
|
1137
|
+
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId,
|
|
1138
|
+
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
1139
|
+
}, Date.now(), s.session.id)
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
// A successful COMMIT can lose its acknowledgement. Never delete a file
|
|
1142
|
+
// until a writer-locked read confirms that this candidate did not commit.
|
|
1143
|
+
let committed: ReportRecord | null
|
|
1144
|
+
try { committed = await deps.db.resolveReportUpload(id) }
|
|
1145
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
|
|
1146
|
+
if (committed) report = committed
|
|
1147
|
+
else { await deps.reportStore.delete(id).catch(() => {}); throw err }
|
|
1148
|
+
}
|
|
1149
|
+
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
1150
|
+
await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// GET /api/admin/reports/<id> — download a stored report (admin|manage). Serves
|
|
1154
|
+
// the bytes with the recorded content-type + filename. The recorded content-type
|
|
1155
|
+
// is uploader-supplied, so the response is forced to download
|
|
1156
|
+
// (Content-Disposition: attachment) AND marked `nosniff` — it can't be sniffed
|
|
1157
|
+
// or rendered inline against the app origin. 404 when there's no such report;
|
|
1158
|
+
// 503 when the row exists but the bytes don't (store desync).
|
|
1159
|
+
async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1160
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1161
|
+
if (s == null) return
|
|
1162
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1163
|
+
const rec = await deps.db.getReport(id)
|
|
1164
|
+
if (rec == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1165
|
+
const bytes = await deps.reportStore.get(id)
|
|
1166
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1167
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1168
|
+
if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1169
|
+
res.writeHead(200, {
|
|
1170
|
+
'content-type': rec.contentType,
|
|
1171
|
+
'content-length': String(bytes.length),
|
|
1172
|
+
'content-disposition': attachmentDisposition(rec.filename),
|
|
1173
|
+
'x-content-type-options': 'nosniff',
|
|
1174
|
+
'cache-control': 'no-store',
|
|
1175
|
+
})
|
|
1176
|
+
writeResponse(res, bytes)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// DELETE /api/admin/reports/<id> — remove a report (admin|manage). Mutation:
|
|
1180
|
+
// same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
|
|
1181
|
+
// was no such report.
|
|
1182
|
+
async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1183
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1184
|
+
if (s == null) return
|
|
1185
|
+
if (!requireManageRole(res, s.user)) return
|
|
1186
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
1187
|
+
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1188
|
+
await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
|
|
1189
|
+
await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
1190
|
+
sendJson(res, 200, { ok: true })
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Apply the same access rule to inventory, cached derivatives, raw downloads
|
|
1194
|
+
// and mutations. Ownership is a manager exception; lower roles require teams.
|
|
1195
|
+
async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: string): Promise<boolean> {
|
|
1196
|
+
if (!roleAtLeast(user.role, 'view')) return false
|
|
1197
|
+
const rec = await deps.db.getBundle(id)
|
|
1198
|
+
if (!rec) return false
|
|
1199
|
+
if (user.role === 'admin') return true
|
|
1200
|
+
if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
|
|
1201
|
+
return rec.visible && rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
|
|
1205
|
+
const repos = await deps.db.listSelectedRepos()
|
|
1206
|
+
const access = await contentAccess(deps.db, user)
|
|
1207
|
+
return repos.filter(access.bundle)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
|
|
1211
|
+
const [githubUserId, token] = await Promise.all([
|
|
1212
|
+
deps.db.getUserGithubId(userId), ensureUserAccessToken(deps.config, deps.db, userId, Date.now()),
|
|
1213
|
+
])
|
|
1214
|
+
return { githubUserId, token }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// After a catalog response: read the commit details it lacked with this
|
|
1218
|
+
// user's repository access, for the next catalog to send.
|
|
1219
|
+
async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, missing: readonly CatalogCommit[]): Promise<void> {
|
|
1220
|
+
let browser: ReturnType<typeof createRepositoryBrowser> | undefined
|
|
1221
|
+
await backfillBundleCommits(deps.db, userId, missing, async repoId => {
|
|
1222
|
+
const repo = (await deps.db.listAllRepos()).find(item => item.repoId === repoId)
|
|
1223
|
+
if (!repo) return null
|
|
1224
|
+
browser ??= createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, userId))
|
|
1225
|
+
return browser.reader(repo)
|
|
1226
|
+
}).catch(err => { console.warn('managed: commit backfill failed:', err) })
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1230
|
+
// access after selection, before returning any refs or source directory data.
|
|
1231
|
+
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1232
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1233
|
+
if (!s) return
|
|
1234
|
+
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
type RepositoryReader = Awaited<ReturnType<ReturnType<typeof createRepositoryBrowser>['reader']>>
|
|
1238
|
+
type RepositoryContents = Awaited<ReturnType<typeof readRepositoryContents>>
|
|
1239
|
+
type RepositoryRefs = Awaited<ReturnType<RepositoryReader['refs']>> & { defaultContents?: RepositoryContents | null }
|
|
1240
|
+
|
|
1241
|
+
async function readRepositoryContents(reader: RepositoryReader, ref: string, path: string, virtualEntries: RepositoryEntry[] | null) {
|
|
1242
|
+
const commit = await reader.commit(ref)
|
|
1243
|
+
const contents = virtualEntries ? { entries: virtualEntries, limited: false } : await reader.directory(path, commit)
|
|
1244
|
+
return { ...contents, path, commit }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo: SelectedRepo, withDefault: boolean, virtualEntries: RepositoryEntry[] | null): Promise<RepositoryRefs> {
|
|
1248
|
+
// Wait for both, including failures: a deleted cached branch must not prevent
|
|
1249
|
+
// a successful live default lookup from recovering the initial directory.
|
|
1250
|
+
const [refs, contents] = await Promise.allSettled([
|
|
1251
|
+
reader.refs(),
|
|
1252
|
+
withDefault && repo.cachedDefaultBranch ? readRepositoryContents(reader, `heads/${repo.cachedDefaultBranch}`, '', virtualEntries) : Promise.resolve(null),
|
|
1253
|
+
])
|
|
1254
|
+
if (refs.status === 'rejected') throw refs.reason
|
|
1255
|
+
const defaultBranch = refs.value.defaultBranch || null
|
|
1256
|
+
const changed = defaultBranch !== repo.cachedDefaultBranch
|
|
1257
|
+
if (changed) await db.cacheRepoDefaultBranch(repo, defaultBranch)
|
|
1258
|
+
if (!withDefault) return refs.value
|
|
1259
|
+
if (!defaultBranch) return { ...refs.value, defaultContents: null }
|
|
1260
|
+
if (changed) return { ...refs.value, defaultContents: await readRepositoryContents(reader, `heads/${defaultBranch}`, '', virtualEntries) }
|
|
1261
|
+
if (contents.status === 'rejected') throw contents.reason
|
|
1262
|
+
return { ...refs.value, defaultContents: contents.value }
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function scopeRepositoryContents(contents: RepositoryContents | null, virtualEntries: RepositoryEntry[] | null) {
|
|
1266
|
+
return contents && { ...contents, ...(virtualEntries ? {
|
|
1267
|
+
entries: virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
|
|
1268
|
+
} : {}) }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Recheck repository/path grants after upstream reads, before returning names.
|
|
1272
|
+
async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
|
|
1273
|
+
const repoId = Number(query.get('repoId'))
|
|
1274
|
+
const normalized = normalizeTeamPath(query.get('path') ?? '')
|
|
1275
|
+
const ref = query.get('ref') ?? ''
|
|
1276
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
|
|
1277
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1278
|
+
}
|
|
1279
|
+
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1280
|
+
const path = refs ? '' : normalized.path ?? ''
|
|
1281
|
+
const authorize = async () => {
|
|
1282
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1283
|
+
if (!s) return null
|
|
1284
|
+
const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
|
|
1285
|
+
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1286
|
+
const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1287
|
+
const virtualEntries = scopedDirectory(path, scopes)
|
|
1288
|
+
if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1289
|
+
return { repo, virtualEntries, user: s.user }
|
|
1290
|
+
}
|
|
1291
|
+
const access = await authorize()
|
|
1292
|
+
if (!access) return
|
|
1293
|
+
try {
|
|
1294
|
+
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
|
|
1295
|
+
const reader = await browser.reader(access.repo)
|
|
1296
|
+
// Microseconds, so overlapping browses practically never share a listing time.
|
|
1297
|
+
const observedUs = Math.round((performance.timeOrigin + performance.now()) * 1000)
|
|
1298
|
+
const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
|
|
1299
|
+
const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
|
|
1300
|
+
const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
|
|
1301
|
+
// Create a bundle refreshes the tags bundle catalogs show, and keeps a tag
|
|
1302
|
+
// its revision input resolves even when the listed page leaves it out.
|
|
1303
|
+
// Like other GitHub caches, written before the access fence below.
|
|
1304
|
+
const tags = refs ? tagCommits : ref.length > 5 && ref.startsWith('tags/') && contents ? [{ name: ref.slice(5), sha: contents.commit }] : []
|
|
1305
|
+
if (refs || tags.length > 0) {
|
|
1306
|
+
await deps.db.refreshGithubTags(access.repo.repoId, tags, refs && tagsComplete, observedUs).catch(err => { console.warn('managed: tag cache refresh failed:', err) })
|
|
1307
|
+
}
|
|
1308
|
+
await reader.recheckAccess()
|
|
1309
|
+
const current = await authorize()
|
|
1310
|
+
if (!current) return
|
|
1311
|
+
// This request (or another reader) may have refreshed the shared hint. It
|
|
1312
|
+
// does not change repository identity, credentials, or managed grants.
|
|
1313
|
+
if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
|
|
1314
|
+
sendJson(res, 409, { error: 'repository-changed' }); return
|
|
1315
|
+
}
|
|
1316
|
+
sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
|
|
1317
|
+
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1318
|
+
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
if (err instanceof GithubApiError) {
|
|
1321
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
throw err
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
1329
|
+
if (!deps.bundleCache || deps.config.serverless) return
|
|
1330
|
+
deps.track((async () => {
|
|
1331
|
+
const record = await deps.db.getBundle(id)
|
|
1332
|
+
if (record?.kind) await deps.bundleCache!.prebuild(record)
|
|
1333
|
+
})().catch(err => console.warn('managed: bundle cache build failed:', err)))
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Brotli-encoded bytes as they are: the browser's HTTP stack decompresses
|
|
1337
|
+
// them without a Brotli JS dependency.
|
|
1338
|
+
async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: OpenedBlob, contentType: string, headers: Record<string, string> = {}) {
|
|
1339
|
+
res.writeHead(200, {
|
|
1340
|
+
...headers, 'content-type': contentType, 'content-encoding': 'br',
|
|
1341
|
+
...(blob.size == null ? {} : { 'content-length': String(blob.size) }), 'cache-control': 'private, no-store',
|
|
1342
|
+
'x-content-type-options': 'nosniff',
|
|
1343
|
+
})
|
|
1344
|
+
if (req.method === 'HEAD') { blob.stream.destroy(); res.end(); return }
|
|
1345
|
+
try { await pipeline(blob.stream, res) } catch { res.destroy() }
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
// How often a scan of repositories' risk checks again that its reader is
|
|
1350
|
+
// still an admin, between the lines it sends; and how long its connection may
|
|
1351
|
+
// go idle, unread or with no line to send, before the scan is stopped.
|
|
1352
|
+
const REPO_RISK_RECHECK_MS = 10_000
|
|
1353
|
+
const REPO_RISK_IDLE_MS = 5 * 60_000
|
|
1354
|
+
|
|
1355
|
+
// The repositories a scan of risk reads, by its query: every connected one;
|
|
1356
|
+
// a team's (`team`, its id), each in the team's directories of it; or one
|
|
1357
|
+
// (`repo`, its id). Null for a team or repository there is none of, or a
|
|
1358
|
+
// query naming both.
|
|
1359
|
+
async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<(SelectedRepo & { paths?: string[] })[] | null> {
|
|
1360
|
+
const connected = await db.listSelectedRepos()
|
|
1361
|
+
const repoId = query.get('repo'), teamId = query.get('team')
|
|
1362
|
+
if (teamId !== null && repoId !== null) return null
|
|
1363
|
+
if (repoId !== null) {
|
|
1364
|
+
const repo = connected.find(item => String(item.repoId) === repoId)
|
|
1365
|
+
return repo ? [repo] : null
|
|
1366
|
+
}
|
|
1367
|
+
if (teamId === null) return connected
|
|
1368
|
+
const team = (await db.listTeams()).find(item => item.id === teamId)
|
|
1369
|
+
if (!team) return null
|
|
1370
|
+
// By repository, its directories; null for the whole of it.
|
|
1371
|
+
const paths = new Map<number, string[] | null>()
|
|
1372
|
+
for (const { repoId: id, path } of team.repos) {
|
|
1373
|
+
const kept = paths.get(id)
|
|
1374
|
+
paths.set(id, path === null || kept === null ? null : [...kept ?? [], path])
|
|
1375
|
+
}
|
|
1376
|
+
return connected.flatMap(repo => {
|
|
1377
|
+
const of = paths.get(repo.repoId)
|
|
1378
|
+
return of === undefined ? [] : [of === null ? repo : { ...repo, paths: of }]
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// GET /api/admin/repositories/risk: every connected repository's npm
|
|
1383
|
+
// packages, or a team's or one repository's (riskRepositories), as its
|
|
1384
|
+
// lockfiles lock them at its default branch's head, each assessed by risk
|
|
1385
|
+
// (repository-risk.ts), a line as each is found. A
|
|
1386
|
+
// repository is read with the admin's own GitHub access, as browsing it is
|
|
1387
|
+
// (repository-browser.ts), and that access checked again once it is read,
|
|
1388
|
+
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1389
|
+
// spare assessments (npm-risk.ts), trusted however new where a team lists
|
|
1390
|
+
// their scope, and each lockfile's asked of npm's advisories at once. The
|
|
1391
|
+
// reader leaving, losing their admin role or the server shutting down stops
|
|
1392
|
+
// it.
|
|
1393
|
+
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1394
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1395
|
+
if (!s) return
|
|
1396
|
+
const repos = await riskRepositories(deps.db, query)
|
|
1397
|
+
if (!repos) {
|
|
1398
|
+
const both = query.has('team') && query.has('repo')
|
|
1399
|
+
sendJson(res, both ? 400 : 404, { error: both ? 'bad-scope' : 'no-repository' })
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
const controller = new AbortController()
|
|
1403
|
+
res.on('close', () => { controller.abort() })
|
|
1404
|
+
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1405
|
+
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
|
|
1406
|
+
const scopes = new Map<string, Promise<boolean>>()
|
|
1407
|
+
const versions = npmVersionStore(deps.db, deps.config.debug)
|
|
1408
|
+
const lines = repositoryRiskLines({
|
|
1409
|
+
repos: () => Promise.resolve(repos),
|
|
1410
|
+
async open(repo) {
|
|
1411
|
+
const reader = await browser.reader(repo)
|
|
1412
|
+
const commit = await reader.commit('')
|
|
1413
|
+
const client = createClient({ token: reader.readToken() })
|
|
1414
|
+
return {
|
|
1415
|
+
commit,
|
|
1416
|
+
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1417
|
+
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1418
|
+
recheck: async () => { await reader.recheckAccess() },
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
teamScoped(name) {
|
|
1422
|
+
const scope = npmPackageScope(name)
|
|
1423
|
+
if (scope === null) return Promise.resolve(false)
|
|
1424
|
+
if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
|
|
1425
|
+
return scopes.get(scope)!
|
|
1426
|
+
},
|
|
1427
|
+
// Public versions' documents kept in the database (npm-kept.ts), as the
|
|
1428
|
+
// viewer keeps them: whether npm still has one, its whole document tells
|
|
1429
|
+
// risk.
|
|
1430
|
+
version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
|
|
1431
|
+
assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1432
|
+
teamScoped, privileged: true, signal, spare: true }),
|
|
1433
|
+
removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
|
|
1434
|
+
teamScoped, privileged: true, signal }),
|
|
1435
|
+
risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
|
|
1436
|
+
// As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
|
|
1437
|
+
// and, for packages anyone may read, OSV: no repository's. Each lockfile's
|
|
1438
|
+
// audit is bounded on its own, for one npm doesn't answer to fail alone and
|
|
1439
|
+
// the scan to carry on.
|
|
1440
|
+
async advisories(packages, scanning) {
|
|
1441
|
+
const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
|
|
1442
|
+
const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
|
|
1443
|
+
{ debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
|
|
1444
|
+
if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
|
|
1445
|
+
return audited.body
|
|
1446
|
+
},
|
|
1447
|
+
}, controller.signal)
|
|
1448
|
+
let checked = Date.now()
|
|
1449
|
+
await sendLines(res, lines, async () => {
|
|
1450
|
+
if (deps.isShuttingDown()) return false
|
|
1451
|
+
if (Date.now() - checked < REPO_RISK_RECHECK_MS) return true
|
|
1452
|
+
if ((await readSession(deps.config, deps.db, cookie, Date.now()))?.user.role !== 'admin') return false
|
|
1453
|
+
checked = Date.now()
|
|
1454
|
+
return true
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// A pretty-printed copy names the file it was formatted from, by the hash it
|
|
1459
|
+
// was asked for and its source was found to have, for the viewer to check.
|
|
1460
|
+
const prettySource = ({ hash }: PrettyRequest): Record<string, string> => ({ 'x-source-hash': hash })
|
|
1461
|
+
|
|
1462
|
+
// GET /api/bundles/:id/{metadata,contents,pretty}: what `open` reads of the
|
|
1463
|
+
// bundle, already encoded; it is absent where its cache is.
|
|
1464
|
+
async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string,
|
|
1465
|
+
open: ((rec: ManagedBundle) => Promise<OpenedBlob>) | undefined, contentType = 'application/json', headers: Record<string, string> = {}) {
|
|
1466
|
+
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1467
|
+
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1468
|
+
const rec = access.bundle
|
|
1469
|
+
if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1470
|
+
if (!open) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1471
|
+
let cached
|
|
1472
|
+
try { cached = await open(rec) }
|
|
1473
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1474
|
+
// A cold build can outlast a session or membership change.
|
|
1475
|
+
const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1476
|
+
if (!current?.bundle) {
|
|
1477
|
+
cached.stream.destroy()
|
|
1478
|
+
sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
|
|
1479
|
+
return
|
|
1480
|
+
}
|
|
1481
|
+
await sendEncoded(req, res, cached, contentType, headers)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1485
|
+
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1486
|
+
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1487
|
+
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1488
|
+
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1489
|
+
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1490
|
+
const readable = async () => {
|
|
1491
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1492
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1493
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1494
|
+
return [base.bundle, other.bundle] as const
|
|
1495
|
+
}
|
|
1496
|
+
const records = await readable()
|
|
1497
|
+
if (!records) return
|
|
1498
|
+
const { prettyCache } = deps
|
|
1499
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1500
|
+
let diff
|
|
1501
|
+
try {
|
|
1502
|
+
diff = await renameDiffOf(request, async () => [await prettyText(await prettyCache.bundle(records[0], request.base)),
|
|
1503
|
+
await prettyText(await prettyCache.bundle(records[1], request.other))])
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return
|
|
1506
|
+
}
|
|
1507
|
+
if (await readable()) await sendEncoded(req, res, diff, 'application/json')
|
|
1508
|
+
else diff.stream.destroy()
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1512
|
+
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1513
|
+
// line each as it is found kept or formatted; GET
|
|
1514
|
+
// /api/bundles/:id/rename-diff?other=, the pairs of them its viewer diffs
|
|
1515
|
+
// with renamed names hidden (rename-diff.ts renamedPairs), a line each as it
|
|
1516
|
+
// is worked out; GET /api/bundles/:id/type-only?other=, those that changed in
|
|
1517
|
+
// types alone (type-only.ts). For a reader of both, checked again before each.
|
|
1518
|
+
async function handleBundleChanges(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string,
|
|
1519
|
+
linesOf: (cache: PrettyCache, base: ManagedBundle, other: ManagedBundle) => Promise<AsyncIterable<unknown>>) {
|
|
1520
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
1521
|
+
const access = () => Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1522
|
+
const [base, other] = await access()
|
|
1523
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1524
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1525
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1526
|
+
let lines
|
|
1527
|
+
try { lines = await linesOf(deps.prettyCache, base.bundle, other.bundle) }
|
|
1528
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1529
|
+
await sendLines(res, lines, async () => (await access()).every(snapshot => snapshot?.bundle))
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Published dependency advisories require security access, independently of access to
|
|
1533
|
+
// unpublished dependency findings. Managers retain their normal bundle access.
|
|
1534
|
+
async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean, details: boolean) {
|
|
1535
|
+
const authorize = async () => {
|
|
1536
|
+
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id, teamId)
|
|
1537
|
+
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1538
|
+
if (!access.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1539
|
+
if (!access.canReadAdvisories) {
|
|
1540
|
+
sendJson(res, 403, { error: 'security-access-required' }); return null
|
|
1541
|
+
}
|
|
1542
|
+
return access.bundle
|
|
1543
|
+
}
|
|
1544
|
+
const record = await authorize()
|
|
1545
|
+
if (!record) return
|
|
1546
|
+
if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
|
|
1547
|
+
if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1548
|
+
let inventory: BundleAdvisoryInventory | null | undefined
|
|
1549
|
+
try { inventory = await deps.bundleCache.advisoryInventory(record, reason) }
|
|
1550
|
+
catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
|
|
1551
|
+
if (!(await authorize())) return
|
|
1552
|
+
if (inventory === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
|
|
1553
|
+
if (inventory === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
|
|
1554
|
+
if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { sendJson(res, 413, { error: 'payload-too-large' }); return }
|
|
1555
|
+
const controller = new AbortController()
|
|
1556
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
1557
|
+
res.on('close', onClose)
|
|
1558
|
+
// Its deadline aborts as AbortSignal.timeout does, for a failure it leaves to be told from the reader's leaving.
|
|
1559
|
+
const timer = setTimeout(() => controller.abort(new DOMException('advisories took too long', 'TimeoutError')), ADVISORIES_TIMEOUT_MS)
|
|
1560
|
+
try {
|
|
1561
|
+
if (res.destroyed) return
|
|
1562
|
+
const needsGithub = inventory.packages.some(pkg => repoAdvisories || pkg.ecosystem === 'github' || pkg.ecosystem === 'soldeer')
|
|
1563
|
+
const githubToken = needsGithub ? await ensureUserAccessToken(deps.config, deps.db, session.userId, Date.now(), (url, init) => fetch(url, {
|
|
1564
|
+
...init, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
|
|
1565
|
+
})) : null
|
|
1566
|
+
if (res.destroyed || (needsGithub && !(await authorize()))) return
|
|
1567
|
+
const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
|
|
1568
|
+
debug: deps.config.debug, repoAdvisories, details, githubToken,
|
|
1569
|
+
cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
|
|
1570
|
+
})
|
|
1571
|
+
if (res.destroyed || !(await authorize())) return
|
|
1572
|
+
// Return just inventory and public advisories, never source or report data.
|
|
1573
|
+
sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
|
|
1574
|
+
} finally {
|
|
1575
|
+
clearTimeout(timer)
|
|
1576
|
+
res.off('close', onClose)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
|
|
1581
|
+
// admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
|
|
1582
|
+
// the upload repo picker.
|
|
1583
|
+
async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1584
|
+
const before = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1585
|
+
const summaries = await bundleSummaries(before.bundles, deps.bundleCache)
|
|
1586
|
+
const commits = await bundleCommits(deps.db, before.bundles, summaries)
|
|
1587
|
+
// Even cached storage and commit reads may outlast changes to access or locations.
|
|
1588
|
+
const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
|
|
1589
|
+
sendJson(res, 200, {
|
|
1590
|
+
...catalog,
|
|
1591
|
+
bundles: catalog.bundles.map(bundle => ({
|
|
1592
|
+
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }), commitInfo: commits.commitInfo(bundle),
|
|
1593
|
+
})),
|
|
1594
|
+
maxBytes: deps.config.maxBundleBytes,
|
|
1595
|
+
})
|
|
1596
|
+
await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
|
|
1597
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1601
|
+
// `reuse` describes a repeated upload/build of an existing row (see reuseBundle).
|
|
1602
|
+
async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
1603
|
+
bundle: UploadedBundle, reuse?: Omit<BundleReuse, 'rename'>): Promise<void> {
|
|
1604
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
1605
|
+
if (!s) return
|
|
1606
|
+
const deduped = reuse != null
|
|
1607
|
+
if (deduped) {
|
|
1608
|
+
// Authorized in the same transaction as the rename. A server build labels
|
|
1609
|
+
// even a row the caller cannot see: the bytes are this server's own output.
|
|
1610
|
+
if (await deps.db.reuseBundleUpload(s.session.id, bundle.id, reuse)) bundle = await deps.db.getBundle(bundle.id) ?? bundle
|
|
1611
|
+
if (!(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1612
|
+
}
|
|
1613
|
+
const { id, slug, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1614
|
+
// Re-uploading also repairs reports uploaded while the bundle was inaccessible.
|
|
1615
|
+
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1616
|
+
prebuildBundle(deps, id)
|
|
1617
|
+
sendJson(res, deduped ? 200 : 201, { id, slug, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1621
|
+
const initial = await manageMutation(req, res, deps, cookie)
|
|
1622
|
+
if (!initial) return
|
|
1623
|
+
const controller = new AbortController()
|
|
1624
|
+
const onClose = () => controller.abort()
|
|
1625
|
+
res.on('close', onClose)
|
|
1626
|
+
try {
|
|
1627
|
+
let body: unknown
|
|
1628
|
+
try { body = await readJsonBody(req, 128 * 1024) } catch { throw new BundleBuildError(400, 'bad-body') }
|
|
1629
|
+
const input = parseBundleBuild(body)
|
|
1630
|
+
const authorize = async (directory: string) => {
|
|
1631
|
+
const session = await manageMutation(req, res, deps, cookie)
|
|
1632
|
+
if (!session) return null
|
|
1633
|
+
const repo = (await bundleRepos(deps, session.user)).find(item => item.repoId === input.repoId)
|
|
1634
|
+
if (!repo) throw new BundleBuildError(404, 'no-repository')
|
|
1635
|
+
const scopes = session.user.role === 'admin' ? [null]
|
|
1636
|
+
: (await deps.db.listRepoScopesForUser(session.user.id)).filter(scope => scope.repoId === input.repoId).map(scope => scope.path)
|
|
1637
|
+
if (scopedDirectory(directory, scopes) !== null) throw new BundleBuildError(403, 'build-scope')
|
|
1638
|
+
return { ...session, repo, scopes }
|
|
1639
|
+
}
|
|
1640
|
+
const access = await authorize(input.directory)
|
|
1641
|
+
if (!access) return
|
|
1642
|
+
await withBundleBuildLease(deps.db, access.user.id, controller.signal, async signal => {
|
|
1643
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1644
|
+
signal.throwIfAborted()
|
|
1645
|
+
// Catalogs then show the built bundle's commit details from the cache.
|
|
1646
|
+
const commitCached = cacheCommitDetails(deps.db, reader, input.repoId, input.commit)
|
|
1647
|
+
const built = await buildRepositoryBundle(access.user.id, {
|
|
1648
|
+
input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
|
|
1649
|
+
cacheDir: deps.config.upstreamCacheDir ?? null,
|
|
1650
|
+
}, signal)
|
|
1651
|
+
await commitCached
|
|
1652
|
+
signal.throwIfAborted()
|
|
1653
|
+
await reader.recheckAccess()
|
|
1654
|
+
const current = await authorize(built.directory)
|
|
1655
|
+
if (!current) return
|
|
1656
|
+
if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
|
|
1657
|
+
throw new BundleBuildError(409, 'repository-changed')
|
|
1658
|
+
}
|
|
1659
|
+
signal.throwIfAborted()
|
|
1660
|
+
const bytes = Buffer.from(built.bytes)
|
|
1661
|
+
await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
|
|
1662
|
+
bundleIntegrity(bytes), input.conditions)
|
|
1663
|
+
})
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
if (res.destroyed || controller.signal.aborted) return
|
|
1666
|
+
if (error instanceof BundleBuildError) { sendJson(res, error.status, { error: error.code }); return }
|
|
1667
|
+
if (error instanceof GithubApiError) {
|
|
1668
|
+
sendJson(res, error.status === 401 ? 502 : error.status, { error: error.message }, error.retryAfter == null ? {} : { 'retry-after': String(error.retryAfter) })
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
throw error
|
|
1672
|
+
} finally { res.off('close', onClose) }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
|
|
1676
|
+
// admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
|
|
1677
|
+
// X-Repo-Directory assign a repository location; Stasis repo headers default
|
|
1678
|
+
// to a matching connected repository within the uploader's access, otherwise
|
|
1679
|
+
// the bundle stays unattached. Its identity is its content hash (sha512), UNIQUE — a
|
|
1680
|
+
// re-upload of identical bytes dedupes to the existing row (no second copy) and
|
|
1681
|
+
// renames it, unless the row was built on this server.
|
|
1682
|
+
// After storing, any reports that declared this integrity but weren't linked yet
|
|
1683
|
+
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
1684
|
+
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1685
|
+
let s = await checkMutation(req, res, deps, cookie)
|
|
1686
|
+
if (s == null) return
|
|
1687
|
+
if (!requireManageRole(res, s.user)) return
|
|
1688
|
+
let bytes: Buffer
|
|
1689
|
+
try {
|
|
1690
|
+
bytes = await readUploadBody(req, deps, s.session, 'bundles')
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
1693
|
+
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
1694
|
+
return
|
|
1695
|
+
}
|
|
1696
|
+
// Shared storage reads may outlast a session or role change.
|
|
1697
|
+
s = await checkMutation(req, res, deps, cookie)
|
|
1698
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
1699
|
+
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
1700
|
+
const repo = await resolveUploadRepoId(req, res, deps)
|
|
1701
|
+
if (!repo.ok) return
|
|
1702
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1703
|
+
const kind = bundleKind(filename)
|
|
1704
|
+
// Explicit locations override embedded defaults. Reuploads keep their
|
|
1705
|
+
// stored location, so only new bundles need their origin header decoded.
|
|
1706
|
+
const integrity = bundleIntegrity(bytes)
|
|
1707
|
+
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1708
|
+
let inferredLocation = false
|
|
1709
|
+
let rawDirectory: unknown
|
|
1710
|
+
try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
|
|
1711
|
+
catch { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1712
|
+
if (!existing && repo.repoId == null && kind === 'stasis') {
|
|
1713
|
+
const embedded = await bundleRepo(bytes)
|
|
1714
|
+
const github = reportRepoGithub({ repo: embedded })
|
|
1715
|
+
const embeddedDirectory = normalizeTeamPath(embedded?.directory)
|
|
1716
|
+
// Invalid optional metadata must not reject an otherwise unattached bundle.
|
|
1717
|
+
// Only repository-wide aliases/direct connections can resolve without a
|
|
1718
|
+
// valid directory; do not decode file paths or match directory aliases.
|
|
1719
|
+
const location = github == null ? null : embeddedDirectory.ok
|
|
1720
|
+
? await resolveRepositoryImportLocation(deps.db, github, embeddedDirectory.path ?? '', () => bundleFilePrefix(bytes))
|
|
1721
|
+
: await deps.db.getRepositoryImportLocation(github, '')
|
|
1722
|
+
if (location?.repoId != null) {
|
|
1723
|
+
repo.repoId = location.repoId
|
|
1724
|
+
if (req.headers['x-repo-directory'] == null) {
|
|
1725
|
+
if (!embeddedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1726
|
+
rawDirectory = location.directory
|
|
1727
|
+
inferredLocation = true
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
s = await checkMutation(req, res, deps, cookie)
|
|
1731
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
1732
|
+
}
|
|
1733
|
+
const normalized = normalizeTeamPath(rawDirectory)
|
|
1734
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1735
|
+
let directory = repo.repoId == null ? '' : normalized.path ?? ''
|
|
1736
|
+
if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
|
|
1737
|
+
if (!inferredLocation) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1738
|
+
// A bundle's own stamp is a default, not a required destination. Retain
|
|
1739
|
+
// it in the archive while letting the uploader assign an allowed location.
|
|
1740
|
+
repo.repoId = null
|
|
1741
|
+
directory = ''
|
|
1742
|
+
}
|
|
1743
|
+
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
1747
|
+
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
1748
|
+
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
|
|
1749
|
+
const kind = bundleKind(filename)
|
|
1750
|
+
const reuse = { provenance, filename, kind, buildConditions }
|
|
1751
|
+
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1752
|
+
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
|
|
1753
|
+
const id = randomUUID()
|
|
1754
|
+
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
1755
|
+
try {
|
|
1756
|
+
await deps.db.insertBundle({
|
|
1757
|
+
id, integrity, filename, kind, dataKey, provenance, buildConditions,
|
|
1758
|
+
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
|
|
1759
|
+
}, Date.now(), s.session.id)
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1762
|
+
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1763
|
+
// Any other failure rethrows.
|
|
1764
|
+
let raced: ManagedBundle | null
|
|
1765
|
+
try { raced = await deps.db.resolveBundleUpload(integrity) }
|
|
1766
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
|
|
1767
|
+
// Preserve a committed candidate after a lost acknowledgement. If this
|
|
1768
|
+
// read fails too, leave the bytes for later reconciliation.
|
|
1769
|
+
if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
|
|
1770
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1771
|
+
if (!raced) throw err
|
|
1772
|
+
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, reuse); return }
|
|
1773
|
+
// Our insert committed: continue with the ordinary creation response.
|
|
1774
|
+
}
|
|
1775
|
+
const stored = await deps.db.getBundle(id)
|
|
1776
|
+
if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1777
|
+
await sendUploadedBundle(req, res, deps, cookie, stored)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
|
1781
|
+
// are opaque archives, so served as application/octet-stream. Sourcemaps use
|
|
1782
|
+
// HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
|
|
1783
|
+
// bundle; 503 row-without-bytes (store desync).
|
|
1784
|
+
async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string): Promise<void> {
|
|
1785
|
+
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1786
|
+
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1787
|
+
const rec = access.bundle
|
|
1788
|
+
if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1789
|
+
const stored = await deps.bundleStore.open(id, rec.kind)
|
|
1790
|
+
if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1791
|
+
const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
|
|
1792
|
+
if (!current?.bundle) {
|
|
1793
|
+
stored.stream.destroy()
|
|
1794
|
+
sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
|
|
1795
|
+
}
|
|
1796
|
+
res.writeHead(200, {
|
|
1797
|
+
'content-type': 'application/octet-stream',
|
|
1798
|
+
...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
|
|
1799
|
+
...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
|
|
1800
|
+
'content-disposition': attachmentDisposition(rec.filename),
|
|
1801
|
+
'x-content-type-options': 'nosniff',
|
|
1802
|
+
'cache-control': 'no-store',
|
|
1803
|
+
})
|
|
1804
|
+
if (req.method === 'HEAD') { stored.stream.destroy(); res.end(); return }
|
|
1805
|
+
try { await pipeline(stored.stream, res) } catch { res.destroy() }
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
|
|
1809
|
+
// same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
|
|
1810
|
+
// the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
|
|
1811
|
+
// best-effort. 404 when there was no such bundle.
|
|
1812
|
+
async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1813
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1814
|
+
if (s == null) return
|
|
1815
|
+
if (!requireManageRole(res, s.user)) return
|
|
1816
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
1817
|
+
await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
1818
|
+
await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1819
|
+
await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
|
|
1820
|
+
await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1821
|
+
sendJson(res, 200, { ok: true })
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
|
|
1825
|
+
// the session, or null after the 401/403 was sent.
|
|
1826
|
+
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
1827
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1828
|
+
if (s == null) return null
|
|
1829
|
+
if (!requireManageRole(res, s.user)) return null
|
|
1830
|
+
return s
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Workspace access and repository connections are administered separately
|
|
1834
|
+
// from content management. Managers cannot change their own access via teams.
|
|
1835
|
+
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
1836
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1837
|
+
if (s == null) return null
|
|
1838
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
1839
|
+
return s
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// GET /api/teams — the CURRENT user's teams, each with the reports and bundles
|
|
1843
|
+
// attached to the team's repos, for the sidebar's per-user Teams section. Any approved
|
|
1844
|
+
// user (not just admin|manage); a user only ever sees their own teams.
|
|
1845
|
+
// Each team carries `app`, its App classification (team-app.ts), so the
|
|
1846
|
+
// sidebar's first paint already shows App teams collapsed.
|
|
1847
|
+
async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
|
|
1848
|
+
let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
|
|
1849
|
+
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1850
|
+
const [summaries, classified] = await Promise.all([
|
|
1851
|
+
bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache),
|
|
1852
|
+
teamAppStates(deps.db, deps.reportStore, session.id, snapshot.teams),
|
|
1853
|
+
])
|
|
1854
|
+
const commits = await bundleCommits(deps.db, snapshot.teams.flatMap(team => team.bundles), summaries)
|
|
1855
|
+
const current = await deps.db.getFeedState(session.id, Date.now())
|
|
1856
|
+
if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1857
|
+
// Keep the post-storage authorization fence, but reload the catalog only
|
|
1858
|
+
// when a concurrent mutation actually invalidated its consistent snapshot.
|
|
1859
|
+
if (current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role || current.catalog !== snapshot.catalog) {
|
|
1860
|
+
snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
|
|
1861
|
+
if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1862
|
+
}
|
|
1863
|
+
const { teams, revision } = snapshot
|
|
1864
|
+
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1865
|
+
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1866
|
+
sendJson(res, 200, {
|
|
1867
|
+
teams: teams.map(team => ({ ...team, app: apps.get(teamAppKey(team)) ?? null,
|
|
1868
|
+
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1869
|
+
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1870
|
+
revision,
|
|
1871
|
+
})
|
|
1872
|
+
await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
|
|
1873
|
+
await backfillCatalogCommits(deps, session.userId, commits.missing)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Admins read all reports. Managers read their uploads or reports inside their
|
|
1877
|
+
// team repository paths, including drafts they may manage. View/triage users
|
|
1878
|
+
// need a published report in their teams; ownership never overrides role none.
|
|
1879
|
+
async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
1880
|
+
if (!roleAtLeast(user.role, 'view')) return false
|
|
1881
|
+
const report = await deps.db.getReport(reportId)
|
|
1882
|
+
if (report == null) return false
|
|
1883
|
+
if (user.role === 'admin') return true
|
|
1884
|
+
if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
|
|
1885
|
+
if (!report.visible) return false
|
|
1886
|
+
return deps.db.userCanReadReport(user.id, reportId)
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
|
|
1890
|
+
// visible findings (location AND evidence) are returned. Missing/unavailable
|
|
1891
|
+
// bundles are a quiet no-op; cached gzip bodies stream directly from disk.
|
|
1892
|
+
async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
|
|
1893
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1894
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1895
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1896
|
+
const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
|
|
1897
|
+
const report = await deps.db.getReport(id)
|
|
1898
|
+
const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
|
|
1899
|
+
const empty = () => { res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
|
|
1900
|
+
if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || !(await canAccessBundle(deps, s.user, bundle.id))) { empty(); return }
|
|
1901
|
+
if (!deps.reportSourcesCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1902
|
+
let cached
|
|
1903
|
+
try { cached = await deps.reportSourcesCache.open(report, bundle, { dependencies: true, security: true }, sourcePaths) }
|
|
1904
|
+
catch { empty(); return }
|
|
1905
|
+
if (!cached) { empty(); return }
|
|
1906
|
+
// Cold parsing can outlast role/team changes, report deletion, or relinking.
|
|
1907
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1908
|
+
const latest = await deps.db.getReport(id)
|
|
1909
|
+
let latestPaths: Set<string> | undefined
|
|
1910
|
+
try { latestPaths = current && !roleAtLeast(current.user.role, 'manage') ? await teamSourcePaths(deps.db, deps.reportStore, current.session.id, teamId ?? '', id) : undefined }
|
|
1911
|
+
catch (error) { cached.stream.destroy(); throw error }
|
|
1912
|
+
if (!current || current.user.role !== s.user.role || sourcePaths?.size !== latestPaths?.size || [...sourcePaths ?? []].some(path => !latestPaths?.has(path)) || !(await canViewReport(deps, current.user, id)) || !(await canAccessBundle(deps, current.user, bundle.id))
|
|
1913
|
+
|| latest?.bundleId !== bundle.id || latest.sha256 !== report.sha256
|
|
1914
|
+
|| latest.repoId !== report.repoId || await repositoryName(deps, latest.repoId) !== cached.repo.github) {
|
|
1915
|
+
cached.stream.destroy()
|
|
1916
|
+
sendJson(res, current ? 404 : 401, { error: current ? 'no-report' : 'unauthenticated' })
|
|
1917
|
+
return
|
|
1918
|
+
}
|
|
1919
|
+
res.writeHead(200, {
|
|
1920
|
+
'content-type': 'application/json', 'content-encoding': 'gzip',
|
|
1921
|
+
...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
|
|
1922
|
+
'x-content-type-options': 'nosniff',
|
|
1923
|
+
})
|
|
1924
|
+
if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
|
|
1925
|
+
try { await pipeline(cached.stream, res) } catch { res.destroy() }
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
async function reportIds(data: unknown): Promise<Set<string>> {
|
|
1929
|
+
const findings = (reportEntries(data) ?? []).flat().filter((f): f is Record<string, unknown> => f !== null && typeof f === 'object' && !Array.isArray(f))
|
|
1930
|
+
await backfillFindingIds(findings)
|
|
1931
|
+
return new Set(findings.flatMap(f => typeof f['id'] === 'string' ? [f['id']] : []))
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1935
|
+
const method = req.method ?? 'GET'
|
|
1936
|
+
if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
|
|
1937
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
1938
|
+
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1939
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1940
|
+
if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
|
|
1941
|
+
if (id) {
|
|
1942
|
+
let body
|
|
1943
|
+
try { body = await readJsonBody(req, 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1944
|
+
const enabled = (body as { enabled?: unknown } | null)?.enabled
|
|
1945
|
+
if (typeof enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-enabled' }); return }
|
|
1946
|
+
await deps.db.setLinkReportEnabled(s.session.id, id, enabled)
|
|
1947
|
+
sendJson(res, 200, { ok: true }); return
|
|
1948
|
+
}
|
|
1949
|
+
let bytes: Buffer
|
|
1950
|
+
try { bytes = await readUploadBody(req, deps, s.session, 'links') }
|
|
1951
|
+
catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
|
|
1952
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
|
|
1953
|
+
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
1954
|
+
sendJson(res, result.reused ? 200 : 201, { ...result.report, deduped: result.reused })
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1958
|
+
const method = req.method ?? 'GET'
|
|
1959
|
+
if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
|
|
1960
|
+
const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
1961
|
+
if (!s) return
|
|
1962
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1963
|
+
if (method === 'GET') {
|
|
1964
|
+
sendJson(res, 200, { aliases: await deps.db.listRepositoryAliases(), repos: await deps.db.listAllRepos() }); return
|
|
1965
|
+
}
|
|
1966
|
+
if (method === 'DELETE') {
|
|
1967
|
+
await deps.db.deleteRepositoryAlias(s.session.id, id!)
|
|
1968
|
+
sendJson(res, 200, { ok: true }); return
|
|
1969
|
+
}
|
|
1970
|
+
let body
|
|
1971
|
+
try { body = await readJsonBody(req, 8192) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1972
|
+
const alias = await deps.db.saveRepositoryAlias(s.session.id, id ?? null, body)
|
|
1973
|
+
sendJson(res, id ? 200 : 201, alias)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Live suggestions do not mutate archived content or its stored assignment.
|
|
1977
|
+
// Managers can resolve only destinations their current repository grants cover.
|
|
1978
|
+
async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1979
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
1980
|
+
if (!await readManageSession(res, deps, cookie)) return
|
|
1981
|
+
const params = new URL(req.url!, 'http://localhost').searchParams
|
|
1982
|
+
const github = reportRepoGithub({ repo: { github: params.get('repo') } })
|
|
1983
|
+
const directory = normalizeTeamPath(params.get('directory'))
|
|
1984
|
+
const filePrefix = normalizeTeamPath(params.get('filePrefix'))
|
|
1985
|
+
if (!github || !directory.ok || !filePrefix.ok) { sendJson(res, 400, { error: 'bad-location' }); return }
|
|
1986
|
+
const location = await deps.db.getRepositoryImportLocation(github, directory.path ?? '', filePrefix.path ?? '')
|
|
1987
|
+
const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
|
|
1988
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1989
|
+
if (!s) return
|
|
1990
|
+
const allowed = repo && (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, location.directory))
|
|
1991
|
+
sendJson(res, 200, { location: allowed ? { repoId: repo.repoId, github: repo.fullName, directory: location.directory,
|
|
1992
|
+
mapped: github.toLowerCase() !== repo.fullName.toLowerCase() || (directory.path ?? '') !== location.directory } : null })
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// Where a report without an embedded repository suggests it belongs, resolved
|
|
1996
|
+
// through current connections and aliases like bundle metadata. `repoId` is
|
|
1997
|
+
// set only for an active destination within the user's grants; the named
|
|
1998
|
+
// repository itself is part of the report. `directory` is null when neither
|
|
1999
|
+
// the report nor an alias specifies one.
|
|
2000
|
+
async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, data: { repo?: { directory?: unknown } } | null | undefined) {
|
|
2001
|
+
const github = findingsRepository(data)
|
|
2002
|
+
if (github == null) return null
|
|
2003
|
+
// An invalid optional directory is ignored, as for bundle metadata.
|
|
2004
|
+
const declared = normalizeTeamPath(data?.repo?.directory)
|
|
2005
|
+
const directory = declared.ok ? declared.path : null
|
|
2006
|
+
const location = await deps.db.getRepositoryImportLocation(github, directory ?? '', ownFileDirectory(data))
|
|
2007
|
+
const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
|
|
2008
|
+
const allowed = repo && (user.role === 'admin' || await deps.db.userCanReadRepoPath(user.id, repo.repoId, location.directory))
|
|
2009
|
+
if (!allowed) return { repoId: null, github, directory }
|
|
2010
|
+
const mapped = github.toLowerCase() !== repo.fullName.toLowerCase() || (directory ?? '') !== location.directory
|
|
2011
|
+
return { repoId: repo.repoId, github: repo.fullName, directory: mapped ? location.directory : directory }
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// GET /api/admin/reports/<id>/location — the live suggestion for a report's
|
|
2015
|
+
// location editor. It never changes the stored assignment.
|
|
2016
|
+
async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2017
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
2018
|
+
const s = await readManageSession(res, deps, cookie)
|
|
2019
|
+
if (!s) return
|
|
2020
|
+
const report = await deps.db.getReport(id)
|
|
2021
|
+
if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2022
|
+
if (report.repoEmbedded) { sendJson(res, 200, { location: null }); return }
|
|
2023
|
+
const bytes = await deps.reportStore.get(id)
|
|
2024
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2025
|
+
const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
|
|
2026
|
+
// Storage reads can outlast a session, role or grant change.
|
|
2027
|
+
const current = await readManageSession(res, deps, cookie)
|
|
2028
|
+
if (!current) return
|
|
2029
|
+
if (!(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2030
|
+
sendJson(res, 200, { location: await suggestReportLocation(deps, current.user, data) })
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// GET /api/reports/<id> — admin/manager preview, within management access.
|
|
2034
|
+
// Accept: application/json includes parsed, filtered data and the server's repo
|
|
2035
|
+
// assignment. Other callers retain the raw text/plain response. The
|
|
2036
|
+
// client renders either without caching to OPFS. 404 covers "no such report" AND
|
|
2037
|
+
// "not authorized" (so neither existence nor membership is probeable); 503 = row
|
|
2038
|
+
// without bytes (store desync).
|
|
2039
|
+
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2040
|
+
const s = await readManageSession(res, deps, cookie)
|
|
2041
|
+
if (s == null) return
|
|
2042
|
+
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
2043
|
+
const access = snapshot?.reports[0]
|
|
2044
|
+
if (!snapshot || !access || !roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2045
|
+
const bytes = await deps.reportStore.get(id)
|
|
2046
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2047
|
+
const metadata = acceptsReportMetadata(req.headers.accept)
|
|
2048
|
+
let envelope
|
|
2049
|
+
if (metadata) {
|
|
2050
|
+
const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
2051
|
+
if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
|
|
2052
|
+
const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], await reportIds(data))
|
|
2053
|
+
envelope = { data, repo: access.repo, ...(links.length > 0 ? { links } : {}) }
|
|
2054
|
+
}
|
|
2055
|
+
// Link decryption and finding-ID backfill can outlast access or link changes.
|
|
2056
|
+
const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
2057
|
+
if (!current || current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role
|
|
2058
|
+
|| current.linkRevision !== snapshot.linkRevision || JSON.stringify(current.reports) !== JSON.stringify(snapshot.reports)) {
|
|
2059
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
2060
|
+
}
|
|
2061
|
+
if (metadata) {
|
|
2062
|
+
sendJson(res, 200, envelope, { vary: 'Accept', 'x-content-type-options': 'nosniff' }); return
|
|
2063
|
+
}
|
|
2064
|
+
res.writeHead(200, {
|
|
2065
|
+
'content-type': 'text/plain; charset=utf-8', 'content-length': String(bytes.length),
|
|
2066
|
+
'x-content-type-options': 'nosniff', 'cache-control': 'no-store', vary: 'Accept',
|
|
2067
|
+
})
|
|
2068
|
+
writeResponse(res, bytes)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// A read-only POST avoids URL-length limits when a workspace has many reports.
|
|
2072
|
+
// Authorize every requested id before reading blobs; never return a partial
|
|
2073
|
+
// workspace, or let one authorized report grant access to another in the batch.
|
|
2074
|
+
const REPORT_QUERY_CONCURRENCY = 8
|
|
2075
|
+
const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
|
|
2076
|
+
async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2077
|
+
const s = await readManageSession(res, deps, cookie)
|
|
2078
|
+
if (!s) return
|
|
2079
|
+
let body
|
|
2080
|
+
try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2081
|
+
const raw = (body as { ids?: unknown } | null)?.ids
|
|
2082
|
+
if (!Array.isArray(raw) || raw.some(id => typeof id !== 'string' || !id || id.length > 256)) {
|
|
2083
|
+
sendJson(res, 400, { error: 'bad-ids' }); return
|
|
2084
|
+
}
|
|
2085
|
+
const ids = [...new Set(raw as string[])]
|
|
2086
|
+
if (ids.length > MAX_REPORT_QUERY_COUNT) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
2087
|
+
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
|
|
2088
|
+
if (!snapshot || snapshot.user.id !== s.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2089
|
+
if (!roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2090
|
+
const reports = new Map(snapshot.reports.map(report => [report.id, report]))
|
|
2091
|
+
if (reports.size !== ids.length) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2092
|
+
const storedBytes = snapshot.reports.reduce((total, report) => total + report.byteSize, 0)
|
|
2093
|
+
if (storedBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
2094
|
+
// Overlap remote blob reads, retaining only each report's encoded response
|
|
2095
|
+
// after processing. Check actual bytes too if storage and metadata disagree.
|
|
2096
|
+
const parts = [Buffer.from('{"reports":[')]
|
|
2097
|
+
const findingIds = new Set<string>()
|
|
2098
|
+
let inputBytes = 0, outputBytes = parts[0]!.length + 2
|
|
2099
|
+
let next = 0, stopped = false
|
|
2100
|
+
let inFlightBytes = 0
|
|
2101
|
+
let capacity = Promise.withResolvers<void>()
|
|
2102
|
+
const fail = (status: number, error: string) => { stopped = true; return { status, error } }
|
|
2103
|
+
const workers = await Promise.allSettled(Array.from({ length: Math.min(REPORT_QUERY_CONCURRENCY, ids.length) }, async () => {
|
|
2104
|
+
try {
|
|
2105
|
+
while (next < ids.length) {
|
|
2106
|
+
if (stopped) return
|
|
2107
|
+
const index = next
|
|
2108
|
+
const id = ids[index]!
|
|
2109
|
+
const access = reports.get(id)!
|
|
2110
|
+
const reservedBytes = Math.max(1, access.byteSize)
|
|
2111
|
+
// Reserve stored bytes before starting remote reads, through encoding.
|
|
2112
|
+
// A report larger than the concurrent budget is still allowed alone.
|
|
2113
|
+
if (inFlightBytes > 0 && inFlightBytes + reservedBytes > REPORT_QUERY_IN_FLIGHT_BYTES) {
|
|
2114
|
+
await capacity.promise
|
|
2115
|
+
continue
|
|
2116
|
+
}
|
|
2117
|
+
next++
|
|
2118
|
+
inFlightBytes += reservedBytes
|
|
2119
|
+
try {
|
|
2120
|
+
const bytes = await deps.reportStore.get(id)
|
|
2121
|
+
if (stopped) return
|
|
2122
|
+
if (bytes == null) return fail(503, 'unavailable')
|
|
2123
|
+
inputBytes += bytes.length
|
|
2124
|
+
if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
2125
|
+
const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
2126
|
+
if (!data) return fail(422, 'unreadable-report')
|
|
2127
|
+
for (const findingId of await reportIds(data)) findingIds.add(findingId)
|
|
2128
|
+
const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
|
|
2129
|
+
outputBytes += part.length
|
|
2130
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
2131
|
+
// Completion order may differ from the requested report order.
|
|
2132
|
+
parts[index + 1] = part
|
|
2133
|
+
} finally {
|
|
2134
|
+
inFlightBytes -= reservedBytes
|
|
2135
|
+
capacity.resolve()
|
|
2136
|
+
capacity = Promise.withResolvers<void>()
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
if (stopped) return
|
|
2141
|
+
stopped = true
|
|
2142
|
+
throw error
|
|
2143
|
+
}
|
|
2144
|
+
return undefined
|
|
2145
|
+
}))
|
|
2146
|
+
// Stop scheduling on any failure, and drain already-started reads before
|
|
2147
|
+
// responding so none outlive the tracked request or reject unhandled.
|
|
2148
|
+
for (const result of workers) {
|
|
2149
|
+
if (result.status === 'rejected') throw result.reason
|
|
2150
|
+
if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
|
|
2151
|
+
}
|
|
2152
|
+
const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], findingIds)
|
|
2153
|
+
// Access to an earlier report may change while a later blob is fetched.
|
|
2154
|
+
// Reject the whole answer if any content/access/assignment snapshot changed.
|
|
2155
|
+
const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
|
|
2156
|
+
if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2157
|
+
if (current.linkRevision !== snapshot.linkRevision || current.user.role !== snapshot.user.role || current.reports.length !== reports.size
|
|
2158
|
+
|| current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
|
|
2159
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
2160
|
+
}
|
|
2161
|
+
const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
|
|
2162
|
+
outputBytes += suffix.length - 2
|
|
2163
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
2164
|
+
parts.push(suffix)
|
|
2165
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
2166
|
+
writeResponse(res, Buffer.concat(parts, outputBytes))
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// The server selects the complete published workspace for classification.
|
|
2170
|
+
// Privileged hidden-report previews are independently scoped and bounded.
|
|
2171
|
+
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null): Promise<void> {
|
|
2172
|
+
const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
|
|
2173
|
+
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
2174
|
+
await recheckTeam(deps.db, session.id, snapshot)
|
|
2175
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
2176
|
+
writeResponse(res, body)
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Triage requires both a writing role and access to the report itself.
|
|
2180
|
+
async function canTriageReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
2181
|
+
return roleAtLeast(user.role, 'triage') && await canViewReport(deps, user, reportId)
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// Ordinary users authorize annotations through the complete team's filtered
|
|
2185
|
+
// workspace. Privileged users retain report-scoped access and the same stable
|
|
2186
|
+
// ID derivation. Both paths store/read annotations by finding ID alone.
|
|
2187
|
+
const VISIBLE_IDS_CACHE_MAX = 256
|
|
2188
|
+
const visibleIdsCaches = new WeakMap<ManagedHttpDeps, Map<string, Set<string>>>()
|
|
2189
|
+
async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, reportId: string, sessionId: string, teamId: string | null): Promise<Set<string>> {
|
|
2190
|
+
if (!roleAtLeast(user.role, 'manage')) {
|
|
2191
|
+
if (!teamId) throw new TeamReportsError(404, 'no-team')
|
|
2192
|
+
return teamFindingIds(deps.db, deps.reportStore, sessionId, teamId, reportId)
|
|
2193
|
+
}
|
|
2194
|
+
const rec = await deps.db.getReport(reportId)
|
|
2195
|
+
if (rec == null) return new Set()
|
|
2196
|
+
const repo = { github: await repositoryName(deps, rec.repoId) }
|
|
2197
|
+
const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
|
|
2198
|
+
let cache = visibleIdsCaches.get(deps)
|
|
2199
|
+
if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
|
|
2200
|
+
const hit = cache.get(key)
|
|
2201
|
+
if (hit != null) return hit
|
|
2202
|
+
const ids = new Set<string>()
|
|
2203
|
+
const bytes = await deps.reportStore.get(reportId)
|
|
2204
|
+
if (bytes == null) return ids
|
|
2205
|
+
const text = bytes.toString('utf8')
|
|
2206
|
+
const { data } = readManagedReport(text, rec.filename)
|
|
2207
|
+
if (data == null) return ids
|
|
2208
|
+
const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
|
|
2209
|
+
if (report == null) return ids
|
|
2210
|
+
for (const f of report.findings) {
|
|
2211
|
+
const id = (f as { id?: unknown }).id
|
|
2212
|
+
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
2213
|
+
}
|
|
2214
|
+
while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
|
|
2215
|
+
const oldest = cache.keys().next().value
|
|
2216
|
+
if (oldest === undefined) break
|
|
2217
|
+
cache.delete(oldest)
|
|
2218
|
+
}
|
|
2219
|
+
cache.set(key, ids)
|
|
2220
|
+
return ids
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// GET /api/reports/<id>/triage — the stored triage entries for the findings of
|
|
2224
|
+
// a report the caller may view (canViewReport; 404 hides existence and denial
|
|
2225
|
+
// alike, matching handleViewReport). Entries are keyed by finding id and shared
|
|
2226
|
+
// by every report carrying the finding; a viewer only receives entries for
|
|
2227
|
+
// findings their visibility permissions keep in THIS report — an entry on a
|
|
2228
|
+
// stripped finding must not leak that the finding exists. A cleared entry is
|
|
2229
|
+
// sent as null (the server's tombstone), distinct from one never set.
|
|
2230
|
+
async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
2231
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2232
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2233
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2234
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
2235
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2236
|
+
if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2237
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
2238
|
+
const entries: Record<string, TriageEntryPatch | null> = {}
|
|
2239
|
+
for (const row of await deps.db.listTriage([...visible])) entries[row.findingId] = triageWireEntry(row)
|
|
2240
|
+
sendJson(res, 200, { entries })
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
|
|
2244
|
+
// trail (newest first, capped). The caller must have at least triage access
|
|
2245
|
+
// to the report, and the finding is one their visibility permissions
|
|
2246
|
+
// keep in it (a stripped or foreign id 404s without revealing whether it
|
|
2247
|
+
// exists). Each event is the entry as written then (null = a clear), who
|
|
2248
|
+
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
2249
|
+
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
2250
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2251
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2252
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2253
|
+
const finding = query.get('finding') ?? ''
|
|
2254
|
+
if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2255
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
2256
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2257
|
+
if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2258
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
2259
|
+
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
2260
|
+
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
2261
|
+
seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
|
|
2262
|
+
batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
2263
|
+
}))
|
|
2264
|
+
// A database read can outlive a role, membership, or visibility change.
|
|
2265
|
+
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2266
|
+
if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2267
|
+
if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
2268
|
+
sendJson(res, 200, { finding, events })
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// POST /api/reports/<id>/triage — write triage entries for a report's findings.
|
|
2272
|
+
// Mutation: same-origin + CSRF, then canTriageReport (404 on any failure — role
|
|
2273
|
+
// too low, no membership, or no such report). Body { entries: { <findingId>:
|
|
2274
|
+
// entry|null } } — whole-entry replace, last write wins, null clears the entry
|
|
2275
|
+
// (a tombstone, so a later reader adopts the clear); each entry validates
|
|
2276
|
+
// through parseTriageEntryPatch (400 on a malformed one). Every id must be a
|
|
2277
|
+
// finding the writer sees in THIS report — the report is the authorization
|
|
2278
|
+
// scope for rows that are themselves per finding id — so a stripped (or
|
|
2279
|
+
// foreign) finding id 404s without revealing whether it exists.
|
|
2280
|
+
async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
2281
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
2282
|
+
if (s == null) return
|
|
2283
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2284
|
+
let body: unknown
|
|
2285
|
+
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2286
|
+
const raw = (body as { entries?: unknown } | null)?.entries
|
|
2287
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2288
|
+
const pairs = Object.entries(raw)
|
|
2289
|
+
if (pairs.length > MAX_TRIAGE_ENTRIES) { sendJson(res, 400, { error: 'too-many-entries' }); return }
|
|
2290
|
+
const parsed: [string, TriageEntryPatch | null][] = []
|
|
2291
|
+
for (const [findingId, value] of pairs) {
|
|
2292
|
+
const patch = parseTriageEntryPatch(value)
|
|
2293
|
+
if (value != null && typeof value === 'object' && Object.hasOwn(value, 'comment')) {
|
|
2294
|
+
sendJson(res, 400, { error: 'use-comments-endpoint' }); return
|
|
2295
|
+
}
|
|
2296
|
+
if (patch === 'invalid' || findingId === '' || findingId.length > MAX_FINDING_ID) {
|
|
2297
|
+
sendJson(res, 400, { error: 'bad-entry' }); return
|
|
2298
|
+
}
|
|
2299
|
+
parsed.push([findingId, patch])
|
|
2300
|
+
}
|
|
2301
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
2302
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2303
|
+
if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2304
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
2305
|
+
if (parsed.some(([findingId]) => !visible.has(findingId))) {
|
|
2306
|
+
sendJson(res, 404, { error: 'no-finding' }); return
|
|
2307
|
+
}
|
|
2308
|
+
await deps.db.setTriageEntries(parsed, s.user.id, s.user.login, Date.now(), id)
|
|
2309
|
+
sendJson(res, 200, { ok: true })
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// GET only: clients match local annotations against these IDs without sending
|
|
2313
|
+
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
2314
|
+
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
2315
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
2316
|
+
if (!s) return
|
|
2317
|
+
const after = search.get('after') ?? ''
|
|
2318
|
+
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
2319
|
+
const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
|
|
2320
|
+
const reports = []
|
|
2321
|
+
let bytes = 0, cursor = after, processed = 0
|
|
2322
|
+
for (const report of stored) {
|
|
2323
|
+
if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
|
|
2324
|
+
bytes += report.byteSize
|
|
2325
|
+
processed++
|
|
2326
|
+
cursor = report.id
|
|
2327
|
+
const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
|
|
2328
|
+
if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
|
|
2329
|
+
}
|
|
2330
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2331
|
+
sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
2335
|
+
// writing. The store commits shared finding triage and imported comments together.
|
|
2336
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
2337
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2338
|
+
if (!s) return
|
|
2339
|
+
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
2340
|
+
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
|
|
2341
|
+
catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2342
|
+
if (!body || typeof body !== 'object') { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2343
|
+
const reading = body.entries === undefined
|
|
2344
|
+
const raw = body.entries
|
|
2345
|
+
if (!reading && (!raw || typeof raw !== 'object' || Array.isArray(raw))) { sendJson(res, 400, { error: 'bad-entries' }); return }
|
|
2346
|
+
const ids = reading ? body.findingIds : Object.keys(raw!)
|
|
2347
|
+
if (!Array.isArray(ids) || ids.length > MAX_TRIAGE_ENTRIES || ids.some(value => typeof value !== 'string' || !value || value.length > MAX_FINDING_ID)) {
|
|
2348
|
+
sendJson(res, 400, { error: 'bad-finding-ids' }); return
|
|
2349
|
+
}
|
|
2350
|
+
const parsed: [string, TriageEntryPatch | null][] = []
|
|
2351
|
+
const expected = body.expected as Record<string, string> | null
|
|
2352
|
+
if (!reading) {
|
|
2353
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) { sendJson(res, 400, { error: 'bad-expected' }); return }
|
|
2354
|
+
for (const [findingId, value] of Object.entries(raw!)) {
|
|
2355
|
+
const patch = parseTriageEntryPatch(value)
|
|
2356
|
+
if (patch === 'invalid' || !/^[a-f\d]{64}$/u.test(expected[findingId] ?? '')) { sendJson(res, 400, { error: 'bad-entry' }); return }
|
|
2357
|
+
parsed.push([findingId, patch])
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2361
|
+
const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
2362
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2363
|
+
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
2364
|
+
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
2365
|
+
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
2366
|
+
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// A report grants access to its visible findings; comments themselves are
|
|
2370
|
+
// shared by finding ID. Author IDs always come from the authenticated session.
|
|
2371
|
+
async function handleReportComments(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, reportId: string, commentId: string | null, teamId: string | null): Promise<void> {
|
|
2372
|
+
const method = req.method ?? 'GET'
|
|
2373
|
+
if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
|
|
2374
|
+
send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
|
|
2375
|
+
}
|
|
2376
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
2377
|
+
if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2378
|
+
const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
|
|
2379
|
+
if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
2380
|
+
let raw: { body?: unknown; findingId?: unknown; version?: unknown } | null
|
|
2381
|
+
if (method === 'GET') raw = null
|
|
2382
|
+
else {
|
|
2383
|
+
try { raw = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof raw } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2384
|
+
}
|
|
2385
|
+
// Body delivery and a cold report read can outlive a permission change.
|
|
2386
|
+
// Match triage's recheck before exposing or changing any annotation.
|
|
2387
|
+
await visibleFindingIds(deps, s.user, reportId, s.session.id, teamId)
|
|
2388
|
+
const session = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2389
|
+
if (!session || session.user.role !== s.user.role || !(await canViewReport(deps, session.user, reportId))) {
|
|
2390
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
2391
|
+
}
|
|
2392
|
+
const visible = await visibleFindingIds(deps, session.user, reportId, session.session.id, teamId)
|
|
2393
|
+
if (method === 'GET') {
|
|
2394
|
+
sendJson(res, 200, { comments: await deps.db.listComments([...visible]) }); return
|
|
2395
|
+
}
|
|
2396
|
+
const body = parseCommentBody(raw?.body)
|
|
2397
|
+
if (body == null && method !== 'DELETE') { sendJson(res, 400, { error: 'bad-comment' }); return }
|
|
2398
|
+
if (commentId == null) {
|
|
2399
|
+
if (typeof raw?.findingId !== 'string' || !visible.has(raw.findingId)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
2400
|
+
const comment = await deps.db.createComment({ findingId: raw.findingId, body: body!, authorId: session.user.id, authorLogin: session.user.login, reportId }, Date.now())
|
|
2401
|
+
sendJson(res, 201, { comment }); return
|
|
2402
|
+
}
|
|
2403
|
+
const current = await deps.db.getComment(commentId)
|
|
2404
|
+
if (current == null || !visible.has(current.findingId)) { sendJson(res, 404, { error: 'no-comment' }); return }
|
|
2405
|
+
const allowedComment = method === 'DELETE' ? canDeleteComment(current, session.user) : current.authorId === session.user.id
|
|
2406
|
+
if (!allowedComment) { sendJson(res, 403, { error: 'not-comment-author' }); return }
|
|
2407
|
+
if (typeof raw?.version !== 'number' || !Number.isSafeInteger(raw.version) || raw.version < 1) {
|
|
2408
|
+
sendJson(res, 400, { error: 'bad-version' }); return
|
|
2409
|
+
}
|
|
2410
|
+
const comment = method === 'DELETE'
|
|
2411
|
+
? await deps.db.deleteComment(commentId, session.user.id, session.user.login, raw.version, reportId, Date.now())
|
|
2412
|
+
: await deps.db.editComment(commentId, session.user.id, session.user.login, body!, raw.version, reportId, Date.now())
|
|
2413
|
+
if (comment === 'conflict') { sendJson(res, 409, { error: 'comment-changed' }); return }
|
|
2414
|
+
if (comment === 'forbidden') { sendJson(res, 403, { error: 'not-comment-author' }); return }
|
|
2415
|
+
if (comment == null) { sendJson(res, 404, { error: 'no-comment' }); return }
|
|
2416
|
+
if (comment === 'deleted') { res.writeHead(204, { 'cache-control': 'no-store' }); res.end(); return }
|
|
2417
|
+
sendJson(res, 200, { comment })
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// GET /api/admin/teams — every team (members + repos inlined) plus the pickers
|
|
2421
|
+
// the page needs: all users (member dropdown), selected repos (repo dropdown),
|
|
2422
|
+
// and the visibility-permission keys. admin, read-only (no CSRF).
|
|
2423
|
+
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2424
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
2425
|
+
if (s == null) return
|
|
2426
|
+
const scopes = await deps.db.listTeamNpmScopes()
|
|
2427
|
+
const reviewTeams = new Set(await deps.db.listDependencyReviewEnabledTeams())
|
|
2428
|
+
sendJson(res, 200, {
|
|
2429
|
+
teams: (await deps.db.listTeams()).map(team => ({ ...team, npmScopes: scopes[team.id] ?? [], dependencyReview: reviewTeams.has(team.id) })),
|
|
2430
|
+
users: await deps.db.listUserOptions(),
|
|
2431
|
+
repos: selectableRepos(await deps.db.listSelectedRepos()),
|
|
2432
|
+
permissions: VISIBILITY_PERMISSIONS,
|
|
2433
|
+
})
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
|
|
2437
|
+
async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2438
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2439
|
+
if (s == null) return
|
|
2440
|
+
let body: unknown
|
|
2441
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2442
|
+
const rawName = (body as { name?: unknown } | null)?.name
|
|
2443
|
+
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
|
2444
|
+
if (name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
|
|
2445
|
+
const id = randomUUID()
|
|
2446
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2447
|
+
if (!(await deps.db.createTeam(id, name, Date.now()))) { sendJson(res, 409, { error: 'name-taken' }); return }
|
|
2448
|
+
await activity(deps, s.user, 'access', `created team ${name}`)
|
|
2449
|
+
sendJson(res, 201, await deps.db.getTeam(id))
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
|
|
2453
|
+
// such team; 409 if the new name is already taken by another team.
|
|
2454
|
+
async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2455
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2456
|
+
if (s == null) return
|
|
2457
|
+
let body: unknown
|
|
2458
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2459
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2460
|
+
const rawName = (body as { name?: unknown } | null)?.name
|
|
2461
|
+
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
|
2462
|
+
if (typeof teamId !== 'string' || name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
|
|
2463
|
+
const team = await deps.db.getTeam(teamId)
|
|
2464
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2465
|
+
const result = await deps.db.renameTeam(teamId, name, Date.now())
|
|
2466
|
+
if (result === 'not-found') { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2467
|
+
if (result === 'name-taken') { sendJson(res, 409, { error: 'name-taken' }); return }
|
|
2468
|
+
if (team?.name !== name) await activity(deps, s.user, 'access', `renamed team ${team?.name ?? teamId} to ${name}`)
|
|
2469
|
+
sendJson(res, 200, { ok: true, name })
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Hidden teams retain their configuration but grant no access or sidebar presence.
|
|
2473
|
+
async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2474
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2475
|
+
if (s == null) return
|
|
2476
|
+
let body: unknown
|
|
2477
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2478
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2479
|
+
const hidden = (body as { hidden?: unknown } | null)?.hidden
|
|
2480
|
+
if (typeof teamId !== 'string' || typeof hidden !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2481
|
+
const team = await deps.db.getTeam(teamId)
|
|
2482
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2483
|
+
if (!(await deps.db.setTeamHidden(teamId, hidden, Date.now()))) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2484
|
+
if (team?.hidden !== hidden) await activity(deps, s.user, 'access', `${hidden ? 'hid' : 'restored'} team ${team?.name ?? teamId}`)
|
|
2485
|
+
sendJson(res, 200, { ok: true, hidden })
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
2489
|
+
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2490
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2491
|
+
if (s == null) return
|
|
2492
|
+
let body: unknown
|
|
2493
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2494
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2495
|
+
if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2496
|
+
const team = await deps.db.getTeam(teamId)
|
|
2497
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2498
|
+
if (!(await deps.db.deleteTeam(teamId))) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2499
|
+
await activity(deps, s.user, 'access', `deleted team ${team?.name ?? teamId}`)
|
|
2500
|
+
sendJson(res, 200, { ok: true })
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// POST /api/admin/teams/set-repo — add a repo path. Empty path replaces all
|
|
2504
|
+
// path links with the whole repository. Body { teamId, repoId, path? }.
|
|
2505
|
+
// Repo must be in the selected set.
|
|
2506
|
+
async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2507
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2508
|
+
if (s == null) return
|
|
2509
|
+
let body: unknown
|
|
2510
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2511
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2512
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
2513
|
+
if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) {
|
|
2514
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
2515
|
+
}
|
|
2516
|
+
const path = normalizeTeamPath((body as { path?: unknown }).path)
|
|
2517
|
+
if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
2518
|
+
const team = (await deps.db.listTeams()).find(row => row.id === teamId)
|
|
2519
|
+
if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2520
|
+
if (!(await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)) { sendJson(res, 400, { error: 'repo-not-selected' }); return }
|
|
2521
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2522
|
+
await deps.db.setTeamRepo(teamId, repoId, path.path)
|
|
2523
|
+
if (!team.repos.some(repo => repo.repoId === repoId && (!repo.path || repo.path === (path.path ?? '')))) {
|
|
2524
|
+
await activity(deps, s.user, 'access', `granted team ${team.name} access to ${path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
|
|
2525
|
+
}
|
|
2526
|
+
sendJson(res, 200, { ok: true })
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
|
|
2530
|
+
// the legacy remove-all behavior. Body { teamId, repoId, path? }.
|
|
2531
|
+
async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2532
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2533
|
+
if (s == null) return
|
|
2534
|
+
let body: unknown
|
|
2535
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2536
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2537
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
2538
|
+
if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2539
|
+
const rawPath = (body as { path?: unknown }).path
|
|
2540
|
+
if (rawPath !== undefined && rawPath !== null && typeof rawPath !== 'string') { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
2541
|
+
const path = normalizeTeamPath(rawPath)
|
|
2542
|
+
if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
2543
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2544
|
+
if (!(await deps.db.removeTeamRepo(teamId, repoId, rawPath === undefined ? undefined : path.path))) { sendJson(res, 404, { error: 'not-linked' }); return }
|
|
2545
|
+
await activity(deps, s.user, 'access', `removed team ${(await deps.db.getTeam(teamId))?.name ?? teamId}'s access to ${rawPath === undefined ? 'all paths' : path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
|
|
2546
|
+
sendJson(res, 200, { ok: true })
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// POST /api/admin/teams/set-member — add/update a member + their visibility
|
|
2550
|
+
// permissions. Body { teamId, userId, dependencies?, security? }.
|
|
2551
|
+
async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2552
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2553
|
+
if (s == null) return
|
|
2554
|
+
let body: unknown
|
|
2555
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2556
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2557
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
2558
|
+
if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2559
|
+
const team = (await deps.db.listTeams()).find(row => row.id === teamId)
|
|
2560
|
+
if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2561
|
+
const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
|
|
2562
|
+
if (user == null) { sendJson(res, 404, { error: 'no-user' }); return }
|
|
2563
|
+
const permissions = parseTeamUserPermissions(body)
|
|
2564
|
+
const member = team.members.find(row => row.userId === userId)
|
|
2565
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2566
|
+
await deps.db.setTeamMember(teamId, userId, permissions)
|
|
2567
|
+
if (!member || member.dependencies !== permissions.dependencies || member.security !== permissions.security) {
|
|
2568
|
+
await activity(deps, s.user, 'access', `set ${user.login}'s membership in ${team.name} (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`)
|
|
2569
|
+
}
|
|
2570
|
+
sendJson(res, 200, { ok: true })
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
|
|
2574
|
+
async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2575
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2576
|
+
if (s == null) return
|
|
2577
|
+
let body: unknown
|
|
2578
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2579
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2580
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
2581
|
+
if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2582
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2583
|
+
if (!(await deps.db.removeTeamMember(teamId, userId))) { sendJson(res, 404, { error: 'not-member' }); return }
|
|
2584
|
+
const team = await deps.db.getTeam(teamId)
|
|
2585
|
+
const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
|
|
2586
|
+
await activity(deps, s.user, 'access', `removed ${user?.login ?? userId} from team ${team?.name ?? teamId}`)
|
|
2587
|
+
sendJson(res, 200, { ok: true })
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
async function handleSetTeamDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2591
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2592
|
+
if (!s) return
|
|
2593
|
+
let body
|
|
2594
|
+
try { body = await readJsonBody(req) as { teamId?: unknown; enabled?: unknown } } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2595
|
+
if (typeof body?.teamId !== 'string' || typeof body?.enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2596
|
+
if (!await deps.db.setTeamDependencyReview(s.session.id, body.teamId, body.enabled)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2597
|
+
sendJson(res, 200, { ok: true })
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// POST /api/admin/teams/set-npm-scopes — replace the npm scopes whose private
|
|
2601
|
+
// packages the team's members may read. Body { teamId, scopes: ['@scope'] }.
|
|
2602
|
+
async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2603
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2604
|
+
if (s == null) return
|
|
2605
|
+
let body: unknown
|
|
2606
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2607
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2608
|
+
if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2609
|
+
const team = await deps.db.getTeam(teamId)
|
|
2610
|
+
const changed = await deps.db.setTeamNpmScopes(s.session.id, teamId, (body as { scopes?: unknown }).scopes)
|
|
2611
|
+
if (changed == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2612
|
+
const parts = [changed.added.length > 0 ? `added ${changed.added.join(', ')}` : '', changed.removed.length > 0 ? `removed ${changed.removed.join(', ')}` : ''].filter(Boolean)
|
|
2613
|
+
if (parts.length > 0) await activity(deps, s.user, 'access', `changed team ${team?.name ?? teamId}'s npm scopes: ${parts.join('; ')}`)
|
|
2614
|
+
sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// Who reads npm packages now: a session with workspace access, its role and
|
|
2618
|
+
// the npm scopes of its visible teams. Null for none.
|
|
2619
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
|
|
2620
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2621
|
+
if (s == null || !roleAtLeast(s.user.role, 'view')) return null
|
|
2622
|
+
return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
2626
|
+
// answers it) where it found none.
|
|
2627
|
+
function found<T>(value: T | null | undefined): T {
|
|
2628
|
+
if (value == null) throw new NpmPackageError(404, 'package-not-found')
|
|
2629
|
+
return value
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Lists must enforce the same target and base access as the review itself,
|
|
2633
|
+
// including permission changes while the registry requests are in flight.
|
|
2634
|
+
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
|
|
2635
|
+
const controller = new AbortController()
|
|
2636
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2637
|
+
res.on('close', onClose)
|
|
2638
|
+
try {
|
|
2639
|
+
const reader = await npmReader(deps, cookie)
|
|
2640
|
+
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2641
|
+
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2642
|
+
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2643
|
+
// day: a list asks npm for none it read within it.
|
|
2644
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2645
|
+
const read = (name: string, version: string) => {
|
|
2646
|
+
const key = `${name}@${version}`
|
|
2647
|
+
if (!documents.has(key)) documents.set(key, readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
2648
|
+
return documents.get(key)!
|
|
2649
|
+
}
|
|
2650
|
+
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2651
|
+
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2652
|
+
])))
|
|
2653
|
+
const current = await npmReader(deps, cookie)
|
|
2654
|
+
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2655
|
+
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
if (controller.signal.aborted) return null
|
|
2658
|
+
if (error instanceof NpmPackageError) { sendJson(res, error.status, { error: error.message }); return null }
|
|
2659
|
+
throw error
|
|
2660
|
+
} finally { res.off('close', onClose) }
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
const githubRepoOf = (doc: NpmVersionDocument) => (doc.manifest['github'] as { github?: string } | undefined)?.github ?? null
|
|
2664
|
+
|
|
2665
|
+
function npmAdvisoryOptions(deps: ManagedHttpDeps, githubToken: NpmAdvisoryOptions['githubToken'], latestDoc: () => Promise<NpmVersionDocument | null>): NpmAdvisoryOptions {
|
|
2666
|
+
return {
|
|
2667
|
+
githubToken,
|
|
2668
|
+
repo: async () => { const doc = await latestDoc(); return doc && githubRepoOf(doc) },
|
|
2669
|
+
cache: signal => auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
|
|
2670
|
+
lastListing: repo => lastListing(deps.config.upstreamCacheDir, deps.db, repo, deps.config.debug),
|
|
2671
|
+
debug: deps.config.debug,
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// Read the same target-version evidence as the viewer. Missing upstream
|
|
2676
|
+
// reports impose no extra verdict restriction; detected findings do.
|
|
2677
|
+
async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReader, doc: NpmVersionDocument, signal: AbortSignal) {
|
|
2678
|
+
if (npmTakenDown({ npm: doc })) return 'reject' as const
|
|
2679
|
+
const privileged = canReadPrivateNpm(reader, doc.name)
|
|
2680
|
+
const advisories = async () => {
|
|
2681
|
+
const versions = await readNpmVersions(doc.name, privileged, signal)
|
|
2682
|
+
if (!versions) return null
|
|
2683
|
+
return npmAdvisories(doc.name, versions.versions, npmAdvisoryOptions(deps,
|
|
2684
|
+
() => ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null),
|
|
2685
|
+
() => readNpmVersion(doc.name, 'latest', privileged, signal)))
|
|
2686
|
+
}
|
|
2687
|
+
const [list, socket, risk] = await Promise.all([advisories().catch(() => null), doc.private ? null : npmSocketReport(doc.name, doc.version).catch(() => null),
|
|
2688
|
+
npmRisk(doc, null, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped: true, privileged, signal })])
|
|
2689
|
+
return npmReviewVerdictLimit(doc, list, socket, risk)
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// Reviews are scoped to the bytes actually displayed, including the base.
|
|
2693
|
+
// Access to both versions is rechecked before returning or saving annotations.
|
|
2694
|
+
async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
|
|
2695
|
+
const method = req.method ?? 'GET'
|
|
2696
|
+
if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
|
|
2697
|
+
const mutation = method !== 'GET'
|
|
2698
|
+
const session = mutation ? await checkMutation(req, res, deps, cookie) : await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2699
|
+
if (!session) { if (!mutation) sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2700
|
+
if (!roleAtLeast(session.user.role, mutation ? 'triage' : 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2701
|
+
let body: Record<string, unknown> = {}
|
|
2702
|
+
if (mutation) {
|
|
2703
|
+
try {
|
|
2704
|
+
const raw = await readJsonBody(req, 128 * 1024)
|
|
2705
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
2706
|
+
body = raw as Record<string, unknown>
|
|
2707
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2708
|
+
}
|
|
2709
|
+
const field = (key: string) => mutation ? body[key] : params.get(key)
|
|
2710
|
+
const name = field('package')
|
|
2711
|
+
const version = field('version')
|
|
2712
|
+
const integrity = field('integrity')
|
|
2713
|
+
const basePackage = field('basePackage') ?? null
|
|
2714
|
+
const baseVersion = field('baseVersion') ?? null
|
|
2715
|
+
const baseIntegrity = field('baseIntegrity') ?? null
|
|
2716
|
+
const validIntegrity = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 1024
|
|
2717
|
+
if (field('ecosystem') !== 'npm' || !isNpmPackageName(name) || !isNpmPackageSpec(version) || !validIntegrity(integrity)
|
|
2718
|
+
|| (basePackage !== null || baseVersion !== null || baseIntegrity !== null)
|
|
2719
|
+
&& (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))) {
|
|
2720
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
2721
|
+
}
|
|
2722
|
+
const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity,
|
|
2723
|
+
basePackage: basePackage as string | null, baseVersion: baseVersion as string | null, baseIntegrity: baseIntegrity as string | null }
|
|
2724
|
+
let change: ReviewChange | null = null
|
|
2725
|
+
if (mutation) {
|
|
2726
|
+
const action = body['action'], reviewId = body['reviewId'] ?? null, revision = body['revision'] ?? null
|
|
2727
|
+
if (typeof action !== 'string' || !['start', 'save', 'publish', 'continue', 'revoke'].includes(action)
|
|
2728
|
+
|| (reviewId !== null && (typeof reviewId !== 'string' || reviewId.length === 0 || reviewId.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1))
|
|
2729
|
+
|| (!['save', 'start'].includes(action) && reviewId === null) || (action === 'start' && reviewId !== null)) { sendJson(res, 400, { error: 'bad-review' }); return }
|
|
2730
|
+
if (action === 'start') change = { action, reviewId: null, revision: null }
|
|
2731
|
+
else if (action === 'publish') {
|
|
2732
|
+
const teamIds = body['teamIds']
|
|
2733
|
+
if (!Array.isArray(teamIds) || teamIds.length === 0 || teamIds.length > 100 || teamIds.some(id => typeof id !== 'string' || id.length > 100)) {
|
|
2734
|
+
sendJson(res, 400, { error: 'bad-review-teams' }); return
|
|
2735
|
+
}
|
|
2736
|
+
const finalComment = body['finalComment'] ?? '', finalStatus = body['finalStatus'] ?? null
|
|
2737
|
+
if (typeof finalComment !== 'string' || finalComment.length > 20000 || finalStatus !== null
|
|
2738
|
+
&& (typeof finalStatus !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(finalStatus))) {
|
|
2739
|
+
sendJson(res, 400, { error: 'bad-final-verdict' }); return
|
|
2740
|
+
}
|
|
2741
|
+
change = { action, reviewId: reviewId as string, revision: revision as number, teamIds, files: [], nonTextFiles: [],
|
|
2742
|
+
final: finalStatus === null ? null : { status: finalStatus as ReviewInput['status'] & string, comment: finalComment } }
|
|
2743
|
+
} else if (action === 'continue' || action === 'revoke') change = { action, reviewId: reviewId as string, revision: revision as number }
|
|
2744
|
+
else {
|
|
2745
|
+
const file = body['file']
|
|
2746
|
+
const comment = body['comment']
|
|
2747
|
+
const status = body['status'] ?? null
|
|
2748
|
+
const entryId = body['entryId'] ?? null
|
|
2749
|
+
if (typeof file !== 'string' || !file || file.length > 4096 || file.includes('\0')
|
|
2750
|
+
|| (entryId !== null && (typeof entryId !== 'string' || entryId.length === 0 || entryId.length > 100))
|
|
2751
|
+
|| typeof comment !== 'string' || comment.length > 20000
|
|
2752
|
+
|| (status !== null && (typeof status !== 'string' || !['accept', 'accept-with-nits', 'request-changes', 'reject'].includes(status))) || (status === null && entryId === null)) {
|
|
2753
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
2754
|
+
}
|
|
2755
|
+
change = { action: 'save', reviewId: reviewId as string | null, revision: revision as number | null, entryId: entryId as string | null,
|
|
2756
|
+
entry: { file, comment, status: status as ReviewInput['status'] } }
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
const controller = new AbortController()
|
|
2760
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2761
|
+
res.on('close', onClose)
|
|
2762
|
+
try {
|
|
2763
|
+
const reader = await npmReader(deps, cookie)
|
|
2764
|
+
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2765
|
+
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2766
|
+
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
2767
|
+
const [doc, base] = await Promise.all([read(name, version), context.basePackage ? read(context.basePackage, context.baseVersion!) : null])
|
|
2768
|
+
if (!doc || (context.basePackage && !base)) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2769
|
+
if (doc.version !== version || doc.dist.integrity !== integrity
|
|
2770
|
+
|| (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
|
|
2771
|
+
sendJson(res, 409, { error: 'review-content-changed' }); return
|
|
2772
|
+
}
|
|
2773
|
+
if (change?.action === 'publish' || change?.action === 'save') {
|
|
2774
|
+
const loads = [doc, ...base ? [base] : []].map(loadNpmFiles)
|
|
2775
|
+
try {
|
|
2776
|
+
const files = await Promise.all(loads.map(load => load.result))
|
|
2777
|
+
const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
|
|
2778
|
+
const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
|
|
2779
|
+
const diff = computeBundleDiff(before, after, () => '.')
|
|
2780
|
+
const changed: { path: string; basePath?: string }[] = [...diff.files.onlyBase, ...diff.files.onlyOther, ...diff.files.changed]
|
|
2781
|
+
const reviewable = (file: typeof changed[number]) => isReviewableFile(before.get(file.basePath ?? file.path), after.get(file.path))
|
|
2782
|
+
const reviewedPath = change.action === 'save' ? change.entry.file : null
|
|
2783
|
+
const reviewedFile = changed.find(file => file.path === reviewedPath)
|
|
2784
|
+
if (change.action === 'save' && !reviewedFile) { sendJson(res, 400, { error: 'unknown-review-file' }); return }
|
|
2785
|
+
if (change.action === 'save' && change.entry.status !== null && reviewedFile && !reviewable(reviewedFile)) { sendJson(res, 400, { error: 'non-text-review-file' }); return }
|
|
2786
|
+
if (change.action === 'publish') {
|
|
2787
|
+
change.files = changed.map(file => file.path)
|
|
2788
|
+
change.nonTextFiles = changed.filter(file => !reviewable(file)).map(file => file.path)
|
|
2789
|
+
}
|
|
2790
|
+
} finally { loads.forEach(load => load.release()) }
|
|
2791
|
+
}
|
|
2792
|
+
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
2793
|
+
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
2794
|
+
const current = await npmReader(deps, cookie)
|
|
2795
|
+
if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2796
|
+
if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2797
|
+
if (!change) {
|
|
2798
|
+
const allBases = params.get('allBases') === 'true'
|
|
2799
|
+
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2800
|
+
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
2801
|
+
if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2802
|
+
return
|
|
2803
|
+
}
|
|
2804
|
+
const checked = await checkMutation(req, res, deps, cookie)
|
|
2805
|
+
if (!checked) return
|
|
2806
|
+
if (checked.user.id !== current.userId || !roleAtLeast(checked.user.role, 'triage')) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
2807
|
+
if (change.action === 'publish' && change.final && !allowedReviewVerdicts([], limit).includes(change.final.status)) {
|
|
2808
|
+
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2809
|
+
}
|
|
2810
|
+
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2811
|
+
sendJson(res, 200, { review })
|
|
2812
|
+
} catch (err) {
|
|
2813
|
+
if (controller.signal.aborted) return
|
|
2814
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
2815
|
+
if (err instanceof NpmRiskError) { sendRiskError(res, err); return }
|
|
2816
|
+
throw err
|
|
2817
|
+
} finally { res.off('close', onClose) }
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
2821
|
+
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2822
|
+
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2823
|
+
// Access is checked again after the registry's answer.
|
|
2824
|
+
async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string, params: URLSearchParams): Promise<void> {
|
|
2825
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
2826
|
+
const name = params.get('name'), spec = params.get('version') ?? 'latest'
|
|
2827
|
+
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2828
|
+
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2829
|
+
const otherSpec = params.get('otherVersion') ?? ''
|
|
2830
|
+
if (!isNpmPackageName(name) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2831
|
+
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2832
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2833
|
+
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2834
|
+
}
|
|
2835
|
+
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2836
|
+
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2837
|
+
const reader = await npmReader(deps, cookie)
|
|
2838
|
+
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2839
|
+
const controller = new AbortController()
|
|
2840
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2841
|
+
res.on('close', onClose)
|
|
2842
|
+
// A reader who lost their session, or their private access to an answer
|
|
2843
|
+
// that needed it, while the registry was asked gets nothing.
|
|
2844
|
+
const recheck = async (isPrivate: boolean) => {
|
|
2845
|
+
const current = await npmReader(deps, cookie)
|
|
2846
|
+
if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
2847
|
+
if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
|
|
2848
|
+
return !res.destroyed
|
|
2849
|
+
}
|
|
2850
|
+
const reply = async (isPrivate: boolean, body: Record<string, unknown>) => { if (await recheck(isPrivate)) sendJson(res, 200, body) }
|
|
2851
|
+
// The reader's GitHub token, for GitHub's rate limits and never for what it
|
|
2852
|
+
// shows: asked for once, and only where an answer isn't kept (npm-insights.ts).
|
|
2853
|
+
let token: Promise<string | null> | undefined
|
|
2854
|
+
const githubToken = () => token ??= ensureUserAccessToken(deps.config, deps.db, reader.userId, Date.now()).catch(() => null)
|
|
2855
|
+
const privileged = canReadPrivateNpm(reader, name)
|
|
2856
|
+
// Public versions' documents, kept in the database (npm-kept.ts), as
|
|
2857
|
+
// repository risk scans keep them: an exact version's, read on every view,
|
|
2858
|
+
// asked of npm once a day.
|
|
2859
|
+
const keptVersions = npmVersionStore(deps.db, deps.config.debug)
|
|
2860
|
+
// The latest version, read once a request: the package's figures and
|
|
2861
|
+
// advisories go by the repository it names.
|
|
2862
|
+
let latest: Promise<NpmVersionDocument | null> | undefined
|
|
2863
|
+
const latestDoc = () => latest ??= readNpmVersion(name, 'latest', privileged, controller.signal, keptVersions)
|
|
2864
|
+
const advisoryOptions = npmAdvisoryOptions(deps, githubToken, latestDoc)
|
|
2865
|
+
// The figures of the repository the latest version names, noted as its
|
|
2866
|
+
// repository for npmKnown; null where it names none, or they can't be had.
|
|
2867
|
+
const githubFigures = (doc: NpmVersionDocument) => {
|
|
2868
|
+
const repo = githubRepoOf(doc)
|
|
2869
|
+
npmLatestRepo(name, repo)
|
|
2870
|
+
return repo === null ? null : npmGithubStats(repo, githubToken).catch(() => null)
|
|
2871
|
+
}
|
|
2872
|
+
try {
|
|
2873
|
+
// With whether a visible team lists its scope (`teamScoped`): its own
|
|
2874
|
+
// releases, which the viewer doesn't flag as just published.
|
|
2875
|
+
if (path === NPM_VERSIONS_PATH) {
|
|
2876
|
+
const scope = npmPackageScope(name)
|
|
2877
|
+
const [versions, teamScoped] = await Promise.all([readNpmVersions(name, privileged, controller.signal, { times: true }),
|
|
2878
|
+
scope !== null && deps.db.hasTeamNpmScope(scope)])
|
|
2879
|
+
await reply(found(versions).private, { ...versions, teamScoped })
|
|
2880
|
+
return
|
|
2881
|
+
}
|
|
2882
|
+
// Across every published version, as the version list has them, which
|
|
2883
|
+
// each advisory's `affected` indexes; those of the repository its latest
|
|
2884
|
+
// version names, where GitHub says it is public, asked with the reader's
|
|
2885
|
+
// GitHub token, its listing kept as bundle advisories keep it.
|
|
2886
|
+
if (path === NPM_ADVISORIES_PATH) {
|
|
2887
|
+
const versions = found(await readNpmVersions(name, privileged, controller.signal))
|
|
2888
|
+
await reply(versions.private, { name, ...await npmAdvisories(name, versions.versions, advisoryOptions) })
|
|
2889
|
+
return
|
|
2890
|
+
}
|
|
2891
|
+
// The package's, with the repository its latest version names; either
|
|
2892
|
+
// is null where it can't be had.
|
|
2893
|
+
if (path === NPM_STATS_PATH) {
|
|
2894
|
+
// npm's downloads, which need nothing of the version, are asked at once;
|
|
2895
|
+
// none is answered before the package's access is.
|
|
2896
|
+
const downloadsAsked = npmDownloads(name).catch(() => null)
|
|
2897
|
+
const doc = found(await latestDoc())
|
|
2898
|
+
const [downloads, github] = await Promise.all([downloadsAsked, githubFigures(doc)])
|
|
2899
|
+
await reply(doc.private, { name, downloads, github })
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
const doc = found(await readNpmVersion(name, spec, privileged, controller.signal, keptVersions))
|
|
2903
|
+
if (spec === 'latest') latest = Promise.resolve(doc)
|
|
2904
|
+
const repo = githubRepoOf(doc), sha = bundleCommitHash(doc.manifest['gitHead'])
|
|
2905
|
+
// What the viewer asks about a version beside its files, by its route,
|
|
2906
|
+
// and asks for meanwhile below. Its publish commit's tags, where its
|
|
2907
|
+
// repository is public on GitHub and the reader has a token to ask with.
|
|
2908
|
+
// Socket's report, asked only where npm answers for the version without a
|
|
2909
|
+
// token: a private package's name never leaves for Socket. npm's
|
|
2910
|
+
// provenance, asked only where its version says npm keeps one, and npm
|
|
2911
|
+
// answers for the version without a token.
|
|
2912
|
+
const tagsOf = () => repo === null || sha === null ? Promise.resolve([]) : npmCommitTags(repo, sha, doc.version, githubToken)
|
|
2913
|
+
const socketOf = () => doc.private ? Promise.resolve(null) : npmSocketReport(name, doc.version)
|
|
2914
|
+
const provenanceOf = () => doc.private || doc.manifest['provenance'] !== true ? Promise.resolve(null) : npmProvenance(name, doc.version, doc.dist.integrity)
|
|
2915
|
+
// None where tags or Socket's report can't be had; a failure to have
|
|
2916
|
+
// provenance is answered as one, for the viewer to ask again.
|
|
2917
|
+
if (path === NPM_TAGS_PATH) { await reply(doc.private, { name, version: doc.version, tags: await tagsOf().catch(() => []) }); return }
|
|
2918
|
+
if (path === NPM_SOCKET_PATH) { await reply(doc.private, { name, version: doc.version, socket: await socketOf().catch(() => null) }); return }
|
|
2919
|
+
// What calls for a look at the version, as risk tells it (npm-risk.ts),
|
|
2920
|
+
// against `otherVersion` where given, as Compare asks, else against the
|
|
2921
|
+
// one risk picks of its versions, as the Overview asks; trusted however
|
|
2922
|
+
// new and however few download it where a team lists its scope, its own
|
|
2923
|
+
// packages, as a new dependency on a package of such a scope is.
|
|
2924
|
+
if (path === NPM_RISK_PATH) {
|
|
2925
|
+
const old = otherSpec === '' || otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2926
|
+
const scope = npmPackageScope(name)
|
|
2927
|
+
const teamScoped = scope !== null && await deps.db.hasTeamNpmScope(scope)
|
|
2928
|
+
const assessment = await npmRisk(doc, otherSpec === '' ? 'pick' : old, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug, teamScoped, fromEmpty: otherSpec === '(empty)',
|
|
2929
|
+
ownScope: listed => deps.db.hasTeamNpmScope(listed), privileged, signal: controller.signal })
|
|
2930
|
+
// What it tells is read of both versions: private where either is.
|
|
2931
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2932
|
+
await reply(doc.private || old?.private === true, { ...assessment })
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
2936
|
+
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2937
|
+
// npmAll), or, against another version, the files that changed between
|
|
2938
|
+
// them, both sides' (compared), a line each as it is formatted, or found
|
|
2939
|
+
// kept for a public version; its files read before anything is answered.
|
|
2940
|
+
// A reader who lost their session, or their access, meanwhile has the
|
|
2941
|
+
// rest cut short.
|
|
2942
|
+
if (path === NPM_PRETTY_PATH && params.has('path')) {
|
|
2943
|
+
// One of its files pretty-printed, as one of a bundle's is.
|
|
2944
|
+
const file = prettyRequest(params)
|
|
2945
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2946
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2947
|
+
const copy = await deps.prettyCache.npm(doc, file)
|
|
2948
|
+
if (!(await recheck(doc.private))) { copy.stream.destroy(); return }
|
|
2949
|
+
await sendEncoded(req, res, copy, 'text/plain; charset=utf-8', prettySource(file))
|
|
2950
|
+
return
|
|
2951
|
+
}
|
|
2952
|
+
// Against another version, the pairs of its files that changed that the
|
|
2953
|
+
// viewer diffs with renamed names hidden (rename-diff.ts renamedPairs), a
|
|
2954
|
+
// line each as it is worked out, as Compare's pretty-printed files are;
|
|
2955
|
+
// and those that changed in types alone (type-only.ts).
|
|
2956
|
+
if (path === NPM_PRETTY_PATH || path === NPM_TYPE_ONLY_PATH || (path === NPM_RENAME_DIFF_PATH && !diffed)) {
|
|
2957
|
+
if (!deps.prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2958
|
+
const other = otherSpec === '' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2959
|
+
const sides = other && await deps.prettyCache.npmSides(doc, other)
|
|
2960
|
+
const lines = path === NPM_RENAME_DIFF_PATH ? renamedPairs(sides!, !doc.private && !other!.private)
|
|
2961
|
+
: path === NPM_TYPE_ONLY_PATH ? typeOnlyFiles(sides!) : sides ? compared(sides) : await deps.prettyCache.npmAll(doc)
|
|
2962
|
+
const isPrivate = doc.private || other?.private === true
|
|
2963
|
+
if (!(await recheck(isPrivate))) return
|
|
2964
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2965
|
+
await sendLines(res, lines, async () => {
|
|
2966
|
+
const current = await npmReader(deps, cookie)
|
|
2967
|
+
return current != null && (!isPrivate || canReadPrivateNpm(current, name))
|
|
2968
|
+
})
|
|
2969
|
+
return
|
|
2970
|
+
}
|
|
2971
|
+
// One of its JavaScript files diffed with another version's, both
|
|
2972
|
+
// pretty-printed, renamed names hidden (rename-diff.ts).
|
|
2973
|
+
if (diffed) {
|
|
2974
|
+
const { prettyCache } = deps
|
|
2975
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2976
|
+
const other = found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
2977
|
+
const diff = await renameDiffOf(diffed, async () => [await prettyText(await prettyCache.npm(doc, diffed.base)),
|
|
2978
|
+
await prettyText(await prettyCache.npm(other, diffed.other))], !doc.private && !other.private)
|
|
2979
|
+
if (!(await recheck(doc.private || other.private))) { diff.stream.destroy(); return }
|
|
2980
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2981
|
+
await sendEncoded(req, res, diff, 'application/json')
|
|
2982
|
+
return
|
|
2983
|
+
}
|
|
2984
|
+
// The load stays held until this response is written or abandoned, and
|
|
2985
|
+
// a reader who stops reading it gives it up after a while. One already
|
|
2986
|
+
// gone takes none, as its close has passed.
|
|
2987
|
+
if (res.destroyed) return
|
|
2988
|
+
const download = path === NPM_DOWNLOAD_PATH
|
|
2989
|
+
// A version's files take longest to load: what the viewer asks for beside
|
|
2990
|
+
// them is asked for meanwhile, so that what is answered by the time they
|
|
2991
|
+
// are encoded comes with them (npmKnown), and the rest is on its way when
|
|
2992
|
+
// the viewer asks. Nothing waits on it, and a failure is left for the
|
|
2993
|
+
// viewer's own request to meet. Its whole document, read for its
|
|
2994
|
+
// advisories' version list and the time each version was published
|
|
2995
|
+
// (npmPublishTime), is read only where registry reads leave room for
|
|
2996
|
+
// readers'.
|
|
2997
|
+
if (!download) {
|
|
2998
|
+
const quiet = (asked: Promise<unknown>) => { asked.catch(() => {}) }
|
|
2999
|
+
quiet(npmDownloads(name))
|
|
3000
|
+
quiet(latestDoc().then(newest => newest && githubFigures(newest)))
|
|
3001
|
+
quiet(readNpmVersions(name, privileged, controller.signal, { times: true, spare: true })
|
|
3002
|
+
.then(versions => versions && npmAdvisories(name, versions.versions, advisoryOptions)))
|
|
3003
|
+
quiet(tagsOf())
|
|
3004
|
+
quiet(socketOf())
|
|
3005
|
+
quiet(provenanceOf())
|
|
3006
|
+
}
|
|
3007
|
+
const load = download ? loadNpmTarball(doc) : loadNpmPackageBody(doc, () => npmKnown(name, doc.version, doc.private, repo, sha))
|
|
3008
|
+
res.once('close', load.release)
|
|
3009
|
+
const bytes = await load.result
|
|
3010
|
+
if (!(await recheck(doc.private))) return
|
|
3011
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3012
|
+
res.writeHead(200, {
|
|
3013
|
+
...download
|
|
3014
|
+
? { 'content-type': 'application/gzip', 'content-disposition': attachmentDisposition(npmTarballFilename(doc.name, doc.version)) }
|
|
3015
|
+
: { 'content-type': 'application/json', 'content-encoding': 'br' },
|
|
3016
|
+
'content-length': String(bytes.byteLength), 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
3017
|
+
})
|
|
3018
|
+
writeResponse(res, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength))
|
|
3019
|
+
} catch (err) {
|
|
3020
|
+
if (err instanceof NpmPackageError || err instanceof PrettyError) { if (!res.headersSent && !res.destroyed) sendJson(res, err.status, { error: err.message }); return }
|
|
3021
|
+
if (err instanceof NpmRiskError) { if (!res.headersSent && !res.destroyed) sendRiskError(res, err); return }
|
|
3022
|
+
if (controller.signal.aborted) return
|
|
3023
|
+
throw err
|
|
3024
|
+
} finally {
|
|
3025
|
+
res.off('close', onClose)
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
|
|
3030
|
+
if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
3031
|
+
const method = req.method ?? 'GET'
|
|
3032
|
+
const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
|
|
3033
|
+
if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
|
|
3034
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
3035
|
+
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3036
|
+
if (!requireManageRole(res, s.user)) return
|
|
3037
|
+
const { db } = deps
|
|
3038
|
+
if (method === 'GET') {
|
|
3039
|
+
const shares = await db.listWorkspaceShares(s.session.id, Date.now(), teamId)
|
|
3040
|
+
sendJson(res, shares ? 200 : 404, shares ? { shares } : { error: 'no-team' }); return
|
|
3041
|
+
}
|
|
3042
|
+
let body
|
|
3043
|
+
if (method === 'POST' || method === 'PATCH') {
|
|
3044
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
3045
|
+
}
|
|
3046
|
+
const permissions = parseTeamUserPermissions(body), token = method === 'POST' ? randomToken() : ''
|
|
3047
|
+
const shareId = id ?? hashToken(token)
|
|
3048
|
+
const ok = method === 'POST' ? await db.createWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
|
|
3049
|
+
: method === 'PATCH' ? await db.updateWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
|
|
3050
|
+
: await db.revokeWorkspaceShares(s.session.id, Date.now(), teamId, id)
|
|
3051
|
+
if (!ok) { sendJson(res, 404, { error: 'no-share' }); return }
|
|
3052
|
+
const team = await db.getTeam(teamId)
|
|
3053
|
+
if (!team) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
3054
|
+
const action = method === 'POST' ? 'created a public link' : method === 'PATCH' ? 'updated a public link' : id ? 'revoked a public link' : 'revoked public links'
|
|
3055
|
+
const access = method === 'DELETE' ? '' : ` (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`
|
|
3056
|
+
await activity(deps, s.user, 'access', `${action} for team ${team.name}${access}`)
|
|
3057
|
+
sendJson(res, 200, method === 'POST' ? { id: shareId, path: `/team/${team.slug}#public=${shareId.slice(0, 8)}.${token}` } : { ok: true })
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
3061
|
+
const { config, db, avatarStore, isShuttingDown, track } = deps
|
|
3062
|
+
const repositoryDiscovery = new RepositoryDiscovery(config)
|
|
3063
|
+
|
|
3064
|
+
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
3065
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
3066
|
+
const path = url.pathname
|
|
3067
|
+
const method = req.method ?? 'GET'
|
|
3068
|
+
const cookie = req.headers.cookie
|
|
3069
|
+
const viewing = viewToken(config, cookie) != null
|
|
3070
|
+
|
|
3071
|
+
if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
|
|
3072
|
+
await handlePublicWorkspace(req, res, deps, url); return
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// Public mode probe — lets a client detect the managed protocol up front.
|
|
3076
|
+
if (path === CONFIG_PATH) {
|
|
3077
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3078
|
+
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
3079
|
+
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
3080
|
+
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
3081
|
+
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
3082
|
+
} : {}) } })
|
|
3083
|
+
return
|
|
3084
|
+
}
|
|
3085
|
+
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
3086
|
+
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
3087
|
+
if (path === LOGIN_PATH) {
|
|
3088
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3089
|
+
const { location, setCookie } = buildLoginRedirect(config)
|
|
3090
|
+
res.writeHead(302, { location, 'set-cookie': setCookie, 'cache-control': 'no-store' })
|
|
3091
|
+
res.end()
|
|
3092
|
+
return
|
|
3093
|
+
}
|
|
3094
|
+
// OAuth: callback (the GitHub hook) → mint a session, land on the app.
|
|
3095
|
+
if (path === CALLBACK_PATH) {
|
|
3096
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3097
|
+
try {
|
|
3098
|
+
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3099
|
+
if (viewing) { sendViewOnly(res); return }
|
|
3100
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3101
|
+
if (!s) return
|
|
3102
|
+
const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
|
|
3103
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3104
|
+
res.end(); return
|
|
3105
|
+
}
|
|
3106
|
+
const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
|
|
3107
|
+
// A new sign-in never resumes an earlier view.
|
|
3108
|
+
const setCookies = viewing ? [...result.setCookies, clearViewCookie(config)] : result.setCookies
|
|
3109
|
+
res.writeHead(302, { location: result.location, 'set-cookie': setCookies, 'cache-control': 'no-store' })
|
|
3110
|
+
res.end()
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
|
|
3113
|
+
else throw err
|
|
3114
|
+
}
|
|
3115
|
+
return
|
|
3116
|
+
}
|
|
3117
|
+
if (path === ISSUE_LOGIN_PATH) {
|
|
3118
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3119
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3120
|
+
if (!s) return
|
|
3121
|
+
const result = issueLoginRedirect(config, s.session)
|
|
3122
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3123
|
+
res.end(); return
|
|
3124
|
+
}
|
|
3125
|
+
// Who am I? While an admin views as another user, that user, plus the
|
|
3126
|
+
// admin as `viewer`. A view that has ended (with the admin's session or
|
|
3127
|
+
// role, or the viewed account) is cleared, returning to the own session.
|
|
3128
|
+
if (path === SESSION_PATH) {
|
|
3129
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3130
|
+
const now = Date.now()
|
|
3131
|
+
let s = await readSession(config, db, cookie, now)
|
|
3132
|
+
const ended = s == null && viewing
|
|
3133
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3134
|
+
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3135
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3136
|
+
sendJson(res, 200, {
|
|
3137
|
+
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
3138
|
+
csrfToken: s.session.csrfToken,
|
|
3139
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3140
|
+
}, headers)
|
|
3141
|
+
return
|
|
3142
|
+
}
|
|
3143
|
+
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
3144
|
+
// Authentication is not workspace access. Keep bootstrap/session/logout
|
|
3145
|
+
// available so blocked accounts can see their role and sign out, but deny
|
|
3146
|
+
// every managed data route before reading bodies or looking up resources.
|
|
3147
|
+
const managedDataPath = underPrefix(path, MANAGED_DATA_PREFIXES)
|
|
3148
|
+
const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
|
|
3149
|
+
if (managedDataPath && !workspaceSession) return
|
|
3150
|
+
const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
|
|
3151
|
+
if (issueRoute) {
|
|
3152
|
+
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
3153
|
+
}
|
|
3154
|
+
const deduplication = /^\/api\/admin\/deduplication(?:\/([a-f\d-]{36}))?$/iu.exec(path)
|
|
3155
|
+
if (deduplication) { await handleDeduplication(req, res, deps, cookie, deduplication[1]); return }
|
|
3156
|
+
const repositoryAliases = /^\/api\/admin\/repositories\/aliases(?:\/([a-f\d-]{36}))?$/iu.exec(path)
|
|
3157
|
+
if (repositoryAliases) { await handleRepositoryAliases(req, res, deps, cookie, repositoryAliases[1]); return }
|
|
3158
|
+
if (path === '/api/admin/repositories/resolve') { await handleRepositorySuggestion(req, res, deps, cookie); return }
|
|
3159
|
+
if (path === '/api/admin/links') {
|
|
3160
|
+
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
3161
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3162
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
3163
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3164
|
+
if (!requireManageRole(res, s.user)) return
|
|
3165
|
+
const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
|
|
3166
|
+
sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
|
|
3167
|
+
}
|
|
3168
|
+
const shareRoute = /^\/api\/teams\/([^/]+)\/share(?:\/([A-Za-z0-9_-]{43}))?$/u.exec(path)
|
|
3169
|
+
if (shareRoute) {
|
|
3170
|
+
await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
|
|
3171
|
+
}
|
|
3172
|
+
if (path === '/api/admin/uploads/key') { await handleUploadKey(req, res, deps, cookie); return }
|
|
3173
|
+
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
3174
|
+
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
3175
|
+
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
3176
|
+
// switch never serves a stale avatar. Workspace access is required.
|
|
3177
|
+
if (path.startsWith(AVATAR_PREFIX)) {
|
|
3178
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3179
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
3180
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3181
|
+
await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
|
|
3182
|
+
return
|
|
3183
|
+
}
|
|
3184
|
+
// Admin: list users (admin-only).
|
|
3185
|
+
if (path === ADMIN_HISTORY_PATH) {
|
|
3186
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3187
|
+
await handleHistory(res, deps, cookie, url.searchParams); return
|
|
3188
|
+
}
|
|
3189
|
+
if (path === ADMIN_USERS_PATH) {
|
|
3190
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3191
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
3192
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3193
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
3194
|
+
sendJson(res, 200, { users: await db.listUsers() })
|
|
3195
|
+
return
|
|
3196
|
+
}
|
|
3197
|
+
if (path === ADMIN_SCAN_MODELS_PATH) {
|
|
3198
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3199
|
+
await handleListModels(res, deps, cookie); return
|
|
3200
|
+
}
|
|
3201
|
+
if (path === SET_ROLE_PATH) { await handleSetRole(req, res, deps, cookie); return }
|
|
3202
|
+
if (path === REPO_IMPACT_PATH) {
|
|
3203
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3204
|
+
await handleRepositoryImpact(req, res, deps, cookie); return
|
|
3205
|
+
}
|
|
3206
|
+
if (path === REMOVE_REPO_PATH) {
|
|
3207
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3208
|
+
await handleRemoveRepository(req, res, deps, cookie); return
|
|
3209
|
+
}
|
|
3210
|
+
if (path === REPO_RISK_PATH) {
|
|
3211
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3212
|
+
await handleRepositoryRisk(res, deps, cookie, url.searchParams); return
|
|
3213
|
+
}
|
|
3214
|
+
if (path === BROWSABLE_REPOS_PATH) {
|
|
3215
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3216
|
+
await handleBrowsableRepositories(res, deps, cookie); return
|
|
3217
|
+
}
|
|
3218
|
+
if (path === REPO_REFS_PATH || path === REPO_CONTENTS_PATH) {
|
|
3219
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3220
|
+
await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
|
|
3221
|
+
}
|
|
3222
|
+
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
3223
|
+
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
3224
|
+
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
3225
|
+
if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
|
|
3226
|
+
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
3227
|
+
// prefix. Method-dispatched here since each path carries two verbs.
|
|
3228
|
+
if (path === ADMIN_REPORTS_PATH) {
|
|
3229
|
+
if (method === 'GET') { await handleListReports(res, deps, workspaceSession!.session); return }
|
|
3230
|
+
if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
|
|
3231
|
+
send405(res, 'GET, POST'); return
|
|
3232
|
+
}
|
|
3233
|
+
// set-repo is an exact sub-path; check it before the per-id prefix (a report
|
|
3234
|
+
// id is a uuid, so it never collides with "set-repo").
|
|
3235
|
+
if (path === REPORT_SET_REPO_PATH) {
|
|
3236
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3237
|
+
await handleSetReportRepo(req, res, deps, cookie); return
|
|
3238
|
+
}
|
|
3239
|
+
if (path === REPORT_SET_VISIBLE_PATH) {
|
|
3240
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3241
|
+
await handleSetReportVisible(req, res, deps, cookie); return
|
|
3242
|
+
}
|
|
3243
|
+
if (path === '/api/admin/reports/finding-ids') {
|
|
3244
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3245
|
+
await handleImportFindingIds(res, deps, cookie, url.searchParams); return
|
|
3246
|
+
}
|
|
3247
|
+
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
3248
|
+
if (importTriage) {
|
|
3249
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3250
|
+
await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
3251
|
+
}
|
|
3252
|
+
const reportLocation = /^\/api\/admin\/reports\/([a-f\d-]{36})\/location$/iu.exec(path)
|
|
3253
|
+
if (reportLocation) { await handleReportLocationSuggestion(req, res, deps, cookie, reportLocation[1]!); return }
|
|
3254
|
+
if (path.startsWith(REPORT_PREFIX)) {
|
|
3255
|
+
const id = path.slice(REPORT_PREFIX.length)
|
|
3256
|
+
if (method === 'GET') { await handleGetReport(res, deps, cookie, id); return }
|
|
3257
|
+
if (method === 'DELETE') { await handleDeleteReport(req, res, deps, cookie, id); return }
|
|
3258
|
+
send405(res, 'GET, DELETE'); return
|
|
3259
|
+
}
|
|
3260
|
+
// Bundles: list / upload on the exact path, download / delete per-id on the
|
|
3261
|
+
// prefix (same shape as reports).
|
|
3262
|
+
const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
|
|
3263
|
+
if (bundleAdvisories) {
|
|
3264
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3265
|
+
await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
|
|
3266
|
+
return
|
|
3267
|
+
}
|
|
3268
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
3269
|
+
if (bundleRead) {
|
|
3270
|
+
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
3271
|
+
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3272
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3273
|
+
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
3274
|
+
// The pairs a scoped Compare diffs, of the files it compares within its scope.
|
|
3275
|
+
const [linesOf, scope] = part === 'pretty' ? [compared, ''] : [part === 'type-only' ? typeOnlyFiles : renamedPairs, url.searchParams.get('scope') ?? '']
|
|
3276
|
+
await handleBundleChanges(res, deps, session, id, url.searchParams.get('other') ?? '', async (cache, base, other) => linesOf(await cache.bundleSides(base, other, scope))); return
|
|
3277
|
+
}
|
|
3278
|
+
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
3279
|
+
if (part === 'download') { await handleGetBundle(req, res, deps, session, id); return }
|
|
3280
|
+
if (part !== 'pretty') { await handleBundleCache(req, res, deps, session, id, bundleCache && (rec => bundleCache.open(rec, part as BundleCachePart))); return }
|
|
3281
|
+
const file = prettyRequest(url.searchParams)
|
|
3282
|
+
if (!file) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
3283
|
+
await handleBundleCache(req, res, deps, session, id, prettyCache && (rec => prettyCache.bundle(rec, file)), 'text/plain; charset=utf-8', prettySource(file))
|
|
3284
|
+
return
|
|
3285
|
+
}
|
|
3286
|
+
if (path === BUNDLE_CREATE_PATH) {
|
|
3287
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3288
|
+
await handleCreateBundle(req, res, deps, cookie); return
|
|
3289
|
+
}
|
|
3290
|
+
if (path === ADMIN_BUNDLES_PATH) {
|
|
3291
|
+
if (method === 'GET') { await handleListBundles(res, deps, workspaceSession!.session); return }
|
|
3292
|
+
if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
|
|
3293
|
+
send405(res, 'GET, POST'); return
|
|
3294
|
+
}
|
|
3295
|
+
if (path === BUNDLE_SET_REPO_PATH) {
|
|
3296
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3297
|
+
await handleSetBundleRepo(req, res, deps, cookie); return
|
|
3298
|
+
}
|
|
3299
|
+
if (path === BUNDLE_SET_VISIBLE_PATH) {
|
|
3300
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3301
|
+
await handleSetBundleVisible(req, res, deps, cookie); return
|
|
3302
|
+
}
|
|
3303
|
+
if (path.startsWith(BUNDLE_PREFIX)) {
|
|
3304
|
+
const id = path.slice(BUNDLE_PREFIX.length)
|
|
3305
|
+
if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
|
|
3306
|
+
if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
|
|
3307
|
+
send405(res, 'GET, DELETE'); return
|
|
3308
|
+
}
|
|
3309
|
+
const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
|
|
3310
|
+
if (teamFeed || path === '/api/teams/feed') {
|
|
3311
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3312
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3313
|
+
if (!s) return
|
|
3314
|
+
const teamId = teamFeed?.[1] ?? null
|
|
3315
|
+
const reportId = url.searchParams.get('reportId')
|
|
3316
|
+
if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
|
|
3317
|
+
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
|
|
3318
|
+
}
|
|
3319
|
+
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
3320
|
+
if (teamFixes) {
|
|
3321
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3322
|
+
await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!, url.searchParams.get('reportId')); return
|
|
3323
|
+
}
|
|
3324
|
+
const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
|
|
3325
|
+
if (teamAnnotations) {
|
|
3326
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3327
|
+
const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!, url.searchParams.get('reportId'))
|
|
3328
|
+
const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
|
|
3329
|
+
await recheckTeam(db, workspaceSession!.session.id, snapshot)
|
|
3330
|
+
sendJson(res, 200, annotations); return
|
|
3331
|
+
}
|
|
3332
|
+
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
3333
|
+
if (teamReports) {
|
|
3334
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3335
|
+
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
3336
|
+
}
|
|
3337
|
+
if (path === REPORT_QUERY_PATH) {
|
|
3338
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3339
|
+
await handleQueryReports(req, res, deps, cookie); return
|
|
3340
|
+
}
|
|
3341
|
+
const sourcesRoute = /^\/api\/reports\/([^/]+)\/sources$/u.exec(path)
|
|
3342
|
+
if (sourcesRoute) {
|
|
3343
|
+
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
3344
|
+
await handleReportSources(req, res, deps, cookie, sourcesRoute[1]!, url.searchParams.get('team')); return
|
|
3345
|
+
}
|
|
3346
|
+
const commentsRoute = /^\/api\/reports\/([^/]+)\/comments(?:\/([^/]+))?$/u.exec(path)
|
|
3347
|
+
if (commentsRoute) {
|
|
3348
|
+
await handleReportComments(req, res, deps, cookie, commentsRoute[1]!, commentsRoute[2] ?? null, url.searchParams.get('team')); return
|
|
3349
|
+
}
|
|
3350
|
+
// Per-finding triage on a viewable report. The '/triage' suffixes are
|
|
3351
|
+
// matched before the bare per-id slice below (a report id is a uuid, so it
|
|
3352
|
+
// never ends in them) — same trick as REPORT_SET_REPO_PATH above; the
|
|
3353
|
+
// longer '/triage/history' goes first.
|
|
3354
|
+
if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_HISTORY_SUFFIX)) {
|
|
3355
|
+
const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_HISTORY_SUFFIX.length)
|
|
3356
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3357
|
+
await handleGetReportTriageHistory(res, deps, cookie, id, url.searchParams, url.searchParams.get('team')); return
|
|
3358
|
+
}
|
|
3359
|
+
if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_SUFFIX)) {
|
|
3360
|
+
const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_SUFFIX.length)
|
|
3361
|
+
if (method === 'GET') { await handleGetReportTriage(res, deps, cookie, id, url.searchParams.get('team')); return }
|
|
3362
|
+
if (method === 'POST') { await handleSetReportTriage(req, res, deps, cookie, id, url.searchParams.get('team')); return }
|
|
3363
|
+
send405(res, 'GET, POST'); return
|
|
3364
|
+
}
|
|
3365
|
+
// Individual report previews are reserved for admins/managers.
|
|
3366
|
+
if (path.startsWith(MY_REPORT_PREFIX)) {
|
|
3367
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3368
|
+
await handleViewReport(req, res, deps, cookie, path.slice(MY_REPORT_PREFIX.length)); return
|
|
3369
|
+
}
|
|
3370
|
+
// The signed-in user's own team memberships (any approved user).
|
|
3371
|
+
if (path === MY_TEAMS_PATH) {
|
|
3372
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3373
|
+
await handleMyTeams(res, deps, workspaceSession!.session); return
|
|
3374
|
+
}
|
|
3375
|
+
// Teams: list / create on the exact path; the link mutations are POST-only
|
|
3376
|
+
// action sub-paths (each carries its ids in the JSON body).
|
|
3377
|
+
if (path === ADMIN_TEAMS_PATH) {
|
|
3378
|
+
if (method === 'GET') { await handleListTeams(res, deps, cookie); return }
|
|
3379
|
+
if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
|
|
3380
|
+
send405(res, 'GET, POST'); return
|
|
3381
|
+
}
|
|
3382
|
+
if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_HIDDEN_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
|
|
3383
|
+
|| path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
|
|
3384
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3385
|
+
if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
|
|
3386
|
+
if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
|
|
3387
|
+
if (path === TEAM_SET_HIDDEN_PATH) { await handleSetTeamHidden(req, res, deps, cookie); return }
|
|
3388
|
+
if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
|
|
3389
|
+
if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
|
|
3390
|
+
if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
|
|
3391
|
+
await handleRemoveTeamMember(req, res, deps, cookie); return
|
|
3392
|
+
}
|
|
3393
|
+
if (path === '/api/admin/teams/set-dependency-review') {
|
|
3394
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3395
|
+
await handleSetTeamDependencyReview(req, res, deps, cookie); return
|
|
3396
|
+
}
|
|
3397
|
+
if (path === TEAM_SET_NPM_SCOPES_PATH) {
|
|
3398
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3399
|
+
await handleSetTeamNpmScopes(req, res, deps, cookie); return
|
|
3400
|
+
}
|
|
3401
|
+
if (path === '/api/npm/reviews/latest') {
|
|
3402
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3403
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3404
|
+
if (!s) return
|
|
3405
|
+
const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
|
|
3406
|
+
if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
|
|
3407
|
+
sendJson(res, 400, { error: 'bad-review-context' }); return
|
|
3408
|
+
}
|
|
3409
|
+
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3410
|
+
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3411
|
+
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
3412
|
+
return
|
|
3413
|
+
}
|
|
3414
|
+
if (path === '/api/npm/reviews/discard') {
|
|
3415
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
3416
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
3417
|
+
if (!s) return
|
|
3418
|
+
let body: Record<string, unknown>
|
|
3419
|
+
try {
|
|
3420
|
+
const raw = await readJsonBody(req, 4096)
|
|
3421
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('bad-body')
|
|
3422
|
+
body = raw as Record<string, unknown>
|
|
3423
|
+
} catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
3424
|
+
const id = body['reviewId'], revision = body['revision']
|
|
3425
|
+
if (typeof id !== 'string' || !id || id.length > 100 || !Number.isSafeInteger(revision) || Number(revision) < 1) {
|
|
3426
|
+
sendJson(res, 400, { error: 'bad-review' }); return
|
|
3427
|
+
}
|
|
3428
|
+
await deps.db.discardDependencyReview(s.session.id, id, revision as number)
|
|
3429
|
+
sendJson(res, 200, { ok: true }); return
|
|
3430
|
+
}
|
|
3431
|
+
if (path === '/api/npm/reviews/mine') {
|
|
3432
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3433
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
3434
|
+
if (!s) return
|
|
3435
|
+
const id = url.searchParams.get('beforeId'), time = url.searchParams.get('beforeTime')
|
|
3436
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
3437
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
3438
|
+
}
|
|
3439
|
+
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3440
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3441
|
+
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
3442
|
+
return
|
|
3443
|
+
}
|
|
3444
|
+
if (path === '/api/npm/reviews') {
|
|
3445
|
+
await handleNpmReviews(req, res, deps, cookie, url.searchParams); return
|
|
3446
|
+
}
|
|
3447
|
+
if (NPM_PATHS.has(path)) {
|
|
3448
|
+
await handleNpm(req, res, deps, cookie, path, url.searchParams); return
|
|
3449
|
+
}
|
|
3450
|
+
if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
|
|
3451
|
+
if (deps.serveStatic?.(req, res)) return
|
|
3452
|
+
if (deps.next) { deps.next(req, res); return }
|
|
3453
|
+
sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
return (req, res) => {
|
|
3457
|
+
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
3458
|
+
if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
|
|
3459
|
+
// Feeds lease a connection per poll, never across streaming sleeps. This
|
|
3460
|
+
// also covers capability-authenticated feeds handled by public-workspace.
|
|
3461
|
+
const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
|
|
3462
|
+
const serve = () => db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)
|
|
3463
|
+
const work = runViewing(viewToken(config, req.headers.cookie) != null, serve).catch((err) => {
|
|
3464
|
+
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
3465
|
+
console.warn('managed: request handler error:', err)
|
|
3466
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
3467
|
+
else sendJson(res, 500, { error: 'internal' })
|
|
3468
|
+
})
|
|
3469
|
+
track(work)
|
|
3470
|
+
return work
|
|
3471
|
+
}
|
|
3472
|
+
}
|