@sleep2agi/commhub-server 0.9.0-preview.144 → 0.9.0-preview.146
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/package.json +1 -1
- package/src/db.ts +15 -0
- package/src/departments-mcp.test.ts +128 -0
- package/src/departments.ts +5 -2
- package/src/mcp-context-cost-http.test.ts +5 -1
- package/src/node-lifecycle-read-http.test.ts +5 -0
- package/src/node-lifecycle-read.ts +3 -0
- package/src/node-permissions.ts +2 -0
- package/src/node-session-control-http.test.ts +308 -0
- package/src/node-session-control.ts +254 -0
- package/src/server.ts +145 -1
- package/src/tool-audience-http.test.ts +5 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools-list-json-schema.test.ts +27 -0
- package/src/tools.ts +91 -0
- package/src/user-avatar-image.ts +153 -0
- package/src/user-avatar-upload-http.test.ts +294 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// #725-adjacent? No: Hub 专用账号头像上传(通信Mac马 交付单)。
|
|
2
|
+
//
|
|
3
|
+
// 头像不是普通附件:它必须 PNG、边长 ≤512、≤8 MiB,且只能由本人用自己的
|
|
4
|
+
// **用户令牌**写入;读取按「人类资料可见性」(本人或共享网络的合法成员)判定,
|
|
5
|
+
// 不借用 /api/files 的附件 ACL(那是按网络/会话/DM 作用域判的,语义不同)。
|
|
6
|
+
//
|
|
7
|
+
// 这个模块只做纯校验,便于单测与复用;HTTP 与落库在 server.ts。
|
|
8
|
+
import { inflateSync } from "node:zlib";
|
|
9
|
+
|
|
10
|
+
// 校验强度:结构(签名/chunk/CRC)+ 真实解码(IDAT 解压且扫描行长度与 IHDR 相符)。
|
|
11
|
+
|
|
12
|
+
export const MAX_AVATAR_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
/** 客户端会把图重编码到最长边 512;服务端独立复核这条上限。 */
|
|
14
|
+
export const MAX_AVATAR_EDGE = 512;
|
|
15
|
+
/** version 只允许 URL 安全字符,长度有界(避免拼路径时引入 `.`、`/`)。 */
|
|
16
|
+
export const AVATAR_VERSION_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
export const AVATAR_FIELD = "file";
|
|
18
|
+
|
|
19
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
20
|
+
|
|
21
|
+
export type AvatarImageCheck =
|
|
22
|
+
| { ok: true; width: number; height: number }
|
|
23
|
+
| { ok: false; reason: "empty" | "too_large" | "not_png" | "truncated" | "bad_dimensions" | "too_big" | "bad_crc" | "bad_image" | "interlaced_unsupported" };
|
|
24
|
+
|
|
25
|
+
const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/** 标准 CRC-32(PNG 每个 chunk 的尾部)。表只建一次。 */
|
|
29
|
+
const CRC_TABLE = (() => {
|
|
30
|
+
const table = new Uint32Array(256);
|
|
31
|
+
for (let n = 0; n < 256; n++) {
|
|
32
|
+
let c = n;
|
|
33
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
34
|
+
table[n] = c >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
function crc32(bytes: Uint8Array): number {
|
|
40
|
+
let c = 0xffffffff;
|
|
41
|
+
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
42
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function be32(bytes: Uint8Array, offset: number): number {
|
|
46
|
+
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PNG 结构校验:签名 + 逐个 chunk 的 CRC + 必须有 IDAT 且以 IEND 结尾。
|
|
51
|
+
*
|
|
52
|
+
* 只信任「结构完整」而不是「声明了什么」:截断的 33/40 字节、零填充的伪图
|
|
53
|
+
* (IHDR 对但 CRC 与 IDAT 缺失)都会被拒;合法用户因此不能把已有头像
|
|
54
|
+
* 覆盖成一张显示不出来的图。
|
|
55
|
+
*/
|
|
56
|
+
export function inspectAvatarImage(input: Uint8Array): AvatarImageCheck {
|
|
57
|
+
if (input.byteLength === 0) return { ok: false, reason: "empty" };
|
|
58
|
+
if (input.byteLength > MAX_AVATAR_BYTES) return { ok: false, reason: "too_large" };
|
|
59
|
+
for (let i = 0; i < PNG_SIGNATURE.length && i < input.byteLength; i++) {
|
|
60
|
+
if (input[i] !== PNG_SIGNATURE[i]) return { ok: false, reason: "not_png" };
|
|
61
|
+
}
|
|
62
|
+
if (input.byteLength < 45) return { ok: false, reason: "truncated" }; // 签名 + IHDR(25) + 最小 IEND(12)
|
|
63
|
+
|
|
64
|
+
let pos = 8;
|
|
65
|
+
let width = 0;
|
|
66
|
+
let height = 0;
|
|
67
|
+
let sawIHDR = false;
|
|
68
|
+
let idatChunks = 0;
|
|
69
|
+
let sawIEND = false;
|
|
70
|
+
let sawPLTE = false;
|
|
71
|
+
let bitDepth = 0;
|
|
72
|
+
let colorType = 0;
|
|
73
|
+
const idat: Uint8Array[] = [];
|
|
74
|
+
|
|
75
|
+
while (pos + 12 <= input.byteLength) {
|
|
76
|
+
const length = be32(input, pos);
|
|
77
|
+
const type = String.fromCharCode(input[pos + 4], input[pos + 5], input[pos + 6], input[pos + 7]);
|
|
78
|
+
const dataStart = pos + 8;
|
|
79
|
+
const dataEnd = dataStart + length;
|
|
80
|
+
const crcEnd = dataEnd + 4;
|
|
81
|
+
if (crcEnd > input.byteLength) return { ok: false, reason: "truncated" };
|
|
82
|
+
if (!sawIHDR && type !== "IHDR") return { ok: false, reason: "not_png" };
|
|
83
|
+
if ((crc32(input.subarray(pos + 4, dataEnd)) >>> 0) !== be32(input, dataEnd)) {
|
|
84
|
+
return { ok: false, reason: "bad_crc" };
|
|
85
|
+
}
|
|
86
|
+
if (type === "IHDR") {
|
|
87
|
+
if (sawIHDR || length !== 13) return { ok: false, reason: "not_png" };
|
|
88
|
+
width = be32(input, dataStart);
|
|
89
|
+
height = be32(input, dataStart + 4);
|
|
90
|
+
bitDepth = input[dataStart + 8];
|
|
91
|
+
colorType = input[dataStart + 9];
|
|
92
|
+
const compression = input[dataStart + 10];
|
|
93
|
+
const filter = input[dataStart + 11];
|
|
94
|
+
const interlace = input[dataStart + 12];
|
|
95
|
+
// 只接受规范的 IHDR 取值组合;非法组合的图多数解码器也会拒绝。
|
|
96
|
+
const legalDepth: Record<number, number[]> = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
|
|
97
|
+
if (!legalDepth[colorType]?.includes(bitDepth)) return { ok: false, reason: "not_png" };
|
|
98
|
+
if (compression !== 0 || filter !== 0) return { ok: false, reason: "not_png" };
|
|
99
|
+
// 本实现只校验逐行(interlace=0)扫描布局:Adam7(=1) 的布局不同,其余取值(2..255)
|
|
100
|
+
// 在 PNG 规范里根本不合法。一律明确拒收,而不是错算长度后放行。
|
|
101
|
+
if (interlace !== 0) return { ok: false, reason: "interlaced_unsupported" };
|
|
102
|
+
if (width === 0 || height === 0) return { ok: false, reason: "bad_dimensions" };
|
|
103
|
+
if (width > MAX_AVATAR_EDGE || height > MAX_AVATAR_EDGE) return { ok: false, reason: "too_big" };
|
|
104
|
+
sawIHDR = true;
|
|
105
|
+
} else if (type === "PLTE") {
|
|
106
|
+
if (length === 0 || length % 3 !== 0) return { ok: false, reason: "not_png" };
|
|
107
|
+
sawPLTE = true;
|
|
108
|
+
} else if (type === "IDAT") {
|
|
109
|
+
idatChunks++;
|
|
110
|
+
idat.push(input.subarray(dataStart, dataEnd));
|
|
111
|
+
} else if (type === "IEND") {
|
|
112
|
+
if (length !== 0) return { ok: false, reason: "not_png" };
|
|
113
|
+
if (crcEnd !== input.byteLength) return { ok: false, reason: "not_png" };
|
|
114
|
+
sawIEND = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
pos = crcEnd;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!sawIHDR || !sawIEND) return { ok: false, reason: "truncated" };
|
|
121
|
+
if (idatChunks === 0) return { ok: false, reason: "not_png" };
|
|
122
|
+
// 调色板图必须带 PLTE,否则解码器无从取色。
|
|
123
|
+
if (colorType === 3 && !sawPLTE) return { ok: false, reason: "not_png" };
|
|
124
|
+
// CRC 只证明「容器没坏」,不证明 IDAT 里是图像数据:真解一次 zlib,并核对
|
|
125
|
+
// 解压后长度正好等于 IHDR 声明的扫描行总长(每行 1 字节 filter + 像素字节)。
|
|
126
|
+
const bytesPerRow = Math.ceil((width * (CHANNELS[colorType] ?? 0) * bitDepth) / 8);
|
|
127
|
+
const expected = height * (1 + bytesPerRow);
|
|
128
|
+
let raw: Uint8Array;
|
|
129
|
+
try {
|
|
130
|
+
const merged = Buffer.concat(idat.map(part => Buffer.from(part)));
|
|
131
|
+
raw = new Uint8Array(inflateSync(merged, { maxOutputLength: expected + 1024 }));
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, reason: "bad_image" };
|
|
134
|
+
}
|
|
135
|
+
if (raw.byteLength !== expected) return { ok: false, reason: "bad_image" };
|
|
136
|
+
for (let row = 0; row < height; row++) {
|
|
137
|
+
if (raw[row * (1 + bytesPerRow)] > 4) return { ok: false, reason: "bad_image" }; // 过滤器 0..4
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, width, height };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isValidAvatarVersion(value: unknown): value is string {
|
|
143
|
+
return typeof value === "string" && AVATAR_VERSION_RE.test(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function avatarUrl(userId: string, version: string): string {
|
|
147
|
+
return `/api/users/${encodeURIComponent(userId)}/avatar/${version}.png`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 只接受 multipart 里的单张图;多余字段一律拒绝,避免「顺手塞个 filename 覆盖路径」。 */
|
|
151
|
+
export function isMultipart(contentType: string | null): boolean {
|
|
152
|
+
return !!contentType && /^multipart\/form-data\b/i.test(contentType);
|
|
153
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { register, createNetworkTokenForNode, addNetworkMember } from "./auth.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { inspectAvatarImage, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
|
|
5
|
+
import { deflateSync } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
// 1×1 PNG(透明)。只用来走真实 multipart → 真实文件头校验。
|
|
8
|
+
const PNG_1X1 = Buffer.from(
|
|
9
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
10
|
+
"base64",
|
|
11
|
+
);
|
|
12
|
+
/** 真的 8-bit RGBA PNG:IHDR + 一段 zlib 压缩的扫描行 + IEND,CRC 全对。 */
|
|
13
|
+
function realPng(width: number, height: number): Buffer {
|
|
14
|
+
const crcTable = new Uint32Array(256).map((_, n) => {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
17
|
+
return c >>> 0;
|
|
18
|
+
});
|
|
19
|
+
const crc = (buf: Buffer) => {
|
|
20
|
+
let c = 0xffffffff;
|
|
21
|
+
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
22
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
23
|
+
};
|
|
24
|
+
const chunk = (type: string, data: Buffer) => {
|
|
25
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
26
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
27
|
+
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body));
|
|
28
|
+
return Buffer.concat([len, body, sum]);
|
|
29
|
+
};
|
|
30
|
+
const ihdr = Buffer.alloc(13);
|
|
31
|
+
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
32
|
+
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // 8-bit RGBA
|
|
33
|
+
const raw = Buffer.alloc((1 + width * 4) * height); // 每行前面一个 filter 字节 0
|
|
34
|
+
return Buffer.concat([
|
|
35
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
36
|
+
chunk("IHDR", ihdr),
|
|
37
|
+
chunk("IDAT", deflateSync(raw)),
|
|
38
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
/** CRC 全对、但 IDAT 内容非法的 PNG:用来证明「结构通过 ≠ 图像有效」。 */
|
|
44
|
+
function pngWithIdat(idat: Buffer, opts: { colorType?: number; bitDepth?: number; width?: number; height?: number; interlace?: number } = {}): Buffer {
|
|
45
|
+
const crcTable = new Uint32Array(256).map((_, n) => {
|
|
46
|
+
let c = n;
|
|
47
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
48
|
+
return c >>> 0;
|
|
49
|
+
});
|
|
50
|
+
const crc = (buf: Buffer) => {
|
|
51
|
+
let c = 0xffffffff;
|
|
52
|
+
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
53
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
54
|
+
};
|
|
55
|
+
const chunk = (type: string, data: Buffer) => {
|
|
56
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
57
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
58
|
+
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body));
|
|
59
|
+
return Buffer.concat([len, body, sum]);
|
|
60
|
+
};
|
|
61
|
+
const ihdr = Buffer.alloc(13);
|
|
62
|
+
ihdr.writeUInt32BE(opts.width ?? 1, 0); ihdr.writeUInt32BE(opts.height ?? 1, 4);
|
|
63
|
+
ihdr[8] = opts.bitDepth ?? 8; ihdr[9] = opts.colorType ?? 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = opts.interlace ?? 0;
|
|
64
|
+
return Buffer.concat([
|
|
65
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
66
|
+
chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0)),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("account avatar upload: user token only, PNG limits, visibility by shared network, revoke, atomic replace", async () => {
|
|
71
|
+
const a = register("avup_alice", "AvatarUpload1!");
|
|
72
|
+
const b = register("avup_bob", "AvatarUpload1!");
|
|
73
|
+
const outsider = register("avup_outsider", "AvatarUpload1!");
|
|
74
|
+
expect(a.ok && b.ok && outsider.ok).toBe(true);
|
|
75
|
+
expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
|
|
76
|
+
const node = createNetworkTokenForNode(a.user!.user_id, a.network_id!, "avup_alice", "avup_node");
|
|
77
|
+
expect(node.ok).toBe(true);
|
|
78
|
+
|
|
79
|
+
const { bootServer } = await import("./server.js");
|
|
80
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
81
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
82
|
+
|
|
83
|
+
const upload = async (token: string | null, bytes: Buffer, fieldName = "file", extra?: [string, string]) => {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: "image/png" }), "a.png");
|
|
86
|
+
if (extra) form.append(extra[0], extra[1]);
|
|
87
|
+
const headers: Record<string, string> = {};
|
|
88
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
89
|
+
const res = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers, body: form });
|
|
90
|
+
const body = await res.json().catch(() => null);
|
|
91
|
+
return { status: res.status, body: body as any };
|
|
92
|
+
};
|
|
93
|
+
const getAvatar = async (token: string | null, path: string) => {
|
|
94
|
+
const res = await fetch(base + path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
95
|
+
return { status: res.status, headers: res.headers, bytes: new Uint8Array(await res.arrayBuffer()) };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// 前置:陌生人之间没有共享网络。
|
|
100
|
+
expect(db.get("SELECT 1 AS ok FROM network_members m WHERE m.user_id=?1 AND m.network_id=?2", outsider.user!.user_id, a.network_id!)).toBeNull();
|
|
101
|
+
|
|
102
|
+
// 匿名/节点令牌都不是「本人」。
|
|
103
|
+
expect((await upload(null, PNG_1X1)).status).toBe(401);
|
|
104
|
+
expect((await upload(node.token!, PNG_1X1)).status).toBe(403);
|
|
105
|
+
|
|
106
|
+
// 畸形输入:非 PNG、超边长、空、多字段、非 multipart。
|
|
107
|
+
expect((await upload(a.token!, Buffer.from("not a png"))).body.error).toBe("invalid_image");
|
|
108
|
+
expect((await upload(a.token!, realPng(600, 600))).body.reason).toBe("too_big");
|
|
109
|
+
expect((await upload(a.token!, Buffer.alloc(0))).status).toBe(400);
|
|
110
|
+
expect((await upload(a.token!, PNG_1X1, "other")).status).toBe(400);
|
|
111
|
+
expect((await upload(a.token!, PNG_1X1, "file", ["avatar_url", "/etc/passwd"])).status).toBe(400);
|
|
112
|
+
const notMultipart = await fetch(`${base}/api/auth/me/avatar/upload`, {
|
|
113
|
+
method: "POST", headers: { Authorization: `Bearer ${a.token}`, "Content-Type": "application/json" }, body: "{}",
|
|
114
|
+
});
|
|
115
|
+
expect(notMultipart.status).toBe(415);
|
|
116
|
+
|
|
117
|
+
// 成功:URL 由 Hub 发,version 只含 URL 安全字符。
|
|
118
|
+
const ok1 = await upload(a.token!, PNG_1X1);
|
|
119
|
+
expect(ok1.status).toBe(200);
|
|
120
|
+
expect(ok1.body.ok).toBe(true);
|
|
121
|
+
expect(ok1.body.user_id).toBe(a.user!.user_id);
|
|
122
|
+
expect(ok1.body.avatar_url).toMatch(new RegExp(`^/api/users/${a.user!.user_id}/avatar/[A-Za-z0-9_-]{1,128}\\.png$`));
|
|
123
|
+
|
|
124
|
+
// 资料读取面必须同步看到这张头像(同一事务写 users.avatar_url)。
|
|
125
|
+
const meAfter = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${a.token}` } });
|
|
126
|
+
const meBody = await meAfter.json() as any;
|
|
127
|
+
expect(meBody.user.avatar_url).toBe(ok1.body.avatar_url);
|
|
128
|
+
|
|
129
|
+
const first = await getAvatar(a.token!, ok1.body.avatar_url);
|
|
130
|
+
expect(first.status).toBe(200);
|
|
131
|
+
expect(first.headers.get("content-type")).toBe("image/png");
|
|
132
|
+
expect(first.headers.get("cache-control")).toBe("private, no-store");
|
|
133
|
+
expect(first.headers.get("location")).toBeNull();
|
|
134
|
+
expect(first.bytes.byteLength).toBe(PNG_1X1.byteLength);
|
|
135
|
+
|
|
136
|
+
// 同网络成员可读;不同网络的人读不到(与不存在同码,避免探测)。
|
|
137
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(200);
|
|
138
|
+
expect((await getAvatar(outsider.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
139
|
+
expect((await getAvatar(null, ok1.body.avatar_url)).status).toBe(401);
|
|
140
|
+
expect((await getAvatar(node.token!, ok1.body.avatar_url)).status).toBe(403);
|
|
141
|
+
expect((await getAvatar(a.token!, `/api/users/${a.user!.user_id}/avatar/nope..%2F.png`)).status).toBe(404);
|
|
142
|
+
|
|
143
|
+
// 撤权后立刻读不到。
|
|
144
|
+
expect(db.run("DELETE FROM network_members WHERE user_id=?1 AND network_id=?2", [b.user!.user_id, a.network_id!]).changes).toBeGreaterThan(0);
|
|
145
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
146
|
+
|
|
147
|
+
// 「CRC 正确但图像数据非法」也必须 400:结构通过不等于可解码。
|
|
148
|
+
const beforeInvalid = db.get<{ version: string; bytes: number }>(
|
|
149
|
+
"SELECT version, bytes FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id);
|
|
150
|
+
expect((await upload(a.token!, pngWithIdat(Buffer.alloc(0)))).body.reason).toBe("bad_image");
|
|
151
|
+
expect((await upload(a.token!, pngWithIdat(Buffer.from("not-a-zlib-stream")))).body.reason).toBe("bad_image");
|
|
152
|
+
// 解压成功但扫描行长度不符(声明 2×2 RGBA,实际只给 1×1)
|
|
153
|
+
expect((await upload(a.token!, pngWithIdat(deflateSync(Buffer.alloc(5)), { width: 2, height: 2 }))).body.reason).toBe("bad_image");
|
|
154
|
+
const afterInvalid = db.get<{ version: string; bytes: number }>(
|
|
155
|
+
"SELECT version, bytes FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id);
|
|
156
|
+
expect(afterInvalid).toEqual(beforeInvalid);
|
|
157
|
+
|
|
158
|
+
// P2:清空后旧托管 URL 立即失效(本人与同网络成员都不能再读到)。
|
|
159
|
+
const putProfile = async (token: string, avatar_url: string | null) => {
|
|
160
|
+
const res = await fetch(`${base}/api/auth/me/avatar`, {
|
|
161
|
+
method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ avatar_url }),
|
|
163
|
+
});
|
|
164
|
+
return { status: res.status, body: await res.json() as any };
|
|
165
|
+
};
|
|
166
|
+
// 重新造一张可读的:此刻 b 已被撤权,先用 outsider 之外的同网成员做读校验
|
|
167
|
+
expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
|
|
168
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(200);
|
|
169
|
+
expect((await putProfile(a.token!, null)).status).toBe(200);
|
|
170
|
+
expect((await getAvatar(a.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
171
|
+
expect((await getAvatar(b.token!, ok1.body.avatar_url)).status).toBe(404);
|
|
172
|
+
const meCleared = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${a.token}` } });
|
|
173
|
+
expect(((await meCleared.json()) as any).user.avatar_url).toBeNull();
|
|
174
|
+
expect(db.get("SELECT 1 AS ok FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id])).toBeNull();
|
|
175
|
+
|
|
176
|
+
// 换成池里的一张(非托管 URL)也必须撤销旧托管行。
|
|
177
|
+
const again = await upload(a.token!, PNG_1X1);
|
|
178
|
+
expect(again.status).toBe(200);
|
|
179
|
+
expect((await getAvatar(a.token!, again.body.avatar_url)).status).toBe(200);
|
|
180
|
+
expect((await putProfile(a.token!, "/avatars/avatar-03.webp")).status).toBe(200);
|
|
181
|
+
expect((await getAvatar(a.token!, again.body.avatar_url)).status).toBe(404);
|
|
182
|
+
expect(db.get("SELECT 1 AS ok FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id])).toBeNull();
|
|
183
|
+
|
|
184
|
+
// 原子替换:一次失败的上传不能把上一张弄丢;成功后旧 version 立刻 404。
|
|
185
|
+
const baseline = await upload(a.token!, PNG_1X1);
|
|
186
|
+
expect(baseline.status).toBe(200);
|
|
187
|
+
const beforeBad = db.get<{ version: string }>("SELECT version FROM user_avatar_uploads WHERE user_id=?1", [a.user!.user_id]);
|
|
188
|
+
expect(beforeBad?.version).toBeTruthy();
|
|
189
|
+
expect((await upload(a.token!, Buffer.from("broken"))).status).toBe(400);
|
|
190
|
+
expect(db.get<{ version: string }>("SELECT version FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id)!.version).toBe(beforeBad!.version);
|
|
191
|
+
expect((await getAvatar(a.token!, baseline.body.avatar_url)).status).toBe(200);
|
|
192
|
+
|
|
193
|
+
const ok2 = await upload(a.token!, realPng(512, 512));
|
|
194
|
+
expect(ok2.status).toBe(200);
|
|
195
|
+
expect(ok2.body.avatar_url).not.toBe(baseline.body.avatar_url);
|
|
196
|
+
expect((await getAvatar(a.token!, baseline.body.avatar_url)).status).toBe(404);
|
|
197
|
+
expect((await getAvatar(a.token!, ok2.body.avatar_url)).status).toBe(200);
|
|
198
|
+
expect(db.get<{ width: number }>("SELECT width FROM user_avatar_uploads WHERE user_id=?1", a.user!.user_id)!.width).toBe(512);
|
|
199
|
+
} finally {
|
|
200
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id IN (?1, ?2, ?3)", [a.user!.user_id, b.user!.user_id, outsider.user!.user_id]);
|
|
201
|
+
await hub.stop?.();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("deleted network leaves only ghost membership: member loses read, owner keeps it", async () => {
|
|
206
|
+
const owner = register("avup_netowner", "AvatarUpload1!");
|
|
207
|
+
const member = register("avup_netmember", "AvatarUpload1!");
|
|
208
|
+
expect(owner.ok && member.ok).toBe(true);
|
|
209
|
+
expect(addNetworkMember(owner.network_id!, member.user!.user_id, "member").ok).toBe(true);
|
|
210
|
+
const { bootServer } = await import("./server.js");
|
|
211
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
212
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
213
|
+
const read = async (token: string, path: string) => (await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } })).status;
|
|
214
|
+
try {
|
|
215
|
+
const form = new FormData();
|
|
216
|
+
form.append("file", new Blob([new Uint8Array(PNG_1X1)], { type: "image/png" }), "a.png");
|
|
217
|
+
const up = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers: { Authorization: `Bearer ${owner.token}` }, body: form });
|
|
218
|
+
expect(up.status).toBe(200);
|
|
219
|
+
const url = ((await up.json()) as any).avatar_url as string;
|
|
220
|
+
expect(await read(member.token!, url)).toBe(200);
|
|
221
|
+
// 只删网络行,故意留下 network_members 残行:live membership 必须据此拒绝。
|
|
222
|
+
db.run("DELETE FROM networks WHERE network_id = ?1", [owner.network_id!]);
|
|
223
|
+
expect(await read(member.token!, url)).toBe(404);
|
|
224
|
+
expect(await read(owner.token!, url)).toBe(200); // 本人不依赖成员关系
|
|
225
|
+
} finally {
|
|
226
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [owner.user!.user_id]);
|
|
227
|
+
db.run("UPDATE users SET avatar_url = NULL WHERE user_id = ?1", [owner.user!.user_id]);
|
|
228
|
+
await hub.stop?.();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("upload is rate limited before multipart parsing, and rejects keepalive leftovers", async () => {
|
|
233
|
+
const u = register("avup_ratelimit", "AvatarUpload1!");
|
|
234
|
+
expect(u.ok).toBe(true);
|
|
235
|
+
const { bootServer } = await import("./server.js");
|
|
236
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
237
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
238
|
+
const send = async (body: BodyInit | null, contentType?: string) => {
|
|
239
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${u.token}` };
|
|
240
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
241
|
+
const res = await fetch(`${base}/api/auth/me/avatar/upload`, { method: "POST", headers, body });
|
|
242
|
+
return { status: res.status, connection: res.headers.get("connection"), body: await res.json().catch(() => null) as any };
|
|
243
|
+
};
|
|
244
|
+
const multipart = () => {
|
|
245
|
+
const form = new FormData();
|
|
246
|
+
form.append("file", new Blob([PNG_1X1], { type: "image/png" }), "a.png");
|
|
247
|
+
return form;
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
// 60 次成功用满这一小时的桶(每张 1×1,成本可忽略)。
|
|
251
|
+
for (let i = 0; i < 60; i++) {
|
|
252
|
+
const r = await send(multipart());
|
|
253
|
+
expect(r.status).toBe(200);
|
|
254
|
+
}
|
|
255
|
+
const limited = await send(multipart());
|
|
256
|
+
expect(limited.status).toBe(429);
|
|
257
|
+
expect(limited.body.error).toBe("rate_limited");
|
|
258
|
+
expect(limited.connection).toBe("close");
|
|
259
|
+
// 关键:限流在 multipart 解析之前——普通 JSON 也被先拒成 429,而不是 415。
|
|
260
|
+
const early = await send("{}", "application/json");
|
|
261
|
+
expect(early.status).toBe(429);
|
|
262
|
+
expect(early.connection).toBe("close");
|
|
263
|
+
} finally {
|
|
264
|
+
db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [u.user!.user_id]);
|
|
265
|
+
db.run("UPDATE users SET avatar_url = NULL WHERE user_id = ?1", [u.user!.user_id]);
|
|
266
|
+
await hub.stop?.();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("image inspector: signature, shape and size limits", () => {
|
|
271
|
+
expect(inspectAvatarImage(PNG_1X1)).toEqual({ ok: true, width: 1, height: 1 });
|
|
272
|
+
expect(inspectAvatarImage(new Uint8Array(0))).toEqual({ ok: false, reason: "empty" });
|
|
273
|
+
expect(inspectAvatarImage(new Uint8Array(10))).toEqual({ ok: false, reason: "not_png" });
|
|
274
|
+
expect(inspectAvatarImage(Buffer.from(realPng(16, 16).subarray(0, 20)))).toEqual({ ok: false, reason: "truncated" });
|
|
275
|
+
expect(inspectAvatarImage(Buffer.from(realPng(16, 16).subarray(0, 40)))).toEqual({ ok: false, reason: "truncated" });
|
|
276
|
+
const truePng = realPng(16, 16);
|
|
277
|
+
const corrupt = Buffer.from(truePng); corrupt[corrupt.length - 20] ^= 0xff;
|
|
278
|
+
expect(inspectAvatarImage(corrupt).ok).toBe(false);
|
|
279
|
+
expect(inspectAvatarImage(realPng(513, 16))).toEqual({ ok: false, reason: "too_big" });
|
|
280
|
+
// Adam7 隔行:明确拒收(不按非隔行长度错算)
|
|
281
|
+
const interlaced = pngWithIdat(deflateSync(Buffer.alloc(6)), { width: 1, height: 1, interlace: 1 });
|
|
282
|
+
expect(inspectAvatarImage(interlaced)).toEqual({ ok: false, reason: "interlaced_unsupported" });
|
|
283
|
+
// 规范外取值 2..255:同样拒收(负控:扫描数据本来是非隔行的合法布局,
|
|
284
|
+
// 只有 interlace 字节非法,证明拦截来自 interlace 判断而不是解码失败)
|
|
285
|
+
for (const bad of [2, 3, 128, 255]) {
|
|
286
|
+
const invalidInterlace = pngWithIdat(deflateSync(Buffer.alloc(6)), { width: 1, height: 1, interlace: bad });
|
|
287
|
+
expect(inspectAvatarImage(invalidInterlace)).toEqual({ ok: false, reason: "interlaced_unsupported" });
|
|
288
|
+
}
|
|
289
|
+
const zeroWide = realPng(0, 1); // 宽 0 + 正确 CRC:专证 bad_dimensions 而不是 bad_crc
|
|
290
|
+
expect(inspectAvatarImage(zeroWide)).toEqual({ ok: false, reason: "bad_dimensions" });
|
|
291
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(40).fill(0)]);
|
|
292
|
+
expect(inspectAvatarImage(jpeg)).toEqual({ ok: false, reason: "not_png" });
|
|
293
|
+
expect(inspectAvatarImage(Buffer.alloc(MAX_AVATAR_BYTES + 1))).toEqual({ ok: false, reason: "too_large" });
|
|
294
|
+
});
|