ipcr-public: a second ipcrd behind Caddy at ipcr-<domain>, plain HTTP, no pinning, off the admin and CI networks. With PUBLIC_POLICY it serves only this forge's publisher name, its verified ENS/DNS name and its published image roots; everything else is 404, pushes 405. So docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0 works on any machine with plain Docker. Switch in the admin page (config.json "public"), mirrored into published.json for the pages, which show the public pull lines while it is on. ipcr 1.5.0, bridge 0.4.0. Verified from a second machine (docs/forge.md).
604 lines
34 KiB
HTML
604 lines
34 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>IPCR Forge admin</title>
|
|
<link rel="icon" href="icon.png">
|
|
<!--
|
|
The forge's admin page: Gitea site administrators only (the bridge checks the session for the
|
|
page and for every API call). Everything goes through /admin/api/: the bridge's own endpoints
|
|
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
|
|
-->
|
|
<style>
|
|
:root {
|
|
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
|
--accent: #0f766e; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
|
--accent: #2dd4bf; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
|
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
main { max-width: 1080px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
header { display: flex; gap: 14px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
|
header .title { display: flex; gap: 12px; align-items: center; }
|
|
header img { width: 40px; height: 40px; }
|
|
h1 { font-size: 24px; margin: 0; }
|
|
h2 { font-size: 19px; margin: 32px 0 10px; }
|
|
h3 { font-size: 15px; margin: 14px 0 6px; }
|
|
p { margin: 6px 0; }
|
|
a { color: var(--accent); }
|
|
.muted { color: var(--muted); }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; min-width: 0; }
|
|
.card h3 { margin-top: 0; }
|
|
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
|
|
dt { color: var(--muted); }
|
|
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; background: var(--code);
|
|
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
|
.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
|
|
button, input[type=submit] { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
|
font: inherit; font-size: 13px; padding: 4px 10px; cursor: pointer; }
|
|
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
button.danger { color: var(--bad); }
|
|
button:disabled { opacity: .5; cursor: default; }
|
|
input[type=text], input[type=password], input[type=number], input[type=file] { font: inherit; font-size: 14px; padding: 5px 8px;
|
|
border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); max-width: 100%; }
|
|
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
th { color: var(--muted); font-weight: 600; font-size: 12.5px; }
|
|
.scroll { overflow-x: auto; }
|
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
|
|
.bar span { display: block; height: 100%; background: var(--accent); }
|
|
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
|
|
.wide { grid-column: 1 / -1; }
|
|
/* Names, CIDs and commands stay on one line; their box scrolls instead. */
|
|
.line { display: block; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; }
|
|
.copyrow { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
|
.copyrow code { flex: 1; min-width: 0; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; }
|
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 20px 0 4px; overflow-x: auto; }
|
|
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted);
|
|
padding: 8px 14px; font-size: 14px; white-space: nowrap; }
|
|
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
|
|
.count { font-size: 12px; background: var(--code); border-radius: 10px; padding: 0 7px; margin-left: 4px; }
|
|
.addrs { list-style: none; padding: 0; margin: 4px 0 0; font-size: 13px; }
|
|
.addrs li { margin: 2px 0; }
|
|
.tag { border-top: 1px solid var(--line); padding: 10px 0; }
|
|
.tag:first-of-type { border-top: 0; }
|
|
.taghead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 6px; }
|
|
.cmd { display: grid; grid-template-columns: 44px 1fr auto; gap: 8px; align-items: center; margin: 3px 0; }
|
|
.cmd .k { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; }
|
|
.cmd code { white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; min-width: 0; }
|
|
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent);
|
|
border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; margin-right: 6px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
|
|
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<div class="title"><img src="icon.png" alt=""><div><h1>IPCR Forge admin</h1>
|
|
<div class="muted"><a href="/">Forge page</a> · signed in as <b id="me">…</b></div></div></div>
|
|
<button id="logout">Sign out</button>
|
|
</header>
|
|
|
|
<nav class="tabs" role="tablist">
|
|
<button role="tab" data-tab="overview">Overview</button>
|
|
<button role="tab" data-tab="images">Published images<span class="count" id="images-count">…</span></button>
|
|
<button role="tab" data-tab="repos">Repositories</button>
|
|
<button role="tab" data-tab="backup">Key backup</button>
|
|
</nav>
|
|
|
|
<section class="panel" id="tab-overview">
|
|
<h2>Publisher name</h2>
|
|
<div class="grid">
|
|
<div class="card wide">
|
|
<h3>Key</h3>
|
|
<dl>
|
|
<dt>Key</dt><dd><code id="key-name">…</code></dd>
|
|
<dt>IPNS name</dt><dd><div class="copyrow"><code id="key-id">…</code><button class="copy" data-copy="key-id">Copy</button></div></dd>
|
|
<dt>Pull as</dt><dd><div class="copyrow"><code id="pull-as">…</code></div></dd>
|
|
</dl>
|
|
<p class="muted">Every image is published under this one name. Back the key up (Key backup tab): it exists only on this server.</p>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Your domain or ENS name</h3>
|
|
<p class="muted">Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.</p>
|
|
<form class="row" id="name-form">
|
|
<input type="text" id="name" placeholder="example.eth or example.com" size="30" autocomplete="off">
|
|
<input type="submit" value="Save and check">
|
|
</form>
|
|
<p id="name-check" class="muted"></p>
|
|
<details><summary class="muted">How to point it here</summary>
|
|
<p><b>ENS:</b> in the ENS manager, Records → Content hash =</p>
|
|
<div class="copyrow"><code id="ens-value">ipns://k51…</code><button class="copy" data-copy="ens-value">Copy</button></div>
|
|
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> =</p>
|
|
<div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div>
|
|
</details>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Public pulls</h3>
|
|
<p class="muted">This forge's images, and only those, can also be pulled from any machine with plain Docker, without IPCR,
|
|
through <code id="public-host">ipcr-…</code>. Read-only; a pull there pins nothing. Layers are still checked by digest.</p>
|
|
<form class="row" id="public-form">
|
|
<label><input type="checkbox" id="public-on"> Serve public pulls</label>
|
|
<span id="public-state" class="muted"></span>
|
|
</form>
|
|
<div class="copyrow"><code id="public-try">docker pull …</code><button class="copy" data-copy="public-try">Copy</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>IPNS record</h3>
|
|
<dl id="record"></dl>
|
|
<div class="actions" style="margin-top:8px"><button id="republish">Republish now</button></div>
|
|
<p class="muted">The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a
|
|
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Storage</h3>
|
|
<div id="storage"></div>
|
|
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Node</h3>
|
|
<dl id="node"></dl>
|
|
<h3 style="margin-top:14px">Listening on</h3>
|
|
<ul class="addrs" id="addrs"></ul>
|
|
<div class="actions" style="margin-top:10px"><button id="peers">What the network sees</button></div>
|
|
<div id="peers-out" class="muted"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-images" hidden>
|
|
<h2>Published images</h2>
|
|
<p class="muted">Every tag can be pulled three ways: by <b>IPFS</b> CID (this exact image, forever), by <b>IPNS</b> name (follows the tag),
|
|
and by your ENS or DNS name once it checks out (Overview).</p>
|
|
<div id="images" class="muted">Loading…</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-repos" hidden>
|
|
<h2>Root organisation</h2>
|
|
<div class="card">
|
|
<p class="muted">The public repositories of this Gitea organisation are also published at the root of the forge's
|
|
name: <code>metadec/app</code> → <code>…/ipns/<name>/app</code>. Its members (Gitea teams) are the ones who
|
|
can publish there. Leave empty for none.</p>
|
|
<form class="row" id="org-form">
|
|
<input type="text" id="org" placeholder="organisation" size="24" autocomplete="off">
|
|
<input type="submit" value="Save">
|
|
</form>
|
|
<p id="org-state" class="muted"></p>
|
|
</div>
|
|
|
|
<h2>Which repositories may publish</h2>
|
|
<p class="muted">Each public repository switched on here publishes under its own name only: the forge sets an
|
|
<code>IPCR_PUSH_TOKEN</code> secret on it, and the staging registry accepts that token for that name and no other.
|
|
Workflows log in with it (see the forge page's template). New public repositories are on.</p>
|
|
<div id="repos" class="muted">Loading…</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-backup" hidden>
|
|
<h2>Key backup</h2>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Download a backup</h3>
|
|
<p class="muted">The key, encrypted with your passphrase (at least 12 characters). Keep the file and the passphrase apart.
|
|
Without a backup, losing this server loses the name for good.</p>
|
|
<form class="row" id="export-form">
|
|
<input type="password" id="ex-pass" placeholder="passphrase" autocomplete="new-password" required>
|
|
<input type="password" id="ex-pass2" placeholder="again" autocomplete="new-password" required>
|
|
<input type="submit" value="Download">
|
|
</form>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Restore a backup</h3>
|
|
<p class="muted">Makes the backed-up key this forge's publisher key: to move a forge to a new server, or recover one.
|
|
The current key is kept (renamed), not deleted.</p>
|
|
<form class="row" id="import-form">
|
|
<input type="file" id="im-file" accept=".json,application/json" required>
|
|
<input type="password" id="im-pass" placeholder="passphrase" autocomplete="off" required>
|
|
<input type="number" id="im-seq" placeholder="sequence (only if asked)" min="1">
|
|
<input type="submit" value="Restore">
|
|
</form>
|
|
<p class="note">Every image name then points at this server's images under the restored name. On a fresh server, the
|
|
name's last published images are adopted.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
var $ = function (id) { return document.getElementById(id); };
|
|
var publisher = "", pubName = "";
|
|
// The public front door's host: same suffix as this page, ipcr- instead of ipcr-forge-.
|
|
var publicHost = location.hostname.indexOf("ipcr-forge-") === 0 ? "ipcr-" + location.hostname.slice("ipcr-forge-".length) : "";
|
|
|
|
function toast(msg, ms) {
|
|
var t = $("toast"); t.textContent = msg; t.style.display = "block";
|
|
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
|
|
}
|
|
// Every call: same origin, with the header the bridge requires on writes.
|
|
function api(method, path, body) {
|
|
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
|
|
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
|
|
return fetch("/admin/api/" + path, opt).then(function (r) {
|
|
if (r.status === 401) { location.href = "/admin/login"; throw new Error("signed out"); }
|
|
return r.text().then(function (t) {
|
|
var j = null; try { j = JSON.parse(t); } catch (e) {}
|
|
if (!r.ok) throw new Error((j && j.error) || t || r.statusText);
|
|
return j;
|
|
});
|
|
});
|
|
}
|
|
function el(tag, text, cls) { var e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }
|
|
function dl(target, rows) {
|
|
target.textContent = "";
|
|
rows.forEach(function (r) {
|
|
target.appendChild(el("dt", r[0]));
|
|
var dd = el("dd");
|
|
if (r[1] instanceof Node) dd.appendChild(r[1]); else dd.textContent = r[1] == null ? "—" : r[1];
|
|
target.appendChild(dd);
|
|
});
|
|
}
|
|
function badge(text, cls) { return el("span", text, cls); }
|
|
function size(n) {
|
|
if (!n) return "0";
|
|
var u = ["B", "KB", "MB", "GB", "TB"], i = 0;
|
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
return n.toFixed(i ? 1 : 0) + " " + u[i];
|
|
}
|
|
function until(iso) {
|
|
if (!iso) return "";
|
|
var h = (Date.parse(iso) - Date.now()) / 3600000;
|
|
if (h < 0) return "expired";
|
|
return h > 48 ? Math.round(h / 24) + " days left" : Math.round(h) + " h left";
|
|
}
|
|
function busy(btn, p) {
|
|
btn.disabled = true;
|
|
return p.then(function (v) { btn.disabled = false; return v; }, function (e) { btn.disabled = false; toast(e.message, 8000); throw e; });
|
|
}
|
|
|
|
api("GET", "me").then(function (me) { $("me").textContent = me.user; });
|
|
$("logout").onclick = function () {
|
|
fetch("/admin/logout", { method: "POST", headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin" })
|
|
.then(function () { location.href = "/"; });
|
|
};
|
|
|
|
// ---- tabs (the open one is kept in the URL, so a reload stays on it)
|
|
var tabs = document.querySelectorAll(".tabs button");
|
|
function showTab(name) {
|
|
if (!$("tab-" + name)) name = "overview";
|
|
Array.prototype.forEach.call(tabs, function (b) {
|
|
var on = b.getAttribute("data-tab") === name;
|
|
b.setAttribute("aria-selected", on ? "true" : "false");
|
|
$("tab-" + b.getAttribute("data-tab")).hidden = !on;
|
|
});
|
|
}
|
|
Array.prototype.forEach.call(tabs, function (b) {
|
|
b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); };
|
|
});
|
|
showTab(location.hash.slice(1));
|
|
|
|
// ---- copy
|
|
function copy(text, btn) {
|
|
var done = function () { if (btn) { btn.textContent = "Copied"; setTimeout(function () { btn.textContent = "Copy"; }, 1500); } };
|
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
|
else window.prompt("Copy:", text);
|
|
}
|
|
function copyButton(text) { var b = el("button", "Copy", "copy"); b.type = "button"; b.onclick = function () { copy(text, b); }; return b; }
|
|
Array.prototype.forEach.call(document.querySelectorAll("button[data-copy]"), function (b) {
|
|
b.type = "button";
|
|
b.onclick = function () { copy($(b.getAttribute("data-copy")).textContent, b); };
|
|
});
|
|
function spinner(text) { var s = el("span"); s.appendChild(el("span", null, "spin")); s.appendChild(document.createTextNode(text)); return s; }
|
|
|
|
// ---- status
|
|
function recordView(r, err, tree) {
|
|
if (!r) return badge(err || "none", "bad");
|
|
var s = el("span");
|
|
s.appendChild(badge(r.valid ? "valid" : "invalid", r.valid ? "ok" : "bad"));
|
|
s.appendChild(document.createTextNode(" · sequence " + r.sequence + " · " + until(r.validity)));
|
|
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
|
|
return s;
|
|
}
|
|
function showName(n) {
|
|
var nc = $("name-check"); nc.textContent = "";
|
|
if (n && n.name) {
|
|
nc.appendChild(n.ok ? badge("✓ " + n.name + " points at this forge", "ok") : badge("✗ " + (n.error || "does not resolve"), "bad"));
|
|
if (!n.ok && n.resolves) nc.appendChild(el("div", n.name + " currently resolves to " + n.resolves, "muted"));
|
|
if (n.checkedAt) nc.appendChild(el("div", "Checked " + n.checkedAt.replace("T", " ").replace("Z", " UTC"), "muted"));
|
|
} else nc.textContent = "No name set: pull lines use the IPNS name.";
|
|
}
|
|
function loadStatus(refresh) {
|
|
var st8 = $("refresh-state");
|
|
st8.textContent = ""; st8.appendChild(spinner(refresh ? "Checking the network…" : "Loading…"));
|
|
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
|
|
st8.textContent = "";
|
|
publisher = s.key.id || "";
|
|
$("key-name").textContent = s.key.name || "—";
|
|
$("key-id").textContent = publisher || "not created yet (the first import creates it)";
|
|
$("ens-value").textContent = "ipns://" + (publisher || "k51…");
|
|
$("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…");
|
|
pubName = s.name && s.name.ok ? s.name.name : "";
|
|
$("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
|
if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name;
|
|
showName(s.name);
|
|
var rec = s.record;
|
|
dl($("record"), [
|
|
["Here", recordView(rec.local, rec.localError, rec.tree)],
|
|
["Network", recordView(rec.network, rec.networkError, rec.tree)],
|
|
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
|
|
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
|
|
]);
|
|
var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id));
|
|
dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]);
|
|
// Public addresses first; loopback and the container's own network are of no use to anyone else.
|
|
var addrs = (s.node.addresses || []).filter(function (a) { return !/^\/ip4\/(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)|^\/ip6\/(::1|f[cd])/.test(a); });
|
|
var ul = $("addrs"); ul.textContent = "";
|
|
if (!addrs.length) ul.appendChild(el("li", "no public address", "warn"));
|
|
addrs.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a.replace(/\/p2p\/[^/]+$/, ""), "line")); ul.appendChild(li); });
|
|
var sto = s.storage, pct = sto.storageMax ? Math.min(100, 100 * sto.repoSize / sto.storageMax) : 0;
|
|
$("storage").textContent = "";
|
|
$("storage").appendChild(el("div", size(sto.repoSize) + " of " + size(sto.storageMax) + " · " + sto.pins + " pins"));
|
|
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
|
|
$("storage").appendChild(bar);
|
|
}).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
|
|
}
|
|
function loadPublic() {
|
|
$("public-host").textContent = publicHost || "ipcr-<domain>";
|
|
return api("GET", "ipcr/config").then(function (c) {
|
|
$("public-on").checked = !!c.public;
|
|
$("public-state").textContent = c.public ? "on" : "off";
|
|
$("public-try").textContent = "docker pull " + (publicHost || "ipcr-<domain>") + "/ipns/" + (pubName || publisher || "k51…") + "/<repo>:<tag>";
|
|
});
|
|
}
|
|
$("public-on").onchange = function () {
|
|
var cb = this, st = $("public-state");
|
|
cb.disabled = true; st.textContent = ""; st.appendChild(spinner("saving…"));
|
|
api("PATCH", "ipcr/config", { public: cb.checked })
|
|
.then(function (c) { toast(c.public ? "Public pulls on (within 10 s)" : "Public pulls off (within 10 s)"); return loadPublic(); },
|
|
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
|
.then(function () { cb.disabled = false; loadImages(); });
|
|
};
|
|
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
|
|
$("republish").onclick = function () {
|
|
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
|
|
};
|
|
$("peers").onclick = function () {
|
|
var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…"));
|
|
busy(this, api("GET", "ipcr/peers")).then(function (p) {
|
|
o.textContent = "";
|
|
if (p.error) o.appendChild(badge(p.error, "bad"));
|
|
else if (!p.network || !p.network.length) o.appendChild(badge("The network does not know this node's addresses yet: check that port 4768 (TCP and UDP) is reachable.", "warn"));
|
|
else {
|
|
o.appendChild(badge("✓ Other nodes can reach this one via:", "ok"));
|
|
var ul = el("ul", null, "addrs");
|
|
p.network.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a, "line")); ul.appendChild(li); });
|
|
o.appendChild(ul);
|
|
}
|
|
}, function () { o.textContent = ""; });
|
|
};
|
|
$("name-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim();
|
|
var nc = $("name-check");
|
|
nc.textContent = "";
|
|
nc.appendChild(spinner(name ? "Saving, then resolving " + name + " (up to a minute)…" : "Clearing the name…"));
|
|
btn.disabled = input.disabled = true; btn.value = "Checking…";
|
|
api("PATCH", "ipcr/config", { name: name })
|
|
.then(function () { return loadStatus(true); })
|
|
.then(function () {
|
|
toast(name ? (pubName ? name + " checks out: pull lines use it" : "Saved: " + name + " does not point here yet") : "Name cleared");
|
|
loadImages();
|
|
}, function (err) { showName(null); toast(err.message, 8000); })
|
|
.then(function () { btn.disabled = input.disabled = false; btn.value = "Save and check"; });
|
|
};
|
|
|
|
// ---- images
|
|
// The pull commands for one tag: by CID, by IPNS name, by the configured name when it checks out.
|
|
// The root organisation's repositories have a short path too: it comes first.
|
|
function pullCommands(reg, d, img, tg) {
|
|
var short = img.aliases && img.aliases.length ? img.aliases[0] : "";
|
|
var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]];
|
|
if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]);
|
|
if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]);
|
|
if (d.public && publicHost && (d.name || d.publisher)) rows.push(["Public", publicHost + "/ipns/" + (d.name || d.publisher) + "/" + path]);
|
|
if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]);
|
|
var box = el("div");
|
|
rows.forEach(function (r) {
|
|
var cmd = "docker pull " + r[1], row = el("div", null, "cmd");
|
|
row.appendChild(el("span", r[0], "k")); row.appendChild(el("code", cmd)); row.appendChild(copyButton(cmd));
|
|
box.appendChild(row);
|
|
});
|
|
return box;
|
|
}
|
|
function loadImages() {
|
|
return api("GET", "ipcr/images").then(function (d) {
|
|
var box = $("images"); box.textContent = ""; box.className = "";
|
|
var reg = d.registry || "ipcr.localhost:4767", n = 0;
|
|
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
|
|
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); $("images-count").textContent = "0"; return; }
|
|
d.images.forEach(function (img) {
|
|
n += img.tags.length;
|
|
var card = el("div", null, "card"); card.style.marginBottom = "12px";
|
|
var h = el("h3", (img.aliases && img.aliases.length ? img.aliases[0] + " · " : "") + img.repo + " ");
|
|
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
|
|
card.appendChild(h);
|
|
img.tags.forEach(function (tg) {
|
|
var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div");
|
|
left.appendChild(el("code", tg.tag));
|
|
if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
|
var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov);
|
|
var acts = el("div", null, "actions");
|
|
var chk = el("button", "Findable?");
|
|
chk.onclick = function () {
|
|
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
|
|
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
|
|
prov.textContent = "";
|
|
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
|
|
if (!p.self) {
|
|
var ann = el("button", "Announce");
|
|
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
|
|
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
|
|
}
|
|
}, function () { prov.textContent = ""; });
|
|
};
|
|
acts.appendChild(chk);
|
|
if (tg.tag !== "latest") {
|
|
var lat = el("button", "Make latest");
|
|
lat.onclick = function () {
|
|
busy(lat, api("POST", "ipcr/retag", { repo: img.repo, tag: "latest", from: tg.tag })).then(function () { toast(img.repo + ":latest → " + tg.tag); loadImages(); });
|
|
};
|
|
acts.appendChild(lat);
|
|
}
|
|
var un = el("button", "Unpublish", "danger");
|
|
un.onclick = function () {
|
|
if (!confirm("Unpublish " + img.repo + ":" + tg.tag + "?\n\nThe name stops resolving to it. Servers that already pulled it keep their copy, and its CID keeps working wherever it is still pinned.")) return;
|
|
busy(un, api("POST", "ipcr/unpublish", { repo: img.repo, tag: tg.tag })).then(function (r) {
|
|
toast("Unpublished (" + r.unpinned + " objects unpinned)"); loadImages(); loadStatus(true);
|
|
});
|
|
};
|
|
acts.appendChild(un);
|
|
head.appendChild(left); head.appendChild(acts);
|
|
t.appendChild(head);
|
|
t.appendChild(pullCommands(reg, d, img, tg));
|
|
card.appendChild(t);
|
|
});
|
|
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
|
|
if (pending.length) {
|
|
card.appendChild(el("h3", "In staging, not published"));
|
|
var tbl = el("table");
|
|
pending.forEach(function (s) {
|
|
var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td");
|
|
td1.appendChild(el("code", s.tag));
|
|
if (img.allowed) {
|
|
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
|
|
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued: imported on the next pass"); setTimeout(loadImages, 5000); }); };
|
|
td3.appendChild(re);
|
|
}
|
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
|
|
});
|
|
card.appendChild(tbl);
|
|
}
|
|
box.appendChild(card);
|
|
});
|
|
$("images-count").textContent = String(n);
|
|
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
|
|
}
|
|
|
|
// ---- repositories
|
|
function loadSettings() {
|
|
return api("GET", "settings").then(function (st) {
|
|
if (document.activeElement !== $("org")) $("org").value = st.rootOrg || "";
|
|
$("org-state").textContent = st.rootOrg ? "Publishing " + st.rootOrg + "'s repositories at the root." : "No root organisation.";
|
|
}).catch(function (e) { $("org-state").textContent = e.message; });
|
|
}
|
|
$("org-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var btn = this.querySelector("input[type=submit]"), st = $("org-state");
|
|
st.textContent = ""; st.appendChild(spinner("Saving, then updating credentials and IPCR's list…"));
|
|
btn.disabled = true;
|
|
api("PUT", "settings", { rootOrg: $("org").value.trim() })
|
|
.then(function (r) { toast(r.rootOrg ? r.rootOrg + " is the root organisation; short paths appear on IPCR's next pass" : "No root organisation"); return Promise.all([loadSettings(), loadRepos()]); },
|
|
function (err) { toast(err.message, 8000); return loadSettings(); })
|
|
.then(function () { btn.disabled = false; setTimeout(loadImages, 70000); });
|
|
};
|
|
function loadRepos() {
|
|
return api("GET", "repos").then(function (repos) {
|
|
var box = $("repos"); box.textContent = ""; box.className = "";
|
|
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
|
|
var t = el("table"), head = el("tr");
|
|
["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); });
|
|
t.appendChild(head);
|
|
repos.forEach(function (r) {
|
|
var tr = el("tr"), td1 = el("td"), td2 = el("td"), td3 = el("td"), td4 = el("td"), cb = document.createElement("input");
|
|
td1.appendChild(el("code", r.full_name));
|
|
(r.published_as || []).forEach(function (p, i) { var d = el("div"); d.appendChild(el("code", p + ":<tag>", i ? "muted" : "")); td2.appendChild(d); });
|
|
if (!r.publish) td2.appendChild(el("span", "—", "muted"));
|
|
if (r.collision) td2.appendChild(el("div", "no short path: " + r.collision, "warn"));
|
|
if (r.publish) {
|
|
td3.appendChild(r.credential ? badge("✓ IPCR_PUSH_TOKEN set", "ok") : badge("✗ missing", "bad"));
|
|
var rot = el("button", "Reissue"); rot.style.marginLeft = "8px";
|
|
rot.onclick = function () {
|
|
if (!confirm("Give " + r.full_name + " a new IPCR_PUSH_TOKEN? The old one stops working at once; running builds may fail.")) return;
|
|
busy(rot, api("POST", "repos/rotate", { full_name: r.full_name })).then(function () { toast("New token set on " + r.full_name); loadRepos(); });
|
|
};
|
|
td3.appendChild(rot);
|
|
} else td3.appendChild(el("span", "—", "muted"));
|
|
cb.type = "checkbox"; cb.checked = r.publish;
|
|
cb.onchange = function () {
|
|
cb.disabled = true;
|
|
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
|
|
.then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); },
|
|
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
|
.then(function () { cb.disabled = false; loadImages(); });
|
|
};
|
|
td4.appendChild(cb);
|
|
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
|
t.appendChild(tr);
|
|
});
|
|
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
|
|
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
|
|
}
|
|
|
|
// ---- key backup
|
|
$("export-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var p = $("ex-pass").value;
|
|
if (p !== $("ex-pass2").value) { toast("The passphrases differ"); return; }
|
|
if (p.length < 12) { toast("At least 12 characters"); return; }
|
|
var btn = this.querySelector("input[type=submit]");
|
|
btn.disabled = true;
|
|
fetch("/admin/api/key/export", { method: "POST", credentials: "same-origin",
|
|
headers: { "X-IPCR-Admin": "1", "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: p }) })
|
|
.then(function (r) {
|
|
if (!r.ok) return r.text().then(function (t) { throw new Error(t); });
|
|
var name = (r.headers.get("Content-Disposition") || "").replace(/.*filename="([^"]+)".*/, "$1") || "publisher.ipcrkey.json";
|
|
return r.blob().then(function (b) {
|
|
var a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = name;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
$("ex-pass").value = $("ex-pass2").value = "";
|
|
toast("Saved " + name);
|
|
});
|
|
})
|
|
.catch(function (e) { toast(e.message, 8000); })
|
|
.then(function () { btn.disabled = false; });
|
|
};
|
|
$("import-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var f = $("im-file").files[0], btn = this.querySelector("input[type=submit]");
|
|
if (!f) return;
|
|
f.text().then(function (txt) {
|
|
var backup; try { backup = JSON.parse(txt); } catch (x) { throw new Error("Not a key backup file"); }
|
|
if (!confirm("Make " + backup.id + " this forge's publisher key?\n\nThe current key (" + (publisher || "none") + ") is kept, renamed. Pull lines with the current IPNS name stop following new releases.")) return;
|
|
var body = { backup: backup, passphrase: $("im-pass").value };
|
|
if ($("im-seq").value) body.sequence = parseInt($("im-seq").value, 10);
|
|
return busy(btn, api("POST", "ipcr/key/import", body)).then(function (r) {
|
|
toast(r.status || ("Publisher key is now " + r.id + (r.adopted ? " (network tree adopted)" : "")), 8000);
|
|
$("im-pass").value = ""; loadStatus(true); loadImages();
|
|
});
|
|
}).catch(function (e) { toast(e.message, 8000); });
|
|
};
|
|
|
|
loadStatus(false).then(loadPublic); loadImages(); loadRepos(); loadSettings();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|