@marver-design/marver 0.11.0 → 0.12.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/CHANGELOG.md +83 -5
- package/README.md +17 -4
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-BmaOjwP1.mjs +781 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
- package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
- package/dist/events-C9k-ceOj.mjs +101 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
- package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
- package/dist/share-C64b1tdN.mjs +159 -0
- package/dist/summary-Bf9tiGrE.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +43 -8
- package/src/client/shell/Comments.tsx +9 -5
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +39 -9
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +54 -3
- package/src/client/shell/styles.css +27 -0
- package/src/shared/events.ts +8 -3
- package/templates/instructions/publish.md +15 -1
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-C-n-uQdm.mjs +0 -364
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- package/dist/utm-CxC3QN5X.mjs +0 -20
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
2
|
+
import { A as reconfirmGrant, C as commentAllowed, D as operativeMode, E as loadShare, F as setBlocked, I as setGeneralMode, L as shareState, M as resolveAccess, N as resolveRequest, O as provisionVerdict, P as rosterEtag, S as ceilingsFromRights, T as loadRequests, _ as sessionUser, a as claimInvite, b as updateProfile, c as issueDeviceSession, d as opaqueId, f as ownerName, g as sessionIsOperator, h as revokeUser, j as removeGrant, k as putRequest, l as loadStore, m as publicUser, o as createInvite, s as inviteInfo, u as normEmail, v as signIn, w as entryAllowed, y as signOut, z as upsertGrant } from "./auth-DdUKeVKt.mjs";
|
|
3
|
+
import { canvasIdentity, deriveThreads, loadSeen, markSeen, signCanvasJws, unreadCount, verifyCanvasJws } from "./summary-Bf9tiGrE.mjs";
|
|
4
|
+
import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
//#region src/server/notify.ts
|
|
9
|
+
/**
|
|
10
|
+
* The canvas's half of the notification relay (04-solution §2.3, §9.5).
|
|
11
|
+
*
|
|
12
|
+
* Three transactional moments, exactly: you were invited (a view/comment grant
|
|
13
|
+
* landed on an address), your request was approved, someone asked for access
|
|
14
|
+
* (to the owner - and that mail carries only "someone"). Delivery is the ID
|
|
15
|
+
* service's relay; the canvas never talks to Resend. The action token is
|
|
16
|
+
* signed by the canvas identity key - owner authorization is implicit in the
|
|
17
|
+
* signature, since only the canvas server holds it.
|
|
18
|
+
*
|
|
19
|
+
* Fire-and-forget, always: a notification that failed must never be the reason
|
|
20
|
+
* a grant did not land. Idempotency lives at the relay, keyed on
|
|
21
|
+
* (origin, template, eventId) - the eventId is derived from the STATE
|
|
22
|
+
* TRANSITION, so a re-invite at the same role sends nothing and a role change
|
|
23
|
+
* sends one.
|
|
24
|
+
*/
|
|
25
|
+
const transitionId = (...parts) => createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
|
|
26
|
+
function relayNotify(ctx, template, recipient, eventId) {
|
|
27
|
+
if (!ctx.enabled || !ctx.issuer || !ctx.origin) return;
|
|
28
|
+
if (recipient.startsWith("@") || !recipient.includes("@")) return;
|
|
29
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
30
|
+
let token;
|
|
31
|
+
try {
|
|
32
|
+
token = signCanvasJws(ctx.dataDir, {
|
|
33
|
+
origin: ctx.origin,
|
|
34
|
+
template,
|
|
35
|
+
recipient,
|
|
36
|
+
eventId,
|
|
37
|
+
iat: now,
|
|
38
|
+
exp: now + 600
|
|
39
|
+
}, "marver-relay+jwt");
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
fetch(`${ctx.issuer.replace(/\/+$/, "")}/relay/notify`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { authorization: `Bearer ${token}` },
|
|
46
|
+
signal: AbortSignal.timeout(5e3)
|
|
47
|
+
}).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/server/collab.ts
|
|
51
|
+
const MONTH = 2592e3;
|
|
52
|
+
const MAX_BODY = 262144;
|
|
53
|
+
/** The permission seam, per-person since the sharing release: `read` stays
|
|
54
|
+
* board-level (is it published - no read-privacy claim in v1), `admin` stays
|
|
55
|
+
* the owner role, and `comment` (which resolve/reopen ride, 01-sharing §7.4)
|
|
56
|
+
* asks the resolver - blocklist, grants, expiry, ceiling - freshly on every
|
|
57
|
+
* call, so a revocation or an expiry crossing bites on the next request.
|
|
58
|
+
* `dataDir` is what connects the seam to the roster; without a roster the
|
|
59
|
+
* legacy rule (any signed-in user on a comment board) is exactly what runs. */
|
|
60
|
+
function can(user, rights, board, action, dataDir) {
|
|
61
|
+
if (action === "read") return board in rights;
|
|
62
|
+
if (action === "admin") return user?.role === "owner";
|
|
63
|
+
if (!user || rights[board] !== "comment") return false;
|
|
64
|
+
if (!dataDir) return true;
|
|
65
|
+
return commentAllowed(dataDir, user, board, ceilingsFromRights(rights));
|
|
66
|
+
}
|
|
67
|
+
const EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
68
|
+
"create",
|
|
69
|
+
"reply",
|
|
70
|
+
"edit",
|
|
71
|
+
"resolve",
|
|
72
|
+
"reopen",
|
|
73
|
+
"react",
|
|
74
|
+
"profile",
|
|
75
|
+
"reanchor"
|
|
76
|
+
]);
|
|
77
|
+
const ID_RE = /^[\w-]{8,64}$/;
|
|
78
|
+
const MAX_BODY_TEXT = 1e4;
|
|
79
|
+
/** An avatar the server will store: a raster data-URI whose ACTUAL decoded bytes
|
|
80
|
+
* match the declared type. The declared MIME alone is a lie an attacker controls
|
|
81
|
+
* (base64 SVG labelled image/png); the magic bytes are the truth. Capped small -
|
|
82
|
+
* the client downscales to a 128px jpeg (~15KB), 64KB is generous headroom. */
|
|
83
|
+
function validAvatar(s) {
|
|
84
|
+
if (typeof s !== "string" || s.length > 65536) return false;
|
|
85
|
+
const m = /^data:image\/(jpeg|png|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(s);
|
|
86
|
+
if (!m) return false;
|
|
87
|
+
let head;
|
|
88
|
+
try {
|
|
89
|
+
head = Buffer.from(m[2].slice(0, 24), "base64");
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
jpeg: (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
95
|
+
png: (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
|
|
96
|
+
gif: (b) => b.toString("latin1", 0, 4) === "GIF8",
|
|
97
|
+
webp: (b) => b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP"
|
|
98
|
+
}[m[1]]?.(head) ?? false;
|
|
99
|
+
}
|
|
100
|
+
/** Reject anything the log must not absorb. Returns an error string or null.
|
|
101
|
+
* Events are append-only and synced by id, so acceptance is forever - validate hard. */
|
|
102
|
+
function validateEvents(incoming, log, u, board) {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const creates = /* @__PURE__ */ new Map();
|
|
105
|
+
const authors = /* @__PURE__ */ new Map();
|
|
106
|
+
for (const ev of log) {
|
|
107
|
+
if (ev.type === "create" && ev.commentId && !creates.has(ev.commentId)) creates.set(ev.commentId, ev);
|
|
108
|
+
if ((ev.type === "create" || ev.type === "reply") && ev.commentId && ev.author?.email && !authors.has(ev.commentId)) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
const me = u.email.toLowerCase();
|
|
111
|
+
for (const ev of incoming) {
|
|
112
|
+
if (!ev || typeof ev !== "object") return "malformed event";
|
|
113
|
+
if (typeof ev.id !== "string" || !ID_RE.test(ev.id)) return "event id must be an 8-64 char token";
|
|
114
|
+
if (!EVENT_TYPES.has(ev.type)) return `unknown event type "${ev.type}"`;
|
|
115
|
+
if (ev.agent !== void 0 || ev.agentMeta !== void 0) return "agent provenance is daemon-only";
|
|
116
|
+
if (ev.author && ev.author.id !== void 0) return "author id is server-assigned";
|
|
117
|
+
if (typeof ev.ts !== "number" || ev.ts < 15778368e5 || ev.ts > now + 6e4) return "event timestamp out of bounds";
|
|
118
|
+
if (ev.board !== void 0 && ev.board !== board) return "event board does not match the endpoint";
|
|
119
|
+
if (ev.body !== void 0 && (typeof ev.body !== "string" || ev.body.length > MAX_BODY_TEXT)) return "body too long";
|
|
120
|
+
if ((ev.type === "create" || ev.type === "reply" || ev.type === "react" || ev.type === "edit" || ev.type === "reanchor") && ev.author?.email?.toLowerCase() !== me) return "event author must be the signed-in account";
|
|
121
|
+
if (typeof ev.commentId !== "string" || !ID_RE.test(ev.commentId)) {
|
|
122
|
+
if (ev.type !== "profile") return "event needs a commentId";
|
|
123
|
+
}
|
|
124
|
+
switch (ev.type) {
|
|
125
|
+
case "create":
|
|
126
|
+
if (creates.has(ev.commentId)) return "a thread with that id already exists";
|
|
127
|
+
creates.set(ev.commentId, ev);
|
|
128
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
129
|
+
break;
|
|
130
|
+
case "reply":
|
|
131
|
+
if (typeof ev.parentId !== "string" || !creates.has(ev.parentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.parentId)) return "reply parent does not exist";
|
|
132
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
133
|
+
break;
|
|
134
|
+
case "edit": {
|
|
135
|
+
const owner = authors.get(ev.commentId);
|
|
136
|
+
if (!owner) return "cannot edit a comment that does not exist";
|
|
137
|
+
if (owner !== me) return "only the author can edit a comment";
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "resolve":
|
|
141
|
+
case "reopen":
|
|
142
|
+
if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot resolve a thread that does not exist";
|
|
143
|
+
break;
|
|
144
|
+
case "reanchor": {
|
|
145
|
+
if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot reanchor a thread that does not exist";
|
|
146
|
+
if (ev.anchor == null) return "reanchor needs a new anchor";
|
|
147
|
+
const owner = authors.get(ev.commentId);
|
|
148
|
+
if (owner && owner !== me) return "only the thread author can reanchor";
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function collabHandler(dataDir, distDir) {
|
|
156
|
+
let rights = {};
|
|
157
|
+
let meta = {};
|
|
158
|
+
try {
|
|
159
|
+
meta = JSON.parse(readFileSync(join(distDir, "meta.json"), "utf8"));
|
|
160
|
+
rights = meta.rights ?? {};
|
|
161
|
+
} catch {}
|
|
162
|
+
const commentsDir = join(dataDir, "comments");
|
|
163
|
+
const idIssuer = (process.env.MARVER_ID_ISSUER ?? "").trim() || null;
|
|
164
|
+
const publicOrigin = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim() || null;
|
|
165
|
+
const appOrigin = (process.env.MARVER_APP_ORIGIN ?? "").trim() || "https://app.marver.design";
|
|
166
|
+
const frontDoorOn = !!idIssuer && !!publicOrigin && meta.frontDoor !== false;
|
|
167
|
+
const notifyCtx = {
|
|
168
|
+
dataDir,
|
|
169
|
+
issuer: idIssuer,
|
|
170
|
+
origin: publicOrigin,
|
|
171
|
+
enabled: meta.notify !== false
|
|
172
|
+
};
|
|
173
|
+
const threads = /* @__PURE__ */ new Map();
|
|
174
|
+
for (const b of Object.keys(rights)) threads.set(b, deriveThreads(readLog(commentsDir, b)));
|
|
175
|
+
const summaryJtis = /* @__PURE__ */ new Map();
|
|
176
|
+
const boardType = (b) => meta.boards?.[b]?.type ?? "mix";
|
|
177
|
+
const cors = (res) => {
|
|
178
|
+
res.setHeader("access-control-allow-origin", appOrigin);
|
|
179
|
+
res.setHeader("vary", "origin");
|
|
180
|
+
res.setHeader("access-control-allow-methods", "GET, OPTIONS");
|
|
181
|
+
res.setHeader("access-control-allow-headers", "authorization");
|
|
182
|
+
res.setHeader("access-control-max-age", "600");
|
|
183
|
+
};
|
|
184
|
+
const bootEpoch = randomBytes(4).toString("hex");
|
|
185
|
+
let seq = 0;
|
|
186
|
+
const ring = [];
|
|
187
|
+
const clients = /* @__PURE__ */ new Map();
|
|
188
|
+
const broadcast = (board, events) => {
|
|
189
|
+
for (const ev of events) {
|
|
190
|
+
const frame = `id: ${bootEpoch}-${++seq}\nevent: comment\ndata: ${JSON.stringify({
|
|
191
|
+
board,
|
|
192
|
+
ev
|
|
193
|
+
})}\n\n`;
|
|
194
|
+
ring.push({
|
|
195
|
+
seq,
|
|
196
|
+
data: frame
|
|
197
|
+
});
|
|
198
|
+
if (ring.length > 500) ring.shift();
|
|
199
|
+
for (const res of clients.keys()) res.write(frame);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
setInterval(() => {
|
|
203
|
+
for (const res of clients.keys()) res.write(": keepalive\n\n");
|
|
204
|
+
}, 24e4).unref();
|
|
205
|
+
const ceilings = ceilingsFromRights(rights);
|
|
206
|
+
const streamAllowed = (tok) => {
|
|
207
|
+
const store = shareState(dataDir);
|
|
208
|
+
if (!store) return true;
|
|
209
|
+
if (!tok) return store.general.mode !== "private";
|
|
210
|
+
const u = sessionUser(dataDir, tok);
|
|
211
|
+
return !!u && entryAllowed(dataDir, u, ceilings);
|
|
212
|
+
};
|
|
213
|
+
setInterval(() => {
|
|
214
|
+
for (const [res, c] of clients) {
|
|
215
|
+
if (streamAllowed(c.tok)) continue;
|
|
216
|
+
clients.delete(res);
|
|
217
|
+
try {
|
|
218
|
+
res.end();
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
}, 6e4).unref();
|
|
222
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
223
|
+
const limited = (...keys) => {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
if (attempts.size > 1e4) {
|
|
226
|
+
for (const [k, a] of attempts) if (a.reset < now) attempts.delete(k);
|
|
227
|
+
}
|
|
228
|
+
if (attempts.size > 2e4) return true;
|
|
229
|
+
let hit = false;
|
|
230
|
+
for (const key of keys) {
|
|
231
|
+
const a = attempts.get(key);
|
|
232
|
+
if (!a || a.reset < now) attempts.set(key, {
|
|
233
|
+
n: 1,
|
|
234
|
+
reset: now + 6e4
|
|
235
|
+
});
|
|
236
|
+
else if (++a.n > 10) hit = true;
|
|
237
|
+
}
|
|
238
|
+
return hit;
|
|
239
|
+
};
|
|
240
|
+
const project = (ev) => {
|
|
241
|
+
if (!ev.author?.email) return ev;
|
|
242
|
+
const { email, ...rest } = ev.author;
|
|
243
|
+
return {
|
|
244
|
+
...ev,
|
|
245
|
+
author: {
|
|
246
|
+
...rest,
|
|
247
|
+
id: opaqueId(dataDir, email)
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
const rawTransport = (req) => {
|
|
252
|
+
const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
|
|
253
|
+
return tok ? sessionIsOperator(dataDir, tok) : false;
|
|
254
|
+
};
|
|
255
|
+
const cookie = (req, name) => new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1];
|
|
256
|
+
/** browser session cookie, or a bearer token (the dev proxy / agent CLI path -
|
|
257
|
+
* same session store, held server-side by the Vite process, never by a page) */
|
|
258
|
+
const sessionToken = (req) => /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? cookie(req, "mv_s") ?? null;
|
|
259
|
+
const currentUser = (req) => {
|
|
260
|
+
const tok = sessionToken(req);
|
|
261
|
+
return tok ? sessionUser(dataDir, tok) : null;
|
|
262
|
+
};
|
|
263
|
+
const json = (res, code, body) => {
|
|
264
|
+
res.statusCode = code;
|
|
265
|
+
res.setHeader("content-type", "application/json");
|
|
266
|
+
res.setHeader("cache-control", "no-store");
|
|
267
|
+
res.end(JSON.stringify(body));
|
|
268
|
+
};
|
|
269
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
270
|
+
if (!/^application\/json\b/.test(String(req.headers["content-type"] ?? ""))) return reject(/* @__PURE__ */ new Error("content-type must be application/json"));
|
|
271
|
+
let body = "";
|
|
272
|
+
let done = false;
|
|
273
|
+
const settle = (fn) => {
|
|
274
|
+
if (done) return;
|
|
275
|
+
done = true;
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
fn();
|
|
278
|
+
};
|
|
279
|
+
const timer = setTimeout(() => settle(() => {
|
|
280
|
+
req.destroy();
|
|
281
|
+
reject(/* @__PURE__ */ new Error("body timed out"));
|
|
282
|
+
}), 1e4);
|
|
283
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
284
|
+
req.on("data", (c) => {
|
|
285
|
+
body += c;
|
|
286
|
+
if (body.length > MAX_BODY) settle(() => {
|
|
287
|
+
req.destroy();
|
|
288
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
req.on("end", () => settle(() => {
|
|
292
|
+
try {
|
|
293
|
+
resolve(body ? JSON.parse(body) : {});
|
|
294
|
+
} catch {
|
|
295
|
+
reject(/* @__PURE__ */ new Error("bad json"));
|
|
296
|
+
}
|
|
297
|
+
}));
|
|
298
|
+
req.on("error", () => settle(() => reject(/* @__PURE__ */ new Error("request failed"))));
|
|
299
|
+
});
|
|
300
|
+
const setSession = (req, res, token) => {
|
|
301
|
+
const secure = secureSuffix(req);
|
|
302
|
+
res.setHeader("set-cookie", [`mv_s=${token}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`, `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure}`]);
|
|
303
|
+
};
|
|
304
|
+
const csrfOk = (req) => !!/^Bearer /.test(String(req.headers.authorization ?? "")) || !cookie(req, "mv_s") || !!cookie(req, "mv_c") && req.headers["x-mv-c"] === cookie(req, "mv_c");
|
|
305
|
+
/** Returns true when the request was handled. Mounted behind the gate by serve. */
|
|
306
|
+
return async (req, res, url) => {
|
|
307
|
+
if (!url.pathname.startsWith("/__mv/api/")) return false;
|
|
308
|
+
const path = url.pathname.slice(10);
|
|
309
|
+
const ip = process.env.MARVER_TRUSTED_PROXY ? String(req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "?").split(",")[0].trim() : String(req.socket.remoteAddress ?? "?");
|
|
310
|
+
try {
|
|
311
|
+
if (frontDoorOn && path === "summary" && req.method === "OPTIONS") {
|
|
312
|
+
cors(res);
|
|
313
|
+
res.statusCode = 204;
|
|
314
|
+
return res.end(), true;
|
|
315
|
+
}
|
|
316
|
+
if (frontDoorOn && path === "identity" && req.method === "GET") {
|
|
317
|
+
cors(res);
|
|
318
|
+
const id = canvasIdentity(dataDir);
|
|
319
|
+
return json(res, 200, {
|
|
320
|
+
kid: id.kid,
|
|
321
|
+
jwk: id.publicJwk
|
|
322
|
+
}), true;
|
|
323
|
+
}
|
|
324
|
+
if (frontDoorOn && path === "summary" && req.method === "GET") {
|
|
325
|
+
cors(res);
|
|
326
|
+
if (String(req.headers.cookie ?? "").trim()) return json(res, 401, { error: "not authorized" }), true;
|
|
327
|
+
if (limited(`sum:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
328
|
+
const tok = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
329
|
+
const { verifyBearerJwt } = await import("./marver-id-CVCoaMGI.mjs");
|
|
330
|
+
const v = await verifyBearerJwt({
|
|
331
|
+
token: tok,
|
|
332
|
+
origin: publicOrigin,
|
|
333
|
+
issuer: idIssuer,
|
|
334
|
+
typ: "marver-summary+jwt",
|
|
335
|
+
azp: appOrigin,
|
|
336
|
+
maxAgeS: 120
|
|
337
|
+
});
|
|
338
|
+
if (!v.ok) return json(res, 401, { error: "not authorized" }), true;
|
|
339
|
+
const jti = typeof v.claims.jti === "string" ? v.claims.jti : "";
|
|
340
|
+
if (!jti) return json(res, 401, { error: "not authorized" }), true;
|
|
341
|
+
{
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
for (const [k, e] of summaryJtis) if (e < now) summaryJtis.delete(k);
|
|
344
|
+
if (summaryJtis.has(jti)) return json(res, 401, { error: "not authorized" }), true;
|
|
345
|
+
summaryJtis.set(jti, now + 15e4);
|
|
346
|
+
}
|
|
347
|
+
const share = shareState(dataDir);
|
|
348
|
+
const ceilings = ceilingsFromRights(rights);
|
|
349
|
+
const account = loadStore(dataDir).users.find((u) => normEmail(u.email) === normEmail(v.email));
|
|
350
|
+
const role = account?.role === "owner" ? "owner" : "member";
|
|
351
|
+
const resolution = share ? resolveAccess({
|
|
352
|
+
email: v.email,
|
|
353
|
+
userRole: account?.role,
|
|
354
|
+
store: share,
|
|
355
|
+
ceilings
|
|
356
|
+
}) : null;
|
|
357
|
+
if (!(share ? role === "owner" || resolution.entry : !!account)) return json(res, 404, { error: "not found" }), true;
|
|
358
|
+
const marks = loadSeen(dataDir)[normEmail(v.email)] ?? {};
|
|
359
|
+
let open = 0, unread = 0;
|
|
360
|
+
for (const [b, st] of threads) {
|
|
361
|
+
open += st.open.size;
|
|
362
|
+
unread += unreadCount(st, marks[b]);
|
|
363
|
+
}
|
|
364
|
+
const boardNames = Object.keys(rights);
|
|
365
|
+
const payload = {
|
|
366
|
+
v: 1,
|
|
367
|
+
name: meta.name ?? "Marver",
|
|
368
|
+
role: resolution?.role ?? (account ? "comment" : "view"),
|
|
369
|
+
type: boardType(boardNames[0] ?? ""),
|
|
370
|
+
boards: boardNames.map((b) => ({
|
|
371
|
+
name: b,
|
|
372
|
+
role: resolution?.boards[b] ?? (rights[b] === "comment" ? "comment" : "view"),
|
|
373
|
+
type: boardType(b)
|
|
374
|
+
})),
|
|
375
|
+
threads: {
|
|
376
|
+
open,
|
|
377
|
+
unread
|
|
378
|
+
},
|
|
379
|
+
owner: role === "owner",
|
|
380
|
+
...role === "owner" ? { people: loadStore(dataDir).users.length } : {},
|
|
381
|
+
kid: canvasIdentity(dataDir).kid
|
|
382
|
+
};
|
|
383
|
+
res.statusCode = 200;
|
|
384
|
+
res.setHeader("content-type", "application/jose");
|
|
385
|
+
res.setHeader("cache-control", "no-store");
|
|
386
|
+
return res.end(signCanvasJws(dataDir, payload)), true;
|
|
387
|
+
}
|
|
388
|
+
if (idIssuer && publicOrigin && path === "request-access" && req.method === "POST") {
|
|
389
|
+
if (limited(`req:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
390
|
+
const b = await readBody(req).catch(() => null);
|
|
391
|
+
const requestedRole = b?.requestedRole === "comment" ? "comment" : "view";
|
|
392
|
+
const note = typeof b?.note === "string" ? b.note.slice(0, 500) : void 0;
|
|
393
|
+
const share = shareState(dataDir);
|
|
394
|
+
const notBlocked = (email) => !share || provisionVerdict(share, email) !== "blocked";
|
|
395
|
+
const tok0 = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
|
|
396
|
+
const tok = tok0?.includes(".") ? tok0 : void 0;
|
|
397
|
+
const sessionCaller = tok || !b ? null : currentUser(req);
|
|
398
|
+
const tellOwner = (asker, put) => {
|
|
399
|
+
if (!put.fresh) return;
|
|
400
|
+
const owner = loadStore(dataDir).users.find((u) => u.role === "owner");
|
|
401
|
+
if (owner) relayNotify(notifyCtx, "access-requested", owner.email, transitionId("asked", asker, put.at));
|
|
402
|
+
};
|
|
403
|
+
if (b && tok) {
|
|
404
|
+
const v = verifyCanvasJws(dataDir, tok, "marver-reqaccess+jwt", publicOrigin);
|
|
405
|
+
if (v.ok && typeof v.claims.email === "string" && notBlocked(v.claims.email)) {
|
|
406
|
+
const put = putRequest(dataDir, {
|
|
407
|
+
email: v.claims.email,
|
|
408
|
+
name: typeof v.claims.name === "string" ? v.claims.name : void 0,
|
|
409
|
+
picture: typeof v.claims.picture === "string" ? v.claims.picture : void 0,
|
|
410
|
+
requestedRole,
|
|
411
|
+
target: typeof v.claims.target === "string" && v.claims.target ? v.claims.target.slice(0, 300) : void 0,
|
|
412
|
+
note
|
|
413
|
+
});
|
|
414
|
+
tellOwner(v.claims.email, put);
|
|
415
|
+
}
|
|
416
|
+
} else if (sessionCaller && csrfOk(req) && notBlocked(sessionCaller.email)) tellOwner(sessionCaller.email, putRequest(dataDir, {
|
|
417
|
+
email: sessionCaller.email,
|
|
418
|
+
name: sessionCaller.name,
|
|
419
|
+
requestedRole,
|
|
420
|
+
note
|
|
421
|
+
}));
|
|
422
|
+
return json(res, 202, { ok: true }), true;
|
|
423
|
+
}
|
|
424
|
+
if (path.startsWith("share/")) {
|
|
425
|
+
if (req.method === "OPTIONS") {
|
|
426
|
+
cors(res);
|
|
427
|
+
res.setHeader("access-control-allow-methods", "GET, PUT, POST, DELETE, OPTIONS");
|
|
428
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type, x-mv-salt");
|
|
429
|
+
res.statusCode = 204;
|
|
430
|
+
return res.end(), true;
|
|
431
|
+
}
|
|
432
|
+
if (String(req.headers.cookie ?? "").trim()) {
|
|
433
|
+
cors(res);
|
|
434
|
+
return json(res, 403, { error: "owner only" }), true;
|
|
435
|
+
}
|
|
436
|
+
let bodyBuf;
|
|
437
|
+
try {
|
|
438
|
+
bodyBuf = req.method === "GET" ? Buffer.alloc(0) : await new Promise((resolve, reject) => {
|
|
439
|
+
const chunks = [];
|
|
440
|
+
let size = 0;
|
|
441
|
+
const onData = (c) => {
|
|
442
|
+
chunks.push(c);
|
|
443
|
+
size += c.length;
|
|
444
|
+
if (size > MAX_BODY) {
|
|
445
|
+
req.removeListener("data", onData);
|
|
446
|
+
req.pause();
|
|
447
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
req.on("data", onData);
|
|
451
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
452
|
+
req.on("error", () => reject(/* @__PURE__ */ new Error("request failed")));
|
|
453
|
+
});
|
|
454
|
+
} catch (err) {
|
|
455
|
+
cors(res);
|
|
456
|
+
res.setHeader("connection", "close");
|
|
457
|
+
return json(res, 422, { error: err.message }), true;
|
|
458
|
+
}
|
|
459
|
+
const body = bodyBuf.toString("utf8");
|
|
460
|
+
const tok = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
461
|
+
const owner = loadStore(dataDir).users.find((u) => u.role === "owner");
|
|
462
|
+
let allowed = false;
|
|
463
|
+
const su = tok ? sessionUser(dataDir, tok) : null;
|
|
464
|
+
if (su && su.role === "owner") allowed = true;
|
|
465
|
+
if (!allowed && tok && idIssuer && publicOrigin) {
|
|
466
|
+
const { verifyBearerJwt } = await import("./marver-id-CVCoaMGI.mjs");
|
|
467
|
+
const v = await verifyBearerJwt({
|
|
468
|
+
token: tok,
|
|
469
|
+
origin: publicOrigin,
|
|
470
|
+
issuer: idIssuer,
|
|
471
|
+
typ: "marver-owner-api+jwt",
|
|
472
|
+
azp: appOrigin,
|
|
473
|
+
maxAgeS: 300
|
|
474
|
+
});
|
|
475
|
+
const subjectOk = owner?.idSubject ? owner.idSubject === `${idIssuer}#${v.ok ? v.sub : ""}` : true;
|
|
476
|
+
if (v.ok && owner && subjectOk && normEmail(v.email) === normEmail(owner.email)) {
|
|
477
|
+
if (req.method === "GET") allowed = true;
|
|
478
|
+
else {
|
|
479
|
+
const etag = rosterEtag(dataDir);
|
|
480
|
+
const claimedEtag = typeof v.claims.etag === "string" ? v.claims.etag : "";
|
|
481
|
+
if (claimedEtag && claimedEtag !== etag) {
|
|
482
|
+
cors(res);
|
|
483
|
+
return json(res, 409, { error: "roster changed - re-read and retry" }), true;
|
|
484
|
+
}
|
|
485
|
+
const salt = String(req.headers["x-mv-salt"] ?? "");
|
|
486
|
+
const bodyHash = createHash("sha256").update(bodyBuf).digest("hex");
|
|
487
|
+
const want = createHash("sha256").update(`${req.method}\n${url.pathname}\n${bodyHash}\n${etag}${salt ? `\n${salt}` : ""}`).digest("hex");
|
|
488
|
+
if (v.claims.digest === want) allowed = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
cors(res);
|
|
493
|
+
if (!allowed) return json(res, 403, { error: "owner only" }), true;
|
|
494
|
+
const b = body ? (() => {
|
|
495
|
+
try {
|
|
496
|
+
return JSON.parse(body);
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
})() : {};
|
|
501
|
+
if (b === null) return json(res, 422, { error: "bad json" }), true;
|
|
502
|
+
const ceilings = ceilingsFromRights(rights);
|
|
503
|
+
const roster = () => {
|
|
504
|
+
const s = loadShare(dataDir);
|
|
505
|
+
const history = {};
|
|
506
|
+
const allAuthors = /* @__PURE__ */ new Set();
|
|
507
|
+
for (const [b, st] of threads) {
|
|
508
|
+
const authors = /* @__PURE__ */ new Set();
|
|
509
|
+
for (const e of st.events) if (e.author?.email) {
|
|
510
|
+
authors.add(e.author.email.toLowerCase());
|
|
511
|
+
allAuthors.add(e.author.email.toLowerCase());
|
|
512
|
+
}
|
|
513
|
+
if (authors.size) history[b] = authors.size;
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
general: s?.general ?? {
|
|
517
|
+
mode: "private",
|
|
518
|
+
role: "view"
|
|
519
|
+
},
|
|
520
|
+
blocked: s?.blocked ?? [],
|
|
521
|
+
grants: s?.grants ?? [],
|
|
522
|
+
requests: loadRequests(dataDir).map(({ exp, ...r }) => r),
|
|
523
|
+
etag: rosterEtag(dataDir),
|
|
524
|
+
ceilings,
|
|
525
|
+
history,
|
|
526
|
+
historyAuthors: allAuthors.size,
|
|
527
|
+
gate: idIssuer ? "identity" : process.env.MARVER_PASSWORD ? "password" : "open"
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
const withEtag = (r) => {
|
|
531
|
+
res.setHeader("etag", `"${r.etag}"`);
|
|
532
|
+
return r;
|
|
533
|
+
};
|
|
534
|
+
try {
|
|
535
|
+
const sub = path.slice(6);
|
|
536
|
+
if (sub === "roster" && req.method === "GET") return json(res, 200, withEtag(roster())), true;
|
|
537
|
+
if (sub === "explain" && req.method === "GET") {
|
|
538
|
+
const who = String(url.searchParams.get("who") ?? "");
|
|
539
|
+
if (!who) return json(res, 422, { error: "who is required" }), true;
|
|
540
|
+
const s = loadShare(dataDir);
|
|
541
|
+
if (!s) return json(res, 422, { error: "no roster yet" }), true;
|
|
542
|
+
const asEmail = who.startsWith("@") ? `probe-${randomBytes(8).toString("hex")}${who}` : who;
|
|
543
|
+
const account = loadStore(dataDir).users.find((u) => normEmail(u.email) === normEmail(asEmail));
|
|
544
|
+
const r = resolveAccess({
|
|
545
|
+
email: asEmail,
|
|
546
|
+
userRole: account?.role,
|
|
547
|
+
store: s,
|
|
548
|
+
ceilings
|
|
549
|
+
});
|
|
550
|
+
return json(res, 200, {
|
|
551
|
+
who,
|
|
552
|
+
entry: r.entry,
|
|
553
|
+
role: r.role,
|
|
554
|
+
boards: r.boards,
|
|
555
|
+
trace: r.trace
|
|
556
|
+
}), true;
|
|
557
|
+
}
|
|
558
|
+
if (sub === "grant" && req.method === "PUT") {
|
|
559
|
+
const g = upsertGrant(dataDir, ceilings, {
|
|
560
|
+
principal: String(b.principal ?? ""),
|
|
561
|
+
scope: b.scope === "canvas" ? "canvas" : String(b.scope ?? ""),
|
|
562
|
+
assigned: b.assigned === "comment" ? "comment" : "view",
|
|
563
|
+
expires: typeof b.expires === "string" ? b.expires : null,
|
|
564
|
+
by: owner?.email ?? "owner"
|
|
565
|
+
}, { identityMode: !!idIssuer });
|
|
566
|
+
if (g.changed) relayNotify(notifyCtx, "invited", g.principal, transitionId("invited", g.principal, g.assigned, g.at));
|
|
567
|
+
return json(res, 200, withEtag(roster())), true;
|
|
568
|
+
}
|
|
569
|
+
if (sub === "grant" && req.method === "DELETE") {
|
|
570
|
+
removeGrant(dataDir, String(b.principal ?? ""), b.scope === "canvas" ? "canvas" : String(b.scope ?? ""));
|
|
571
|
+
return json(res, 200, withEtag(roster())), true;
|
|
572
|
+
}
|
|
573
|
+
if (sub === "reconfirm" && req.method === "POST") {
|
|
574
|
+
reconfirmGrant(dataDir, ceilings, String(b.principal ?? ""), b.scope === "canvas" ? "canvas" : String(b.scope ?? ""), String(b.board ?? ""));
|
|
575
|
+
return json(res, 200, withEtag(roster())), true;
|
|
576
|
+
}
|
|
577
|
+
if (sub === "general" && req.method === "PUT") {
|
|
578
|
+
const mode = String(b.mode ?? "");
|
|
579
|
+
if (mode !== "private" && mode !== "password" && mode !== "public") return json(res, 422, { error: "mode must be private | password | public" }), true;
|
|
580
|
+
const operative = operativeMode(mode, {
|
|
581
|
+
password: !!process.env.MARVER_PASSWORD && !idIssuer,
|
|
582
|
+
issuer: !!idIssuer
|
|
583
|
+
});
|
|
584
|
+
setGeneralMode(dataDir, operative);
|
|
585
|
+
const r = withEtag(roster());
|
|
586
|
+
return json(res, 200, operative === mode ? r : {
|
|
587
|
+
...r,
|
|
588
|
+
clamped: {
|
|
589
|
+
asked: mode,
|
|
590
|
+
operative
|
|
591
|
+
}
|
|
592
|
+
}), true;
|
|
593
|
+
}
|
|
594
|
+
if (sub === "block" && (req.method === "PUT" || req.method === "DELETE")) {
|
|
595
|
+
const address = String(b.address ?? "");
|
|
596
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) return json(res, 422, { error: "a valid address is required" }), true;
|
|
597
|
+
setBlocked(dataDir, address, req.method === "PUT");
|
|
598
|
+
return json(res, 200, withEtag(roster())), true;
|
|
599
|
+
}
|
|
600
|
+
const rm = /^request\/(.+)$/.exec(sub);
|
|
601
|
+
if (rm && req.method === "POST") {
|
|
602
|
+
const email = decodeURIComponent(rm[1]);
|
|
603
|
+
const approved = b.approve === true ? {
|
|
604
|
+
assigned: b.assigned === "comment" ? "comment" : "view",
|
|
605
|
+
by: owner?.email ?? "owner"
|
|
606
|
+
} : null;
|
|
607
|
+
if (!resolveRequest(dataDir, ceilings, email, approved)) return json(res, 422, { error: "no pending request for that address" }), true;
|
|
608
|
+
if (approved) relayNotify(notifyCtx, "request-approved", email, transitionId("approved", email, approved.assigned, (/* @__PURE__ */ new Date()).toISOString()));
|
|
609
|
+
return json(res, 200, withEtag(roster())), true;
|
|
610
|
+
}
|
|
611
|
+
} catch (err) {
|
|
612
|
+
return json(res, 422, { error: err.message }), true;
|
|
613
|
+
}
|
|
614
|
+
return json(res, 422, { error: "no such share route" }), true;
|
|
615
|
+
}
|
|
616
|
+
if (path === "seen" && req.method === "POST") {
|
|
617
|
+
if (!csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
|
|
618
|
+
const u = currentUser(req);
|
|
619
|
+
if (!u) return json(res, 401, { error: "sign in first" }), true;
|
|
620
|
+
const b = await readBody(req);
|
|
621
|
+
const board = String(b.board ?? "");
|
|
622
|
+
const st = threads.get(board);
|
|
623
|
+
if (!st) return json(res, 422, { error: "unknown board" }), true;
|
|
624
|
+
const latest = st.events[st.events.length - 1]?.id;
|
|
625
|
+
if (latest) markSeen(dataDir, normEmail(u.email), board, latest);
|
|
626
|
+
res.statusCode = 204;
|
|
627
|
+
return res.end(), true;
|
|
628
|
+
}
|
|
629
|
+
if (req.method === "GET") {
|
|
630
|
+
if (path === "me") {
|
|
631
|
+
const u = currentUser(req);
|
|
632
|
+
if (!u) return json(res, 401, { error: "signed out" }), true;
|
|
633
|
+
const share = shareState(dataDir);
|
|
634
|
+
const boards = share ? resolveAccess({
|
|
635
|
+
email: u.email,
|
|
636
|
+
userRole: u.role,
|
|
637
|
+
store: share,
|
|
638
|
+
ceilings: ceilingsFromRights(rights)
|
|
639
|
+
}).boards : Object.fromEntries(Object.entries(rights).map(([b, r]) => [b, r === "comment" ? "comment" : "view"]));
|
|
640
|
+
return json(res, 200, {
|
|
641
|
+
user: publicUser(u),
|
|
642
|
+
role: u.role,
|
|
643
|
+
id: opaqueId(dataDir, u.email),
|
|
644
|
+
boards
|
|
645
|
+
}), true;
|
|
646
|
+
}
|
|
647
|
+
if (path === "boards") {
|
|
648
|
+
const name = ownerName(dataDir);
|
|
649
|
+
return json(res, 200, {
|
|
650
|
+
rights,
|
|
651
|
+
stored: listBoards(commentsDir),
|
|
652
|
+
...name ? { owner: { name } } : {}
|
|
653
|
+
}), true;
|
|
654
|
+
}
|
|
655
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
656
|
+
if (m) {
|
|
657
|
+
if (!can(currentUser(req), rights, m[1], "read")) return json(res, 404, { error: "no such board" }), true;
|
|
658
|
+
const events = readLog(commentsDir, m[1]);
|
|
659
|
+
return json(res, 200, { events: rawTransport(req) ? events : events.map(project) }), true;
|
|
660
|
+
}
|
|
661
|
+
if (path === "invite-info") {
|
|
662
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
663
|
+
const info = inviteInfo(dataDir, String(url.searchParams.get("token") ?? ""));
|
|
664
|
+
return json(res, info ? 200 : 404, info ?? { error: "this invite link is invalid, expired, or already used" }), true;
|
|
665
|
+
}
|
|
666
|
+
if (path === "events") {
|
|
667
|
+
const tok = sessionToken(req);
|
|
668
|
+
if (!streamAllowed(tok)) return json(res, 401, { error: "not authorized" }), true;
|
|
669
|
+
res.statusCode = 200;
|
|
670
|
+
res.setHeader("content-type", "text/event-stream");
|
|
671
|
+
res.setHeader("cache-control", "no-store");
|
|
672
|
+
res.setHeader("x-accel-buffering", "no");
|
|
673
|
+
res.write(": connected\n\n");
|
|
674
|
+
const lastRaw = String(req.headers["last-event-id"] ?? "");
|
|
675
|
+
const lm = /^([0-9a-f]{8})-(\d+)$/.exec(lastRaw);
|
|
676
|
+
if (lastRaw) {
|
|
677
|
+
const last = lm && lm[1] === bootEpoch ? Number(lm[2]) : NaN;
|
|
678
|
+
if (!Number.isFinite(last)) res.write("event: resync\ndata: {}\n\n");
|
|
679
|
+
else {
|
|
680
|
+
const missed = ring.filter((r) => r.seq > last);
|
|
681
|
+
if (missed.length && missed[0].seq !== last + 1) res.write("event: resync\ndata: {}\n\n");
|
|
682
|
+
else for (const r of missed) res.write(r.data);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
clients.set(res, { tok });
|
|
686
|
+
req.on("close", () => clients.delete(res));
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
if (req.method !== "POST") return false;
|
|
692
|
+
if (!(path === "auth/claim" || path === "auth/signin") && !csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
|
|
693
|
+
if (path === "auth/claim") {
|
|
694
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
695
|
+
const b = await readBody(req);
|
|
696
|
+
const { user, session } = claimInvite(dataDir, String(b.token ?? ""), {
|
|
697
|
+
password: String(b.password ?? ""),
|
|
698
|
+
name: String(b.name ?? ""),
|
|
699
|
+
avatar: validAvatar(b.avatar) ? b.avatar : void 0
|
|
700
|
+
}, ceilings);
|
|
701
|
+
setSession(req, res, session);
|
|
702
|
+
return json(res, 200, { user: publicUser(user) }), true;
|
|
703
|
+
}
|
|
704
|
+
if (path === "auth/signin") {
|
|
705
|
+
const b = await readBody(req);
|
|
706
|
+
const email = String(b.email ?? "").trim().toLowerCase();
|
|
707
|
+
if (limited(`ip:${ip}`, `em:${email}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
708
|
+
const hit = signIn(dataDir, email, String(b.password ?? ""), ceilings);
|
|
709
|
+
if (!hit) return json(res, 401, { error: "wrong email or password" }), true;
|
|
710
|
+
setSession(req, res, hit.session);
|
|
711
|
+
return json(res, 200, { user: publicUser(hit.user) }), true;
|
|
712
|
+
}
|
|
713
|
+
if (path === "auth/signout") {
|
|
714
|
+
const tok = cookie(req, "mv_s");
|
|
715
|
+
if (tok) signOut(dataDir, tok);
|
|
716
|
+
res.setHeader("set-cookie", ["mv_s=; Path=/; Max-Age=0; HttpOnly", "mv_c=; Path=/; Max-Age=0"]);
|
|
717
|
+
return json(res, 200, {}), true;
|
|
718
|
+
}
|
|
719
|
+
if (path === "profile") {
|
|
720
|
+
const u = currentUser(req);
|
|
721
|
+
if (!u) return json(res, 401, { error: "sign in first" }), true;
|
|
722
|
+
const b = await readBody(req);
|
|
723
|
+
const next = updateProfile(dataDir, u.email, {
|
|
724
|
+
name: typeof b.name === "string" ? b.name : void 0,
|
|
725
|
+
avatar: b.avatar === "" ? "" : validAvatar(b.avatar) ? b.avatar : void 0
|
|
726
|
+
});
|
|
727
|
+
return json(res, 200, { user: publicUser(next) }), true;
|
|
728
|
+
}
|
|
729
|
+
if (path === "cli-session") {
|
|
730
|
+
const bearer = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
731
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
732
|
+
const issued = issueDeviceSession(dataDir, bearer);
|
|
733
|
+
if (!issued) return json(res, 401, { error: "MARVER_CLI_TOKEN was refused, or nobody owns this canvas yet" }), true;
|
|
734
|
+
return json(res, 200, {
|
|
735
|
+
token: issued.token,
|
|
736
|
+
exp: issued.exp,
|
|
737
|
+
user: publicUser(issued.user)
|
|
738
|
+
}), true;
|
|
739
|
+
}
|
|
740
|
+
if (path === "invite") {
|
|
741
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
742
|
+
const b = await readBody(req);
|
|
743
|
+
const email = String(b.email ?? "");
|
|
744
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json(res, 400, { error: "a valid email is required" }), true;
|
|
745
|
+
const idMode = !!process.env.MARVER_ID_ISSUER;
|
|
746
|
+
return json(res, 200, {
|
|
747
|
+
...createInvite(dataDir, email),
|
|
748
|
+
idMode
|
|
749
|
+
}), true;
|
|
750
|
+
}
|
|
751
|
+
if (path === "revoke") {
|
|
752
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
753
|
+
const b = await readBody(req);
|
|
754
|
+
revokeUser(dataDir, String(b.email ?? ""));
|
|
755
|
+
return json(res, 200, {}), true;
|
|
756
|
+
}
|
|
757
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
758
|
+
if (m) {
|
|
759
|
+
const u = currentUser(req);
|
|
760
|
+
if (!can(u, rights, m[1], "comment", dataDir)) return json(res, u ? 403 : 401, { error: u ? `this board is read-only - ask ${ownerName(dataDir) ?? "the canvas owner"} for commenting access` : "sign in to comment" }), true;
|
|
761
|
+
const b = await readBody(req);
|
|
762
|
+
const incoming = Array.isArray(b.events) ? b.events : [];
|
|
763
|
+
if (incoming.length > 100) return json(res, 400, { error: "too many events in one push" }), true;
|
|
764
|
+
const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1]);
|
|
765
|
+
if (bad) return json(res, 400, { error: bad }), true;
|
|
766
|
+
const fresh = appendEvents(commentsDir, m[1], incoming);
|
|
767
|
+
if (fresh.length) {
|
|
768
|
+
const st = threads.get(m[1]);
|
|
769
|
+
threads.set(m[1], deriveThreads([...st?.events ?? [], ...fresh]));
|
|
770
|
+
}
|
|
771
|
+
broadcast(m[1], fresh.map(project));
|
|
772
|
+
return json(res, 200, { accepted: fresh.length }), true;
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
} catch (err) {
|
|
776
|
+
return json(res, 400, { error: err.message }), true;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
//#endregion
|
|
781
|
+
export { collabHandler, validAvatar };
|