Files
anonymous_github/src/server/routes/user.ts
T
Thomas Durieux 6434337923 perf: bound request work and resource usage (#866)
* perf: bound request work and resource usage

* fix: address performance review regressions

* fix: guard content expiration and dashboard state

* fix: tolerate cache outages and retire derived paths

* fix: revalidate expiration claims and status pagination

* fix: retry interrupted repository metadata cleanup

* fix: retire obsolete content cache storage

* fix: activate tree metadata and recheck expiration claims

* fix: fence cache publication across expiration

* fix: recover staged trees and enforce cache lifecycle cleanup

* fix: prune expired transforms during database outages

* fix: finish conference removals and reload expiration candidates
2026-10-08 03:38:19 +01:00

366 lines
10 KiB
TypeScript

import DashboardName from "../../core/model/dashboard-name";
import { dashboardSummary } from "./dashboard-summary";
import { revokeGrant } from "./github-app";
import CredentialModel from "../../core/model/credentials/credentials.model";
import * as express from "express";
import config from "../../config";
import { ensureAuthenticated } from "./connection";
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
import UserModel from "../../core/model/users/users.model";
import AnonymizedRepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
import AnonymizedPullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
import AnonymizedGistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
import User from "../../core/User";
import FileModel from "../../core/model/files/files.model";
import { isConnected } from "../database";
import { octokit } from "../../core/GitHubUtils";
import { createLogger, serializeError } from "../../core/logger";
import { projectNameKey, saveProjectName } from "./project-names";
const logger = createLogger("user");
const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
router.get("/logout", async (req, res) => {
try {
req.logout((error) => {
if (error) {
logger.error("logout failed", serializeError(error));
}
});
res.redirect("/");
} catch (error) {
handleError(error, res, req);
}
});
router.get("/", async (req, res) => {
try {
const user = await getUser(req);
res.json({
username: user.username,
photo: user.photo,
isAdmin: user.isAdmin,
});
} catch (error) {
handleError(error, res, req);
}
});
router.get("/dashboard", async (req, res) => {
try { res.json(await dashboardSummary(await getUser(req), req.query)); }
catch (error) { handleError(error, res, req); }
});
router.get("/quota", async (req, res) => {
try {
const user = await getUser(req);
const ready = await user.quotaRepositories();
let totalStorage = 0;
let totalFiles = 0;
const uncachedIds: string[] = [];
for (const r of ready) {
const cached = r.size;
if (cached && (cached.file > 0 || r.sizeComputedAt) && cached.storage != null) {
totalStorage += cached.storage;
totalFiles += cached.file;
} else {
uncachedIds.push(r.repoId);
}
}
if (uncachedIds.length) {
const uncachedSet = new Set(uncachedIds);
const agg = await FileModel.aggregate([
{ $match: { $or: ready.filter(r => uncachedSet.has(r.repoId)).map(r => ({ repoId: r.repoId, treeGeneration: r.treeGeneration || { $exists: false } })) } },
{
$group: {
_id: "$repoId",
storage: { $sum: "$size" },
file: { $sum: 1 },
},
},
]);
const byId = new Map<string, { storage: number; file: number }>();
for (const row of agg) {
byId.set(row._id, { storage: row.storage || 0, file: row.file || 0 });
}
for (const r of ready) {
if (!uncachedSet.has(r.repoId)) continue;
const size = byId.get(r.repoId) || { storage: 0, file: 0 };
totalStorage += size.storage;
totalFiles += size.file;
r.size = size;
r.sizeComputedAt = new Date();
}
if (isConnected) {
await Promise.all(
ready
.filter((r) => uncachedSet.has(r.repoId))
.map((r) =>
AnonymizedRepositoryModel.updateOne(
{ _id: r._id, status: "ready", treeGeneration: r.treeGeneration || { $exists: false },
fileMetadataRevision: r.fileMetadataRevision || { $exists: false } },
{ $set: { size: r.size, sizeComputedAt: r.sizeComputedAt } }
).exec()
)
);
}
}
res.json({
storage: {
used: totalStorage,
total: config.DEFAULT_QUOTA,
},
file: {
used: totalFiles,
total: 0,
},
repository: {
used: ready.length,
total: 20,
},
});
} catch (error) {
handleError(error, res, req);
}
});
router.post("/project-name", async (req, res) => {
try {
const user = await getUser(req);
res.json({ name: await saveProjectName(user, req.body) });
} catch (error) {
handleError(error, res, req);
}
});
router.get("/default", async (req, res) => {
try {
const user = await getUser(req);
res.json(user.default);
} catch (error) {
handleError(error, res, req);
}
});
router.post("/default", async (req, res) => {
try {
const user = await getUser(req);
const d = req.body;
user.model.default = d;
await UserModel.updateOne(
{ _id: user.model._id },
{ $set: { default: d } }
).exec();
res.send("ok");
} catch (error) {
handleError(error, res, req);
}
});
// Delete the account: remove all anonymized content owned by the user,
// best-effort revoke the GitHub OAuth grant, and scrub personal data from
// the user record (#741). The record itself is kept (with a placeholder
// username) so removed repoIds stay reserved and owner references remain
// resolvable.
router.delete("/", async (req, res) => {
try {
const user = await getUser(req);
const repositories = (await user.getRepositories()).filter(
(r) => r.owner.id === user.model.id && r.status !== "removed"
);
for (const repo of repositories) {
await repo.remove();
}
for (const pullRequest of await user.getPullRequests()) {
if (pullRequest.status !== "removed") await pullRequest.remove();
}
for (const gist of await user.getGists()) {
if (gist.status !== "removed") await gist.remove();
}
// Removal keeps anonymized records as tombstones, so explicitly erase the
// duplicated OAuth credentials stored on each source record.
await Promise.all([
AnonymizedRepositoryModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "", accessToken: "" } }
).exec(),
AnonymizedPullRequestModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "", accessToken: "" } }
).exec(),
AnonymizedGistModel.updateMany(
{ owner: user.model._id },
{ $unset: { "source.accessToken": "", accessToken: "" } }
).exec(),
]);
// Removing one account must not uninstall a shared organization installation.
for (const provider of ["github", "github-app-user"] as const) {
try { await revokeGrant(user.id, provider); }
catch (error) { logger.warn("grant revocation failed", serializeError(error)); }
}
await CredentialModel.deleteMany({ ownerId: user.model._id });
if (isConnected) await DashboardName.deleteMany({ owner: user.model._id });
await UserModel.updateOne(
{ _id: user.model._id },
{
$set: {
status: "removed",
username: `deleted-${user.model._id}`,
emails: [],
apiTokens: [],
repositories: [],
},
$unset: {
accessTokens: "",
accessTokenDates: "",
externalIDs: "",
photo: "",
default: "",
projectNames: "",
},
}
).exec();
logger.info("account removed", { userId: user.model.id });
req.logout((error) => {
if (error) {
logger.error("logout after account removal failed", serializeError(error));
}
res.json({ status: "ok" });
});
} catch (error) {
handleError(error, res, req);
}
});
router.get(
"/anonymized_repositories",
async (req, res) => {
try {
const user = await getUser(req);
res.json(
(await user.getRepositories()).map((x) => {
const json = x.toJSON() as Record<string, unknown>;
json.role = x.owner.id === user.model.id ? "owner" : "coauthor";
json.projectName = user.model.projectNames?.get(projectNameKey("repo", x.repoId));
return json;
})
);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/anonymized_gists",
async (req, res) => {
try {
const user = await getUser(req);
res.json(
(await user.getGists()).map((x) => {
return { ...x.toJSON(), projectName: user.model.projectNames?.get(projectNameKey("gist", x.gistId)) };
})
);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/anonymized_pull_requests",
async (req, res) => {
try {
const user = await getUser(req);
res.json(
(await user.getPullRequests()).map((x) => {
return { ...x.toJSON(), projectName: user.model.projectNames?.get(projectNameKey("pr", x.pullRequestId)) };
})
);
} catch (error) {
handleError(error, res, req);
}
}
);
// search GitHub users (used by the coauthor picker)
router.get(
"/search/github-users",
async (req, res) => {
try {
const user = await getUser(req);
const q = (req.query.q as string) || "";
if (!q || q.length < 2) {
return res.json([]);
}
const oct = octokit(await user.getAccessToken());
const r = await oct.search.users({ q, per_page: 10 });
res.json(
r.data.items.map((u) => ({
username: u.login,
githubId: String(u.id),
photo: u.avatar_url,
}))
);
} catch (error) {
handleError(error, res, req);
}
}
);
async function getAllRepositories(user: User, force: boolean) {
const repos = await user.getGitHubRepositories({
force,
});
return repos.map((x) => {
return {
fullName: x.fullName,
id: x.id,
};
});
}
router.get(
"/all_repositories",
async (req, res) => {
try {
const user = await getUser(req);
res.json(await getAllRepositories(user, req.query.force == "1"));
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/:username/all_repositories",
async (req, res) => {
try {
const loggedUser = await getUser(req);
isOwnerOrAdmin([req.params.username], loggedUser);
const model = await UserModel.findOne({ username: req.params.username });
if (!model) {
throw new Error("User not found");
}
const user = new User(model);
res.json(await getAllRepositories(user, req.query.force == "1"));
} catch (error) {
handleError(error, res, req);
}
}
);
export default router;