@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34
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/client/linked-findings.js +5 -4
- package/common/bundle-metadata.js +4 -3
- package/common/bundle-source-links.d.ts +2 -1
- package/common/bundle-source-links.js +133 -6
- package/common/bundle-sources.d.ts +2 -0
- package/common/bundle-sources.js +2 -4
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/routes.js +20 -1
- package/common/managed/triage.ts +2 -2
- package/out/client-managed-import.js +51 -46
- package/out/client-managed.js +252 -72
- package/out/client-sync.js +14 -14
- package/out/graph.js +11 -11
- package/out/index.html +1 -0
- package/out/terminal.js +169 -169
- package/out/view.css +1 -1
- package/out/view.js +417 -201
- package/package.json +23 -17
- package/server-managed/activity.ts +15 -1
- package/server-managed/brotli.ts +3 -3
- package/server-managed/bundle-advisories.ts +8 -3
- package/server-managed/bundle-advisory-inventory.ts +27 -25
- package/server-managed/bundle-build-worker.js +45 -9
- package/server-managed/bundle-build.ts +35 -11
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle.ts +17 -1
- package/server-managed/config.ts +7 -2
- package/server-managed/db-methods.ts +170 -49
- package/server-managed/db-neon.ts +59 -4
- package/server-managed/db-schema.ts +12 -3
- package/server-managed/db.ts +9 -1
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/http-response.ts +7 -1
- package/server-managed/http.ts +248 -80
- package/server-managed/index.ts +4 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/managed-issues.ts +31 -3
- package/server-managed/management.ts +17 -4
- package/server-managed/object-storage-disk.ts +7 -3
- package/server-managed/object-storage-vercel.ts +6 -2
- package/server-managed/object-storage.ts +3 -2
- package/server-managed/public-workspace.ts +5 -2
- package/server-managed/report-sources.ts +6 -5
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/revisions.ts +2 -2
- package/server-managed/sql.ts +3 -2
- package/server-managed/storage-encryption.ts +72 -6
- package/server-managed/storage-maintenance.ts +64 -49
- package/server-managed/storage-payload.ts +11 -10
- package/server-managed/storage-stores.ts +3 -1
- package/server-managed/storage.ts +17 -0
- package/server-managed/team-feed.ts +3 -3
- package/server-managed/team-reports.ts +24 -15
- package/server-managed/upstream-cache.ts +73 -0
- package/server-managed/workspace-shares.ts +16 -12
package/server-managed/http.ts
CHANGED
|
@@ -41,13 +41,17 @@
|
|
|
41
41
|
// GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
|
|
42
42
|
// POST /api/admin/teams → admin creates a team | 401/403/409
|
|
43
43
|
// POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
|
|
44
|
+
// POST /api/admin/teams/set-hidden → admin hides/restores a team | 401/403/404
|
|
44
45
|
// POST /api/admin/teams/delete → admin deletes a team | 401/403/404
|
|
45
46
|
// POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
|
|
46
47
|
// POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
|
|
47
48
|
// POST /api/auth/logout → same-origin + CSRF, drops the session
|
|
49
|
+
import { mergeLinkGroups } from './link-reports.ts'
|
|
50
|
+
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
48
51
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
49
52
|
import { pipeline } from 'node:stream/promises'
|
|
50
53
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
54
|
+
import { auditCache } from './upstream-cache.ts'
|
|
51
55
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
52
56
|
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
|
|
53
57
|
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
@@ -58,9 +62,10 @@ import { Buffer } from 'node:buffer'
|
|
|
58
62
|
import { createHash, randomUUID } from 'node:crypto'
|
|
59
63
|
import type { AvatarStore } from './avatar-store.ts'
|
|
60
64
|
import type { BlobStore } from './blob-store.ts'
|
|
61
|
-
import { bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
65
|
+
import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
|
|
66
|
+
import { resolveRepositoryImportLocation } from './repository-aliases.ts'
|
|
62
67
|
import type { ManagedConfig } from './config.ts'
|
|
63
|
-
import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
|
|
68
|
+
import type { BundleBuildConditions, BundleProvenance, BundleReuse, ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
|
|
64
69
|
import type { OriginGate } from '../server-common/origin.ts'
|
|
65
70
|
import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
66
71
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
@@ -85,9 +90,11 @@ import { acceptsReportMetadata } from './report-response.ts'
|
|
|
85
90
|
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
86
91
|
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
87
92
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
93
|
+
import { lookupIssueLinks } from './github-issue-links.ts'
|
|
94
|
+
import { visibleManagedIssues } from './managed-issues.ts'
|
|
88
95
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
89
96
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
90
|
-
import { sendJson, writeResponse } from './http-response.ts'
|
|
97
|
+
import { attachmentDisposition, sendJson, writeResponse } from './http-response.ts'
|
|
91
98
|
import { triageWireEntry } from './triage-response.ts'
|
|
92
99
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
93
100
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
@@ -119,6 +126,7 @@ const REPORT_PREFIX = '/api/admin/reports/'
|
|
|
119
126
|
const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
|
|
120
127
|
const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
|
|
121
128
|
const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
|
|
129
|
+
const BUNDLE_SET_VISIBLE_PATH = '/api/admin/bundles/set-visible'
|
|
122
130
|
const BUNDLE_PREFIX = '/api/admin/bundles/'
|
|
123
131
|
const MY_TEAMS_PATH = '/api/teams'
|
|
124
132
|
const MY_REPORT_PREFIX = '/api/reports/'
|
|
@@ -127,29 +135,39 @@ const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
|
|
|
127
135
|
const ADMIN_TEAMS_PATH = '/api/admin/teams'
|
|
128
136
|
const TEAM_DELETE_PATH = '/api/admin/teams/delete'
|
|
129
137
|
const TEAM_RENAME_PATH = '/api/admin/teams/rename'
|
|
138
|
+
const TEAM_SET_HIDDEN_PATH = '/api/admin/teams/set-hidden'
|
|
130
139
|
const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
|
|
131
140
|
const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
|
|
132
141
|
const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
|
|
133
142
|
const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
|
|
134
143
|
const MAX_TEAM_NAME = 100
|
|
135
144
|
|
|
136
|
-
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
|
|
145
|
+
async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
|
|
137
146
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
138
147
|
if (!s) return
|
|
139
|
-
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
|
|
148
|
+
const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
|
|
140
149
|
const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
141
|
-
const
|
|
150
|
+
const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
|
|
151
|
+
const storedIssues = await deps.db.listManagedIssues(ids)
|
|
152
|
+
const issues = visibleManagedIssues(storedIssues, snapshot)
|
|
153
|
+
const visibleIds = new Set(issues.map(issue => issue.findingId))
|
|
154
|
+
const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
|
|
142
155
|
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
143
|
-
const
|
|
156
|
+
const result = issues.length > 0
|
|
157
|
+
? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
|
|
158
|
+
: { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
|
|
144
159
|
// Cold report reads, token refresh and upstream batches can outlive changes
|
|
145
160
|
// to security/links, team grants, publication, sessions or the Fix links.
|
|
146
161
|
const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
|
|
147
162
|
if (await readWorkspaceSession(res, deps, cookie) == null) return
|
|
148
163
|
await recheckTeam(deps.db, s.session.id, snapshot)
|
|
149
|
-
if (JSON.stringify(currentUrls) !== JSON.stringify(
|
|
164
|
+
if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
|
|
150
165
|
sendJson(res, 404, { error: 'workspace-changed' }); return
|
|
151
166
|
}
|
|
152
|
-
|
|
167
|
+
if (result.updates.length > 0 && !await deps.db.applyManagedIssueFixes(s.session.id, snapshot, result.updates)) {
|
|
168
|
+
sendJson(res, 404, { error: 'workspace-changed' }); return
|
|
169
|
+
}
|
|
170
|
+
sendJson(res, 200, { fixes: result.fixes })
|
|
153
171
|
}
|
|
154
172
|
|
|
155
173
|
async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
|
|
@@ -787,6 +805,19 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
787
805
|
sendJson(res, 200, { ok: true, visible })
|
|
788
806
|
}
|
|
789
807
|
|
|
808
|
+
async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
809
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
810
|
+
if (s == null) return
|
|
811
|
+
let body: unknown
|
|
812
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
813
|
+
const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
|
|
814
|
+
const visible = (body as { visible?: unknown } | null)?.visible
|
|
815
|
+
if (typeof bundleId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
816
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'visibility', visible })
|
|
817
|
+
if (bundle.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a bundle' : 'hid a bundle', { bundleId, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
818
|
+
sendJson(res, 200, { ok: true, visible })
|
|
819
|
+
}
|
|
820
|
+
|
|
790
821
|
// POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
|
|
791
822
|
// and directory (same shape as reports). Body { bundleId, repoId, directory }.
|
|
792
823
|
async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -870,7 +901,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
870
901
|
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
871
902
|
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
|
|
872
903
|
const parsed = readManagedReport(bytes.toString('utf8'), filename)
|
|
873
|
-
|
|
904
|
+
if (parsed.data == null) {
|
|
905
|
+
sendJson(res, 400, { error: 'invalid-report', reason: parsed.reason ?? 'Unrecognized report format' })
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
if (parsed.format === 'links' || parsed.data.source === 'links') {
|
|
909
|
+
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
910
|
+
sendJson(res, result.reused ? 200 : 201, { ...result.report, kind: 'links', deduped: result.reused })
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
const repoGithub = reportRepoGithub(parsed.data)
|
|
914
|
+
// The browser splits these documents before upload. Enforce that boundary
|
|
915
|
+
// for direct API clients too: one stored record has only one repo's ACL.
|
|
916
|
+
if (parsed.format === 'markdown-generic' && repoGithub == null) {
|
|
917
|
+
sendJson(res, 400, { error: 'invalid-report', reason: 'Multi-product Markdown must be split into one report per product before upload.' })
|
|
918
|
+
return
|
|
919
|
+
}
|
|
874
920
|
const repoEmbedded = repoGithub != null
|
|
875
921
|
const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
|
|
876
922
|
let headerDirectory = rawHeaderDirectory
|
|
@@ -880,23 +926,26 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
880
926
|
const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
|
|
881
927
|
const normalizedDirectory = normalizeTeamPath(requestedDirectory)
|
|
882
928
|
if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
883
|
-
|
|
884
|
-
const analyzer =
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
929
|
+
let directory = normalizedDirectory.path ?? ''
|
|
930
|
+
const analyzer = typeof parsed.data.source === 'string' ? parsed.data.source : null
|
|
931
|
+
const sha256 = createHash('sha256').update(bytes).digest('base64url')
|
|
932
|
+
// Reuploads retain their stored location, including after an alias changes.
|
|
933
|
+
const existing = await deps.db.getReportByHash(sha256, analyzer)
|
|
934
|
+
let repoId: number | null = null
|
|
935
|
+
if (repoGithub != null) {
|
|
936
|
+
const location = existing ? { repoId: existing.repoId, directory: existing.repoDirectory }
|
|
937
|
+
: await deps.db.getRepositoryImportLocation(repoGithub, directory)
|
|
938
|
+
repoId = location.repoId; directory = location.directory
|
|
939
|
+
if (!existing && repoId == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
|
|
940
|
+
}
|
|
888
941
|
// When the report has no repository header, X-Repo-Id lets the managed uploader
|
|
889
942
|
// assign it at upload time. Clients may omit it and attach the report later.
|
|
890
943
|
if (repoGithub == null) {
|
|
891
944
|
const legacyRepo = await resolveUploadRepoId(req, res, deps)
|
|
892
945
|
if (!legacyRepo.ok) return
|
|
893
|
-
|
|
946
|
+
repoId = legacyRepo.repoId
|
|
894
947
|
}
|
|
895
|
-
if (
|
|
896
|
-
const sha256 = createHash('sha256').update(bytes).digest('base64url')
|
|
897
|
-
// The analyzer also distinguishes a CSV from the same bytes previously
|
|
898
|
-
// uploaded under a filename that did not identify it as a report.
|
|
899
|
-
const existing = await deps.db.getReportByHash(sha256, analyzer)
|
|
948
|
+
if (repoId != null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
900
949
|
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
901
950
|
const id = randomUUID()
|
|
902
951
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
@@ -906,7 +955,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
906
955
|
try {
|
|
907
956
|
report = await deps.db.insertOrReuseReport({
|
|
908
957
|
id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
|
|
909
|
-
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId
|
|
958
|
+
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId,
|
|
910
959
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
911
960
|
}, Date.now(), s.session.id)
|
|
912
961
|
} catch (err) {
|
|
@@ -938,13 +987,10 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
|
|
|
938
987
|
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
939
988
|
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
940
989
|
if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
941
|
-
// The stored filename is already control-/path-stripped (sanitizeReportFilename
|
|
942
|
-
// at upload); only a double-quote could break the quoted Content-Disposition.
|
|
943
|
-
const dispoName = rec.filename.replaceAll('"', '')
|
|
944
990
|
res.writeHead(200, {
|
|
945
991
|
'content-type': rec.contentType,
|
|
946
992
|
'content-length': String(bytes.length),
|
|
947
|
-
'content-disposition':
|
|
993
|
+
'content-disposition': attachmentDisposition(rec.filename),
|
|
948
994
|
'x-content-type-options': 'nosniff',
|
|
949
995
|
'cache-control': 'no-store',
|
|
950
996
|
})
|
|
@@ -973,7 +1019,7 @@ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: stri
|
|
|
973
1019
|
if (!rec) return false
|
|
974
1020
|
if (user.role === 'admin') return true
|
|
975
1021
|
if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
|
|
976
|
-
return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
|
|
1022
|
+
return rec.visible && rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
|
|
977
1023
|
}
|
|
978
1024
|
|
|
979
1025
|
async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
|
|
@@ -1115,7 +1161,7 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1115
1161
|
|
|
1116
1162
|
// Published dependency advisories require security access, independently of access to
|
|
1117
1163
|
// unpublished dependency findings. Managers retain their normal bundle access.
|
|
1118
|
-
async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean) {
|
|
1164
|
+
async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean, details: boolean) {
|
|
1119
1165
|
const authorize = async () => {
|
|
1120
1166
|
const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id, teamId)
|
|
1121
1167
|
if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
@@ -1147,7 +1193,9 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
|
|
|
1147
1193
|
...init, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
|
|
1148
1194
|
})) : null
|
|
1149
1195
|
if (res.destroyed || (needsGithub && !(await authorize()))) return
|
|
1150
|
-
const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
|
|
1196
|
+
const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
|
|
1197
|
+
debug: deps.config.debug, repoAdvisories, details, githubToken, cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug),
|
|
1198
|
+
})
|
|
1151
1199
|
if (res.destroyed || !(await authorize())) return
|
|
1152
1200
|
// Return just inventory and public advisories, never source or report data.
|
|
1153
1201
|
sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
|
|
@@ -1176,10 +1224,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, ses
|
|
|
1176
1224
|
}
|
|
1177
1225
|
|
|
1178
1226
|
type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1179
|
-
|
|
1227
|
+
// `reuse` describes a repeated upload/build of an existing row (see reuseBundle).
|
|
1228
|
+
async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
1229
|
+
bundle: UploadedBundle, reuse?: Omit<BundleReuse, 'rename'>): Promise<void> {
|
|
1180
1230
|
const s = await manageMutation(req, res, deps, cookie)
|
|
1181
1231
|
if (!s) return
|
|
1182
|
-
|
|
1232
|
+
const deduped = reuse != null
|
|
1233
|
+
if (deduped) {
|
|
1234
|
+
// Authorized in the same transaction as the rename. A server build labels
|
|
1235
|
+
// even a row the caller cannot see: the bytes are this server's own output.
|
|
1236
|
+
if (await deps.db.reuseBundleUpload(s.session.id, bundle.id, reuse)) bundle = await deps.db.getBundle(bundle.id) ?? bundle
|
|
1237
|
+
if (!(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1238
|
+
}
|
|
1183
1239
|
const { id, slug, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1184
1240
|
// Re-uploading also repairs reports uploaded while the bundle was inaccessible.
|
|
1185
1241
|
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
@@ -1214,6 +1270,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1214
1270
|
signal.throwIfAborted()
|
|
1215
1271
|
const built = await buildRepositoryBundle(access.user.id, {
|
|
1216
1272
|
input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
|
|
1273
|
+
cacheDir: deps.config.upstreamCacheDir ?? null,
|
|
1217
1274
|
}, signal)
|
|
1218
1275
|
signal.throwIfAborted()
|
|
1219
1276
|
await reader.recheckAccess()
|
|
@@ -1223,7 +1280,9 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1223
1280
|
throw new BundleBuildError(409, 'repository-changed')
|
|
1224
1281
|
}
|
|
1225
1282
|
signal.throwIfAborted()
|
|
1226
|
-
|
|
1283
|
+
const bytes = Buffer.from(built.bytes)
|
|
1284
|
+
await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
|
|
1285
|
+
bundleIntegrity(bytes), input.conditions)
|
|
1227
1286
|
})
|
|
1228
1287
|
} catch (error) {
|
|
1229
1288
|
if (res.destroyed || controller.signal.aborted) return
|
|
@@ -1241,7 +1300,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1241
1300
|
// X-Repo-Directory assign a repository location; Stasis repo headers default
|
|
1242
1301
|
// to a matching connected repository within the uploader's access, otherwise
|
|
1243
1302
|
// the bundle stays unattached. Its identity is its content hash (sha512), UNIQUE — a
|
|
1244
|
-
// re-upload of identical bytes dedupes to the existing row (no second copy)
|
|
1303
|
+
// re-upload of identical bytes dedupes to the existing row (no second copy) and
|
|
1304
|
+
// renames it, unless the row was built on this server.
|
|
1245
1305
|
// After storing, any reports that declared this integrity but weren't linked yet
|
|
1246
1306
|
// get attached (auto-link). 413 over the cap, 400 on empty.
|
|
1247
1307
|
async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1275,12 +1335,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1275
1335
|
if (!existing && repo.repoId == null && kind === 'stasis') {
|
|
1276
1336
|
const embedded = await bundleRepo(bytes)
|
|
1277
1337
|
const github = reportRepoGithub({ repo: embedded })
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1338
|
+
const embeddedDirectory = normalizeTeamPath(embedded?.directory)
|
|
1339
|
+
// Invalid optional metadata must not reject an otherwise unattached bundle.
|
|
1340
|
+
// Only repository-wide aliases/direct connections can resolve without a
|
|
1341
|
+
// valid directory; do not decode file paths or match directory aliases.
|
|
1342
|
+
const location = github == null ? null : embeddedDirectory.ok
|
|
1343
|
+
? await resolveRepositoryImportLocation(deps.db, github, embeddedDirectory.path ?? '', () => bundleFilePrefix(bytes))
|
|
1344
|
+
: await deps.db.getRepositoryImportLocation(github, '')
|
|
1345
|
+
if (location?.repoId != null) {
|
|
1346
|
+
repo.repoId = location.repoId
|
|
1282
1347
|
if (req.headers['x-repo-directory'] == null) {
|
|
1283
|
-
|
|
1348
|
+
if (!embeddedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
1349
|
+
rawDirectory = location.directory
|
|
1284
1350
|
inferredLocation = true
|
|
1285
1351
|
}
|
|
1286
1352
|
}
|
|
@@ -1297,19 +1363,21 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1297
1363
|
repo.repoId = null
|
|
1298
1364
|
directory = ''
|
|
1299
1365
|
}
|
|
1300
|
-
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, integrity)
|
|
1366
|
+
await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
|
|
1301
1367
|
}
|
|
1302
1368
|
|
|
1303
1369
|
async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
|
|
1304
|
-
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
1370
|
+
s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
|
|
1371
|
+
provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
|
|
1372
|
+
const kind = bundleKind(filename)
|
|
1373
|
+
const reuse = { provenance, filename, kind, buildConditions }
|
|
1305
1374
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1306
|
-
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing,
|
|
1375
|
+
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
|
|
1307
1376
|
const id = randomUUID()
|
|
1308
|
-
const kind = bundleKind(filename)
|
|
1309
1377
|
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
1310
1378
|
try {
|
|
1311
1379
|
await deps.db.insertBundle({
|
|
1312
|
-
id, integrity, filename, kind, dataKey,
|
|
1380
|
+
id, integrity, filename, kind, dataKey, provenance, buildConditions,
|
|
1313
1381
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
|
|
1314
1382
|
}, Date.now(), s.session.id)
|
|
1315
1383
|
} catch (err) {
|
|
@@ -1324,12 +1392,12 @@ async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, de
|
|
|
1324
1392
|
if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
|
|
1325
1393
|
if (err instanceof ManagedMutationError) throw err
|
|
1326
1394
|
if (!raced) throw err
|
|
1327
|
-
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced,
|
|
1395
|
+
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, reuse); return }
|
|
1328
1396
|
// Our insert committed: continue with the ordinary creation response.
|
|
1329
1397
|
}
|
|
1330
1398
|
const stored = await deps.db.getBundle(id)
|
|
1331
1399
|
if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1332
|
-
await sendUploadedBundle(req, res, deps, cookie, stored
|
|
1400
|
+
await sendUploadedBundle(req, res, deps, cookie, stored)
|
|
1333
1401
|
}
|
|
1334
1402
|
|
|
1335
1403
|
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
|
@@ -1348,12 +1416,11 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
1348
1416
|
stored.stream.destroy()
|
|
1349
1417
|
sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
|
|
1350
1418
|
}
|
|
1351
|
-
const dispoName = rec.filename.replaceAll('"', '')
|
|
1352
1419
|
res.writeHead(200, {
|
|
1353
1420
|
'content-type': 'application/octet-stream',
|
|
1354
1421
|
...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
|
|
1355
1422
|
...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
|
|
1356
|
-
'content-disposition':
|
|
1423
|
+
'content-disposition': attachmentDisposition(rec.filename),
|
|
1357
1424
|
'x-content-type-options': 'nosniff',
|
|
1358
1425
|
'cache-control': 'no-store',
|
|
1359
1426
|
})
|
|
@@ -1470,6 +1537,73 @@ async function handleReportSources(req: IncomingMessage, res: ServerResponse, de
|
|
|
1470
1537
|
try { await pipeline(cached.stream, res) } catch { res.destroy() }
|
|
1471
1538
|
}
|
|
1472
1539
|
|
|
1540
|
+
async function reportIds(data: unknown): Promise<Set<string>> {
|
|
1541
|
+
const findings = (reportEntries(data) ?? []).flat().filter((f): f is Record<string, unknown> => f !== null && typeof f === 'object' && !Array.isArray(f))
|
|
1542
|
+
await backfillFindingIds(findings)
|
|
1543
|
+
return new Set(findings.flatMap(f => typeof f['id'] === 'string' ? [f['id']] : []))
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1547
|
+
const method = req.method ?? 'GET'
|
|
1548
|
+
if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
|
|
1549
|
+
const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
|
|
1550
|
+
if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1551
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1552
|
+
if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
|
|
1553
|
+
if (id) {
|
|
1554
|
+
let body
|
|
1555
|
+
try { body = await readJsonBody(req, 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1556
|
+
const enabled = (body as { enabled?: unknown } | null)?.enabled
|
|
1557
|
+
if (typeof enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-enabled' }); return }
|
|
1558
|
+
await deps.db.setLinkReportEnabled(s.session.id, id, enabled)
|
|
1559
|
+
sendJson(res, 200, { ok: true }); return
|
|
1560
|
+
}
|
|
1561
|
+
let bytes: Buffer
|
|
1562
|
+
try { bytes = await readBodyBytes(req, deps.config.maxReportBytes) }
|
|
1563
|
+
catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
|
|
1564
|
+
const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
|
|
1565
|
+
const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
|
|
1566
|
+
sendJson(res, result.reused ? 200 : 201, { ...result.report, deduped: result.reused })
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1570
|
+
const method = req.method ?? 'GET'
|
|
1571
|
+
if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
|
|
1572
|
+
const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
|
|
1573
|
+
if (!s) return
|
|
1574
|
+
if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
1575
|
+
if (method === 'GET') {
|
|
1576
|
+
sendJson(res, 200, { aliases: await deps.db.listRepositoryAliases(), repos: await deps.db.listAllRepos() }); return
|
|
1577
|
+
}
|
|
1578
|
+
if (method === 'DELETE') {
|
|
1579
|
+
await deps.db.deleteRepositoryAlias(s.session.id, id!)
|
|
1580
|
+
sendJson(res, 200, { ok: true }); return
|
|
1581
|
+
}
|
|
1582
|
+
let body
|
|
1583
|
+
try { body = await readJsonBody(req, 8192) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
1584
|
+
const alias = await deps.db.saveRepositoryAlias(s.session.id, id ?? null, body)
|
|
1585
|
+
sendJson(res, id ? 200 : 201, alias)
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Live suggestions do not mutate archived content or its stored assignment.
|
|
1589
|
+
// Managers can resolve only destinations their current repository grants cover.
|
|
1590
|
+
async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1591
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
1592
|
+
if (!await readManageSession(res, deps, cookie)) return
|
|
1593
|
+
const params = new URL(req.url!, 'http://localhost').searchParams
|
|
1594
|
+
const github = reportRepoGithub({ repo: { github: params.get('repo') } })
|
|
1595
|
+
const directory = normalizeTeamPath(params.get('directory'))
|
|
1596
|
+
const filePrefix = normalizeTeamPath(params.get('filePrefix'))
|
|
1597
|
+
if (!github || !directory.ok || !filePrefix.ok) { sendJson(res, 400, { error: 'bad-location' }); return }
|
|
1598
|
+
const location = await deps.db.getRepositoryImportLocation(github, directory.path ?? '', filePrefix.path ?? '')
|
|
1599
|
+
const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
|
|
1600
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1601
|
+
if (!s) return
|
|
1602
|
+
const allowed = repo && (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, location.directory))
|
|
1603
|
+
sendJson(res, 200, { location: allowed ? { repoId: repo.repoId, github: repo.fullName, directory: location.directory,
|
|
1604
|
+
mapped: github.toLowerCase() !== repo.fullName.toLowerCase() || (directory.path ?? '') !== location.directory } : null })
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1473
1607
|
// GET /api/reports/<id> — admin/manager preview, within management access.
|
|
1474
1608
|
// Accept: application/json includes parsed, filtered data and the server's repo
|
|
1475
1609
|
// assignment. Other callers retain the raw text/plain response. The
|
|
@@ -1479,32 +1613,33 @@ async function handleReportSources(req: IncomingMessage, res: ServerResponse, de
|
|
|
1479
1613
|
async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1480
1614
|
const s = await readManageSession(res, deps, cookie)
|
|
1481
1615
|
if (s == null) return
|
|
1482
|
-
|
|
1616
|
+
const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
1617
|
+
const access = snapshot?.reports[0]
|
|
1618
|
+
if (!snapshot || !access || !roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1483
1619
|
const bytes = await deps.reportStore.get(id)
|
|
1484
1620
|
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
if (
|
|
1488
|
-
const
|
|
1489
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1490
|
-
const repo = { github: await repositoryName(deps, report.repoId), directory: report.repoDirectory }
|
|
1491
|
-
const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
|
|
1621
|
+
const metadata = acceptsReportMetadata(req.headers.accept)
|
|
1622
|
+
let envelope
|
|
1623
|
+
if (metadata) {
|
|
1624
|
+
const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
1492
1625
|
if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1626
|
+
const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], await reportIds(data))
|
|
1627
|
+
envelope = { data, repo: access.repo, ...(links.length > 0 ? { links } : {}) }
|
|
1628
|
+
}
|
|
1629
|
+
// Link decryption and finding-ID backfill can outlast access or link changes.
|
|
1630
|
+
const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
|
|
1631
|
+
if (!current || current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role
|
|
1632
|
+
|| current.linkRevision !== snapshot.linkRevision || JSON.stringify(current.reports) !== JSON.stringify(snapshot.reports)) {
|
|
1633
|
+
sendJson(res, 404, { error: 'no-report' }); return
|
|
1634
|
+
}
|
|
1635
|
+
if (metadata) {
|
|
1636
|
+
sendJson(res, 200, envelope, { vary: 'Accept', 'x-content-type-options': 'nosniff' }); return
|
|
1498
1637
|
}
|
|
1499
|
-
const out = bytes
|
|
1500
1638
|
res.writeHead(200, {
|
|
1501
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
1502
|
-
'content-
|
|
1503
|
-
'x-content-type-options': 'nosniff',
|
|
1504
|
-
'cache-control': 'no-store',
|
|
1505
|
-
vary: 'Accept',
|
|
1639
|
+
'content-type': 'text/plain; charset=utf-8', 'content-length': String(bytes.length),
|
|
1640
|
+
'x-content-type-options': 'nosniff', 'cache-control': 'no-store', vary: 'Accept',
|
|
1506
1641
|
})
|
|
1507
|
-
writeResponse(res,
|
|
1642
|
+
writeResponse(res, bytes)
|
|
1508
1643
|
}
|
|
1509
1644
|
|
|
1510
1645
|
// A read-only POST avoids URL-length limits when a workspace has many reports.
|
|
@@ -1533,6 +1668,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1533
1668
|
// Overlap remote blob reads, retaining only each report's encoded response
|
|
1534
1669
|
// after processing. Check actual bytes too if storage and metadata disagree.
|
|
1535
1670
|
const parts = [Buffer.from('{"reports":[')]
|
|
1671
|
+
const findingIds = new Set<string>()
|
|
1536
1672
|
let inputBytes = 0, outputBytes = parts[0]!.length + 2
|
|
1537
1673
|
let next = 0, stopped = false
|
|
1538
1674
|
let inFlightBytes = 0
|
|
@@ -1562,6 +1698,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1562
1698
|
if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
1563
1699
|
const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
1564
1700
|
if (!data) return fail(422, 'unreadable-report')
|
|
1701
|
+
for (const findingId of await reportIds(data)) findingIds.add(findingId)
|
|
1565
1702
|
const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
|
|
1566
1703
|
outputBytes += part.length
|
|
1567
1704
|
if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
|
|
@@ -1586,23 +1723,27 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1586
1723
|
if (result.status === 'rejected') throw result.reason
|
|
1587
1724
|
if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
|
|
1588
1725
|
}
|
|
1726
|
+
const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], findingIds)
|
|
1589
1727
|
// Access to an earlier report may change while a later blob is fetched.
|
|
1590
1728
|
// Reject the whole answer if any content/access/assignment snapshot changed.
|
|
1591
1729
|
const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
|
|
1592
1730
|
if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1593
|
-
if (current.user.role !== snapshot.user.role || current.reports.length !== reports.size
|
|
1731
|
+
if (current.linkRevision !== snapshot.linkRevision || current.user.role !== snapshot.user.role || current.reports.length !== reports.size
|
|
1594
1732
|
|| current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
|
|
1595
1733
|
sendJson(res, 404, { error: 'no-report' }); return
|
|
1596
1734
|
}
|
|
1597
|
-
|
|
1735
|
+
const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
|
|
1736
|
+
outputBytes += suffix.length - 2
|
|
1737
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
|
|
1738
|
+
parts.push(suffix)
|
|
1598
1739
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
1599
1740
|
writeResponse(res, Buffer.concat(parts, outputBytes))
|
|
1600
1741
|
}
|
|
1601
1742
|
|
|
1602
|
-
// The server selects the complete workspace
|
|
1603
|
-
//
|
|
1604
|
-
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string): Promise<void> {
|
|
1605
|
-
const snapshot = await teamSnapshot(deps.db, session.id, teamId)
|
|
1743
|
+
// The server selects the complete published workspace for classification.
|
|
1744
|
+
// Privileged hidden-report previews are independently scoped and bounded.
|
|
1745
|
+
async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null): Promise<void> {
|
|
1746
|
+
const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
|
|
1606
1747
|
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
1607
1748
|
await recheckTeam(deps.db, session.id, snapshot)
|
|
1608
1749
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
@@ -1900,6 +2041,22 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
1900
2041
|
sendJson(res, 200, { ok: true, name })
|
|
1901
2042
|
}
|
|
1902
2043
|
|
|
2044
|
+
// Hidden teams retain their configuration but grant no access or sidebar presence.
|
|
2045
|
+
async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
2046
|
+
const s = await adminMutation(req, res, deps, cookie)
|
|
2047
|
+
if (s == null) return
|
|
2048
|
+
let body: unknown
|
|
2049
|
+
try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
|
|
2050
|
+
const teamId = (body as { teamId?: unknown } | null)?.teamId
|
|
2051
|
+
const hidden = (body as { hidden?: unknown } | null)?.hidden
|
|
2052
|
+
if (typeof teamId !== 'string' || typeof hidden !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
2053
|
+
const team = await deps.db.getTeam(teamId)
|
|
2054
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
2055
|
+
if (!(await deps.db.setTeamHidden(teamId, hidden, Date.now()))) { sendJson(res, 404, { error: 'no-team' }); return }
|
|
2056
|
+
if (team?.hidden !== hidden) await activity(deps, s.user, 'access', `${hidden ? 'hid' : 'restored'} team ${team?.name ?? teamId}`)
|
|
2057
|
+
sendJson(res, 200, { ok: true, hidden })
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1903
2060
|
// POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
|
|
1904
2061
|
async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
1905
2062
|
const s = await adminMutation(req, res, deps, cookie)
|
|
@@ -2115,6 +2272,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2115
2272
|
if (issueRoute) {
|
|
2116
2273
|
await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
|
|
2117
2274
|
}
|
|
2275
|
+
const deduplication = /^\/api\/admin\/deduplication(?:\/([a-f\d-]{36}))?$/iu.exec(path)
|
|
2276
|
+
if (deduplication) { await handleDeduplication(req, res, deps, cookie, deduplication[1]); return }
|
|
2277
|
+
const repositoryAliases = /^\/api\/admin\/repositories\/aliases(?:\/([a-f\d-]{36}))?$/iu.exec(path)
|
|
2278
|
+
if (repositoryAliases) { await handleRepositoryAliases(req, res, deps, cookie, repositoryAliases[1]); return }
|
|
2279
|
+
if (path === '/api/admin/repositories/resolve') { await handleRepositorySuggestion(req, res, deps, cookie); return }
|
|
2118
2280
|
if (path === '/api/admin/links') {
|
|
2119
2281
|
if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
|
|
2120
2282
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
@@ -2214,7 +2376,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2214
2376
|
const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
|
|
2215
2377
|
if (bundleAdvisories) {
|
|
2216
2378
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2217
|
-
await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true')
|
|
2379
|
+
await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
|
|
2218
2380
|
return
|
|
2219
2381
|
}
|
|
2220
2382
|
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
|
|
@@ -2238,6 +2400,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2238
2400
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2239
2401
|
await handleSetBundleRepo(req, res, deps, cookie); return
|
|
2240
2402
|
}
|
|
2403
|
+
if (path === BUNDLE_SET_VISIBLE_PATH) {
|
|
2404
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2405
|
+
await handleSetBundleVisible(req, res, deps, cookie); return
|
|
2406
|
+
}
|
|
2241
2407
|
if (path.startsWith(BUNDLE_PREFIX)) {
|
|
2242
2408
|
const id = path.slice(BUNDLE_PREFIX.length)
|
|
2243
2409
|
if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
|
|
@@ -2250,18 +2416,19 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2250
2416
|
const s = await readWorkspaceSession(res, deps, cookie)
|
|
2251
2417
|
if (!s) return
|
|
2252
2418
|
const teamId = teamFeed?.[1] ?? null
|
|
2253
|
-
|
|
2254
|
-
await
|
|
2419
|
+
const reportId = url.searchParams.get('reportId')
|
|
2420
|
+
if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
|
|
2421
|
+
await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
|
|
2255
2422
|
}
|
|
2256
2423
|
const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
|
|
2257
2424
|
if (teamFixes) {
|
|
2258
2425
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2259
|
-
await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]
|
|
2426
|
+
await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!, url.searchParams.get('reportId')); return
|
|
2260
2427
|
}
|
|
2261
2428
|
const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
|
|
2262
2429
|
if (teamAnnotations) {
|
|
2263
2430
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2264
|
-
const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]
|
|
2431
|
+
const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!, url.searchParams.get('reportId'))
|
|
2265
2432
|
const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
|
|
2266
2433
|
await recheckTeam(db, workspaceSession!.session.id, snapshot)
|
|
2267
2434
|
sendJson(res, 200, annotations); return
|
|
@@ -2269,7 +2436,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2269
2436
|
const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
|
|
2270
2437
|
if (teamReports) {
|
|
2271
2438
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2272
|
-
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]
|
|
2439
|
+
await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
|
|
2273
2440
|
}
|
|
2274
2441
|
if (path === '/api/reports/query') {
|
|
2275
2442
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
@@ -2316,11 +2483,12 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2316
2483
|
if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
|
|
2317
2484
|
send405(res, 'GET, POST'); return
|
|
2318
2485
|
}
|
|
2319
|
-
if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
|
|
2486
|
+
if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_HIDDEN_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
|
|
2320
2487
|
|| path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
|
|
2321
2488
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2322
2489
|
if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
|
|
2323
2490
|
if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
|
|
2491
|
+
if (path === TEAM_SET_HIDDEN_PATH) { await handleSetTeamHidden(req, res, deps, cookie); return }
|
|
2324
2492
|
if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
|
|
2325
2493
|
if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
|
|
2326
2494
|
if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
|