@sleep2agi/commhub-server 0.9.0-preview.25 → 0.9.0-preview.26
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/README.md +9 -6
- package/package.json +1 -1
- package/src/auth.ts +5 -0
- package/src/avatar-validate.test.ts +52 -2
- package/src/avatar-validate.ts +35 -1
- package/src/ext-validation-regression.test.ts +138 -0
- package/src/file-download-authz.test.ts +50 -16
- package/src/file-download-date-bucket.test.ts +7 -25
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-scope.ts +81 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +269 -85
- package/src/tools.ts +148 -42
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +80 -3
- package/src/uploads.ts +15 -0
package/src/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { registerTools } from "./tools.js";
|
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
|
|
7
7
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
|
+
import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
8
9
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
9
10
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
10
11
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
@@ -216,7 +217,55 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
216
217
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
// #
|
|
220
|
+
// #503 — the credential kinds that authorization decisions branch on.
|
|
221
|
+
// Resolved once per request at the handler, so the authz helpers stay
|
|
222
|
+
// pure functions of (principal, entry) and every credential kind is a
|
|
223
|
+
// directly constructible test input instead of a hand-assembled Request.
|
|
224
|
+
export type Principal =
|
|
225
|
+
| { kind: "anonymous" }
|
|
226
|
+
| { kind: "dev-open-anon" }
|
|
227
|
+
| { kind: "legacy-master" }
|
|
228
|
+
| { kind: "utok"; userId: string; username: string; isAdmin: false }
|
|
229
|
+
| { kind: "ntok"; userId: string; boundNetworkId: string }
|
|
230
|
+
| { kind: "admin-utok"; userId: string; username: string };
|
|
231
|
+
|
|
232
|
+
export function resolvePrincipal(req: Request): Principal {
|
|
233
|
+
const authCtx = resolveRequestAuth(req);
|
|
234
|
+
if (!authCtx) {
|
|
235
|
+
if (isLegacyAuthToken(req)) return { kind: "legacy-master" };
|
|
236
|
+
if (DEV_OPEN) return { kind: "dev-open-anon" };
|
|
237
|
+
return { kind: "anonymous" };
|
|
238
|
+
}
|
|
239
|
+
const token = requestToken(req);
|
|
240
|
+
const resolved = token ? resolveToken(token) : null;
|
|
241
|
+
const isAdmin = !!resolved && resolved.user.role === "admin";
|
|
242
|
+
// Bound network wins over admin classification: a ntok_ issued BY an
|
|
243
|
+
// admin is still a ntok_, and its bound scope defines its identity.
|
|
244
|
+
// Inverting this order would silently re-grant every production agent
|
|
245
|
+
// node cross-network read, since production node tokens resolve to an
|
|
246
|
+
// admin user (see the 生产影响 note on authorizeFileDownload).
|
|
247
|
+
if (authCtx.networkId) return { kind: "ntok", userId: authCtx.userId, boundNetworkId: authCtx.networkId };
|
|
248
|
+
if (isAdmin) return { kind: "admin-utok", userId: authCtx.userId, username: authCtx.username };
|
|
249
|
+
return { kind: "utok", userId: authCtx.userId, username: authCtx.username, isAdmin: false };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// #503 — coerce the on-disk (untrusted, hand-editable) index entry into
|
|
253
|
+
// the shape the authz helper reasons about. Every `typeof` narrowing for
|
|
254
|
+
// these two fields lives here and nowhere else, so the authz rules can be
|
|
255
|
+
// read as rules rather than as string-checks.
|
|
256
|
+
export function normalizeEntry(
|
|
257
|
+
entry: { owner_id?: unknown; network_id?: unknown },
|
|
258
|
+
): { ownerId: string | null; networkId: string | null } {
|
|
259
|
+
const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
|
|
260
|
+
? entry.owner_id
|
|
261
|
+
: null;
|
|
262
|
+
const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
|
|
263
|
+
? entry.network_id
|
|
264
|
+
: null;
|
|
265
|
+
return { ownerId, networkId };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// #495/#503 — shared authorization gate for /api/files/:file_id downloads.
|
|
220
269
|
// Called from the GET handler and any future HEAD/Range/dashboard-proxy
|
|
221
270
|
// entry so the allow-list can't drift between methods (通信龙 clause 2:
|
|
222
271
|
// "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
|
|
@@ -225,90 +274,85 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
225
274
|
// caller emits 404 on false — never 403; 403 would leak that the
|
|
226
275
|
// file_id exists and enables enumeration.
|
|
227
276
|
//
|
|
228
|
-
// Allow-list
|
|
229
|
-
// here — see follow-up #503):
|
|
277
|
+
// Allow-list — network scope enforced by #503:
|
|
230
278
|
// - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
|
|
231
279
|
// - admin utok_ → allow (mirrors requireAdminAuth).
|
|
232
|
-
// -
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
280
|
+
// - entry.network_id present → caller must belong to THAT network:
|
|
281
|
+
// ntok_ must be bound to it; utok_ must hold any role in it
|
|
282
|
+
// (viewer included — viewer is read-only, and a viewer who can see
|
|
283
|
+
// a task but not open its attachment is a broken product).
|
|
284
|
+
// - entry.network_id absent (legacy / unattributed) → the pre-#503
|
|
285
|
+
// rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
|
|
238
286
|
// - Everything else → deny (404). New uploads in production always
|
|
239
287
|
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
240
288
|
// producing path when DEV_OPEN=off and no legacy master token is
|
|
241
289
|
// configured), so denying null-owner in production closes the
|
|
242
290
|
// enumeration vector for legacy blobs without breaking new uploads.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
return
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
denied?: string;
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
function getUserNetworkIds(userId: string): string[] {
|
|
265
|
-
return db.all<{ network_id: string }>(
|
|
266
|
-
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
267
|
-
userId
|
|
268
|
-
).map((row) => row.network_id);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
|
|
272
|
-
const requested = url.searchParams.get("network_id");
|
|
273
|
-
|
|
274
|
-
// Legacy global token or open dev mode keeps the old global behavior.
|
|
275
|
-
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
276
|
-
|
|
277
|
-
// Network tokens are forcibly scoped to their bound network.
|
|
278
|
-
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
279
|
-
|
|
280
|
-
// System admins may intentionally inspect all networks.
|
|
281
|
-
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
282
|
-
|
|
283
|
-
if (requested) {
|
|
284
|
-
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
285
|
-
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
286
|
-
return { networkId: requested, networkIds: null };
|
|
291
|
+
//
|
|
292
|
+
// 生产影响: 新的 ntok 归属文件走 network gate, admin 签发的 ntok 也走 gate
|
|
293
|
+
// (kind='ntok' 分类优先绑定). 98 历史文件无 network_id 走老文件兼容分支
|
|
294
|
+
// (owner/admin 可读, 存量行为不变).
|
|
295
|
+
//
|
|
296
|
+
// 行为变更: admin 的节点令牌 (ntok_) 失去它今天拥有的跨网络读权. 生产今天
|
|
297
|
+
// 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
|
|
298
|
+
export function authorizeFileDownload(
|
|
299
|
+
principal: Principal,
|
|
300
|
+
entry: { ownerId: string | null; networkId: string | null },
|
|
301
|
+
): boolean {
|
|
302
|
+
if (principal.kind === "legacy-master") return true;
|
|
303
|
+
if (principal.kind === "admin-utok") return true;
|
|
304
|
+
|
|
305
|
+
if (entry.networkId !== null) {
|
|
306
|
+
if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
|
|
307
|
+
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
|
|
308
|
+
return !!getUserNetworkRole(principal.userId, entry.networkId);
|
|
287
309
|
}
|
|
288
310
|
|
|
289
|
-
|
|
311
|
+
if (principal.kind === "ntok" || principal.kind === "utok") {
|
|
312
|
+
if (entry.ownerId !== null && principal.userId === entry.ownerId) return true;
|
|
313
|
+
}
|
|
314
|
+
// Kept env-gated rather than narrowed to kind==='dev-open-anon': the
|
|
315
|
+
// pre-#503 rule allowed ANY caller to read a null-owner entry while
|
|
316
|
+
// DEV_OPEN is on, and #503 is scoped to adding network scope, not to
|
|
317
|
+
// tightening the local-dev carve-out.
|
|
318
|
+
if (entry.ownerId === null && DEV_OPEN) return true;
|
|
319
|
+
return false;
|
|
290
320
|
}
|
|
291
321
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
322
|
+
type ByteRange =
|
|
323
|
+
| { ok: true; start: number; end: number; length: number }
|
|
324
|
+
| { ok: false };
|
|
325
|
+
|
|
326
|
+
function parseSingleByteRange(rangeHeader: string | null, size: number): ByteRange | null {
|
|
327
|
+
if (rangeHeader === null) return null;
|
|
328
|
+
if (!Number.isSafeInteger(size) || size < 0) return { ok: false };
|
|
329
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
330
|
+
if (!match) return { ok: false };
|
|
331
|
+
const [, rawStart, rawEnd] = match;
|
|
332
|
+
if (rawStart === "" && rawEnd === "") return { ok: false };
|
|
333
|
+
|
|
334
|
+
let start: number;
|
|
335
|
+
let end: number;
|
|
336
|
+
if (rawStart === "") {
|
|
337
|
+
const suffixLength = Number(rawEnd);
|
|
338
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { ok: false };
|
|
339
|
+
if (size === 0) return { ok: false };
|
|
340
|
+
start = Math.max(0, size - suffixLength);
|
|
341
|
+
end = size - 1;
|
|
342
|
+
} else {
|
|
343
|
+
start = Number(rawStart);
|
|
344
|
+
if (!Number.isSafeInteger(start) || start < 0) return { ok: false };
|
|
345
|
+
if (start >= size) return { ok: false };
|
|
346
|
+
if (rawEnd === "") {
|
|
347
|
+
end = size - 1;
|
|
299
348
|
} else {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
349
|
+
end = Number(rawEnd);
|
|
350
|
+
if (!Number.isSafeInteger(end) || end < start) return { ok: false };
|
|
351
|
+
end = Math.min(end, size - 1);
|
|
303
352
|
}
|
|
304
353
|
}
|
|
305
|
-
return sql;
|
|
306
|
-
}
|
|
307
354
|
|
|
308
|
-
|
|
309
|
-
if (scope.networkId) return scope.networkId;
|
|
310
|
-
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
311
|
-
return null;
|
|
355
|
+
return { ok: true, start, end, length: end - start + 1 };
|
|
312
356
|
}
|
|
313
357
|
|
|
314
358
|
function sqliteTime(date: Date): string {
|
|
@@ -409,13 +453,6 @@ function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
|
|
|
409
453
|
}));
|
|
410
454
|
}
|
|
411
455
|
|
|
412
|
-
function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
413
|
-
if (!authCtx) return true; // legacy global token or open dev mode
|
|
414
|
-
if (isAdmin) return true;
|
|
415
|
-
if (!networkId) return false;
|
|
416
|
-
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
417
|
-
return !!role && role !== "viewer";
|
|
418
|
-
}
|
|
419
456
|
|
|
420
457
|
type RestDeliveryTarget =
|
|
421
458
|
| { state: "online"; alias: string; session: any }
|
|
@@ -1305,7 +1342,7 @@ return Bun.serve({
|
|
|
1305
1342
|
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
1306
1343
|
const restAuth = resolveRequestAuth(req);
|
|
1307
1344
|
const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
1308
|
-
const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
|
|
1345
|
+
const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
|
|
1309
1346
|
if (restScope.denied) {
|
|
1310
1347
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1311
1348
|
}
|
|
@@ -1602,8 +1639,22 @@ return Bun.serve({
|
|
|
1602
1639
|
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
1603
1640
|
// executed or served as HTML.
|
|
1604
1641
|
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
1642
|
+
// #503 Finding 3 = Option A (lead 40be9845): every 4xx returned
|
|
1643
|
+
// BEFORE `req.formData()` drains the request body MUST include
|
|
1644
|
+
// `Connection: close`. Otherwise the client's still-inbound bytes
|
|
1645
|
+
// land on a keepalive connection the server already answered on,
|
|
1646
|
+
// poisoning the next pooled request. Bug is pre-existing (411/
|
|
1647
|
+
// 413/415/429/401 all have this shape) — the #503 authz block
|
|
1648
|
+
// just made it visible under aggregate `bun test` with a 12MiB
|
|
1649
|
+
// upload immediately followed by another request. Per-file green
|
|
1650
|
+
// hides this class of bug.
|
|
1651
|
+
const earlyReject = (res: Response): Response => {
|
|
1652
|
+
const wrapped = withCors(req, res);
|
|
1653
|
+
wrapped.headers.set("Connection", "close");
|
|
1654
|
+
return wrapped;
|
|
1655
|
+
};
|
|
1605
1656
|
const authErr = requireAuth(req);
|
|
1606
|
-
if (authErr) return
|
|
1657
|
+
if (authErr) return earlyReject(authErr);
|
|
1607
1658
|
|
|
1608
1659
|
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
1609
1660
|
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
@@ -1617,7 +1668,7 @@ return Bun.serve({
|
|
|
1617
1668
|
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1618
1669
|
};
|
|
1619
1670
|
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1620
|
-
return
|
|
1671
|
+
return earlyReject(new Response(
|
|
1621
1672
|
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1622
1673
|
{ status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
|
|
1623
1674
|
));
|
|
@@ -1631,25 +1682,132 @@ return Bun.serve({
|
|
|
1631
1682
|
// or lied Content-Length header)
|
|
1632
1683
|
const contentLength = req.headers.get("Content-Length");
|
|
1633
1684
|
if (!contentLength) {
|
|
1634
|
-
return
|
|
1685
|
+
return earlyReject(Response.json(
|
|
1635
1686
|
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
1636
1687
|
{ status: 411 },
|
|
1637
1688
|
));
|
|
1638
1689
|
}
|
|
1639
1690
|
const declaredBytes = Number(contentLength);
|
|
1640
1691
|
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
1641
|
-
return
|
|
1692
|
+
return earlyReject(Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
1642
1693
|
}
|
|
1643
1694
|
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
1644
|
-
return
|
|
1695
|
+
return earlyReject(Response.json(
|
|
1645
1696
|
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
1646
1697
|
{ status: 413 },
|
|
1647
1698
|
));
|
|
1648
1699
|
}
|
|
1649
1700
|
|
|
1701
|
+
// #503 — decide which network this upload is attributed to, then
|
|
1702
|
+
// check the caller may write there. Attribution and authorization
|
|
1703
|
+
// are two questions; neither substitutes for the other. Placed
|
|
1704
|
+
// after the in-memory rate limiter (above) and before the body is
|
|
1705
|
+
// read, so an unauthorized caller never gets a DB query per byte
|
|
1706
|
+
// uploaded and never gets their multipart envelope parsed.
|
|
1707
|
+
const principal = resolvePrincipal(req);
|
|
1708
|
+
const requestedNetId = url.searchParams.get("network_id");
|
|
1709
|
+
let uploadNetId: string | null = null;
|
|
1710
|
+
|
|
1711
|
+
switch (principal.kind) {
|
|
1712
|
+
case "anonymous":
|
|
1713
|
+
// Unreachable: requireAuth above already 401s. Kept so the
|
|
1714
|
+
// switch stays exhaustive and a future auth change fails loud.
|
|
1715
|
+
return earlyReject(Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
1716
|
+
case "dev-open-anon":
|
|
1717
|
+
// Local dev only. Deliberately does NOT claim a real network,
|
|
1718
|
+
// even if one was requested — an unauthenticated caller must
|
|
1719
|
+
// not be able to file blobs into a tenant's network.
|
|
1720
|
+
uploadNetId = null;
|
|
1721
|
+
break;
|
|
1722
|
+
case "legacy-master":
|
|
1723
|
+
// Unreachable today: requireAuth 401s master tokens on every
|
|
1724
|
+
// non-GET /api/ request (RFC-001 made them read-only). Kept
|
|
1725
|
+
// fail-closed so relaxing that rule cannot silently start
|
|
1726
|
+
// producing unattributed blobs.
|
|
1727
|
+
if (!requestedNetId) {
|
|
1728
|
+
return earlyReject(Response.json({
|
|
1729
|
+
ok: false, error: "network_id_required",
|
|
1730
|
+
message: "legacy master uploads must specify a network_id query param",
|
|
1731
|
+
}, { status: 400 }));
|
|
1732
|
+
}
|
|
1733
|
+
uploadNetId = requestedNetId;
|
|
1734
|
+
break;
|
|
1735
|
+
case "ntok":
|
|
1736
|
+
if (requestedNetId && requestedNetId !== principal.boundNetworkId) {
|
|
1737
|
+
return earlyReject(Response.json({
|
|
1738
|
+
ok: false, error: "network_id_conflict",
|
|
1739
|
+
message: "network_id query param conflicts with the token-bound network",
|
|
1740
|
+
}, { status: 400 }));
|
|
1741
|
+
}
|
|
1742
|
+
uploadNetId = principal.boundNetworkId;
|
|
1743
|
+
break;
|
|
1744
|
+
case "admin-utok": {
|
|
1745
|
+
// #503 Finding 2 = Option F (lead 40be9845): admin follows the same
|
|
1746
|
+
// "auto-derive when unambiguous / require param when ambiguous" rule
|
|
1747
|
+
// as utok_. Rule 4's REASON (no unowned files) is preserved — the
|
|
1748
|
+
// derived network is a real membership. Strict on genuine ambiguity:
|
|
1749
|
+
// 0 or ≥2 memberships → 400. Preserves prod upload contract (in
|
|
1750
|
+
// prod admin is in exactly 1 network; auto-derive succeeds).
|
|
1751
|
+
if (requestedNetId) {
|
|
1752
|
+
// Admin bypasses membership; network existence validated below.
|
|
1753
|
+
uploadNetId = requestedNetId;
|
|
1754
|
+
break;
|
|
1755
|
+
}
|
|
1756
|
+
const adminNetworks = getUserNetworkIds(principal.userId);
|
|
1757
|
+
if (adminNetworks.length === 1) {
|
|
1758
|
+
uploadNetId = adminNetworks[0];
|
|
1759
|
+
} else {
|
|
1760
|
+
return earlyReject(Response.json({
|
|
1761
|
+
ok: false, error: "network_id_required",
|
|
1762
|
+
message: "admin has 0 or ≥2 network memberships; network_id query param required",
|
|
1763
|
+
}, { status: 400 }));
|
|
1764
|
+
}
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
case "utok": {
|
|
1768
|
+
if (requestedNetId) {
|
|
1769
|
+
// A non-member asking for someone else's network never gets
|
|
1770
|
+
// here: the REST scope guard (`restScope.denied`) already
|
|
1771
|
+
// 403'd, with the same body whether the network exists or
|
|
1772
|
+
// not. Re-checking membership here would be a second, drifting
|
|
1773
|
+
// copy of a decision this codebase makes in one place.
|
|
1774
|
+
uploadNetId = requestedNetId;
|
|
1775
|
+
break;
|
|
1776
|
+
}
|
|
1777
|
+
uploadNetId = singleNetworkId(resolveRestNetworkScope(url.searchParams.get("network_id"), authCtx, false));
|
|
1778
|
+
if (!uploadNetId) {
|
|
1779
|
+
return earlyReject(Response.json({
|
|
1780
|
+
ok: false, error: "network_id_required",
|
|
1781
|
+
message: "network_id query param required for a multi-network user token; 'first network' is not assumed",
|
|
1782
|
+
}, { status: 400 }));
|
|
1783
|
+
}
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (uploadNetId !== null) {
|
|
1789
|
+
const networkRow = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", uploadNetId);
|
|
1790
|
+
if (!networkRow) {
|
|
1791
|
+
if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
|
|
1792
|
+
return earlyReject(Response.json({
|
|
1793
|
+
ok: false, error: "unknown_network",
|
|
1794
|
+
message: "network_id does not exist",
|
|
1795
|
+
}, { status: 400 }));
|
|
1796
|
+
}
|
|
1797
|
+
// Byte-identical to the non-member 404 above: distinguishing
|
|
1798
|
+
// the two would hand an unprivileged caller a network-existence
|
|
1799
|
+
// oracle.
|
|
1800
|
+
return earlyReject(Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
if (!canRestWriteNetwork(authCtx, uploadNetId, principal.kind === "admin-utok")) {
|
|
1805
|
+
return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1650
1808
|
const contentType = req.headers.get("Content-Type") ?? "";
|
|
1651
1809
|
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
1652
|
-
return
|
|
1810
|
+
return earlyReject(Response.json(
|
|
1653
1811
|
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
1654
1812
|
{ status: 415 },
|
|
1655
1813
|
));
|
|
@@ -1721,6 +1879,8 @@ return Bun.serve({
|
|
|
1721
1879
|
size: file.size,
|
|
1722
1880
|
owner: authCtx?.username ?? null,
|
|
1723
1881
|
owner_id: authCtx?.userId ?? null,
|
|
1882
|
+
// Key omitted (not null) when unattributed — see UploadIndexEntry.
|
|
1883
|
+
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
1724
1884
|
uploaded_at: new Date().toISOString(),
|
|
1725
1885
|
}, null, 2));
|
|
1726
1886
|
}
|
|
@@ -1791,7 +1951,7 @@ return Bun.serve({
|
|
|
1791
1951
|
// "no such file". Both branches return the same 404 shape
|
|
1792
1952
|
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
1793
1953
|
// status code and headers are the authoritative signal).
|
|
1794
|
-
if (!authorizeFileDownload(req, entry)) {
|
|
1954
|
+
if (!authorizeFileDownload(resolvePrincipal(req), normalizeEntry(entry))) {
|
|
1795
1955
|
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1796
1956
|
}
|
|
1797
1957
|
|
|
@@ -1847,7 +2007,31 @@ return Bun.serve({
|
|
|
1847
2007
|
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
1848
2008
|
"X-Content-Type-Options": "nosniff",
|
|
1849
2009
|
"Cache-Control": "private, no-store",
|
|
2010
|
+
"Accept-Ranges": "bytes",
|
|
1850
2011
|
};
|
|
2012
|
+
const range = parseSingleByteRange(req.headers.get("Range"), entry.size);
|
|
2013
|
+
if (range && !range.ok) {
|
|
2014
|
+
return new Response(null, {
|
|
2015
|
+
status: 416,
|
|
2016
|
+
headers: {
|
|
2017
|
+
...responseHeaders,
|
|
2018
|
+
"Content-Length": "0",
|
|
2019
|
+
"Content-Range": `bytes */${entry.size}`,
|
|
2020
|
+
},
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
if (range?.ok) {
|
|
2024
|
+
const headers = {
|
|
2025
|
+
...responseHeaders,
|
|
2026
|
+
"Content-Length": String(range.length),
|
|
2027
|
+
"Content-Range": `bytes ${range.start}-${range.end}/${entry.size}`,
|
|
2028
|
+
};
|
|
2029
|
+
if (req.method === "HEAD") {
|
|
2030
|
+
return new Response(null, { status: 206, headers });
|
|
2031
|
+
}
|
|
2032
|
+
const blob = Bun.file(storage.absolutePath).slice(range.start, range.end + 1);
|
|
2033
|
+
return new Response(blob, { status: 206, headers });
|
|
2034
|
+
}
|
|
1851
2035
|
// HEAD: return the same status and headers as GET, but no body,
|
|
1852
2036
|
// per RFC 9110 §9.3.2. This ensures HEAD passes through the same
|
|
1853
2037
|
// authorization helper — a cross-owner HEAD is 404 (same as GET),
|