@sleep2agi/commhub-server 0.8.5 → 0.8.6-preview.1
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 +3 -2
- package/src/auth_login_guard.test.ts +130 -0
- package/src/auth_login_guard.ts +142 -0
- package/src/db.ts +189 -4
- package/src/event_bus.ts +10 -0
- package/src/index.ts +354 -44
- package/src/push.test.ts +47 -0
- package/src/push.ts +28 -0
- package/src/rename.ts +39 -14
- package/src/tools.ts +60 -34
- package/src/uploads-http.test.ts +208 -0
- package/src/uploads.test.ts +294 -0
- package/src/uploads.ts +240 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// #221 — unit tests for the upload primitives. The HTTP wire-up + Bun
|
|
2
|
+
// multipart parsing lives in server/src/index.ts; this file pins the
|
|
3
|
+
// path / regex / rate-limit / attachment-schema invariants that the
|
|
4
|
+
// 6-item security checklist depends on.
|
|
5
|
+
//
|
|
6
|
+
// Coverage matrix:
|
|
7
|
+
// 1. file_id regex accepts server-generated ids + rejects traversal attempts
|
|
8
|
+
// 2. sanitizeExt strips client-side filename paths, lowercases ext
|
|
9
|
+
// 3. buildStoragePath rejects malformed id / ext, places file under date bucket
|
|
10
|
+
// 4. isPathInsideUploadsRoot defends against symlink / traversal escape
|
|
11
|
+
// 5. UploadRateLimiter — first call allowed, exhausting bucket blocks, window expiry resets
|
|
12
|
+
// 6. validateAttachments accepts MCP-compatible shape, rejects too-many / bad type / bad file_id
|
|
13
|
+
// 7. validateIndexEntry refuses tampered shapes
|
|
14
|
+
// 8. indexEntryPath rejects malformed file_id
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
FILE_ID_REGEX,
|
|
18
|
+
MAX_UPLOAD_BYTES,
|
|
19
|
+
UploadRateLimiter,
|
|
20
|
+
UPLOAD_RATE_MAX_PER_WINDOW,
|
|
21
|
+
UPLOAD_RATE_WINDOW_MS,
|
|
22
|
+
buildStoragePath,
|
|
23
|
+
generateFileId,
|
|
24
|
+
getDateBucket,
|
|
25
|
+
indexEntryPath,
|
|
26
|
+
isPathInsideUploadsRoot,
|
|
27
|
+
sanitizeExt,
|
|
28
|
+
validateAttachments,
|
|
29
|
+
validateIndexEntry,
|
|
30
|
+
} from "./uploads";
|
|
31
|
+
|
|
32
|
+
describe("FILE_ID_REGEX", () => {
|
|
33
|
+
test("accepts the server-generated uuidv4-without-hyphens shape (32 hex chars)", () => {
|
|
34
|
+
for (let i = 0; i < 20; i++) {
|
|
35
|
+
const id = generateFileId();
|
|
36
|
+
expect(FILE_ID_REGEX.test(id)).toBe(true);
|
|
37
|
+
expect(id.length).toBe(32);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects every directory-traversal attempt a client might try", () => {
|
|
42
|
+
const traversals = [
|
|
43
|
+
"../foo",
|
|
44
|
+
"..%2Ffoo",
|
|
45
|
+
"foo/bar",
|
|
46
|
+
"foo\\bar",
|
|
47
|
+
"foo.bar",
|
|
48
|
+
".bashrc",
|
|
49
|
+
"/etc/passwd",
|
|
50
|
+
"foo bar",
|
|
51
|
+
"",
|
|
52
|
+
"abc", // too short
|
|
53
|
+
"x".repeat(65), // too long
|
|
54
|
+
];
|
|
55
|
+
for (const t of traversals) {
|
|
56
|
+
expect(FILE_ID_REGEX.test(t)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("sanitizeExt", () => {
|
|
62
|
+
test("returns a leading-dot lowercase extension for a normal filename", () => {
|
|
63
|
+
expect(sanitizeExt("photo.PNG")).toBe(".png");
|
|
64
|
+
expect(sanitizeExt("report.pdf")).toBe(".pdf");
|
|
65
|
+
expect(sanitizeExt("archive.tar.gz")).toBe(".gz");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("strips any client-supplied directory prefix before reading the ext", () => {
|
|
69
|
+
// The handler must NEVER use the client filename for storage paths,
|
|
70
|
+
// but the ext is allowed through for nicer URLs. We still strip any
|
|
71
|
+
// path separator the client tries to sneak in.
|
|
72
|
+
expect(sanitizeExt("../../etc/passwd.jpg")).toBe(".jpg");
|
|
73
|
+
expect(sanitizeExt("..\\..\\windows\\boot.ini")).toBe(".ini");
|
|
74
|
+
expect(sanitizeExt("foo/bar/baz.html")).toBe(".html");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns empty string for missing / weird inputs", () => {
|
|
78
|
+
expect(sanitizeExt(undefined)).toBe("");
|
|
79
|
+
expect(sanitizeExt(null)).toBe("");
|
|
80
|
+
expect(sanitizeExt("")).toBe("");
|
|
81
|
+
expect(sanitizeExt("noext")).toBe("");
|
|
82
|
+
// ".bashrc" → ".bashrc" is acceptable: storage uses the server-generated
|
|
83
|
+
// file_id, so the file lands as "<file_id>.bashrc". The download handler
|
|
84
|
+
// forces Content-Disposition: attachment + X-Content-Type-Options: nosniff,
|
|
85
|
+
// so the ext can't be leveraged to execute / mis-render the file.
|
|
86
|
+
expect(sanitizeExt(".bashrc")).toBe(".bashrc");
|
|
87
|
+
// ext > 16 chars is rejected; "extensiontoolong1" is 17 chars.
|
|
88
|
+
expect(sanitizeExt("foo.extensiontoolong1")).toBe("");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects exts with non-alphanumeric characters", () => {
|
|
92
|
+
expect(sanitizeExt("payload.exe;.png")).toBe(".png");
|
|
93
|
+
expect(sanitizeExt("foo.<script>")).toBe("");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("buildStoragePath", () => {
|
|
98
|
+
test("places the file under a YYYY-MM-DD bucket with file_id+ext as the name", () => {
|
|
99
|
+
const now = new Date("2026-06-11T03:00:00Z");
|
|
100
|
+
const r = buildStoragePath("abcdef1234567890abcdef1234567890", ".png", {
|
|
101
|
+
uploadsRoot: "/srv/uploads",
|
|
102
|
+
now,
|
|
103
|
+
});
|
|
104
|
+
expect(r.dateBucket).toBe("2026-06-11");
|
|
105
|
+
expect(r.relativePath).toBe("2026-06-11/abcdef1234567890abcdef1234567890.png");
|
|
106
|
+
expect(r.absolutePath).toBe("/srv/uploads/2026-06-11/abcdef1234567890abcdef1234567890.png");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects malformed file_id", () => {
|
|
110
|
+
expect(() => buildStoragePath("../etc/passwd", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
111
|
+
expect(() => buildStoragePath("ok", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects malformed ext", () => {
|
|
115
|
+
expect(() => buildStoragePath(generateFileId(), "../passwd", { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
116
|
+
expect(() => buildStoragePath(generateFileId(), ".x".repeat(40), { uploadsRoot: "/srv" })).toThrow(/invalid/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("allows empty ext", () => {
|
|
120
|
+
const r = buildStoragePath(generateFileId(), "", { uploadsRoot: "/srv" });
|
|
121
|
+
expect(r.relativePath.endsWith("/")).toBe(false);
|
|
122
|
+
expect(r.relativePath.includes(".")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("getDateBucket", () => {
|
|
127
|
+
test("uses UTC so the bucket is consistent across hub timezones", () => {
|
|
128
|
+
expect(getDateBucket(new Date("2026-06-11T23:59:59Z"))).toBe("2026-06-11");
|
|
129
|
+
expect(getDateBucket(new Date("2026-06-12T00:00:01Z"))).toBe("2026-06-12");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("isPathInsideUploadsRoot", () => {
|
|
134
|
+
test("true for a normal nested path", () => {
|
|
135
|
+
expect(isPathInsideUploadsRoot("/srv/uploads/2026-06-11/abc.png", "/srv/uploads")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("false for an upward traversal that lands outside the root", () => {
|
|
139
|
+
expect(isPathInsideUploadsRoot("/srv/uploads/../etc/passwd", "/srv/uploads")).toBe(false);
|
|
140
|
+
expect(isPathInsideUploadsRoot("/etc/passwd", "/srv/uploads")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("false for the root itself (must be a file underneath, not the root dir)", () => {
|
|
144
|
+
expect(isPathInsideUploadsRoot("/srv/uploads", "/srv/uploads")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("UploadRateLimiter", () => {
|
|
149
|
+
test("first N calls within window are allowed; the (N+1)th is blocked", () => {
|
|
150
|
+
const lim = new UploadRateLimiter(60_000, 3); // 3 per minute
|
|
151
|
+
const t0 = 1_700_000_000_000;
|
|
152
|
+
const a1 = lim.check("token-x", t0);
|
|
153
|
+
const a2 = lim.check("token-x", t0 + 100);
|
|
154
|
+
const a3 = lim.check("token-x", t0 + 200);
|
|
155
|
+
const a4 = lim.check("token-x", t0 + 300);
|
|
156
|
+
expect(a1.allowed).toBe(true);
|
|
157
|
+
expect(a2.allowed).toBe(true);
|
|
158
|
+
expect(a3.allowed).toBe(true);
|
|
159
|
+
expect(a4.allowed).toBe(false);
|
|
160
|
+
if (!a4.allowed) {
|
|
161
|
+
expect(a4.retryAfterMs).toBeGreaterThan(0);
|
|
162
|
+
expect(a4.retryAfterMs).toBeLessThanOrEqual(60_000);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("different keys are isolated", () => {
|
|
167
|
+
const lim = new UploadRateLimiter(60_000, 1);
|
|
168
|
+
expect(lim.check("alice", 1).allowed).toBe(true);
|
|
169
|
+
expect(lim.check("alice", 2).allowed).toBe(false);
|
|
170
|
+
expect(lim.check("bob", 3).allowed).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("window expiry resets the bucket", () => {
|
|
174
|
+
const lim = new UploadRateLimiter(60_000, 1);
|
|
175
|
+
const t0 = 1_700_000_000_000;
|
|
176
|
+
expect(lim.check("token-x", t0).allowed).toBe(true);
|
|
177
|
+
expect(lim.check("token-x", t0 + 30_000).allowed).toBe(false);
|
|
178
|
+
expect(lim.check("token-x", t0 + 60_001).allowed).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("default constructor matches documented public defaults", () => {
|
|
182
|
+
const lim = new UploadRateLimiter();
|
|
183
|
+
expect(UPLOAD_RATE_WINDOW_MS).toBe(60 * 60 * 1000);
|
|
184
|
+
expect(UPLOAD_RATE_MAX_PER_WINDOW).toBe(60);
|
|
185
|
+
for (let i = 0; i < UPLOAD_RATE_MAX_PER_WINDOW; i++) {
|
|
186
|
+
expect(lim.check("token-y", 1 + i).allowed).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
expect(lim.check("token-y", 1000).allowed).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("validateAttachments", () => {
|
|
193
|
+
test("undefined / null input → empty array", () => {
|
|
194
|
+
const a = validateAttachments(undefined);
|
|
195
|
+
expect(a.ok).toBe(true);
|
|
196
|
+
if (a.ok) expect(a.attachments).toEqual([]);
|
|
197
|
+
const b = validateAttachments(null);
|
|
198
|
+
expect(b.ok).toBe(true);
|
|
199
|
+
if (b.ok) expect(b.attachments).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("rejects non-array input", () => {
|
|
203
|
+
expect(validateAttachments("nope").ok).toBe(false);
|
|
204
|
+
expect(validateAttachments({}).ok).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("accepts a well-formed attachment matching MCP send_task shape", () => {
|
|
208
|
+
const fileId = generateFileId();
|
|
209
|
+
const res = validateAttachments([
|
|
210
|
+
{ type: "file", file_id: fileId, name: "photo.png", mime: "image/png", size: 12345 },
|
|
211
|
+
]);
|
|
212
|
+
expect(res.ok).toBe(true);
|
|
213
|
+
if (res.ok) {
|
|
214
|
+
expect(res.attachments[0].file_id).toBe(fileId);
|
|
215
|
+
expect(res.attachments[0].mime).toBe("image/png");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("rejects too many attachments (>20)", () => {
|
|
220
|
+
const fileId = generateFileId();
|
|
221
|
+
const many = Array.from({ length: 21 }, () => ({ type: "file" as const, file_id: fileId }));
|
|
222
|
+
const res = validateAttachments(many);
|
|
223
|
+
expect(res.ok).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects invalid file_id (path traversal)", () => {
|
|
227
|
+
const res = validateAttachments([{ type: "file", file_id: "../etc/passwd" }]);
|
|
228
|
+
expect(res.ok).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("rejects size larger than MAX_UPLOAD_BYTES", () => {
|
|
232
|
+
const fileId = generateFileId();
|
|
233
|
+
const res = validateAttachments([{ type: "file", file_id: fileId, size: MAX_UPLOAD_BYTES + 1 }]);
|
|
234
|
+
expect(res.ok).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects unexpected type", () => {
|
|
238
|
+
const res = validateAttachments([{ type: "image", file_id: generateFileId() }]);
|
|
239
|
+
expect(res.ok).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("validateIndexEntry", () => {
|
|
244
|
+
test("accepts a well-formed entry", () => {
|
|
245
|
+
const id = generateFileId();
|
|
246
|
+
expect(
|
|
247
|
+
validateIndexEntry({
|
|
248
|
+
file_id: id,
|
|
249
|
+
date_bucket: "2026-06-11",
|
|
250
|
+
ext: ".png",
|
|
251
|
+
name: "anything",
|
|
252
|
+
mime: "image/png",
|
|
253
|
+
size: 12345,
|
|
254
|
+
uploaded_at: "2026-06-11T03:00:00Z",
|
|
255
|
+
}),
|
|
256
|
+
).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("rejects bad date_bucket (anti-tamper)", () => {
|
|
260
|
+
expect(
|
|
261
|
+
validateIndexEntry({ file_id: generateFileId(), date_bucket: "../etc", ext: ".png", size: 1 }),
|
|
262
|
+
).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("rejects bad ext", () => {
|
|
266
|
+
expect(
|
|
267
|
+
validateIndexEntry({ file_id: generateFileId(), date_bucket: "2026-06-11", ext: "/etc/passwd", size: 1 }),
|
|
268
|
+
).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("rejects size beyond MAX_UPLOAD_BYTES (anti-tamper)", () => {
|
|
272
|
+
expect(
|
|
273
|
+
validateIndexEntry({
|
|
274
|
+
file_id: generateFileId(),
|
|
275
|
+
date_bucket: "2026-06-11",
|
|
276
|
+
ext: "",
|
|
277
|
+
size: MAX_UPLOAD_BYTES + 1,
|
|
278
|
+
}),
|
|
279
|
+
).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("indexEntryPath", () => {
|
|
284
|
+
test("returns null on malformed file_id", () => {
|
|
285
|
+
expect(indexEntryPath("../etc/passwd", { uploadsRoot: "/srv" })).toBeNull();
|
|
286
|
+
expect(indexEntryPath("", { uploadsRoot: "/srv" })).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("places the index file under <uploadsRoot>/.index/", () => {
|
|
290
|
+
const id = generateFileId();
|
|
291
|
+
const p = indexEntryPath(id, { uploadsRoot: "/srv/up" });
|
|
292
|
+
expect(p).toBe(`/srv/up/.index/${id}.json`);
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// #221 — commhub-server file upload primitives.
|
|
2
|
+
//
|
|
3
|
+
// Hub upload surface for the mobile APP (#220). The hub started reverse-
|
|
4
|
+
// proxying over HTTPS to the public internet last night, so the upload
|
|
5
|
+
// endpoint is a brand-new public attack surface. The six security items
|
|
6
|
+
// in the issue are HARD verification items, encoded here:
|
|
7
|
+
//
|
|
8
|
+
// 1. file_id is server-generated (uuidv4, alphanumeric only), client-
|
|
9
|
+
// supplied filenames never enter the storage path.
|
|
10
|
+
// 2. Storage root sits in $HOME/.anet/server/uploads (outside any
|
|
11
|
+
// web-served directory); downloads go through an explicit handler.
|
|
12
|
+
// 3. Download handler forces Content-Disposition: attachment +
|
|
13
|
+
// X-Content-Type-Options: nosniff — uploaded files are never
|
|
14
|
+
// executed or served as HTML.
|
|
15
|
+
// 4. 12 MB cap enforced two-stage per 通信龙: Content-Length header
|
|
16
|
+
// pre-check (reject before reading body) + post-parse size verify.
|
|
17
|
+
// 5. Auth is the existing requireAuth — anonymous + bad tokens 401.
|
|
18
|
+
// 6. Naive per-token rate limit: 60 uploads/hour per token (or IP for
|
|
19
|
+
// legacy/dev mode).
|
|
20
|
+
//
|
|
21
|
+
// This module stays pure-ish: file I/O lives in the HTTP handlers, the
|
|
22
|
+
// path / id / rate-limit primitives are unit-testable.
|
|
23
|
+
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
import { join, relative, isAbsolute } from "path";
|
|
26
|
+
|
|
27
|
+
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
// Allow ~1 MiB of multipart envelope overhead on top of the 12 MiB
|
|
30
|
+
// payload limit when checking Content-Length. The post-parse check is
|
|
31
|
+
// still done against MAX_UPLOAD_BYTES so the actual binary payload
|
|
32
|
+
// never exceeds the documented cap.
|
|
33
|
+
export const MAX_REQUEST_CONTENT_LENGTH = MAX_UPLOAD_BYTES + 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
// Per-token rate-limit constants. 60 uploads/hour matches the issue
|
|
36
|
+
// "如 60 req/h" suggestion and is enforced in-memory (one process holds
|
|
37
|
+
// the source of truth; resets on restart).
|
|
38
|
+
export const UPLOAD_RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
39
|
+
export const UPLOAD_RATE_MAX_PER_WINDOW = 60;
|
|
40
|
+
|
|
41
|
+
// Strict file_id regex. Generated server-side via crypto.randomUUID()
|
|
42
|
+
// with hyphens stripped, so the natural output is 32 hex chars. The
|
|
43
|
+
// regex is intentionally permissive on the upper bound (64 chars) to
|
|
44
|
+
// allow future scheme changes (e.g. prefix), but it rejects ANY of `.`
|
|
45
|
+
// `/` `\` `..` so the same regex used for path generation can also be
|
|
46
|
+
// used to validate a /api/files/:file_id lookup against directory
|
|
47
|
+
// traversal.
|
|
48
|
+
export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
|
|
49
|
+
|
|
50
|
+
export type GeneratedFile = {
|
|
51
|
+
fileId: string;
|
|
52
|
+
ext: string;
|
|
53
|
+
dateBucket: string;
|
|
54
|
+
relativePath: string;
|
|
55
|
+
absolutePath: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function getUploadsRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
59
|
+
return env.COMMHUB_UPLOADS_DIR || join(homedir(), ".anet/server/uploads");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getDateBucket(now: Date = new Date()): string {
|
|
63
|
+
// YYYY-MM-DD partitioning per issue spec — keeps a single directory
|
|
64
|
+
// from accumulating millions of entries while leaving the lookup
|
|
65
|
+
// path predictable from the disk index (file_id → date bucket).
|
|
66
|
+
return now.toISOString().slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function generateFileId(): string {
|
|
70
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract a safe file extension from a client-supplied filename.
|
|
75
|
+
* Server NEVER uses the rest of the filename. Returns "" if no
|
|
76
|
+
* acceptable extension is present.
|
|
77
|
+
*
|
|
78
|
+
* Accepts: alphanumeric extensions of 1–16 chars (`.png`, `.tar`,
|
|
79
|
+
* `.docx`, `.tar.gz` returns only `.gz`). Rejects: anything containing
|
|
80
|
+
* path separators, dots in unusual positions, or non-ASCII characters.
|
|
81
|
+
*/
|
|
82
|
+
export function sanitizeExt(filename: string | undefined | null): string {
|
|
83
|
+
if (!filename || typeof filename !== "string") return "";
|
|
84
|
+
// Defense: strip any leading directory the client might have shoved
|
|
85
|
+
// into "name", then take only the last extension.
|
|
86
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
87
|
+
const match = base.match(/\.([A-Za-z0-9]{1,16})$/);
|
|
88
|
+
return match ? "." + match[1].toLowerCase() : "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the per-upload storage path. The file_id and ext are validated
|
|
93
|
+
* against strict regexes; any path-separator or `..` segment from a
|
|
94
|
+
* malicious client gets rejected at the regex layer before any I/O.
|
|
95
|
+
*/
|
|
96
|
+
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
97
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
98
|
+
if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
99
|
+
const dateBucket = getDateBucket(opts.now);
|
|
100
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
101
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
102
|
+
const absolutePath = join(root, relativePath);
|
|
103
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type UploadIndexEntry = {
|
|
107
|
+
file_id: string;
|
|
108
|
+
date_bucket: string; // YYYY-MM-DD
|
|
109
|
+
ext: string; // ".png" / ".pdf" / etc., including the leading dot, lowercase
|
|
110
|
+
name: string; // original filename — stored as metadata only, NEVER used in paths
|
|
111
|
+
mime: string; // best-effort mime from the multipart Content-Type
|
|
112
|
+
size: number; // byte length of the binary
|
|
113
|
+
owner?: string; // upload token id / username, for traceability
|
|
114
|
+
uploaded_at: string; // ISO 8601
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Path to the index entry file for a given file_id. Lives in
|
|
118
|
+
* `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
|
|
119
|
+
* an O(1) lookup without scanning every date bucket. */
|
|
120
|
+
export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } = {}): string | null {
|
|
121
|
+
if (!FILE_ID_REGEX.test(fileId)) return null;
|
|
122
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
123
|
+
return join(root, ".index", fileId + ".json");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validate an index entry shape before serving a download. Defends
|
|
127
|
+
* against a malformed/tampered .index file by re-checking the same
|
|
128
|
+
* invariants the upload handler enforces. */
|
|
129
|
+
export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
|
|
130
|
+
if (!entry || typeof entry !== "object") return false;
|
|
131
|
+
const e: any = entry;
|
|
132
|
+
return (
|
|
133
|
+
typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
|
|
134
|
+
typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
|
|
135
|
+
typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
|
|
136
|
+
typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Defence-in-depth: confirm a resolved absolute path falls within
|
|
142
|
+
* the uploads root. The HTTP handler calls this after constructing
|
|
143
|
+
* the download path; if it ever reports `false` we hard-fail the
|
|
144
|
+
* request rather than serve whatever the resolved path points at.
|
|
145
|
+
*/
|
|
146
|
+
export function isPathInsideUploadsRoot(absPath: string, uploadsRoot?: string): boolean {
|
|
147
|
+
const root = uploadsRoot ?? getUploadsRoot();
|
|
148
|
+
const rel = relative(root, absPath);
|
|
149
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Rate limit (in-memory, per token/IP) ────────────────────────────
|
|
153
|
+
|
|
154
|
+
type UploadRateState = { count: number; resetAt: number };
|
|
155
|
+
|
|
156
|
+
export class UploadRateLimiter {
|
|
157
|
+
private state = new Map<string, UploadRateState>();
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
private readonly windowMs: number = UPLOAD_RATE_WINDOW_MS,
|
|
161
|
+
private readonly maxPerWindow: number = UPLOAD_RATE_MAX_PER_WINDOW,
|
|
162
|
+
) {}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Attempt to consume one upload quota slot for `key` (typically the
|
|
166
|
+
* caller's token id, falling back to IP for legacy auth). Returns
|
|
167
|
+
* `{ allowed: true }` when the upload may proceed and
|
|
168
|
+
* `{ allowed: false, retryAfterMs }` when the caller is rate-limited.
|
|
169
|
+
* Window is rolling on first touch (not aligned to wall-clock hours).
|
|
170
|
+
*/
|
|
171
|
+
check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
|
|
172
|
+
const entry = this.state.get(key);
|
|
173
|
+
if (!entry || nowMs >= entry.resetAt) {
|
|
174
|
+
const resetAt = nowMs + this.windowMs;
|
|
175
|
+
this.state.set(key, { count: 1, resetAt });
|
|
176
|
+
return { allowed: true, remaining: this.maxPerWindow - 1, resetAt };
|
|
177
|
+
}
|
|
178
|
+
if (entry.count >= this.maxPerWindow) {
|
|
179
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfterMs: entry.resetAt - nowMs };
|
|
180
|
+
}
|
|
181
|
+
entry.count++;
|
|
182
|
+
return { allowed: true, remaining: this.maxPerWindow - entry.count, resetAt: entry.resetAt };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Visible for tests — clear all rate-limit state. */
|
|
186
|
+
reset(): void {
|
|
187
|
+
this.state.clear();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Visible for tests — current bucket count. */
|
|
191
|
+
size(): number {
|
|
192
|
+
return this.state.size;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Module-level singleton shared by the HTTP /api/upload handler. */
|
|
197
|
+
export const sharedUploadRateLimiter = new UploadRateLimiter();
|
|
198
|
+
|
|
199
|
+
// ── Attachment schema (shared between REST /api/task and MCP send_task) ───
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Canonical shape for the `attachments` field on tasks. Both the REST
|
|
203
|
+
* /api/task body and the MCP send_task tool accept this same array;
|
|
204
|
+
* server persistence routes through `tasks.meta_json` so the storage
|
|
205
|
+
* layout is identical regardless of transport.
|
|
206
|
+
*/
|
|
207
|
+
export type TaskAttachment = {
|
|
208
|
+
type: "file";
|
|
209
|
+
file_id: string;
|
|
210
|
+
name?: string;
|
|
211
|
+
mime?: string;
|
|
212
|
+
size?: number;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Light validator usable from the REST handler. Returns null on success or an error message string. */
|
|
216
|
+
export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
|
|
217
|
+
if (input === undefined || input === null) return { ok: true, attachments: [] };
|
|
218
|
+
if (!Array.isArray(input)) return { ok: false, error: "attachments must be an array" };
|
|
219
|
+
if (input.length > 20) return { ok: false, error: "too many attachments (max 20)" };
|
|
220
|
+
const out: TaskAttachment[] = [];
|
|
221
|
+
for (let i = 0; i < input.length; i++) {
|
|
222
|
+
const a: any = input[i];
|
|
223
|
+
if (!a || typeof a !== "object") return { ok: false, error: `attachments[${i}] is not an object` };
|
|
224
|
+
if (a.type !== "file") return { ok: false, error: `attachments[${i}].type must be "file"` };
|
|
225
|
+
if (typeof a.file_id !== "string" || !FILE_ID_REGEX.test(a.file_id)) {
|
|
226
|
+
return { ok: false, error: `attachments[${i}].file_id is invalid` };
|
|
227
|
+
}
|
|
228
|
+
if (a.name !== undefined && (typeof a.name !== "string" || a.name.length > 255)) {
|
|
229
|
+
return { ok: false, error: `attachments[${i}].name must be a string ≤ 255 chars` };
|
|
230
|
+
}
|
|
231
|
+
if (a.mime !== undefined && (typeof a.mime !== "string" || a.mime.length > 100)) {
|
|
232
|
+
return { ok: false, error: `attachments[${i}].mime must be a string ≤ 100 chars` };
|
|
233
|
+
}
|
|
234
|
+
if (a.size !== undefined && (typeof a.size !== "number" || a.size < 0 || a.size > MAX_UPLOAD_BYTES)) {
|
|
235
|
+
return { ok: false, error: `attachments[${i}].size must be a non-negative number ≤ ${MAX_UPLOAD_BYTES}` };
|
|
236
|
+
}
|
|
237
|
+
out.push({ type: "file", file_id: a.file_id, name: a.name, mime: a.mime, size: a.size });
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, attachments: out };
|
|
240
|
+
}
|