@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21
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/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1336 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,2043 @@
|
|
|
1
|
+
// HTTP router for the managed auth server. Mounts the GitHub OAuth flow plus
|
|
2
|
+
// the session endpoints + the mode probe; there is NO api/sync here (a managed
|
|
3
|
+
// server doesn't speak the e2e sync protocol yet). Every handler is async; the
|
|
4
|
+
// boot's `track()` keeps in-flight requests drainable on shutdown.
|
|
5
|
+
//
|
|
6
|
+
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
|
|
7
|
+
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
8
|
+
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
9
|
+
// GET /api/auth/session → { user, csrfToken } | 401
|
|
10
|
+
// GET /api/teams → the current user's teams + their reports and bundles | 401
|
|
11
|
+
// GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
|
|
12
|
+
// GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
|
|
13
|
+
// GET /api/teams/<id>/feed → SSE notifications for visible triage and comments | 401/404
|
|
14
|
+
// GET /api/reports/<id> → admin/manager report preview | 401/403/404
|
|
15
|
+
// POST /api/reports/query → admin/manager batch preview, with repository metadata | 400/401/403/404/503
|
|
16
|
+
// GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
|
|
17
|
+
// POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
|
|
18
|
+
// GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
|
|
19
|
+
// GET /api/avatar/<id> → cached avatar bytes by user id | 401/404
|
|
20
|
+
// GET /api/admin/users → admin-only user list | 401/403
|
|
21
|
+
// GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
|
|
22
|
+
// POST /api/admin/set-role → admin sets another user's role | 401/403/404
|
|
23
|
+
// GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
|
|
24
|
+
// POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
|
|
25
|
+
// GET /api/admin/repositories/impact → admin attached data summary
|
|
26
|
+
// POST /api/admin/repositories/remove → admin permanently removes a repo
|
|
27
|
+
// GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
|
|
28
|
+
// POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
|
|
29
|
+
// GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
|
|
30
|
+
// DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
|
|
31
|
+
// POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
|
|
32
|
+
// GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
|
|
33
|
+
// GET /api/bundles/<id>/advisories → published npm advisories (security access) | 401/403/404
|
|
34
|
+
// GET /api/bundles/<id>/download → authorized original upload | 401/404
|
|
35
|
+
// GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
|
|
36
|
+
// POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
|
|
37
|
+
// GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
|
|
38
|
+
// DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
|
|
39
|
+
// POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
|
|
40
|
+
// GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
|
|
41
|
+
// POST /api/admin/teams → admin creates a team | 401/403/409
|
|
42
|
+
// POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
|
|
43
|
+
// POST /api/admin/teams/delete → admin deletes a team | 401/403/404
|
|
44
|
+
// POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
|
|
45
|
+
// POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
|
|
46
|
+
// POST /api/auth/logout → same-origin + CSRF, drops the session
|
|
47
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
48
|
+
import { pipeline } from 'node:stream/promises'
|
|
49
|
+
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
50
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, putUploadPart, readUpload, validUploadPart } from './uploads.ts'
|
|
51
|
+
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
52
|
+
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
53
|
+
import type { BundleStore } from './bundle-store.ts'
|
|
54
|
+
import { Buffer } from 'node:buffer'
|
|
55
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
56
|
+
import { teamCatalogRevision } from './team-catalog.ts'
|
|
57
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
58
|
+
import type { BlobStore } from './blob-store.ts'
|
|
59
|
+
import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
|
|
60
|
+
import type { ManagedConfig } from './config.ts'
|
|
61
|
+
import type { ManagedDb, ManagedSession, StoredUser, TriageEventRow } from './db.ts'
|
|
62
|
+
import type { OriginGate } from '../server-common/origin.ts'
|
|
63
|
+
import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
64
|
+
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
65
|
+
import { filterReportData } from '../common/managed/report-filter.ts'
|
|
66
|
+
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
67
|
+
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
68
|
+
import { reportRepoGithub } from '../report/index.js'
|
|
69
|
+
import { loadManagedFindings, readManagedReport } from '../common/managed/report-content.ts'
|
|
70
|
+
import type { ReportSourcesCache } from './report-sources.ts'
|
|
71
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
72
|
+
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
73
|
+
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
74
|
+
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName } from './github-app.ts'
|
|
75
|
+
import type { ConnectedRepo } from './github-app.ts'
|
|
76
|
+
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
77
|
+
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
78
|
+
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
79
|
+
import { clearCookie, endSession, readSession } from './session.ts'
|
|
80
|
+
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
81
|
+
import { acceptsReportMetadata } from './report-response.ts'
|
|
82
|
+
import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
83
|
+
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
84
|
+
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
85
|
+
import { sendJson, writeResponse } from './http-response.ts'
|
|
86
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
87
|
+
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
88
|
+
import { serveUserTeamFeed } from './team-feed.ts'
|
|
89
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
90
|
+
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
91
|
+
|
|
92
|
+
const SESSION_PATH = '/api/auth/session'
|
|
93
|
+
const AVATAR_PREFIX = '/api/avatar/'
|
|
94
|
+
const LOGOUT_PATH = '/api/auth/logout'
|
|
95
|
+
const ADMIN_USERS_PATH = '/api/admin/users'
|
|
96
|
+
const ADMIN_HISTORY_PATH = '/api/admin/history'
|
|
97
|
+
const ADMIN_SCAN_MODELS_PATH = '/api/admin/scan/models'
|
|
98
|
+
const SET_ROLE_PATH = '/api/admin/set-role'
|
|
99
|
+
const ADMIN_REPOS_PATH = '/api/admin/repositories'
|
|
100
|
+
const SELECT_REPO_PATH = '/api/admin/repositories/select'
|
|
101
|
+
const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
|
|
102
|
+
const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
|
|
103
|
+
const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
|
|
104
|
+
const ADMIN_REPORTS_PATH = '/api/admin/reports'
|
|
105
|
+
const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
|
|
106
|
+
const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
|
|
107
|
+
const REPORT_PREFIX = '/api/admin/reports/'
|
|
108
|
+
const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
|
|
109
|
+
const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
|
|
110
|
+
const BUNDLE_PREFIX = '/api/admin/bundles/'
|
|
111
|
+
const MY_TEAMS_PATH = '/api/teams'
|
|
112
|
+
const MY_REPORT_PREFIX = '/api/reports/'
|
|
113
|
+
const MY_REPORT_TRIAGE_SUFFIX = '/triage'
|
|
114
|
+
const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
|
|
115
|
+
const ADMIN_TEAMS_PATH = '/api/admin/teams'
|
|
116
|
+
const TEAM_DELETE_PATH = '/api/admin/teams/delete'
|
|
117
|
+
const TEAM_RENAME_PATH = '/api/admin/teams/rename'
|
|
118
|
+
const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
|
|
119
|
+
const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
|
|
120
|
+
const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
|
|
121
|
+
const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
|
|
122
|
+
const MAX_TEAM_NAME = 100
|
|
123
|
+
|
|
124
|
+
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
|
|
125
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
126
|
+
if (!s) return
|
|
127
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
|
|
128
|
+
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
129
|
+
const urls = storedFixUrls(await deps.db.listTriage(ids))
|
|
130
|
+
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
131
|
+
const fixes = await lookupFixes(deps.config, deps.db, snapshot, urls)
|
|
132
|
+
// Cold report reads, token refresh and upstream batches can outlive changes
|
|
133
|
+
// to security/links, team grants, publication, sessions or the Fix links.
|
|
134
|
+
const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
|
|
135
|
+
if (await readWorkspaceSession(res, deps, cookie) == null) return
|
|
136
|
+
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
137
|
+
if (JSON.stringify(currentUrls) !== JSON.stringify(urls)) {
|
|
138
|
+
sendJson(res, 404, { error: 'workspace-changed' }); return
|
|
139
|
+
}
|
|
140
|
+
sendJson(res, 200, { fixes })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['kind'], action: string, context: Pick<ActivityInput, 'repo' | 'reportId' | 'bundleId' | 'report' | 'repoId' | 'repoDirectory'> = {}): Promise<void> {
|
|
144
|
+
return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
|
|
148
|
+
if (repoId == null) return null
|
|
149
|
+
return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Workspace history is authorized BEFORE filtering, counting, or paging.
|
|
153
|
+
// Managers get content activity for their currently accessible team content;
|
|
154
|
+
// even the displayed report context must come from those accessible reports.
|
|
155
|
+
async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
|
|
156
|
+
const s = await readManageSession(res, deps, cookie)
|
|
157
|
+
if (s == null) return
|
|
158
|
+
const page = Number(query.get('page') ?? 1)
|
|
159
|
+
const limit = Number(query.get('limit') ?? 100)
|
|
160
|
+
const kind = query.get('kind') ?? 'all'
|
|
161
|
+
const search = (query.get('q') ?? '').trim()
|
|
162
|
+
const repo = query.get('repo') ?? ''
|
|
163
|
+
const actor = query.get('actor') ?? ''
|
|
164
|
+
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|
|
165
|
+
|| !['all', 'triage', 'upload', 'visibility', 'access', 'repository', 'delete'].includes(kind) || search.length > 500
|
|
166
|
+
|| repo.length > 500 || actor.length > 500) {
|
|
167
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
168
|
+
}
|
|
169
|
+
let contexts: ActivityContext[] | null = null
|
|
170
|
+
if (s.user.role !== 'admin') {
|
|
171
|
+
const findings = new Map<string, ActivityContext>()
|
|
172
|
+
const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
|
|
173
|
+
for (const report of reports) {
|
|
174
|
+
for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
|
|
175
|
+
if (!findings.has(finding)) findings.set(finding, { finding, ...report })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
contexts = [...findings.values()]
|
|
179
|
+
}
|
|
180
|
+
sendJson(res, 200, await deps.db.listActivity({ page, limit, kind, query: search, repo, actor, contexts, userId: s.user.id }))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ManagedHttpDeps {
|
|
184
|
+
config: ManagedConfig
|
|
185
|
+
db: ManagedDb
|
|
186
|
+
avatarStore: AvatarStore
|
|
187
|
+
reportStore: BlobStore
|
|
188
|
+
bundleStore: BundleStore
|
|
189
|
+
bundleCache?: BundleCache
|
|
190
|
+
reportSourcesCache?: ReportSourcesCache
|
|
191
|
+
uploadStore?: BlobStore
|
|
192
|
+
originGate: OriginGate
|
|
193
|
+
isShuttingDown: () => boolean
|
|
194
|
+
track: (p: Promise<unknown>) => void
|
|
195
|
+
// Combined boot delegates unmatched paths to e2e and overrides discovery.
|
|
196
|
+
serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
|
|
197
|
+
next?: Handler
|
|
198
|
+
serverInfo?: ServerInfo
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
202
|
+
|
|
203
|
+
function send405(res: ServerResponse, allow: string): void {
|
|
204
|
+
sendJson(res, 405, { error: 'method-not-allowed' }, { allow })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function firstHeader(v: string | string[] | undefined): string | null {
|
|
208
|
+
if (typeof v === 'string') return v
|
|
209
|
+
if (Array.isArray(v) && v.length > 0) return v[0] ?? null
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
|
|
214
|
+
const avatar = await avatarStore.get(userId)
|
|
215
|
+
if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
'content-type': avatar.contentType,
|
|
218
|
+
'content-length': String(avatar.bytes.length),
|
|
219
|
+
'x-content-type-options': 'nosniff',
|
|
220
|
+
'cache-control': 'no-store',
|
|
221
|
+
})
|
|
222
|
+
res.end(avatar.bytes)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const MAX_JSON_BODY_BYTES = 4096
|
|
226
|
+
|
|
227
|
+
// Buffer the request body, aborting (and throwing 'too-large') once it exceeds
|
|
228
|
+
// `maxBytes` — bounds memory on the JSON mutations (small) and the report
|
|
229
|
+
// upload (config.maxReportBytes) alike.
|
|
230
|
+
async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
231
|
+
const chunks: Buffer[] = []
|
|
232
|
+
let size = 0
|
|
233
|
+
for await (const chunk of req) {
|
|
234
|
+
const c = chunk as Buffer
|
|
235
|
+
size += c.length
|
|
236
|
+
if (size > maxBytes) { req.destroy(); throw new Error('too-large') }
|
|
237
|
+
chunks.push(c)
|
|
238
|
+
}
|
|
239
|
+
return Buffer.concat(chunks)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: string, kind: UploadKind) {
|
|
243
|
+
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
244
|
+
if (req.headers['x-upload-id'] == null) return readBodyBytes(req, maxBytes)
|
|
245
|
+
if (!deps.uploadStore) throw new Error('bad-upload')
|
|
246
|
+
return readUpload(deps.uploadStore, req, session, kind, maxBytes)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
250
|
+
if (req.method !== 'POST') { send405(res, 'POST'); return }
|
|
251
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
252
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
253
|
+
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
254
|
+
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)\/(0|[1-9][0-9]*)$/u.exec(path)
|
|
255
|
+
if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
256
|
+
const id = match[2]!, index = Number(match[3]), kind = match[1] as UploadKind
|
|
257
|
+
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
258
|
+
if (!validUploadPart(id, index, maxBytes)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
259
|
+
let bytes
|
|
260
|
+
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
261
|
+
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
262
|
+
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
263
|
+
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
264
|
+
sendJson(res, 200, { ok: true })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `maxBytes` overrides the small default for the one endpoint whose JSON body
|
|
268
|
+
// legitimately grows (triage entry batches).
|
|
269
|
+
async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<unknown> {
|
|
270
|
+
const buf = await readBodyBytes(req, maxBytes)
|
|
271
|
+
return JSON.parse(buf.toString('utf8') || 'null')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Validate a mutation: same-origin gate + an authenticated session whose
|
|
275
|
+
// double-submit CSRF token matches the X-CSRF-Token header. Returns the session,
|
|
276
|
+
// or null after having already sent the 401/403.
|
|
277
|
+
async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
278
|
+
if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return null }
|
|
279
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
280
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
281
|
+
const csrf = firstHeader(req.headers['x-csrf-token'])
|
|
282
|
+
if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
|
|
283
|
+
if (csrf !== s.session.csrfToken) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
|
|
284
|
+
return s
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// POST /api/auth/logout — drop the session (same-origin + CSRF).
|
|
288
|
+
async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
289
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
290
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
291
|
+
if (s == null) return
|
|
292
|
+
await endSession(deps.config, deps.db, cookie)
|
|
293
|
+
res.writeHead(204, { 'set-cookie': clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure), 'cache-control': 'no-store' })
|
|
294
|
+
res.end()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
|
|
298
|
+
// refused for the caller's own id so an admin can't drop their own admin (keeps
|
|
299
|
+
// at least one admin).
|
|
300
|
+
async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
301
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
302
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
303
|
+
if (s == null) return
|
|
304
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
305
|
+
let body: unknown
|
|
306
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
307
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
308
|
+
const role = (body as { role?: unknown } | null)?.role
|
|
309
|
+
if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
310
|
+
if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
|
|
311
|
+
const target = (await deps.db.listUsers()).find(user => user.id === userId)
|
|
312
|
+
// A caller can hold the body open while their admin access is revoked.
|
|
313
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
314
|
+
const ok = await deps.db.setUserRole(userId, role)
|
|
315
|
+
if (!ok) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
316
|
+
if (target?.role !== role) await activity(deps, s.user, 'access', `changed ${target?.login ?? userId}'s role to ${role}`)
|
|
317
|
+
sendJson(res, 200, { ok: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// admin OR manage — the roles allowed into the management pages (repositories,
|
|
321
|
+
// reports), matching the sidebar account-menu gate. Sends 403 + returns false
|
|
322
|
+
// otherwise.
|
|
323
|
+
function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
|
|
324
|
+
if (user.role === 'admin' || user.role === 'manage') return true
|
|
325
|
+
sendJson(res, 403, { error: 'forbidden' })
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A read endpoint open to admin|manage: resolve the session (401 if absent),
|
|
330
|
+
// then gate on the role (403). Returns the session, or null after the response
|
|
331
|
+
// was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
|
|
332
|
+
async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
333
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
334
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
335
|
+
if (!requireManageRole(res, s.user)) return null
|
|
336
|
+
return s
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
340
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
341
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
342
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
343
|
+
return s
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
347
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
348
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
349
|
+
if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
350
|
+
return s
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
354
|
+
// levels. Names are intentionally absent; the client derives them from ids.
|
|
355
|
+
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
356
|
+
const s = await readManageSession(res, deps, cookie)
|
|
357
|
+
if (s == null) return
|
|
358
|
+
sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// GET /api/admin/repositories — the connected repositories by default. The
|
|
362
|
+
// potentially large GitHub discovery lists are opt-in (`scope=installed` or
|
|
363
|
+
// `scope=public`). Return the complete catalogue for local search and organization
|
|
364
|
+
// filtering. Admin-only. No CSRF.
|
|
365
|
+
async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
|
|
366
|
+
if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
|
|
367
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
368
|
+
if (s == null) return
|
|
369
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
370
|
+
// Keep the original unparameterized response shape for older clients. The
|
|
371
|
+
// admin UI always sends an explicit scope, which opts into the connected-first
|
|
372
|
+
// discovery flow below.
|
|
373
|
+
if ([...url.searchParams.keys()].length === 0) {
|
|
374
|
+
const token = await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
|
|
375
|
+
const listing = await collectRepos(deps.config, token)
|
|
376
|
+
const selected = new Set((await deps.db.listSelectedRepos()).map((repo) => repo.repoId))
|
|
377
|
+
sendJson(res, 200, {
|
|
378
|
+
installUrl: installUrl(deps.config),
|
|
379
|
+
repositories: listing.repositories.map((repo) => ({
|
|
380
|
+
id: repo.id, fullName: repo.fullName, private: repo.private, visibility: repo.visibility, htmlUrl: repo.htmlUrl,
|
|
381
|
+
installed: repo.installationId != null, selected: selected.has(repo.id),
|
|
382
|
+
})),
|
|
383
|
+
tokenMissing: listing.tokenMissing,
|
|
384
|
+
})
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const scope = url.searchParams.get('scope') ?? 'connected'
|
|
388
|
+
if (scope !== 'connected' && scope !== 'installed' && scope !== 'public') { sendJson(res, 400, { error: 'bad-scope' }); return }
|
|
389
|
+
const selectedRows = await deps.db.listSelectedRepos()
|
|
390
|
+
const allRows = scope === 'connected' ? await deps.db.listAllRepos() : []
|
|
391
|
+
const selected = new Set(selectedRows.map((r) => r.repoId))
|
|
392
|
+
let tokenMissing = false
|
|
393
|
+
let repositories
|
|
394
|
+
if (scope === 'connected') {
|
|
395
|
+
repositories = allRows.map((r) => ({
|
|
396
|
+
id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
|
|
397
|
+
installed: r.installationId != null, selected: r.active, active: r.active,
|
|
398
|
+
}))
|
|
399
|
+
} else {
|
|
400
|
+
const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
|
|
401
|
+
const token = showAll ? null : await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
|
|
402
|
+
let listing
|
|
403
|
+
try {
|
|
404
|
+
listing = await discovery.list(scope, s.user.id, token, showAll, url.searchParams.get('refresh') === 'true')
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
407
|
+
sendJson(res, 502, { error: err.message })
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
tokenMissing = listing.tokenMissing
|
|
411
|
+
repositories = listing.repositories
|
|
412
|
+
.map((r) => ({
|
|
413
|
+
id: r.id, fullName: r.fullName, private: r.private, visibility: r.visibility, htmlUrl: r.htmlUrl,
|
|
414
|
+
installed: r.installationId != null, selected: selected.has(r.id),
|
|
415
|
+
}))
|
|
416
|
+
}
|
|
417
|
+
repositories.sort((a, b) => a.fullName.localeCompare(b.fullName))
|
|
418
|
+
const total = repositories.length
|
|
419
|
+
sendJson(res, 200, {
|
|
420
|
+
installUrl: installUrl(deps.config),
|
|
421
|
+
canAddAnyPublicRepository: canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id)),
|
|
422
|
+
repositories,
|
|
423
|
+
connectedCount: selectedRows.length,
|
|
424
|
+
inactiveCount: allRows.filter((r) => !r.active).length,
|
|
425
|
+
total,
|
|
426
|
+
tokenMissing,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// The select half of the toggle: re-list the caller's reachable repos to VERIFY
|
|
431
|
+
// access (never trust a client-supplied id) and capture the server-derived read
|
|
432
|
+
// context (installation id, default branch). A PRIVATE repo with no installation
|
|
433
|
+
// can't be read server-side → 409. Stores via selectRepo (upsert).
|
|
434
|
+
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number): Promise<void> {
|
|
435
|
+
if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
436
|
+
const userId = user.id
|
|
437
|
+
const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
|
|
438
|
+
const { repositories } = await collectRepos(deps.config, token)
|
|
439
|
+
let repo = repositories.find((r) => r.id === repoId)
|
|
440
|
+
// A WHITEHAT addition may not appear in /user/repos. Revalidate the stored
|
|
441
|
+
// canonical name when reactivating it, with the same permission gates.
|
|
442
|
+
if (repo == null && canAddAnyPublicRepository(user, await deps.db.getUserGithubId(userId))) {
|
|
443
|
+
const stored = (await deps.db.listAllRepos()).find(r => r.repoId === repoId)
|
|
444
|
+
if (stored && !stored.private && stored.installationId == null) {
|
|
445
|
+
try { repo = await fetchPublicRepository(stored.fullName) } catch (err) {
|
|
446
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
447
|
+
sendJson(res, err.status, { error: err.message }); return
|
|
448
|
+
}
|
|
449
|
+
if (repo.id !== repoId) { sendJson(res, 409, { error: 'repo-identity-changed' }); return }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (repo == null) { sendJson(res, 404, { error: 'repo-not-accessible' }); return }
|
|
453
|
+
// (0) Installed or explicitly public. For (3a), an admin may choose any App
|
|
454
|
+
// installation; for (3b), /user/repos is the public involvement evidence.
|
|
455
|
+
if (repo.installationId == null) {
|
|
456
|
+
if (repo.private || repo.visibility !== 'public') { sendJson(res, 409, { error: 'repo-not-readable' }); return }
|
|
457
|
+
if (!passesPublicRepositorySafeguard(await deps.db.getUserGithubId(userId), repositories.some(r => r.id === repoId))) {
|
|
458
|
+
sendJson(res, 403, { error: 'forbidden' }); return
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
await connectRepository(res, deps, user, repo)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repo: ConnectedRepo): Promise<void> {
|
|
465
|
+
const alreadySelected = (await deps.db.listSelectedRepos()).some(row => row.repoId === repo.id)
|
|
466
|
+
await deps.db.selectRepo({
|
|
467
|
+
repoId: repo.id, fullName: repo.fullName, private: repo.private,
|
|
468
|
+
installationId: repo.installationId, defaultBranch: repo.defaultBranch,
|
|
469
|
+
htmlUrl: repo.htmlUrl, addedBy: user.id,
|
|
470
|
+
}, Date.now())
|
|
471
|
+
if (!alreadySelected) await activity(deps, user, 'repository', 'connected a repository', { repo: repo.fullName })
|
|
472
|
+
sendJson(res, 200, { ok: true, selected: true })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Separate from ordinary discovery: WHITEHAT bypasses only public involvement.
|
|
476
|
+
// Authentication, server admin permission, origin and CSRF remain mandatory.
|
|
477
|
+
async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
478
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
479
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
480
|
+
if (s == null) return
|
|
481
|
+
if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
482
|
+
let body: unknown
|
|
483
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
484
|
+
const fullName = publicRepositoryName((body as { repository?: unknown } | null)?.repository)
|
|
485
|
+
if (fullName == null) { sendJson(res, 400, { error: 'bad-repository' }); return }
|
|
486
|
+
let repo
|
|
487
|
+
try { repo = await fetchPublicRepository(fullName) } catch (err) {
|
|
488
|
+
if (!(err instanceof GithubApiError)) throw err
|
|
489
|
+
sendJson(res, err.status, { error: err.message }); return
|
|
490
|
+
}
|
|
491
|
+
// A GitHub lookup can outlive a role change or logout.
|
|
492
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
493
|
+
// Only public access was verified. Keep its null installation context rather
|
|
494
|
+
// than restoring a stored App installation that may no longer cover this repo.
|
|
495
|
+
await connectRepository(res, deps, s.user, repo)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// POST /api/admin/repositories/select — an admin toggles whether a repo is
|
|
499
|
+
// active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
|
|
500
|
+
// selected }. selected:true verifies + records the read context; selected:false
|
|
501
|
+
// deactivates the row while retaining its reports, bundles, and metadata.
|
|
502
|
+
async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
503
|
+
if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
|
|
504
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
505
|
+
if (s == null) return
|
|
506
|
+
let body: unknown
|
|
507
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
508
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
509
|
+
const selected = (body as { selected?: unknown } | null)?.selected
|
|
510
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof selected !== 'boolean') {
|
|
511
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
512
|
+
}
|
|
513
|
+
if (!selected) {
|
|
514
|
+
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
515
|
+
await deps.db.deactivateRepo(repoId)
|
|
516
|
+
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
517
|
+
sendJson(res, 200, { ok: true, selected: false }); return
|
|
518
|
+
}
|
|
519
|
+
await selectRepository(res, deps, s.user, repoId)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
523
|
+
const ids = new Set<string>()
|
|
524
|
+
for (const report of reports) {
|
|
525
|
+
const bytes = await deps.reportStore.get(report.id)
|
|
526
|
+
if (bytes == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unavailable`)
|
|
527
|
+
const parsed = await loadManagedFindings(bytes.toString('utf8'), report.filename)
|
|
528
|
+
if (parsed == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unreadable`)
|
|
529
|
+
for (const finding of parsed.findings) {
|
|
530
|
+
const id = (finding as { id?: unknown })?.id
|
|
531
|
+
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return ids
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
|
|
538
|
+
const allReports = await deps.db.listReports()
|
|
539
|
+
const reports = allReports.filter((report) => report.repoId === repoId)
|
|
540
|
+
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
541
|
+
const triageIds = await repositoryExclusiveTriageIds(deps, reports, allReports.filter((report) => report.repoId !== repoId))
|
|
542
|
+
return {
|
|
543
|
+
reports: reports.map((report) => ({ id: report.id, filename: report.filename, repoDirectory: report.repoDirectory })),
|
|
544
|
+
bundles,
|
|
545
|
+
triageCount: triageIds.length,
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[]): Promise<string[]> {
|
|
550
|
+
const targetIds = await repositoryFindingIds(deps, reports)
|
|
551
|
+
const triage = await deps.db.listTriage([...targetIds])
|
|
552
|
+
const commentIds = await deps.db.listCommentedFindingIds([...targetIds])
|
|
553
|
+
const annotatedIds = new Set([...triage.map(entry => entry.findingId), ...commentIds])
|
|
554
|
+
if (annotatedIds.size === 0) return []
|
|
555
|
+
// Only annotated findings need an overlap check.
|
|
556
|
+
// TODO(managed): Persist finding IDs per report at upload time and maintain
|
|
557
|
+
// the index on report deletion. Use it for repository impact and triage
|
|
558
|
+
// cleanup so overlap checks do not fetch and parse every other report blob.
|
|
559
|
+
// Deferred for production; the preview still performs the blob scan below.
|
|
560
|
+
const otherIds = await repositoryFindingIds(deps, otherReports)
|
|
561
|
+
return [...annotatedIds].filter((id) => !otherIds.has(id))
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// GET /api/admin/repositories/impact — show the data a permanent repository
|
|
565
|
+
// removal would destroy. This is intentionally separate from the repository
|
|
566
|
+
// list so the normal page stays cheap even with many reports.
|
|
567
|
+
async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
568
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
569
|
+
if (s == null) return
|
|
570
|
+
const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
|
|
571
|
+
if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
572
|
+
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
573
|
+
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
574
|
+
sendJson(res, 200, { repoId, fullName: repo.fullName, ...await repositoryImpact(deps, repoId) })
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// POST /api/admin/repositories/remove — permanently remove a repository and
|
|
578
|
+
// attached reports/bundles. The exact name + explicit acknowledgement are
|
|
579
|
+
// checked server-side too; the UI's dialog is a usability guard, not the policy.
|
|
580
|
+
async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
581
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
582
|
+
if (s == null) return
|
|
583
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
584
|
+
let body: unknown
|
|
585
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
586
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
587
|
+
const fullName = (body as { fullName?: unknown } | null)?.fullName
|
|
588
|
+
const acknowledge = (body as { acknowledge?: unknown } | null)?.acknowledge
|
|
589
|
+
const deleteTriage = (body as { deleteTriage?: unknown } | null)?.deleteTriage
|
|
590
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof fullName !== 'string' || acknowledge !== true || typeof deleteTriage !== 'boolean') {
|
|
591
|
+
sendJson(res, 400, { error: 'confirmation-required' }); return
|
|
592
|
+
}
|
|
593
|
+
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
594
|
+
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
595
|
+
if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
|
|
596
|
+
const reports = await deps.db.listReportsForRepo(repoId)
|
|
597
|
+
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
598
|
+
const triageIds = deleteTriage
|
|
599
|
+
? await repositoryExclusiveTriageIds(deps, reports, (await deps.db.listReports()).filter((report) => report.repoId !== repoId))
|
|
600
|
+
: []
|
|
601
|
+
const deletedReports = await deps.db.deleteReportsForRepo(repoId)
|
|
602
|
+
const deletedBundles = await deps.db.deleteBundlesForRepo(repoId)
|
|
603
|
+
// Remove metadata first so a blob-store failure leaves an orphaned blob for
|
|
604
|
+
// later cleanup, rather than a live row pointing at missing report data.
|
|
605
|
+
for (const report of reports) {
|
|
606
|
+
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
607
|
+
await deps.reportStore.delete(report.id).catch(() => {})
|
|
608
|
+
}
|
|
609
|
+
for (const bundle of bundles) {
|
|
610
|
+
// Cache cleanup must not interrupt triage/repository removal after the
|
|
611
|
+
// report rows needed to reconstruct exclusive finding IDs are gone.
|
|
612
|
+
await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
613
|
+
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
614
|
+
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
615
|
+
}
|
|
616
|
+
const deletedTriage = await deps.db.deleteTriage(triageIds)
|
|
617
|
+
await deps.db.deleteRepo(repoId)
|
|
618
|
+
await activity(deps, s.user, 'delete', `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)`, { repo: repo.fullName })
|
|
619
|
+
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Strip a client-supplied upload filename to a safe display string. The bytes
|
|
623
|
+
// are keyed by a server uuid. This name is used for display, Content-Disposition,
|
|
624
|
+
// and format detection: decode URLs, remove controls/paths, and cap the basename
|
|
625
|
+
// without discarding the extension that the report reader needs.
|
|
626
|
+
// Falls back to `fallback` when nothing usable remains.
|
|
627
|
+
function sanitizeFilename(raw: string | null, fallback: string): string {
|
|
628
|
+
if (raw == null || raw === '') return fallback
|
|
629
|
+
let decoded = raw
|
|
630
|
+
try { decoded = decodeURIComponent(raw) } catch { decoded = raw }
|
|
631
|
+
// Build the cleaned name char-by-char: drop control chars, fold path
|
|
632
|
+
// separators to '_' (avoids a control-character regex).
|
|
633
|
+
let cleaned = ''
|
|
634
|
+
for (const ch of decoded) {
|
|
635
|
+
const code = ch.codePointAt(0) ?? 0
|
|
636
|
+
if (code < 0x20 || code === 0x7f) continue
|
|
637
|
+
cleaned += ch === '/' || ch === '\\' ? '_' : ch
|
|
638
|
+
}
|
|
639
|
+
cleaned = cleaned.trim()
|
|
640
|
+
if (cleaned.length <= 200) return cleaned || fallback
|
|
641
|
+
const dot = cleaned.lastIndexOf('.')
|
|
642
|
+
const suffix = dot > 0 ? cleaned.slice(dot) : ''
|
|
643
|
+
// An extension that alone exceeds the cap cannot fit; ordinary extensions
|
|
644
|
+
// (including case-sensitive display names such as .CSV) remain unchanged.
|
|
645
|
+
const extension = suffix.length < 200 ? suffix : ''
|
|
646
|
+
return cleaned.slice(0, 200 - extension.length) + extension
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The selected repos a report / bundle can be linked to, for the upload UI's
|
|
650
|
+
// repo picker. Minimal shape (id + full name).
|
|
651
|
+
function selectableRepos(repos: { repoId: number; fullName: string }[]): { repoId: number; fullName: string }[] {
|
|
652
|
+
return repos.map((r) => ({ repoId: r.repoId, fullName: r.fullName }))
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Resolve the optional X-Repo-Id upload header to a selected repo id, or null
|
|
656
|
+
// when absent. Validates the id is currently in the selected-repos set (so the
|
|
657
|
+
// FK can't dangle); on a bad / unknown id sends 400 and returns { ok: false }.
|
|
658
|
+
async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps): Promise<{ ok: true; repoId: number | null } | { ok: false }> {
|
|
659
|
+
const raw = firstHeader(req.headers['x-repo-id'])
|
|
660
|
+
if (raw == null || raw === '') return { ok: true, repoId: null }
|
|
661
|
+
const n = Number(raw)
|
|
662
|
+
if (!Number.isSafeInteger(n)) { sendJson(res, 400, { error: 'bad-repo' }); return { ok: false } }
|
|
663
|
+
const selected = await deps.db.listSelectedRepos()
|
|
664
|
+
if (!selected.some((r) => r.repoId === n)) { sendJson(res, 400, { error: 'repo-not-selected' }); return { ok: false } }
|
|
665
|
+
return { ok: true, repoId: n }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// True iff `repoId` may be linked to a report / bundle: null (detach) or a
|
|
669
|
+
// currently-selected repo id (so the FK can't dangle and you can only attach to
|
|
670
|
+
// a managed repo).
|
|
671
|
+
async function repoIdAllowed(deps: ManagedHttpDeps, repoId: unknown): Promise<boolean> {
|
|
672
|
+
if (repoId == null) return true
|
|
673
|
+
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) return false
|
|
674
|
+
return (await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
678
|
+
if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
|
|
679
|
+
const report = await deps.db.getReport(reportId)
|
|
680
|
+
return report != null && (user.role === 'admin' || report.repoId === null || await deps.db.userCanReadReport(user.id, reportId))
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// POST /api/admin/reports/set-repo — attach / detach a stored report's repo
|
|
684
|
+
// + directory link. Mutation: same-origin + CSRF, admin|manage. Body
|
|
685
|
+
// { reportId, repoId, directory }, where repoId is null (detach) or a
|
|
686
|
+
// currently-selected repo id. Directory is relative to the repository root.
|
|
687
|
+
async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
688
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
689
|
+
if (s == null) return
|
|
690
|
+
let body: unknown
|
|
691
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
692
|
+
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
693
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
694
|
+
const directory = (body as { directory?: unknown } | null)?.directory ?? ''
|
|
695
|
+
if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
696
|
+
if (!(await canViewReport(deps, s.user, reportId))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
697
|
+
const report = await deps.db.getReport(reportId)
|
|
698
|
+
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
699
|
+
if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
700
|
+
if (report.repoEmbedded) { sendJson(res, 409, { error: 'repo-in-report' }); return }
|
|
701
|
+
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
702
|
+
const normalized = normalizeTeamPath(directory)
|
|
703
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
704
|
+
if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, normalized.path ?? ''))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
705
|
+
if (!(await deps.db.setReportRepo(reportId, repoId as number | null, normalized.path ?? ''))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
706
|
+
if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
|
|
707
|
+
await activity(deps, s.user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, (repoId as number | null) ?? report.repoId) })
|
|
708
|
+
}
|
|
709
|
+
sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// POST /api/admin/reports/set-visible — publish or hide a stored report. New
|
|
713
|
+
// reports start hidden so an admin can inspect the parsed metadata first.
|
|
714
|
+
async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
715
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
716
|
+
if (s == null) return
|
|
717
|
+
let body: unknown
|
|
718
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
719
|
+
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
720
|
+
const visible = (body as { visible?: unknown } | null)?.visible
|
|
721
|
+
if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
722
|
+
if (!(await canViewReport(deps, s.user, reportId))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
723
|
+
if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
724
|
+
const report = await deps.db.getReport(reportId)
|
|
725
|
+
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
726
|
+
if (!(await deps.db.setReportVisible(reportId, visible))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
727
|
+
if (report.visible !== visible) await activity(deps, s.user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
728
|
+
sendJson(res, 200, { ok: true, visible })
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
732
|
+
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
733
|
+
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
734
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
735
|
+
if (s == null) return
|
|
736
|
+
let body: unknown
|
|
737
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
738
|
+
const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
|
|
739
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
740
|
+
const normalized = normalizeTeamPath((body as { directory?: unknown } | null)?.directory ?? '')
|
|
741
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
742
|
+
const directory = repoId == null ? '' : normalized.path ?? ''
|
|
743
|
+
if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
744
|
+
if (!(await canAccessBundle(deps, s.user, bundleId))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
745
|
+
const bundle = await deps.db.getBundle(bundleId)
|
|
746
|
+
if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
747
|
+
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
748
|
+
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
749
|
+
if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, directory))) {
|
|
750
|
+
sendJson(res, 403, { error: 'forbidden' }); return
|
|
751
|
+
}
|
|
752
|
+
if (!(await deps.db.setBundleRepo(bundleId, repoId as number | null, directory))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
753
|
+
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, s.user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, (repoId as number | null) ?? bundle.repoId) })
|
|
754
|
+
sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// GET /api/admin/reports — the uploaded reports for the "Manage reports" page.
|
|
758
|
+
// Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
|
|
759
|
+
// `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
|
|
760
|
+
// the upload repo picker.
|
|
761
|
+
async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
762
|
+
const s = await readManageSession(res, deps, cookie)
|
|
763
|
+
if (s == null) return
|
|
764
|
+
sendJson(res, 200, {
|
|
765
|
+
reports: await Promise.all((await deps.db.listReports(s.user.role === 'admin' ? undefined : s.user.id)).map(async report => {
|
|
766
|
+
const bundleAllowed = report.bundleId != null && await canAccessBundle(deps, s.user, report.bundleId)
|
|
767
|
+
return { ...report, canChangeRepo: await canChangeReportRepo(deps, s.user, report.id),
|
|
768
|
+
bundleId: bundleAllowed ? report.bundleId : null, bundleFilename: bundleAllowed ? report.bundleFilename : null }
|
|
769
|
+
})),
|
|
770
|
+
maxBytes: deps.config.maxReportBytes,
|
|
771
|
+
repos: selectableRepos(await bundleRepos(deps, s.user)),
|
|
772
|
+
repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Resolve a report's bundle link from its embedded `bundleHashes`: the first
|
|
777
|
+
// declared integrity that has a stored bundle wins (bundleId set). When none is
|
|
778
|
+
// stored yet, keep the first declared integrity so a later bundle upload of it
|
|
779
|
+
// re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
|
|
780
|
+
async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
|
|
781
|
+
const hashes = reportBundleHashes(bytes)
|
|
782
|
+
for (const h of hashes) {
|
|
783
|
+
const b = await deps.db.getBundleByIntegrity(h)
|
|
784
|
+
if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
|
|
785
|
+
}
|
|
786
|
+
return { bundleId: null, integrity: hashes[0] ?? null }
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// POST /api/admin/reports — upload a report. Mutation: same-origin + CSRF,
|
|
790
|
+
// admin|manage. The body is the raw report bytes (any findings format — JSON /
|
|
791
|
+
// markdown / CSV — archived as-is, like the e2e objstore; the server parses them
|
|
792
|
+
// downstream). Display name rides X-Report-Filename; the repository and
|
|
793
|
+
// directory come from the report header, or from optional repository/directory
|
|
794
|
+
// headers when the report has no repository metadata. The bundle link is
|
|
795
|
+
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
796
|
+
// published. Bytes
|
|
797
|
+
// are written first (keyed by a fresh uuid) then the metadata row — a failed
|
|
798
|
+
// insert drops the orphan blob. 413 over the cap, 400 on empty.
|
|
799
|
+
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
800
|
+
let s = await checkMutation(req, res, deps, cookie)
|
|
801
|
+
if (s == null) return
|
|
802
|
+
if (!requireManageRole(res, s.user)) return
|
|
803
|
+
let bytes: Buffer
|
|
804
|
+
try {
|
|
805
|
+
bytes = await readUploadBody(req, deps, s.session.id, 'reports')
|
|
806
|
+
} catch (err) {
|
|
807
|
+
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
808
|
+
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
// Shared storage reads may outlast a session or role change.
|
|
812
|
+
s = await checkMutation(req, res, deps, cookie)
|
|
813
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
814
|
+
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
815
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
|
|
816
|
+
const parsed = readManagedReport(bytes.toString('utf8'), filename)
|
|
817
|
+
const repoGithub = parsed.data == null ? null : reportRepoGithub(parsed.data)
|
|
818
|
+
const repoEmbedded = repoGithub != null
|
|
819
|
+
const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
|
|
820
|
+
let headerDirectory = rawHeaderDirectory
|
|
821
|
+
try { headerDirectory = decodeURIComponent(rawHeaderDirectory) } catch { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
822
|
+
// Validate the raw header, not repoDirectory()'s display/link normalization,
|
|
823
|
+
// which trims characters and turns invalid paths into the repository root.
|
|
824
|
+
const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
|
|
825
|
+
const normalizedDirectory = normalizeTeamPath(requestedDirectory)
|
|
826
|
+
if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
827
|
+
const directory = normalizedDirectory.path ?? ''
|
|
828
|
+
const analyzer = parsed.data != null && typeof parsed.data.source === 'string' ? parsed.data.source : null
|
|
829
|
+
const selected = await deps.db.listSelectedRepos()
|
|
830
|
+
let matchedRepo = repoGithub == null ? null : selected.find((repo) => repo.fullName.toLocaleLowerCase() === repoGithub.toLocaleLowerCase())
|
|
831
|
+
if (repoGithub != null && matchedRepo == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
|
|
832
|
+
// When the report has no repository header, X-Repo-Id lets the managed uploader
|
|
833
|
+
// assign it at upload time. Clients may omit it and attach the report later.
|
|
834
|
+
if (repoGithub == null) {
|
|
835
|
+
const legacyRepo = await resolveUploadRepoId(req, res, deps)
|
|
836
|
+
if (!legacyRepo.ok) return
|
|
837
|
+
matchedRepo = legacyRepo.repoId == null ? null : selected.find((repo) => repo.repoId === legacyRepo.repoId) ?? null
|
|
838
|
+
}
|
|
839
|
+
if (matchedRepo && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, matchedRepo.repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
840
|
+
const id = randomUUID()
|
|
841
|
+
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
842
|
+
const sha256 = createHash('sha256').update(bytes).digest('base64url')
|
|
843
|
+
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
|
|
844
|
+
await deps.reportStore.put(id, bytes)
|
|
845
|
+
try {
|
|
846
|
+
await deps.db.insertReport({
|
|
847
|
+
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
848
|
+
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
849
|
+
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
850
|
+
}, Date.now())
|
|
851
|
+
} catch (err) {
|
|
852
|
+
await deps.reportStore.delete(id).catch(() => {})
|
|
853
|
+
throw err
|
|
854
|
+
}
|
|
855
|
+
sendJson(res, 201, { id, slug: (await deps.db.getReport(id))!.slug, filename, byteSize: bytes.length, sha256, repoId: matchedRepo?.repoId ?? null, repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId })
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// GET /api/admin/reports/<id> — download a stored report (admin|manage). Serves
|
|
859
|
+
// the bytes with the recorded content-type + filename. The recorded content-type
|
|
860
|
+
// is uploader-supplied, so the response is forced to download
|
|
861
|
+
// (Content-Disposition: attachment) AND marked `nosniff` — it can't be sniffed
|
|
862
|
+
// or rendered inline against the app origin. 404 when there's no such report;
|
|
863
|
+
// 503 when the row exists but the bytes don't (store desync).
|
|
864
|
+
async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
865
|
+
const s = await readManageSession(res, deps, cookie)
|
|
866
|
+
if (s == null) return
|
|
867
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
868
|
+
const rec = await deps.db.getReport(id)
|
|
869
|
+
if (rec == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
870
|
+
const bytes = await deps.reportStore.get(id)
|
|
871
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
872
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
873
|
+
if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
874
|
+
// The stored filename is already control-/path-stripped (sanitizeReportFilename
|
|
875
|
+
// at upload); only a double-quote could break the quoted Content-Disposition.
|
|
876
|
+
const dispoName = rec.filename.replaceAll('"', '')
|
|
877
|
+
res.writeHead(200, {
|
|
878
|
+
'content-type': rec.contentType,
|
|
879
|
+
'content-length': String(bytes.length),
|
|
880
|
+
'content-disposition': `attachment; filename="${dispoName}"`,
|
|
881
|
+
'x-content-type-options': 'nosniff',
|
|
882
|
+
'cache-control': 'no-store',
|
|
883
|
+
})
|
|
884
|
+
writeResponse(res, bytes)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// DELETE /api/admin/reports/<id> — remove a report (admin|manage). Mutation:
|
|
888
|
+
// same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
|
|
889
|
+
// was no such report.
|
|
890
|
+
async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
891
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
892
|
+
if (s == null) return
|
|
893
|
+
if (!requireManageRole(res, s.user)) return
|
|
894
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
895
|
+
if (!(await canChangeReportRepo(deps, s.user, id))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
896
|
+
const report = await deps.db.getReport(id)
|
|
897
|
+
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
898
|
+
const existed = await deps.db.deleteReport(id)
|
|
899
|
+
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
900
|
+
await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
|
|
901
|
+
if (!existed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
902
|
+
await activity(deps, s.user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
903
|
+
sendJson(res, 200, { ok: true })
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Apply the same access rule to inventory, cached derivatives, raw downloads
|
|
907
|
+
// and mutations. Ownership is a manager exception; lower roles require teams.
|
|
908
|
+
async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: string): Promise<boolean> {
|
|
909
|
+
if (!roleAtLeast(user.role, 'view')) return false
|
|
910
|
+
const rec = await deps.db.getBundle(id)
|
|
911
|
+
if (!rec) return false
|
|
912
|
+
if (user.role === 'admin') return true
|
|
913
|
+
if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
|
|
914
|
+
return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function canChangeBundleRepo(deps: ManagedHttpDeps, user: StoredUser, repoId: number | null, directory: string) {
|
|
918
|
+
return roleAtLeast(user.role, 'manage') && (user.role === 'admin' || repoId === null || await deps.db.userCanReadRepoPath(user.id, repoId, directory))
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
|
|
922
|
+
const repos = await deps.db.listSelectedRepos()
|
|
923
|
+
if (user.role === 'admin') return repos
|
|
924
|
+
const allowed = await Promise.all(repos.map(repo => deps.db.userCanReadRepo(user.id, repo.repoId)))
|
|
925
|
+
return repos.filter((_, index) => allowed[index])
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function prebuildBundle(deps: ManagedHttpDeps, id: string) {
|
|
929
|
+
if (!deps.bundleCache || deps.config.serverless) return
|
|
930
|
+
deps.track((async () => {
|
|
931
|
+
const record = await deps.db.getBundle(id)
|
|
932
|
+
if (record?.kind) await deps.bundleCache!.prebuild(record)
|
|
933
|
+
})().catch(err => console.warn('managed: bundle cache build failed:', err)))
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// GET /api/bundles/:id/{metadata,contents}. Source/cache bytes are already encoded;
|
|
937
|
+
// the browser's HTTP stack decompresses them without a Brotli JS dependency.
|
|
938
|
+
async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, part: BundleCachePart) {
|
|
939
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
940
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
941
|
+
if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
942
|
+
const rec = await deps.db.getBundle(id)
|
|
943
|
+
if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
944
|
+
if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
945
|
+
let cached
|
|
946
|
+
try { cached = await deps.bundleCache.open(rec, part) }
|
|
947
|
+
catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
|
|
948
|
+
// A cold build can outlast a session or membership change.
|
|
949
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
950
|
+
if (!current || !(await canAccessBundle(deps, current.user, id))) {
|
|
951
|
+
cached.stream.destroy()
|
|
952
|
+
sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
res.writeHead(200, {
|
|
956
|
+
'content-type': 'application/json', 'content-encoding': 'br',
|
|
957
|
+
...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
|
|
958
|
+
'x-content-type-options': 'nosniff',
|
|
959
|
+
})
|
|
960
|
+
if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
|
|
961
|
+
try { await pipeline(cached.stream, res) } catch { res.destroy() }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Published npm advisories require security access, independently of access to
|
|
965
|
+
// unpublished dependency findings. Managers retain their normal bundle access.
|
|
966
|
+
async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null, reason: string) {
|
|
967
|
+
const authorize = async () => {
|
|
968
|
+
const session = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
969
|
+
if (!session) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
970
|
+
if (!(await canAccessBundle(deps, session.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return false }
|
|
971
|
+
if (!roleAtLeast(session.user.role, 'manage') && !(await deps.db.userCanReadBundleAdvisories(session.user.id, id, teamId))) {
|
|
972
|
+
sendJson(res, 403, { error: 'security-access-required' }); return false
|
|
973
|
+
}
|
|
974
|
+
return true
|
|
975
|
+
}
|
|
976
|
+
if (!(await authorize())) return
|
|
977
|
+
const record = await deps.db.getBundle(id)
|
|
978
|
+
if (!record) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
979
|
+
if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
|
|
980
|
+
if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
981
|
+
let packages: Record<string, string[]> | null | undefined
|
|
982
|
+
try { packages = await deps.bundleCache.packageVersions(record, reason) }
|
|
983
|
+
catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
|
|
984
|
+
if (!(await authorize())) return
|
|
985
|
+
if (packages === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
|
|
986
|
+
if (packages === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
|
|
987
|
+
const body = Buffer.from(JSON.stringify(packages))
|
|
988
|
+
if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { sendJson(res, 413, { error: 'payload-too-large' }); return }
|
|
989
|
+
const controller = new AbortController()
|
|
990
|
+
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
991
|
+
res.on('close', onClose)
|
|
992
|
+
const timer = setTimeout(() => controller.abort(), NPM_ADVISORIES_TIMEOUT_MS)
|
|
993
|
+
try {
|
|
994
|
+
if (res.destroyed) return
|
|
995
|
+
const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
|
|
996
|
+
: await fetchNpmAdvisories(body, controller.signal, deps.config.debug)
|
|
997
|
+
if (res.destroyed || !(await authorize())) return
|
|
998
|
+
// Return just inventory and public advisories, never source or report data.
|
|
999
|
+
sendJson(res, result.status, result.status >= 200 && result.status < 300 ? { packages, advisories: result.body } : result.body)
|
|
1000
|
+
} finally {
|
|
1001
|
+
clearTimeout(timer)
|
|
1002
|
+
res.off('close', onClose)
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
|
|
1007
|
+
// admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
|
|
1008
|
+
// the upload repo picker.
|
|
1009
|
+
async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1010
|
+
let s = await readManageSession(res, deps, cookie)
|
|
1011
|
+
if (s == null) return
|
|
1012
|
+
const summaries = await bundleSummaries(await deps.db.listBundles(s.user.role === 'admin' ? undefined : s.user.id), deps.bundleCache)
|
|
1013
|
+
// Even cached storage reads may outlast changes to access or locations.
|
|
1014
|
+
s = await readManageSession(res, deps, cookie)
|
|
1015
|
+
if (s == null) return
|
|
1016
|
+
const bundles = await deps.db.listBundles(s.user.role === 'admin' ? undefined : s.user.id)
|
|
1017
|
+
sendJson(res, 200, {
|
|
1018
|
+
bundles: await Promise.all(bundles.map(async bundle => ({
|
|
1019
|
+
...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1020
|
+
canChangeRepo: await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory),
|
|
1021
|
+
}))),
|
|
1022
|
+
maxBytes: deps.config.maxBundleBytes,
|
|
1023
|
+
repos: selectableRepos(await bundleRepos(deps, s.user)),
|
|
1024
|
+
repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
|
|
1025
|
+
})
|
|
1026
|
+
await backfillBundleSummaries(bundles, deps.bundleCache)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
|
|
1030
|
+
// admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
|
|
1031
|
+
// X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
|
|
1032
|
+
// re-upload of identical bytes dedupes to the existing row (no second copy).
|
|
1033
|
+
// After storing, any reports that declared this integrity but weren't linked yet
|
|
1034
|
+
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
1035
|
+
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1036
|
+
let s = await checkMutation(req, res, deps, cookie)
|
|
1037
|
+
if (s == null) return
|
|
1038
|
+
if (!requireManageRole(res, s.user)) return
|
|
1039
|
+
let bytes: Buffer
|
|
1040
|
+
try {
|
|
1041
|
+
bytes = await readUploadBody(req, deps, s.session.id, 'bundles')
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
const tooLarge = err instanceof Error && err.message === 'too-large'
|
|
1044
|
+
sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
// Shared storage reads may outlast a session or role change.
|
|
1048
|
+
s = await checkMutation(req, res, deps, cookie)
|
|
1049
|
+
if (!s || !requireManageRole(res, s.user)) return
|
|
1050
|
+
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
1051
|
+
const repo = await resolveUploadRepoId(req, res, deps)
|
|
1052
|
+
if (!repo.ok) return
|
|
1053
|
+
let rawDirectory
|
|
1054
|
+
try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
|
|
1055
|
+
catch { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1056
|
+
const normalized = normalizeTeamPath(rawDirectory)
|
|
1057
|
+
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1058
|
+
const directory = repo.repoId == null ? '' : normalized.path ?? ''
|
|
1059
|
+
if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
|
|
1060
|
+
sendJson(res, 403, { error: 'forbidden' }); return
|
|
1061
|
+
}
|
|
1062
|
+
const integrity = bundleIntegrity(bytes)
|
|
1063
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1064
|
+
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1065
|
+
if (existing != null) {
|
|
1066
|
+
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1067
|
+
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1068
|
+
// integrity. An authorized re-upload repairs those pending links too.
|
|
1069
|
+
await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1070
|
+
prebuildBundle(deps, existing.id)
|
|
1071
|
+
sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
const id = randomUUID()
|
|
1075
|
+
const kind = bundleKind(filename)
|
|
1076
|
+
await deps.bundleStore.put(id, bytes, kind)
|
|
1077
|
+
try {
|
|
1078
|
+
await deps.db.insertBundle({
|
|
1079
|
+
id, integrity, filename, kind,
|
|
1080
|
+
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1081
|
+
}, Date.now())
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
await deps.bundleStore.delete(id).catch(() => {})
|
|
1084
|
+
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1085
|
+
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1086
|
+
// Any other failure rethrows.
|
|
1087
|
+
const raced = await deps.db.getBundleByIntegrity(integrity)
|
|
1088
|
+
if (raced != null) {
|
|
1089
|
+
if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1090
|
+
await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1091
|
+
prebuildBundle(deps, raced.id)
|
|
1092
|
+
sendJson(res, 200, { id: raced.id, integrity, filename: raced.filename, repoId: raced.repoId, repoDirectory: raced.repoDirectory, deduped: true }); return }
|
|
1093
|
+
throw err
|
|
1094
|
+
}
|
|
1095
|
+
// Auto-link reports that declared this integrity before the bundle existed.
|
|
1096
|
+
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1097
|
+
prebuildBundle(deps, id)
|
|
1098
|
+
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
|
1102
|
+
// are opaque archives, so served as application/octet-stream. Sourcemaps use
|
|
1103
|
+
// HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
|
|
1104
|
+
// bundle; 503 row-without-bytes (store desync).
|
|
1105
|
+
async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1106
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1107
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1108
|
+
if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1109
|
+
const rec = await deps.db.getBundle(id)
|
|
1110
|
+
if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1111
|
+
const stored = await deps.bundleStore.open(id, rec.kind)
|
|
1112
|
+
if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1113
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1114
|
+
if (!current || !(await canAccessBundle(deps, current.user, id))) {
|
|
1115
|
+
stored.stream.destroy()
|
|
1116
|
+
sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
|
|
1117
|
+
}
|
|
1118
|
+
const dispoName = rec.filename.replaceAll('"', '')
|
|
1119
|
+
res.writeHead(200, {
|
|
1120
|
+
'content-type': 'application/octet-stream',
|
|
1121
|
+
...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
|
|
1122
|
+
...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
|
|
1123
|
+
'content-disposition': `attachment; filename="${dispoName}"`,
|
|
1124
|
+
'x-content-type-options': 'nosniff',
|
|
1125
|
+
'cache-control': 'no-store',
|
|
1126
|
+
})
|
|
1127
|
+
if (req.method === 'HEAD') { stored.stream.destroy(); res.end(); return }
|
|
1128
|
+
try { await pipeline(stored.stream, res) } catch { res.destroy() }
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
|
|
1132
|
+
// same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
|
|
1133
|
+
// the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
|
|
1134
|
+
// best-effort. 404 when there was no such bundle.
|
|
1135
|
+
async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1136
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1137
|
+
if (s == null) return
|
|
1138
|
+
if (!requireManageRole(res, s.user)) return
|
|
1139
|
+
if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1140
|
+
const bundle = await deps.db.getBundle(id)
|
|
1141
|
+
if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1142
|
+
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
1143
|
+
const existed = await deps.db.deleteBundle(id)
|
|
1144
|
+
await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
1145
|
+
await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1146
|
+
await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
|
|
1147
|
+
if (!existed) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1148
|
+
await activity(deps, s.user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1149
|
+
sendJson(res, 200, { ok: true })
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
|
|
1153
|
+
// the session, or null after the 401/403 was sent.
|
|
1154
|
+
async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
1155
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1156
|
+
if (s == null) return null
|
|
1157
|
+
if (!requireManageRole(res, s.user)) return null
|
|
1158
|
+
return s
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Workspace access and repository connections are administered separately
|
|
1162
|
+
// from content management. Managers cannot change their own access via teams.
|
|
1163
|
+
async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
1164
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1165
|
+
if (s == null) return null
|
|
1166
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
|
|
1167
|
+
return s
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// GET /api/teams — the CURRENT user's teams, each with the reports and bundles
|
|
1171
|
+
// attached to the team's repos, for the sidebar's per-user Teams section. Any approved
|
|
1172
|
+
// user (not just admin|manage); a user only ever sees their own teams.
|
|
1173
|
+
async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1174
|
+
let s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1175
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1176
|
+
const summaries = await bundleSummaries(s.user.role === 'none' ? [] : (await deps.db.listTeamsForUser(s.user.id)).flatMap(team => team.bundles), deps.bundleCache)
|
|
1177
|
+
s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1178
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1179
|
+
const teams = s.user.role === 'none' ? [] : await deps.db.listTeamsForUser(s.user.id)
|
|
1180
|
+
sendJson(res, 200, {
|
|
1181
|
+
teams: teams.map(team => ({ ...team, bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })) })),
|
|
1182
|
+
// Derivatives do not change the catalog revision used by the live feed.
|
|
1183
|
+
revision: teamCatalogRevision(teams),
|
|
1184
|
+
})
|
|
1185
|
+
await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Admins read all reports. Managers read their uploads or reports inside their
|
|
1189
|
+
// team repository paths, including drafts they may manage. View/triage users
|
|
1190
|
+
// need a published report in their teams; ownership never overrides role none.
|
|
1191
|
+
async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
1192
|
+
if (!roleAtLeast(user.role, 'view')) return false
|
|
1193
|
+
const report = await deps.db.getReport(reportId)
|
|
1194
|
+
if (report == null) return false
|
|
1195
|
+
if (user.role === 'admin') return true
|
|
1196
|
+
if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
|
|
1197
|
+
if (!report.visible) return false
|
|
1198
|
+
return deps.db.userCanReadReport(user.id, reportId)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
|
|
1202
|
+
// visible findings (location AND evidence) are returned. Missing/unavailable
|
|
1203
|
+
// bundles are a quiet no-op; cached gzip bodies stream directly from disk.
|
|
1204
|
+
async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
|
|
1205
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1206
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1207
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1208
|
+
const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
|
|
1209
|
+
const report = await deps.db.getReport(id)
|
|
1210
|
+
const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
|
|
1211
|
+
const empty = () => { res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
|
|
1212
|
+
if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || !(await canAccessBundle(deps, s.user, bundle.id))) { empty(); return }
|
|
1213
|
+
if (!deps.reportSourcesCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1214
|
+
let cached
|
|
1215
|
+
try { cached = await deps.reportSourcesCache.open(report, bundle, { dependencies: true, security: true }, sourcePaths) }
|
|
1216
|
+
catch { empty(); return }
|
|
1217
|
+
if (!cached) { empty(); return }
|
|
1218
|
+
// Cold parsing can outlast role/team changes, report deletion, or relinking.
|
|
1219
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1220
|
+
const latest = await deps.db.getReport(id)
|
|
1221
|
+
let latestPaths: Set<string> | undefined
|
|
1222
|
+
try { latestPaths = current && !roleAtLeast(current.user.role, 'manage') ? await teamSourcePaths(deps.db, deps.reportStore, current.session.id, teamId ?? '', id) : undefined }
|
|
1223
|
+
catch (error) { cached.stream.destroy(); throw error }
|
|
1224
|
+
if (!current || current.user.role !== s.user.role || sourcePaths?.size !== latestPaths?.size || [...sourcePaths ?? []].some(path => !latestPaths?.has(path)) || !(await canViewReport(deps, current.user, id)) || !(await canAccessBundle(deps, current.user, bundle.id))
|
|
1225
|
+
|| latest?.bundleId !== bundle.id || latest.sha256 !== report.sha256
|
|
1226
|
+
|| latest.repoId !== report.repoId || await repositoryName(deps, latest.repoId) !== cached.repo.github) {
|
|
1227
|
+
cached.stream.destroy()
|
|
1228
|
+
sendJson(res, current ? 404 : 401, { error: current ? 'no-report' : 'unauthenticated' })
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
res.writeHead(200, {
|
|
1232
|
+
'content-type': 'application/json', 'content-encoding': 'gzip',
|
|
1233
|
+
...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
|
|
1234
|
+
'x-content-type-options': 'nosniff',
|
|
1235
|
+
})
|
|
1236
|
+
if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
|
|
1237
|
+
try { await pipeline(cached.stream, res) } catch { res.destroy() }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// GET /api/reports/<id> — admin/manager preview, within management access.
|
|
1241
|
+
// Accept: application/json includes parsed, filtered data and the server's repo
|
|
1242
|
+
// assignment. Other callers retain the raw text/plain response. The
|
|
1243
|
+
// client renders either without caching to OPFS. 404 covers "no such report" AND
|
|
1244
|
+
// "not authorized" (so neither existence nor membership is probeable); 503 = row
|
|
1245
|
+
// without bytes (store desync).
|
|
1246
|
+
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1247
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1248
|
+
if (s == null) return
|
|
1249
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1250
|
+
const bytes = await deps.reportStore.get(id)
|
|
1251
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1252
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1253
|
+
if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1254
|
+
if (acceptsReportMetadata(req.headers.accept)) {
|
|
1255
|
+
const report = await deps.db.getReport(id)
|
|
1256
|
+
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1257
|
+
const repo = { github: await repositoryName(deps, report.repoId), directory: report.repoDirectory }
|
|
1258
|
+
const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
|
|
1259
|
+
if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
|
|
1260
|
+
sendJson(res, 200, {
|
|
1261
|
+
data,
|
|
1262
|
+
repo,
|
|
1263
|
+
}, { vary: 'Accept', 'x-content-type-options': 'nosniff' })
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
const out = bytes
|
|
1267
|
+
res.writeHead(200, {
|
|
1268
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
1269
|
+
'content-length': String(out.length),
|
|
1270
|
+
'x-content-type-options': 'nosniff',
|
|
1271
|
+
'cache-control': 'no-store',
|
|
1272
|
+
vary: 'Accept',
|
|
1273
|
+
})
|
|
1274
|
+
writeResponse(res, out)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// A read-only POST avoids URL-length limits when a workspace has many reports.
|
|
1278
|
+
// Authorize every requested id before reading blobs; never return a partial
|
|
1279
|
+
// workspace, or let one authorized report grant access to another in the batch.
|
|
1280
|
+
const REPORT_QUERY_CONCURRENCY = 8
|
|
1281
|
+
const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
|
|
1282
|
+
async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1283
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1284
|
+
if (!s) return
|
|
1285
|
+
let body
|
|
1286
|
+
try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1287
|
+
const raw = (body as { ids?: unknown } | null)?.ids
|
|
1288
|
+
if (!Array.isArray(raw) || raw.some(id => typeof id !== 'string' || !id || id.length > 256)) {
|
|
1289
|
+
sendJson(res, 400, { error: 'bad-ids' }); return
|
|
1290
|
+
}
|
|
1291
|
+
const ids = [...new Set(raw as string[])]
|
|
1292
|
+
if (ids.length > MAX_REPORT_QUERY_COUNT) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
1293
|
+
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
|
|
1294
|
+
if (!snapshot || snapshot.user.id !== s.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1295
|
+
if (!roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1296
|
+
const reports = new Map(snapshot.reports.map(report => [report.id, report]))
|
|
1297
|
+
if (reports.size !== ids.length) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1298
|
+
const storedBytes = snapshot.reports.reduce((total, report) => total + report.byteSize, 0)
|
|
1299
|
+
if (storedBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
1300
|
+
// Overlap remote blob reads, retaining only each report's encoded response
|
|
1301
|
+
// after processing. Check actual bytes too if storage and metadata disagree.
|
|
1302
|
+
const parts = [Buffer.from('{"reports":[')]
|
|
1303
|
+
let inputBytes = 0, outputBytes = parts[0]!.length + 2
|
|
1304
|
+
let next = 0, stopped = false
|
|
1305
|
+
let inFlightBytes = 0
|
|
1306
|
+
let capacity = Promise.withResolvers<void>()
|
|
1307
|
+
const fail = (status: number, error: string) => { stopped = true; return { status, error } }
|
|
1308
|
+
const workers = await Promise.allSettled(Array.from({ length: Math.min(REPORT_QUERY_CONCURRENCY, ids.length) }, async () => {
|
|
1309
|
+
try {
|
|
1310
|
+
while (next < ids.length) {
|
|
1311
|
+
if (stopped) return
|
|
1312
|
+
const index = next
|
|
1313
|
+
const id = ids[index]!
|
|
1314
|
+
const access = reports.get(id)!
|
|
1315
|
+
const reservedBytes = Math.max(1, access.byteSize)
|
|
1316
|
+
// Reserve stored bytes before starting remote reads, through encoding.
|
|
1317
|
+
// A report larger than the concurrent budget is still allowed alone.
|
|
1318
|
+
if (inFlightBytes > 0 && inFlightBytes + reservedBytes > REPORT_QUERY_IN_FLIGHT_BYTES) {
|
|
1319
|
+
await capacity.promise
|
|
1320
|
+
continue
|
|
1321
|
+
}
|
|
1322
|
+
next++
|
|
1323
|
+
inFlightBytes += reservedBytes
|
|
1324
|
+
try {
|
|
1325
|
+
const bytes = await deps.reportStore.get(id)
|
|
1326
|
+
if (stopped) return
|
|
1327
|
+
if (bytes == null) return fail(503, 'unavailable')
|
|
1328
|
+
inputBytes += bytes.length
|
|
1329
|
+
if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
1330
|
+
const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
1331
|
+
if (!data) return fail(422, 'unreadable-report')
|
|
1332
|
+
const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
|
|
1333
|
+
outputBytes += part.length
|
|
1334
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
1335
|
+
// Completion order may differ from the requested report order.
|
|
1336
|
+
parts[index + 1] = part
|
|
1337
|
+
} finally {
|
|
1338
|
+
inFlightBytes -= reservedBytes
|
|
1339
|
+
capacity.resolve()
|
|
1340
|
+
capacity = Promise.withResolvers<void>()
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
if (stopped) return
|
|
1345
|
+
stopped = true
|
|
1346
|
+
throw error
|
|
1347
|
+
}
|
|
1348
|
+
return undefined
|
|
1349
|
+
}))
|
|
1350
|
+
// Stop scheduling on any failure, and drain already-started reads before
|
|
1351
|
+
// responding so none outlive the tracked request or reject unhandled.
|
|
1352
|
+
for (const result of workers) {
|
|
1353
|
+
if (result.status === 'rejected') throw result.reason
|
|
1354
|
+
if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
|
|
1355
|
+
}
|
|
1356
|
+
// Access to an earlier report may change while a later blob is fetched.
|
|
1357
|
+
// Reject the whole answer if any content/access/assignment snapshot changed.
|
|
1358
|
+
const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
|
|
1359
|
+
if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1360
|
+
if (current.user.role !== snapshot.user.role || current.reports.length !== reports.size
|
|
1361
|
+
|| current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
|
|
1362
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
1363
|
+
}
|
|
1364
|
+
parts.push(Buffer.from(']}'))
|
|
1365
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
1366
|
+
writeResponse(res, Buffer.concat(parts, outputBytes))
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// The server selects the complete workspace; clients cannot omit a report
|
|
1370
|
+
// or links file to evade classification through the rest of their team.
|
|
1371
|
+
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
|
|
1372
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1373
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1374
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
|
|
1375
|
+
const reports = await loadTeamReports(deps.db, deps.reportStore, snapshot)
|
|
1376
|
+
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
1377
|
+
const parts = [Buffer.from('{"reports":[')]
|
|
1378
|
+
for (const [index, report] of reports.entries()) parts.push(Buffer.from(`${index ? ',' : ''}${JSON.stringify(report)}`))
|
|
1379
|
+
parts.push(Buffer.from(']}'))
|
|
1380
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
1381
|
+
writeResponse(res, Buffer.concat(parts))
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Triage requires both a writing role and access to the report itself.
|
|
1385
|
+
async function canTriageReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
1386
|
+
return roleAtLeast(user.role, 'triage') && await canViewReport(deps, user, reportId)
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Ordinary users authorize annotations through the complete team's filtered
|
|
1390
|
+
// workspace. Privileged users retain report-scoped access and the same stable
|
|
1391
|
+
// ID derivation. Both paths store/read annotations by finding ID alone.
|
|
1392
|
+
const VISIBLE_IDS_CACHE_MAX = 256
|
|
1393
|
+
const visibleIdsCaches = new WeakMap<ManagedHttpDeps, Map<string, Set<string>>>()
|
|
1394
|
+
async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, reportId: string, sessionId: string, teamId: string | null): Promise<Set<string>> {
|
|
1395
|
+
if (!roleAtLeast(user.role, 'manage')) {
|
|
1396
|
+
if (!teamId) throw new TeamReportsError(404, 'no-team')
|
|
1397
|
+
return teamFindingIds(deps.db, deps.reportStore, sessionId, teamId, reportId)
|
|
1398
|
+
}
|
|
1399
|
+
const rec = await deps.db.getReport(reportId)
|
|
1400
|
+
if (rec == null) return new Set()
|
|
1401
|
+
const repo = { github: await repositoryName(deps, rec.repoId) }
|
|
1402
|
+
const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
|
|
1403
|
+
let cache = visibleIdsCaches.get(deps)
|
|
1404
|
+
if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
|
|
1405
|
+
const hit = cache.get(key)
|
|
1406
|
+
if (hit != null) return hit
|
|
1407
|
+
const ids = new Set<string>()
|
|
1408
|
+
const bytes = await deps.reportStore.get(reportId)
|
|
1409
|
+
if (bytes == null) return ids
|
|
1410
|
+
const text = bytes.toString('utf8')
|
|
1411
|
+
const { data } = readManagedReport(text, rec.filename)
|
|
1412
|
+
if (data == null) return ids
|
|
1413
|
+
const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
|
|
1414
|
+
if (report == null) return ids
|
|
1415
|
+
for (const f of report.findings) {
|
|
1416
|
+
const id = (f as { id?: unknown }).id
|
|
1417
|
+
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
1418
|
+
}
|
|
1419
|
+
while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
|
|
1420
|
+
const oldest = cache.keys().next().value
|
|
1421
|
+
if (oldest === undefined) break
|
|
1422
|
+
cache.delete(oldest)
|
|
1423
|
+
}
|
|
1424
|
+
cache.set(key, ids)
|
|
1425
|
+
return ids
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// GET /api/reports/<id>/triage — the stored triage entries for the findings of
|
|
1429
|
+
// a report the caller may view (canViewReport; 404 hides existence and denial
|
|
1430
|
+
// alike, matching handleViewReport). Entries are keyed by finding id and shared
|
|
1431
|
+
// by every report carrying the finding; a viewer only receives entries for
|
|
1432
|
+
// findings their visibility permissions keep in THIS report — an entry on a
|
|
1433
|
+
// stripped finding must not leak that the finding exists. A cleared entry is
|
|
1434
|
+
// sent as null (the server's tombstone), distinct from one never set.
|
|
1435
|
+
async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
1436
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1437
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1438
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1439
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1440
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1441
|
+
if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1442
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1443
|
+
const entries: Record<string, TriageEntryPatch | null> = {}
|
|
1444
|
+
for (const row of await deps.db.listTriage([...visible])) entries[row.findingId] = triageWireEntry(row)
|
|
1445
|
+
sendJson(res, 200, { entries })
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
|
|
1449
|
+
// trail (newest first, capped), gated exactly like the entries: the caller
|
|
1450
|
+
// may view the report, and the finding is one their visibility permissions
|
|
1451
|
+
// keep in it (a stripped or foreign id 404s without revealing whether it
|
|
1452
|
+
// exists). Each event is the entry as written then (null = a clear), who
|
|
1453
|
+
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
1454
|
+
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
1455
|
+
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1456
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1457
|
+
if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1458
|
+
const finding = query.get('finding') ?? ''
|
|
1459
|
+
if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1460
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1461
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1462
|
+
if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1463
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1464
|
+
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1465
|
+
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1466
|
+
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1467
|
+
}))
|
|
1468
|
+
sendJson(res, 200, { finding, events })
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// POST /api/reports/<id>/triage — write triage entries for a report's findings.
|
|
1472
|
+
// Mutation: same-origin + CSRF, then canTriageReport (404 on any failure — role
|
|
1473
|
+
// too low, no membership, or no such report). Body { entries: { <findingId>:
|
|
1474
|
+
// entry|null } } — whole-entry replace, last write wins, null clears the entry
|
|
1475
|
+
// (a tombstone, so a later reader adopts the clear); each entry validates
|
|
1476
|
+
// through parseTriageEntryPatch (400 on a malformed one). Every id must be a
|
|
1477
|
+
// finding the writer sees in THIS report — the report is the authorization
|
|
1478
|
+
// scope for rows that are themselves per finding id — so a stripped (or
|
|
1479
|
+
// foreign) finding id 404s without revealing whether it exists.
|
|
1480
|
+
async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
|
|
1481
|
+
const s = await checkMutation(req, res, deps, cookie)
|
|
1482
|
+
if (s == null) return
|
|
1483
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1484
|
+
let body: unknown
|
|
1485
|
+
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1486
|
+
const raw = (body as { entries?: unknown } | null)?.entries
|
|
1487
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1488
|
+
const pairs = Object.entries(raw)
|
|
1489
|
+
if (pairs.length > MAX_TRIAGE_ENTRIES) { sendJson(res, 400, { error: 'too-many-entries' }); return }
|
|
1490
|
+
const parsed: [string, TriageEntryPatch | null][] = []
|
|
1491
|
+
for (const [findingId, value] of pairs) {
|
|
1492
|
+
const patch = parseTriageEntryPatch(value)
|
|
1493
|
+
if (value != null && typeof value === 'object' && Object.hasOwn(value, 'comment')) {
|
|
1494
|
+
sendJson(res, 400, { error: 'use-comments-endpoint' }); return
|
|
1495
|
+
}
|
|
1496
|
+
if (patch === 'invalid' || findingId === '' || findingId.length > MAX_FINDING_ID) {
|
|
1497
|
+
sendJson(res, 400, { error: 'bad-entry' }); return
|
|
1498
|
+
}
|
|
1499
|
+
parsed.push([findingId, patch])
|
|
1500
|
+
}
|
|
1501
|
+
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1502
|
+
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1503
|
+
if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1504
|
+
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1505
|
+
if (parsed.some(([findingId]) => !visible.has(findingId))) {
|
|
1506
|
+
sendJson(res, 404, { error: 'no-finding' }); return
|
|
1507
|
+
}
|
|
1508
|
+
await deps.db.setTriageEntries(parsed, s.user.id, s.user.login, Date.now(), id)
|
|
1509
|
+
sendJson(res, 200, { ok: true })
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1513
|
+
// writing. The store commits shared finding triage and imported comments together.
|
|
1514
|
+
async function handleImportReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1515
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1516
|
+
if (!s) return
|
|
1517
|
+
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
1518
|
+
try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
|
|
1519
|
+
catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1520
|
+
if (!body || typeof body !== 'object') { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1521
|
+
const reading = body.entries === undefined
|
|
1522
|
+
const raw = body.entries
|
|
1523
|
+
if (!reading && (!raw || typeof raw !== 'object' || Array.isArray(raw))) { sendJson(res, 400, { error: 'bad-entries' }); return }
|
|
1524
|
+
const ids = reading ? body.findingIds : Object.keys(raw!)
|
|
1525
|
+
if (!Array.isArray(ids) || ids.length > MAX_TRIAGE_ENTRIES || ids.some(value => typeof value !== 'string' || !value || value.length > MAX_FINDING_ID)) {
|
|
1526
|
+
sendJson(res, 400, { error: 'bad-finding-ids' }); return
|
|
1527
|
+
}
|
|
1528
|
+
const parsed: [string, TriageEntryPatch | null][] = []
|
|
1529
|
+
const expected = body.expected as Record<string, string> | null
|
|
1530
|
+
if (!reading) {
|
|
1531
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) { sendJson(res, 400, { error: 'bad-expected' }); return }
|
|
1532
|
+
for (const [findingId, value] of Object.entries(raw!)) {
|
|
1533
|
+
const patch = parseTriageEntryPatch(value)
|
|
1534
|
+
if (patch === 'invalid' || !/^[a-f\d]{64}$/u.test(expected[findingId] ?? '')) { sendJson(res, 400, { error: 'bad-entry' }); return }
|
|
1535
|
+
parsed.push([findingId, patch])
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1539
|
+
const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1540
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1541
|
+
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1542
|
+
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
1543
|
+
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
1544
|
+
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// A report grants access to its visible findings; comments themselves are
|
|
1548
|
+
// shared by finding ID. Author IDs always come from the authenticated session.
|
|
1549
|
+
async function handleReportComments(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, reportId: string, commentId: string | null, teamId: string | null): Promise<void> {
|
|
1550
|
+
const method = req.method ?? 'GET'
|
|
1551
|
+
if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
|
|
1552
|
+
send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
|
|
1553
|
+
}
|
|
1554
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
1555
|
+
if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1556
|
+
const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
|
|
1557
|
+
if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1558
|
+
let raw: { body?: unknown; findingId?: unknown; version?: unknown } | null
|
|
1559
|
+
if (method === 'GET') raw = null
|
|
1560
|
+
else {
|
|
1561
|
+
try { raw = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof raw } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1562
|
+
}
|
|
1563
|
+
// Body delivery and a cold report read can outlive a permission change.
|
|
1564
|
+
// Match triage's recheck before exposing or changing any annotation.
|
|
1565
|
+
await visibleFindingIds(deps, s.user, reportId, s.session.id, teamId)
|
|
1566
|
+
const session = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1567
|
+
if (!session || session.user.role !== s.user.role || !(await canViewReport(deps, session.user, reportId))) {
|
|
1568
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
1569
|
+
}
|
|
1570
|
+
const visible = await visibleFindingIds(deps, session.user, reportId, session.session.id, teamId)
|
|
1571
|
+
if (method === 'GET') {
|
|
1572
|
+
sendJson(res, 200, { comments: await deps.db.listComments([...visible]) }); return
|
|
1573
|
+
}
|
|
1574
|
+
const body = parseCommentBody(raw?.body)
|
|
1575
|
+
if (body == null && method !== 'DELETE') { sendJson(res, 400, { error: 'bad-comment' }); return }
|
|
1576
|
+
if (commentId == null) {
|
|
1577
|
+
if (typeof raw?.findingId !== 'string' || !visible.has(raw.findingId)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1578
|
+
const comment = await deps.db.createComment({ findingId: raw.findingId, body: body!, authorId: session.user.id, authorLogin: session.user.login, reportId }, Date.now())
|
|
1579
|
+
sendJson(res, 201, { comment }); return
|
|
1580
|
+
}
|
|
1581
|
+
const current = await deps.db.getComment(commentId)
|
|
1582
|
+
if (current == null || !visible.has(current.findingId)) { sendJson(res, 404, { error: 'no-comment' }); return }
|
|
1583
|
+
const allowedComment = method === 'DELETE' ? canDeleteComment(current, session.user) : current.authorId === session.user.id
|
|
1584
|
+
if (!allowedComment) { sendJson(res, 403, { error: 'not-comment-author' }); return }
|
|
1585
|
+
if (typeof raw?.version !== 'number' || !Number.isSafeInteger(raw.version) || raw.version < 1) {
|
|
1586
|
+
sendJson(res, 400, { error: 'bad-version' }); return
|
|
1587
|
+
}
|
|
1588
|
+
const comment = method === 'DELETE'
|
|
1589
|
+
? await deps.db.deleteComment(commentId, session.user.id, session.user.login, raw.version, reportId, Date.now())
|
|
1590
|
+
: await deps.db.editComment(commentId, session.user.id, session.user.login, body!, raw.version, reportId, Date.now())
|
|
1591
|
+
if (comment === 'conflict') { sendJson(res, 409, { error: 'comment-changed' }); return }
|
|
1592
|
+
if (comment === 'forbidden') { sendJson(res, 403, { error: 'not-comment-author' }); return }
|
|
1593
|
+
if (comment == null) { sendJson(res, 404, { error: 'no-comment' }); return }
|
|
1594
|
+
if (comment === 'deleted') { res.writeHead(204, { 'cache-control': 'no-store' }); res.end(); return }
|
|
1595
|
+
sendJson(res, 200, { comment })
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// GET /api/admin/teams — every team (members + repos inlined) plus the pickers
|
|
1599
|
+
// the page needs: all users (member dropdown), selected repos (repo dropdown),
|
|
1600
|
+
// and the visibility-permission keys. admin, read-only (no CSRF).
|
|
1601
|
+
async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1602
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1603
|
+
if (s == null) return
|
|
1604
|
+
sendJson(res, 200, {
|
|
1605
|
+
teams: await deps.db.listTeams(),
|
|
1606
|
+
users: await deps.db.listUserOptions(),
|
|
1607
|
+
repos: selectableRepos(await deps.db.listSelectedRepos()),
|
|
1608
|
+
permissions: VISIBILITY_PERMISSIONS,
|
|
1609
|
+
})
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
|
|
1613
|
+
async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1614
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1615
|
+
if (s == null) return
|
|
1616
|
+
let body: unknown
|
|
1617
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1618
|
+
const rawName = (body as { name?: unknown } | null)?.name
|
|
1619
|
+
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
|
1620
|
+
if (name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
|
|
1621
|
+
const id = randomUUID()
|
|
1622
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1623
|
+
if (!(await deps.db.createTeam(id, name, Date.now()))) { sendJson(res, 409, { error: 'name-taken' }); return }
|
|
1624
|
+
await activity(deps, s.user, 'access', `created team ${name}`)
|
|
1625
|
+
sendJson(res, 201, await deps.db.getTeam(id))
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
|
|
1629
|
+
// such team; 409 if the new name is already taken by another team.
|
|
1630
|
+
async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1631
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1632
|
+
if (s == null) return
|
|
1633
|
+
let body: unknown
|
|
1634
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1635
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1636
|
+
const rawName = (body as { name?: unknown } | null)?.name
|
|
1637
|
+
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
|
1638
|
+
if (typeof teamId !== 'string' || name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
|
|
1639
|
+
const team = await deps.db.getTeam(teamId)
|
|
1640
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1641
|
+
const result = await deps.db.renameTeam(teamId, name, Date.now())
|
|
1642
|
+
if (result === 'not-found') { sendJson(res, 404, { error: 'no-team' }); return }
|
|
1643
|
+
if (result === 'name-taken') { sendJson(res, 409, { error: 'name-taken' }); return }
|
|
1644
|
+
if (team?.name !== name) await activity(deps, s.user, 'access', `renamed team ${team?.name ?? teamId} to ${name}`)
|
|
1645
|
+
sendJson(res, 200, { ok: true, name })
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
1649
|
+
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1650
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1651
|
+
if (s == null) return
|
|
1652
|
+
let body: unknown
|
|
1653
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1654
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1655
|
+
if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1656
|
+
const team = await deps.db.getTeam(teamId)
|
|
1657
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1658
|
+
if (!(await deps.db.deleteTeam(teamId))) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
1659
|
+
await activity(deps, s.user, 'access', `deleted team ${team?.name ?? teamId}`)
|
|
1660
|
+
sendJson(res, 200, { ok: true })
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// POST /api/admin/teams/set-repo — add a repo path. Empty path replaces all
|
|
1664
|
+
// path links with the whole repository. Body { teamId, repoId, path? }.
|
|
1665
|
+
// Repo must be in the selected set.
|
|
1666
|
+
async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1667
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1668
|
+
if (s == null) return
|
|
1669
|
+
let body: unknown
|
|
1670
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1671
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1672
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
1673
|
+
if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) {
|
|
1674
|
+
sendJson(res, 400, { error: 'bad-request' }); return
|
|
1675
|
+
}
|
|
1676
|
+
const path = normalizeTeamPath((body as { path?: unknown }).path)
|
|
1677
|
+
if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
1678
|
+
const team = (await deps.db.listTeams()).find(row => row.id === teamId)
|
|
1679
|
+
if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
1680
|
+
if (!(await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)) { sendJson(res, 400, { error: 'repo-not-selected' }); return }
|
|
1681
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1682
|
+
await deps.db.setTeamRepo(teamId, repoId, path.path)
|
|
1683
|
+
if (!team.repos.some(repo => repo.repoId === repoId && (!repo.path || repo.path === (path.path ?? '')))) {
|
|
1684
|
+
await activity(deps, s.user, 'access', `granted team ${team.name} access to ${path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
|
|
1685
|
+
}
|
|
1686
|
+
sendJson(res, 200, { ok: true })
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
|
|
1690
|
+
// the legacy remove-all behavior. Body { teamId, repoId, path? }.
|
|
1691
|
+
async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1692
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1693
|
+
if (s == null) return
|
|
1694
|
+
let body: unknown
|
|
1695
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1696
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1697
|
+
const repoId = (body as { repoId?: unknown } | null)?.repoId
|
|
1698
|
+
if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1699
|
+
const rawPath = (body as { path?: unknown }).path
|
|
1700
|
+
if (rawPath !== undefined && rawPath !== null && typeof rawPath !== 'string') { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
1701
|
+
const path = normalizeTeamPath(rawPath)
|
|
1702
|
+
if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
|
|
1703
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1704
|
+
if (!(await deps.db.removeTeamRepo(teamId, repoId, rawPath === undefined ? undefined : path.path))) { sendJson(res, 404, { error: 'not-linked' }); return }
|
|
1705
|
+
await activity(deps, s.user, 'access', `removed team ${(await deps.db.getTeam(teamId))?.name ?? teamId}'s access to ${rawPath === undefined ? 'all paths' : path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
|
|
1706
|
+
sendJson(res, 200, { ok: true })
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// POST /api/admin/teams/set-member — add/update a member + their visibility
|
|
1710
|
+
// permissions. Body { teamId, userId, dependencies?, security? }.
|
|
1711
|
+
async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1712
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1713
|
+
if (s == null) return
|
|
1714
|
+
let body: unknown
|
|
1715
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1716
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1717
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
1718
|
+
if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1719
|
+
const team = (await deps.db.listTeams()).find(row => row.id === teamId)
|
|
1720
|
+
if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
1721
|
+
const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
|
|
1722
|
+
if (user == null) { sendJson(res, 404, { error: 'no-user' }); return }
|
|
1723
|
+
const permissions = parseTeamUserPermissions(body)
|
|
1724
|
+
const member = team.members.find(row => row.userId === userId)
|
|
1725
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1726
|
+
await deps.db.setTeamMember(teamId, userId, permissions)
|
|
1727
|
+
if (!member || member.dependencies !== permissions.dependencies || member.security !== permissions.security) {
|
|
1728
|
+
await activity(deps, s.user, 'access', `set ${user.login}'s membership in ${team.name} (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`)
|
|
1729
|
+
}
|
|
1730
|
+
sendJson(res, 200, { ok: true })
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
|
|
1734
|
+
async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1735
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
1736
|
+
if (s == null) return
|
|
1737
|
+
let body: unknown
|
|
1738
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1739
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
1740
|
+
const userId = (body as { userId?: unknown } | null)?.userId
|
|
1741
|
+
if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1742
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1743
|
+
if (!(await deps.db.removeTeamMember(teamId, userId))) { sendJson(res, 404, { error: 'not-member' }); return }
|
|
1744
|
+
const team = await deps.db.getTeam(teamId)
|
|
1745
|
+
const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
|
|
1746
|
+
await activity(deps, s.user, 'access', `removed ${user?.login ?? userId} from team ${team?.name ?? teamId}`)
|
|
1747
|
+
sendJson(res, 200, { ok: true })
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
|
|
1751
|
+
if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
1752
|
+
const method = req.method ?? 'GET'
|
|
1753
|
+
const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
|
|
1754
|
+
if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
|
|
1755
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
1756
|
+
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1757
|
+
if (!requireManageRole(res, s.user)) return
|
|
1758
|
+
const { db } = deps
|
|
1759
|
+
if (method === 'GET') {
|
|
1760
|
+
const shares = await db.listWorkspaceShares(s.session.id, Date.now(), teamId)
|
|
1761
|
+
sendJson(res, shares ? 200 : 404, shares ? { shares } : { error: 'no-team' }); return
|
|
1762
|
+
}
|
|
1763
|
+
let body
|
|
1764
|
+
if (method === 'POST' || method === 'PATCH') {
|
|
1765
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1766
|
+
}
|
|
1767
|
+
const permissions = parseTeamUserPermissions(body), token = method === 'POST' ? randomToken() : ''
|
|
1768
|
+
const shareId = id ?? hashToken(token)
|
|
1769
|
+
const ok = method === 'POST' ? await db.createWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
|
|
1770
|
+
: method === 'PATCH' ? await db.updateWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
|
|
1771
|
+
: await db.revokeWorkspaceShares(s.session.id, Date.now(), teamId, id)
|
|
1772
|
+
if (!ok) { sendJson(res, 404, { error: 'no-share' }); return }
|
|
1773
|
+
const team = await db.getTeam(teamId)
|
|
1774
|
+
if (!team) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
1775
|
+
const action = method === 'POST' ? 'created a public link' : method === 'PATCH' ? 'updated a public link' : id ? 'revoked a public link' : 'revoked public links'
|
|
1776
|
+
const access = method === 'DELETE' ? '' : ` (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`
|
|
1777
|
+
await activity(deps, s.user, 'access', `${action} for team ${team.name}${access}`)
|
|
1778
|
+
sendJson(res, 200, method === 'POST' ? { id: shareId, path: `/team/${team.slug}#public=${shareId.slice(0, 8)}.${token}` } : { ok: true })
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
1782
|
+
const { config, db, avatarStore, isShuttingDown, track } = deps
|
|
1783
|
+
const repositoryDiscovery = new RepositoryDiscovery(config)
|
|
1784
|
+
|
|
1785
|
+
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1786
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1787
|
+
const path = url.pathname
|
|
1788
|
+
const method = req.method ?? 'GET'
|
|
1789
|
+
const cookie = req.headers.cookie
|
|
1790
|
+
|
|
1791
|
+
if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
|
|
1792
|
+
await handlePublicWorkspace(req, res, deps, url); return
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Public mode probe — lets a client detect the managed protocol up front.
|
|
1796
|
+
if (path === CONFIG_PATH) {
|
|
1797
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1798
|
+
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
1799
|
+
sendJson(res, 200, { ...info, managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
|
|
1800
|
+
return
|
|
1801
|
+
}
|
|
1802
|
+
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
1803
|
+
if (path === LOGIN_PATH) {
|
|
1804
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1805
|
+
const { location, setCookie } = buildLoginRedirect(config)
|
|
1806
|
+
res.writeHead(302, { location, 'set-cookie': setCookie, 'cache-control': 'no-store' })
|
|
1807
|
+
res.end()
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
// OAuth: callback (the GitHub hook) → mint a session, land on the app.
|
|
1811
|
+
if (path === CALLBACK_PATH) {
|
|
1812
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1813
|
+
try {
|
|
1814
|
+
const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
|
|
1815
|
+
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookies, 'cache-control': 'no-store' })
|
|
1816
|
+
res.end()
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
|
|
1819
|
+
else throw err
|
|
1820
|
+
}
|
|
1821
|
+
return
|
|
1822
|
+
}
|
|
1823
|
+
// Who am I?
|
|
1824
|
+
if (path === SESSION_PATH) {
|
|
1825
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1826
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
1827
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1828
|
+
sendJson(res, 200, {
|
|
1829
|
+
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
1830
|
+
csrfToken: s.session.csrfToken,
|
|
1831
|
+
})
|
|
1832
|
+
return
|
|
1833
|
+
}
|
|
1834
|
+
// Authentication is not workspace access. Keep bootstrap/session/logout
|
|
1835
|
+
// available so blocked accounts can see their role and sign out, but deny
|
|
1836
|
+
// every managed data route before reading bodies or looking up resources.
|
|
1837
|
+
const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
|
|
1838
|
+
.some(prefix => path === prefix || path.startsWith(prefix + '/'))
|
|
1839
|
+
if (managedDataPath && await readWorkspaceSession(res, deps, cookie) == null) return
|
|
1840
|
+
if (path === '/api/admin/links') {
|
|
1841
|
+
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
1842
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1843
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
1844
|
+
if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1845
|
+
if (!requireManageRole(res, s.user)) return
|
|
1846
|
+
const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
|
|
1847
|
+
sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
|
|
1848
|
+
}
|
|
1849
|
+
const shareRoute = /^\/api\/teams\/([^/]+)\/share(?:\/([A-Za-z0-9_-]{43}))?$/u.exec(path)
|
|
1850
|
+
if (shareRoute) {
|
|
1851
|
+
await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
|
|
1852
|
+
}
|
|
1853
|
+
if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
|
|
1854
|
+
// Cached avatar by user id, served same-origin (the page CSP forbids the
|
|
1855
|
+
// github CDN). The id in the path keys the browser cache per user, so a user
|
|
1856
|
+
// switch never serves a stale avatar. Workspace access is required.
|
|
1857
|
+
if (path.startsWith(AVATAR_PREFIX)) {
|
|
1858
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1859
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
1860
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1861
|
+
await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
|
|
1862
|
+
return
|
|
1863
|
+
}
|
|
1864
|
+
// Admin: list users (admin-only).
|
|
1865
|
+
if (path === ADMIN_HISTORY_PATH) {
|
|
1866
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1867
|
+
await handleHistory(res, deps, cookie, url.searchParams); return
|
|
1868
|
+
}
|
|
1869
|
+
if (path === ADMIN_USERS_PATH) {
|
|
1870
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1871
|
+
const s = await readSession(config, db, cookie, Date.now())
|
|
1872
|
+
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1873
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1874
|
+
sendJson(res, 200, { users: await db.listUsers() })
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
if (path === ADMIN_SCAN_MODELS_PATH) {
|
|
1878
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1879
|
+
await handleListModels(res, deps, cookie); return
|
|
1880
|
+
}
|
|
1881
|
+
if (path === SET_ROLE_PATH) { await handleSetRole(req, res, deps, cookie); return }
|
|
1882
|
+
if (path === REPO_IMPACT_PATH) {
|
|
1883
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1884
|
+
await handleRepositoryImpact(req, res, deps, cookie); return
|
|
1885
|
+
}
|
|
1886
|
+
if (path === REMOVE_REPO_PATH) {
|
|
1887
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1888
|
+
await handleRemoveRepository(req, res, deps, cookie); return
|
|
1889
|
+
}
|
|
1890
|
+
if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
|
|
1891
|
+
if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
|
|
1892
|
+
if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
|
|
1893
|
+
// Reports: list / upload on the exact path, download / delete per-id on the
|
|
1894
|
+
// prefix. Method-dispatched here since each path carries two verbs.
|
|
1895
|
+
if (path === ADMIN_REPORTS_PATH) {
|
|
1896
|
+
if (method === 'GET') { await handleListReports(res, deps, cookie); return }
|
|
1897
|
+
if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
|
|
1898
|
+
send405(res, 'GET, POST'); return
|
|
1899
|
+
}
|
|
1900
|
+
// set-repo is an exact sub-path; check it before the per-id prefix (a report
|
|
1901
|
+
// id is a uuid, so it never collides with "set-repo").
|
|
1902
|
+
if (path === REPORT_SET_REPO_PATH) {
|
|
1903
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1904
|
+
await handleSetReportRepo(req, res, deps, cookie); return
|
|
1905
|
+
}
|
|
1906
|
+
if (path === REPORT_SET_VISIBLE_PATH) {
|
|
1907
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1908
|
+
await handleSetReportVisible(req, res, deps, cookie); return
|
|
1909
|
+
}
|
|
1910
|
+
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
1911
|
+
if (importTriage) {
|
|
1912
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1913
|
+
await handleImportReportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
1914
|
+
}
|
|
1915
|
+
if (path.startsWith(REPORT_PREFIX)) {
|
|
1916
|
+
const id = path.slice(REPORT_PREFIX.length)
|
|
1917
|
+
if (method === 'GET') { await handleGetReport(res, deps, cookie, id); return }
|
|
1918
|
+
if (method === 'DELETE') { await handleDeleteReport(req, res, deps, cookie, id); return }
|
|
1919
|
+
send405(res, 'GET, DELETE'); return
|
|
1920
|
+
}
|
|
1921
|
+
// Bundles: list / upload on the exact path, download / delete per-id on the
|
|
1922
|
+
// prefix (same shape as reports).
|
|
1923
|
+
const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
|
|
1924
|
+
if (bundleAdvisories) {
|
|
1925
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1926
|
+
await handleBundleAdvisories(res, deps, cookie, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '')
|
|
1927
|
+
return
|
|
1928
|
+
}
|
|
1929
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
|
|
1930
|
+
if (bundleRead) {
|
|
1931
|
+
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
1932
|
+
const id = bundleRead[1]!
|
|
1933
|
+
if (bundleRead[2] === 'download') await handleGetBundle(req, res, deps, cookie, id)
|
|
1934
|
+
else await handleBundleCache(req, res, deps, cookie, id, bundleRead[2] as BundleCachePart)
|
|
1935
|
+
return
|
|
1936
|
+
}
|
|
1937
|
+
if (path === ADMIN_BUNDLES_PATH) {
|
|
1938
|
+
if (method === 'GET') { await handleListBundles(res, deps, cookie); return }
|
|
1939
|
+
if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
|
|
1940
|
+
send405(res, 'GET, POST'); return
|
|
1941
|
+
}
|
|
1942
|
+
if (path === BUNDLE_SET_REPO_PATH) {
|
|
1943
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1944
|
+
await handleSetBundleRepo(req, res, deps, cookie); return
|
|
1945
|
+
}
|
|
1946
|
+
if (path.startsWith(BUNDLE_PREFIX)) {
|
|
1947
|
+
const id = path.slice(BUNDLE_PREFIX.length)
|
|
1948
|
+
if (method === 'GET') { await handleGetBundle(req, res, deps, cookie, id); return }
|
|
1949
|
+
if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
|
|
1950
|
+
send405(res, 'GET, DELETE'); return
|
|
1951
|
+
}
|
|
1952
|
+
const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
|
|
1953
|
+
if (teamFeed || path === '/api/teams/feed') {
|
|
1954
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1955
|
+
const s = await readWorkspaceSession(res, deps, cookie)
|
|
1956
|
+
if (!s) return
|
|
1957
|
+
const teamId = teamFeed?.[1] ?? null
|
|
1958
|
+
if (teamId) await teamSnapshot(db, s.session.id, teamId)
|
|
1959
|
+
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId); return
|
|
1960
|
+
}
|
|
1961
|
+
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
1962
|
+
if (teamFixes) {
|
|
1963
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1964
|
+
await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!); return
|
|
1965
|
+
}
|
|
1966
|
+
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
1967
|
+
if (teamReports) {
|
|
1968
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1969
|
+
await handleTeamReports(res, deps, cookie, teamReports[1]!); return
|
|
1970
|
+
}
|
|
1971
|
+
if (path === '/api/reports/query') {
|
|
1972
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
1973
|
+
await handleQueryReports(req, res, deps, cookie); return
|
|
1974
|
+
}
|
|
1975
|
+
const sourcesRoute = /^\/api\/reports\/([^/]+)\/sources$/u.exec(path)
|
|
1976
|
+
if (sourcesRoute) {
|
|
1977
|
+
if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
|
|
1978
|
+
await handleReportSources(req, res, deps, cookie, sourcesRoute[1]!, url.searchParams.get('team')); return
|
|
1979
|
+
}
|
|
1980
|
+
const commentsRoute = /^\/api\/reports\/([^/]+)\/comments(?:\/([^/]+))?$/u.exec(path)
|
|
1981
|
+
if (commentsRoute) {
|
|
1982
|
+
await handleReportComments(req, res, deps, cookie, commentsRoute[1]!, commentsRoute[2] ?? null, url.searchParams.get('team')); return
|
|
1983
|
+
}
|
|
1984
|
+
// Per-finding triage on a viewable report. The '/triage' suffixes are
|
|
1985
|
+
// matched before the bare per-id slice below (a report id is a uuid, so it
|
|
1986
|
+
// never ends in them) — same trick as REPORT_SET_REPO_PATH above; the
|
|
1987
|
+
// longer '/triage/history' goes first.
|
|
1988
|
+
if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_HISTORY_SUFFIX)) {
|
|
1989
|
+
const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_HISTORY_SUFFIX.length)
|
|
1990
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1991
|
+
await handleGetReportTriageHistory(res, deps, cookie, id, url.searchParams, url.searchParams.get('team')); return
|
|
1992
|
+
}
|
|
1993
|
+
if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_SUFFIX)) {
|
|
1994
|
+
const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_SUFFIX.length)
|
|
1995
|
+
if (method === 'GET') { await handleGetReportTriage(res, deps, cookie, id, url.searchParams.get('team')); return }
|
|
1996
|
+
if (method === 'POST') { await handleSetReportTriage(req, res, deps, cookie, id, url.searchParams.get('team')); return }
|
|
1997
|
+
send405(res, 'GET, POST'); return
|
|
1998
|
+
}
|
|
1999
|
+
// Individual report previews are reserved for admins/managers.
|
|
2000
|
+
if (path.startsWith(MY_REPORT_PREFIX)) {
|
|
2001
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2002
|
+
await handleViewReport(req, res, deps, cookie, path.slice(MY_REPORT_PREFIX.length)); return
|
|
2003
|
+
}
|
|
2004
|
+
// The signed-in user's own team memberships (any approved user).
|
|
2005
|
+
if (path === MY_TEAMS_PATH) {
|
|
2006
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2007
|
+
await handleMyTeams(res, deps, cookie); return
|
|
2008
|
+
}
|
|
2009
|
+
// Teams: list / create on the exact path; the link mutations are POST-only
|
|
2010
|
+
// action sub-paths (each carries its ids in the JSON body).
|
|
2011
|
+
if (path === ADMIN_TEAMS_PATH) {
|
|
2012
|
+
if (method === 'GET') { await handleListTeams(res, deps, cookie); return }
|
|
2013
|
+
if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
|
|
2014
|
+
send405(res, 'GET, POST'); return
|
|
2015
|
+
}
|
|
2016
|
+
if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
|
|
2017
|
+
|| path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
|
|
2018
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2019
|
+
if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
|
|
2020
|
+
if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
|
|
2021
|
+
if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
|
|
2022
|
+
if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
|
|
2023
|
+
if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
|
|
2024
|
+
await handleRemoveTeamMember(req, res, deps, cookie); return
|
|
2025
|
+
}
|
|
2026
|
+
if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
|
|
2027
|
+
if (deps.serveStatic?.(req, res)) return
|
|
2028
|
+
if (deps.next) { deps.next(req, res); return }
|
|
2029
|
+
sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
return (req, res) => {
|
|
2033
|
+
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
2034
|
+
const work = route(req, res).catch((err) => {
|
|
2035
|
+
if (err instanceof TeamReportsError && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2036
|
+
console.warn('managed: request handler error:', err)
|
|
2037
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
2038
|
+
else sendJson(res, 500, { error: 'internal' })
|
|
2039
|
+
})
|
|
2040
|
+
track(work)
|
|
2041
|
+
return work
|
|
2042
|
+
}
|
|
2043
|
+
}
|