papercliped 1.1.0-beta.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +70 -1
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/commands/paperclip-report.md +5 -5
- package/commands/paperclip-status.md +4 -4
- package/commands/paperclip-sync.md +5 -5
- package/dist/accounts/alias.js +1 -1
- package/dist/accounts/username.js +1 -1
- package/dist/cli.js +11 -11
- package/dist/http.js +17 -3
- package/dist/manage/routes.js +60 -82
- package/dist/oauth/pages.js +32 -18
- package/dist/oauth/paperclip-login.js +38 -0
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +83 -64
- package/dist/oauth/scopes.js +19 -16
- package/dist/openapi.js +19 -2
- package/dist/public-api/health.js +46 -0
- package/dist/public-api/openapi.js +51 -0
- package/dist/public-api/routes.js +251 -0
- package/dist/public-api/types.js +14 -0
- package/dist/server.js +4 -0
- package/dist/site/landing.js +3 -3
- package/dist/site/markdown.js +2 -1
- package/dist/site/site.js +16 -4
- package/dist/telemetry/recorder.js +1 -1
- package/dist/tools.js +48 -2
- package/dist/web/csp.js +16 -0
- package/dist/web/hosts.js +23 -0
- package/dist/web/routes.js +132 -0
- package/docs/ARCHITECTURE.md +74 -73
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -3
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +50 -34
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -65
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -20
- package/docs/least-privilege.sql +23 -23
- package/docs/legal/PRIVACY.md +37 -37
- package/docs/legal/TERMS.md +11 -11
- package/docs/oauth.md +61 -62
- package/docs/supabase-schema.sql +8 -1
- package/migrations/001_init.sql +84 -84
- package/migrations/002_audit.sql +41 -41
- package/migrations/003_accounts.sql +66 -66
- package/migrations/004_privacy_panel.sql +57 -57
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +10 -14
- package/site/docs/chatgpt.md +1 -1
- package/site/docs/cli.md +27 -0
- package/site/docs/connect-your-paperclip.md +120 -0
- package/site/docs/environment.md +64 -0
- package/site/docs/faq.md +31 -0
- package/site/docs/getting-started.md +11 -5
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +12 -21
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +36 -0
- package/site/docs/permissions.md +5 -5
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +2 -2
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +3 -3
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +2 -2
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +5 -3
- package/site/terms.md +1 -1
- package/skills/paperclip/SKILL.md +23 -23
- package/dist/demo.js +0 -144
- package/dist/manage/page.js +0 -170
- package/dist/panel/config.js +0 -61
- package/dist/panel/main.js +0 -90
- package/dist/panel/page.js +0 -553
- package/dist/panel/routes.js +0 -284
- package/dist/panel/server.js +0 -37
- package/docs/panel-role.sql +0 -26
package/dist/manage/page.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { THEME, esc } from "../oauth/pages.js";
|
|
2
|
-
import { FAVICON_LINK } from "../site/favicon.js";
|
|
3
|
-
const CSS = `
|
|
4
|
-
:root{--bg:${THEME.bg};--ink:${THEME.ink};--ink2:${THEME.ink2};--muted:${THEME.muted};--line:${THEME.line};--err:${THEME.errInk}}
|
|
5
|
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
6
|
-
a{color:var(--ink)}button,input,select{font:inherit;color:inherit}
|
|
7
|
-
:focus-visible{outline:2px solid var(--ink2);outline-offset:2px}
|
|
8
|
-
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px max(20px,5vw);border-bottom:1px solid var(--line)}
|
|
9
|
-
.brand{display:flex;align-items:center;gap:8px;font-weight:600;font-size:17px}.brand img{width:26px;height:26px}
|
|
10
|
-
main{max-width:760px;margin:0 auto;padding:28px 20px 60px}
|
|
11
|
-
h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:30px 0 8px}p.lede{color:var(--muted);margin:0 0 18px}
|
|
12
|
-
.card{border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:10px 0;background:rgba(255,255,255,.35)}
|
|
13
|
-
label.f{display:block;font-size:12px;color:var(--muted);margin:12px 0 4px}
|
|
14
|
-
input[type=text],input[type=password]{width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:transparent}
|
|
15
|
-
.btn{height:38px;padding:0 16px;border:1px solid var(--ink2);border-radius:8px;background:var(--ink2);color:var(--bg);font-weight:600;cursor:pointer}
|
|
16
|
-
.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}.btn.danger{background:#8a1f11;border-color:#8a1f11;color:#fff}.btn:disabled{opacity:.5;cursor:not-allowed}
|
|
17
|
-
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.grow{flex:1;min-width:0}
|
|
18
|
-
.pill{display:inline-block;font-size:12px;padding:2px 9px;border:1px solid var(--line);border-radius:99px;color:var(--ink2)}
|
|
19
|
-
.muted{color:var(--muted);font-size:13.5px}
|
|
20
|
-
.err{background:#fde8e3;border:1px solid #e9a99c;color:var(--err);border-radius:8px;padding:9px 12px;margin:12px 0}
|
|
21
|
-
.ok{background:#e9f3df;border:1px solid #b9d49b;color:#2f5a14;border-radius:8px;padding:9px 12px;margin:12px 0}
|
|
22
|
-
.secret{display:block;margin:12px 0;padding:14px 12px;border:1px dashed var(--ink2);border-radius:8px;font:13px ui-monospace,Menlo,monospace;user-select:all;overflow-wrap:anywhere}
|
|
23
|
-
select{height:34px;border:1px solid var(--line);border-radius:8px;background:transparent;padding:0 8px}
|
|
24
|
-
.hide{display:none}
|
|
25
|
-
`;
|
|
26
|
-
const JS = `
|
|
27
|
-
(function () {
|
|
28
|
-
var $ = function (id) { return document.getElementById(id); };
|
|
29
|
-
var app = $("app");
|
|
30
|
-
function h(tag, attrs) {
|
|
31
|
-
var el = document.createElement(tag);
|
|
32
|
-
for (var k in (attrs || {})) {
|
|
33
|
-
if (k === "class") el.className = attrs[k];
|
|
34
|
-
else if (k.slice(0, 2) === "on") el.addEventListener(k.slice(2), attrs[k]);
|
|
35
|
-
else if (attrs[k] !== false && attrs[k] != null) el.setAttribute(k, attrs[k]);
|
|
36
|
-
}
|
|
37
|
-
for (var i = 2; i < arguments.length; i++) {
|
|
38
|
-
var c = arguments[i];
|
|
39
|
-
if (c == null || c === false) continue;
|
|
40
|
-
el.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
41
|
-
}
|
|
42
|
-
return el;
|
|
43
|
-
}
|
|
44
|
-
function api(method, path, body) {
|
|
45
|
-
var opt = { method: method, headers: { "x-papercliped": "1" }, credentials: "same-origin" };
|
|
46
|
-
if (body !== undefined) { opt.headers["content-type"] = "application/json"; opt.body = JSON.stringify(body); }
|
|
47
|
-
return fetch("/api/manage" + path, opt).then(function (r) {
|
|
48
|
-
return r.json().catch(function () { return {}; }).then(function (j) { return { status: r.status, body: j }; });
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
function note(kind, text) { return h("div", { class: kind, role: kind === "err" ? "alert" : "status" }, text); }
|
|
52
|
-
function when(ms) { if (!ms) return "never"; var d = Date.now() - ms, m = Math.round(d / 60000); if (m < 1) return "just now"; if (m < 60) return m + " min ago"; if (m < 1440) return Math.round(m / 60) + " h ago"; return Math.round(m / 1440) + " days ago"; }
|
|
53
|
-
var LEVELS = { "paperclip:read": "Read only", "paperclip:control": "Full control (beta)", "paperclip:admin": "Admin" };
|
|
54
|
-
|
|
55
|
-
function loginView(msg) {
|
|
56
|
-
app.replaceChildren();
|
|
57
|
-
var u = h("input", { type: "text", id: "u", autocomplete: "username", autocapitalize: "off", spellcheck: "false" });
|
|
58
|
-
var s = h("input", { type: "password", id: "s", autocomplete: "current-password", placeholder: "pcs_XXXX-XXXX-..." });
|
|
59
|
-
var box = h("div");
|
|
60
|
-
var go = function (e) {
|
|
61
|
-
e.preventDefault();
|
|
62
|
-
api("POST", "/login", { username: u.value, secret: s.value }).then(function (r) {
|
|
63
|
-
if (r.status === 200) return load();
|
|
64
|
-
box.replaceChildren(note("err", r.body.error || "Could not sign in"));
|
|
65
|
-
});
|
|
66
|
-
};
|
|
67
|
-
app.appendChild(h("h1", {}, "Manage your connections"));
|
|
68
|
-
app.appendChild(h("p", { class: "lede" }, "Sign in with your Papercliped username and secret key."));
|
|
69
|
-
app.appendChild(h("form", { onsubmit: go },
|
|
70
|
-
msg ? note("ok", msg) : null, box,
|
|
71
|
-
h("label", { class: "f", for: "u" }, "Username"), u,
|
|
72
|
-
h("label", { class: "f", for: "s" }, "Secret key"), s,
|
|
73
|
-
h("div", { class: "row", style: "margin-top:16px" }, h("button", { class: "btn", type: "submit" }, "Sign in"))));
|
|
74
|
-
app.appendChild(h("p", { class: "muted", style: "margin-top:18px" }, "Lost your secret key? Connect from your AI app again and choose Connect your Paperclip: you keep your account and get a new key."));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function confirmBox(title, text, withUsername, danger, run) {
|
|
78
|
-
var s = h("input", { type: "password", autocomplete: "current-password", placeholder: "Your secret key" });
|
|
79
|
-
var c = withUsername ? h("input", { type: "text", autocomplete: "off", placeholder: "Type your username" }) : null;
|
|
80
|
-
var out = h("div");
|
|
81
|
-
var box = h("div", { class: "card" }, h("strong", {}, title), h("p", { class: "muted" }, text), out,
|
|
82
|
-
h("label", { class: "f" }, "Confirm with your secret key"), s, c ? h("label", { class: "f" }, "Username") : null, c,
|
|
83
|
-
h("div", { class: "row", style: "margin-top:12px" },
|
|
84
|
-
h("button", { class: "btn" + (danger ? " danger" : ""), type: "button", onclick: function () { run(s.value, c ? c.value : undefined, out, box); } }, "Confirm"),
|
|
85
|
-
h("button", { class: "btn ghost", type: "button", onclick: function () { box.remove(); } }, "Cancel")));
|
|
86
|
-
return box;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function appView(me) {
|
|
90
|
-
app.replaceChildren();
|
|
91
|
-
app.appendChild(h("h1", {}, "Hi, " + me.name));
|
|
92
|
-
app.appendChild(h("p", { class: "lede" }, (me.paperclip ? "Connected to " + me.paperclip : "No Paperclip connected") + (me.connected ? "" : " (connection expired)") + (me.anonymous ? " · appearing anonymously" : "")));
|
|
93
|
-
|
|
94
|
-
if (!me.beta) {
|
|
95
|
-
app.appendChild(h("div", { class: "card" }, h("strong", {}, "Join the beta"),
|
|
96
|
-
h("p", { class: "muted" }, "The beta adds this connection manager: see every app connected to your Paperclip, change what each may do, and disconnect any of them."),
|
|
97
|
-
h("button", { class: "btn", type: "button", onclick: function () { api("POST", "/beta", { beta: true }).then(load); } }, "Join the beta")));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
app.appendChild(h("h2", {}, "Connected apps"));
|
|
102
|
-
var list = h("div"); app.appendChild(list);
|
|
103
|
-
function refresh() {
|
|
104
|
-
api("GET", "/connections").then(function (r) {
|
|
105
|
-
list.replaceChildren();
|
|
106
|
-
var cs = (r.body && r.body.connections) || [];
|
|
107
|
-
if (!cs.length) list.appendChild(h("p", { class: "muted" }, "No apps connected. Add Papercliped as a connector in Claude or ChatGPT."));
|
|
108
|
-
cs.forEach(function (c) {
|
|
109
|
-
var sel = h("select", { "aria-label": "Access level for " + c.app });
|
|
110
|
-
[["read", "Read only"], ["control", "Full control (beta)"]].forEach(function (o) { sel.appendChild(h("option", { value: o[0], selected: LEVELS["paperclip:" + o[0]] === LEVELS[c.level] ? "selected" : false }, o[1])); });
|
|
111
|
-
if (c.level === "paperclip:admin") sel.appendChild(h("option", { value: "admin", selected: "selected", disabled: "disabled" }, "Admin"));
|
|
112
|
-
var msg = h("span", { class: "muted", role: "status" });
|
|
113
|
-
sel.addEventListener("change", function () {
|
|
114
|
-
api("POST", "/connections/" + encodeURIComponent(c.id), { level: sel.value }).then(function (x) { msg.textContent = x.status === 200 ? "Saved." : (x.body.error || "Could not save"); });
|
|
115
|
-
});
|
|
116
|
-
list.appendChild(h("div", { class: "card row" },
|
|
117
|
-
h("div", { class: "grow" }, h("strong", {}, c.app), h("div", { class: "muted" }, "Last used " + when(c.lastUsedAt) + " · connected " + when(c.createdAt))),
|
|
118
|
-
sel, msg,
|
|
119
|
-
h("button", { class: "btn ghost", type: "button", onclick: function () { api("DELETE", "/connections/" + encodeURIComponent(c.id)).then(refresh); } }, "Disconnect")));
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
refresh();
|
|
124
|
-
app.appendChild(h("p", { class: "muted" }, "Changes apply on the app's very next request. Raising or lowering access never needs the app to reconnect."));
|
|
125
|
-
|
|
126
|
-
app.appendChild(h("h2", {}, "Privacy"));
|
|
127
|
-
var anon = h("input", { type: "checkbox", id: "anon" }); anon.checked = me.anonymous;
|
|
128
|
-
var pmsg = h("span", { class: "muted", role: "status" });
|
|
129
|
-
anon.addEventListener("change", function () {
|
|
130
|
-
api("POST", "/privacy", { anonymous: anon.checked }).then(function (r) { pmsg.textContent = r.status === 200 ? (r.body.anonymous ? "You now appear as " + r.body.alias + "." : "You now appear under your username.") : (r.body.error || "Could not save"); });
|
|
131
|
-
});
|
|
132
|
-
app.appendChild(h("label", { class: "card row", for: "anon" }, anon, h("span", { class: "grow" }, "Appear anonymously in the operator logs and panel (as a name like Ann02)."), pmsg));
|
|
133
|
-
|
|
134
|
-
app.appendChild(h("h2", {}, "Security"));
|
|
135
|
-
var area = h("div");
|
|
136
|
-
var rot = h("button", { class: "btn ghost", type: "button", onclick: function () {
|
|
137
|
-
area.replaceChildren(confirmBox("Make a new secret key", "Your old key stops working immediately. The new key is shown once.", false, false, function (secret, _c, out, box) {
|
|
138
|
-
api("POST", "/secret/rotate", { secret: secret }).then(function (r) {
|
|
139
|
-
out.replaceChildren();
|
|
140
|
-
if (r.status !== 200) return out.appendChild(note("err", r.body.error || "Failed"));
|
|
141
|
-
box.replaceChildren(h("strong", {}, "Your new secret key"), h("span", { class: "secret" }, r.body.secret), h("p", { class: "muted" }, "Save it in a password manager now. It will not be shown again."));
|
|
142
|
-
});
|
|
143
|
-
}));
|
|
144
|
-
} }, "Make a new secret key");
|
|
145
|
-
var dis = h("button", { class: "btn ghost", type: "button", onclick: function () {
|
|
146
|
-
area.replaceChildren(confirmBox("Disconnect your Paperclip", "Papercliped forgets your Paperclip key and asks your Paperclip to revoke it. Every connected app stops working. Your account stays; reconnect any time.", false, true, function (secret, _c, out) {
|
|
147
|
-
api("POST", "/paperclip/disconnect", { secret: secret }).then(function (r) { if (r.status !== 200) return out.replaceChildren(note("err", r.body.error || "Failed")); load(); });
|
|
148
|
-
}));
|
|
149
|
-
} }, "Disconnect my Paperclip");
|
|
150
|
-
var del = h("button", { class: "btn danger", type: "button", onclick: function () {
|
|
151
|
-
area.replaceChildren(confirmBox("Delete my account", "Removes your username, stored Paperclip key and every connection. This cannot be undone.", true, true, function (secret, name, out) {
|
|
152
|
-
api("POST", "/account/delete", { secret: secret, confirm: name }).then(function (r) { if (r.status !== 200) return out.replaceChildren(note("err", r.body.error || "Failed")); loginView("Your account was deleted."); });
|
|
153
|
-
}));
|
|
154
|
-
} }, "Delete my account");
|
|
155
|
-
app.appendChild(h("div", { class: "row" }, rot, dis, del));
|
|
156
|
-
app.appendChild(area);
|
|
157
|
-
app.appendChild(h("div", { style: "margin-top:26px" }, h("button", { class: "btn ghost", type: "button", onclick: function () { api("POST", "/logout", {}).then(function () { loginView(); }); } }, "Sign out")));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function load() {
|
|
161
|
-
api("GET", "/me").then(function (r) { if (r.status === 200) appView(r.body); else loginView(); });
|
|
162
|
-
}
|
|
163
|
-
load();
|
|
164
|
-
})();
|
|
165
|
-
`;
|
|
166
|
-
export function managePage(nonce) {
|
|
167
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><meta name="robots" content="noindex"><title>Manage connections · Papercliped</title>${FAVICON_LINK}<style>${CSS}</style></head><body><header><a class="brand" href="/" style="text-decoration:none;color:inherit"><img alt="" src="data:image/svg+xml,${encodeURIComponent(FAV)}"><span>Papercliped</span></a><span class="pill">beta</span></header><main id="app" aria-live="polite"><noscript>${esc("This page needs JavaScript.")}</noscript></main><script nonce="${nonce}">${JS}</script></body></html>`;
|
|
168
|
-
}
|
|
169
|
-
import { FAVICON_SVG as FAV } from "../site/favicon.js";
|
|
170
|
-
export { JS as MANAGE_SCRIPT };
|
package/dist/panel/config.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { BlockList, isIP } from "node:net";
|
|
2
|
-
const nonEmpty = (v) => (v && v.trim() ? v.trim() : null);
|
|
3
|
-
const posInt = (v, d) => (Number(v) > 0 ? Math.floor(Number(v)) : d);
|
|
4
|
-
const truthy = (v) => /^(1|true|yes|on)$/i.test(v?.trim() ?? "");
|
|
5
|
-
export function readPanelConfig(env = process.env) {
|
|
6
|
-
const token = nonEmpty(env.PANEL_ADMIN_TOKEN);
|
|
7
|
-
if (!token || token.length < 24)
|
|
8
|
-
throw new Error("PANEL_ADMIN_TOKEN is required and must be at least 24 characters (`openssl rand -hex 32`)");
|
|
9
|
-
const demo = truthy(env.PANEL_DEMO);
|
|
10
|
-
const url = nonEmpty(env.PANEL_DATABASE_URL);
|
|
11
|
-
if (!demo && !url)
|
|
12
|
-
throw new Error("PANEL_DATABASE_URL is required (the read-only panel_ro role; see docs/panel-role.sql). Set PANEL_DEMO=1 to preview with fake data.");
|
|
13
|
-
for (const leaked of ["BRIDGE_SECRET", "BRIDGE_SECRET_PREVIOUS", "DATABASE_URL", "DATABASE_MIGRATE_URL"]) {
|
|
14
|
-
if (nonEmpty(env[leaked]))
|
|
15
|
-
throw new Error(`${leaked} is set in the panel's environment. The panel is a separate program: give it ONLY its own PANEL_* variables, so a compromise of the panel can never expose the bridge's secrets or write access.`);
|
|
16
|
-
}
|
|
17
|
-
const ssl = (nonEmpty(env.PANEL_DATABASE_SSL) ?? "verify");
|
|
18
|
-
if (!["off", "require", "verify"].includes(ssl))
|
|
19
|
-
throw new Error("PANEL_DATABASE_SSL must be off, require or verify");
|
|
20
|
-
const allow = (env.PANEL_ALLOWED_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
21
|
-
for (const a of allow)
|
|
22
|
-
if (!ipMatcher([a]))
|
|
23
|
-
throw new Error(`PANEL_ALLOWED_IPS contains an invalid entry: ${a}`);
|
|
24
|
-
const secure = nonEmpty(env.PANEL_PUBLIC_URL)?.replace(/\/+$/, "") ?? null;
|
|
25
|
-
return {
|
|
26
|
-
host: nonEmpty(env.PANEL_HOST) ?? (nonEmpty(env.PORT) ? "0.0.0.0" : "127.0.0.1"),
|
|
27
|
-
port: posInt(env.PANEL_PORT ?? env.PORT, 3940),
|
|
28
|
-
token,
|
|
29
|
-
sessionHours: posInt(env.PANEL_SESSION_HOURS, 8),
|
|
30
|
-
publicUrl: secure,
|
|
31
|
-
slowMs: posInt(env.PANEL_SLOW_MS, 1500),
|
|
32
|
-
communityName: (nonEmpty(env.PANEL_COMMUNITY_NAME) ?? "cliped").slice(0, 40),
|
|
33
|
-
proxyHops: env.PANEL_PROXY_HOPS !== undefined ? Math.max(0, Math.floor(Number(env.PANEL_PROXY_HOPS)) || 0) : 0,
|
|
34
|
-
allowedIps: allow.length ? allow : null,
|
|
35
|
-
database: url ? { connectionString: url, ssl: ssl, ca: nonEmpty(env.PANEL_DATABASE_CA)?.replace(/\\n/g, "\n") ?? undefined, max: 4 } : null,
|
|
36
|
-
demo,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
/** Matcher for a list of IPs / IPv4 CIDRs. Returns null if any entry is invalid. */
|
|
40
|
-
export function ipMatcher(entries) {
|
|
41
|
-
const list = new BlockList();
|
|
42
|
-
for (const e of entries) {
|
|
43
|
-
const [addr, bits] = e.split("/");
|
|
44
|
-
const fam = isIP(addr);
|
|
45
|
-
if (!fam)
|
|
46
|
-
return null;
|
|
47
|
-
if (bits === undefined)
|
|
48
|
-
list.addAddress(addr, fam === 4 ? "ipv4" : "ipv6");
|
|
49
|
-
else {
|
|
50
|
-
const n = Number(bits);
|
|
51
|
-
if (!Number.isInteger(n) || n < 0 || n > (fam === 4 ? 32 : 128))
|
|
52
|
-
return null;
|
|
53
|
-
list.addSubnet(addr, n, fam === 4 ? "ipv4" : "ipv6");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return (ip) => {
|
|
57
|
-
const clean = ip.replace(/^::ffff:/i, "");
|
|
58
|
-
const fam = isIP(clean);
|
|
59
|
-
return fam ? list.check(clean, fam === 4 ? "ipv4" : "ipv6") : false;
|
|
60
|
-
};
|
|
61
|
-
}
|
package/dist/panel/main.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* The operator panel — a SEPARATE program from the bridge. It holds no bridge secrets and connects to the database with the
|
|
4
|
-
* read-only `panel_ro` role (docs/panel-role.sql), so even a fully compromised panel can read only the safe views.
|
|
5
|
-
*/
|
|
6
|
-
import { performance } from "node:perf_hooks";
|
|
7
|
-
import { readPanelConfig } from "./config.js";
|
|
8
|
-
import { PanelRoutes } from "./routes.js";
|
|
9
|
-
import { createPanelServer } from "./server.js";
|
|
10
|
-
import { PgPanelStore } from "../oauth/pg-store.js";
|
|
11
|
-
async function main() {
|
|
12
|
-
let config;
|
|
13
|
-
try {
|
|
14
|
-
config = readPanelConfig();
|
|
15
|
-
}
|
|
16
|
-
catch (e) {
|
|
17
|
-
console.error(`Configuration error: ${e.message}`);
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
if (config.demo) {
|
|
21
|
-
const { runDemo } = await import("../demo.js");
|
|
22
|
-
return void (await runDemo(config));
|
|
23
|
-
}
|
|
24
|
-
const store = new PgPanelStore(config.database);
|
|
25
|
-
try {
|
|
26
|
-
await store.ping();
|
|
27
|
-
}
|
|
28
|
-
catch (e) {
|
|
29
|
-
console.error(`Cannot reach the database: ${e.message}`);
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
await startPanel(config, store);
|
|
33
|
-
}
|
|
34
|
-
/** Shared by the real panel and the demo. */
|
|
35
|
-
export async function startPanel(config, store, mode = "postgres (read-only)") {
|
|
36
|
-
let pingMs = null;
|
|
37
|
-
const sample = async () => {
|
|
38
|
-
const t = performance.now();
|
|
39
|
-
try {
|
|
40
|
-
await store.ping();
|
|
41
|
-
pingMs = Math.round((performance.now() - t) * 10) / 10;
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
pingMs = null;
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
void sample();
|
|
48
|
-
const timer = setInterval(() => void sample(), 10_000);
|
|
49
|
-
timer.unref();
|
|
50
|
-
const hits = new Map();
|
|
51
|
-
const limit = (key, n, windowMs) => {
|
|
52
|
-
const now = Date.now();
|
|
53
|
-
const list = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
54
|
-
if (list.length >= n)
|
|
55
|
-
return false;
|
|
56
|
-
list.push(now);
|
|
57
|
-
hits.set(key, list);
|
|
58
|
-
if (hits.size > 5000)
|
|
59
|
-
for (const [k, v] of hits)
|
|
60
|
-
if (!v.some((t) => now - t < windowMs))
|
|
61
|
-
hits.delete(k);
|
|
62
|
-
return true;
|
|
63
|
-
};
|
|
64
|
-
const routes = new PanelRoutes({
|
|
65
|
-
token: config.token,
|
|
66
|
-
sessionHours: config.sessionHours,
|
|
67
|
-
secureCookie: !!config.publicUrl?.startsWith("https:"),
|
|
68
|
-
store,
|
|
69
|
-
slowMs: config.slowMs,
|
|
70
|
-
communityName: config.communityName,
|
|
71
|
-
mode,
|
|
72
|
-
persistent: !config.demo,
|
|
73
|
-
proxyHops: config.proxyHops,
|
|
74
|
-
limit,
|
|
75
|
-
panelPingMs: () => pingMs,
|
|
76
|
-
});
|
|
77
|
-
const server = createPanelServer({ routes, allowedIps: config.allowedIps, proxyHops: config.proxyHops });
|
|
78
|
-
server.listen(config.port, config.host, () => console.error(`papercliped panel listening on http://${config.host}:${config.port}${config.allowedIps ? " [ip allow-list]" : ""}`));
|
|
79
|
-
const shutdown = (sig) => {
|
|
80
|
-
console.error(`${sig}: shutting down`);
|
|
81
|
-
clearInterval(timer);
|
|
82
|
-
server.close(() => void store.close().finally(() => process.exit(0)));
|
|
83
|
-
setTimeout(() => process.exit(0), 5_000).unref();
|
|
84
|
-
};
|
|
85
|
-
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
86
|
-
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
87
|
-
return server;
|
|
88
|
-
}
|
|
89
|
-
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("panel/main.js"))
|
|
90
|
-
void main();
|