* bluebubbles: harden local media path handling * bluebubbles: remove racy post-open symlink lstat * fix: bluebubbles mediaLocalRoots docs + typing fix (#16322) (thanks @mbelinky)
318 lines
9.4 KiB
TypeScript
318 lines
9.4 KiB
TypeScript
import { constants as fsConstants } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
|
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
|
import { sendMessageBlueBubbles } from "./send.js";
|
|
|
|
const HTTP_URL_RE = /^https?:\/\//i;
|
|
const MB = 1024 * 1024;
|
|
|
|
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
|
if (typeof maxBytes !== "number" || maxBytes <= 0) {
|
|
return;
|
|
}
|
|
if (sizeBytes <= maxBytes) {
|
|
return;
|
|
}
|
|
const maxLabel = (maxBytes / MB).toFixed(0);
|
|
const sizeLabel = (sizeBytes / MB).toFixed(2);
|
|
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
|
}
|
|
|
|
function resolveLocalMediaPath(source: string): string {
|
|
if (!source.startsWith("file://")) {
|
|
return source;
|
|
}
|
|
try {
|
|
return fileURLToPath(source);
|
|
} catch {
|
|
throw new Error(`Invalid file:// URL: ${source}`);
|
|
}
|
|
}
|
|
|
|
function expandHomePath(input: string): string {
|
|
if (input === "~") {
|
|
return os.homedir();
|
|
}
|
|
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
|
|
return path.join(os.homedir(), input.slice(2));
|
|
}
|
|
return input;
|
|
}
|
|
|
|
function resolveConfiguredPath(input: string): string {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
throw new Error("Empty mediaLocalRoots entry is not allowed");
|
|
}
|
|
if (trimmed.startsWith("file://")) {
|
|
let parsed: string;
|
|
try {
|
|
parsed = fileURLToPath(trimmed);
|
|
} catch {
|
|
throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
|
|
}
|
|
if (!path.isAbsolute(parsed)) {
|
|
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
const resolved = expandHomePath(trimmed);
|
|
if (!path.isAbsolute(resolved)) {
|
|
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function isPathInsideRoot(candidate: string, root: string): boolean {
|
|
const normalizedCandidate = path.normalize(candidate);
|
|
const normalizedRoot = path.normalize(root);
|
|
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
|
? normalizedRoot
|
|
: normalizedRoot + path.sep;
|
|
if (process.platform === "win32") {
|
|
const candidateLower = normalizedCandidate.toLowerCase();
|
|
const rootLower = normalizedRoot.toLowerCase();
|
|
const rootWithSepLower = rootWithSep.toLowerCase();
|
|
return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
|
|
}
|
|
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
|
|
}
|
|
|
|
function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
|
|
const account = resolveBlueBubblesAccount({
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
});
|
|
return (account.config.mediaLocalRoots ?? [])
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
async function assertLocalMediaPathAllowed(params: {
|
|
localPath: string;
|
|
localRoots: string[];
|
|
accountId?: string;
|
|
}): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> {
|
|
if (params.localRoots.length === 0) {
|
|
throw new Error(
|
|
`Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${
|
|
params.accountId
|
|
? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots`
|
|
: ""
|
|
} to explicitly allow local file directories.`,
|
|
);
|
|
}
|
|
|
|
const resolvedLocalPath = path.resolve(params.localPath);
|
|
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
|
|
|
|
for (const rootEntry of params.localRoots) {
|
|
const resolvedRootInput = resolveConfiguredPath(rootEntry);
|
|
const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
|
|
if (
|
|
relativeToRoot.startsWith("..") ||
|
|
path.isAbsolute(relativeToRoot) ||
|
|
relativeToRoot === ""
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
let rootReal: string;
|
|
try {
|
|
rootReal = await fs.realpath(resolvedRootInput);
|
|
} catch {
|
|
rootReal = path.resolve(resolvedRootInput);
|
|
}
|
|
const candidatePath = path.resolve(rootReal, relativeToRoot);
|
|
|
|
if (!isPathInsideRoot(candidatePath, rootReal)) {
|
|
continue;
|
|
}
|
|
|
|
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
|
try {
|
|
handle = await fs.open(candidatePath, openFlags);
|
|
const realPath = await fs.realpath(candidatePath);
|
|
if (!isPathInsideRoot(realPath, rootReal)) {
|
|
continue;
|
|
}
|
|
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile()) {
|
|
continue;
|
|
}
|
|
const realStat = await fs.stat(realPath);
|
|
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
|
|
continue;
|
|
}
|
|
|
|
const data = await handle.readFile();
|
|
return { data, realPath, sizeBytes: stat.size };
|
|
} catch {
|
|
// Try next configured root.
|
|
continue;
|
|
} finally {
|
|
if (handle) {
|
|
await handle.close().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`,
|
|
);
|
|
}
|
|
|
|
function resolveFilenameFromSource(source?: string): string | undefined {
|
|
if (!source) {
|
|
return undefined;
|
|
}
|
|
if (source.startsWith("file://")) {
|
|
try {
|
|
return path.basename(fileURLToPath(source)) || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
if (HTTP_URL_RE.test(source)) {
|
|
try {
|
|
return path.basename(new URL(source).pathname) || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
const base = path.basename(source);
|
|
return base || undefined;
|
|
}
|
|
|
|
export async function sendBlueBubblesMedia(params: {
|
|
cfg: OpenClawConfig;
|
|
to: string;
|
|
mediaUrl?: string;
|
|
mediaPath?: string;
|
|
mediaBuffer?: Uint8Array;
|
|
contentType?: string;
|
|
filename?: string;
|
|
caption?: string;
|
|
replyToId?: string | null;
|
|
accountId?: string;
|
|
asVoice?: boolean;
|
|
}) {
|
|
const {
|
|
cfg,
|
|
to,
|
|
mediaUrl,
|
|
mediaPath,
|
|
mediaBuffer,
|
|
contentType,
|
|
filename,
|
|
caption,
|
|
replyToId,
|
|
accountId,
|
|
asVoice,
|
|
} = params;
|
|
const core = getBlueBubblesRuntime();
|
|
const maxBytes = resolveChannelMediaMaxBytes({
|
|
cfg,
|
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
|
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
|
|
cfg.channels?.bluebubbles?.mediaMaxMb,
|
|
accountId,
|
|
});
|
|
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });
|
|
|
|
let buffer: Uint8Array;
|
|
let resolvedContentType = contentType ?? undefined;
|
|
let resolvedFilename = filename ?? undefined;
|
|
|
|
if (mediaBuffer) {
|
|
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
|
|
buffer = mediaBuffer;
|
|
if (!resolvedContentType) {
|
|
const hint = mediaPath ?? mediaUrl;
|
|
const detected = await core.media.detectMime({
|
|
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
|
|
filePath: hint,
|
|
});
|
|
resolvedContentType = detected ?? undefined;
|
|
}
|
|
if (!resolvedFilename) {
|
|
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
|
|
}
|
|
} else {
|
|
const source = mediaPath ?? mediaUrl;
|
|
if (!source) {
|
|
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
|
|
}
|
|
if (HTTP_URL_RE.test(source)) {
|
|
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
url: source,
|
|
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
|
|
});
|
|
buffer = fetched.buffer;
|
|
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
|
|
resolvedFilename = resolvedFilename ?? fetched.fileName;
|
|
} else {
|
|
const localPath = expandHomePath(resolveLocalMediaPath(source));
|
|
const localFile = await assertLocalMediaPathAllowed({
|
|
localPath,
|
|
localRoots: mediaLocalRoots,
|
|
accountId,
|
|
});
|
|
if (typeof maxBytes === "number" && maxBytes > 0) {
|
|
assertMediaWithinLimit(localFile.sizeBytes, maxBytes);
|
|
}
|
|
const data = localFile.data;
|
|
assertMediaWithinLimit(data.byteLength, maxBytes);
|
|
buffer = new Uint8Array(data);
|
|
if (!resolvedContentType) {
|
|
const detected = await core.media.detectMime({
|
|
buffer: data,
|
|
filePath: localFile.realPath,
|
|
});
|
|
resolvedContentType = detected ?? undefined;
|
|
}
|
|
if (!resolvedFilename) {
|
|
resolvedFilename = resolveFilenameFromSource(localFile.realPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve short ID (e.g., "5") to full UUID
|
|
const replyToMessageGuid = replyToId?.trim()
|
|
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
|
: undefined;
|
|
|
|
const attachmentResult = await sendBlueBubblesAttachment({
|
|
to,
|
|
buffer,
|
|
filename: resolvedFilename ?? "attachment",
|
|
contentType: resolvedContentType ?? undefined,
|
|
replyToMessageGuid,
|
|
asVoice,
|
|
opts: {
|
|
cfg,
|
|
accountId,
|
|
},
|
|
});
|
|
|
|
const trimmedCaption = caption?.trim();
|
|
if (trimmedCaption) {
|
|
await sendMessageBlueBubbles(to, trimmedCaption, {
|
|
cfg,
|
|
accountId,
|
|
replyToMessageGuid,
|
|
});
|
|
}
|
|
|
|
return attachmentResult;
|
|
}
|