@preventive/triage 1.0.0-alpha.22 → 1.0.0-alpha.25
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/out/client-managed-import.js +41 -41
- package/out/client-managed.js +74 -59
- package/out/graph.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +170 -169
- package/package.json +5 -1
- package/report/index.js +7 -1
- package/report/src/finding-id.js +1 -1
- package/report/src/finding.js +6 -4
- package/report/src/labels.js +3 -1
- package/report/src/parse-codex.js +3 -1
- package/report/src/parse-deepsec.js +3 -1
- package/report/src/parse-deepview-md.js +2 -1
- package/report/src/parse-md.js +3 -2
- package/report/src/parse-piolium-rows.js +7 -12
- package/report/src/parse-piolium-tokens.js +12 -2
- package/report/src/parse-piolium.js +7 -8
- package/report/src/severity.js +18 -0
- package/server-common/vercel-blob.ts +7 -18
- package/server-e2e/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +4 -4
- package/server-managed/db-methods.ts +30 -12
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/github-app.ts +5 -17
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/http.ts +75 -83
- package/server-managed/import-triage.ts +2 -2
- package/server-managed/management.ts +86 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +1 -11
- package/server-managed/repository-browser.ts +34 -13
- package/server-managed/sql.ts +5 -0
- package/server-managed/workspace-shares.ts +3 -3
|
@@ -6,7 +6,7 @@ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-met
|
|
|
6
6
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
7
7
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
8
8
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
9
|
-
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
9
|
+
import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
10
10
|
import { postgresSchema, postgresSql } from './sql-postgres.ts'
|
|
11
11
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
12
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
@@ -144,16 +144,23 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
|
|
|
144
144
|
async scope(write, work) {
|
|
145
145
|
if (closed) throw new Error('Managed database is closed')
|
|
146
146
|
const db = await connect()
|
|
147
|
+
let commitAttempted = false
|
|
148
|
+
let result
|
|
147
149
|
try {
|
|
148
150
|
await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
|
|
149
151
|
if (write) await db.query(LOCK)
|
|
150
|
-
|
|
152
|
+
result = await context.run(db, work)
|
|
153
|
+
commitAttempted = true
|
|
151
154
|
await db.query('COMMIT')
|
|
152
|
-
return result
|
|
153
155
|
} catch (err) {
|
|
154
156
|
await db.query('ROLLBACK').catch(() => {})
|
|
157
|
+
await db.release().catch(() => {})
|
|
158
|
+
if (write && commitAttempted) throw new ManagedCommitError(err)
|
|
155
159
|
throw err
|
|
156
|
-
}
|
|
160
|
+
}
|
|
161
|
+
try { await db.release() }
|
|
162
|
+
catch (err) { if (write) throw new ManagedCommitError(err); throw err }
|
|
163
|
+
return result
|
|
157
164
|
},
|
|
158
165
|
close() { closed = true },
|
|
159
166
|
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { Buffer } from 'node:buffer'
|
|
6
6
|
import { createHash, createSign } from 'node:crypto'
|
|
7
7
|
import type { ManagedConfig } from './config.ts'
|
|
8
|
+
import { GithubApiError, throwGithubResponseError } from './github-errors.ts'
|
|
9
|
+
|
|
10
|
+
export { GithubApiError } from './github-errors.ts'
|
|
8
11
|
|
|
9
12
|
const GITHUB_API = 'https://api.github.com'
|
|
10
13
|
const API_VERSION = '2022-11-28'
|
|
@@ -39,17 +42,6 @@ export interface ConnectedRepo {
|
|
|
39
42
|
installationId: number | null
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
// A failure carrying the HTTP status the router should surface. 401 passes
|
|
43
|
-
// through so the user-token path can map it to "log in again".
|
|
44
|
-
export class GithubApiError extends Error {
|
|
45
|
-
status: number
|
|
46
|
-
constructor(status: number, message: string) {
|
|
47
|
-
super(message)
|
|
48
|
-
this.name = 'GithubApiError'
|
|
49
|
-
this.status = status
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
45
|
// App installation credentials are configured (id + private key present) →
|
|
54
46
|
// private repos can be listed via its installation tokens.
|
|
55
47
|
export function githubAppConfigured(config: ManagedConfig): boolean {
|
|
@@ -89,12 +81,8 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
|
|
|
89
81
|
},
|
|
90
82
|
})
|
|
91
83
|
} catch { throw new GithubApiError(502, 'github-unreachable') }
|
|
92
|
-
if (res.status === 401)
|
|
93
|
-
|
|
94
|
-
throw new GithubApiError(401, 'github-unauthorized')
|
|
95
|
-
}
|
|
96
|
-
if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
97
|
-
if (!res.ok) throw new GithubApiError(502, `github-status-${res.status}`)
|
|
84
|
+
if (res.status === 401) discardInstallationToken(token, fetchImpl)
|
|
85
|
+
if (!res.ok) await throwGithubResponseError(res)
|
|
98
86
|
try { return await res.json() } catch { throw new GithubApiError(502, 'github-malformed') }
|
|
99
87
|
}
|
|
100
88
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A failure carrying the HTTP status the router should surface. 401 passes
|
|
2
|
+
// through so the user-token path can map it to "log in again".
|
|
3
|
+
export class GithubApiError extends Error {
|
|
4
|
+
status: number
|
|
5
|
+
retryAfter: number | null
|
|
6
|
+
constructor(status: number, message: string, retryAfter: number | null = null) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'GithubApiError'
|
|
9
|
+
this.status = status
|
|
10
|
+
this.retryAfter = retryAfter
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function throwGithubResponseError(res: Response): Promise<never> {
|
|
15
|
+
if (res.status === 403 || res.status === 429) {
|
|
16
|
+
const body = await res.json().catch(() => null) as { message?: unknown } | null
|
|
17
|
+
if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
|
|
18
|
+
|| (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
|
|
19
|
+
const retry = Number(res.headers.get('retry-after'))
|
|
20
|
+
const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
|
|
21
|
+
const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
|
|
22
|
+
throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
|
|
26
|
+
if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
|
|
27
|
+
throw new GithubApiError(502, `github-status-${res.status}`)
|
|
28
|
+
}
|
package/server-managed/http.ts
CHANGED
|
@@ -92,6 +92,8 @@ import { handlePublicWorkspace } from './public-workspace.ts'
|
|
|
92
92
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
93
93
|
import { hashToken, randomToken } from './crypto.ts'
|
|
94
94
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
95
|
+
import { ManagedCommitError } from './sql.ts'
|
|
96
|
+
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
95
97
|
|
|
96
98
|
const SESSION_PATH = '/api/auth/session'
|
|
97
99
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
@@ -291,6 +293,7 @@ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
291
293
|
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
292
294
|
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
293
295
|
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
296
|
+
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
294
297
|
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
295
298
|
sendJson(res, 200, { ok: true })
|
|
296
299
|
}
|
|
@@ -462,7 +465,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
462
465
|
// access (never trust a client-supplied id) and capture the server-derived read
|
|
463
466
|
// context (installation id, default branch). A PRIVATE repo with no installation
|
|
464
467
|
// can't be read server-side → 409. Stores via selectRepo (upsert).
|
|
465
|
-
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number): Promise<void> {
|
|
468
|
+
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
|
|
466
469
|
if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
467
470
|
const userId = user.id
|
|
468
471
|
const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
|
|
@@ -489,6 +492,7 @@ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user
|
|
|
489
492
|
sendJson(res, 403, { error: 'forbidden' }); return
|
|
490
493
|
}
|
|
491
494
|
}
|
|
495
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
492
496
|
await connectRepository(res, deps, user, repo)
|
|
493
497
|
}
|
|
494
498
|
|
|
@@ -580,11 +584,12 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
580
584
|
}
|
|
581
585
|
if (!selected) {
|
|
582
586
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
587
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
583
588
|
await deps.db.deactivateRepo(repoId)
|
|
584
589
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
585
590
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
586
591
|
}
|
|
587
|
-
await selectRepository(res, deps, s.user, repoId)
|
|
592
|
+
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
588
593
|
}
|
|
589
594
|
|
|
590
595
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
@@ -614,13 +619,15 @@ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
|
|
|
614
619
|
}
|
|
615
620
|
}
|
|
616
621
|
|
|
617
|
-
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[]): Promise<string[]> {
|
|
622
|
+
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
|
|
618
623
|
const targetIds = await repositoryFindingIds(deps, reports)
|
|
619
|
-
const triage = await deps.db.listTriage([...targetIds])
|
|
620
|
-
const commentIds = await deps.db.listCommentedFindingIds([...targetIds])
|
|
621
|
-
|
|
624
|
+
const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
|
|
625
|
+
const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
|
|
626
|
+
// Removal includes every exclusive finding, including annotations created
|
|
627
|
+
// during the blob scan; impact only needs the currently annotated subset.
|
|
628
|
+
const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
|
|
622
629
|
if (annotatedIds.size === 0) return []
|
|
623
|
-
//
|
|
630
|
+
// Impact can skip unannotated findings; deletion must also cover new writes.
|
|
624
631
|
// TODO(managed): Persist finding IDs per report at upload time and maintain
|
|
625
632
|
// the index on report deletion. Use it for repository impact and triage
|
|
626
633
|
// cleanup so overlap checks do not fetch and parse every other report blob.
|
|
@@ -661,13 +668,12 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
661
668
|
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
662
669
|
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
663
670
|
if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
|
|
664
|
-
const
|
|
665
|
-
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
671
|
+
const allReports = deleteTriage ? await deps.db.listReports() : []
|
|
666
672
|
const triageIds = deleteTriage
|
|
667
|
-
? await repositoryExclusiveTriageIds(deps,
|
|
673
|
+
? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
|
|
668
674
|
: []
|
|
669
|
-
const deletedReports = await deps.db.
|
|
670
|
-
|
|
675
|
+
const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
|
|
676
|
+
deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
|
|
671
677
|
// Remove metadata first so a blob-store failure leaves an orphaned blob for
|
|
672
678
|
// later cleanup, rather than a live row pointing at missing report data.
|
|
673
679
|
for (const report of reports) {
|
|
@@ -675,15 +681,11 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
675
681
|
await deps.reportStore.delete(report.id).catch(() => {})
|
|
676
682
|
}
|
|
677
683
|
for (const bundle of bundles) {
|
|
678
|
-
//
|
|
679
|
-
// report rows needed to reconstruct exclusive finding IDs are gone.
|
|
684
|
+
// Best-effort derivative cleanup follows the committed metadata removal.
|
|
680
685
|
await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
681
686
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
682
687
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
683
688
|
}
|
|
684
|
-
const deletedTriage = await deps.db.deleteTriage(triageIds)
|
|
685
|
-
await deps.db.deleteRepo(repoId)
|
|
686
|
-
await activity(deps, s.user, 'delete', `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)`, { repo: repo.fullName })
|
|
687
689
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
688
690
|
}
|
|
689
691
|
|
|
@@ -733,15 +735,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
733
735
|
return { ok: true, repoId: n }
|
|
734
736
|
}
|
|
735
737
|
|
|
736
|
-
// True iff `repoId` may be linked to a report / bundle: null (detach) or a
|
|
737
|
-
// currently-selected repo id (so the FK can't dangle and you can only attach to
|
|
738
|
-
// a managed repo).
|
|
739
|
-
async function repoIdAllowed(deps: ManagedHttpDeps, repoId: unknown): Promise<boolean> {
|
|
740
|
-
if (repoId == null) return true
|
|
741
|
-
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) return false
|
|
742
|
-
return (await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)
|
|
743
|
-
}
|
|
744
|
-
|
|
745
738
|
async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
746
739
|
if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
|
|
747
740
|
const report = await deps.db.getReport(reportId)
|
|
@@ -761,18 +754,12 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
761
754
|
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
762
755
|
const directory = (body as { directory?: unknown } | null)?.directory ?? ''
|
|
763
756
|
if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
764
|
-
if (
|
|
765
|
-
const report = await deps.db.getReport(reportId)
|
|
766
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
767
|
-
if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
768
|
-
if (report.repoEmbedded) { sendJson(res, 409, { error: 'repo-in-report' }); return }
|
|
769
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
757
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
770
758
|
const normalized = normalizeTeamPath(directory)
|
|
771
759
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
772
|
-
|
|
773
|
-
if (!(await deps.db.setReportRepo(reportId, repoId as number | null, normalized.path ?? ''))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
760
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
|
|
774
761
|
if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
|
|
775
|
-
await activity(deps,
|
|
762
|
+
await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
|
|
776
763
|
}
|
|
777
764
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
|
|
778
765
|
}
|
|
@@ -787,12 +774,8 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
787
774
|
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
788
775
|
const visible = (body as { visible?: unknown } | null)?.visible
|
|
789
776
|
if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
const report = await deps.db.getReport(reportId)
|
|
793
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
794
|
-
if (!(await deps.db.setReportVisible(reportId, visible))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
795
|
-
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) })
|
|
777
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
|
|
778
|
+
if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
796
779
|
sendJson(res, 200, { ok: true, visible })
|
|
797
780
|
}
|
|
798
781
|
|
|
@@ -809,16 +792,9 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
809
792
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
810
793
|
const directory = repoId == null ? '' : normalized.path ?? ''
|
|
811
794
|
if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
812
|
-
if (
|
|
813
|
-
const bundle = await deps.db.
|
|
814
|
-
if (bundle
|
|
815
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
816
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
817
|
-
if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, directory))) {
|
|
818
|
-
sendJson(res, 403, { error: 'forbidden' }); return
|
|
819
|
-
}
|
|
820
|
-
if (!(await deps.db.setBundleRepo(bundleId, repoId as number | null, directory))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
821
|
-
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) })
|
|
795
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
796
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
|
|
797
|
+
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
|
|
822
798
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
|
|
823
799
|
}
|
|
824
800
|
|
|
@@ -873,8 +849,9 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
873
849
|
// headers when the report has no repository metadata. The bundle link is
|
|
874
850
|
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
875
851
|
// published. Identical content reuses its stored identity and metadata. Bytes
|
|
876
|
-
// are written first (keyed by a fresh uuid) then the metadata row
|
|
877
|
-
// insert or concurrent duplicate drops the orphan blob
|
|
852
|
+
// are written first (keyed by a fresh uuid) then the metadata row. A definite
|
|
853
|
+
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
854
|
+
// commit retains it. 413 over the cap, 400 on empty.
|
|
878
855
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
879
856
|
let s = await checkMutation(req, res, deps, cookie)
|
|
880
857
|
if (s == null) return
|
|
@@ -931,9 +908,10 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
931
908
|
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
932
909
|
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
933
910
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
934
|
-
}, Date.now())
|
|
911
|
+
}, Date.now(), s.session.id)
|
|
935
912
|
} catch (err) {
|
|
936
|
-
|
|
913
|
+
// Losing the commit acknowledgement cannot turn primary bytes into garbage.
|
|
914
|
+
if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
|
|
937
915
|
throw err
|
|
938
916
|
}
|
|
939
917
|
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
@@ -976,15 +954,10 @@ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
976
954
|
const s = await checkMutation(req, res, deps, cookie)
|
|
977
955
|
if (s == null) return
|
|
978
956
|
if (!requireManageRole(res, s.user)) return
|
|
979
|
-
|
|
980
|
-
if (!(await canChangeReportRepo(deps, s.user, id))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
981
|
-
const report = await deps.db.getReport(id)
|
|
982
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
983
|
-
const existed = await deps.db.deleteReport(id)
|
|
957
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
984
958
|
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
985
959
|
await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
|
|
986
|
-
|
|
987
|
-
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) })
|
|
960
|
+
await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
988
961
|
sendJson(res, 200, { ok: true })
|
|
989
962
|
}
|
|
990
963
|
|
|
@@ -1057,10 +1030,13 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1057
1030
|
if (!current) return
|
|
1058
1031
|
if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1059
1032
|
sendJson(res, 200, refs ? result : {
|
|
1060
|
-
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
|
|
1033
|
+
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
|
|
1061
1034
|
})
|
|
1062
1035
|
} catch (err) {
|
|
1063
|
-
if (err instanceof GithubApiError) {
|
|
1036
|
+
if (err instanceof GithubApiError) {
|
|
1037
|
+
sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
1064
1040
|
throw err
|
|
1065
1041
|
}
|
|
1066
1042
|
}
|
|
@@ -1203,6 +1179,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1203
1179
|
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1204
1180
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1205
1181
|
if (existing != null) {
|
|
1182
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1183
|
+
if (!s) return
|
|
1206
1184
|
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1207
1185
|
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1208
1186
|
// integrity. An authorized re-upload repairs those pending links too.
|
|
@@ -1218,14 +1196,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1218
1196
|
await deps.db.insertBundle({
|
|
1219
1197
|
id, integrity, filename, kind,
|
|
1220
1198
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1221
|
-
}, Date.now())
|
|
1199
|
+
}, Date.now(), s.session.id)
|
|
1222
1200
|
} catch (err) {
|
|
1223
|
-
|
|
1201
|
+
// A committed row may already point here even though insertion rejected.
|
|
1202
|
+
if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
|
|
1203
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1224
1204
|
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1225
1205
|
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1226
1206
|
// Any other failure rethrows.
|
|
1227
1207
|
const raced = await deps.db.getBundleByIntegrity(integrity)
|
|
1228
1208
|
if (raced != null) {
|
|
1209
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1210
|
+
if (!s) return
|
|
1229
1211
|
if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1230
1212
|
await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1231
1213
|
prebuildBundle(deps, raced.id)
|
|
@@ -1233,6 +1215,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1233
1215
|
throw err
|
|
1234
1216
|
}
|
|
1235
1217
|
// Auto-link reports that declared this integrity before the bundle existed.
|
|
1218
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1219
|
+
if (!s) return
|
|
1236
1220
|
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1237
1221
|
prebuildBundle(deps, id)
|
|
1238
1222
|
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
@@ -1276,16 +1260,11 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1276
1260
|
const s = await checkMutation(req, res, deps, cookie)
|
|
1277
1261
|
if (s == null) return
|
|
1278
1262
|
if (!requireManageRole(res, s.user)) return
|
|
1279
|
-
|
|
1280
|
-
const bundle = await deps.db.getBundle(id)
|
|
1281
|
-
if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1282
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
1283
|
-
const existed = await deps.db.deleteBundle(id)
|
|
1263
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
1284
1264
|
await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
1285
1265
|
await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1286
1266
|
await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
|
|
1287
|
-
|
|
1288
|
-
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) })
|
|
1267
|
+
await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1289
1268
|
sendJson(res, 200, { ok: true })
|
|
1290
1269
|
}
|
|
1291
1270
|
|
|
@@ -1586,25 +1565,29 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1586
1565
|
}
|
|
1587
1566
|
|
|
1588
1567
|
// GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
|
|
1589
|
-
// trail (newest first, capped)
|
|
1590
|
-
//
|
|
1568
|
+
// trail (newest first, capped). The caller must have at least triage access
|
|
1569
|
+
// to the report, and the finding is one their visibility permissions
|
|
1591
1570
|
// keep in it (a stripped or foreign id 404s without revealing whether it
|
|
1592
1571
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
1593
1572
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
1594
1573
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
1595
1574
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1596
1575
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1597
|
-
if (!(await
|
|
1576
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1598
1577
|
const finding = query.get('finding') ?? ''
|
|
1599
1578
|
if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1600
1579
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1601
1580
|
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1602
|
-
if (!current || !(await
|
|
1581
|
+
if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1603
1582
|
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1604
1583
|
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1605
1584
|
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1606
1585
|
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1607
1586
|
}))
|
|
1587
|
+
// A database read can outlive a role, membership, or visibility change.
|
|
1588
|
+
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1589
|
+
if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1590
|
+
if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1608
1591
|
sendJson(res, 200, { finding, events })
|
|
1609
1592
|
}
|
|
1610
1593
|
|
|
@@ -1651,7 +1634,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
1651
1634
|
|
|
1652
1635
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1653
1636
|
// writing. The store commits shared finding triage and imported comments together.
|
|
1654
|
-
async function
|
|
1637
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1655
1638
|
const s = await adminMutation(req, res, deps, cookie)
|
|
1656
1639
|
if (!s) return
|
|
1657
1640
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
@@ -1675,10 +1658,15 @@ async function handleImportReportTriage(req: IncomingMessage, res: ServerRespons
|
|
|
1675
1658
|
parsed.push([findingId, patch])
|
|
1676
1659
|
}
|
|
1677
1660
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1661
|
+
// Workspace imports remain report-scoped. Standalone local triage can be
|
|
1662
|
+
// imported before its reports; only administrators have this global access.
|
|
1663
|
+
let visible: Set<string> | undefined
|
|
1664
|
+
if (id !== undefined) {
|
|
1665
|
+
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1666
|
+
visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1667
|
+
}
|
|
1680
1668
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1681
|
-
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1669
|
+
if (visible && ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1682
1670
|
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
1683
1671
|
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
1684
1672
|
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
@@ -2076,10 +2064,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2076
2064
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2077
2065
|
await handleSetReportVisible(req, res, deps, cookie); return
|
|
2078
2066
|
}
|
|
2067
|
+
if (path === '/api/admin/import-triage') {
|
|
2068
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2069
|
+
await handleImportTriage(req, res, deps, cookie); return
|
|
2070
|
+
}
|
|
2079
2071
|
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
2080
2072
|
if (importTriage) {
|
|
2081
2073
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2082
|
-
await
|
|
2074
|
+
await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
2083
2075
|
}
|
|
2084
2076
|
if (path.startsWith(REPORT_PREFIX)) {
|
|
2085
2077
|
const id = path.slice(REPORT_PREFIX.length)
|
|
@@ -2201,7 +2193,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2201
2193
|
return (req, res) => {
|
|
2202
2194
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
2203
2195
|
const work = route(req, res).catch((err) => {
|
|
2204
|
-
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2196
|
+
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2205
2197
|
console.warn('managed: request handler error:', err)
|
|
2206
2198
|
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
2207
2199
|
else sendJson(res, 500, { error: 'internal' })
|
|
@@ -11,7 +11,7 @@ export interface ImportTriageSnapshot {
|
|
|
11
11
|
}
|
|
12
12
|
export interface ImportTriageStore {
|
|
13
13
|
getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
|
|
14
|
-
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
|
|
14
|
+
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string | undefined, now: number): Promise<boolean>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
@@ -38,7 +38,7 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
|
|
|
38
38
|
changes.push([id, Object.keys(entry).length > 0 ? entry : null])
|
|
39
39
|
if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
|
|
40
40
|
await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
|
|
41
|
-
actor, createdAt: null, updatedAt: null, reportId }, now)
|
|
41
|
+
actor, createdAt: null, updatedAt: null, reportId: reportId ?? null }, now)
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AdminReport, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
2
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
3
|
+
|
|
4
|
+
export class ManagedMutationError extends Error {
|
|
5
|
+
status: number
|
|
6
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string }
|
|
10
|
+
export type ReportMutation = ContentMutation | { type: 'visibility'; visible: boolean }
|
|
11
|
+
type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
|
|
12
|
+
type RemovalAnnotations = { reports: string; ids: string[] }
|
|
13
|
+
export interface ManagementStore {
|
|
14
|
+
mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
|
|
15
|
+
mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
|
|
16
|
+
removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
|
|
17
|
+
reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
|
|
18
|
+
}>
|
|
19
|
+
}
|
|
20
|
+
type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
|
|
21
|
+
| 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
|
|
22
|
+
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo'
|
|
23
|
+
| 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
|
|
24
|
+
|
|
25
|
+
// Only immutable bytes/format and repository membership affect the overlap
|
|
26
|
+
// scan. Ignore publication, user display names, and unrelated bundle links.
|
|
27
|
+
export function reportReferenceSnapshot(reports: readonly Pick<AdminReport, 'id' | 'sha256' | 'filename' | 'repoId'>[]): string {
|
|
28
|
+
return JSON.stringify(reports.map(r => [r.id, r.sha256, r.filename, r.repoId]).toSorted((a, b) => String(a[0]).localeCompare(String(b[0]))))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// These methods are wrapped by scopeManagedMethods: authorization, comparison,
|
|
32
|
+
// metadata changes (and repository-removal audit) share the writer transaction. Blob reads
|
|
33
|
+
// and deletes stay outside it. Use the raw store here, never nested scopes.
|
|
34
|
+
export function managementMethods(db: ManagementDb): ManagementStore {
|
|
35
|
+
async function authorize(sessionId: string, role: 'admin' | 'manage') {
|
|
36
|
+
const s = await db.sessionWithUser(sessionId, Date.now())
|
|
37
|
+
if (!s) throw new ManagedMutationError(401, 'unauthenticated')
|
|
38
|
+
if (!roleAtLeast(s.user.role, role)) throw new ManagedMutationError(403, 'forbidden')
|
|
39
|
+
return s.user
|
|
40
|
+
}
|
|
41
|
+
async function destination(user: { id: string; role: string }, change: ContentMutation) {
|
|
42
|
+
if (change.type !== 'repo' || change.repoId === null) return
|
|
43
|
+
if (!(await db.listSelectedRepos()).some(repo => repo.repoId === change.repoId)) throw new ManagedMutationError(400, 'bad-repo')
|
|
44
|
+
if (user.role !== 'admin' && !await db.userCanReadRepoPath(user.id, change.repoId, change.directory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
async mutateReport(sessionId: string, id: string, change: ReportMutation) {
|
|
48
|
+
const user = await authorize(sessionId, 'manage')
|
|
49
|
+
const report = await db.getReport(id)
|
|
50
|
+
if (!report || (user.role !== 'admin' && report.uploadedBy !== user.id && !await db.userCanReadReport(user.id, id))) throw new ManagedMutationError(404, 'no-report')
|
|
51
|
+
if (user.role !== 'admin' && report.repoId !== null && !await db.userCanReadReport(user.id, id)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
52
|
+
if (change.type === 'repo' && report.repoEmbedded) throw new ManagedMutationError(409, 'repo-in-report')
|
|
53
|
+
if (change.type !== 'visibility') await destination(user, change)
|
|
54
|
+
if (change.type === 'delete') await db.deleteReport(id)
|
|
55
|
+
else if (change.type === 'visibility') await db.setReportVisible(id, change.visible)
|
|
56
|
+
else await db.setReportRepo(id, change.repoId, change.directory)
|
|
57
|
+
return { report, user }
|
|
58
|
+
},
|
|
59
|
+
async mutateBundle(sessionId: string, id: string, change: ContentMutation) {
|
|
60
|
+
const user = await authorize(sessionId, 'manage')
|
|
61
|
+
const bundle = await db.getBundle(id)
|
|
62
|
+
if (!bundle || (user.role !== 'admin' && !await db.userCanReadBundle(user.id, id))) throw new ManagedMutationError(404, 'no-bundle')
|
|
63
|
+
if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
64
|
+
await destination(user, change)
|
|
65
|
+
if (change.type === 'delete') await db.deleteBundle(id)
|
|
66
|
+
else await db.setBundleRepo(id, change.repoId, change.directory)
|
|
67
|
+
return { bundle, user }
|
|
68
|
+
},
|
|
69
|
+
async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
|
|
70
|
+
const user = await authorize(sessionId, 'admin')
|
|
71
|
+
const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
|
|
72
|
+
if (!repo) throw new ManagedMutationError(404, 'no-repo')
|
|
73
|
+
if (repo.fullName !== expected.fullName || repo.addedAt !== expected.addedAt) throw new ManagedMutationError(409, 'repository-changed')
|
|
74
|
+
if (annotations && reportReferenceSnapshot(await db.listReports()) !== annotations.reports) throw new ManagedMutationError(409, 'repository-changed')
|
|
75
|
+
const reports = await db.listReportsForRepo(repo.repoId)
|
|
76
|
+
const bundles = await db.listBundlesForRepo(repo.repoId)
|
|
77
|
+
const deletedReports = await db.deleteReportsForRepo(repo.repoId)
|
|
78
|
+
const deletedBundles = await db.deleteBundlesForRepo(repo.repoId)
|
|
79
|
+
const deletedTriage = await db.deleteTriage(annotations?.ids ?? [])
|
|
80
|
+
await db.deleteRepo(repo.repoId)
|
|
81
|
+
await db.recordActivity({ kind: 'delete', actor: user.login, actorId: user.id, repo: repo.fullName,
|
|
82
|
+
action: `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)` }, Date.now())
|
|
83
|
+
return { reports, bundles, deletedReports, deletedBundles, deletedTriage }
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|