@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45
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/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +31 -31
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +55 -6
- package/risk/actions/findings.js +156 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +165 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
package/server-managed/http.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
// server doesn't speak the e2e sync protocol yet). Every handler is async; the
|
|
4
4
|
// boot's `track()` keeps in-flight requests drainable on shutdown.
|
|
5
5
|
//
|
|
6
|
-
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public
|
|
6
|
+
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
|
|
7
|
+
// `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
|
|
7
8
|
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
8
9
|
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
9
10
|
// GET /api/auth/session → { user, csrfToken } | 401
|
|
11
|
+
// GET /api/auth/profile → { profile: { login, name, githubName, nameAccount, avatarAccount, accounts } }, the
|
|
12
|
+
// GitHub accounts linked to the user's and which of them the displayed name and avatar come from | 401/403
|
|
13
|
+
// POST /api/auth/profile → CSRF, chooses those ({ name?, avatar? }: a linked account's id, null the sign-in one) | 400/401/403/404
|
|
14
|
+
// DELETE /api/auth/profile/accounts/<id> → CSRF, unlinks a linked GitHub account | 401/403/404
|
|
15
|
+
// GET /api/oauth/github/link → 302 to GitHub to link another account (+ state cookie); the callback lands on /my/profile
|
|
10
16
|
// GET /api/teams → the current user's teams + their reports and bundles | 401
|
|
11
17
|
// GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
|
|
12
18
|
// GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
|
|
@@ -16,14 +22,48 @@
|
|
|
16
22
|
// GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
|
|
17
23
|
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
18
24
|
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
19
|
-
// GET /api/avatar/<id>
|
|
25
|
+
// GET /api/avatar/<id>[?own] → cached avatar bytes by user id, of the account chosen on their profile (`own`: the
|
|
26
|
+
// sign-in account's), or by linked account id; `app-<id>` or `user-<id>`, an app agent's GitHub App's or account's (APP_AGENTS) | 401/404
|
|
20
27
|
// GET /api/admin/users → admin-only user list | 401/403
|
|
21
28
|
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
22
29
|
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
23
30
|
// GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
|
|
24
31
|
// POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
|
|
32
|
+
// POST /api/admin/repositories/dependency-review → admin sets a repo's dependency review: enabled, the reviews each
|
|
33
|
+
// version needs, and the commit its lockfiles' versions need none since | 400/401/403/404
|
|
25
34
|
// GET /api/admin/repositories/impact → admin attached data summary
|
|
26
|
-
// GET /api/admin/repositories/{browsable,refs,contents} →
|
|
35
|
+
// GET /api/admin/repositories/{browsable,refs,contents} → view or above, within the reader's team grants, + GitHub read access
|
|
36
|
+
// (browsable: the names alone; these and the reads below are the code viewer's, open to every team member)
|
|
37
|
+
// GET /api/admin/repositories/code?repoId=&ref=&path= → the files of a directory at a revision, within the reader's grants,
|
|
38
|
+
// Brotli-encoded | 400/401/403/404/409/413/429/502
|
|
39
|
+
// GET /api/admin/repositories/history?repoId=&ref=&path=&page=[&all=true[&from=]] → the commits a revision reaches, a page at a time,
|
|
40
|
+
// or across the most recently updated branches, with the branches and tags naming them, then on from the commits `from`
|
|
41
|
+
// names | 400/401/403/404/409/429/502
|
|
42
|
+
// GET /api/admin/repositories/changes?repoId=&path=&shas= → the files each of the commits `shas` names (fifty at most)
|
|
43
|
+
// adds, modifies, removes and renames within `path`, as `git log --name-status` lists them | 400/401/403/404/409/429/502
|
|
44
|
+
// GET /api/admin/repositories/tree?repoId=&path=&commit= → the files commit `commit` holds within `path`, as many as
|
|
45
|
+
// 75,000, for Replay to start from | 400/401/403/404/409/429/502
|
|
46
|
+
// GET /api/admin/repositories/issues?repoId=&kind=issues|pulls&state=open|closed[&after=] → a page of the repository's issues
|
|
47
|
+
// or pull requests, the most recently updated first, with how many are open and closed | 400/401/403/404/409/429/502
|
|
48
|
+
// GET /api/admin/repositories/agents?repoId=[&pulls=&commits=] → a page of what agents left: pull requests and commits to
|
|
49
|
+
// the default branch no pull request merged, with issues, agents' branches and the app agents first | 400/401/403/404/409/429/502
|
|
50
|
+
// GET /api/admin/repositories/item?repoId=&number= → an issue or pull request, as its popup shows it, with its
|
|
51
|
+
// description's Markdown | 400/401/403/404/409/429/502
|
|
52
|
+
// GET /api/admin/repositories/pull-dependencies?repoId=&number= → the package versions a pull request's npm, pnpm and
|
|
53
|
+
// Yarn 1 lockfiles lock that they didn't at its merge base, an update's with those it was, by directory; with review=1, each's
|
|
54
|
+
// review, where its repository reviews dependencies | 400/401/403/404/409/429/502
|
|
55
|
+
// GET /api/admin/repositories/branch-dependencies?repoId= → the versions the default branch's lockfiles lock that want review, where
|
|
56
|
+
// its review is in effect for the reader | 400/401/403/404/409/429/502
|
|
57
|
+
// GET /api/admin/repositories/lockfiles?repoId= → the lockfiles of the default branch its dependency review reads, by their
|
|
58
|
+
// paths, for its settings to name | 400/401/403/404/409/429/502
|
|
59
|
+
// GET /api/admin/repositories/pulse?repoId=&weeks=1|4|13|52 → the default branch's commits and lines a week and a day over
|
|
60
|
+
// the last year, its contributors and when in the period they commit, counted from its commits as Commits keeps them (how many are
|
|
61
|
+
// read while they are), and the pull requests
|
|
62
|
+
// merged and opened and the issues closed and opened in the last `weeks` weeks and the same days a period earlier | 400/401/403/404/409/429/502
|
|
63
|
+
// GET /api/admin/repositories/pulse/items?repoId=&weeks=&from=[&to=&authors=] → those pull requests and issues of the days
|
|
64
|
+
// `from` to `to` (YYYY-MM-DD, UTC; no `to`, on), and of any of `authors` (GitHub logins, comma-separated) | 400/401/403/404/409/429/502
|
|
65
|
+
// History, issues, item, agents and pulse each carry `users`: the app's users and agents the GitHub accounts they
|
|
66
|
+
// name are (github-users.ts), by login
|
|
27
67
|
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
28
68
|
// GET /api/admin/repositories/risk[?team=|?repo=] → admin: each connected repo's npm lockfiles, or a team's or one repo's,
|
|
29
69
|
// their packages and what risk tells of each, Brotli-encoded ndjson as each is found; the reader leaving stops it | 400/401/403/404
|
|
@@ -63,6 +103,8 @@
|
|
|
63
103
|
// GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
|
|
64
104
|
// POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
|
|
65
105
|
// GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
|
|
106
|
+
// GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
|
|
107
|
+
// reads them there | 400/401/404
|
|
66
108
|
// GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
|
|
67
109
|
// 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
110
|
// GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
|
|
@@ -81,6 +123,9 @@
|
|
|
81
123
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
82
124
|
// GET /api/npm/type-only?name=&version=&otherVersion= → the files that changed between two versions in types alone,
|
|
83
125
|
// Brotli-encoded ndjson | 400/401/404/413/429/502/503
|
|
126
|
+
// GET /api/npm/delta?name=&version=&otherVersion= → the lines a version adds and removes against another, or `(empty)`, and
|
|
127
|
+
// those but for declaration files changed in types alone, where any are, and whether binary files changed
|
|
128
|
+
// | 400/401/404/413/422/429/502/503/504
|
|
84
129
|
// 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
130
|
// POST /api/auth/logout → same-origin + CSRF, drops the session (and any view)
|
|
86
131
|
// POST /api/auth/view-as → admin opens a read-only view as another user | 400/401/403/404
|
|
@@ -89,7 +134,9 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
89
134
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
90
135
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
91
136
|
import { pipeline } from 'node:stream/promises'
|
|
92
|
-
import {
|
|
137
|
+
import { REPOSITORY_READ_KEPT_MS, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore, npmDeltaStore, npmVersionStore, pullDependencyStore,
|
|
138
|
+
repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
139
|
+
import { type NpmDelta, npmDelta } from './npm-delta.ts'
|
|
93
140
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
94
141
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
95
142
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -114,13 +161,14 @@ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
|
114
161
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
115
162
|
import { filterReportData } from '../common/managed/report-filter.ts'
|
|
116
163
|
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
164
|
+
import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
|
|
117
165
|
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
118
166
|
import { reportRepoGithub } from '@preventive/report'
|
|
119
167
|
import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
|
|
120
168
|
import type { ReportSourcesCache } from './report-sources.ts'
|
|
121
169
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
122
170
|
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'
|
|
171
|
+
import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
|
|
124
172
|
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
125
173
|
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
|
|
126
174
|
import { upstreamGithub } from './github-errors.ts'
|
|
@@ -129,8 +177,10 @@ import type { ConnectedRepo } from './github-app.ts'
|
|
|
129
177
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
130
178
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
131
179
|
import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
|
|
132
|
-
import {
|
|
133
|
-
import {
|
|
180
|
+
import { WORKFLOWS_DIR } from './repository-actions.ts'
|
|
181
|
+
import { githubActionsSource } from './github-actions.ts'
|
|
182
|
+
import { type RepositoryEntry, createRepositoryBrowser, repositoryTreeFiles, scopedDirectory } from './repository-browser.ts'
|
|
183
|
+
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, cacheAvatar, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
134
184
|
import { clearCookie, endSession, readSession } from './session.ts'
|
|
135
185
|
import { VIEW_AS_PATH, VIEW_ONLY_ERROR, clearViewCookie, endViewSession, runViewing, startViewSession, viewToken } from './view-as.ts'
|
|
136
186
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
@@ -142,17 +192,40 @@ import { lookupIssueLinks } from './github-issue-links.ts'
|
|
|
142
192
|
import { visibleManagedIssues } from './managed-issues.ts'
|
|
143
193
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
144
194
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
195
|
+
import { LINK_LOGIN_PATH, isLinkOAuthCallback, linkLoginRedirect, linkOAuthCallback } from './github-link-oauth.ts'
|
|
196
|
+
import type { ProfileChoice } from './github-links.ts'
|
|
197
|
+
import { withAppUsers } from './github-users.ts'
|
|
198
|
+
import { isBlobId } from './object-storage.ts'
|
|
145
199
|
import { attachmentDisposition, sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
146
200
|
import { triageWireEntry } from './triage-response.ts'
|
|
147
201
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
148
202
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
149
|
-
import { teamAppKey, teamAppStates } from './team-app.ts'
|
|
203
|
+
import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
|
|
150
204
|
import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
151
205
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
152
206
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
153
207
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
154
208
|
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
209
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
210
|
+
import { loadRepositoryCodeBody, repositoryCodeFits } from './repository-code.ts'
|
|
211
|
+
import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
|
|
212
|
+
import { COAUTHORS_READ } from './commit-coauthors.ts'
|
|
213
|
+
import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, type SquashedPull, type Squashes, historyFrom, historyStarts, openPullHeads,
|
|
214
|
+
repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips, squashedPulls } from './repository-history.ts'
|
|
215
|
+
import { AGENTS_READ_VERSION, APP_AGENTS, COMMITS_CURSOR, repositoryAgents } from './repository-agents.ts'
|
|
216
|
+
import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
|
|
217
|
+
import { MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullDependencies, type PullDependencyStore, eachOf, lockfileFormat, pullDependencies } from './pull-dependencies.ts'
|
|
218
|
+
import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
|
|
219
|
+
import { MAX_REQUIRED_REVIEWS, SINCE_COMMIT } from './repository-dependency-reviews.ts'
|
|
220
|
+
import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
|
|
221
|
+
import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
|
|
222
|
+
import { formattedPull, withFormatted } from './dependency-review-comments.ts'
|
|
223
|
+
import type { ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
224
|
+
import { commentReviews } from './pull-comment-reviews.ts'
|
|
225
|
+
import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
|
|
226
|
+
repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
|
|
227
|
+
import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
|
|
228
|
+
import { releaseCommits } from './release-commits.ts'
|
|
156
229
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
157
230
|
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
158
231
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
@@ -163,9 +236,11 @@ import { NpmRiskError } from '../risk/index.js'
|
|
|
163
236
|
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
237
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
165
238
|
import { typeOnlyFiles } from './type-only.ts'
|
|
239
|
+
import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
|
|
166
240
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
167
241
|
|
|
168
242
|
const SESSION_PATH = '/api/auth/session'
|
|
243
|
+
const PROFILE_PATH = '/api/auth/profile'
|
|
169
244
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
170
245
|
const LOGOUT_PATH = '/api/auth/logout'
|
|
171
246
|
const ADMIN_USERS_PATH = '/api/admin/users'
|
|
@@ -176,7 +251,20 @@ const ADMIN_REPOS_PATH = '/api/admin/repositories'
|
|
|
176
251
|
const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
|
|
177
252
|
const REPO_REFS_PATH = '/api/admin/repositories/refs'
|
|
178
253
|
const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
|
|
254
|
+
const REPO_CODE_PATH = '/api/admin/repositories/code'
|
|
255
|
+
const REPO_HISTORY_PATH = '/api/admin/repositories/history'
|
|
256
|
+
const REPO_CHANGES_PATH = '/api/admin/repositories/changes'
|
|
257
|
+
const REPO_TREE_PATH = '/api/admin/repositories/tree'
|
|
258
|
+
const REPO_ISSUES_PATH = '/api/admin/repositories/issues'
|
|
259
|
+
const REPO_AGENTS_PATH = '/api/admin/repositories/agents'
|
|
260
|
+
const REPO_ITEM_PATH = '/api/admin/repositories/item'
|
|
261
|
+
const REPO_PULL_DEPENDENCIES_PATH = '/api/admin/repositories/pull-dependencies'
|
|
262
|
+
const REPO_BRANCH_DEPENDENCIES_PATH = '/api/admin/repositories/branch-dependencies'
|
|
263
|
+
const REPO_LOCKFILES_PATH = '/api/admin/repositories/lockfiles'
|
|
264
|
+
const REPO_PULSE_PATH = '/api/admin/repositories/pulse'
|
|
265
|
+
const REPO_PULSE_ITEMS_PATH = '/api/admin/repositories/pulse/items'
|
|
179
266
|
const SELECT_REPO_PATH = '/api/admin/repositories/select'
|
|
267
|
+
const REPO_DEPENDENCY_REVIEW_PATH = '/api/admin/repositories/dependency-review'
|
|
180
268
|
const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
181
269
|
const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
|
|
182
270
|
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
@@ -216,7 +304,9 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
|
|
|
216
304
|
const NPM_PRETTY_PATH = '/api/npm/pretty'
|
|
217
305
|
const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
218
306
|
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
307
|
+
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
219
308
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
309
|
+
const NPM_DELTA_PATH = '/api/npm/delta'
|
|
220
310
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
221
311
|
// having no such package or version, or a tarball unpacking past risk's
|
|
222
312
|
// bounds; npm failing otherwise a 502.
|
|
@@ -229,7 +319,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
|
229
319
|
sendJson(res, status, { error: err.code })
|
|
230
320
|
}
|
|
231
321
|
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])
|
|
322
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH, NPM_DELTA_PATH])
|
|
233
323
|
// How long an npm response may go unread before its connection is dropped.
|
|
234
324
|
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
235
325
|
const MAX_TEAM_NAME = 100
|
|
@@ -350,6 +440,9 @@ export interface ManagedHttpDeps {
|
|
|
350
440
|
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
351
441
|
next?: Handler
|
|
352
442
|
serverInfo?: ServerInfo
|
|
443
|
+
// The reviewed dependencies the host registered, as the server was
|
|
444
|
+
// assembled (reviewed-dependencies.ts).
|
|
445
|
+
reviewedDependencies?: ReviewedDependencies
|
|
353
446
|
}
|
|
354
447
|
|
|
355
448
|
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
@@ -364,8 +457,9 @@ function firstHeader(v: string | string[] | undefined): string | null {
|
|
|
364
457
|
return null
|
|
365
458
|
}
|
|
366
459
|
|
|
367
|
-
|
|
368
|
-
|
|
460
|
+
// `key` is the image chosen for `id`; one not cached falls back to `id`'s own.
|
|
461
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, id: string, key = id): Promise<void> {
|
|
462
|
+
const avatar = await avatarStore.get(key) ?? (key === id ? null : await avatarStore.get(id))
|
|
369
463
|
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
370
464
|
res.writeHead(200, {
|
|
371
465
|
'content-type': avatar.contentType,
|
|
@@ -376,6 +470,26 @@ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId
|
|
|
376
470
|
res.end(avatar.bytes)
|
|
377
471
|
}
|
|
378
472
|
|
|
473
|
+
// The avatars `/api/avatar/app-<id>` and `/api/avatar/user-<id>` serve, these
|
|
474
|
+
// alone: the agents' known by their GitHub Apps or accounts (APP_AGENTS),
|
|
475
|
+
// each its mark on the Agents tab, and those the app draws agents' accounts
|
|
476
|
+
// by (GITHUB_AGENTS). Each is fetched from GitHub once and
|
|
477
|
+
// kept, under a key no user's, a v4 UUID, can be: an app's, `…-0000-<id>`,
|
|
478
|
+
// an account's, `…-0001-<id>`.
|
|
479
|
+
const AGENT_AVATARS: ReadonlySet<string> = new Set([...APP_AGENTS, ...Object.values(GITHUB_AGENTS)]
|
|
480
|
+
.flatMap(agent => 'avatar' in agent && agent.avatar ? [agent.avatar] : []))
|
|
481
|
+
const AGENT_AVATAR = /^(app|user)-([1-9]\d{0,11})$/u
|
|
482
|
+
|
|
483
|
+
async function serveAgentAvatar(res: ServerResponse, avatarStore: AvatarStore, avatar: string): Promise<void> {
|
|
484
|
+
const [, kind, id] = AGENT_AVATAR.exec(avatar)!
|
|
485
|
+
const key = `00000000-0000-0000-000${kind === 'app' ? 0 : 1}-${id!.padStart(12, '0')}`
|
|
486
|
+
if (await avatarStore.get(key) == null) {
|
|
487
|
+
await cacheAvatar(avatarStore, key, `https://avatars.githubusercontent.com/${kind === 'app' ? 'in' : 'u'}/${id}`, globalThis.fetch)
|
|
488
|
+
.catch((err) => { console.warn('managed: agent avatar caching failed:', err) })
|
|
489
|
+
}
|
|
490
|
+
await serveAvatar(res, avatarStore, key)
|
|
491
|
+
}
|
|
492
|
+
|
|
379
493
|
const MAX_JSON_BODY_BYTES = 4096
|
|
380
494
|
|
|
381
495
|
// Buffer the request body, aborting (and throwing 'too-large') once it exceeds
|
|
@@ -478,9 +592,10 @@ function sendViewOnly(res: ServerResponse): void {
|
|
|
478
592
|
// Reads stay available while an admin views as another user. Everything else
|
|
479
593
|
// is refused before dispatch, except ending the view or signing out, and the
|
|
480
594
|
// batch report preview, a POST without effects. Issue authorization would
|
|
481
|
-
// grant GitHub access on the viewed user's behalf
|
|
595
|
+
// grant GitHub access on the viewed user's behalf, and linking an account
|
|
596
|
+
// would link it to the viewed user.
|
|
482
597
|
function viewAllows(method: string, path: string): boolean {
|
|
483
|
-
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH
|
|
598
|
+
if (method === 'GET' || method === 'HEAD') return path !== ISSUE_LOGIN_PATH && path !== LINK_LOGIN_PATH
|
|
484
599
|
return (method === 'DELETE' && path === VIEW_AS_PATH) || (method === 'POST' && (path === LOGOUT_PATH || path === REPORT_QUERY_PATH))
|
|
485
600
|
}
|
|
486
601
|
|
|
@@ -525,6 +640,65 @@ async function handleViewAs(req: IncomingMessage, res: ServerResponse, deps: Man
|
|
|
525
640
|
res.end()
|
|
526
641
|
}
|
|
527
642
|
|
|
643
|
+
// GET /api/auth/profile — the user's profile (github-links.ts). POST chooses
|
|
644
|
+
// the accounts their displayed name and avatar come from. Workspace access is
|
|
645
|
+
// required: the accounts a user links are reserved for them.
|
|
646
|
+
async function handleProfile(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
647
|
+
const method = req.method ?? 'GET'
|
|
648
|
+
if (method === 'GET') {
|
|
649
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
650
|
+
if (s) await sendProfile(res, deps, s.user.id)
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
if (method !== 'POST') { send405(res, 'GET, POST'); return }
|
|
654
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
655
|
+
if (s == null) return
|
|
656
|
+
let body: unknown
|
|
657
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
658
|
+
const choice = parseProfileChoice(body)
|
|
659
|
+
if (choice == null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
660
|
+
await deps.db.setProfileChoice(s.session.id, choice, Date.now())
|
|
661
|
+
await sendProfile(res, deps, s.user.id)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// DELETE /api/auth/profile/accounts/<id> — unlink one of the user's linked
|
|
665
|
+
// GitHub accounts, which may then sign in or register again.
|
|
666
|
+
async function handleUnlinkAccount(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
667
|
+
if ((req.method ?? 'GET') !== 'DELETE') { send405(res, 'DELETE'); return }
|
|
668
|
+
const s = await profileMutation(req, res, deps, cookie)
|
|
669
|
+
if (s == null) return
|
|
670
|
+
const link = await deps.db.unlinkGithubAccount(s.session.id, id, Date.now())
|
|
671
|
+
if (link == null) { sendJson(res, 404, { error: 'no-account' }); return }
|
|
672
|
+
await deps.avatarStore.delete(link.id).catch((err) => { console.warn('managed: linked avatar removal failed:', err) })
|
|
673
|
+
await activity(deps, s.user, 'access', `unlinked GitHub account @${link.login}`)
|
|
674
|
+
await sendProfile(res, deps, s.user.id)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function profileMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
678
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
679
|
+
if (s != null && !roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
680
|
+
return s
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function sendProfile(res: ServerResponse, deps: ManagedHttpDeps, userId: string): Promise<void> {
|
|
684
|
+
const profile = await deps.db.getProfile(userId)
|
|
685
|
+
if (profile == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
686
|
+
sendJson(res, 200, { profile })
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// { name?, avatar? }, each a linked account's id or null; at least one.
|
|
690
|
+
function parseProfileChoice(body: unknown): ProfileChoice | null {
|
|
691
|
+
if (body == null || typeof body !== 'object' || Array.isArray(body)) return null
|
|
692
|
+
const choice: ProfileChoice = {}
|
|
693
|
+
for (const key of ['name', 'avatar'] as const) {
|
|
694
|
+
if (!Object.hasOwn(body, key)) continue
|
|
695
|
+
const value = (body as Record<string, unknown>)[key]
|
|
696
|
+
if (value !== null && (typeof value !== 'string' || !isBlobId(value))) return null
|
|
697
|
+
choice[key] = value
|
|
698
|
+
}
|
|
699
|
+
return Object.keys(choice).length > 0 ? choice : null
|
|
700
|
+
}
|
|
701
|
+
|
|
528
702
|
// POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
|
|
529
703
|
// refused for the caller's own id so an admin can't drop their own admin (keeps
|
|
530
704
|
// at least one admin).
|
|
@@ -581,6 +755,27 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
581
755
|
return s
|
|
582
756
|
}
|
|
583
757
|
|
|
758
|
+
// Who am I? While an admin views as another user, that user, plus the admin
|
|
759
|
+
// as `viewer`. A view that has ended (with the admin's session or role, or the
|
|
760
|
+
// viewed account) is cleared, returning to the own session. Null signed out.
|
|
761
|
+
// `dependencyReview` where they publish dependency reviews to some team.
|
|
762
|
+
async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
|
|
763
|
+
const now = Date.now()
|
|
764
|
+
let s = await readSession(config, db, cookie, now)
|
|
765
|
+
const ended = s == null && viewToken(config, cookie) != null
|
|
766
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
767
|
+
const reviews = s != null && (await db.listDependencyReviewTeams(s.user.id)).length > 0
|
|
768
|
+
return {
|
|
769
|
+
body: s && {
|
|
770
|
+
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
771
|
+
csrfToken: s.session.csrfToken,
|
|
772
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
773
|
+
...(reviews ? { dependencyReview: true as const } : {}),
|
|
774
|
+
},
|
|
775
|
+
headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
584
779
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
585
780
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
586
781
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -623,9 +818,13 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
623
818
|
let tokenMissing = false
|
|
624
819
|
let repositories
|
|
625
820
|
if (scope === 'connected') {
|
|
821
|
+
const reviews = await deps.db.listRepoDependencyReviews()
|
|
822
|
+
const reviewTeams = await deps.db.listRepoReviewTeams()
|
|
626
823
|
repositories = allRows.map((r) => ({
|
|
627
824
|
id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
|
|
628
825
|
installed: r.installationId != null, selected: r.active, active: r.active,
|
|
826
|
+
dependencyReview: reviews.get(r.repoId) ?? { enabled: false, required: 1, since: null, lockfiles: EVERY_LOCKFILE },
|
|
827
|
+
reviewTeams: reviewTeams.get(r.repoId) ?? [],
|
|
629
828
|
}))
|
|
630
829
|
} else {
|
|
631
830
|
const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
|
|
@@ -655,6 +854,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
655
854
|
inactiveCount: allRows.filter((r) => !r.active).length,
|
|
656
855
|
total,
|
|
657
856
|
tokenMissing,
|
|
857
|
+
...deps.reviewedDependencies ? { reviewedDependencies: true } : {},
|
|
658
858
|
})
|
|
659
859
|
}
|
|
660
860
|
|
|
@@ -789,6 +989,39 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
789
989
|
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
790
990
|
}
|
|
791
991
|
|
|
992
|
+
// POST /api/admin/repositories/dependency-review — a repository's dependency
|
|
993
|
+
// review (repository-dependency-reviews.ts): `enabled`, the published reviews
|
|
994
|
+
// each version its pull requests' lockfiles lock needs (`required`, 1 to
|
|
995
|
+
// MAX_REQUIRED_REVIEWS), and the commit whose lockfiles' versions need none
|
|
996
|
+
// (`since`, a full commit id, or null; where the host registered reviewed
|
|
997
|
+
// dependencies, which replace it, none, `400 since-unsupported`, the one
|
|
998
|
+
// stored kept as it is). Answers the setting then. Admin, CSRF.
|
|
999
|
+
async function handleRepositoryDependencyReview(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1000
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
1001
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1002
|
+
if (s == null) return
|
|
1003
|
+
let body: Record<string, unknown> | null
|
|
1004
|
+
try { body = await readJsonBody(req) as Record<string, unknown> | null } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1005
|
+
const repoId = body?.['repoId']
|
|
1006
|
+
const enabled = body?.['enabled']
|
|
1007
|
+
const required = body?.['required']
|
|
1008
|
+
const since = body?.['since'] ?? null
|
|
1009
|
+
// The lockfiles it holds to, kept as they are where none is sent.
|
|
1010
|
+
const lockfiles = body?.['lockfiles'] === undefined ? undefined : scopeOf(body['lockfiles'])
|
|
1011
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof enabled !== 'boolean'
|
|
1012
|
+
|| typeof required !== 'number' || !Number.isSafeInteger(required) || required < 1 || required > MAX_REQUIRED_REVIEWS
|
|
1013
|
+
|| (since !== null && (typeof since !== 'string' || !SINCE_COMMIT.test(since))) || lockfiles === null) {
|
|
1014
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1015
|
+
}
|
|
1016
|
+
if (since !== null && deps.reviewedDependencies) { sendJson(res, 400, { error: 'since-unsupported' }); return }
|
|
1017
|
+
const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
|
|
1018
|
+
const setting = repo ? await deps.db.setRepoDependencyReview(repoId, { enabled, required, ...deps.reviewedDependencies ? {} : { since: since as string | null },
|
|
1019
|
+
...lockfiles ? { lockfiles } : {} }) : null
|
|
1020
|
+
if (!repo || !setting) { sendJson(res, 404, { error: 'no-repository' }); return }
|
|
1021
|
+
await activity(deps, s.user, 'repository', enabled ? 'enabled dependency review' : 'disabled dependency review', { repo: repo.fullName })
|
|
1022
|
+
sendJson(res, 200, { ok: true, dependencyReview: setting })
|
|
1023
|
+
}
|
|
1024
|
+
|
|
792
1025
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
793
1026
|
const ids = new Set<string>()
|
|
794
1027
|
for (const report of reports) {
|
|
@@ -1228,8 +1461,10 @@ async function backfillCatalogCommits(deps: ManagedHttpDeps, userId: string, mis
|
|
|
1228
1461
|
|
|
1229
1462
|
// Listing connected repository names uses managed access only. Verify GitHub
|
|
1230
1463
|
// access after selection, before returning any refs or source directory data.
|
|
1464
|
+
// Any team member (view or above) lists those their teams reach, for the code
|
|
1465
|
+
// viewer as for Create a bundle, whose builds stay admin|manage.
|
|
1231
1466
|
async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1232
|
-
const s = await
|
|
1467
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1233
1468
|
if (!s) return
|
|
1234
1469
|
sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
|
|
1235
1470
|
}
|
|
@@ -1268,25 +1503,54 @@ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEnt
|
|
|
1268
1503
|
} : {}) }
|
|
1269
1504
|
}
|
|
1270
1505
|
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1506
|
+
// A browse's repository, revision and directory, as its query names them;
|
|
1507
|
+
// null, answered, where it names none.
|
|
1508
|
+
function repositoryQuery(res: ServerResponse, query: URLSearchParams) {
|
|
1273
1509
|
const repoId = Number(query.get('repoId'))
|
|
1274
1510
|
const normalized = normalizeTeamPath(query.get('path') ?? '')
|
|
1275
1511
|
const ref = query.get('ref') ?? ''
|
|
1276
1512
|
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
|
|
1277
|
-
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1513
|
+
sendJson(res, 400, { error: 'bad-request' }); return null
|
|
1278
1514
|
}
|
|
1515
|
+
return { repoId, ref, path: normalized.path ?? '' }
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// The repository `repoId` as the reader may browse it at `path`: one of their
|
|
1519
|
+
// bundle repositories, and what their grants show of that directory
|
|
1520
|
+
// (`virtualEntries`, null where they reach all of it: scopedDirectory). Null,
|
|
1521
|
+
// answered, where it is none of theirs. Every team member (view or above)
|
|
1522
|
+
// reads the repositories their teams reach; the reader then checks their own
|
|
1523
|
+
// GitHub read permission (repository-browser.ts), on each read.
|
|
1524
|
+
async function authorizeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number, path: string) {
|
|
1525
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1526
|
+
if (!s) return null
|
|
1527
|
+
const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
|
|
1528
|
+
if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
|
|
1529
|
+
const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
|
|
1530
|
+
return { repo, virtualEntries: scopedDirectory(path, scopes), user: s.user }
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// The repository as it was authorized, but for the shared default branch hint,
|
|
1534
|
+
// which a read may refresh meanwhile: that changes none of its identity,
|
|
1535
|
+
// credentials or managed grants.
|
|
1536
|
+
const sameRepository = (current: SelectedRepo, authorized: SelectedRepo) =>
|
|
1537
|
+
JSON.stringify({ ...current, cachedDefaultBranch: authorized.cachedDefaultBranch }) === JSON.stringify(authorized)
|
|
1538
|
+
|
|
1539
|
+
function sendGithubError(res: ServerResponse, err: GithubApiError) {
|
|
1540
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Recheck repository/path grants after upstream reads, before returning names.
|
|
1544
|
+
async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
|
|
1545
|
+
const asked = repositoryQuery(res, query)
|
|
1546
|
+
if (!asked) return
|
|
1547
|
+
const { repoId, ref } = asked
|
|
1279
1548
|
const withDefault = refs && query.get('withDefault') === 'true'
|
|
1280
|
-
const path = refs ? '' :
|
|
1549
|
+
const path = refs ? '' : asked.path
|
|
1281
1550
|
const authorize = async () => {
|
|
1282
|
-
const
|
|
1283
|
-
if (!
|
|
1284
|
-
|
|
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 }
|
|
1551
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1552
|
+
if (access && (!refs || withDefault) && access.virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1553
|
+
return access
|
|
1290
1554
|
}
|
|
1291
1555
|
const access = await authorize()
|
|
1292
1556
|
if (!access) return
|
|
@@ -1308,23 +1572,729 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1308
1572
|
await reader.recheckAccess()
|
|
1309
1573
|
const current = await authorize()
|
|
1310
1574
|
if (!current) return
|
|
1311
|
-
// This request (or another reader) may have refreshed the shared hint.
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
sendJson(res, 409, { error: 'repository-changed' }); return
|
|
1315
|
-
}
|
|
1316
|
-
sendJson(res, 200, refs ? { ...listed, ...(withDefault ? {
|
|
1575
|
+
// This request (or another reader) may have refreshed the shared hint.
|
|
1576
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1577
|
+
sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
|
|
1317
1578
|
defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
|
|
1318
1579
|
} : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
|
|
1319
1580
|
} catch (err) {
|
|
1320
|
-
if (err instanceof GithubApiError) {
|
|
1321
|
-
|
|
1581
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1582
|
+
throw err
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// GET /api/admin/repositories/code: the files of a directory of a repository
|
|
1587
|
+
// at a revision, for the code viewer (repository-code.ts), read as browsing
|
|
1588
|
+
// it is, with the reader's own GitHub access, and only where their grants
|
|
1589
|
+
// reach the whole directory: one they reach only the ancestor of is none to
|
|
1590
|
+
// read. Its tree's listing says first whether its files fit, before its
|
|
1591
|
+
// tarball is asked for; the reader's access is checked again once it is
|
|
1592
|
+
// read, before any of it is sent. The load stays held until this response is
|
|
1593
|
+
// written or abandoned, and a reader who stops reading it gives it up after
|
|
1594
|
+
// a while.
|
|
1595
|
+
async function handleRepositoryCode(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1596
|
+
const asked = repositoryQuery(res, query)
|
|
1597
|
+
if (!asked) return
|
|
1598
|
+
const { repoId, ref, path } = asked
|
|
1599
|
+
const authorize = async () => {
|
|
1600
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1601
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1602
|
+
return access
|
|
1603
|
+
}
|
|
1604
|
+
const access = await authorize()
|
|
1605
|
+
if (!access) return
|
|
1606
|
+
try {
|
|
1607
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1608
|
+
const commit = await reader.commit(ref)
|
|
1609
|
+
const repo = access.repo.fullName
|
|
1610
|
+
const client = createClient({ token: reader.readToken() })
|
|
1611
|
+
// A directory the commit has none at is none to read, as browsing it says.
|
|
1612
|
+
const tree = await upstreamGithub(() => client.getRepoTreeId({ repo, sha: commit, ...(path ? { directory: path } : {}) }).catch((err: unknown) => {
|
|
1613
|
+
throw err instanceof Error && err.name === 'AssertionError' && err.message.includes(' has no directory at ') ? new GithubApiError(404, 'no-directory') : err
|
|
1614
|
+
}))
|
|
1615
|
+
// Access still held, and the same repository, before anything read is sent.
|
|
1616
|
+
const fenced = async () => {
|
|
1617
|
+
await reader.recheckAccess()
|
|
1618
|
+
const current = await authorize()
|
|
1619
|
+
if (!current) return false
|
|
1620
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return false }
|
|
1621
|
+
return true
|
|
1622
|
+
}
|
|
1623
|
+
// Too large names the commit it was read at, for History to read.
|
|
1624
|
+
if (!repositoryCodeFits(await repositoryTreeFiles(repo, reader.readToken(), tree))) {
|
|
1625
|
+
if (await fenced()) sendJson(res, 413, { error: 'directory-too-large', commit })
|
|
1322
1626
|
return
|
|
1323
1627
|
}
|
|
1628
|
+
if (res.destroyed) return
|
|
1629
|
+
const load = loadRepositoryCodeBody({ repoId, repo, commit, path, tree }, () => upstreamGithub(() => client.getRepoTreeTarball({ repo, tree })))
|
|
1630
|
+
res.once('close', load.release)
|
|
1631
|
+
const bytes = await load.result
|
|
1632
|
+
if (!(await fenced()) || res.destroyed) return
|
|
1633
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
1634
|
+
res.writeHead(200, {
|
|
1635
|
+
'content-type': 'application/json', 'content-encoding': 'br', 'content-length': String(bytes.byteLength),
|
|
1636
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
1637
|
+
})
|
|
1638
|
+
writeResponse(res, bytes)
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
if (res.headersSent || res.destroyed) return
|
|
1641
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1642
|
+
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
1643
|
+
throw err
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// GET /api/admin/repositories/history: what the code viewer's History tab
|
|
1648
|
+
// draws (repository-history.ts), read as browsing is, with the reader's own
|
|
1649
|
+
// GitHub access, within their grants as the code is. From a revision (`ref`),
|
|
1650
|
+
// a page at a time (`page`), the commits touching `path` where it names a
|
|
1651
|
+
// directory, with the branches and tags naming commits on the first page; or,
|
|
1652
|
+
// with `all`, across the repository's most recently updated branches, which
|
|
1653
|
+
// needs a GitHub token. A whole repository's history, from a revision or all
|
|
1654
|
+
// branches, is read from commits a page each, kept by the commit each starts
|
|
1655
|
+
// at, and those kept glued on (historyFrom); a later page reads on from the
|
|
1656
|
+
// commits `from` names. The branches and tags naming commits are kept as an
|
|
1657
|
+
// issue's page is (keptAnswer). Access is checked again once it is read,
|
|
1658
|
+
// before any of it is sent, with the app's users its authors are
|
|
1659
|
+
// (github-users.ts).
|
|
1660
|
+
async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1661
|
+
const asked = repositoryQuery(res, query)
|
|
1662
|
+
if (!asked) return
|
|
1663
|
+
const { repoId, ref, path } = asked
|
|
1664
|
+
const page = Number(query.get('page') ?? '1')
|
|
1665
|
+
const all = query.get('all') === 'true'
|
|
1666
|
+
// Every branch's heads read now, not as kept: the client asks so where
|
|
1667
|
+
// they miss the commit shown, as a branch moved since may.
|
|
1668
|
+
const fresh = query.get('fresh') === 'true'
|
|
1669
|
+
// A whole repository's later pages read on from where those before stop:
|
|
1670
|
+
// all branches', always; a revision's, where the client names them.
|
|
1671
|
+
const from = query.get('from')
|
|
1672
|
+
const starts = from === null ? null : historyStarts(from)
|
|
1673
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > MAX_HISTORY_PAGE || (all && (path !== '' || (page > 1) !== (starts !== null)))
|
|
1674
|
+
|| (from !== null && (starts === null || path !== '' || page === 1))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1675
|
+
const authorize = async () => {
|
|
1676
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1677
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1678
|
+
return access
|
|
1679
|
+
}
|
|
1680
|
+
const access = await authorize()
|
|
1681
|
+
if (!access) return
|
|
1682
|
+
try {
|
|
1683
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1684
|
+
const repo = access.repo.fullName
|
|
1685
|
+
const token = reader.readToken()
|
|
1686
|
+
// With a token, as GraphQL reads them.
|
|
1687
|
+
const pages = historyPages(deps, repoId, token !== null)
|
|
1688
|
+
let body: object
|
|
1689
|
+
if (all) {
|
|
1690
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
1691
|
+
// What a later page reads on from, the client knows; whether one may
|
|
1692
|
+
// follow, the deepest page says.
|
|
1693
|
+
// Asked for afresh, those kept answered as just read: how long till
|
|
1694
|
+
// they may be read again (`retryIn`), for the client to ask then.
|
|
1695
|
+
const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
|
|
1696
|
+
const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
|
|
1697
|
+
body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
|
|
1698
|
+
: { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
|
|
1699
|
+
} else {
|
|
1700
|
+
const commit = await reader.commit(ref)
|
|
1701
|
+
// The branches and tags naming commits: with a token, every branch All
|
|
1702
|
+
// branches reads and the latest tags, kept for both; without, GitHub's
|
|
1703
|
+
// first page of each.
|
|
1704
|
+
const naming = () => token
|
|
1705
|
+
? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
|
|
1706
|
+
branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
|
|
1707
|
+
: keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
|
|
1708
|
+
const tips = page === 1 ? naming() : null
|
|
1709
|
+
// A whole repository's from its commit, or on from those `from` names
|
|
1710
|
+
// (a page by its number where none are, as GitHub lists them); a
|
|
1711
|
+
// directory's by its number.
|
|
1712
|
+
const history = path !== '' || (page > 1 && !starts) ? repositoryHistory(repo, token, commit, path, page)
|
|
1713
|
+
: historyFrom(repo, token, starts ?? [commit], pages).then(read => ({ commits: read.commits, more: (starts !== null || read.more) && page < MAX_HISTORY_PAGE }))
|
|
1714
|
+
const [read, named] = await Promise.all([history, tips])
|
|
1715
|
+
body = { commit, path, page, ...read, ...named }
|
|
1716
|
+
}
|
|
1717
|
+
// The pull requests squashed into a whole repository's commits, drawn as
|
|
1718
|
+
// the merges they were, and on its first page the open ones' heads,
|
|
1719
|
+
// naming commits: with a token, as far as GitHub answers them, the
|
|
1720
|
+
// history sent all the same where it doesn't.
|
|
1721
|
+
const listed = (body as { commits: HistoryCommit[] }).commits
|
|
1722
|
+
const quiet = <T>(fallback: T) => (err: unknown) => {
|
|
1723
|
+
if (deps.config.debug) console.warn('managed: history pull requests read failed:', err)
|
|
1724
|
+
return fallback
|
|
1725
|
+
}
|
|
1726
|
+
const [pulls, openPulls] = token && path === '' ? await Promise.all([
|
|
1727
|
+
squashedPulls(repo, token, listed, historySquashes(deps, repoId)).catch(quiet<SquashedPull[]>([])),
|
|
1728
|
+
page === 1 ? keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null)) : null,
|
|
1729
|
+
]) : [[], null]
|
|
1730
|
+
await reader.recheckAccess()
|
|
1731
|
+
const current = await authorize()
|
|
1732
|
+
if (!current) return
|
|
1733
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1734
|
+
// Each commit as the tab draws it, its co-authors with it: what Pulse
|
|
1735
|
+
// counts of it (its lines, its author's time) not sent.
|
|
1736
|
+
const shown = ({ sha, parents, subject, authorName, authorLogin, authorId, coauthors, committedAt }: HistoryCommit) =>
|
|
1737
|
+
({ sha, parents, subject, authorName, authorLogin, authorId: authorId ?? null, coauthors: coauthors ?? [], committedAt })
|
|
1738
|
+
sendJson(res, 200, await withAppUsers(deps.db, { ...body, commits: listed.map(shown),
|
|
1739
|
+
...pulls.length > 0 ? { pulls: pulls.map(pull => ({ ...pull, commits: pull.commits.map(shown) })) } : {}, ...openPulls?.length ? { openPulls } : {} }))
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1742
|
+
throw err
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// GET /api/admin/repositories/changes: the files each commit `shas` names
|
|
1747
|
+
// changes, as the code viewer's Replay plays them (repository-changes.ts),
|
|
1748
|
+
// within `path`, each commit's kept whole by the commit (historyChanges);
|
|
1749
|
+
// read as Replay's reads are (readForReplay).
|
|
1750
|
+
async function handleRepositoryChanges(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1751
|
+
const shas = changesAsked(query.get('shas'))
|
|
1752
|
+
if (!shas) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1753
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1754
|
+
const changes = await repositoryChanges(fullName, token, shas, historyChanges(deps, repoId))
|
|
1755
|
+
return { path, changes: [...changes].map(([sha, read]) => read ? { sha, ...changesWithin(read, path) } : { sha, files: null, more: false }) }
|
|
1756
|
+
})
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// GET /api/admin/repositories/tree: the files commit `commit` holds within
|
|
1760
|
+
// `path` and their bytes (`sizes`), which Replay starts from and the Treemap
|
|
1761
|
+
// of a directory too large to open draws (repository-changes.ts
|
|
1762
|
+
// directoryFiles: the directory's own tree where the commit's is cut short),
|
|
1763
|
+
// kept whole by the commit and the rules it is read by (historyTreeStore,
|
|
1764
|
+
// TREES_READ); none (null) for one GitHub has none of. Read as Replay's
|
|
1765
|
+
// reads are (readForReplay).
|
|
1766
|
+
async function handleRepositoryTree(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1767
|
+
const commit = query.get('commit') ?? ''
|
|
1768
|
+
if (!/^[a-f\d]{40}$/u.test(commit)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1769
|
+
const store = historyTreeStore<CommitTree>(deps.db, deps.config.debug)
|
|
1770
|
+
await readForReplay(res, deps, cookie, query, async (fullName, token, repoId, path) => {
|
|
1771
|
+
const kept = { read: async (sha: string) => {
|
|
1772
|
+
const value = (await store.read(`${repoId}\0${TREES_READ}\0${sha}`))?.value
|
|
1773
|
+
return value && Array.isArray(value.files) && Array.isArray(value.sizes) && value.sizes.length === value.files.length ? value : null
|
|
1774
|
+
}, write: (sha: string, tree: CommitTree) => store.write(`${repoId}\0${TREES_READ}\0${sha}`, tree, Date.now()) }
|
|
1775
|
+
const tree = await directoryFiles(fullName, token, commit, path, kept)
|
|
1776
|
+
return { commit, path, ...tree ?? { files: null, sizes: null, cut: false } }
|
|
1777
|
+
})
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// What Replay reads of repository `repoId` within `path` (`read`, with its
|
|
1781
|
+
// name and the reader's token), as the history is read: with the reader's
|
|
1782
|
+
// own GitHub access, within their grants as the code is, a directory they
|
|
1783
|
+
// reach all of, and sent once access is checked again.
|
|
1784
|
+
async function readForReplay(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams,
|
|
1785
|
+
read: (fullName: string, token: string | null, repoId: number, path: string) => Promise<object>): Promise<void> {
|
|
1786
|
+
const asked = repositoryQuery(res, query)
|
|
1787
|
+
if (!asked) return
|
|
1788
|
+
const { repoId, path } = asked
|
|
1789
|
+
const authorize = async () => {
|
|
1790
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, path)
|
|
1791
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1792
|
+
return access
|
|
1793
|
+
}
|
|
1794
|
+
const access = await authorize()
|
|
1795
|
+
if (!access) return
|
|
1796
|
+
try {
|
|
1797
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1798
|
+
const body = await read(access.repo.fullName, reader.readToken(), repoId, path)
|
|
1799
|
+
await reader.recheckAccess()
|
|
1800
|
+
const current = await authorize()
|
|
1801
|
+
if (!current) return
|
|
1802
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1803
|
+
sendJson(res, 200, body)
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1806
|
+
throw err
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// The files repository `repoId`'s commits change, kept by each commit
|
|
1811
|
+
// (historyChangeStore, repository-changes.ts repositoryChanges).
|
|
1812
|
+
function historyChanges(deps: ManagedHttpDeps, repoId: number): ChangesStore {
|
|
1813
|
+
const store = historyChangeStore<CommitChanges>(deps.db, deps.config.debug)
|
|
1814
|
+
const keyOf = (sha: string) => `${repoId}\0${sha}`
|
|
1815
|
+
return {
|
|
1816
|
+
async read(shas) {
|
|
1817
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1818
|
+
return new Map(shas.flatMap(sha => {
|
|
1819
|
+
const value = kept.get(keyOf(sha))?.value
|
|
1820
|
+
return value && Array.isArray(value.files) ? [[sha, value] as const] : []
|
|
1821
|
+
}))
|
|
1822
|
+
},
|
|
1823
|
+
write: (sha, changes) => store.write(keyOf(sha), changes, Date.now()),
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// How long what readWholeRepository read is answered as kept, unread again;
|
|
1828
|
+
// and how long one read is answered as kept even where it is asked for afresh.
|
|
1829
|
+
const REPOSITORY_READ_FRESH_MS = 2 * 60_000
|
|
1830
|
+
const REPOSITORY_READ_FLOOR_MS = 10_000
|
|
1831
|
+
// The repositories' reads being read again, behind a kept answer or as asked
|
|
1832
|
+
// for afresh, by key: one at a time, those asking meanwhile answered by it.
|
|
1833
|
+
const repositoryRereads = new Map<string, Promise<unknown>>()
|
|
1834
|
+
// A read GitHub refused, not failed: its credential or the GitHub App's
|
|
1835
|
+
// permission for it withdrawn (Issues, Pull requests, Checks), the
|
|
1836
|
+
// repository or what was asked of it gone. What was kept of it goes too.
|
|
1837
|
+
const refusedRead = (err: unknown) => err instanceof GithubApiError
|
|
1838
|
+
&& (err.status === 401 || err.status === 403 || err.status === 404 || err.message === 'github-status-403')
|
|
1839
|
+
|
|
1840
|
+
// What `read` answers of a repository, kept by `key` (its id first) for every
|
|
1841
|
+
// reader (repositoryReadStore), each one's access checked as they ask before
|
|
1842
|
+
// this: answered as kept for REPOSITORY_READ_FRESH_MS, then, while it is
|
|
1843
|
+
// kept, answered at once and read again behind it for the next to ask, where
|
|
1844
|
+
// the instance outlives its answer (not serverless, where it is read again
|
|
1845
|
+
// before it is answered); `kept` where it was, or where reading it again
|
|
1846
|
+
// failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
|
|
1847
|
+
// now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
|
|
1848
|
+
// answered where that read fails. One GitHub refuses to read again is no
|
|
1849
|
+
// longer kept: the next to ask is refused as GitHub refused it. `at` is when
|
|
1850
|
+
// what it answers was read.
|
|
1851
|
+
async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
|
|
1852
|
+
keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
|
|
1853
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
1854
|
+
// One kept longer than a day, not yet removed (pruneKept removes them as
|
|
1855
|
+
// others are written), is none.
|
|
1856
|
+
const stored = await store.read(key)
|
|
1857
|
+
const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
|
|
1858
|
+
// Read, and kept as read when it was, which it is said read at.
|
|
1859
|
+
const readKept = async () => {
|
|
1860
|
+
try {
|
|
1861
|
+
const value = await read()
|
|
1862
|
+
const at = Date.now()
|
|
1863
|
+
await store.write(key, value, at)
|
|
1864
|
+
return { value, at }
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
if (refusedRead(err)) await store.remove(key)
|
|
1867
|
+
throw err
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const reread = () => {
|
|
1871
|
+
let reading = repositoryRereads.get(key)
|
|
1872
|
+
if (!reading) {
|
|
1873
|
+
reading = readKept().finally(() => repositoryRereads.delete(key))
|
|
1874
|
+
repositoryRereads.set(key, reading)
|
|
1875
|
+
}
|
|
1876
|
+
return reading as Promise<{ value: T; at: number }>
|
|
1877
|
+
}
|
|
1878
|
+
const age = held ? Date.now() - held.at : Infinity
|
|
1879
|
+
const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
|
|
1880
|
+
if (held && !now && (age < REPOSITORY_READ_FRESH_MS || !deps.config.serverless)) {
|
|
1881
|
+
if (age >= REPOSITORY_READ_FRESH_MS && !repositoryRereads.has(key)) {
|
|
1882
|
+
// Its failure leaves the kept answer sent until it is a day old, so it is said.
|
|
1883
|
+
deps.track(reread().catch((err: unknown) => { console.warn('managed: repository read failed:', err) }))
|
|
1884
|
+
}
|
|
1885
|
+
return { value: held.value as T, kept: true, at: held.at }
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
return { ...now ? await reread() : await readKept(), kept: false }
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
|
|
1324
1891
|
throw err
|
|
1325
1892
|
}
|
|
1326
1893
|
}
|
|
1327
1894
|
|
|
1895
|
+
// The pages of repository `repoId`'s history kept (historyPageStore), each by
|
|
1896
|
+
// the commit it starts at (repository-history.ts historyFrom) and the rules
|
|
1897
|
+
// its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
|
|
1898
|
+
// telling each commit's co-authors (kept since they are read), and with
|
|
1899
|
+
// `graphql`, only those read with GraphQL, telling its lines and the accounts
|
|
1900
|
+
// its co-authors' addresses are (a page GitHub's REST API listed tells
|
|
1901
|
+
// neither); the rest read again.
|
|
1902
|
+
function historyPages(deps: ManagedHttpDeps, repoId: number, graphql = false): HistoryPages {
|
|
1903
|
+
const store = historyPageStore<HistoryCommit[]>(deps.db, deps.config.debug)
|
|
1904
|
+
const keyOf = (top: string) => `${repoId}\0${COAUTHORS_READ}\0${top}`
|
|
1905
|
+
return {
|
|
1906
|
+
async read(tops) {
|
|
1907
|
+
const kept = await store.readMany(tops.map(keyOf))
|
|
1908
|
+
return new Map(tops.flatMap(top => {
|
|
1909
|
+
const page = kept.get(keyOf(top))?.value
|
|
1910
|
+
return Array.isArray(page) && page.every(commit => Array.isArray(commit.coauthors)) && (!graphql || page.every(commit => commit.additions != null))
|
|
1911
|
+
? [[top, page] as const] : []
|
|
1912
|
+
}))
|
|
1913
|
+
},
|
|
1914
|
+
write: (top, commits) => store.write(keyOf(top), commits, Date.now()),
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// The pull requests squashed into repository `repoId`'s commits, kept by
|
|
1919
|
+
// each commit and the rules their commits' co-authors were read by
|
|
1920
|
+
// (COAUTHORS_READ), null for one none was (repository-history.ts
|
|
1921
|
+
// squashedPulls); one kept before its commits' co-authors were read, read
|
|
1922
|
+
// again.
|
|
1923
|
+
function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
|
|
1924
|
+
const store = historySquashStore<SquashedPull | null>(deps.db, deps.config.debug)
|
|
1925
|
+
const keyOf = (sha: string) => `${repoId}\0${COAUTHORS_READ}\0${sha}`
|
|
1926
|
+
return {
|
|
1927
|
+
async read(shas) {
|
|
1928
|
+
const kept = await store.readMany(shas.map(keyOf))
|
|
1929
|
+
return new Map(shas.flatMap(sha => {
|
|
1930
|
+
const entry = kept.get(keyOf(sha))
|
|
1931
|
+
return entry && (entry.value === null || (Array.isArray(entry.value.commits) && entry.value.commits.every(commit => Array.isArray(commit.coauthors))))
|
|
1932
|
+
? [[sha, entry.value] as const] : []
|
|
1933
|
+
}))
|
|
1934
|
+
},
|
|
1935
|
+
write: (sha, pull) => store.write(keyOf(sha), pull, Date.now()),
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// What the whole of repository `repoId` gives in GraphQL (`read`, with its
|
|
1940
|
+
// name and a token), read as browsing is, with the reader's own GitHub
|
|
1941
|
+
// access: only by a reader whose grants reach all of it, with a token, and
|
|
1942
|
+
// sent once access is checked again after it is read; what `key` names kept
|
|
1943
|
+
// (keptAnswer), sent as kept, its reader's access checked as they ask; with
|
|
1944
|
+
// `readAt`, when it was read from GitHub, one kept as old as it was kept, so
|
|
1945
|
+
// that a reader holding two reads of the same can tell the later.
|
|
1946
|
+
async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1947
|
+
read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
|
|
1948
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
1949
|
+
const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
|
|
1950
|
+
: await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
|
|
1951
|
+
return { value: readAt ? { ...value, readAt: at } : value, kept }
|
|
1952
|
+
})
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// What `answer` gives of the whole of repository `repoId` (with its name and
|
|
1956
|
+
// a token), as readWholeRepository reads it: sent at once where it was kept,
|
|
1957
|
+
// else once access is checked again, with the app's users it names
|
|
1958
|
+
// (github-users.ts), never kept with it.
|
|
1959
|
+
async function answerWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
|
|
1960
|
+
answer: (fullName: string, token: string, user: StoredUser) => Promise<{ value: object; kept: boolean }>): Promise<void> {
|
|
1961
|
+
const authorize = async () => {
|
|
1962
|
+
const access = await authorizeRepository(res, deps, cookie, repoId, '')
|
|
1963
|
+
if (access && access.virtualEntries !== null) { sendJson(res, 404, { error: 'no-directory' }); return null }
|
|
1964
|
+
return access
|
|
1965
|
+
}
|
|
1966
|
+
const access = await authorize()
|
|
1967
|
+
if (!access) return
|
|
1968
|
+
try {
|
|
1969
|
+
const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
|
|
1970
|
+
const token = reader.readToken()
|
|
1971
|
+
if (!token) { sendJson(res, 403, { error: 'github-token-required' }); return }
|
|
1972
|
+
const answered = await answer(access.repo.fullName, token, access.user)
|
|
1973
|
+
if (answered.kept) { sendJson(res, 200, await withAppUsers(deps.db, answered.value)); return }
|
|
1974
|
+
await reader.recheckAccess()
|
|
1975
|
+
const current = await authorize()
|
|
1976
|
+
if (!current) return
|
|
1977
|
+
if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1978
|
+
sendJson(res, 200, await withAppUsers(deps.db, answered.value))
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
if (err instanceof GithubApiError) { sendGithubError(res, err); return }
|
|
1981
|
+
throw err
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
|
|
1986
|
+
// requests tabs show (repository-issues.ts): `kind`'s open or closed
|
|
1987
|
+
// (`state`), a page at a time, on from the cursor `after` names. They are
|
|
1988
|
+
// the whole repository's (readWholeRepository).
|
|
1989
|
+
async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1990
|
+
const repoId = Number(query.get('repoId'))
|
|
1991
|
+
const kind = query.get('kind')
|
|
1992
|
+
const state = query.get('state') ?? 'open'
|
|
1993
|
+
const after = query.get('after')
|
|
1994
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (kind !== 'issues' && kind !== 'pulls') || (state !== 'open' && state !== 'closed')
|
|
1995
|
+
|| (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1996
|
+
await readWholeRepository(res, deps, cookie, repoId, async (fullName, token) => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }),
|
|
1997
|
+
`issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`, true)
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// GET /api/admin/repositories/agents: what the code viewer's Agents tab
|
|
2001
|
+
// reconstructs agents' sessions from (repository-agents.ts): a page
|
|
2002
|
+
// of pull requests on from the cursor `pulls` names, and of the default
|
|
2003
|
+
// branch's commits on from `commits`, the first page, naming neither, with
|
|
2004
|
+
// issues and branches. They are the whole repository's (readWholeRepository).
|
|
2005
|
+
async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2006
|
+
const repoId = Number(query.get('repoId'))
|
|
2007
|
+
const cursors = { pulls: query.get('pulls'), commits: query.get('commits') }
|
|
2008
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || (cursors.pulls !== null && !ISSUES_CURSOR.test(cursors.pulls))
|
|
2009
|
+
|| (cursors.commits !== null && !COMMITS_CURSOR.test(cursors.commits))) {
|
|
2010
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
2011
|
+
}
|
|
2012
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors),
|
|
2013
|
+
`agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// GET /api/admin/repositories/item: the issue or pull request numbered
|
|
2017
|
+
// `number`, as the code viewer's popup for it shows it (repository-issues.ts
|
|
2018
|
+
// repositoryItem). It is the whole repository's (readWholeRepository).
|
|
2019
|
+
async function handleRepositoryItem(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2020
|
+
const repoId = Number(query.get('repoId'))
|
|
2021
|
+
const number = itemNumber(query)
|
|
2022
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2023
|
+
await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryItem(fullName, token, number), `item\0${ISSUES_READ_VERSION}\0${number}`, true)
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// The issue's or pull request's number a request names, as GraphQL takes one
|
|
2027
|
+
// (an Int, at most 2³¹ − 1); null for none.
|
|
2028
|
+
function itemNumber(query: URLSearchParams): number | null {
|
|
2029
|
+
const number = Number(query.get('number'))
|
|
2030
|
+
return /^[1-9]\d{0,9}$/u.test(query.get('number') ?? '') && number <= 2 ** 31 - 1 ? number : null
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// GET /api/admin/repositories/pull-dependencies: the package versions pull
|
|
2034
|
+
// request `number`'s lockfiles add (pull-dependencies.ts), what its base's and head's
|
|
2035
|
+
// commits tell kept by them (pullDependencyKept), its `number` with them.
|
|
2036
|
+
// They are the whole repository's (answerWholeRepository), kept by its number
|
|
2037
|
+
// as its popup's item is, its head moving with each push. With `review=1`,
|
|
2038
|
+
// as an open one's are asked, where the repository reviews dependencies,
|
|
2039
|
+
// each version's review (pull-dependency-reviews.ts), read afresh as each
|
|
2040
|
+
// asks, reviews being published meanwhile; its baseline kept, where the host
|
|
2041
|
+
// registered no reviewed dependencies, which are asked in its place.
|
|
2042
|
+
async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2043
|
+
const repoId = Number(query.get('repoId'))
|
|
2044
|
+
const number = itemNumber(query)
|
|
2045
|
+
const review = query.get('review') === '1'
|
|
2046
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || number === null || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2047
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2048
|
+
const answered = await keptAnswer(deps, `${repoId}\0pull-dependencies\0${PULL_DEPENDENCIES_READ}\0${number}`,
|
|
2049
|
+
async () => ({ number, ...await pullDependencies(fullName, token, number, pullDependencyKept(deps, repoId)) }))
|
|
2050
|
+
// Where its review is on, those of lockfiles it leaves out told to none,
|
|
2051
|
+
// whoever asks.
|
|
2052
|
+
const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
|
|
2053
|
+
const held = setting?.enabled ? heldDependencies(answered.value, setting.lockfiles) : answered.value
|
|
2054
|
+
const now = review ? await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(held)) : null
|
|
2055
|
+
// Its review by the lockfiles they were told by, though they have changed
|
|
2056
|
+
// since; turned off meanwhile, all of them, as where it is off.
|
|
2057
|
+
const state = now && setting?.enabled ? { ...now, lockfiles: setting.lockfiles } : now
|
|
2058
|
+
const told = review && !now && setting?.enabled && !(await deps.db.listRepoDependencyReviews()).get(repoId)?.enabled ? answered.value : held
|
|
2059
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2060
|
+
const { baseline, unread, error } = state && !reviewed ? await baselineFor(fullName, token, repoId, state, baselineKept(deps)) : { baseline: null, unread: [], error: null }
|
|
2061
|
+
// Its comments read for reviews, where the host reads them, while it is
|
|
2062
|
+
// open (pull-comment-reviews.ts reads whether it is with them).
|
|
2063
|
+
const parse = reviewed?.parseComments
|
|
2064
|
+
const comments = state && parse ? commentReviews({ fullName, number, token, parse, reviewers: accounts => repoReviewers(deps, repoId, accounts) }) : null
|
|
2065
|
+
// The comment the host formats of the reader's reviews of its versions, for its copy button.
|
|
2066
|
+
const formatted = await formattedPull(reviewed, deps.db, user.id, told, state, { repository: fullName, number })
|
|
2067
|
+
return { value: { number: told.number, ...await reviewedDependencies(told, state, { baseline, baselineUnread: unread, baselineError: error, reviewed, commentReviews: comments }),
|
|
2068
|
+
...formatted ? { formatted } : {} }, kept: answered.kept }
|
|
2069
|
+
})
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// GET /api/admin/repositories/branch-dependencies: what the lockfiles of the
|
|
2073
|
+
// repository's default branch lock that wants review, where its review is in
|
|
2074
|
+
// effect for the reader (`review`, else none), as a team's Dependencies tab
|
|
2075
|
+
// shows it: its head, as its pulse reads it, kept a while; every lockfile
|
|
2076
|
+
// there its review holds to (lockfile-scope.ts, commit-lockfiles.ts) as one
|
|
2077
|
+
// directory, its versions as a pull request's others are
|
|
2078
|
+
// (pull-dependency-reviews.ts), each left unread a directory of its own
|
|
2079
|
+
// failing the review, as a pull request's are.
|
|
2080
|
+
async function handleRepositoryBranchDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2081
|
+
const repoId = Number(query.get('repoId'))
|
|
2082
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2083
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
|
|
2084
|
+
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2085
|
+
if (!head) return { value: { head: null, review: null }, kept: false }
|
|
2086
|
+
// Those its review holds to alone read, as many as are read of them.
|
|
2087
|
+
const setting = (await deps.db.listRepoDependencyReviews()).get(repoId)
|
|
2088
|
+
if (!setting?.enabled) return { value: { head, review: null }, kept: false }
|
|
2089
|
+
const read = await lockedAt(fullName, token, head, path => heldTo(setting.lockfiles, path))
|
|
2090
|
+
const { packages, paths } = lockedIn(read)
|
|
2091
|
+
const told = { base: head, head, cut: false, lockfiles: [{ paths, added: [], more: 0, locked: packages.slice(0, MAX_LOCKED), moreLocked: Math.max(0, packages.length - MAX_LOCKED) },
|
|
2092
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' }))] }
|
|
2093
|
+
const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told))
|
|
2094
|
+
if (!now) return { value: { head, review: null }, kept: false }
|
|
2095
|
+
// Told by the lockfiles it held to as they were read, though they have
|
|
2096
|
+
// changed since.
|
|
2097
|
+
const state = { ...now, lockfiles: setting.lockfiles }
|
|
2098
|
+
const reviewed = deps.reviewedDependencies ?? null
|
|
2099
|
+
const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null } : await baselineFor(fullName, token, repoId, state, baselineKept(deps))
|
|
2100
|
+
return { value: await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed }), kept: false }
|
|
2101
|
+
})
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// GET /api/admin/repositories/lockfiles: the lockfiles of the repository's
|
|
2105
|
+
// default branch its dependency review reads (commit-lockfiles.ts), by their
|
|
2106
|
+
// paths, with their formats, at its head, as its pulse reads it, for its
|
|
2107
|
+
// settings to name those it holds to (lockfile-scope.ts).
|
|
2108
|
+
async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2109
|
+
const repoId = Number(query.get('repoId'))
|
|
2110
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2111
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2112
|
+
const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
|
|
2113
|
+
return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head)).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
|
|
2114
|
+
})
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// The app's users `accounts` are, by their logins in lower case, of those
|
|
2118
|
+
// who publish reviews to a team counting for repository `repoId`.
|
|
2119
|
+
async function repoReviewers(deps: ManagedHttpDeps, repoId: number, accounts: { login: string; githubId: number | null }[]) {
|
|
2120
|
+
const users = await deps.db.listUsersByAccount(accounts)
|
|
2121
|
+
const counted = await deps.db.listRepoReviewers(repoId, [...new Set(Object.values(users).map(user => user.id))])
|
|
2122
|
+
return new Map(Object.entries(users).flatMap(([login, user]) => counted.has(user.id) ? [[login.toLowerCase(), user.id] as const] : []))
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// The versions repository lockfiles locked at the commits their dependencies
|
|
2126
|
+
// are reviewed since, kept by each (reviewBaselineStore).
|
|
2127
|
+
function baselineKept(deps: ManagedHttpDeps): ReviewBaselineStore {
|
|
2128
|
+
const store = reviewBaselineStore(deps.db, deps.config.debug)
|
|
2129
|
+
return {
|
|
2130
|
+
async read(key) {
|
|
2131
|
+
const value = (await store.read(key))?.value
|
|
2132
|
+
const strings = (list: unknown) => Array.isArray(list) && list.every(item => typeof item === 'string')
|
|
2133
|
+
return strings(value?.versions) && strings(value?.unread) ? value : null
|
|
2134
|
+
},
|
|
2135
|
+
write: (key, baseline) => store.write(key, baseline, Date.now()),
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// What repository `repoId`'s pull requests' lockfiles add, kept by the
|
|
2140
|
+
// commits of each's base and head and the rules they were told by
|
|
2141
|
+
// (PULL_DEPENDENCIES_READ, pullDependencyStore).
|
|
2142
|
+
function pullDependencyKept(deps: ManagedHttpDeps, repoId: number): PullDependencyStore {
|
|
2143
|
+
const store = pullDependencyStore<PullDependencies>(deps.db, deps.config.debug)
|
|
2144
|
+
const keyOf = (commits: string) => `${repoId}\0${PULL_DEPENDENCIES_READ}\0${commits}`
|
|
2145
|
+
return {
|
|
2146
|
+
async read(commits) {
|
|
2147
|
+
const value = (await store.read(keyOf(commits)))?.value
|
|
2148
|
+
return value && Array.isArray(value.lockfiles) ? value : null
|
|
2149
|
+
},
|
|
2150
|
+
write: (commits, told) => store.write(keyOf(commits), told, Date.now()),
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// GET /api/admin/repositories/pulse: what the code viewer's Pulse tab shows
|
|
2155
|
+
// (repository-pulse.ts): the default branch's year, counted from its commits
|
|
2156
|
+
// (pulseCommits), and the pull requests and issues of the last `weeks`
|
|
2157
|
+
// weeks, each kept apart. A year not read whole yet answers how many of its
|
|
2158
|
+
// commits are (`reading`), or of the pull requests merged in it (`pulls`);
|
|
2159
|
+
// one GitHub's statistics count, while it is still computing them,
|
|
2160
|
+
// `pending`, for the client to ask again. They are the whole repository's
|
|
2161
|
+
// (readWholeRepository).
|
|
2162
|
+
async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2163
|
+
const repoId = Number(query.get('repoId'))
|
|
2164
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2165
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2166
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2167
|
+
const [period, year] = await Promise.all([
|
|
2168
|
+
keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
|
|
2169
|
+
pulseCommits(deps, repoId, fullName, token),
|
|
2170
|
+
])
|
|
2171
|
+
return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
|
|
2172
|
+
pending: year.pending }, kept: period.kept && year.kept }
|
|
2173
|
+
})
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// The default branch's year (repository-pulse.ts commitStats), counted from
|
|
2177
|
+
// its commits as the Commits tab keeps them, with their lines
|
|
2178
|
+
// (yearCommits): kept by the head it was read from (repositoryReadStore),
|
|
2179
|
+
// that head kept as an issue's page is; its commits merged from an agent's
|
|
2180
|
+
// pull request told by those merged (agentMerges), kept as read, the rest
|
|
2181
|
+
// read on by the next answer as the year's commits are, and read again as
|
|
2182
|
+
// an answer kept is, a label changing whose a pull request is without
|
|
2183
|
+
// moving the head, the year counted again where what they bring changed.
|
|
2184
|
+
// A year not read whole yet is how many of its commits are (`reading`), or
|
|
2185
|
+
// of the pull requests merged (`pulls`), beside the year last counted from
|
|
2186
|
+
// an earlier head over the same weeks, where one is kept, so that a head
|
|
2187
|
+
// moved on shows the year as it was till it is counted again; one past
|
|
2188
|
+
// YEAR_COMMITS is GitHub's statistics' (repositoryStats, repositoryLines),
|
|
2189
|
+
// kept together as an issue's page is and through GitHub computing them
|
|
2190
|
+
// again, `pending` while it first computes them, the year last counted
|
|
2191
|
+
// beside it so too.
|
|
2192
|
+
// A day as a pulse's items are narrowed by (YYYY-MM-DD, UTC), when it starts;
|
|
2193
|
+
// a GitHub login, as many of them as PULSE_AUTHORS.
|
|
2194
|
+
const PULSE_DAY = /^\d{4}-\d{2}-\d{2}$/u
|
|
2195
|
+
const GITHUB_LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?:\[bot\])?$/iu
|
|
2196
|
+
const PULSE_AUTHORS = 10
|
|
2197
|
+
const pulseDay = (value: string | null) => {
|
|
2198
|
+
const at = value !== null && PULSE_DAY.test(value) ? Date.parse(`${value}T00:00:00Z`) : Number.NaN
|
|
2199
|
+
return Number.isSafeInteger(at) ? at : null
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
|
|
2203
|
+
// issues narrowed to a picked day or week (`from` to `to`, else on from
|
|
2204
|
+
// `from`) and the GitHub accounts a picked contributor committed as
|
|
2205
|
+
// (`authors`), as repositoryActivity reads them, kept apart for each.
|
|
2206
|
+
async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
2207
|
+
const repoId = Number(query.get('repoId'))
|
|
2208
|
+
const weeks = Number(query.get('weeks') ?? '4')
|
|
2209
|
+
const [from, to] = [pulseDay(query.get('from')), query.has('to') ? pulseDay(query.get('to')) : null]
|
|
2210
|
+
const logins = (query.get('authors') ?? '').split(',').filter(Boolean)
|
|
2211
|
+
// Each once, whatever its case, as GitHub matches logins.
|
|
2212
|
+
const authors = [...new Set(logins.map(login => login.toLowerCase()))].toSorted()
|
|
2213
|
+
if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks) || from === null || (query.has('to') && (to === null || to < from))
|
|
2214
|
+
|| authors.length > PULSE_AUTHORS || !logins.every(login => GITHUB_LOGIN.test(login))) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2215
|
+
await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
|
|
2216
|
+
const narrowing = { from, to, authors }
|
|
2217
|
+
const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
|
|
2218
|
+
() => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
|
|
2219
|
+
return { value: { weeks, ...items.value }, kept: items.kept }
|
|
2220
|
+
})
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string) {
|
|
2224
|
+
const first = pulseSince(52, Date.now())
|
|
2225
|
+
const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
|
|
2226
|
+
if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2227
|
+
const store = repositoryReadStore(deps.db, deps.config.debug)
|
|
2228
|
+
const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
|
|
2229
|
+
const lastKey = `${repoId}\0pulse-year-last`
|
|
2230
|
+
const mergesKey = `${repoId}\0pulse-merges\0${PULSE_YEAR_VERSION}`
|
|
2231
|
+
const last = async () => {
|
|
2232
|
+
const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
|
|
2233
|
+
return kept?.first === first ? kept.stats : null
|
|
2234
|
+
}
|
|
2235
|
+
const sha = head.value.sha
|
|
2236
|
+
// What tells whose the year's commits are, kept as read: the agents' pull
|
|
2237
|
+
// requests merged (agentMerges), and the commits that release the
|
|
2238
|
+
// repository (`releases`, release-commits.ts releaseCommits).
|
|
2239
|
+
type Attribution = AgentMerges & { releases?: string[] }
|
|
2240
|
+
const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
|
|
2241
|
+
const attribution = async (kept: Attribution | null): Promise<Attribution> => {
|
|
2242
|
+
const [merges, releases] = await Promise.all([agentMerges(fullName, token, first, kept), releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
|
|
2243
|
+
return { ...merges, releases }
|
|
2244
|
+
}
|
|
2245
|
+
// The year counted from its `commits` as `read` tells whose they are and
|
|
2246
|
+
// which were pushed directly, the branch followed through those it was
|
|
2247
|
+
// read on past (`passed`, yearCommits) too; kept by its head and as the
|
|
2248
|
+
// year last counted.
|
|
2249
|
+
const count = async ({ commits, passed }: { commits: HistoryCommit[]; passed: HistoryCommit[] }, read: Attribution) => {
|
|
2250
|
+
const walked = [...commits, ...passed]
|
|
2251
|
+
const stats = commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha))
|
|
2252
|
+
await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
|
|
2253
|
+
return stats
|
|
2254
|
+
}
|
|
2255
|
+
// A year kept by its head, what tells whose its commits are read again as
|
|
2256
|
+
// an answer kept is (keptAnswer), a label or a tag changing it without
|
|
2257
|
+
// moving the head: counted again from its pages kept where what agents'
|
|
2258
|
+
// pull requests bring, or the releases, changed, or a read of the pull
|
|
2259
|
+
// requests left unfinished is finished; the year as kept answered
|
|
2260
|
+
// meanwhile, and where reading them fails.
|
|
2261
|
+
const relabelled = async (stats: PulseStats) => {
|
|
2262
|
+
const recounted: { stats?: PulseStats } = {}
|
|
2263
|
+
await keptAnswer(deps, mergesKey, async () => {
|
|
2264
|
+
const kept = await keptAttribution()
|
|
2265
|
+
const read = await attribution(kept)
|
|
2266
|
+
const same = kept && !kept.next && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join()
|
|
2267
|
+
if (read.next || same) return read
|
|
2268
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2269
|
+
if (year.complete) recounted.stats = await count(year, read)
|
|
2270
|
+
return read
|
|
2271
|
+
}, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
|
|
2272
|
+
return recounted.stats ?? stats
|
|
2273
|
+
}
|
|
2274
|
+
const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
|
|
2275
|
+
if (held && !('tooMany' in held)) return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
|
|
2276
|
+
if (!held) {
|
|
2277
|
+
const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true))
|
|
2278
|
+
if (year.complete) {
|
|
2279
|
+
const read = await attribution(await keptAttribution())
|
|
2280
|
+
await store.write(mergesKey, read, Date.now())
|
|
2281
|
+
if (read.next) return { stats: await last(), reading: null, pulls: read.next.read, pending: false, kept: false }
|
|
2282
|
+
return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
|
|
2283
|
+
}
|
|
2284
|
+
if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
|
|
2285
|
+
await store.write(key, { tooMany: true }, Date.now())
|
|
2286
|
+
}
|
|
2287
|
+
// Its commits and lines read together, kept as one: never the one GitHub
|
|
2288
|
+
// has counted since a push beside the other kept from before it.
|
|
2289
|
+
const computing = (err: unknown) => err instanceof StatsPending
|
|
2290
|
+
const counted = await keptAnswer(deps, `${repoId}\0pulse-stats`, async () => {
|
|
2291
|
+
const [stats, lines] = await Promise.all([repositoryStats(fullName, token), repositoryLines(fullName, token, first)])
|
|
2292
|
+
return withLines(stats, lines)
|
|
2293
|
+
}, computing).catch((err: unknown) => { if (computing(err)) return null; throw err })
|
|
2294
|
+
if (counted) return { stats: counted.value, reading: null, pulls: null, pending: false, kept: counted.kept }
|
|
2295
|
+
return { stats: await last(), reading: null, pulls: null, pending: true, kept: false }
|
|
2296
|
+
}
|
|
2297
|
+
|
|
1328
2298
|
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
1329
2299
|
if (!deps.bundleCache || deps.config.serverless) return
|
|
1330
2300
|
deps.track((async () => {
|
|
@@ -1399,9 +2369,13 @@ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
|
1399
2369
|
// (repository-browser.ts), and that access checked again once it is read,
|
|
1400
2370
|
// before any of it is told; packages as the npm viewer's admins read them, in
|
|
1401
2371
|
// spare assessments (npm-risk.ts), trusted however new where a team lists
|
|
1402
|
-
// their scope, and each lockfile's asked of npm's advisories at once
|
|
1403
|
-
//
|
|
1404
|
-
// it
|
|
2372
|
+
// their scope, and each lockfile's asked of npm's advisories at once; each
|
|
2373
|
+
// repository's Dependabot alerts, open or dismissed, read with the App's
|
|
2374
|
+
// installation token where the admin's own access to it lets them see them on
|
|
2375
|
+
// GitHub (dependabot-access.ts); and its GitHub Actions, the references they
|
|
2376
|
+
// make resolved with the admin's own GitHub token (github-actions.ts). The
|
|
2377
|
+
// reader leaving, losing their admin role or
|
|
2378
|
+
// the server shutting down stops it.
|
|
1405
2379
|
async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
1406
2380
|
const s = await readAdminSession(res, deps, cookie)
|
|
1407
2381
|
if (!s) return
|
|
@@ -1414,7 +2388,8 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1414
2388
|
const controller = new AbortController()
|
|
1415
2389
|
res.on('close', () => { controller.abort() })
|
|
1416
2390
|
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1417
|
-
const
|
|
2391
|
+
const user = await repositoryBrowserUser(deps, s.user.id)
|
|
2392
|
+
const browser = createRepositoryBrowser(deps.config, user)
|
|
1418
2393
|
const scopes = new Map<string, Promise<boolean>>()
|
|
1419
2394
|
const teamScoped = (name: string) => {
|
|
1420
2395
|
const scope = npmPackageScope(name)
|
|
@@ -1439,8 +2414,15 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1439
2414
|
lockfiles: async () => (await upstreamGithub(() => client.findRepoLockfiles({ repo: repo.fullName, sha: commit }))).lockfiles,
|
|
1440
2415
|
file: path => upstreamGithub(() => client.getRepoFile({ repo: repo.fullName, path, ref: commit })),
|
|
1441
2416
|
recheck: async () => { await reader.recheckAccess() },
|
|
2417
|
+
// As GitHub has them now, where the admin's own access lets them see them.
|
|
2418
|
+
dependabot: () => reader.dependabotAlerts(),
|
|
2419
|
+
workflows: () => reader.directory(WORKFLOWS_DIR, commit).then(({ entries }) => entries.filter(entry => entry.type === 'file' && /\.ya?ml$/iu.test(entry.name)).map(entry => entry.path),
|
|
2420
|
+
(error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
|
|
1442
2421
|
}
|
|
1443
2422
|
},
|
|
2423
|
+
actions: githubActionsSource(user.token),
|
|
2424
|
+
actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
2425
|
+
actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
|
|
1444
2426
|
teamScoped,
|
|
1445
2427
|
// Each package's downloads readied before it is assessed
|
|
1446
2428
|
// (scan-downloads.ts), so that none waits on npm's downloads API; a
|
|
@@ -1515,18 +2497,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1515
2497
|
await sendEncoded(req, res, cached, contentType, headers)
|
|
1516
2498
|
}
|
|
1517
2499
|
|
|
2500
|
+
// Two bundles a reader asks about together, where they may read both, else
|
|
2501
|
+
// null, told as 401 or 404: asked before what is worked out of them and
|
|
2502
|
+
// again before it is sent.
|
|
2503
|
+
function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
|
|
2504
|
+
return async () => {
|
|
2505
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
2506
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2507
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
2508
|
+
return [base.bundle, other.bundle] as const
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
1518
2512
|
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1519
2513
|
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1520
2514
|
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1521
2515
|
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1522
2516
|
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1523
2517
|
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1524
|
-
const readable =
|
|
1525
|
-
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1526
|
-
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1527
|
-
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1528
|
-
return [base.bundle, other.bundle] as const
|
|
1529
|
-
}
|
|
2518
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
1530
2519
|
const records = await readable()
|
|
1531
2520
|
if (!records) return
|
|
1532
2521
|
const { prettyCache } = deps
|
|
@@ -1542,6 +2531,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
|
|
|
1542
2531
|
else diff.stream.destroy()
|
|
1543
2532
|
}
|
|
1544
2533
|
|
|
2534
|
+
// GET /api/bundles/:id/comparison?other=: its files paired with the `other`
|
|
2535
|
+
// bundle's as Compare pairs them, with what its Overview reads beside them
|
|
2536
|
+
// (comparison.ts), within a reason `scope` where given; for a reader of
|
|
2537
|
+
// both, checked again once it is worked out.
|
|
2538
|
+
async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
2539
|
+
const otherId = params.get('other') ?? ''
|
|
2540
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
2541
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
2542
|
+
const records = await readable()
|
|
2543
|
+
if (!records) return
|
|
2544
|
+
const { prettyCache } = deps
|
|
2545
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
2546
|
+
let body
|
|
2547
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
|
|
2548
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
2549
|
+
if (await readable()) await sendEncoded(req, res, body, 'application/json')
|
|
2550
|
+
else body.stream.destroy()
|
|
2551
|
+
}
|
|
2552
|
+
|
|
1545
2553
|
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1546
2554
|
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1547
2555
|
// line each as it is found kept or formatted; GET
|
|
@@ -1898,7 +2906,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
1898
2906
|
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1899
2907
|
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1900
2908
|
sendJson(res, 200, {
|
|
1901
|
-
teams: teams.map(team => ({ ...team,
|
|
2909
|
+
teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
|
|
1902
2910
|
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1903
2911
|
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1904
2912
|
revision,
|
|
@@ -2666,6 +3674,11 @@ function found<T>(value: T | null | undefined): T {
|
|
|
2666
3674
|
return value
|
|
2667
3675
|
}
|
|
2668
3676
|
|
|
3677
|
+
// Version documents a list of reviews reads at once: each holds a version
|
|
3678
|
+
// document's room in what npm's reads may hold at once (npm-packages.ts),
|
|
3679
|
+
// which refuses past thirty-two of them.
|
|
3680
|
+
const REVIEW_READS_AT_ONCE = 8
|
|
3681
|
+
|
|
2669
3682
|
// Lists must enforce the same target and base access as the review itself,
|
|
2670
3683
|
// including permission changes while the registry requests are in flight.
|
|
2671
3684
|
async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, user: string, reviews: T[]): Promise<T[] | null> {
|
|
@@ -2675,18 +3688,15 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2675
3688
|
try {
|
|
2676
3689
|
const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
|
|
2677
3690
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2678
|
-
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2679
3691
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
2680
|
-
// day: a list asks npm for none it read within it.
|
|
3692
|
+
// day: a list asks npm for none it read within it. Each version once, as
|
|
3693
|
+
// many at once as REVIEW_READS_AT_ONCE.
|
|
2681
3694
|
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2682
|
-
const
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
}
|
|
2687
|
-
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2688
|
-
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2689
|
-
])))
|
|
3695
|
+
const versionsOf = (review: T) => [[review.package, review.version], ...review.basePackage ? [[review.basePackage, review.baseVersion!]] : []] as [string, string][]
|
|
3696
|
+
const asked = new Map(reviews.flatMap(versionsOf).map(([name, version]) => [`${name}\0${version}`, [name, version] as const]))
|
|
3697
|
+
const read = await eachOf([...asked], REVIEW_READS_AT_ONCE, ([, [name, version]]) => readNpmVersion(name, version, canReadPrivateNpm(reader, name), controller.signal, kept))
|
|
3698
|
+
const documents = new Map([...asked.keys()].map((key, at) => [key, read[at] ?? null]))
|
|
3699
|
+
const packages = reviews.map(review => versionsOf(review).map(([name, version]) => documents.get(`${name}\0${version}`) ?? null))
|
|
2690
3700
|
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
2691
3701
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2692
3702
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
@@ -2835,7 +3845,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2835
3845
|
const allBases = params.get('allBases') === 'true'
|
|
2836
3846
|
const rows = await deps.db.listDependencyReviews(context, current.userId, allBases)
|
|
2837
3847
|
const reviews = allBases ? await readableNpmReviews(res, deps, cookie, current.userId, rows) : rows
|
|
2838
|
-
if (reviews) sendJson(res, 200, { reviews, teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
3848
|
+
if (reviews) sendJson(res, 200, { reviews: await withFormatted(deps.reviewedDependencies ?? null, current.userId, reviews), teams: await deps.db.listDependencyReviewTeams(current.userId) })
|
|
2839
3849
|
return
|
|
2840
3850
|
}
|
|
2841
3851
|
const checked = await checkMutation(req, res, deps, cookie)
|
|
@@ -2845,7 +3855,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2845
3855
|
sendJson(res, 400, { error: limit === 'reject' ? 'review-malware' : 'review-critical-vulnerability' }); return
|
|
2846
3856
|
}
|
|
2847
3857
|
const review = await deps.db.changeDependencyReview(context, checked.session.id, change, Date.now())
|
|
2848
|
-
sendJson(res, 200, { review })
|
|
3858
|
+
sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, current.userId, [review]))[0] })
|
|
2849
3859
|
} catch (err) {
|
|
2850
3860
|
if (controller.signal.aborted) return
|
|
2851
3861
|
if (err instanceof NpmPackageError) { sendJson(res, err.status, { error: err.message }); return }
|
|
@@ -2854,7 +3864,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2854
3864
|
} finally { res.off('close', onClose) }
|
|
2855
3865
|
}
|
|
2856
3866
|
|
|
2857
|
-
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
3867
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
2858
3868
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2859
3869
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2860
3870
|
// Access is checked again after the registry's answer.
|
|
@@ -2864,14 +3874,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2864
3874
|
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2865
3875
|
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2866
3876
|
const otherSpec = params.get('otherVersion') ?? ''
|
|
2867
|
-
|
|
3877
|
+
// The package a comparison's other side is a version of, another where Review compares a dependency replaced.
|
|
3878
|
+
const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
|
|
3879
|
+
if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2868
3880
|
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2869
|
-
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
3881
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
3882
|
+
|| ((path === NPM_COMPARISON_PATH || path === NPM_DELTA_PATH) && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2870
3883
|
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2871
3884
|
}
|
|
2872
3885
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2873
3886
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2874
|
-
const reader = await npmReader(deps, cookie, [name])
|
|
3887
|
+
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
2875
3888
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2876
3889
|
const controller = new AbortController()
|
|
2877
3890
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -2970,6 +3983,42 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2970
3983
|
return
|
|
2971
3984
|
}
|
|
2972
3985
|
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
3986
|
+
// The lines it changes against `otherVersion`, or `(empty)`, its whole
|
|
3987
|
+
// package's, and those but for declaration files changed in types alone
|
|
3988
|
+
// (npm-delta.ts), as a list of versions wanting reviews tells them.
|
|
3989
|
+
if (path === NPM_DELTA_PATH) {
|
|
3990
|
+
const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(name, otherSpec, privileged, controller.signal, keptVersions))
|
|
3991
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3992
|
+
const side = (version: NpmVersionDocument) => ({ integrity: version.dist.integrity, files: async () => {
|
|
3993
|
+
const load = loadNpmFiles(version)
|
|
3994
|
+
try { return await load.result } finally { load.release() }
|
|
3995
|
+
} })
|
|
3996
|
+
const delta = await npmDelta(old ? side(old) : null, side(doc), npmDeltaStore<NpmDelta>(deps.db, deps.config.debug))
|
|
3997
|
+
await reply(doc.private || old?.private === true, { name, version: doc.version, otherVersion: old?.version ?? null, ...delta })
|
|
3998
|
+
return
|
|
3999
|
+
}
|
|
4000
|
+
// Its files paired with those of `otherVersion` (of `otherName`, else the
|
|
4001
|
+
// same package), as Compare and Review pair them, with what their
|
|
4002
|
+
// Overview reads beside them (comparison.ts): the version shown against
|
|
4003
|
+
// the one compared with, or `(empty)`, none, as Review reads a first
|
|
4004
|
+
// version. Read of both, each package's access checked again once worked out.
|
|
4005
|
+
if (path === NPM_COMPARISON_PATH) {
|
|
4006
|
+
const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
|
|
4007
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
4008
|
+
const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
|
|
4009
|
+
let body
|
|
4010
|
+
try {
|
|
4011
|
+
const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
|
|
4012
|
+
body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
|
|
4013
|
+
} finally { for (const load of loads) load.release() }
|
|
4014
|
+
const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
4015
|
+
if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
4016
|
+
if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
|
|
4017
|
+
body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
|
|
4018
|
+
}
|
|
4019
|
+
if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
|
|
4020
|
+
return
|
|
4021
|
+
}
|
|
2973
4022
|
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2974
4023
|
// npmAll), or, against another version, the files that changed between
|
|
2975
4024
|
// them, both sides' (compared), a line each as it is formatted, or found
|
|
@@ -3110,13 +4159,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3110
4159
|
}
|
|
3111
4160
|
|
|
3112
4161
|
// Public mode probe — lets a client detect the managed protocol up front.
|
|
4162
|
+
// It also sends the session, sparing the client's GET /api/auth/session;
|
|
4163
|
+
// a failed read leaves that request to the client, never failing the probe.
|
|
3113
4164
|
if (path === CONFIG_PATH) {
|
|
3114
4165
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3115
4166
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
4167
|
+
const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
|
|
3116
4168
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
3117
4169
|
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
3118
4170
|
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
3119
|
-
} : {}) } })
|
|
4171
|
+
} : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
|
|
3120
4172
|
return
|
|
3121
4173
|
}
|
|
3122
4174
|
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
@@ -3132,6 +4184,15 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3132
4184
|
if (path === CALLBACK_PATH) {
|
|
3133
4185
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3134
4186
|
try {
|
|
4187
|
+
if (isLinkOAuthCallback(config, url.searchParams, cookie)) {
|
|
4188
|
+
if (viewing) { sendViewOnly(res); return }
|
|
4189
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4190
|
+
if (!s) return
|
|
4191
|
+
const result = await linkOAuthCallback(config, db, s.session, url.searchParams, cookie, { avatarStore })
|
|
4192
|
+
if (result.linked != null) await activity(deps, s.user, 'access', `linked GitHub account @${result.linked}`)
|
|
4193
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4194
|
+
res.end(); return
|
|
4195
|
+
}
|
|
3135
4196
|
if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
|
|
3136
4197
|
if (viewing) { sendViewOnly(res); return }
|
|
3137
4198
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
@@ -3159,22 +4220,22 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3159
4220
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3160
4221
|
res.end(); return
|
|
3161
4222
|
}
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
4223
|
+
if (path === LINK_LOGIN_PATH) {
|
|
4224
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4225
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4226
|
+
if (!s) return
|
|
4227
|
+
const result = linkLoginRedirect(config, s.session)
|
|
4228
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
4229
|
+
res.end(); return
|
|
4230
|
+
}
|
|
4231
|
+
if (path === PROFILE_PATH) { await handleProfile(req, res, deps, cookie); return }
|
|
4232
|
+
const unlink = /^\/api\/auth\/profile\/accounts\/([a-f\d-]{36})$/iu.exec(path)
|
|
4233
|
+
if (unlink) { await handleUnlinkAccount(req, res, deps, cookie, unlink[1]!); return }
|
|
3165
4234
|
if (path === SESSION_PATH) {
|
|
3166
4235
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3171
|
-
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3172
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3173
|
-
sendJson(res, 200, {
|
|
3174
|
-
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
3175
|
-
csrfToken: s.session.csrfToken,
|
|
3176
|
-
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3177
|
-
}, headers)
|
|
4236
|
+
const { body, headers } = await currentSession(config, db, cookie)
|
|
4237
|
+
if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
4238
|
+
sendJson(res, 200, body, headers)
|
|
3178
4239
|
return
|
|
3179
4240
|
}
|
|
3180
4241
|
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
@@ -3210,12 +4271,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3210
4271
|
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
3211
4272
|
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
3212
4273
|
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
3213
|
-
// switch never serves a stale avatar. Workspace access is required.
|
|
4274
|
+
// switch never serves a stale avatar. Workspace access is required. A user's
|
|
4275
|
+
// is that of the account chosen on their profile; `own` asks for the
|
|
4276
|
+
// sign-in account's. A linked account's is served by its own id.
|
|
3214
4277
|
if (path.startsWith(AVATAR_PREFIX)) {
|
|
3215
4278
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3216
4279
|
const s = await readSession(config, db, cookie, Date.now())
|
|
3217
4280
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3218
|
-
|
|
4281
|
+
const id = path.slice(AVATAR_PREFIX.length)
|
|
4282
|
+
if (AGENT_AVATARS.has(id) && AGENT_AVATAR.test(id)) { await serveAgentAvatar(res, avatarStore, id); return }
|
|
4283
|
+
await serveAvatar(res, avatarStore, id, url.searchParams.has('own') ? id : await db.getAvatarKey(id))
|
|
3219
4284
|
return
|
|
3220
4285
|
}
|
|
3221
4286
|
// Admin: list users (admin-only).
|
|
@@ -3256,8 +4321,57 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3256
4321
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3257
4322
|
await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
|
|
3258
4323
|
}
|
|
4324
|
+
if (path === REPO_CODE_PATH) {
|
|
4325
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4326
|
+
await handleRepositoryCode(res, deps, cookie, url.searchParams); return
|
|
4327
|
+
}
|
|
4328
|
+
if (path === REPO_HISTORY_PATH) {
|
|
4329
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4330
|
+
await handleRepositoryHistory(res, deps, cookie, url.searchParams); return
|
|
4331
|
+
}
|
|
4332
|
+
if (path === REPO_CHANGES_PATH) {
|
|
4333
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4334
|
+
await handleRepositoryChanges(res, deps, cookie, url.searchParams); return
|
|
4335
|
+
}
|
|
4336
|
+
if (path === REPO_TREE_PATH) {
|
|
4337
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4338
|
+
await handleRepositoryTree(res, deps, cookie, url.searchParams); return
|
|
4339
|
+
}
|
|
4340
|
+
if (path === REPO_ISSUES_PATH) {
|
|
4341
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4342
|
+
await handleRepositoryIssues(res, deps, cookie, url.searchParams); return
|
|
4343
|
+
}
|
|
4344
|
+
if (path === REPO_AGENTS_PATH) {
|
|
4345
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4346
|
+
await handleRepositoryAgents(res, deps, cookie, url.searchParams); return
|
|
4347
|
+
}
|
|
4348
|
+
if (path === REPO_ITEM_PATH) {
|
|
4349
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4350
|
+
await handleRepositoryItem(res, deps, cookie, url.searchParams); return
|
|
4351
|
+
}
|
|
4352
|
+
if (path === REPO_PULL_DEPENDENCIES_PATH) {
|
|
4353
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4354
|
+
await handleRepositoryPullDependencies(res, deps, cookie, url.searchParams); return
|
|
4355
|
+
}
|
|
4356
|
+
if (path === REPO_BRANCH_DEPENDENCIES_PATH) {
|
|
4357
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4358
|
+
await handleRepositoryBranchDependencies(res, deps, cookie, url.searchParams); return
|
|
4359
|
+
}
|
|
4360
|
+
if (path === REPO_LOCKFILES_PATH) {
|
|
4361
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4362
|
+
await handleRepositoryLockfiles(res, deps, cookie, url.searchParams); return
|
|
4363
|
+
}
|
|
4364
|
+
if (path === REPO_PULSE_PATH) {
|
|
4365
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4366
|
+
await handleRepositoryPulse(res, deps, cookie, url.searchParams); return
|
|
4367
|
+
}
|
|
4368
|
+
if (path === REPO_PULSE_ITEMS_PATH) {
|
|
4369
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4370
|
+
await handleRepositoryPulseItems(res, deps, cookie, url.searchParams); return
|
|
4371
|
+
}
|
|
3259
4372
|
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
3260
4373
|
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
4374
|
+
if (path === REPO_DEPENDENCY_REVIEW_PATH) { await handleRepositoryDependencyReview(req, res, deps, cookie); return }
|
|
3261
4375
|
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
3262
4376
|
if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
|
|
3263
4377
|
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
@@ -3302,9 +4416,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3302
4416
|
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')
|
|
3303
4417
|
return
|
|
3304
4418
|
}
|
|
3305
|
-
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
4419
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
|
|
3306
4420
|
if (bundleRead) {
|
|
3307
4421
|
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
4422
|
+
if (part === 'comparison') {
|
|
4423
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4424
|
+
await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
|
|
4425
|
+
}
|
|
3308
4426
|
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3309
4427
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3310
4428
|
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
@@ -3445,7 +4563,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3445
4563
|
}
|
|
3446
4564
|
const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
|
|
3447
4565
|
const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [])
|
|
3448
|
-
if (visible) sendJson(res, 200, { review: visible[0] ?? null })
|
|
4566
|
+
if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
|
|
3449
4567
|
return
|
|
3450
4568
|
}
|
|
3451
4569
|
if (path === '/api/npm/reviews/discard') {
|
|
@@ -3475,7 +4593,27 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3475
4593
|
}
|
|
3476
4594
|
const page = await deps.db.listOwnDependencyReviews(s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
3477
4595
|
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
3478
|
-
if (reviews) sendJson(res, 200, { ...page, reviews })
|
|
4596
|
+
if (reviews) sendJson(res, 200, { ...page, reviews: await withFormatted(deps.reviewedDependencies ?? null, s.user.id, reviews) })
|
|
4597
|
+
return
|
|
4598
|
+
}
|
|
4599
|
+
// Those published to a team, of one who reads them there, as its
|
|
4600
|
+
// Dependencies tab lists them, fifty a page.
|
|
4601
|
+
if (path === '/api/npm/reviews/team') {
|
|
4602
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
4603
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
4604
|
+
if (!s) return
|
|
4605
|
+
const id = url.searchParams.get('beforeId'), teamId = url.searchParams.get('teamId'), time = url.searchParams.get('beforeTime')
|
|
4606
|
+
if (!teamId || teamId.length > 100) { sendJson(res, 400, { error: 'bad-team' }); return }
|
|
4607
|
+
if ((time !== null || id !== null) && (time === null || !/^\d+$/u.test(time) || !Number.isSafeInteger(Number(time)) || !id || id.length > 100)) {
|
|
4608
|
+
sendJson(res, 400, { error: 'bad-review-cursor' }); return
|
|
4609
|
+
}
|
|
4610
|
+
const page = await deps.db.listTeamDependencyReviews(teamId, s.user.id, time !== null && id !== null ? { time: Number(time), id } : null)
|
|
4611
|
+
if (!page) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4612
|
+
const reviews = await readableNpmReviews(res, deps, cookie, s.user.id, page.reviews)
|
|
4613
|
+
if (!reviews) return
|
|
4614
|
+
// Its access asked again, as it may have been taken while npm was read.
|
|
4615
|
+
if (!await deps.db.readsTeamDependencyReviews(teamId, s.user.id)) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
4616
|
+
sendJson(res, 200, { ...page, reviews })
|
|
3479
4617
|
return
|
|
3480
4618
|
}
|
|
3481
4619
|
if (path === '/api/npm/reviews') {
|