fix: use file-type for mime sniffing
parent
1356498ee1
commit
36c85a617a
|
|
@ -55,6 +55,7 @@
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"file-type": "^21.1.1",
|
||||||
"grammy": "^1.38.4",
|
"grammy": "^1.38.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"playwright-core": "1.57.0",
|
"playwright-core": "1.57.0",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,7 +20,9 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
||||||
// all base64 image blocks above this limit while preserving aspect ratio.
|
// all base64 image blocks above this limit while preserving aspect ratio.
|
||||||
const MAX_IMAGE_DIMENSION_PX = 2000;
|
const MAX_IMAGE_DIMENSION_PX = 2000;
|
||||||
|
|
||||||
function sniffMimeFromBase64(base64: string): string | undefined {
|
async function sniffMimeFromBase64(
|
||||||
|
base64: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const trimmed = base64.trim();
|
const trimmed = base64.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
|
@ -30,7 +32,7 @@ function sniffMimeFromBase64(base64: string): string | undefined {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const head = Buffer.from(trimmed.slice(0, sliceLen), "base64");
|
const head = Buffer.from(trimmed.slice(0, sliceLen), "base64");
|
||||||
return detectMime({ buffer: head });
|
return await detectMime({ buffer: head });
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -44,10 +46,10 @@ function rewriteReadImageHeader(text: string, mimeType: string): string {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReadImageResult(
|
async function normalizeReadImageResult(
|
||||||
result: AgentToolResult<unknown>,
|
result: AgentToolResult<unknown>,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): AgentToolResult<unknown> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const content = Array.isArray(result.content) ? result.content : [];
|
const content = Array.isArray(result.content) ? result.content : [];
|
||||||
|
|
||||||
const image = content.find(
|
const image = content.find(
|
||||||
|
|
@ -64,7 +66,7 @@ function normalizeReadImageResult(
|
||||||
throw new Error(`read: image payload is empty (${filePath})`);
|
throw new Error(`read: image payload is empty (${filePath})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sniffed = sniffMimeFromBase64(image.data);
|
const sniffed = await sniffMimeFromBase64(image.data);
|
||||||
if (!sniffed) return result;
|
if (!sniffed) return result;
|
||||||
|
|
||||||
if (!sniffed.startsWith("image/")) {
|
if (!sniffed.startsWith("image/")) {
|
||||||
|
|
@ -233,7 +235,7 @@ async function resizeImageBase64IfNeeded(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sniffed = detectMime({ buffer: out.slice(0, 256) });
|
const sniffed = await detectMime({ buffer: out.slice(0, 256) });
|
||||||
const nextMime = sniffed?.startsWith("image/") ? sniffed : params.mimeType;
|
const nextMime = sniffed?.startsWith("image/") ? sniffed : params.mimeType;
|
||||||
|
|
||||||
return { base64: out.toString("base64"), mimeType: nextMime, resized: true };
|
return { base64: out.toString("base64"), mimeType: nextMime, resized: true };
|
||||||
|
|
@ -310,7 +312,7 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool {
|
||||||
: undefined;
|
: undefined;
|
||||||
const filePath =
|
const filePath =
|
||||||
typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
||||||
const normalized = normalizeReadImageResult(result, filePath);
|
const normalized = await normalizeReadImageResult(result, filePath);
|
||||||
return sanitizeToolResultImages(normalized, `read:${filePath}`);
|
return sanitizeToolResultImages(normalized, `read:${filePath}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ export async function handleA2uiHttpRequest(
|
||||||
const mime =
|
const mime =
|
||||||
lower.endsWith(".html") || lower.endsWith(".htm")
|
lower.endsWith(".html") || lower.endsWith(".htm")
|
||||||
? "text/html"
|
? "text/html"
|
||||||
: (detectMime({ filePath }) ?? "application/octet-stream");
|
: ((await detectMime({ filePath })) ?? "application/octet-stream");
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
|
||||||
if (mime === "text/html") {
|
if (mime === "text/html") {
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ export async function createCanvasHostHandler(
|
||||||
const mime =
|
const mime =
|
||||||
lower.endsWith(".html") || lower.endsWith(".htm")
|
lower.endsWith(".html") || lower.endsWith(".htm")
|
||||||
? "text/html"
|
? "text/html"
|
||||||
: (detectMime({ filePath }) ?? "application/octet-stream");
|
: ((await detectMime({ filePath })) ?? "application/octet-stream");
|
||||||
|
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
if (mime === "text/html") {
|
if (mime === "text/html") {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { fileTypeFromBuffer } from "file-type";
|
||||||
import { type MediaKind, mediaKindFromMime } from "./constants.js";
|
import { type MediaKind, mediaKindFromMime } from "./constants.js";
|
||||||
|
|
||||||
// Map common mimes to preferred file extensions.
|
// Map common mimes to preferred file extensions.
|
||||||
|
|
@ -12,7 +13,23 @@ const EXT_BY_MIME: Record<string, string> = {
|
||||||
"audio/mpeg": ".mp3",
|
"audio/mpeg": ".mp3",
|
||||||
"video/mp4": ".mp4",
|
"video/mp4": ".mp4",
|
||||||
"application/pdf": ".pdf",
|
"application/pdf": ".pdf",
|
||||||
|
"application/json": ".json",
|
||||||
|
"application/zip": ".zip",
|
||||||
|
"application/gzip": ".gz",
|
||||||
|
"application/x-tar": ".tar",
|
||||||
|
"application/x-7z-compressed": ".7z",
|
||||||
|
"application/vnd.rar": ".rar",
|
||||||
|
"application/msword": ".doc",
|
||||||
|
"application/vnd.ms-excel": ".xls",
|
||||||
|
"application/vnd.ms-powerpoint": ".ppt",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
|
".docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||||
|
".pptx",
|
||||||
|
"text/csv": ".csv",
|
||||||
"text/plain": ".txt",
|
"text/plain": ".txt",
|
||||||
|
"text/markdown": ".md",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
|
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
|
||||||
|
|
@ -25,71 +42,14 @@ function normalizeHeaderMime(mime?: string | null): string | undefined {
|
||||||
return cleaned || undefined;
|
return cleaned || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sniffMime(buffer?: Buffer): string | undefined {
|
async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
|
||||||
if (!buffer || buffer.length < 4) return undefined;
|
if (!buffer) return undefined;
|
||||||
|
try {
|
||||||
// JPEG: FF D8 FF
|
const type = await fileTypeFromBuffer(buffer);
|
||||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
return type?.mime ?? undefined;
|
||||||
return "image/jpeg";
|
} catch {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
||||||
if (
|
|
||||||
buffer.length >= 8 &&
|
|
||||||
buffer[0] === 0x89 &&
|
|
||||||
buffer[1] === 0x50 &&
|
|
||||||
buffer[2] === 0x4e &&
|
|
||||||
buffer[3] === 0x47 &&
|
|
||||||
buffer[4] === 0x0d &&
|
|
||||||
buffer[5] === 0x0a &&
|
|
||||||
buffer[6] === 0x1a &&
|
|
||||||
buffer[7] === 0x0a
|
|
||||||
) {
|
|
||||||
return "image/png";
|
|
||||||
}
|
|
||||||
|
|
||||||
// GIF: GIF87a / GIF89a
|
|
||||||
if (buffer.length >= 6) {
|
|
||||||
const sig = buffer.subarray(0, 6).toString("ascii");
|
|
||||||
if (sig === "GIF87a" || sig === "GIF89a") return "image/gif";
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebP: RIFF....WEBP
|
|
||||||
if (
|
|
||||||
buffer.length >= 12 &&
|
|
||||||
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
||||||
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
||||||
) {
|
|
||||||
return "image/webp";
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF: %PDF-
|
|
||||||
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
|
|
||||||
return "application/pdf";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ogg / Opus: OggS
|
|
||||||
if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
|
|
||||||
return "audio/ogg";
|
|
||||||
}
|
|
||||||
|
|
||||||
// MP3: ID3 tag or frame sync FF E0+.
|
|
||||||
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
|
||||||
return "audio/mpeg";
|
|
||||||
}
|
|
||||||
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
|
|
||||||
return "audio/mpeg";
|
|
||||||
}
|
|
||||||
|
|
||||||
// MP4: "ftyp" at offset 4.
|
|
||||||
if (
|
|
||||||
buffer.length >= 12 &&
|
|
||||||
buffer.subarray(4, 8).toString("ascii") === "ftyp"
|
|
||||||
) {
|
|
||||||
return "video/mp4";
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extFromPath(filePath?: string): string | undefined {
|
function extFromPath(filePath?: string): string | undefined {
|
||||||
|
|
@ -110,15 +70,34 @@ export function detectMime(opts: {
|
||||||
buffer?: Buffer;
|
buffer?: Buffer;
|
||||||
headerMime?: string | null;
|
headerMime?: string | null;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
}): string | undefined {
|
}): Promise<string | undefined> {
|
||||||
const sniffed = sniffMime(opts.buffer);
|
return detectMimeImpl(opts);
|
||||||
if (sniffed) return sniffed;
|
}
|
||||||
|
|
||||||
|
function isGenericMime(mime?: string): boolean {
|
||||||
|
if (!mime) return true;
|
||||||
|
const m = mime.toLowerCase();
|
||||||
|
return m === "application/octet-stream" || m === "application/zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectMimeImpl(opts: {
|
||||||
|
buffer?: Buffer;
|
||||||
|
headerMime?: string | null;
|
||||||
|
filePath?: string;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const ext = extFromPath(opts.filePath);
|
||||||
|
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
||||||
|
|
||||||
const headerMime = normalizeHeaderMime(opts.headerMime);
|
const headerMime = normalizeHeaderMime(opts.headerMime);
|
||||||
if (headerMime) return headerMime;
|
const sniffed = await sniffMime(opts.buffer);
|
||||||
|
|
||||||
const ext = extFromPath(opts.filePath);
|
// Prefer sniffed types, but don't let generic container types override a more
|
||||||
if (ext && MIME_BY_EXT[ext]) return MIME_BY_EXT[ext];
|
// specific extension mapping (e.g. XLSX vs ZIP).
|
||||||
|
if (sniffed && (!isGenericMime(sniffed) || !extMime)) return sniffed;
|
||||||
|
if (extMime) return extMime;
|
||||||
|
if (headerMime && !isGenericMime(headerMime)) return headerMime;
|
||||||
|
if (sniffed) return sniffed;
|
||||||
|
if (headerMime) return headerMime;
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function attachMediaRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await fs.readFile(realPath);
|
const data = await fs.readFile(realPath);
|
||||||
const mime = detectMime({ buffer: data, filePath: realPath });
|
const mime = await detectMime({ buffer: data, filePath: realPath });
|
||||||
if (mime) res.type(mime);
|
if (mime) res.type(mime);
|
||||||
res.send(data);
|
res.send(data);
|
||||||
// best-effort single-use cleanup after response ends
|
// best-effort single-use cleanup after response ends
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,16 @@ import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import JSZip from "jszip";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||||
const HOME = path.join(realOs.tmpdir(), "clawdis-home-redirect");
|
const HOME = path.join(realOs.tmpdir(), "clawdis-home-redirect");
|
||||||
|
|
@ -25,6 +34,10 @@ describe("media store redirects", () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRequest.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -71,4 +84,47 @@ describe("media store redirects", () => {
|
||||||
expect(path.extname(saved.path)).toBe(".txt");
|
expect(path.extname(saved.path)).toBe(".txt");
|
||||||
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
|
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sniffs xlsx from zip content when headers and url extension are missing", async () => {
|
||||||
|
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
||||||
|
const res = new PassThrough();
|
||||||
|
const req = {
|
||||||
|
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
if (event === "error") res.on("error", handler);
|
||||||
|
return req;
|
||||||
|
},
|
||||||
|
end: () => undefined,
|
||||||
|
destroy: () => res.destroy(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = {};
|
||||||
|
setImmediate(() => {
|
||||||
|
cb(res as unknown as Parameters<typeof cb>[0]);
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"[Content_Types].xml",
|
||||||
|
'<Types><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>',
|
||||||
|
);
|
||||||
|
zip.file("xl/workbook.xml", "<workbook/>");
|
||||||
|
void zip
|
||||||
|
.generateAsync({ type: "nodebuffer" })
|
||||||
|
.then((buf) => {
|
||||||
|
res.write(buf);
|
||||||
|
res.end();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
res.destroy(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await saveMediaSource("https://example.com/download");
|
||||||
|
expect(saved.contentType).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
expect(path.extname(saved.path)).toBe(".xlsx");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import JSZip from "jszip";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
|
@ -70,6 +71,18 @@ describe("media store", () => {
|
||||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets correct mime for xlsx by extension", async () => {
|
||||||
|
const xlsxPath = path.join(HOME, "sheet.xlsx");
|
||||||
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
|
await fs.writeFile(xlsxPath, "not really an xlsx");
|
||||||
|
|
||||||
|
const saved = await store.saveMediaSource(xlsxPath);
|
||||||
|
expect(saved.contentType).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
expect(path.extname(saved.path)).toBe(".xlsx");
|
||||||
|
});
|
||||||
|
|
||||||
it("renames media based on detected mime even when extension is wrong", async () => {
|
it("renames media based on detected mime even when extension is wrong", async () => {
|
||||||
const pngBytes = await sharp({
|
const pngBytes = await sharp({
|
||||||
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
|
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
|
||||||
|
|
@ -86,4 +99,22 @@ describe("media store", () => {
|
||||||
const buf = await fs.readFile(saved.path);
|
const buf = await fs.readFile(saved.path);
|
||||||
expect(buf.equals(pngBytes)).toBe(true);
|
expect(buf.equals(pngBytes)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sniffs xlsx mime for zip buffers and renames extension", async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file(
|
||||||
|
"[Content_Types].xml",
|
||||||
|
'<Types><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>',
|
||||||
|
);
|
||||||
|
zip.file("xl/workbook.xml", "<workbook/>");
|
||||||
|
const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" });
|
||||||
|
const bogusExt = path.join(HOME, "sheet.bin");
|
||||||
|
await fs.writeFile(bogusExt, fakeXlsx);
|
||||||
|
|
||||||
|
const saved = await store.saveMediaSource(bogusExt);
|
||||||
|
expect(saved.contentType).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
expect(path.extname(saved.path)).toBe(".xlsx");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export async function saveMediaSource(
|
||||||
tempDest,
|
tempDest,
|
||||||
headers,
|
headers,
|
||||||
);
|
);
|
||||||
const mime = detectMime({
|
const mime = await detectMime({
|
||||||
buffer: sniffBuffer,
|
buffer: sniffBuffer,
|
||||||
headerMime,
|
headerMime,
|
||||||
filePath: source,
|
filePath: source,
|
||||||
|
|
@ -147,7 +147,7 @@ export async function saveMediaSource(
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
const buffer = await fs.readFile(source);
|
const buffer = await fs.readFile(source);
|
||||||
const mime = detectMime({ buffer, filePath: source });
|
const mime = await detectMime({ buffer, filePath: source });
|
||||||
const ext = extensionForMime(mime) ?? path.extname(source);
|
const ext = extensionForMime(mime) ?? path.extname(source);
|
||||||
const id = ext ? `${baseId}${ext}` : baseId;
|
const id = ext ? `${baseId}${ext}` : baseId;
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
|
|
@ -169,7 +169,7 @@ export async function saveMediaBuffer(
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const baseId = crypto.randomUUID();
|
const baseId = crypto.randomUUID();
|
||||||
const mime = detectMime({ buffer, headerMime: contentType });
|
const mime = await detectMime({ buffer, headerMime: contentType });
|
||||||
const ext = extensionForMime(mime);
|
const ext = extensionForMime(mime);
|
||||||
const id = ext ? `${baseId}${ext}` : baseId;
|
const id = ext ? `${baseId}${ext}` : baseId;
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ async function resolveMedia(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = Buffer.from(await res.arrayBuffer());
|
const data = Buffer.from(await res.arrayBuffer());
|
||||||
const mime = detectMime({
|
const mime = await detectMime({
|
||||||
buffer: data,
|
buffer: data,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: file.file_path,
|
filePath: file.file_path,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export async function downloadTelegramFile(
|
||||||
throw new Error(`Failed to download telegram file: HTTP ${res.status}`);
|
throw new Error(`Failed to download telegram file: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const array = Buffer.from(await res.arrayBuffer());
|
const array = Buffer.from(await res.arrayBuffer());
|
||||||
const mime = detectMime({
|
const mime = await detectMime({
|
||||||
buffer: array,
|
buffer: array,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: info.file_path,
|
filePath: info.file_path,
|
||||||
|
|
|
||||||
|
|
@ -624,7 +624,7 @@ async function deliverWebReply(params: {
|
||||||
"media:video",
|
"media:video",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const fileName = mediaUrl.split("/").pop() ?? "file";
|
const fileName = media.fileName ?? mediaUrl.split("/").pop() ?? "file";
|
||||||
const mimetype = media.contentType ?? "application/octet-stream";
|
const mimetype = media.contentType ?? "application/octet-stream";
|
||||||
await sendWithRetry(
|
await sendWithRetry(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
mediaKindFromMime,
|
mediaKindFromMime,
|
||||||
} from "../media/constants.js";
|
} from "../media/constants.js";
|
||||||
import { resizeToJpeg } from "../media/image-ops.js";
|
import { resizeToJpeg } from "../media/image-ops.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime, extensionForMime } from "../media/mime.js";
|
||||||
|
|
||||||
export async function loadWebMedia(
|
export async function loadWebMedia(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
|
|
@ -59,11 +59,15 @@ export async function loadWebMedia(
|
||||||
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const array = Buffer.from(await res.arrayBuffer());
|
const array = Buffer.from(await res.arrayBuffer());
|
||||||
const contentType = detectMime({
|
const contentType = await detectMime({
|
||||||
buffer: array,
|
buffer: array,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: mediaUrl,
|
filePath: mediaUrl,
|
||||||
});
|
});
|
||||||
|
if (fileName && !path.extname(fileName) && contentType) {
|
||||||
|
const ext = extensionForMime(contentType);
|
||||||
|
if (ext) fileName = `${fileName}${ext}`;
|
||||||
|
}
|
||||||
const kind = mediaKindFromMime(contentType);
|
const kind = mediaKindFromMime(contentType);
|
||||||
const cap = Math.min(
|
const cap = Math.min(
|
||||||
maxBytes ?? maxBytesForKind(kind),
|
maxBytes ?? maxBytesForKind(kind),
|
||||||
|
|
@ -89,9 +93,13 @@ export async function loadWebMedia(
|
||||||
|
|
||||||
// Local path
|
// Local path
|
||||||
const data = await fs.readFile(mediaUrl);
|
const data = await fs.readFile(mediaUrl);
|
||||||
const mime = detectMime({ buffer: data, filePath: mediaUrl });
|
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
|
||||||
const kind = mediaKindFromMime(mime);
|
const kind = mediaKindFromMime(mime);
|
||||||
const fileName = path.basename(mediaUrl) || undefined;
|
let fileName = path.basename(mediaUrl) || undefined;
|
||||||
|
if (fileName && !path.extname(fileName) && mime) {
|
||||||
|
const ext = extensionForMime(mime);
|
||||||
|
if (ext) fileName = `${fileName}${ext}`;
|
||||||
|
}
|
||||||
const cap = Math.min(
|
const cap = Math.min(
|
||||||
maxBytes ?? maxBytesForKind(kind),
|
maxBytes ?? maxBytesForKind(kind),
|
||||||
maxBytesForKind(kind),
|
maxBytesForKind(kind),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue