b058179177
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash). - Users: row menu (Edit + Open home in Files for an admin, Copy username/email). - Artifacts: card menu repeating its buttons, plus Copy link for a page. - Git identity: named SSH keys beside the default ones (id_<type>_<name>, max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` → `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host), each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of that URL offers only that key and persists it. Terminal sshCommand follows the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and other-host forms. Docs/API.md, OpenAPI updated.
201 lines
7.0 KiB
JavaScript
201 lines
7.0 KiB
JavaScript
/**
|
|
* @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
|
|
* `<username>@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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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://<alias>/…`); with `key`, the only key it uses.
|
|
* DELETE `/ssh-user/<host or alias>` 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;
|