@sleep2agi/commhub-server 0.9.0-preview.24 → 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 +436 -0
- 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 +298 -89
- 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 +81 -3
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";
|
|
@@ -16,6 +17,8 @@ import {
|
|
|
16
17
|
MAX_REQUEST_CONTENT_LENGTH,
|
|
17
18
|
MAX_UPLOAD_BYTES,
|
|
18
19
|
buildStoragePath,
|
|
20
|
+
pathForExistingBlob,
|
|
21
|
+
isValidCalendarBucket,
|
|
19
22
|
generateFileId,
|
|
20
23
|
getUploadsRoot,
|
|
21
24
|
indexEntryPath,
|
|
@@ -214,7 +217,55 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
214
217
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
215
218
|
}
|
|
216
219
|
|
|
217
|
-
// #
|
|
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.
|
|
218
269
|
// Called from the GET handler and any future HEAD/Range/dashboard-proxy
|
|
219
270
|
// entry so the allow-list can't drift between methods (通信龙 clause 2:
|
|
220
271
|
// "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
|
|
@@ -223,90 +274,85 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
223
274
|
// caller emits 404 on false — never 403; 403 would leak that the
|
|
224
275
|
// file_id exists and enables enumeration.
|
|
225
276
|
//
|
|
226
|
-
// Allow-list
|
|
227
|
-
// here — see follow-up #503):
|
|
277
|
+
// Allow-list — network scope enforced by #503:
|
|
228
278
|
// - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
|
|
229
279
|
// - admin utok_ → allow (mirrors requireAdminAuth).
|
|
230
|
-
// -
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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.
|
|
236
286
|
// - Everything else → deny (404). New uploads in production always
|
|
237
287
|
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
238
288
|
// producing path when DEV_OPEN=off and no legacy master token is
|
|
239
289
|
// configured), so denying null-owner in production closes the
|
|
240
290
|
// enumeration vector for legacy blobs without breaking new uploads.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
denied?: string;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
function getUserNetworkIds(userId: string): string[] {
|
|
263
|
-
return db.all<{ network_id: string }>(
|
|
264
|
-
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
265
|
-
userId
|
|
266
|
-
).map((row) => row.network_id);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
|
|
270
|
-
const requested = url.searchParams.get("network_id");
|
|
271
|
-
|
|
272
|
-
// Legacy global token or open dev mode keeps the old global behavior.
|
|
273
|
-
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
274
|
-
|
|
275
|
-
// Network tokens are forcibly scoped to their bound network.
|
|
276
|
-
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
277
|
-
|
|
278
|
-
// System admins may intentionally inspect all networks.
|
|
279
|
-
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
280
|
-
|
|
281
|
-
if (requested) {
|
|
282
|
-
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
283
|
-
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
284
|
-
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);
|
|
285
309
|
}
|
|
286
310
|
|
|
287
|
-
|
|
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;
|
|
288
320
|
}
|
|
289
321
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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;
|
|
297
348
|
} else {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
349
|
+
end = Number(rawEnd);
|
|
350
|
+
if (!Number.isSafeInteger(end) || end < start) return { ok: false };
|
|
351
|
+
end = Math.min(end, size - 1);
|
|
301
352
|
}
|
|
302
353
|
}
|
|
303
|
-
return sql;
|
|
304
|
-
}
|
|
305
354
|
|
|
306
|
-
|
|
307
|
-
if (scope.networkId) return scope.networkId;
|
|
308
|
-
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
309
|
-
return null;
|
|
355
|
+
return { ok: true, start, end, length: end - start + 1 };
|
|
310
356
|
}
|
|
311
357
|
|
|
312
358
|
function sqliteTime(date: Date): string {
|
|
@@ -407,13 +453,6 @@ function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
|
|
|
407
453
|
}));
|
|
408
454
|
}
|
|
409
455
|
|
|
410
|
-
function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
411
|
-
if (!authCtx) return true; // legacy global token or open dev mode
|
|
412
|
-
if (isAdmin) return true;
|
|
413
|
-
if (!networkId) return false;
|
|
414
|
-
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
415
|
-
return !!role && role !== "viewer";
|
|
416
|
-
}
|
|
417
456
|
|
|
418
457
|
type RestDeliveryTarget =
|
|
419
458
|
| { state: "online"; alias: string; session: any }
|
|
@@ -1303,7 +1342,7 @@ return Bun.serve({
|
|
|
1303
1342
|
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
1304
1343
|
const restAuth = resolveRequestAuth(req);
|
|
1305
1344
|
const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
1306
|
-
const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
|
|
1345
|
+
const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
|
|
1307
1346
|
if (restScope.denied) {
|
|
1308
1347
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1309
1348
|
}
|
|
@@ -1600,8 +1639,22 @@ return Bun.serve({
|
|
|
1600
1639
|
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
1601
1640
|
// executed or served as HTML.
|
|
1602
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
|
+
};
|
|
1603
1656
|
const authErr = requireAuth(req);
|
|
1604
|
-
if (authErr) return
|
|
1657
|
+
if (authErr) return earlyReject(authErr);
|
|
1605
1658
|
|
|
1606
1659
|
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
1607
1660
|
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
@@ -1615,7 +1668,7 @@ return Bun.serve({
|
|
|
1615
1668
|
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1616
1669
|
};
|
|
1617
1670
|
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1618
|
-
return
|
|
1671
|
+
return earlyReject(new Response(
|
|
1619
1672
|
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1620
1673
|
{ status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
|
|
1621
1674
|
));
|
|
@@ -1629,25 +1682,132 @@ return Bun.serve({
|
|
|
1629
1682
|
// or lied Content-Length header)
|
|
1630
1683
|
const contentLength = req.headers.get("Content-Length");
|
|
1631
1684
|
if (!contentLength) {
|
|
1632
|
-
return
|
|
1685
|
+
return earlyReject(Response.json(
|
|
1633
1686
|
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
1634
1687
|
{ status: 411 },
|
|
1635
1688
|
));
|
|
1636
1689
|
}
|
|
1637
1690
|
const declaredBytes = Number(contentLength);
|
|
1638
1691
|
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
1639
|
-
return
|
|
1692
|
+
return earlyReject(Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
1640
1693
|
}
|
|
1641
1694
|
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
1642
|
-
return
|
|
1695
|
+
return earlyReject(Response.json(
|
|
1643
1696
|
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
1644
1697
|
{ status: 413 },
|
|
1645
1698
|
));
|
|
1646
1699
|
}
|
|
1647
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
|
+
|
|
1648
1808
|
const contentType = req.headers.get("Content-Type") ?? "";
|
|
1649
1809
|
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
1650
|
-
return
|
|
1810
|
+
return earlyReject(Response.json(
|
|
1651
1811
|
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
1652
1812
|
{ status: 415 },
|
|
1653
1813
|
));
|
|
@@ -1719,6 +1879,8 @@ return Bun.serve({
|
|
|
1719
1879
|
size: file.size,
|
|
1720
1880
|
owner: authCtx?.username ?? null,
|
|
1721
1881
|
owner_id: authCtx?.userId ?? null,
|
|
1882
|
+
// Key omitted (not null) when unattributed — see UploadIndexEntry.
|
|
1883
|
+
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
1722
1884
|
uploaded_at: new Date().toISOString(),
|
|
1723
1885
|
}, null, 2));
|
|
1724
1886
|
}
|
|
@@ -1773,10 +1935,15 @@ return Bun.serve({
|
|
|
1773
1935
|
|
|
1774
1936
|
let entry: any;
|
|
1775
1937
|
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
1776
|
-
|
|
1938
|
+
// #509 副指挥 66983a19: corrupted index must not leak "server
|
|
1939
|
+
// failed" signal — 404 with same shape as unknown file_id
|
|
1940
|
+
// (input refused at boundary, no enumeration).
|
|
1941
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1777
1942
|
}
|
|
1778
1943
|
if (!validateIndexEntry(entry)) {
|
|
1779
|
-
|
|
1944
|
+
// #509 副指挥 66983a19: schema-invalid index (bucket "../etc",
|
|
1945
|
+
// missing fields, wrong types) is also 404 — same discipline.
|
|
1946
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1780
1947
|
}
|
|
1781
1948
|
|
|
1782
1949
|
// #495 ownership gate — placed AFTER file-exists so denied
|
|
@@ -1784,15 +1951,33 @@ return Bun.serve({
|
|
|
1784
1951
|
// "no such file". Both branches return the same 404 shape
|
|
1785
1952
|
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
1786
1953
|
// status code and headers are the authoritative signal).
|
|
1787
|
-
if (!authorizeFileDownload(req, entry)) {
|
|
1954
|
+
if (!authorizeFileDownload(resolvePrincipal(req), normalizeEntry(entry))) {
|
|
1955
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// #509 defence-in-depth: pre-check date_bucket against BOTH
|
|
1959
|
+
// shape regex AND real-calendar semantics. A poisoned index (e.g.
|
|
1960
|
+
// "../etc", "2026-02-30", "2026-13-01") must return 404 not 500
|
|
1961
|
+
// — 500 says "server failed"; 404 says "input refused at boundary".
|
|
1962
|
+
// 副指挥 b1082017 / 66983a19 hard doors:
|
|
1963
|
+
// • 稳定 404 非 500 for invalid/missing/poisoned date_bucket
|
|
1964
|
+
// • 真日历语义校验, 不仅 regex 形状
|
|
1965
|
+
if (!isValidCalendarBucket(entry.date_bucket)) {
|
|
1788
1966
|
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1789
1967
|
}
|
|
1790
1968
|
|
|
1791
1969
|
let storage;
|
|
1792
1970
|
try {
|
|
1793
|
-
|
|
1971
|
+
// #509 fix: use the date_bucket recorded in the index at upload
|
|
1972
|
+
// time, NOT today's date. buildStoragePath computes today; that is
|
|
1973
|
+
// correct for new uploads only. Yesterday's file lives in
|
|
1974
|
+
// yesterday's bucket forever — read path must respect that.
|
|
1975
|
+
storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
|
|
1794
1976
|
} catch {
|
|
1795
|
-
|
|
1977
|
+
// Defence-in-depth: unreachable given the pre-check above, but
|
|
1978
|
+
// keep as fallback. Return 404 not 500 to match the pre-check
|
|
1979
|
+
// shape — caller sees the same code for all invalid-bucket cases.
|
|
1980
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1796
1981
|
}
|
|
1797
1982
|
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1798
1983
|
// Defence: should be unreachable since buildStoragePath rejected
|
|
@@ -1822,7 +2007,31 @@ return Bun.serve({
|
|
|
1822
2007
|
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
1823
2008
|
"X-Content-Type-Options": "nosniff",
|
|
1824
2009
|
"Cache-Control": "private, no-store",
|
|
2010
|
+
"Accept-Ranges": "bytes",
|
|
1825
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
|
+
}
|
|
1826
2035
|
// HEAD: return the same status and headers as GET, but no body,
|
|
1827
2036
|
// per RFC 9110 §9.3.2. This ensures HEAD passes through the same
|
|
1828
2037
|
// authorization helper — a cross-owner HEAD is 404 (same as GET),
|