@openclaw/bluebubbles 2026.2.13 → 2026.2.15
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/accounts.ts +1 -1
- package/src/actions.test.ts +52 -0
- package/src/actions.ts +34 -1
- package/src/attachments.test.ts +32 -0
- package/src/attachments.ts +17 -72
- package/src/chat.test.ts +40 -0
- package/src/chat.ts +38 -29
- package/src/config-schema.ts +1 -0
- package/src/media-send.test.ts +256 -0
- package/src/media-send.ts +150 -7
- package/src/monitor-normalize.ts +107 -153
- package/src/monitor-processing.ts +36 -8
- package/src/monitor.test.ts +328 -3
- package/src/monitor.ts +124 -32
- package/src/multipart.ts +32 -0
- package/src/probe.ts +14 -3
- package/src/reactions.ts +8 -2
- package/src/send-helpers.ts +53 -0
- package/src/send.test.ts +47 -0
- package/src/send.ts +18 -62
- package/src/targets.ts +50 -84
- package/src/types.ts +7 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
8
|
+
import { setBlueBubblesRuntime } from "./runtime.js";
|
|
9
|
+
|
|
10
|
+
const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
|
|
12
|
+
const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn((id: string) => id));
|
|
13
|
+
|
|
14
|
+
vi.mock("./attachments.js", () => ({
|
|
15
|
+
sendBlueBubblesAttachment: sendBlueBubblesAttachmentMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("./send.js", () => ({
|
|
19
|
+
sendMessageBlueBubbles: sendMessageBlueBubblesMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./monitor.js", () => ({
|
|
23
|
+
resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
type RuntimeMocks = {
|
|
27
|
+
detectMime: ReturnType<typeof vi.fn>;
|
|
28
|
+
fetchRemoteMedia: ReturnType<typeof vi.fn>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let runtimeMocks: RuntimeMocks;
|
|
32
|
+
const tempDirs: string[] = [];
|
|
33
|
+
|
|
34
|
+
function createMockRuntime(): { runtime: PluginRuntime; mocks: RuntimeMocks } {
|
|
35
|
+
const detectMime = vi.fn().mockResolvedValue("text/plain");
|
|
36
|
+
const fetchRemoteMedia = vi.fn().mockResolvedValue({
|
|
37
|
+
buffer: new Uint8Array([1, 2, 3]),
|
|
38
|
+
contentType: "image/png",
|
|
39
|
+
fileName: "remote.png",
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
runtime: {
|
|
43
|
+
version: "1.0.0",
|
|
44
|
+
media: {
|
|
45
|
+
detectMime,
|
|
46
|
+
},
|
|
47
|
+
channel: {
|
|
48
|
+
media: {
|
|
49
|
+
fetchRemoteMedia,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
} as unknown as PluginRuntime,
|
|
53
|
+
mocks: { detectMime, fetchRemoteMedia },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createConfig(overrides?: Record<string, unknown>): OpenClawConfig {
|
|
58
|
+
return {
|
|
59
|
+
channels: {
|
|
60
|
+
bluebubbles: {
|
|
61
|
+
...overrides,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} as unknown as OpenClawConfig;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function makeTempDir(): Promise<string> {
|
|
68
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bb-media-"));
|
|
69
|
+
tempDirs.push(dir);
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
const runtime = createMockRuntime();
|
|
75
|
+
runtimeMocks = runtime.mocks;
|
|
76
|
+
setBlueBubblesRuntime(runtime.runtime);
|
|
77
|
+
sendBlueBubblesAttachmentMock.mockReset();
|
|
78
|
+
sendBlueBubblesAttachmentMock.mockResolvedValue({ messageId: "msg-1" });
|
|
79
|
+
sendMessageBlueBubblesMock.mockReset();
|
|
80
|
+
sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "msg-caption" });
|
|
81
|
+
resolveBlueBubblesMessageIdMock.mockClear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
while (tempDirs.length > 0) {
|
|
86
|
+
const dir = tempDirs.pop();
|
|
87
|
+
if (!dir) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("sendBlueBubblesMedia local-path hardening", () => {
|
|
95
|
+
it("rejects local paths when mediaLocalRoots is not configured", async () => {
|
|
96
|
+
await expect(
|
|
97
|
+
sendBlueBubblesMedia({
|
|
98
|
+
cfg: createConfig(),
|
|
99
|
+
to: "chat:123",
|
|
100
|
+
mediaPath: "/etc/passwd",
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toThrow(/mediaLocalRoots/i);
|
|
103
|
+
|
|
104
|
+
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects local paths outside configured mediaLocalRoots", async () => {
|
|
108
|
+
const allowedRoot = await makeTempDir();
|
|
109
|
+
const outsideDir = await makeTempDir();
|
|
110
|
+
const outsideFile = path.join(outsideDir, "outside.txt");
|
|
111
|
+
await fs.writeFile(outsideFile, "not allowed", "utf8");
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
sendBlueBubblesMedia({
|
|
115
|
+
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
|
|
116
|
+
to: "chat:123",
|
|
117
|
+
mediaPath: outsideFile,
|
|
118
|
+
}),
|
|
119
|
+
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
|
|
120
|
+
|
|
121
|
+
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("allows local paths that are explicitly configured", async () => {
|
|
125
|
+
const allowedRoot = await makeTempDir();
|
|
126
|
+
const allowedFile = path.join(allowedRoot, "allowed.txt");
|
|
127
|
+
await fs.writeFile(allowedFile, "allowed", "utf8");
|
|
128
|
+
|
|
129
|
+
const result = await sendBlueBubblesMedia({
|
|
130
|
+
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
|
|
131
|
+
to: "chat:123",
|
|
132
|
+
mediaPath: allowedFile,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({ messageId: "msg-1" });
|
|
136
|
+
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
filename: "allowed.txt",
|
|
140
|
+
contentType: "text/plain",
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
expect(runtimeMocks.detectMime).toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("allows file:// media paths and file:// local roots", async () => {
|
|
147
|
+
const allowedRoot = await makeTempDir();
|
|
148
|
+
const allowedFile = path.join(allowedRoot, "allowed.txt");
|
|
149
|
+
await fs.writeFile(allowedFile, "allowed", "utf8");
|
|
150
|
+
|
|
151
|
+
const result = await sendBlueBubblesMedia({
|
|
152
|
+
cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
|
|
153
|
+
to: "chat:123",
|
|
154
|
+
mediaPath: pathToFileURL(allowedFile).toString(),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ messageId: "msg-1" });
|
|
158
|
+
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
filename: "allowed.txt",
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("uses account-specific mediaLocalRoots over top-level roots", async () => {
|
|
167
|
+
const baseRoot = await makeTempDir();
|
|
168
|
+
const accountRoot = await makeTempDir();
|
|
169
|
+
const baseFile = path.join(baseRoot, "base.txt");
|
|
170
|
+
const accountFile = path.join(accountRoot, "account.txt");
|
|
171
|
+
await fs.writeFile(baseFile, "base", "utf8");
|
|
172
|
+
await fs.writeFile(accountFile, "account", "utf8");
|
|
173
|
+
|
|
174
|
+
const cfg = createConfig({
|
|
175
|
+
mediaLocalRoots: [baseRoot],
|
|
176
|
+
accounts: {
|
|
177
|
+
work: {
|
|
178
|
+
mediaLocalRoots: [accountRoot],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
sendBlueBubblesMedia({
|
|
185
|
+
cfg,
|
|
186
|
+
to: "chat:123",
|
|
187
|
+
accountId: "work",
|
|
188
|
+
mediaPath: baseFile,
|
|
189
|
+
}),
|
|
190
|
+
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
|
|
191
|
+
|
|
192
|
+
const result = await sendBlueBubblesMedia({
|
|
193
|
+
cfg,
|
|
194
|
+
to: "chat:123",
|
|
195
|
+
accountId: "work",
|
|
196
|
+
mediaPath: accountFile,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result).toEqual({ messageId: "msg-1" });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("rejects symlink escapes under an allowed root", async () => {
|
|
203
|
+
const allowedRoot = await makeTempDir();
|
|
204
|
+
const outsideDir = await makeTempDir();
|
|
205
|
+
const outsideFile = path.join(outsideDir, "secret.txt");
|
|
206
|
+
const linkPath = path.join(allowedRoot, "link.txt");
|
|
207
|
+
await fs.writeFile(outsideFile, "secret", "utf8");
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await fs.symlink(outsideFile, linkPath);
|
|
211
|
+
} catch {
|
|
212
|
+
// Some environments disallow symlink creation; skip without failing the suite.
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await expect(
|
|
217
|
+
sendBlueBubblesMedia({
|
|
218
|
+
cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
|
|
219
|
+
to: "chat:123",
|
|
220
|
+
mediaPath: linkPath,
|
|
221
|
+
}),
|
|
222
|
+
).rejects.toThrow(/not under any configured mediaLocalRoots/i);
|
|
223
|
+
|
|
224
|
+
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("rejects relative mediaLocalRoots entries", async () => {
|
|
228
|
+
const allowedRoot = await makeTempDir();
|
|
229
|
+
const allowedFile = path.join(allowedRoot, "allowed.txt");
|
|
230
|
+
const relativeRoot = path.relative(process.cwd(), allowedRoot);
|
|
231
|
+
await fs.writeFile(allowedFile, "allowed", "utf8");
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
sendBlueBubblesMedia({
|
|
235
|
+
cfg: createConfig({ mediaLocalRoots: [relativeRoot] }),
|
|
236
|
+
to: "chat:123",
|
|
237
|
+
mediaPath: allowedFile,
|
|
238
|
+
}),
|
|
239
|
+
).rejects.toThrow(/must be absolute paths/i);
|
|
240
|
+
|
|
241
|
+
expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps remote URL flow unchanged", async () => {
|
|
245
|
+
await sendBlueBubblesMedia({
|
|
246
|
+
cfg: createConfig(),
|
|
247
|
+
to: "chat:123",
|
|
248
|
+
mediaUrl: "https://example.com/file.png",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith(
|
|
252
|
+
expect.objectContaining({ url: "https://example.com/file.png" }),
|
|
253
|
+
);
|
|
254
|
+
expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
});
|
package/src/media-send.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
1
4
|
import path from "node:path";
|
|
2
5
|
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
4
8
|
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
5
9
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
6
10
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
|
@@ -32,6 +36,141 @@ function resolveLocalMediaPath(source: string): string {
|
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
function expandHomePath(input: string): string {
|
|
40
|
+
if (input === "~") {
|
|
41
|
+
return os.homedir();
|
|
42
|
+
}
|
|
43
|
+
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
|
|
44
|
+
return path.join(os.homedir(), input.slice(2));
|
|
45
|
+
}
|
|
46
|
+
return input;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveConfiguredPath(input: string): string {
|
|
50
|
+
const trimmed = input.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
throw new Error("Empty mediaLocalRoots entry is not allowed");
|
|
53
|
+
}
|
|
54
|
+
if (trimmed.startsWith("file://")) {
|
|
55
|
+
let parsed: string;
|
|
56
|
+
try {
|
|
57
|
+
parsed = fileURLToPath(trimmed);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
|
|
60
|
+
}
|
|
61
|
+
if (!path.isAbsolute(parsed)) {
|
|
62
|
+
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
const resolved = expandHomePath(trimmed);
|
|
67
|
+
if (!path.isAbsolute(resolved)) {
|
|
68
|
+
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
|
|
69
|
+
}
|
|
70
|
+
return resolved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPathInsideRoot(candidate: string, root: string): boolean {
|
|
74
|
+
const normalizedCandidate = path.normalize(candidate);
|
|
75
|
+
const normalizedRoot = path.normalize(root);
|
|
76
|
+
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
|
77
|
+
? normalizedRoot
|
|
78
|
+
: normalizedRoot + path.sep;
|
|
79
|
+
if (process.platform === "win32") {
|
|
80
|
+
const candidateLower = normalizedCandidate.toLowerCase();
|
|
81
|
+
const rootLower = normalizedRoot.toLowerCase();
|
|
82
|
+
const rootWithSepLower = rootWithSep.toLowerCase();
|
|
83
|
+
return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
|
|
84
|
+
}
|
|
85
|
+
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
|
|
89
|
+
const account = resolveBlueBubblesAccount({
|
|
90
|
+
cfg: params.cfg,
|
|
91
|
+
accountId: params.accountId,
|
|
92
|
+
});
|
|
93
|
+
return (account.config.mediaLocalRoots ?? [])
|
|
94
|
+
.map((entry) => entry.trim())
|
|
95
|
+
.filter((entry) => entry.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function assertLocalMediaPathAllowed(params: {
|
|
99
|
+
localPath: string;
|
|
100
|
+
localRoots: string[];
|
|
101
|
+
accountId?: string;
|
|
102
|
+
}): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> {
|
|
103
|
+
if (params.localRoots.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${
|
|
106
|
+
params.accountId
|
|
107
|
+
? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots`
|
|
108
|
+
: ""
|
|
109
|
+
} to explicitly allow local file directories.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resolvedLocalPath = path.resolve(params.localPath);
|
|
114
|
+
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
115
|
+
const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
|
|
116
|
+
|
|
117
|
+
for (const rootEntry of params.localRoots) {
|
|
118
|
+
const resolvedRootInput = resolveConfiguredPath(rootEntry);
|
|
119
|
+
const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
|
|
120
|
+
if (
|
|
121
|
+
relativeToRoot.startsWith("..") ||
|
|
122
|
+
path.isAbsolute(relativeToRoot) ||
|
|
123
|
+
relativeToRoot === ""
|
|
124
|
+
) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let rootReal: string;
|
|
129
|
+
try {
|
|
130
|
+
rootReal = await fs.realpath(resolvedRootInput);
|
|
131
|
+
} catch {
|
|
132
|
+
rootReal = path.resolve(resolvedRootInput);
|
|
133
|
+
}
|
|
134
|
+
const candidatePath = path.resolve(rootReal, relativeToRoot);
|
|
135
|
+
|
|
136
|
+
if (!isPathInsideRoot(candidatePath, rootReal)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
|
141
|
+
try {
|
|
142
|
+
handle = await fs.open(candidatePath, openFlags);
|
|
143
|
+
const realPath = await fs.realpath(candidatePath);
|
|
144
|
+
if (!isPathInsideRoot(realPath, rootReal)) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stat = await handle.stat();
|
|
149
|
+
if (!stat.isFile()) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const realStat = await fs.stat(realPath);
|
|
153
|
+
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const data = await handle.readFile();
|
|
158
|
+
return { data, realPath, sizeBytes: stat.size };
|
|
159
|
+
} catch {
|
|
160
|
+
// Try next configured root.
|
|
161
|
+
continue;
|
|
162
|
+
} finally {
|
|
163
|
+
if (handle) {
|
|
164
|
+
await handle.close().catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
35
174
|
function resolveFilenameFromSource(source?: string): string | undefined {
|
|
36
175
|
if (!source) {
|
|
37
176
|
return undefined;
|
|
@@ -88,6 +227,7 @@ export async function sendBlueBubblesMedia(params: {
|
|
|
88
227
|
cfg.channels?.bluebubbles?.mediaMaxMb,
|
|
89
228
|
accountId,
|
|
90
229
|
});
|
|
230
|
+
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });
|
|
91
231
|
|
|
92
232
|
let buffer: Uint8Array;
|
|
93
233
|
let resolvedContentType = contentType ?? undefined;
|
|
@@ -121,24 +261,27 @@ export async function sendBlueBubblesMedia(params: {
|
|
|
121
261
|
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
|
|
122
262
|
resolvedFilename = resolvedFilename ?? fetched.fileName;
|
|
123
263
|
} else {
|
|
124
|
-
const localPath = resolveLocalMediaPath(source);
|
|
125
|
-
const
|
|
264
|
+
const localPath = expandHomePath(resolveLocalMediaPath(source));
|
|
265
|
+
const localFile = await assertLocalMediaPathAllowed({
|
|
266
|
+
localPath,
|
|
267
|
+
localRoots: mediaLocalRoots,
|
|
268
|
+
accountId,
|
|
269
|
+
});
|
|
126
270
|
if (typeof maxBytes === "number" && maxBytes > 0) {
|
|
127
|
-
|
|
128
|
-
assertMediaWithinLimit(stats.size, maxBytes);
|
|
271
|
+
assertMediaWithinLimit(localFile.sizeBytes, maxBytes);
|
|
129
272
|
}
|
|
130
|
-
const data =
|
|
273
|
+
const data = localFile.data;
|
|
131
274
|
assertMediaWithinLimit(data.byteLength, maxBytes);
|
|
132
275
|
buffer = new Uint8Array(data);
|
|
133
276
|
if (!resolvedContentType) {
|
|
134
277
|
const detected = await core.media.detectMime({
|
|
135
278
|
buffer: data,
|
|
136
|
-
filePath:
|
|
279
|
+
filePath: localFile.realPath,
|
|
137
280
|
});
|
|
138
281
|
resolvedContentType = detected ?? undefined;
|
|
139
282
|
}
|
|
140
283
|
if (!resolvedFilename) {
|
|
141
|
-
resolvedFilename = resolveFilenameFromSource(
|
|
284
|
+
resolvedFilename = resolveFilenameFromSource(localFile.realPath);
|
|
142
285
|
}
|
|
143
286
|
}
|
|
144
287
|
}
|