aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/auth.rs7
-rw-r--r--src/api/dashboard/dashboard.html225
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/api/users.rs26
4 files changed, 207 insertions, 58 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs
index a5a987c..ac70b82 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -154,6 +154,13 @@ impl Range {
}
impl AuthState {
+ /// Whether `user` has a password on this node (`--htpasswd`): it logs in by it.
+ pub(crate) fn has_password(&self, user: &str) -> bool {
+ self.users
+ .as_ref()
+ .is_some_and(|users| users.contains_key(user))
+ }
+
/// From the options; None if there is neither an htpasswd file nor a user header
/// (no users: no HTTP API, for a relay).
pub(crate) fn from_settings(
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index c4e6276..0be3526 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -258,6 +258,22 @@
#dialog table { border-collapse: collapse; width: 100%; }
#dialog td { padding: 2px 6px 2px 0; vertical-align: top; }
#dialog td.key { font-family: var(--mono, monospace); word-break: break-all; color: var(--muted); }
+ /* The keys as wide as the dialog lets them, never wider: the name and the actions
+ their widths, the key the rest, cut in the middle (`keyLine`) as much as it must. */
+ #dialog table.keys { table-layout: fixed; }
+ #dialog table.keys col.name { width: 7em; }
+ #dialog table.keys col.actions { width: 4em; }
+ #dialog table.keys td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #dialog table.keys td.actions { text-align: right; padding-right: 0; }
+ /* Being edited: its fields and buttons across the row. */
+ #dialog .key-edit { display: flex; gap: 6px; align-items: baseline; margin: 2px 0; }
+ #dialog .key-edit .key-name { width: 7em; flex: none; }
+ #dialog .key-edit .key-value { flex: 1; min-width: 0; }
+ #dialog table.keys td.key { word-break: normal; overflow: hidden; }
+ .key-line { display: flex; min-width: 0; }
+ .key-line .start { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ min-width: 0; }
+ .key-line .end { flex: none; white-space: pre; }
#dialog form { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#dialog input { font: inherit; }
#dialog input[name="line"] { flex: 1; min-width: 16em; }
@@ -281,8 +297,9 @@
.editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
.editor button { font: inherit; }
- .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad);
- border-radius: 3px; }
+ .editor button.delete, #dialog button.delete { color: #fff; background: var(--bad);
+ border: 1px solid var(--bad); border-radius: 3px; }
+ #dialog button.delete:disabled { opacity: .5; }
.tree details.editing > :not(summary):not(.editor) { display: none; }
.tree .leaf[title] { cursor: text; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
@@ -454,13 +471,19 @@ const handOver = (() => {
// The nodes whose own links this page reads from them (`follow`), kept in the browser,
// and how each goes: declared before anything draws.
const SUBSCRIPTIONS = "continuum-subscriptions";
+// Opened from another node's page (`handOver`), that node is subscribed to too: its
+// links and messages were that page's own, and are another's here.
const subscriptions = (() => {
+ let kept = new Set();
try {
- const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
- return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []);
- } catch (_) {
- return new Set();
+ const listed = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
+ kept = new Set(Array.isArray(listed) ? listed.filter(id => typeof id === "string") : []);
+ } catch (_) { /* none kept */ }
+ if (handOver && typeof handOver.from === "string" && !kept.has(handOver.from)) {
+ kept.add(handOver.from);
+ try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...kept])); } catch (_) {}
}
+ return kept;
})();
// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why`
// not, its latest `report` and when it came (`at`), and how to stop it (`stop`).
@@ -1635,6 +1658,31 @@ async function readStore(path, shallow) {
const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+// A red `Delete` that acts on a second click, from half a second to 3 s after the first
+// (`Delete: sure?`): a double-click doesn't delete. `act()`: whether it did; if not, the
+// button is `Delete` again.
+function deleteButton(act) {
+ const button = el("button", { type: "button", class: "delete" }, "Delete");
+ let sure = null, askedAt = 0;
+ button.addEventListener("click", async () => {
+ if (sure === null) {
+ button.textContent = "Delete: sure?";
+ askedAt = performance.now();
+ sure = setTimeout(() => { sure = null; button.textContent = "Delete"; }, 3000);
+ return;
+ }
+ if (performance.now() - askedAt < 500) return;
+ clearTimeout(sure);
+ button.disabled = true;
+ if (!(await act())) {
+ sure = null;
+ button.textContent = "Delete";
+ button.disabled = false;
+ }
+ });
+ return button;
+}
+
// --- Editing: `Edit value` in an entry's menu turns it into a field holding its contents;
// saved with PUT /store/<path> (the whole value, objects too) ---
// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
@@ -1701,21 +1749,9 @@ function editor(path, value, done, options = {}) {
};
save.addEventListener("click", submit);
cancel.addEventListener("click", () => done(false));
- // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second
- // click to be sure, not too soon after the first: a double-click doesn't delete.
- const remove = options.extra ? null : el("button", { type: "button", class: "delete" },
- "Delete");
- let sure = null, askedAt = 0;
- remove?.addEventListener("click", async () => {
- if (sure === null) {
- remove.textContent = "Delete: sure?";
- askedAt = performance.now();
- sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000);
- return;
- }
- if (performance.now() - askedAt < 500) return;
- clearTimeout(sure);
- remove.disabled = save.disabled = true;
+ // Editing what's there: it can be deleted too (DELETE /store/<path>, `deleteButton`).
+ const remove = options.extra ? null : deleteButton(async () => {
+ save.disabled = true;
message.className = "note";
message.textContent = "Deleting…";
try {
@@ -1726,11 +1762,11 @@ function editor(path, value, done, options = {}) {
throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
}
done("deleted", response);
+ return true;
} catch (error) {
- sure = null;
- remove.textContent = "Delete";
- remove.disabled = save.disabled = false;
+ save.disabled = false;
fail("Not deleted: " + error.message);
+ return false;
}
});
// Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does.
@@ -2291,6 +2327,8 @@ function handOff(id, handed) {
pinned: [...pinned],
view: { ...view },
focused: focused === id ? null : focused,
+ // The node this page is of: the next page subscribes to it.
+ from: state ? state.node.id : undefined,
settings,
legend: !document.getElementById("legends").hidden,
figures: [...open],
@@ -2592,8 +2630,10 @@ async function logOut() {
location.assign("../login");
}
}
-// The user's credentials: its keys, added and removed (`/auth/keys`), but the last;
-// under a strict policy, how to sign them anew; where its password is.
+// The user's credentials: its keys (`/auth/keys`): each edited (its name, its line),
+// removed (its last only where it has a password on this node, which it logs in by
+// then), or one added; under a strict policy, how to sign them anew; where its password
+// is. What fails says so where it was asked, which stays as it was, to be set right.
async function credentials() {
const password = el("p", {}, "Your password is in each node's password file " +
"(--htpasswd): its operator changes it there.");
@@ -2607,49 +2647,134 @@ async function credentials() {
return;
}
const keys = Object.entries(answer.keys || {});
- const rows = keys.map(([id, line]) => {
- const remove = el("button", { type: "button" }, "remove");
- if (answer.signed || keys.length < 2) {
- remove.disabled = true;
- remove.title = answer.signed ? "signed: see below" : "your last key: add another first";
- }
- remove.addEventListener("click", () => changeKey(id, null));
- return el("tr", {}, el("td", {}, id), el("td", { class: "key" }, line), el("td", {}, remove));
- });
+ const signed = !!answer.signed;
+ // The columns' widths set once, whatever its rows (one edited spans them all).
+ const table = el("table", { class: "keys" }, el("colgroup", {},
+ el("col", { class: "name" }), el("col", {}), el("col", { class: "actions" })));
+ const row = (id, line) => {
+ const tr = el("tr", {});
+ // Its name, as much of the key as fits, and `edit`, at the right.
+ const shown = () => {
+ const edit = el("button", { type: "button" }, "edit");
+ if (signed) {
+ edit.disabled = true;
+ edit.title = "signed: see below";
+ }
+ edit.addEventListener("click", editing);
+ tr.replaceChildren(el("td", { class: "name", title: id }, id),
+ el("td", { class: "key", title: line }, keyLine(line)),
+ el("td", { class: "actions" }, edit));
+ };
+ // Its name and line in fields, across the row; saved under the name (a new name:
+ // written under it, then the old one removed), or deleted (`deleteButton`; its last
+ // only where the user has a password on this node).
+ const editing = () => {
+ const name = el("input", { size: 12, spellcheck: "false", class: "key-name" });
+ name.value = id;
+ const value = el("input", { spellcheck: "false", class: "key-value" });
+ value.value = line;
+ const save = el("button", { type: "button" }, "save");
+ const cancel = el("button", { type: "button" }, "cancel");
+ const failed = el("span", { class: "error" });
+ const remove = deleteButton(async () => {
+ save.disabled = true;
+ const why = await keyRequest(id, null);
+ if (why === null) { credentials(); return true; }
+ save.disabled = false;
+ failed.textContent = "Not deleted: " + why;
+ return false;
+ });
+ if (keys.length < 2 && !answer.password) {
+ remove.disabled = true;
+ remove.title = "your last key, and you have no password on this node: add another first";
+ }
+ const submit = async () => {
+ const to = name.value.trim(), text = value.value.trim();
+ if (to === "" || text === "") { failed.textContent = "A name and a key, please."; return; }
+ if (to !== id && keys.some(([other]) => other === to)) {
+ failed.textContent = "\"" + to + "\" is a key of yours already."; return;
+ }
+ save.disabled = true;
+ failed.textContent = "";
+ let why = await keyRequest(to, text);
+ if (why === null && to !== id) {
+ const old = await keyRequest(id, null);
+ if (old !== null) why = "saved as \"" + to + "\", but \"" + id + "\" not removed: " + old;
+ }
+ if (why === null) credentials();
+ else { save.disabled = false; failed.textContent = "Not saved: " + why; }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", shown);
+ for (const field of [name, value]) {
+ field.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); shown(); }
+ });
+ }
+ tr.replaceChildren(el("td", { colspan: "3" },
+ el("div", { class: "key-edit" }, name, value),
+ el("div", { class: "key-edit" }, save, cancel, remove, failed)));
+ value.focus();
+ };
+ shown();
+ return tr;
+ };
+ table.append(...keys.map(([id, line]) => row(id, line)));
const parts = [password, el("h3", {}, "Your keys"),
- keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")];
- if (answer.signed) {
+ keys.length ? table : el("p", { class: "note" }, "none")];
+ if (signed) {
parts.push(el("p", {}, "This swarm has its users sign their entries: change your " +
"keys by signing yours anew, with a key you hold: continuum-signed key-add NAME " +
"FILE.pub, continuum-signed key-remove NAME."));
} else {
+ const failed = el("p", { class: "error" });
const form = el("form", {},
el("input", { name: "id", placeholder: "name (laptop)", required: "", size: 12 }),
el("input", { name: "line", placeholder: "ssh-ed25519 AAAA… (its .pub)", required: "",
spellcheck: "false" }),
el("button", {}, "add"));
- form.addEventListener("submit", event => {
+ form.addEventListener("submit", async event => {
event.preventDefault();
- changeKey(form.elements.id.value.trim(), form.elements.line.value.trim());
+ const id = form.elements.id.value.trim(), line = form.elements.line.value.trim();
+ if (keys.some(([other]) => other === id)) {
+ failed.textContent = "\"" + id + "\" is a key of yours already: edit it instead.";
+ return;
+ }
+ failed.textContent = "";
+ const why = await keyRequest(id, line);
+ if (why === null) credentials();
+ else failed.textContent = "Not added: " + why;
});
- parts.push(form);
+ parts.push(form, failed);
}
say("Credentials", ...parts);
}
-// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew.
-async function changeKey(id, line) {
+// A key as much of it as fits: its type and what tells it apart (an Ed25519 key's
+// base64 after the part every one of them shares), cut in the middle where it doesn't
+// fit, its last eight characters and its comment always shown.
+const ED25519_COMMON = "AAAAC3NzaC1lZDI1NTE5AAAAI";
+function keyLine(line) {
+ const [type = "", data = "", ...comment] = line.trim().split(/\s+/);
+ const own = data.startsWith(ED25519_COMMON) ? data.slice(ED25519_COMMON.length) : data;
+ const cut = Math.max(0, own.length - 8);
+ return el("span", { class: "key-line" },
+ el("span", { class: "start" }, type + " " + own.slice(0, cut)),
+ el("span", { class: "end" }, own.slice(cut) + (comment.length ? " " + comment.join(" ") : "")));
+}
+// Key `id` of the user's set to `line`, or removed (`line` null): null if done, else why
+// not.
+async function keyRequest(id, line) {
const init = line === null ? { method: "DELETE" }
: { method: "PUT", headers: { "Content-Type": "text/plain" }, body: line };
- const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init))
- .catch(error => ({ ok: false, statusText: error.message, json: async () => ({}) }));
- if (!response.ok) {
+ try {
+ const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init));
+ if (response.ok) return null;
const answer = await response.json().catch(() => ({}));
- say("Credentials", el("p", { class: "error" },
- (line === null ? "Couldn't remove " : "Couldn't add ") + id + ": " +
- (answer.error || response.status + " " + response.statusText)));
- return;
+ return answer.error || response.status + " " + response.statusText;
+ } catch (error) {
+ return error.message;
}
- credentials();
}
async function obtainTokens() {
clearTimeout(tokenTimer);
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 0dde808..5c7c8e7 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -114,7 +114,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
state.clone(),
access::authorize,
))
- .layer(middleware::from_fn_with_state(auth_state, basic_auth))
+ .layer(middleware::from_fn_with_state(
+ auth_state.clone(),
+ basic_auth,
+ ))
+ // Who has a password here (`/auth/keys`: a user's last key goes only then).
+ .layer(axum::Extension(auth_state))
// Outside authentication: a token, if the request brings one, names the user.
.layer(middleware::from_fn_with_state(state.clone(), login::bearer))
// Outermost: pages of other origins, and their preflights, which bring no
diff --git a/src/api/users.rs b/src/api/users.rs
index 1dda33b..d6950af 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -24,8 +24,10 @@
//! * `GET /auth/keys`: the caller's keys, its entry, and the envelope holding it under a
//! strict policy (`signed`), to sign the next one against.
//! * `PUT /auth/keys/{id}`: one added or replaced (the line as the body);
-//! `DELETE /auth/keys/{id}` removes one. Under a strict policy `409`: the user signs
-//! its entry with the keys changed, nothing else (`continuum-signed key-add`).
+//! `DELETE /auth/keys/{id}` removes one, the last only of a user with a password on
+//! this node (`--htpasswd`, `password` in `GET /auth/keys`), which logs in by it. Under
+//! a strict policy `409`: the user signs its entry with the keys changed, nothing else
+//! (`continuum-signed key-add`).
//!
//! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm
//! without a registry, being in `--protocol-writers`). Every change is one change to
@@ -142,6 +144,7 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> {
pub(crate) async fn own_keys(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
) -> Result<Json<JsonValue>, ApiError> {
let replica = state.doc.read().await;
let path = registry::user_path(&user.0);
@@ -157,6 +160,7 @@ pub(crate) async fn own_keys(
"keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})),
"entry": entry,
"signed": signed,
+ "password": auth.has_password(&user.0),
})))
}
@@ -168,24 +172,28 @@ pub(crate) async fn put_own_key(
body: String,
) -> Result<StatusCode, ApiError> {
let key = crate::access::sshsig::PublicKey::parse(body.trim()).map_err(|why| bad(&why))?;
- own_key(&state, &user.0, id, Some(key.line())).await
+ own_key(&state, &user.0, id, Some(key.line()), false).await
}
/// `DELETE /auth/keys/{id}`.
pub(crate) async fn delete_own_key(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
- own_key(&state, &user.0, id, None).await
+ let password = auth.has_password(&user.0);
+ own_key(&state, &user.0, id, None, password).await
}
-/// `user`'s key `id` set to `line`, or removed: one change, as `user`.
+/// `user`'s key `id` set to `line`, or removed: one change, as `user`. Its last key only
+/// with `password` (it has one here, and logs in by it).
async fn own_key(
state: &AppState,
user: &str,
id: String,
line: Option<String>,
+ password: bool,
) -> Result<StatusCode, ApiError> {
if !registry::valid_name(&id) {
return Err(bad("not a key's name: 1 to 64 of A-Z a-z 0-9 . _ @ -"));
@@ -216,7 +224,7 @@ async fn own_key(
Some("no entry of yours".to_owned()),
));
}
- if line.is_none() {
+ if line.is_none() && !password {
let mut keys = entry.clone();
keys.push("keys".to_owned());
let others = store::keys_at(&*doc, &keys)?
@@ -227,7 +235,11 @@ async fn own_key(
if others == 0 {
return Err(ApiError(
StatusCode::CONFLICT,
- Some("your last key: add another first".to_owned()),
+ Some(
+ "your last key, and you have no password on this node: add \
+ another first"
+ .to_owned(),
+ ),
));
}
}