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(); 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; 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;