/** * @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`), * mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519 * and/or rsa one plus named ones (`name`, deleted by `?id=`) for more * accounts (make, replace, delete — only the PUBLIC key is ever returned) and * the HTTPS logins (save, delete — `hasToken` only, never a password), * whether the account's own terminal git uses the keys (`core.sshCommand`), * the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's * SSH key ID — each optionally tied to one key), and the commit author (`user.name` / * `user.email` of the account's global git config — a member's commits fail * without one; a new SSH key is labelled with that email, else * `@smartgift.vn`). The account is the signed-in user's * own Linux account under S3, else the host's, which only an admin (or local * mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the * request, and every write passes the same-origin guard. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const express = require("express"); const identity = require("../lib/git-identity"); const { sameOriginGuard } = require("../lib/same-origin"); const router = express.Router(); const STATUS = { EBADHOST: 400, EBADCRED: 400, EEXISTS: 409, EMEMBERGONE: 409, EHOSTIDENTITY: 403, EBADAUTHOR: 400, ESTOREUNREADABLE: 409, EBADKEYTYPE: 400, EFOREIGNSSH: 409, EBADSSHUSER: 400, EBADKEYNAME: 400, ETOOMANYKEYS: 409, EBADALIAS: 400, ENOKEY: 400, }; function fail(res, e) { res .status(STATUS[e.code] || 500) .json({ error: { code: e.code || "EINTERNAL", message: e.message } }); } async function view(account, username) { const keys = identity.sshKeysInfo(account); const terminal = keys.length ? await identity.terminalSshState(account) : undefined; return { account: account.kind, writable: account.kind === "member" || account.writable !== false, // `ssh`: the first key, as before there could be two; `sshKeys`: all. ssh: keys[0] ? { ...keys[0], terminal } : null, sshKeys: keys, sshTerminal: terminal ?? null, sshUsers: await identity.listSshUsers(account), https: identity.listHttps(account), author: await identity.getAuthor(account), // What the author email form suggests while none is set. defaultEmail: identity.defaultEmail(username), }; } /** The signed-in user's name, or the OS user's in local mode. */ const usernameOf = (req) => req.user?.username || os.userInfo().username; /** The request's account; a write to the host's identity needs an admin. */ function accountOf(req, { write = false } = {}) { const account = identity.accountForRequest(req); if (write && account.kind === "host" && !account.writable) { throw Object.assign(new Error("the host's git identity is an admin's to change"), { code: "EHOSTIDENTITY", }); } return account; } router.get("/", async (req, res) => { try { res.json(await view(accountOf(req), usernameOf(req))); } catch (e) { fail(res, e); } }); router.post("/ssh-key", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); await identity.generateSshKey(account, { replace: req.body?.replace === true, type: req.body?.type === "rsa" ? "rsa" : "ed25519", // A named key sits beside the default one of its type. name: typeof req.body?.name === "string" ? req.body.name : null, // The key is labelled with the commit author's email, else the default one. comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)), }); res.status(201).json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); router.delete("/ssh-key", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); const type = req.query.type === undefined ? undefined : String(req.query.type); const id = req.query.id === undefined ? undefined : String(req.query.id); if (!(await identity.removeSshKey(account, { type, id }))) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } }); } res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); /** * Whether a plain `git` in the account's own terminal uses the AgentHub key * (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`. */ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); if (!identity.sshKeyInfo(account) && req.body?.on === true) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } }); } await identity.setTerminalSsh(account, req.body?.on === true); res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); /** * An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key * ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more * account on that host (`ssh:///…`); with `key`, the only key it uses. * DELETE `/ssh-user/` drops it. */ router.put("/ssh-user", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); await identity.setSshUser(account, req.body || {}); res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); if (!(await identity.removeSshUser(account, req.params.host))) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } }); } res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); router.post("/https", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); const { host, username, password } = req.body || {}; identity.saveHttps(account, { protocol: "https", host, username, password }); res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); router.delete("/https/:host", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); if (!identity.removeHttps(account, "https", req.params.host)) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } }); } res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); /** The account's commit author (`git config --global user.name/email`). */ router.put("/author", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); await identity.setAuthor(account, req.body || {}); res.json(await view(account, usernameOf(req))); } catch (e) { fail(res, e); } }); module.exports = router;