mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-18 21:57:20 +02:00
* 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
366 lines
10 KiB
TypeScript
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;
|