@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.27
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 +18 -18
- package/out/client-managed.js +47 -47
- package/out/view.css +1 -1
- package/out/view.js +159 -159
- package/package.json +12 -2
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +5 -1
- package/server-common/vercel-blob.ts +6 -3
- package/server-managed/avatar-store.ts +1 -40
- package/server-managed/blob-store.ts +3 -52
- package/server-managed/bundle-cache.ts +0 -28
- package/server-managed/bundle-store.ts +3 -7
- package/server-managed/cache-storage.ts +0 -25
- package/server-managed/cli.js +10 -2
- package/server-managed/config.ts +12 -1
- package/server-managed/db-methods.ts +42 -21
- package/server-managed/db-neon.ts +9 -4
- package/server-managed/db.ts +7 -1
- package/server-managed/http.ts +37 -39
- package/server-managed/index.ts +3 -2
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/repository-browser.ts +5 -1
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql.ts +0 -5
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +168 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +182 -0
- package/server-managed/storage-payload.ts +60 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +46 -24
- package/server-managed/uploads.ts +2 -2
- package/server-managed/blob-vercel.ts +0 -127
|
@@ -12,6 +12,8 @@ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
|
12
12
|
import type { ManagedSql } from './sql.ts'
|
|
13
13
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
14
14
|
import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
|
|
15
|
+
import { type StorageKey } from '../server-common/storage-crypto.ts'
|
|
16
|
+
import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
|
|
15
17
|
|
|
16
18
|
// A managed user identity (the subset of GitHub's `GET /user` we keep). Input
|
|
17
19
|
// to the upsert; `githubUserId` is the provider lookup key, never exposed to
|
|
@@ -131,6 +133,7 @@ export interface ReportAccessSnapshot {
|
|
|
131
133
|
// bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
|
|
132
134
|
// so a later bundle upload of that integrity re-links it).
|
|
133
135
|
export interface ReportRecordInput {
|
|
136
|
+
dataKey?: string | null
|
|
134
137
|
id: string
|
|
135
138
|
filename: string
|
|
136
139
|
contentType: string
|
|
@@ -228,7 +231,7 @@ export interface ManagedBundle {
|
|
|
228
231
|
|
|
229
232
|
// What the upload handler supplies to record a bundle; the store stamps
|
|
230
233
|
// uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
|
|
231
|
-
export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string }
|
|
234
|
+
export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
|
|
232
235
|
|
|
233
236
|
// A bundle row for the "Manage bundles" list — adds the uploader login + repo
|
|
234
237
|
// full name display joins (null when absent / since removed).
|
|
@@ -310,7 +313,7 @@ export interface UserTeam {
|
|
|
310
313
|
}
|
|
311
314
|
|
|
312
315
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
313
|
-
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
|
|
316
|
+
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
|
|
314
317
|
// Upsert the identity; returns the user's opaque id (stable across logins).
|
|
315
318
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
316
319
|
// promotes a matching No access identity only while it is the sole user.
|
|
@@ -358,6 +361,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
358
361
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
359
362
|
listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
|
|
360
363
|
getReport(id: string): Promise<ReportRecord | null>
|
|
364
|
+
// Writer-locked reconciliation waits for an uncertain earlier insert.
|
|
365
|
+
resolveReportUpload(id: string): Promise<ReportRecord | null>
|
|
361
366
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
|
|
362
367
|
getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
|
|
363
368
|
listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
|
|
@@ -392,6 +397,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
392
397
|
// declared its integrity.
|
|
393
398
|
insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
|
|
394
399
|
getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
|
|
400
|
+
resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
|
|
395
401
|
getBundle(id: string): Promise<ManagedBundle | null>
|
|
396
402
|
listBundles(userId?: string): Promise<AdminBundle[]>
|
|
397
403
|
userCanReadBundle(userId: string, id: string): Promise<boolean>
|
|
@@ -499,11 +505,11 @@ function prepareStatements(db: ManagedSql) {
|
|
|
499
505
|
updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
|
|
500
506
|
updateTokensStmt: db.prepare(
|
|
501
507
|
`UPDATE managed_user
|
|
502
|
-
SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
|
|
508
|
+
SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
|
|
503
509
|
WHERE id = ?`,
|
|
504
510
|
),
|
|
505
511
|
selectTokensStmt: db.prepare(
|
|
506
|
-
`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
|
|
512
|
+
`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
|
|
507
513
|
FROM managed_user WHERE id = ?`,
|
|
508
514
|
),
|
|
509
515
|
deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
|
|
@@ -551,9 +557,9 @@ function prepareStatements(db: ManagedSql) {
|
|
|
551
557
|
),
|
|
552
558
|
insertReportStmt: db.prepare(
|
|
553
559
|
`WITH candidate(id, slug) AS (VALUES (?, ?))
|
|
554
|
-
INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at)
|
|
560
|
+
INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
|
|
555
561
|
SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
|
|
556
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
562
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
557
563
|
),
|
|
558
564
|
// LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
|
|
559
565
|
// nulled) still lists, with null display fields.
|
|
@@ -680,9 +686,9 @@ function prepareStatements(db: ManagedSql) {
|
|
|
680
686
|
WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
|
|
681
687
|
insertBundleStmt: db.prepare(
|
|
682
688
|
`WITH candidate(id, slug) AS (VALUES (?, ?))
|
|
683
|
-
INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
|
|
689
|
+
INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at, data_key, storage_encrypted)
|
|
684
690
|
SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
|
|
685
|
-
?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
691
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
686
692
|
),
|
|
687
693
|
selectBundleByIntegrityStmt: db.prepare(
|
|
688
694
|
`SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
|
|
@@ -924,7 +930,7 @@ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sess
|
|
|
924
930
|
}
|
|
925
931
|
|
|
926
932
|
// The report slice of ManagedDb closes over its prepared statements.
|
|
927
|
-
function reportMethods(stmts: ReturnType<typeof prepareStatements
|
|
933
|
+
function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
|
|
928
934
|
const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
|
|
929
935
|
async function getReport(id: string): Promise<ReportRecord | null> {
|
|
930
936
|
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
@@ -936,16 +942,18 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
936
942
|
return row ? getReport(row.id) : null
|
|
937
943
|
}
|
|
938
944
|
async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
|
|
945
|
+
await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
|
|
939
946
|
await insertReportStmt.run(
|
|
940
947
|
report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
|
|
941
948
|
report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
|
|
942
949
|
report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
|
|
943
|
-
report.bundleIntegrity ?? null, now,
|
|
950
|
+
report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
|
|
944
951
|
)
|
|
945
952
|
}
|
|
946
953
|
return {
|
|
947
954
|
insertReport,
|
|
948
955
|
getReport,
|
|
956
|
+
resolveReportUpload: getReport,
|
|
949
957
|
getReportByHash,
|
|
950
958
|
async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
|
|
951
959
|
await authorizeUpload(stmts, sessionId, report)
|
|
@@ -1126,23 +1134,27 @@ function mapBundle(r: BundleRow): ManagedBundle {
|
|
|
1126
1134
|
}
|
|
1127
1135
|
|
|
1128
1136
|
// The bundle slice of ManagedDb. Closes over its prepared statements.
|
|
1129
|
-
function bundleMethods(stmts: ReturnType<typeof prepareStatements
|
|
1137
|
+
function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
|
|
1130
1138
|
const {
|
|
1131
1139
|
insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
|
|
1132
1140
|
selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
|
|
1133
1141
|
} = stmts
|
|
1142
|
+
async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
|
|
1143
|
+
const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
|
|
1144
|
+
return row == null ? null : mapBundle(row)
|
|
1145
|
+
}
|
|
1134
1146
|
return {
|
|
1135
1147
|
async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
|
|
1136
1148
|
await authorizeUpload(stmts, sessionId, bundle)
|
|
1149
|
+
await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
|
|
1137
1150
|
await insertBundleStmt.run(
|
|
1138
1151
|
bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
|
|
1139
|
-
bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
|
|
1152
|
+
bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
|
|
1140
1153
|
)
|
|
1141
1154
|
},
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
},
|
|
1155
|
+
getBundleByIntegrity,
|
|
1156
|
+
// The distinct method name selects a writer-locked reconciliation scope.
|
|
1157
|
+
resolveBundleUpload: getBundleByIntegrity,
|
|
1146
1158
|
async getBundle(id: string): Promise<ManagedBundle | null> {
|
|
1147
1159
|
const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
|
|
1148
1160
|
return row == null ? null : mapBundle(row)
|
|
@@ -1309,14 +1321,16 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
|
|
|
1309
1321
|
// `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
|
|
1310
1322
|
// default) keeps everything. See ManagedConfig.
|
|
1311
1323
|
export interface ManagedDbOptions {
|
|
1324
|
+
storageEncryptionKey?: StorageKey | null
|
|
1312
1325
|
triageHistoryLimit?: number
|
|
1313
1326
|
}
|
|
1314
1327
|
|
|
1315
1328
|
export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
|
|
1329
|
+
const key = options.storageEncryptionKey ?? null
|
|
1316
1330
|
const comments = commentMethods(db)
|
|
1317
1331
|
const activity = activityMethods(db)
|
|
1318
1332
|
const stmts = prepareStatements(db)
|
|
1319
|
-
const reports = reportMethods(stmts)
|
|
1333
|
+
const reports = reportMethods(stmts, db, key)
|
|
1320
1334
|
const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
|
|
1321
1335
|
const {
|
|
1322
1336
|
upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
|
|
@@ -1324,6 +1338,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1324
1338
|
} = stmts
|
|
1325
1339
|
|
|
1326
1340
|
const methods: Omit<ManagedDb, keyof ManagementStore> = {
|
|
1341
|
+
...storageMethods(db, key),
|
|
1327
1342
|
async upsertUser(user, now, initialAdminGithubId = null) {
|
|
1328
1343
|
// New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
|
|
1329
1344
|
// user's id (DO UPDATE leaves it untouched), so re-read to return it.
|
|
@@ -1364,12 +1379,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1364
1379
|
return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
|
|
1365
1380
|
},
|
|
1366
1381
|
async setUserTokens(id, tokens) {
|
|
1367
|
-
|
|
1382
|
+
const state = await storageState(db, key)
|
|
1383
|
+
const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
|
|
1384
|
+
await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
|
|
1368
1385
|
},
|
|
1369
1386
|
async getUserTokens(id) {
|
|
1370
|
-
const
|
|
1387
|
+
const state = await storageState(db, key)
|
|
1388
|
+
const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
|
|
1371
1389
|
if (row == null || row.access == null) return null
|
|
1372
|
-
|
|
1390
|
+
if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
|
|
1391
|
+
if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
|
|
1392
|
+
const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
|
|
1393
|
+
return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
|
|
1373
1394
|
},
|
|
1374
1395
|
async getUserGithubId(id) {
|
|
1375
1396
|
const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
|
|
@@ -1384,7 +1405,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1384
1405
|
...reports,
|
|
1385
1406
|
...triage,
|
|
1386
1407
|
...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
|
|
1387
|
-
...bundleMethods(stmts),
|
|
1408
|
+
...bundleMethods(stmts, db, key),
|
|
1388
1409
|
...teamMethods(stmts),
|
|
1389
1410
|
async close() {
|
|
1390
1411
|
await db.close()
|
|
@@ -2,11 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
2
2
|
import { WebSocket } from 'ws'
|
|
3
3
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
4
4
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
5
|
+
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
5
6
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
6
7
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
7
8
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
8
9
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
9
|
-
import {
|
|
10
|
+
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
10
11
|
import { postgresSchema, postgresSql } from './sql-postgres.ts'
|
|
11
12
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
13
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
@@ -57,7 +58,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
57
58
|
await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
|
|
58
59
|
await db.query('INSERT INTO managed_schema_version VALUES (4)')
|
|
59
60
|
}
|
|
60
|
-
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
|
|
61
|
+
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA))
|
|
61
62
|
await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
|
|
62
63
|
await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
|
|
63
64
|
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
|
|
@@ -83,6 +84,10 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
83
84
|
await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
|
|
84
85
|
await db.query('INSERT INTO managed_schema_version VALUES (8)')
|
|
85
86
|
}
|
|
87
|
+
for (const table of ['managed_report', 'managed_bundle']) {
|
|
88
|
+
await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
|
|
89
|
+
}
|
|
90
|
+
await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
|
|
86
91
|
await db.query('COMMIT')
|
|
87
92
|
} catch (err) {
|
|
88
93
|
await db.query('ROLLBACK')
|
|
@@ -155,11 +160,11 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
|
|
|
155
160
|
} catch (err) {
|
|
156
161
|
await db.query('ROLLBACK').catch(() => {})
|
|
157
162
|
await db.release().catch(() => {})
|
|
158
|
-
if (write && commitAttempted) throw new
|
|
163
|
+
if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
|
|
159
164
|
throw err
|
|
160
165
|
}
|
|
161
166
|
try { await db.release() }
|
|
162
|
-
catch (err) { if (write) throw new
|
|
167
|
+
catch (err) { if (write) throw new Error('Managed commit outcome is uncertain', { cause: err }); throw err }
|
|
163
168
|
return result
|
|
164
169
|
},
|
|
165
170
|
close() { closed = true },
|
package/server-managed/db.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
|
|
|
7
7
|
import { initCommentMethods } from './comments.ts'
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
10
|
+
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
10
11
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
11
12
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
12
13
|
import { managedTableRenames } from './db-table-names.ts'
|
|
@@ -65,7 +66,12 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
65
66
|
db.exec('PRAGMA synchronous = FULL;')
|
|
66
67
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
67
68
|
migrateManagedTableNames(db)
|
|
68
|
-
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
|
|
69
|
+
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA)
|
|
70
|
+
for (const table of ['managed_report', 'managed_bundle']) {
|
|
71
|
+
ensureColumn(db, table, 'data_key', 'TEXT')
|
|
72
|
+
ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
73
|
+
}
|
|
74
|
+
ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
69
75
|
ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
|
|
70
76
|
ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
|
|
71
77
|
// Existing public links must opt in too; never preserve an implicit grant.
|
package/server-managed/http.ts
CHANGED
|
@@ -59,7 +59,7 @@ import type { AvatarStore } from './avatar-store.ts'
|
|
|
59
59
|
import type { BlobStore } from './blob-store.ts'
|
|
60
60
|
import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
|
|
61
61
|
import type { ManagedConfig } from './config.ts'
|
|
62
|
-
import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
|
|
62
|
+
import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
|
|
63
63
|
import type { OriginGate } from '../server-common/origin.ts'
|
|
64
64
|
import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
65
65
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
@@ -92,7 +92,6 @@ 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
95
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
97
96
|
|
|
98
97
|
const SESSION_PATH = '/api/auth/session'
|
|
@@ -914,18 +913,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
914
913
|
const id = randomUUID()
|
|
915
914
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
916
915
|
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
|
|
917
|
-
await deps.reportStore.put(id, bytes)
|
|
916
|
+
const dataKey = await deps.reportStore.put(id, bytes)
|
|
918
917
|
let report: ReportRecord
|
|
919
918
|
try {
|
|
920
919
|
report = await deps.db.insertOrReuseReport({
|
|
921
|
-
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
920
|
+
id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
|
|
922
921
|
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
923
922
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
924
923
|
}, Date.now(), s.session.id)
|
|
925
924
|
} catch (err) {
|
|
926
|
-
//
|
|
927
|
-
|
|
928
|
-
|
|
925
|
+
// A successful COMMIT can lose its acknowledgement. Never delete a file
|
|
926
|
+
// until a writer-locked read confirms that this candidate did not commit.
|
|
927
|
+
let committed: ReportRecord | null
|
|
928
|
+
try { committed = await deps.db.resolveReportUpload(id) }
|
|
929
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
|
|
930
|
+
if (committed) report = committed
|
|
931
|
+
else { await deps.reportStore.delete(id).catch(() => {}); throw err }
|
|
929
932
|
}
|
|
930
933
|
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
931
934
|
await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
|
|
@@ -1043,7 +1046,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1043
1046
|
if (!current) return
|
|
1044
1047
|
if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1045
1048
|
sendJson(res, 200, refs ? result : {
|
|
1046
|
-
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
|
|
1049
|
+
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false } : {}), path, commit,
|
|
1047
1050
|
})
|
|
1048
1051
|
} catch (err) {
|
|
1049
1052
|
if (err instanceof GithubApiError) {
|
|
@@ -1155,6 +1158,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, coo
|
|
|
1155
1158
|
await backfillBundleSummaries(bundles, deps.bundleCache)
|
|
1156
1159
|
}
|
|
1157
1160
|
|
|
1161
|
+
type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1162
|
+
async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
|
|
1163
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
1164
|
+
if (!s) return
|
|
1165
|
+
if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1166
|
+
const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1167
|
+
// Re-uploading also repairs reports uploaded while the bundle was inaccessible.
|
|
1168
|
+
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1169
|
+
prebuildBundle(deps, id)
|
|
1170
|
+
sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1158
1173
|
// POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
|
|
1159
1174
|
// admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
|
|
1160
1175
|
// X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
|
|
@@ -1191,48 +1206,31 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1191
1206
|
const integrity = bundleIntegrity(bytes)
|
|
1192
1207
|
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1193
1208
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1194
|
-
if (existing
|
|
1195
|
-
s = await manageMutation(req, res, deps, cookie)
|
|
1196
|
-
if (!s) return
|
|
1197
|
-
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1198
|
-
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1199
|
-
// integrity. An authorized re-upload repairs those pending links too.
|
|
1200
|
-
await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1201
|
-
prebuildBundle(deps, existing.id)
|
|
1202
|
-
sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
|
|
1203
|
-
return
|
|
1204
|
-
}
|
|
1209
|
+
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
|
|
1205
1210
|
const id = randomUUID()
|
|
1206
1211
|
const kind = bundleKind(filename)
|
|
1207
|
-
await deps.bundleStore.put(id, bytes, kind)
|
|
1212
|
+
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
1208
1213
|
try {
|
|
1209
1214
|
await deps.db.insertBundle({
|
|
1210
|
-
id, integrity, filename, kind,
|
|
1215
|
+
id, integrity, filename, kind, dataKey,
|
|
1211
1216
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1212
1217
|
}, Date.now(), s.session.id)
|
|
1213
1218
|
} catch (err) {
|
|
1214
|
-
// A committed row may already point here even though insertion rejected.
|
|
1215
|
-
if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
|
|
1216
|
-
if (err instanceof ManagedMutationError) throw err
|
|
1217
1219
|
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1218
1220
|
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1219
1221
|
// Any other failure rethrows.
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1222
|
+
let raced: ManagedBundle | null
|
|
1223
|
+
try { raced = await deps.db.resolveBundleUpload(integrity) }
|
|
1224
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
|
|
1225
|
+
// Preserve a committed candidate after a lost acknowledgement. If this
|
|
1226
|
+
// read fails too, leave the bytes for later reconciliation.
|
|
1227
|
+
if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
|
|
1228
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1229
|
+
if (!raced) throw err
|
|
1230
|
+
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
|
|
1231
|
+
// Our insert committed: continue with the ordinary creation response.
|
|
1229
1232
|
}
|
|
1230
|
-
|
|
1231
|
-
s = await manageMutation(req, res, deps, cookie)
|
|
1232
|
-
if (!s) return
|
|
1233
|
-
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1234
|
-
prebuildBundle(deps, id)
|
|
1235
|
-
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
1233
|
+
await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory }, false)
|
|
1236
1234
|
}
|
|
1237
1235
|
|
|
1238
1236
|
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
package/server-managed/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
42
42
|
})
|
|
43
43
|
const routeRequest = createManagedRequestHandler({
|
|
44
44
|
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
|
|
45
|
-
...(
|
|
45
|
+
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
|
|
46
46
|
isShuttingDown, track,
|
|
47
47
|
})
|
|
48
48
|
|
|
@@ -55,7 +55,8 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
55
55
|
let sessions = 0, uploads = 0
|
|
56
56
|
cleanup = runReapers({
|
|
57
57
|
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
58
|
-
...(
|
|
58
|
+
...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!() } } : {}),
|
|
59
|
+
...('reapStorage' in storage ? { storage: () => storage.reapStorage() } : {}),
|
|
59
60
|
}).then(() => {
|
|
60
61
|
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
61
62
|
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { type ObjectPage, type RawObjectStorage, objectPath } from './object-storage.ts'
|
|
5
|
+
|
|
6
|
+
function missing(err: unknown): boolean { return (err as NodeJS.ErrnoException).code === 'ENOENT' }
|
|
7
|
+
function version(info: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
|
|
8
|
+
return JSON.stringify([info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function syncDirectory(dir: string): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const directory = await open(dir, 'r')
|
|
14
|
+
try { await directory.sync() } finally { await directory.close() }
|
|
15
|
+
} catch (err) {
|
|
16
|
+
// Windows and some mounted filesystems cannot open/fsync directories.
|
|
17
|
+
// Preserve real I/O failures; file fsync remains mandatory on every write.
|
|
18
|
+
if (!['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EISDIR', 'EBADF', 'EPERM'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Keyset pagination survives concurrent file creation/deletion. A final sweep
|
|
23
|
+
// catches objects inserted before the cursor during migration.
|
|
24
|
+
async function listFiles(dir: string, prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage> {
|
|
25
|
+
const objects: ObjectPage['objects'] = []
|
|
26
|
+
async function visit(key: string): Promise<void> {
|
|
27
|
+
signal?.throwIfAborted()
|
|
28
|
+
let entries
|
|
29
|
+
try {
|
|
30
|
+
if (!(await lstat(join(dir, key))).isDirectory()) {
|
|
31
|
+
console.warn('managed-storage: skipping unsupported directory', JSON.stringify(key))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
entries = await readdir(join(dir, key), { withFileTypes: true })
|
|
35
|
+
}
|
|
36
|
+
catch (err) { if (missing(err)) return; throw err }
|
|
37
|
+
const order = (entry: typeof entries[number]) => `${entry.name}${entry.isDirectory() ? '/' : ''}`
|
|
38
|
+
entries.sort((a, b) => order(a) < order(b) ? -1 : order(a) > order(b) ? 1 : 0)
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
signal?.throwIfAborted()
|
|
41
|
+
const child = `${key}${entry.name}`
|
|
42
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
|
43
|
+
console.warn('managed-storage: skipping unsupported entry', JSON.stringify(child))
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
try { objectPath(child) }
|
|
47
|
+
catch { console.warn('managed-storage: skipping invalid entry', JSON.stringify(child)); continue }
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
const folder = `${child}/`
|
|
50
|
+
if (cursor && folder < cursor && !cursor.startsWith(folder)) continue
|
|
51
|
+
await visit(folder)
|
|
52
|
+
if (objects.length > limit) return
|
|
53
|
+
}
|
|
54
|
+
else if (entry.isFile() && (!cursor || child > cursor)) {
|
|
55
|
+
try { objects.push({ key: objectPath(child), modifiedAt: (await stat(join(dir, child))).mtimeMs }) }
|
|
56
|
+
catch (err) { if (!missing(err)) throw err }
|
|
57
|
+
if (objects.length > limit) return
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Another migration worker can observe an unlink before its writer has
|
|
61
|
+
// synced the directory. Persist those observed removals before an empty
|
|
62
|
+
// inventory can mark the shared migration complete.
|
|
63
|
+
try { await syncDirectory(join(dir, key)) } catch (err) { if (!missing(err)) throw err }
|
|
64
|
+
}
|
|
65
|
+
if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
|
|
66
|
+
objectPath(`${prefix}validate`)
|
|
67
|
+
await visit(prefix)
|
|
68
|
+
return { objects: objects.slice(0, limit), cursor: objects.length > limit ? objects[limit - 1]!.key : null }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDiskObjectStorage(dir: string): RawObjectStorage {
|
|
72
|
+
const path = (key: string) => join(dir, objectPath(key))
|
|
73
|
+
return {
|
|
74
|
+
async head(key, signal) {
|
|
75
|
+
signal?.throwIfAborted()
|
|
76
|
+
try {
|
|
77
|
+
const info = await stat(path(key))
|
|
78
|
+
return { size: info.size, version: version(info), modifiedAt: info.mtimeMs }
|
|
79
|
+
} catch (err) { if (missing(err)) return null; throw err }
|
|
80
|
+
},
|
|
81
|
+
async open(key, signal) {
|
|
82
|
+
signal?.throwIfAborted()
|
|
83
|
+
let file
|
|
84
|
+
try { file = await open(path(key), 'r') }
|
|
85
|
+
catch (err) { if (missing(err)) return null; throw err }
|
|
86
|
+
try {
|
|
87
|
+
const info = await file.stat()
|
|
88
|
+
return { size: info.size, version: version(info), modifiedAt: info.mtimeMs,
|
|
89
|
+
stream: file.createReadStream(signal ? { signal } : {}) }
|
|
90
|
+
} catch (err) { await file.close(); throw err }
|
|
91
|
+
},
|
|
92
|
+
async put(key, bytes, signal, expected) {
|
|
93
|
+
const target = path(key)
|
|
94
|
+
// Temp-file + rename makes replacement atomic to readers. Concurrent
|
|
95
|
+
// migrations use the same persisted data key and immutable plaintext.
|
|
96
|
+
const temp = `${target}.${randomUUID()}.tmp`
|
|
97
|
+
await mkdir(dirname(target), { recursive: true })
|
|
98
|
+
const file = await open(temp, 'wx', 0o600)
|
|
99
|
+
try {
|
|
100
|
+
await file.writeFile(bytes, signal ? { signal } : {})
|
|
101
|
+
await file.sync(); await file.close()
|
|
102
|
+
signal?.throwIfAborted()
|
|
103
|
+
if (expected !== undefined) {
|
|
104
|
+
try { if (version(await stat(target)) !== expected) return false }
|
|
105
|
+
catch (err) { if (missing(err)) return false; throw err }
|
|
106
|
+
}
|
|
107
|
+
await rename(temp, target)
|
|
108
|
+
await syncDirectory(dirname(target))
|
|
109
|
+
return true
|
|
110
|
+
} finally { await file.close(); await rm(temp, { force: true }) }
|
|
111
|
+
},
|
|
112
|
+
async delete(key, expected, signal) {
|
|
113
|
+
signal?.throwIfAborted()
|
|
114
|
+
try {
|
|
115
|
+
if (expected !== undefined && version(await stat(path(key))) !== expected) return false
|
|
116
|
+
await rm(path(key), { force: true })
|
|
117
|
+
// Persist plaintext removal before migration checkpoints completion.
|
|
118
|
+
// Otherwise a crash could restore the directory entry after SQL has
|
|
119
|
+
// durably disabled further plaintext inventory sweeps.
|
|
120
|
+
await syncDirectory(dirname(path(key)))
|
|
121
|
+
return true
|
|
122
|
+
} catch (err) { if (missing(err)) return true; throw err }
|
|
123
|
+
},
|
|
124
|
+
async sync(key, signal) { signal?.throwIfAborted(); await syncDirectory(dirname(path(key))) },
|
|
125
|
+
async prune(prefix, signal) {
|
|
126
|
+
if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
|
|
127
|
+
objectPath(`${prefix}validate`)
|
|
128
|
+
async function visit(folder: string): Promise<void> {
|
|
129
|
+
signal?.throwIfAborted()
|
|
130
|
+
let entries
|
|
131
|
+
try {
|
|
132
|
+
if (!(await lstat(folder)).isDirectory()) return
|
|
133
|
+
entries = await readdir(folder, { withFileTypes: true })
|
|
134
|
+
}
|
|
135
|
+
catch (err) { if (missing(err)) return; throw err }
|
|
136
|
+
for (const entry of entries) if (entry.isDirectory()) await visit(join(folder, entry.name))
|
|
137
|
+
try { await rmdir(folder); await syncDirectory(dirname(folder)) }
|
|
138
|
+
catch (err) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err }
|
|
139
|
+
}
|
|
140
|
+
await visit(join(dir, prefix))
|
|
141
|
+
},
|
|
142
|
+
list: (prefix, cursor, limit, signal) => listFiles(dir, prefix, cursor, limit, signal),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
|
|
4
|
+
import { type RawObjectStorage, objectPath } from './object-storage.ts'
|
|
5
|
+
|
|
6
|
+
export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk): Promise<RawObjectStorage> {
|
|
7
|
+
const blobs = sdk ?? await loadVercelBlobSdk()
|
|
8
|
+
const path = (key: string) => `.managed/${objectPath(key)}`
|
|
9
|
+
return {
|
|
10
|
+
async head(key, signal) {
|
|
11
|
+
try {
|
|
12
|
+
const meta = await blobs.head(path(key), { token, ...(signal ? { abortSignal: signal } : {}) })
|
|
13
|
+
if (!meta.etag) throw new Error('Missing object version')
|
|
14
|
+
return { size: meta.size, version: meta.etag, modifiedAt: new Date(meta.uploadedAt ?? Date.now()).getTime() }
|
|
15
|
+
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
16
|
+
},
|
|
17
|
+
async open(key, signal) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await blobs.get(path(key), { token, access: 'private', useCache: false, ...(signal ? { abortSignal: signal } : {}) })
|
|
20
|
+
if (!result) return null
|
|
21
|
+
if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
|
|
22
|
+
await result.stream?.cancel()
|
|
23
|
+
throw new Error('Unexpected blob response or missing object version')
|
|
24
|
+
}
|
|
25
|
+
return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
|
|
26
|
+
modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
|
|
27
|
+
stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
28
|
+
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
29
|
+
},
|
|
30
|
+
async put(key, bytes, signal, expected, sizeHint) {
|
|
31
|
+
const multipart = (sizeHint ?? (Buffer.isBuffer(bytes) ? bytes.length : Infinity)) >= 5 * 1024 * 1024
|
|
32
|
+
try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite: true,
|
|
33
|
+
...(expected ? { ifMatch: expected } : {}), multipart, contentType: 'application/octet-stream', cacheControlMaxAge: 60, ...(signal ? { abortSignal: signal } : {}) })
|
|
34
|
+
return true
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async delete(key, version, signal) {
|
|
41
|
+
signal?.throwIfAborted()
|
|
42
|
+
try { await blobs.del(path(key), { token, ...(version ? { ifMatch: version } : {}), ...(signal ? { abortSignal: signal } : {}) }); return true }
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (isNotFound(err, blobs)) return true
|
|
45
|
+
if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async list(prefix, cursor, limit, signal) {
|
|
50
|
+
signal?.throwIfAborted()
|
|
51
|
+
if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
|
|
52
|
+
objectPath(`${prefix}validate`)
|
|
53
|
+
const page = await blobs.list({ prefix: `.managed/${prefix}`, token, limit, ...(cursor ? { cursor } : {}), ...(signal ? { abortSignal: signal } : {}) })
|
|
54
|
+
if (page.hasMore && (!page.cursor || page.cursor === cursor)) throw new Error('Invalid blob pagination')
|
|
55
|
+
return { objects: page.blobs.filter(blob => blob.pathname.startsWith(`.managed/${prefix}`)).map(blob => ({ key: blob.pathname.slice('.managed/'.length),
|
|
56
|
+
modifiedAt: new Date(blob.uploadedAt ?? Date.now()).getTime() })),
|
|
57
|
+
cursor: page.hasMore ? page.cursor! : null }
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|