@syengup/friday-channel-next 0.0.35 → 0.0.37
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/dist/attachments/0768c9b1-53b0-44df-83e8-be15c4ea188f.jpg +0 -0
- package/dist/attachments/0a379d01-116b-4da1-bf15-77cb2cbb0093.jpg +0 -0
- package/dist/attachments/181caab2-64a7-4004-a057-225a144f949e.mp3 +0 -0
- package/dist/attachments/19662331-e527-47d2-bc0e-0e19a7a91419.jpg +0 -0
- package/dist/attachments/26a23b2b-52df-4572-a5e1-15b34fb87e44.jpg +0 -0
- package/dist/attachments/2f9282c5-8db4-4c4a-a060-e65104f6f9ff.jpg +0 -0
- package/dist/attachments/3929ec3d-ea15-4de6-96bc-97e8b0b658a7.jpg +0 -0
- package/dist/attachments/403c0cbc-4e3c-4146-a3be-ff3746ee7cda.jpg +0 -0
- package/dist/attachments/441977f5-0f7b-4aa2-841a-1d63e787ea53.jpg +0 -0
- package/dist/attachments/453e8aa2-76e3-498d-8d6f-d7b96d6bf45b.jpg +0 -0
- package/dist/attachments/538cde71-d26e-4d3d-b901-e8dd905e668c.mp3 +0 -0
- package/dist/attachments/55c7f628-4ba2-4252-aa4b-4f3eb6045a8a.mp3 +0 -0
- package/dist/attachments/5f7683f5-8194-4698-b077-31d209525379.jpg +0 -0
- package/dist/attachments/60614a35-8f44-4197-b783-2f58f5a72ac8.jpeg +0 -0
- package/dist/attachments/62830489-8814-48b1-851c-3845e514f35e.mp3 +0 -0
- package/dist/attachments/66f4a62d-1531-4f38-a531-7456f9edf221.png +0 -0
- package/dist/attachments/6735d749-769e-483a-9b84-43b9338a720b.png +0 -0
- package/dist/attachments/6d1766b1-05e4-4b04-b3c8-1c25e9d182a1.png +0 -0
- package/dist/attachments/782b077b-06e3-484b-baf5-33e7160234ed.png +0 -0
- package/dist/attachments/7ad638b2-1f56-4d93-9ad8-b40346e0650f.jpg +0 -0
- package/dist/attachments/89f6fb15-e652-4111-a60c-baa414659052.png +0 -0
- package/dist/attachments/8a88b14f-442f-45fb-b01d-e51bab8f800d.mp3 +0 -0
- package/dist/attachments/92292034-9cf6-4f26-8d77-fddca3deb638.png +0 -0
- package/dist/attachments/92c2b414-d33d-4d93-bcb6-013da7bec9a4.jpg +0 -0
- package/dist/attachments/9664f69e-3c05-45ca-9a52-f2d0b9f9bf7e.jpg +0 -0
- package/dist/attachments/977d28c1-43c0-40e0-95e3-defe0f41afe8.jpg +0 -0
- package/dist/attachments/9df40f1a-c6e1-4177-8a03-06757a30b19e.png +0 -0
- package/dist/attachments/a68e6815-6163-4421-a70f-34493aa9a217.jpg +0 -0
- package/dist/attachments/aab32fea-6d99-47ec-ab1f-2340f31312eb.jpg +0 -0
- package/dist/attachments/ab403224-2fb1-49c1-8738-ea194ab65d44.png +0 -0
- package/dist/attachments/ac3da190-d6ee-4038-a673-8b893035a687.png +0 -0
- package/dist/attachments/af02be9c-87f7-4c5a-9969-7db32039bb58.png +0 -0
- package/dist/attachments/b011d42a-00e5-4f77-86bc-08da6112e6e1.mp3 +0 -0
- package/dist/attachments/b7d7df40-c627-4b1f-9b09-167b88545c25.mp3 +0 -0
- package/dist/attachments/c5e9bf09-a718-422c-bcb3-94c173e3755b.mp3 +0 -0
- package/dist/attachments/d5449e13-1995-44ba-9392-ecbfe5f9876f.jpg +0 -0
- package/dist/attachments/ea0069f5-01cf-4ea1-985e-3a1e426399c3.png +0 -0
- package/dist/attachments/f3989ff2-7b70-4a80-a896-74a6b197f7d8.png +0 -0
- package/dist/attachments/f64a4a14-e3aa-4eed-a8d9-1603f04baa5b.jpg +0 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +176 -0
- package/dist/src/agent/abort-run.d.ts +1 -0
- package/dist/src/agent/abort-run.js +11 -0
- package/dist/src/agent/active-runs.d.ts +9 -0
- package/dist/src/agent/active-runs.js +20 -0
- package/dist/src/agent/dispatch-bridge.d.ts +5 -0
- package/dist/src/agent/dispatch-bridge.js +12 -0
- package/dist/src/agent/media-bridge.d.ts +4 -0
- package/dist/src/agent/media-bridge.js +21 -0
- package/dist/src/agent/subagent-registry.d.ts +68 -0
- package/dist/src/agent/subagent-registry.js +142 -0
- package/dist/src/agent-forward-runtime.d.ts +17 -0
- package/dist/src/agent-forward-runtime.js +16 -0
- package/dist/src/agent-run-context-bridge.d.ts +13 -0
- package/dist/src/agent-run-context-bridge.js +23 -0
- package/dist/src/channel-actions.d.ts +13 -0
- package/dist/src/channel-actions.js +101 -0
- package/dist/src/channel.d.ts +6 -0
- package/dist/src/channel.js +248 -0
- package/dist/src/collect-message-media-paths.d.ts +11 -0
- package/dist/src/collect-message-media-paths.js +143 -0
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +39 -0
- package/dist/src/friday-inbound-stats.d.ts +2 -0
- package/dist/src/friday-inbound-stats.js +8 -0
- package/dist/src/friday-session.d.ts +40 -0
- package/dist/src/friday-session.js +395 -0
- package/dist/src/host-config.d.ts +1 -0
- package/dist/src/host-config.js +15 -0
- package/dist/src/http/handlers/cancel.d.ts +2 -0
- package/dist/src/http/handlers/cancel.js +33 -0
- package/dist/src/http/handlers/device-approve.d.ts +2 -0
- package/dist/src/http/handlers/device-approve.js +125 -0
- package/dist/src/http/handlers/device-token.d.ts +2 -0
- package/dist/src/http/handlers/device-token.js +43 -0
- package/dist/src/http/handlers/files-download.d.ts +10 -0
- package/dist/src/http/handlers/files-download.js +210 -0
- package/dist/src/http/handlers/files-upload.d.ts +8 -0
- package/dist/src/http/handlers/files-upload.js +136 -0
- package/dist/src/http/handlers/files.d.ts +75 -0
- package/dist/src/http/handlers/files.js +305 -0
- package/dist/src/http/handlers/import.d.ts +7 -0
- package/dist/src/http/handlers/import.js +69 -0
- package/dist/src/http/handlers/info.d.ts +2 -0
- package/dist/src/http/handlers/info.js +13 -0
- package/dist/src/http/handlers/messages-list.d.ts +7 -0
- package/dist/src/http/handlers/messages-list.js +44 -0
- package/dist/src/http/handlers/messages.d.ts +34 -0
- package/dist/src/http/handlers/messages.js +476 -0
- package/dist/src/http/handlers/models-list.d.ts +10 -0
- package/dist/src/http/handlers/models-list.js +113 -0
- package/dist/src/http/handlers/nodes-approve.d.ts +2 -0
- package/dist/src/http/handlers/nodes-approve.js +146 -0
- package/dist/src/http/handlers/pair.d.ts +2 -0
- package/dist/src/http/handlers/pair.js +39 -0
- package/dist/src/http/handlers/sessions-delete.d.ts +2 -0
- package/dist/src/http/handlers/sessions-delete.js +49 -0
- package/dist/src/http/handlers/sessions-list.d.ts +8 -0
- package/dist/src/http/handlers/sessions-list.js +24 -0
- package/dist/src/http/handlers/sessions-messages-get.d.ts +2 -0
- package/dist/src/http/handlers/sessions-messages-get.js +55 -0
- package/dist/src/http/handlers/sessions-messages-post.d.ts +2 -0
- package/dist/src/http/handlers/sessions-messages-post.js +92 -0
- package/dist/src/http/handlers/sessions-messages.d.ts +2 -0
- package/dist/src/http/handlers/sessions-messages.js +135 -0
- package/dist/src/http/handlers/sessions-settings.d.ts +2 -0
- package/dist/src/http/handlers/sessions-settings.js +71 -0
- package/dist/src/http/handlers/sse.d.ts +2 -0
- package/dist/src/http/handlers/sse.js +70 -0
- package/dist/src/http/handlers/status.d.ts +2 -0
- package/dist/src/http/handlers/status.js +29 -0
- package/dist/src/http/handlers/sync.d.ts +7 -0
- package/dist/src/http/handlers/sync.js +56 -0
- package/dist/src/http/middleware/auth.d.ts +13 -0
- package/dist/src/http/middleware/auth.js +29 -0
- package/dist/src/http/middleware/body.d.ts +2 -0
- package/dist/src/http/middleware/body.js +24 -0
- package/dist/src/http/middleware/cors.d.ts +2 -0
- package/dist/src/http/middleware/cors.js +11 -0
- package/dist/src/http/server.d.ts +19 -0
- package/dist/src/http/server.js +87 -0
- package/dist/src/logging.d.ts +7 -0
- package/dist/src/logging.js +28 -0
- package/dist/src/push/apns.d.ts +15 -0
- package/dist/src/push/apns.js +56 -0
- package/dist/src/push/device-tokens.d.ts +3 -0
- package/dist/src/push/device-tokens.js +39 -0
- package/dist/src/run-metadata.d.ts +25 -0
- package/dist/src/run-metadata.js +139 -0
- package/dist/src/runtime.d.ts +13 -0
- package/dist/src/runtime.js +5 -0
- package/dist/src/session/session-manager.d.ts +22 -0
- package/dist/src/session/session-manager.js +190 -0
- package/dist/src/session-usage-snapshot.d.ts +23 -0
- package/dist/src/session-usage-snapshot.js +65 -0
- package/dist/src/sse/emitter.d.ts +59 -0
- package/dist/src/sse/emitter.js +219 -0
- package/dist/src/sse/offline-queue.d.ts +26 -0
- package/dist/src/sse/offline-queue.js +134 -0
- package/dist/src/sync/account-identity.d.ts +14 -0
- package/dist/src/sync/account-identity.js +101 -0
- package/dist/src/sync/archive.d.ts +9 -0
- package/dist/src/sync/archive.js +25 -0
- package/dist/src/sync/database.d.ts +66 -0
- package/dist/src/sync/database.js +364 -0
- package/dist/src/sync/init.d.ts +3 -0
- package/dist/src/sync/init.js +14 -0
- package/dist/src/sync/installation-id.d.ts +1 -0
- package/dist/src/sync/installation-id.js +41 -0
- package/dist/src/sync/message-accumulator.d.ts +29 -0
- package/dist/src/sync/message-accumulator.js +188 -0
- package/dist/src/sync/message-store.d.ts +68 -0
- package/dist/src/sync/message-store.js +262 -0
- package/dist/src/sync/push-store.d.ts +5 -0
- package/dist/src/sync/push-store.js +54 -0
- package/dist/src/sync/session-key.d.ts +12 -0
- package/dist/src/sync/session-key.js +47 -0
- package/dist/src/sync/sync-state.d.ts +5 -0
- package/dist/src/sync/sync-state.js +54 -0
- package/dist/src/sync/transcript-archive.d.ts +13 -0
- package/dist/src/sync/transcript-archive.js +37 -0
- package/dist/src/sync/transcript-store.d.ts +35 -0
- package/dist/src/sync/transcript-store.js +221 -0
- package/dist/src/sync/translate.d.ts +42 -0
- package/dist/src/sync/translate.js +171 -0
- package/dist/src/vendor/runtime-store.d.ts +26 -0
- package/dist/src/vendor/runtime-store.js +60 -0
- package/package.json +11 -10
- package/src/agent/subagent-registry.ts +195 -0
- package/src/channel.ts +6 -4
- package/src/e2e/subagent-smoke.e2e.test.ts +223 -0
- package/src/e2e/subagent.e2e.test.ts +502 -0
- package/src/friday-session.ts +140 -1
- package/src/http/handlers/device-approve.test.ts +0 -1
- package/src/http/handlers/device-approve.ts +0 -2
- package/src/http/handlers/files-download.ts +4 -1
- package/src/http/handlers/files.ts +7 -4
- package/src/http/handlers/messages.ts +54 -4
- package/src/http/handlers/models-list.ts +24 -2
- package/src/http/handlers/nodes-approve.test.ts +288 -0
- package/src/http/handlers/nodes-approve.ts +189 -0
- package/src/http/server.ts +5 -0
- package/src/openclaw.d.ts +5 -0
- package/src/sse/emitter.ts +1 -1
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
10
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
11
|
+
import { getExternalFileSourceByUrlToken, getFile, guessMimeType, readAttachmentFileFromDisk, readFile, } from "./files.js";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
const logger = createFridayNextLogger("files-download");
|
|
16
|
+
const MIME_FROM_EXT = {
|
|
17
|
+
png: "image/png",
|
|
18
|
+
jpg: "image/jpeg",
|
|
19
|
+
jpeg: "image/jpeg",
|
|
20
|
+
gif: "image/gif",
|
|
21
|
+
webp: "image/webp",
|
|
22
|
+
heic: "image/heic",
|
|
23
|
+
pdf: "application/pdf",
|
|
24
|
+
mp4: "video/mp4",
|
|
25
|
+
mov: "video/quicktime",
|
|
26
|
+
mp3: "audio/mpeg",
|
|
27
|
+
wav: "audio/wav",
|
|
28
|
+
ogg: "audio/ogg",
|
|
29
|
+
opus: "audio/opus",
|
|
30
|
+
m4a: "audio/mp4",
|
|
31
|
+
aac: "audio/aac",
|
|
32
|
+
flac: "audio/flac",
|
|
33
|
+
};
|
|
34
|
+
function sendError(res, status, message) {
|
|
35
|
+
if (res.headersSent)
|
|
36
|
+
return;
|
|
37
|
+
res.statusCode = status;
|
|
38
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
39
|
+
res.end(JSON.stringify({ error: message }));
|
|
40
|
+
}
|
|
41
|
+
/** Avoid decodeURIComponent throwing on malformed % sequences (would surface as 500). */
|
|
42
|
+
function tryDecodeURIComponent(segment) {
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(segment);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Safe Content-Disposition: strip CR/LF/quotes from basename; add RFC 5987 filename* for Unicode.
|
|
52
|
+
*/
|
|
53
|
+
function contentDispositionInline(filename) {
|
|
54
|
+
const base = path.basename(filename).replace(/[\r\n"]/g, "_").replace(/\\/g, "_") || "file";
|
|
55
|
+
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
56
|
+
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Full-body or single-range 206 response. iOS/Safari often sends Range for JPEG; proxies may error if mishandled.
|
|
60
|
+
*/
|
|
61
|
+
function sendBuffer(req, res, buffer, mimeType, filename) {
|
|
62
|
+
const total = buffer.length;
|
|
63
|
+
const disposition = contentDispositionInline(filename);
|
|
64
|
+
const rangeRaw = req.headers.range;
|
|
65
|
+
const range = typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
66
|
+
? rangeRaw.trim()
|
|
67
|
+
: undefined;
|
|
68
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
69
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
70
|
+
res.setHeader("Content-Type", mimeType);
|
|
71
|
+
res.setHeader("Content-Disposition", disposition);
|
|
72
|
+
if (!range || total === 0) {
|
|
73
|
+
res.statusCode = 200;
|
|
74
|
+
res.setHeader("Content-Length", String(total));
|
|
75
|
+
res.end(buffer);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const m = /^bytes=(\d*)-(\d*)$/i.exec(range);
|
|
79
|
+
if (!m) {
|
|
80
|
+
res.statusCode = 200;
|
|
81
|
+
res.setHeader("Content-Length", String(total));
|
|
82
|
+
res.end(buffer);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let start = 0;
|
|
86
|
+
let end = total - 1;
|
|
87
|
+
if (m[1] === "" && m[2] !== "") {
|
|
88
|
+
const suffixLen = parseInt(m[2], 10);
|
|
89
|
+
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
90
|
+
res.statusCode = 200;
|
|
91
|
+
res.setHeader("Content-Length", String(total));
|
|
92
|
+
res.end(buffer);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
start = Math.max(0, total - suffixLen);
|
|
96
|
+
end = total - 1;
|
|
97
|
+
}
|
|
98
|
+
else if (m[1] !== "" && m[2] === "") {
|
|
99
|
+
start = parseInt(m[1], 10);
|
|
100
|
+
end = total - 1;
|
|
101
|
+
}
|
|
102
|
+
else if (m[1] !== "" && m[2] !== "") {
|
|
103
|
+
start = parseInt(m[1], 10);
|
|
104
|
+
end = parseInt(m[2], 10);
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
107
|
+
res.statusCode = 416;
|
|
108
|
+
res.setHeader("Content-Range", `bytes */${total}`);
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (end >= total)
|
|
113
|
+
end = total - 1;
|
|
114
|
+
const chunk = buffer.subarray(start, end + 1);
|
|
115
|
+
res.statusCode = 206;
|
|
116
|
+
res.setHeader("Content-Length", String(chunk.length));
|
|
117
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
118
|
+
res.end(chunk);
|
|
119
|
+
}
|
|
120
|
+
export async function handleFilesDownload(req, res) {
|
|
121
|
+
try {
|
|
122
|
+
if (req.method !== "GET") {
|
|
123
|
+
sendError(res, 405, "Method Not Allowed");
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!extractBearerToken(req)) {
|
|
127
|
+
sendError(res, 401, "Unauthorized");
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
sendError(res, 400, "Bad request URL");
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
139
|
+
const rawSeg = segments[segments.length - 1] ?? "";
|
|
140
|
+
const fileToken = tryDecodeURIComponent(rawSeg);
|
|
141
|
+
if (fileToken === null) {
|
|
142
|
+
sendError(res, 400, "Invalid file path encoding");
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (!fileToken || fileToken === "files") {
|
|
146
|
+
sendError(res, 400, "Missing file ID");
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
// 1. Try Friday's own in-memory file index
|
|
150
|
+
const file = getFile(fileToken);
|
|
151
|
+
if (file) {
|
|
152
|
+
const { buffer, mimeType } = readFile(fileToken);
|
|
153
|
+
if (buffer) {
|
|
154
|
+
sendBuffer(req, res, buffer, mimeType, file.filename);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// 1.2 Plugin-root attachments/ (survives gateway restarts; basename = URL token)
|
|
159
|
+
const fromAttachments = readAttachmentFileFromDisk(fileToken);
|
|
160
|
+
if (fromAttachments) {
|
|
161
|
+
sendBuffer(req, res, fromAttachments.buffer, fromAttachments.mimeType, fromAttachments.filename);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
// 1.4 Best-effort external source by token (when copy-to-attachments failed earlier)
|
|
165
|
+
const externalSource = getExternalFileSourceByUrlToken(fileToken);
|
|
166
|
+
if (externalSource && fs.existsSync(externalSource)) {
|
|
167
|
+
try {
|
|
168
|
+
const buffer = fs.readFileSync(externalSource);
|
|
169
|
+
const filename = path.basename(externalSource);
|
|
170
|
+
sendBuffer(req, res, buffer, guessMimeType(filename), filename);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// continue fallback chain
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 2. Try OpenClaw media buffer (~/.openclaw/media/inbound/<id>)
|
|
178
|
+
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
179
|
+
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
180
|
+
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
181
|
+
const candidates = [
|
|
182
|
+
path.join(mediaDir, baseId),
|
|
183
|
+
path.join(mediaDir, fileToken),
|
|
184
|
+
];
|
|
185
|
+
for (const filePath of candidates) {
|
|
186
|
+
if (fs.existsSync(filePath)) {
|
|
187
|
+
try {
|
|
188
|
+
const st = fs.statSync(filePath);
|
|
189
|
+
if (!st.isFile())
|
|
190
|
+
continue;
|
|
191
|
+
const buffer = fs.readFileSync(filePath);
|
|
192
|
+
const ext = path.extname(fileToken).toLowerCase().replace(/^\./, "");
|
|
193
|
+
const mimeType = MIME_FROM_EXT[ext] ?? "application/octet-stream";
|
|
194
|
+
sendBuffer(req, res, buffer, mimeType, fileToken);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// fall through to 404
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
sendError(res, 404, "File not found");
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
logger.error(`GET download failed: ${String(err)}`);
|
|
207
|
+
sendError(res, 500, "Internal Server Error");
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
export declare function handleFilesUpload(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
8
|
+
import { storeFile, guessMimeType } from "./files.js";
|
|
9
|
+
async function parseMultipartBody(req, boundaryContentType) {
|
|
10
|
+
const boundaryMatch = boundaryContentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
11
|
+
if (!boundaryMatch)
|
|
12
|
+
return null;
|
|
13
|
+
const boundary = boundaryMatch[1] ?? boundaryMatch[2];
|
|
14
|
+
if (!boundary)
|
|
15
|
+
return null;
|
|
16
|
+
const chunks = [];
|
|
17
|
+
await new Promise((resolve, reject) => {
|
|
18
|
+
req.on("data", (c) => chunks.push(c));
|
|
19
|
+
req.on("end", resolve);
|
|
20
|
+
req.on("error", reject);
|
|
21
|
+
});
|
|
22
|
+
const body = Buffer.concat(chunks);
|
|
23
|
+
const parts = { fields: {}, files: [] };
|
|
24
|
+
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
|
25
|
+
let start = 0;
|
|
26
|
+
while (start < body.length) {
|
|
27
|
+
const idx = bufferIndexOf(body, boundaryBuffer, start);
|
|
28
|
+
if (idx === -1)
|
|
29
|
+
break;
|
|
30
|
+
const nextStart = idx + boundaryBuffer.length;
|
|
31
|
+
if (body[nextStart] === 0x2d && body[nextStart + 1] === 0x2d) {
|
|
32
|
+
// "--" after boundary = end
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
if (body[nextStart] !== 0x0d || body[nextStart + 1] !== 0x0a) {
|
|
36
|
+
start = nextStart;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const headerEnd = bufferIndexOf(body, Buffer.from("\r\n\r\n"), nextStart + 2);
|
|
40
|
+
if (headerEnd === -1) {
|
|
41
|
+
start = nextStart;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const headers = body.subarray(idx + boundaryBuffer.length + 2, headerEnd).toString("utf-8");
|
|
45
|
+
const contentDisposition = extractHeaderValue(headers, "Content-Disposition");
|
|
46
|
+
const contentTypeHeader = extractHeaderValue(headers, "Content-Type");
|
|
47
|
+
const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/);
|
|
48
|
+
const nameMatch = contentDisposition?.match(/name="([^"]+)"/);
|
|
49
|
+
const filename = filenameMatch?.[1] ?? nameMatch?.[1] ?? "file";
|
|
50
|
+
const isFile = Boolean(filenameMatch);
|
|
51
|
+
const dataStart = headerEnd + 4;
|
|
52
|
+
// Search for the closing boundary marker (\r\n--boundary) rather than just
|
|
53
|
+
// \r\n, since binary file data may contain CRLF bytes.
|
|
54
|
+
const closingBoundary = Buffer.from(`\r\n--${boundary}`);
|
|
55
|
+
const endIdx = bufferIndexOf(body, closingBoundary, dataStart);
|
|
56
|
+
const end = endIdx === -1 ? body.length - 2 : endIdx;
|
|
57
|
+
if (isFile) {
|
|
58
|
+
const buffer = body.subarray(dataStart, end);
|
|
59
|
+
const mimeType = contentTypeHeader ?? guessMimeType(filename);
|
|
60
|
+
parts.files.push({ filename, buffer, contentType: mimeType });
|
|
61
|
+
}
|
|
62
|
+
else if (nameMatch) {
|
|
63
|
+
const value = body.subarray(dataStart, end).toString("utf-8").trim();
|
|
64
|
+
parts.fields[nameMatch[1]] = value;
|
|
65
|
+
}
|
|
66
|
+
start = end + 2;
|
|
67
|
+
}
|
|
68
|
+
return parts;
|
|
69
|
+
}
|
|
70
|
+
function extractHeaderValue(headers, name) {
|
|
71
|
+
const lines = headers.split(/\r\n/);
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const [key, ...valueParts] = line.split(":");
|
|
74
|
+
if (key.trim().toLowerCase() === name.toLowerCase()) {
|
|
75
|
+
return valueParts.join(":").trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function bufferIndexOf(haystack, needle, start = 0) {
|
|
81
|
+
for (let i = start; i <= haystack.length - needle.length; i++) {
|
|
82
|
+
let match = true;
|
|
83
|
+
for (let j = 0; j < needle.length; j++) {
|
|
84
|
+
if (haystack[i + j] !== needle[j]) {
|
|
85
|
+
match = false;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (match)
|
|
90
|
+
return i;
|
|
91
|
+
}
|
|
92
|
+
return -1;
|
|
93
|
+
}
|
|
94
|
+
export async function handleFilesUpload(req, res) {
|
|
95
|
+
if (req.method !== "POST") {
|
|
96
|
+
res.statusCode = 405;
|
|
97
|
+
res.setHeader("Content-Type", "application/json");
|
|
98
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const token = extractBearerToken(req);
|
|
102
|
+
if (!token) {
|
|
103
|
+
res.statusCode = 401;
|
|
104
|
+
res.setHeader("Content-Type", "application/json");
|
|
105
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
109
|
+
const parsed = await parseMultipartBody(req, contentType);
|
|
110
|
+
if (!parsed) {
|
|
111
|
+
res.statusCode = 400;
|
|
112
|
+
res.setHeader("Content-Type", "application/json");
|
|
113
|
+
res.end(JSON.stringify({ error: "Invalid multipart form data" }));
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.files.length === 0) {
|
|
117
|
+
res.statusCode = 400;
|
|
118
|
+
res.setHeader("Content-Type", "application/json");
|
|
119
|
+
res.end(JSON.stringify({ error: "No files provided" }));
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
const files = parsed.files.map((file) => {
|
|
123
|
+
const stored = storeFile(file.buffer, file.filename, file.contentType);
|
|
124
|
+
return {
|
|
125
|
+
id: stored.id,
|
|
126
|
+
filename: stored.filename,
|
|
127
|
+
mimeType: stored.mimeType,
|
|
128
|
+
size: stored.size,
|
|
129
|
+
url: `/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
res.statusCode = 200;
|
|
133
|
+
res.setHeader("Content-Type", "application/json");
|
|
134
|
+
res.end(JSON.stringify({ files }));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File manager for Friday Next channel attachments.
|
|
3
|
+
*
|
|
4
|
+
* Files are copied under the plugin root `attachments/` and served at
|
|
5
|
+
* GET /friday-next/files/{token} so the app can use stable gateway URLs after restarts.
|
|
6
|
+
*/
|
|
7
|
+
/** Plugin-root `attachments/` directory; created on first use. */
|
|
8
|
+
export declare function getAttachmentsDir(): string;
|
|
9
|
+
export interface StoredFile {
|
|
10
|
+
id: string;
|
|
11
|
+
/** Path segment for /friday-next/files/{urlToken} (on-disk basename under attachments/). */
|
|
12
|
+
urlToken: string;
|
|
13
|
+
filename: string;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
size: number;
|
|
16
|
+
path: string;
|
|
17
|
+
createdAt: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Read a file from `attachments/` by URL path token (disk basename).
|
|
21
|
+
* Used when the in-memory index was cleared after a gateway restart.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readAttachmentFileFromDisk(fileToken: string): {
|
|
24
|
+
buffer: Buffer;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
filename: string;
|
|
27
|
+
} | null;
|
|
28
|
+
/**
|
|
29
|
+
* Copy a local file into `attachments/` and register it (no full-buffer read for the copy path).
|
|
30
|
+
*/
|
|
31
|
+
/** Expand ~, file://, etc. for paths coming from the agent / message tool. */
|
|
32
|
+
export declare function normalizeAgentMediaPath(raw: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Store a file buffer and return its ID and metadata.
|
|
35
|
+
*/
|
|
36
|
+
export declare function storeFile(buffer: Buffer, filename: string, mimeType: string): StoredFile;
|
|
37
|
+
/**
|
|
38
|
+
* Retrieve file metadata by ID or url token.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getFile(id: string): StoredFile | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Path segment for lookup: raw uuid / urlToken, or token extracted from `/friday-next/files/...`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fridayAttachmentLookupKey(ref: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Canonical gateway URL `/friday-next/files/{urlToken}` with extension when stored (for history, MediaUrls).
|
|
47
|
+
*/
|
|
48
|
+
export declare function fridayFilesPublicUrl(ref: string): string;
|
|
49
|
+
export declare function getExternalFileSourceByUrlToken(token: string): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Read a file as a Buffer with its MIME type (by id or urlToken).
|
|
52
|
+
*/
|
|
53
|
+
export declare function readFile(id: string): {
|
|
54
|
+
buffer: Buffer | null;
|
|
55
|
+
mimeType: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Copy a file from a local filesystem path into the Friday Next channel file store
|
|
59
|
+
* and return its /friday-next/files/{token} URL. If the path is already a Friday Next channel
|
|
60
|
+
* file URL (i.e. starts with "/friday-next/files/"), return it as-is.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveMediaUrl(localPath: string): string;
|
|
63
|
+
export interface ResolvedAttachment {
|
|
64
|
+
fileName: string;
|
|
65
|
+
url: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a local path into a Friday-served attachment descriptor.
|
|
69
|
+
* Returns null when source file is missing or cannot be copied.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveMediaAttachment(localPath: string): ResolvedAttachment | null;
|
|
72
|
+
/**
|
|
73
|
+
* Guess MIME type from filename extension.
|
|
74
|
+
*/
|
|
75
|
+
export declare function guessMimeType(filename: string): string;
|