@syengup/friday-channel-next 0.0.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/README.md +35 -0
- package/index.ts +191 -0
- package/install.mjs +158 -0
- package/install.sh +118 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +65 -0
- package/src/agent/abort-run.ts +10 -0
- package/src/agent/active-runs.ts +26 -0
- package/src/agent/dispatch-bridge.ts +18 -0
- package/src/agent/media-bridge.ts +23 -0
- package/src/agent-forward-runtime.ts +30 -0
- package/src/agent-run-context-bridge.ts +32 -0
- package/src/channel-actions.ts +129 -0
- package/src/channel.ts +284 -0
- package/src/collect-message-media-paths.ts +132 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +64 -0
- package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
- package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
- package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
- package/src/e2e/offline-replay.e2e.test.ts +43 -0
- package/src/e2e/send-text.e2e.test.ts +73 -0
- package/src/e2e/slash-commands.e2e.test.ts +33 -0
- package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
- package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
- package/src/friday-inbound-stats.ts +10 -0
- package/src/friday-session.forward-agent.test.ts +270 -0
- package/src/friday-session.ts +327 -0
- package/src/host-config.ts +20 -0
- package/src/http/handlers/cancel.test.ts +70 -0
- package/src/http/handlers/cancel.ts +35 -0
- package/src/http/handlers/files-download.ts +239 -0
- package/src/http/handlers/files-upload.ts +166 -0
- package/src/http/handlers/files.ts +335 -0
- package/src/http/handlers/messages.test.ts +119 -0
- package/src/http/handlers/messages.ts +555 -0
- package/src/http/handlers/models-list.ts +126 -0
- package/src/http/handlers/sessions-delete.ts +59 -0
- package/src/http/handlers/sessions-settings.ts +90 -0
- package/src/http/handlers/sse.test.ts +71 -0
- package/src/http/handlers/sse.ts +84 -0
- package/src/http/handlers/status.test.ts +52 -0
- package/src/http/handlers/status.ts +33 -0
- package/src/http/middleware/auth.test.ts +46 -0
- package/src/http/middleware/auth.ts +31 -0
- package/src/http/middleware/body.test.ts +27 -0
- package/src/http/middleware/body.ts +28 -0
- package/src/http/middleware/cors.test.ts +40 -0
- package/src/http/middleware/cors.ts +12 -0
- package/src/http/server.ts +106 -0
- package/src/logging.ts +27 -0
- package/src/openclaw.d.ts +32 -0
- package/src/run-metadata.ts +180 -0
- package/src/runtime.ts +14 -0
- package/src/session/session-manager.ts +230 -0
- package/src/session-usage-snapshot.ts +80 -0
- package/src/sse/emitter.test.ts +85 -0
- package/src/sse/emitter.ts +249 -0
- package/src/sse/frame-format.test.ts +56 -0
- package/src/sse/offline-queue.test.ts +65 -0
- package/src/sse/offline-queue.ts +140 -0
- package/src/test-support/app-simulator.ts +243 -0
- package/src/test-support/mock-dispatch.ts +181 -0
- package/src/test-support/mock-runtime.ts +74 -0
- package/src/vendor/runtime-store.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { abortRun } from "../../agent/abort-run.js";
|
|
3
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
5
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
6
|
+
|
|
7
|
+
export async function handleCancel(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
8
|
+
if (req.method !== "POST") {
|
|
9
|
+
res.statusCode = 405;
|
|
10
|
+
res.setHeader("Content-Type", "application/json");
|
|
11
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
const token = extractBearerToken(req);
|
|
15
|
+
if (!token) {
|
|
16
|
+
res.statusCode = 401;
|
|
17
|
+
res.setHeader("Content-Type", "application/json");
|
|
18
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const body = await readJsonBody(req);
|
|
22
|
+
const runId = typeof body?.runId === "string" ? body.runId.trim() : "";
|
|
23
|
+
if (!runId) {
|
|
24
|
+
res.statusCode = 400;
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify({ error: "Missing runId" }));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
await abortRun(runId);
|
|
30
|
+
sseEmitter.untrackRun(runId);
|
|
31
|
+
res.statusCode = 200;
|
|
32
|
+
res.setHeader("Content-Type", "application/json");
|
|
33
|
+
res.end(JSON.stringify({ ok: true, runId, cancelled: true }));
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File download handler for GET /friday-next/files/:id
|
|
3
|
+
*
|
|
4
|
+
* Sources checked (in order):
|
|
5
|
+
* 1. In-memory file index (POST /friday-next/files and resolved attachments this session)
|
|
6
|
+
* 2. Plugin-root `attachments/` on disk (same basename as URL token; survives restarts)
|
|
7
|
+
* 3. OpenClaw media buffer (~/.openclaw/media/inbound/<id>)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
12
|
+
import {
|
|
13
|
+
getExternalFileSourceByUrlToken,
|
|
14
|
+
getFile,
|
|
15
|
+
guessMimeType,
|
|
16
|
+
readAttachmentFileFromDisk,
|
|
17
|
+
readFile,
|
|
18
|
+
} from "./files.js";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
|
|
23
|
+
const MIME_FROM_EXT: Record<string, string> = {
|
|
24
|
+
png: "image/png",
|
|
25
|
+
jpg: "image/jpeg",
|
|
26
|
+
jpeg: "image/jpeg",
|
|
27
|
+
gif: "image/gif",
|
|
28
|
+
webp: "image/webp",
|
|
29
|
+
heic: "image/heic",
|
|
30
|
+
pdf: "application/pdf",
|
|
31
|
+
mp4: "video/mp4",
|
|
32
|
+
mov: "video/quicktime",
|
|
33
|
+
mp3: "audio/mpeg",
|
|
34
|
+
wav: "audio/wav",
|
|
35
|
+
ogg: "audio/ogg",
|
|
36
|
+
opus: "audio/opus",
|
|
37
|
+
m4a: "audio/mp4",
|
|
38
|
+
aac: "audio/aac",
|
|
39
|
+
flac: "audio/flac",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function sendError(res: ServerResponse, status: number, message: string): void {
|
|
43
|
+
if (res.headersSent) return;
|
|
44
|
+
res.statusCode = status;
|
|
45
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
46
|
+
res.end(JSON.stringify({ error: message }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Avoid decodeURIComponent throwing on malformed % sequences (would surface as 500). */
|
|
50
|
+
function tryDecodeURIComponent(segment: string): string | null {
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(segment);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Safe Content-Disposition: strip CR/LF/quotes from basename; add RFC 5987 filename* for Unicode.
|
|
60
|
+
*/
|
|
61
|
+
function contentDispositionInline(filename: string): string {
|
|
62
|
+
const base =
|
|
63
|
+
path.basename(filename).replace(/[\r\n"]/g, "_").replace(/\\/g, "_") || "file";
|
|
64
|
+
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
65
|
+
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Full-body or single-range 206 response. iOS/Safari often sends Range for JPEG; proxies may error if mishandled.
|
|
70
|
+
*/
|
|
71
|
+
function sendBuffer(
|
|
72
|
+
req: IncomingMessage,
|
|
73
|
+
res: ServerResponse,
|
|
74
|
+
buffer: Buffer,
|
|
75
|
+
mimeType: string,
|
|
76
|
+
filename: string,
|
|
77
|
+
): void {
|
|
78
|
+
const total = buffer.length;
|
|
79
|
+
const disposition = contentDispositionInline(filename);
|
|
80
|
+
const rangeRaw = req.headers.range;
|
|
81
|
+
const range =
|
|
82
|
+
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
83
|
+
? rangeRaw.trim()
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
87
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
88
|
+
res.setHeader("Content-Type", mimeType);
|
|
89
|
+
res.setHeader("Content-Disposition", disposition);
|
|
90
|
+
|
|
91
|
+
if (!range || total === 0) {
|
|
92
|
+
res.statusCode = 200;
|
|
93
|
+
res.setHeader("Content-Length", String(total));
|
|
94
|
+
res.end(buffer);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const m = /^bytes=(\d*)-(\d*)$/i.exec(range);
|
|
99
|
+
if (!m) {
|
|
100
|
+
res.statusCode = 200;
|
|
101
|
+
res.setHeader("Content-Length", String(total));
|
|
102
|
+
res.end(buffer);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = total - 1;
|
|
108
|
+
|
|
109
|
+
if (m[1] === "" && m[2] !== "") {
|
|
110
|
+
const suffixLen = parseInt(m[2]!, 10);
|
|
111
|
+
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
112
|
+
res.statusCode = 200;
|
|
113
|
+
res.setHeader("Content-Length", String(total));
|
|
114
|
+
res.end(buffer);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
start = Math.max(0, total - suffixLen);
|
|
118
|
+
end = total - 1;
|
|
119
|
+
} else if (m[1] !== "" && m[2] === "") {
|
|
120
|
+
start = parseInt(m[1]!, 10);
|
|
121
|
+
end = total - 1;
|
|
122
|
+
} else if (m[1] !== "" && m[2] !== "") {
|
|
123
|
+
start = parseInt(m[1]!, 10);
|
|
124
|
+
end = parseInt(m[2]!, 10);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
128
|
+
res.statusCode = 416;
|
|
129
|
+
res.setHeader("Content-Range", `bytes */${total}`);
|
|
130
|
+
res.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (end >= total) end = total - 1;
|
|
134
|
+
const chunk = buffer.subarray(start, end + 1);
|
|
135
|
+
res.statusCode = 206;
|
|
136
|
+
res.setHeader("Content-Length", String(chunk.length));
|
|
137
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
138
|
+
res.end(chunk);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function handleFilesDownload(
|
|
142
|
+
req: IncomingMessage,
|
|
143
|
+
res: ServerResponse,
|
|
144
|
+
): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
if (req.method !== "GET") {
|
|
147
|
+
sendError(res, 405, "Method Not Allowed");
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!extractBearerToken(req)) {
|
|
152
|
+
sendError(res, 401, "Unauthorized");
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let url: URL;
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
159
|
+
} catch {
|
|
160
|
+
sendError(res, 400, "Bad request URL");
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
165
|
+
const rawSeg = segments[segments.length - 1] ?? "";
|
|
166
|
+
const fileToken = tryDecodeURIComponent(rawSeg);
|
|
167
|
+
if (fileToken === null) {
|
|
168
|
+
sendError(res, 400, "Invalid file path encoding");
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!fileToken || fileToken === "files") {
|
|
173
|
+
sendError(res, 400, "Missing file ID");
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 1. Try Friday's own in-memory file index
|
|
178
|
+
const file = getFile(fileToken);
|
|
179
|
+
if (file) {
|
|
180
|
+
const { buffer, mimeType } = readFile(fileToken);
|
|
181
|
+
if (buffer) {
|
|
182
|
+
sendBuffer(req, res, buffer, mimeType, file.filename);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 1.2 Plugin-root attachments/ (survives gateway restarts; basename = URL token)
|
|
188
|
+
const fromAttachments = readAttachmentFileFromDisk(fileToken);
|
|
189
|
+
if (fromAttachments) {
|
|
190
|
+
sendBuffer(req, res, fromAttachments.buffer, fromAttachments.mimeType, fromAttachments.filename);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 1.4 Best-effort external source by token (when copy-to-attachments failed earlier)
|
|
195
|
+
const externalSource = getExternalFileSourceByUrlToken(fileToken);
|
|
196
|
+
if (externalSource && fs.existsSync(externalSource)) {
|
|
197
|
+
try {
|
|
198
|
+
const buffer = fs.readFileSync(externalSource);
|
|
199
|
+
const filename = path.basename(externalSource);
|
|
200
|
+
sendBuffer(req, res, buffer, guessMimeType(filename), filename);
|
|
201
|
+
return true;
|
|
202
|
+
} catch {
|
|
203
|
+
// continue fallback chain
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Try OpenClaw media buffer (~/.openclaw/media/inbound/<id>)
|
|
208
|
+
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
209
|
+
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
210
|
+
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
211
|
+
const candidates = [
|
|
212
|
+
path.join(mediaDir, baseId),
|
|
213
|
+
path.join(mediaDir, fileToken),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const filePath of candidates) {
|
|
217
|
+
if (fs.existsSync(filePath)) {
|
|
218
|
+
try {
|
|
219
|
+
const st = fs.statSync(filePath);
|
|
220
|
+
if (!st.isFile()) continue;
|
|
221
|
+
const buffer = fs.readFileSync(filePath);
|
|
222
|
+
const ext = path.extname(fileToken).toLowerCase().replace(/^\./, "");
|
|
223
|
+
const mimeType = MIME_FROM_EXT[ext] ?? "application/octet-stream";
|
|
224
|
+
sendBuffer(req, res, buffer, mimeType, fileToken);
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
// fall through to 404
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
sendError(res, 404, "File not found");
|
|
233
|
+
return true;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error(`[Friday-FILES] GET download failed: ${String(err)}`);
|
|
236
|
+
sendError(res, 500, "Internal Server Error");
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload handler for POST /friday-next/files
|
|
3
|
+
*
|
|
4
|
+
* Handles multipart file uploads from the iOS app.
|
|
5
|
+
* Stores files and returns file IDs that can be referenced in messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
10
|
+
import { storeFile, guessMimeType } from "./files.js";
|
|
11
|
+
|
|
12
|
+
interface ParsedMultipart {
|
|
13
|
+
fields: Record<string, string>;
|
|
14
|
+
files: Array<{ filename: string; buffer: Buffer; contentType: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function parseMultipartBody(
|
|
18
|
+
req: IncomingMessage,
|
|
19
|
+
boundaryContentType: string,
|
|
20
|
+
): Promise<ParsedMultipart | null> {
|
|
21
|
+
const boundaryMatch = boundaryContentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
22
|
+
if (!boundaryMatch) return null;
|
|
23
|
+
const boundary = boundaryMatch[1] ?? boundaryMatch[2];
|
|
24
|
+
if (!boundary) return null;
|
|
25
|
+
|
|
26
|
+
const chunks: Buffer[] = [];
|
|
27
|
+
await new Promise<void>((resolve, reject) => {
|
|
28
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
29
|
+
req.on("end", resolve);
|
|
30
|
+
req.on("error", reject);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const body = Buffer.concat(chunks);
|
|
34
|
+
const parts: ParsedMultipart = { fields: {}, files: [] };
|
|
35
|
+
|
|
36
|
+
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
|
37
|
+
|
|
38
|
+
let start = 0;
|
|
39
|
+
while (start < body.length) {
|
|
40
|
+
const idx = bufferIndexOf(body, boundaryBuffer, start);
|
|
41
|
+
if (idx === -1) break;
|
|
42
|
+
|
|
43
|
+
const nextStart = idx + boundaryBuffer.length;
|
|
44
|
+
if (body[nextStart] === 0x2d && body[nextStart + 1] === 0x2d) {
|
|
45
|
+
// "--" after boundary = end
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (body[nextStart] !== 0x0d || body[nextStart + 1] !== 0x0a) {
|
|
49
|
+
start = nextStart;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const headerEnd = bufferIndexOf(body, Buffer.from("\r\n\r\n"), nextStart + 2);
|
|
54
|
+
if (headerEnd === -1) {
|
|
55
|
+
start = nextStart;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const headers = body.subarray(idx + boundaryBuffer.length + 2, headerEnd).toString("utf-8");
|
|
60
|
+
const contentDisposition = extractHeaderValue(headers, "Content-Disposition");
|
|
61
|
+
const contentTypeHeader = extractHeaderValue(headers, "Content-Type");
|
|
62
|
+
|
|
63
|
+
const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/);
|
|
64
|
+
const nameMatch = contentDisposition?.match(/name="([^"]+)"/);
|
|
65
|
+
const filename = filenameMatch?.[1] ?? nameMatch?.[1] ?? "file";
|
|
66
|
+
const isFile = Boolean(filenameMatch);
|
|
67
|
+
|
|
68
|
+
const dataStart = headerEnd + 4;
|
|
69
|
+
// Search for the closing boundary marker (\r\n--boundary) rather than just
|
|
70
|
+
// \r\n, since binary file data may contain CRLF bytes.
|
|
71
|
+
const closingBoundary = Buffer.from(`\r\n--${boundary}`);
|
|
72
|
+
const endIdx = bufferIndexOf(body, closingBoundary, dataStart);
|
|
73
|
+
const end = endIdx === -1 ? body.length - 2 : endIdx;
|
|
74
|
+
|
|
75
|
+
if (isFile) {
|
|
76
|
+
const buffer = body.subarray(dataStart, end);
|
|
77
|
+
const mimeType = contentTypeHeader ?? guessMimeType(filename);
|
|
78
|
+
parts.files.push({ filename, buffer, contentType: mimeType });
|
|
79
|
+
} else if (nameMatch) {
|
|
80
|
+
const value = body.subarray(dataStart, end).toString("utf-8").trim();
|
|
81
|
+
parts.fields[nameMatch[1]] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
start = end + 2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractHeaderValue(headers: string, name: string): string | undefined {
|
|
91
|
+
const lines = headers.split(/\r\n/);
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const [key, ...valueParts] = line.split(":");
|
|
94
|
+
if (key.trim().toLowerCase() === name.toLowerCase()) {
|
|
95
|
+
return valueParts.join(":").trim();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bufferIndexOf(haystack: Buffer, needle: Buffer, start = 0): number {
|
|
102
|
+
for (let i = start; i <= haystack.length - needle.length; i++) {
|
|
103
|
+
let match = true;
|
|
104
|
+
for (let j = 0; j < needle.length; j++) {
|
|
105
|
+
if (haystack[i + j] !== needle[j]) {
|
|
106
|
+
match = false;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (match) return i;
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function handleFilesUpload(
|
|
116
|
+
req: IncomingMessage,
|
|
117
|
+
res: ServerResponse,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (req.method !== "POST") {
|
|
120
|
+
res.statusCode = 405;
|
|
121
|
+
res.setHeader("Content-Type", "application/json");
|
|
122
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const token = extractBearerToken(req);
|
|
127
|
+
if (!token) {
|
|
128
|
+
res.statusCode = 401;
|
|
129
|
+
res.setHeader("Content-Type", "application/json");
|
|
130
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
135
|
+
const parsed = await parseMultipartBody(req, contentType);
|
|
136
|
+
|
|
137
|
+
if (!parsed) {
|
|
138
|
+
res.statusCode = 400;
|
|
139
|
+
res.setHeader("Content-Type", "application/json");
|
|
140
|
+
res.end(JSON.stringify({ error: "Invalid multipart form data" }));
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (parsed.files.length === 0) {
|
|
145
|
+
res.statusCode = 400;
|
|
146
|
+
res.setHeader("Content-Type", "application/json");
|
|
147
|
+
res.end(JSON.stringify({ error: "No files provided" }));
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const files = parsed.files.map((file) => {
|
|
152
|
+
const stored = storeFile(file.buffer, file.filename, file.contentType);
|
|
153
|
+
return {
|
|
154
|
+
id: stored.id,
|
|
155
|
+
filename: stored.filename,
|
|
156
|
+
mimeType: stored.mimeType,
|
|
157
|
+
size: stored.size,
|
|
158
|
+
url: `/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
res.statusCode = 200;
|
|
163
|
+
res.setHeader("Content-Type", "application/json");
|
|
164
|
+
res.end(JSON.stringify({ files }));
|
|
165
|
+
return true;
|
|
166
|
+
}
|