Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.
| GET | `/api/scan-results?url=` | re-run a scan for a URL (used by deep links) |
| GET | `/api/scans` | list saved scans from the user's store |
| GET | `/api/scans/:id` | load one saved report from the user's store |
| DELETE | `/api/scans/:id` | delete one saved report from the user's store |
| GET | `/api/problems/:id` | look up a single problem (legacy mock lookup) |

Every backend path lives under `/api` so one serverless function can claim the
Expand Down
66 changes: 66 additions & 0 deletions backend/controllers/scanController.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class ScanController {
this.getScanResults = this.getScanResults.bind(this);
this.getSavedScan = this.getSavedScan.bind(this);
this.getSavedScans = this.getSavedScans.bind(this);
this.deleteSavedScan = this.deleteSavedScan.bind(this);
this.getProblem = this.getProblem.bind(this);
}

Expand Down Expand Up @@ -173,6 +174,71 @@ class ScanController {
}
}

/**
* DELETE /api/scans/:id — remove one saved report from attached storage.
*/
async deleteSavedScan(req, res) {
if (
typeof req.isAuthenticated !== 'function' ||
!req.isAuthenticated() ||
!req.user?.storage
) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!this.authService || !this.storageService) {
return res.status(503).json({ error: 'Storage is not configured' });
}

const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Missing scan id' });
}

try {
const clients = await this.authService.clientsFor(req.user, {
storageRef: req.user.storage,
});
const result = await this.storageService.deleteScanById(
req.user,
id,
clients,
);

if (!req.user.account) {
req.user.account = {
settings: { autoDelete90d: true },
scanCount: 0,
};
}
req.user.account.scanCount = result.scanCount;
await this.authService.persistUser(req);

return res.json({
scanCount: result.scanCount,
scans: result.scans,
});
} catch (err) {
if (err.code === 'SCAN_NOT_FOUND' || err.status === 404) {
return res.status(404).json({ error: 'Scan not found' });
}
if (err.code === 'PROVIDER_NOT_AVAILABLE' || err.status === 501) {
return res.status(501).json({
error: err.message,
code: err.code,
});
}
if (
err.code === 'STORAGE_ACCESS_DENIED' ||
err.code === 'STORAGE_IDENTITY_MISMATCH' ||
err.status === 403
) {
return res.status(403).json({ error: err.message });
}
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
}

/**
* GET /api/scans — list the saved scans held in the user's own storage.
*
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* - GET /api/scan-results
* - GET /api/scans
* - GET /api/scans/:id
* - DELETE /api/scans/:id
*
* Only knows about HTTP shape. All logic lives in ScanController.
*
Expand All @@ -21,6 +22,7 @@ function makeScanRouter(controller) {
router.get('/scan-results', controller.getScanResults);
router.get('/scans', controller.getSavedScans);
router.get('/scans/:id', controller.getSavedScan);
router.delete('/scans/:id', controller.deleteSavedScan);
return router;
}

Expand Down
202 changes: 187 additions & 15 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,156 @@ class StorageService {
};
}

/**
* Delete one immutable saved scan by id and refresh index/manifest caches.
* @param {object} account session user (with storage binding)
* @param {string} scanId
* @param {StorageClients} clients
* @returns {Promise<{ deletedId: string, path: string, scanCount: number, scans: object[] }>}
*/
async deleteScanById(account, scanId, clients) {
if (account?.storage?.provider === 'google') {
const err = new Error(GOOGLE_NOT_AVAILABLE);
err.status = 501;
err.code = 'PROVIDER_NOT_AVAILABLE';
throw err;
}
if (!clients.githubClient) {
throw new Error('GitHub client is required to delete a saved scan');
}
if (!scanId || typeof scanId !== 'string') {
const err = new Error('Scan id is required');
err.status = 400;
err.code = 'SCAN_ID_REQUIRED';
throw err;
}

const maxAttempts = 3;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await this._deleteScanByIdOnce(account, scanId, clients);
} catch (err) {
const canRetry = this._isRefConflict(err) && attempt < maxAttempts - 1;
if (!canRetry) {
throw err;
}
}
}

throw new Error('GitHub write failed after retries');
}

/**
* @param {object} account
* @param {string} scanId
* @param {StorageClients} clients
* @private
*/
async _deleteScanByIdOnce(account, scanId, clients) {
const storageRef = account.storageRef ?? account.storage;
const { owner, repo } = this._parseGitHubRef(storageRef);
const octokit = clients.githubClient;
const branch = await this._resolveGitHubBranch(
octokit,
owner,
repo,
storageRef.branch,
);

const scanEntries = await this._listGitHubDirectory(
octokit,
owner,
repo,
SCANS_DIR,
branch,
);
const match = scanEntries.find(
(entry) =>
entry.type === 'file' &&
entry.name.startsWith(`${scanId}_`) &&
entry.name.endsWith('.json') &&
entry.name !== 'index.json',
);

if (!match) {
const err = new Error('Scan not found');
err.status = 404;
err.code = 'SCAN_NOT_FOUND';
throw err;
}

const scanPath = `${SCANS_DIR}/${match.name}`;
const fileData = await this._readGitHubFile(
octokit,
owner,
repo,
scanPath,
branch,
);
if (!fileData) {
const err = new Error('Scan not found');
err.status = 404;
err.code = 'SCAN_NOT_FOUND';
throw err;
}

const manifestFile = await this._readAccountManifest(octokit, owner, repo, branch);
if (!manifestFile) {
throw new Error('Account manifest not found');
}

const { manifest } = this._normalizeManifestBrand(
this._parseJson(manifestFile.content, 'manifest'),
);
const { index } = await this._reconcileGitHubIndex(octokit, owner, repo, branch);
index.scans = index.scans.filter((entry) => entry.id !== scanId);

const updatedManifest = this._updateManifestSummary(
manifest,
index,
index.scans[0]?.scannedAt,
);
if (index.scans.length === 0) {
updatedManifest.summary.lastScanAt = null;
}
updatedManifest.account.updatedAt = new Date().toISOString();

const indexFile = await this._readGitHubFile(octokit, owner, repo, INDEX_PATH, branch);
const host = match.name.slice(scanId.length + 1).replace(/\.json$/, '');

await this._writeGitHubFiles(
octokit,
owner,
repo,
branch,
[
{
path: scanPath,
delete: true,
sha: fileData.sha || match.sha,
},
{
path: INDEX_PATH,
content: JSON.stringify(index, null, 2) + '\n',
sha: indexFile?.sha,
},
{
path: MANIFEST_PATH,
content: JSON.stringify(updatedManifest, null, 2) + '\n',
...(manifestFile.path === MANIFEST_PATH ? { sha: manifestFile.sha } : {}),
},
],
`Delete accessibility scan for ${host || scanId}`,
);

return {
deletedId: scanId,
path: scanPath,
scanCount: index.scans.length,
scans: index.scans,
};
}

/**
* One save attempt: reconcile from scan-file truth, append prepared scan, write.
* @param {object} account
Expand Down Expand Up @@ -1336,24 +1486,30 @@ class StorageService {
* Contents API when the Git Database API is unavailable. Conflicts bubble up
* so callers (e.g. saveScanResults) can re-reconcile against scan truth —
* never rewrite caches with a refreshed sha and stale content.
*
* Batches that include deletes skip the Git Database API and use Contents
* `deleteFile` — createTree + sha:null is unreliable on real GitHub.
* @private
*/
async _writeGitHubFiles(octokit, owner, repo, branch, files, message) {
try {
return await this._writeGitHubFilesViaGit(
octokit,
owner,
repo,
branch,
files,
message,
);
} catch (err) {
if (!this._shouldFallbackToContentsApi(err)) {
throw Object.assign(new Error(this._formatGitHubStorageError(err)), {
status: err?.status,
cause: err,
});
const hasDeletes = files.some((file) => Boolean(file.delete));
if (!hasDeletes) {
try {
return await this._writeGitHubFilesViaGit(
octokit,
owner,
repo,
branch,
files,
message,
);
} catch (err) {
if (!this._shouldFallbackToContentsApi(err)) {
throw Object.assign(new Error(this._formatGitHubStorageError(err)), {
status: err?.status,
cause: err,
});
}
}
}

Expand Down Expand Up @@ -1486,6 +1642,22 @@ class StorageService {
: `${message} (${i + 1}/${files.length})`;

try {
if (file.delete) {
if (!sha) {
continue;
}
const { data } = await octokit.rest.repos.deleteFile({
owner,
repo,
path: file.path,
message: fileMessage,
sha,
branch,
});
lastCommit = data.commit;
continue;
}

const { data } = await octokit.rest.repos.createOrUpdateFileContents({
owner,
repo,
Expand Down
Loading