Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
70
packages/media-core/package.json
Normal file
70
packages/media-core/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@openclaw/media-core",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./base64": {
|
||||
"types": "./dist/base64.d.mts",
|
||||
"import": "./dist/base64.mjs",
|
||||
"default": "./dist/base64.mjs"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./dist/constants.d.mts",
|
||||
"import": "./dist/constants.mjs",
|
||||
"default": "./dist/constants.mjs"
|
||||
},
|
||||
"./content-length": {
|
||||
"types": "./dist/content-length.d.mts",
|
||||
"import": "./dist/content-length.mjs",
|
||||
"default": "./dist/content-length.mjs"
|
||||
},
|
||||
"./file-name": {
|
||||
"types": "./dist/file-name.d.mts",
|
||||
"import": "./dist/file-name.mjs",
|
||||
"default": "./dist/file-name.mjs"
|
||||
},
|
||||
"./inbound-path-policy": {
|
||||
"types": "./dist/inbound-path-policy.d.mts",
|
||||
"import": "./dist/inbound-path-policy.mjs",
|
||||
"default": "./dist/inbound-path-policy.mjs"
|
||||
},
|
||||
"./inline-image-data-url": {
|
||||
"types": "./dist/inline-image-data-url.d.mts",
|
||||
"import": "./dist/inline-image-data-url.mjs",
|
||||
"default": "./dist/inline-image-data-url.mjs"
|
||||
},
|
||||
"./media-source-url": {
|
||||
"types": "./dist/media-source-url.d.mts",
|
||||
"import": "./dist/media-source-url.mjs",
|
||||
"default": "./dist/media-source-url.mjs"
|
||||
},
|
||||
"./mime": {
|
||||
"types": "./dist/mime.d.mts",
|
||||
"import": "./dist/mime.mjs",
|
||||
"default": "./dist/mime.mjs"
|
||||
},
|
||||
"./read-byte-stream-with-limit": {
|
||||
"types": "./dist/read-byte-stream-with-limit.d.mts",
|
||||
"import": "./dist/read-byte-stream-with-limit.mjs",
|
||||
"default": "./dist/read-byte-stream-with-limit.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@openclaw/normalization-core": "workspace:*",
|
||||
"file-type": "22.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/base64.ts src/constants.ts src/content-length.ts src/file-name.ts src/inbound-path-policy.ts src/inline-image-data-url.ts src/media-source-url.ts src/mime.ts src/read-byte-stream-with-limit.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
}
|
||||
}
|
||||
44
packages/media-core/src/base64.test.ts
Normal file
44
packages/media-core/src/base64.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Media Core tests cover base64 behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
|
||||
|
||||
describe("base64 helpers", () => {
|
||||
function expectBase64HelperCase<T>(actual: T, expected: T) {
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "canonicalizeBase64 normalizes whitespace and keeps valid base64",
|
||||
actual: canonicalizeBase64(" SGV s bG8= \n"),
|
||||
expected: "SGVsbG8=",
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 pads valid unpadded base64",
|
||||
actual: canonicalizeBase64("SGVsbG8"),
|
||||
expected: "SGVsbG8=",
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 rejects impossible unpadded length",
|
||||
actual: canonicalizeBase64("S"),
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 rejects invalid base64 characters",
|
||||
actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'),
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "estimateBase64DecodedBytes handles whitespace",
|
||||
actual: estimateBase64DecodedBytes("SGV s bG8= \n"),
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "estimateBase64DecodedBytes handles empty input",
|
||||
actual: estimateBase64DecodedBytes(""),
|
||||
expected: 0,
|
||||
},
|
||||
] as const)("$name", ({ actual, expected }) => {
|
||||
expectBase64HelperCase(actual, expected);
|
||||
});
|
||||
});
|
||||
88
packages/media-core/src/base64.ts
Normal file
88
packages/media-core/src/base64.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/** Estimates decoded bytes without allocating a cleaned copy of the base64 payload. */
|
||||
export function estimateBase64DecodedBytes(base64: string): number {
|
||||
// Avoid `trim()`/`replace()` here: they allocate a second (potentially huge) string.
|
||||
// We only need a conservative decoded-size estimate to enforce budgets before Buffer.from(..., "base64").
|
||||
let effectiveLen = 0;
|
||||
for (let i = 0; i < base64.length; i += 1) {
|
||||
const code = base64.charCodeAt(i);
|
||||
// Treat ASCII control + space as whitespace; base64 decoders commonly ignore these.
|
||||
if (code <= 0x20) {
|
||||
continue;
|
||||
}
|
||||
effectiveLen += 1;
|
||||
}
|
||||
|
||||
if (effectiveLen === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
// Find last non-whitespace char(s) to detect '=' padding without allocating/copying.
|
||||
let end = base64.length - 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 1;
|
||||
end -= 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
|
||||
return Math.max(0, estimated);
|
||||
}
|
||||
|
||||
function isBase64DataChar(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code === 0x2b ||
|
||||
code === 0x2f
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and validates a base64 string, returning canonical no-whitespace
|
||||
* base64 only when the input has valid alphabet, padding, and length.
|
||||
*/
|
||||
export function canonicalizeBase64(base64: string): string | undefined {
|
||||
let cleaned = "";
|
||||
let padding = 0;
|
||||
let sawPadding = false;
|
||||
for (let i = 0; i < base64.length; i += 1) {
|
||||
const code = base64.charCodeAt(i);
|
||||
if (code <= 0x20) {
|
||||
continue;
|
||||
}
|
||||
if (code === 0x3d) {
|
||||
padding += 1;
|
||||
if (padding > 2) {
|
||||
return undefined;
|
||||
}
|
||||
sawPadding = true;
|
||||
cleaned += "=";
|
||||
continue;
|
||||
}
|
||||
if (sawPadding || !isBase64DataChar(code)) {
|
||||
return undefined;
|
||||
}
|
||||
cleaned += base64[i];
|
||||
}
|
||||
if (!cleaned) {
|
||||
return undefined;
|
||||
}
|
||||
const remainder = cleaned.length % 4;
|
||||
if (remainder !== 0) {
|
||||
if (sawPadding || remainder === 1) {
|
||||
return undefined;
|
||||
}
|
||||
cleaned += "=".repeat(4 - remainder);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
53
packages/media-core/src/constants.ts
Normal file
53
packages/media-core/src/constants.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Default outbound image payload cap shared by media loaders and adapters. */
|
||||
export const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6MB
|
||||
/** Default outbound audio payload cap shared by media loaders and adapters. */
|
||||
export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
/** Default outbound video payload cap shared by media loaders and adapters. */
|
||||
export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
/** Default outbound document payload cap shared by media loaders and adapters. */
|
||||
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/** Media families that share size-policy and MIME-classification behavior. */
|
||||
export type MediaKind = "image" | "audio" | "video" | "document";
|
||||
|
||||
/** Maps a MIME type to the media family used for size limits and routing. */
|
||||
export function mediaKindFromMime(mime?: string | null): MediaKind | undefined {
|
||||
if (!mime) {
|
||||
return undefined;
|
||||
}
|
||||
if (mime.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (mime === "application/pdf") {
|
||||
return "document";
|
||||
}
|
||||
if (mime.startsWith("text/")) {
|
||||
return "document";
|
||||
}
|
||||
if (mime.startsWith("application/")) {
|
||||
return "document";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns the default byte cap for a classified media family. */
|
||||
export function maxBytesForKind(kind: MediaKind): number {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MAX_IMAGE_BYTES;
|
||||
case "audio":
|
||||
return MAX_AUDIO_BYTES;
|
||||
case "video":
|
||||
return MAX_VIDEO_BYTES;
|
||||
case "document":
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
default:
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
}
|
||||
}
|
||||
15
packages/media-core/src/content-length.ts
Normal file
15
packages/media-core/src/content-length.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Parses a Content-Length header as a safe integer or rejects malformed values. */
|
||||
export function parseMediaContentLength(raw: string | null): number | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
throw new Error(`invalid content-length header: ${raw}`);
|
||||
}
|
||||
const size = Number(trimmed);
|
||||
if (!Number.isSafeInteger(size)) {
|
||||
throw new Error(`invalid content-length header: ${raw}`);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
19
packages/media-core/src/file-name.ts
Normal file
19
packages/media-core/src/file-name.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Media Core module implements file name behavior.
|
||||
import path from "node:path";
|
||||
|
||||
/** Returns the final filename segment for either POSIX or Windows-style paths. */
|
||||
export function basenameFromAnyPath(value: string): string {
|
||||
return path.win32.basename(path.posix.basename(value));
|
||||
}
|
||||
|
||||
/** Returns the extension from the final filename segment of any path flavor. */
|
||||
export function extnameFromAnyPath(value: string): string {
|
||||
return path.extname(basenameFromAnyPath(value));
|
||||
}
|
||||
|
||||
/** Returns the extensionless filename from the final segment of any path flavor. */
|
||||
export function nameFromAnyPath(value: string): string {
|
||||
const base = basenameFromAnyPath(value);
|
||||
const ext = path.extname(base);
|
||||
return path.basename(base, ext);
|
||||
}
|
||||
75
packages/media-core/src/inbound-path-policy.test.ts
Normal file
75
packages/media-core/src/inbound-path-policy.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Media Core tests cover inbound path policy behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
isValidInboundPathRootPattern,
|
||||
mergeInboundPathRoots,
|
||||
} from "./inbound-path-policy.js";
|
||||
|
||||
describe("inbound-path-policy", () => {
|
||||
function expectInboundRootPatternCase(pattern: string, expected: boolean) {
|
||||
expect(isValidInboundPathRootPattern(pattern)).toBe(expected);
|
||||
}
|
||||
|
||||
function expectInboundPathAllowedCase(filePath: string, expected: boolean) {
|
||||
expect(
|
||||
isInboundPathAllowed({ filePath, roots: ["/Users/*/Library/Messages/Attachments"] }),
|
||||
).toBe(expected);
|
||||
}
|
||||
|
||||
function expectMergedInboundPathRootsCase(params: {
|
||||
defaults: string[];
|
||||
additions: string[];
|
||||
expected: readonly string[];
|
||||
}) {
|
||||
expect(mergeInboundPathRoots(params.defaults, params.additions)).toEqual(params.expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ pattern: "/Users/*/Library/Messages/Attachments", expected: true },
|
||||
{ pattern: "/Volumes/relay/attachments", expected: true },
|
||||
{ pattern: "./attachments", expected: false },
|
||||
{ pattern: "/Users/**/Attachments", expected: false },
|
||||
] as const)("validates absolute root pattern %s", ({ pattern, expected }) => {
|
||||
expectInboundRootPatternCase(pattern, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
filePath: "/Users/alice/Library/Messages/Attachments/12/34/ABCDEF/IMG_0001.jpeg",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
filePath: "/etc/passwd",
|
||||
expected: false,
|
||||
},
|
||||
] as const)("matches wildcard roots for %s => $expected", ({ filePath, expected }) => {
|
||||
expectInboundPathAllowedCase(filePath, expected);
|
||||
});
|
||||
|
||||
it("matches Windows drive roots case-insensitively", () => {
|
||||
expect(
|
||||
isInboundPathAllowed({
|
||||
filePath: "C:\\Users\\Alice\\Library\\Messages\\Attachments\\12\\34\\ABCDEF\\IMG_0001.jpeg",
|
||||
roots: ["c:/users/*/library/messages/attachments"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "normalizes and de-duplicates merged roots",
|
||||
run: () =>
|
||||
expectMergedInboundPathRootsCase({
|
||||
defaults: [
|
||||
"/Users/*/Library/Messages/Attachments/",
|
||||
"/Users/*/Library/Messages/Attachments",
|
||||
],
|
||||
additions: ["/Volumes/relay/attachments"],
|
||||
expected: ["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"],
|
||||
}),
|
||||
},
|
||||
] as const)("$name", ({ run }) => {
|
||||
run();
|
||||
});
|
||||
});
|
||||
122
packages/media-core/src/inbound-path-policy.ts
Normal file
122
packages/media-core/src/inbound-path-policy.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Media Core module implements inbound path policy behavior.
|
||||
import path from "node:path";
|
||||
|
||||
const WILDCARD_SEGMENT = "*";
|
||||
const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//;
|
||||
const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/;
|
||||
|
||||
function normalizePosixAbsolutePath(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.includes("\0")) {
|
||||
return undefined;
|
||||
}
|
||||
// Compare all roots as POSIX-style absolute paths so channel configs can use
|
||||
// stable patterns even when a source reports Windows separators.
|
||||
const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/"));
|
||||
const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized);
|
||||
if (!isAbsolute || normalized === "/") {
|
||||
return undefined;
|
||||
}
|
||||
const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
||||
if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) {
|
||||
return undefined;
|
||||
}
|
||||
return WINDOWS_DRIVE_ABS_RE.test(withoutTrailingSlash)
|
||||
? withoutTrailingSlash.toLowerCase()
|
||||
: withoutTrailingSlash;
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string): string[] {
|
||||
return value.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesRootPattern(params: { candidatePath: string; rootPattern: string }): boolean {
|
||||
const candidateSegments = splitPathSegments(params.candidatePath);
|
||||
const rootSegments = splitPathSegments(params.rootPattern);
|
||||
if (candidateSegments.length < rootSegments.length) {
|
||||
return false;
|
||||
}
|
||||
for (let idx = 0; idx < rootSegments.length; idx += 1) {
|
||||
const expected = rootSegments[idx];
|
||||
const actual = candidateSegments[idx];
|
||||
if (expected === WILDCARD_SEGMENT) {
|
||||
continue;
|
||||
}
|
||||
if (expected !== actual) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Validates an absolute inbound root pattern with whole-segment wildcards only. */
|
||||
export function isValidInboundPathRootPattern(value: string): boolean {
|
||||
const normalized = normalizePosixAbsolutePath(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const segments = splitPathSegments(normalized);
|
||||
if (segments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*"));
|
||||
}
|
||||
|
||||
/** Normalizes configured inbound attachment roots, dropping invalid or duplicate patterns. */
|
||||
export function normalizeInboundPathRoots(roots?: readonly string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const root of roots ?? []) {
|
||||
if (typeof root !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!isValidInboundPathRootPattern(root)) {
|
||||
continue;
|
||||
}
|
||||
const candidate = normalizePosixAbsolutePath(root);
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
normalized.push(candidate);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Merges inbound attachment root lists while preserving first-seen priority. */
|
||||
export function mergeInboundPathRoots(
|
||||
...rootsLists: Array<readonly string[] | undefined>
|
||||
): string[] {
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const roots of rootsLists) {
|
||||
const normalized = normalizeInboundPathRoots(roots);
|
||||
for (const root of normalized) {
|
||||
if (seen.has(root)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(root);
|
||||
merged.push(root);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Checks whether a candidate inbound media path is covered by configured or fallback roots. */
|
||||
export function isInboundPathAllowed(params: {
|
||||
filePath: string;
|
||||
roots: readonly string[];
|
||||
fallbackRoots?: readonly string[];
|
||||
}): boolean {
|
||||
const candidatePath = normalizePosixAbsolutePath(params.filePath);
|
||||
if (!candidatePath) {
|
||||
return false;
|
||||
}
|
||||
const roots = normalizeInboundPathRoots(params.roots);
|
||||
const effectiveRoots =
|
||||
roots.length > 0 ? roots : normalizeInboundPathRoots(params.fallbackRoots ?? undefined);
|
||||
if (effectiveRoots.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return effectiveRoots.some((rootPattern) => matchesRootPattern({ candidatePath, rootPattern }));
|
||||
}
|
||||
11
packages/media-core/src/index.ts
Normal file
11
packages/media-core/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Public barrel for media URL, MIME, path, and byte-stream helpers.
|
||||
|
||||
export * from "./base64.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./content-length.js";
|
||||
export * from "./file-name.js";
|
||||
export * from "./inbound-path-policy.js";
|
||||
export * from "./inline-image-data-url.js";
|
||||
export * from "./media-source-url.js";
|
||||
export * from "./mime.js";
|
||||
export * from "./read-byte-stream-with-limit.js";
|
||||
93
packages/media-core/src/inline-image-data-url.test.ts
Normal file
93
packages/media-core/src/inline-image-data-url.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Media Core tests cover inline image data url behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
sanitizeInlineImageBase64,
|
||||
sanitizeInlineImageDataUrl,
|
||||
sanitizeInlineImageDataUrlForStorage,
|
||||
sniffInlineImageMime,
|
||||
} from "./inline-image-data-url.js";
|
||||
|
||||
const PNG_1X1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
|
||||
const BMP_HEADER = Buffer.from("BMfixture", "ascii").toString("base64");
|
||||
const HEIC_HEADER = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00,
|
||||
0x6d, 0x69, 0x66, 0x31,
|
||||
]).toString("base64");
|
||||
const HEIF_HEADER = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x69, 0x66, 0x31, 0x00, 0x00, 0x00, 0x00,
|
||||
]).toString("base64");
|
||||
|
||||
describe("inline image data URL sanitizer", () => {
|
||||
it("keeps non-data image references unchanged", () => {
|
||||
expect(sanitizeInlineImageDataUrl("https://example.test/image.png")).toBe(
|
||||
"https://example.test/image.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed and non-image data URLs", () => {
|
||||
expect(sanitizeInlineImageDataUrl("data:image/png;base64")).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl("data:image/png,SGVsbG8=")).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl("data:image/png;base64,not base64!")).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl("data:image/png;base64,SGVsbG8=")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("canonicalizes valid data URLs with sniffed MIME type", () => {
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/jpeg;base64,\n${PNG_1X1}`)).toBe(
|
||||
`data:image/png;base64,${PNG_1X1}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes valid unpadded image data URLs", () => {
|
||||
const unpaddedPng = PNG_1X1.replace(/=+$/u, "");
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/png;base64,${unpaddedPng}`)).toBe(
|
||||
`data:image/png;base64,${PNG_1X1}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects image data URLs for formats that require conversion before provider transport", () => {
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/bmp;base64,${BMP_HEADER}`)).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/heic;base64,${HEIC_HEADER}`)).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/heif;base64,${HEIF_HEADER}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("canonicalizes valid image data URLs for storage without transport allowlist filtering", () => {
|
||||
expect(sanitizeInlineImageDataUrlForStorage(`data:image/bmp;base64,${BMP_HEADER}`)).toBe(
|
||||
`data:image/bmp;base64,${BMP_HEADER}`,
|
||||
);
|
||||
expect(sanitizeInlineImageDataUrlForStorage(`data:image/heic;base64,${HEIC_HEADER}`)).toBe(
|
||||
`data:image/heic;base64,${HEIC_HEADER}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes valid image base64 with sniffed MIME type", () => {
|
||||
expect(sanitizeInlineImageBase64({ mimeType: "image/jpeg", base64: `\n${PNG_1X1}` })).toEqual({
|
||||
mimeType: "image/png",
|
||||
base64: PNG_1X1,
|
||||
});
|
||||
expect(
|
||||
sanitizeInlineImageBase64({ mimeType: "image/png", base64: "SGVsbG8=" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts supported non-browser image signatures", () => {
|
||||
expect(sanitizeInlineImageBase64({ mimeType: "image/bmp", base64: BMP_HEADER })).toEqual({
|
||||
mimeType: "image/bmp",
|
||||
base64: BMP_HEADER,
|
||||
});
|
||||
expect(sanitizeInlineImageBase64({ mimeType: "image/heic", base64: HEIC_HEADER })).toEqual({
|
||||
mimeType: "image/heic",
|
||||
base64: HEIC_HEADER,
|
||||
});
|
||||
expect(sanitizeInlineImageBase64({ mimeType: "image/heif", base64: HEIF_HEADER })).toEqual({
|
||||
mimeType: "image/heif",
|
||||
base64: HEIF_HEADER,
|
||||
});
|
||||
});
|
||||
|
||||
it("sniffs supported inline image signatures", () => {
|
||||
expect(sniffInlineImageMime(Buffer.from("GIF89a", "ascii"))).toBe("image/gif");
|
||||
expect(sniffInlineImageMime(Buffer.from([0xff, 0xd8, 0xff]))).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
191
packages/media-core/src/inline-image-data-url.ts
Normal file
191
packages/media-core/src/inline-image-data-url.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// Media Core module implements inline image data url behavior.
|
||||
import { canonicalizeBase64 } from "./base64.js";
|
||||
|
||||
/** Prefix used to distinguish inline data URLs from remote/local image references. */
|
||||
export const INLINE_IMAGE_DATA_URL_PREFIX = "data:";
|
||||
|
||||
const IMAGE_SIGNATURES: Array<{
|
||||
mime: string;
|
||||
matches: (buffer: Buffer) => boolean;
|
||||
}> = [
|
||||
{
|
||||
mime: "image/png",
|
||||
matches: (buffer) =>
|
||||
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,
|
||||
},
|
||||
{
|
||||
mime: "image/jpeg",
|
||||
matches: (buffer) =>
|
||||
buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff,
|
||||
},
|
||||
{
|
||||
mime: "image/webp",
|
||||
matches: (buffer) =>
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||
buffer.subarray(8, 12).toString("ascii") === "WEBP",
|
||||
},
|
||||
{
|
||||
mime: "image/gif",
|
||||
matches: (buffer) =>
|
||||
buffer.length >= 6 &&
|
||||
(buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
||||
buffer.subarray(0, 6).toString("ascii") === "GIF89a"),
|
||||
},
|
||||
{
|
||||
mime: "image/bmp",
|
||||
matches: (buffer) => buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d,
|
||||
},
|
||||
];
|
||||
|
||||
const HEIC_BRANDS = new Set(["heic", "heix", "hevc", "hevx", "heis", "heim", "hevm", "hevs"]);
|
||||
const HEIF_BRANDS = new Set(["mif1", "msf1"]);
|
||||
const IMAGE_SIGNATURE_PREFIX_BASE64_CHARS = 128;
|
||||
const INLINE_IMAGE_DATA_URL_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
||||
|
||||
function startsWithDataUrl(value: string): boolean {
|
||||
return (
|
||||
value.slice(0, INLINE_IMAGE_DATA_URL_PREFIX.length).toLowerCase() ===
|
||||
INLINE_IMAGE_DATA_URL_PREFIX
|
||||
);
|
||||
}
|
||||
|
||||
function sniffIsoBmffImageMime(buffer: Buffer): string | undefined {
|
||||
if (buffer.length < 12 || buffer.subarray(4, 8).toString("ascii") !== "ftyp") {
|
||||
return undefined;
|
||||
}
|
||||
const brands = [buffer.subarray(8, 12).toString("ascii")];
|
||||
for (let offset = 16; offset + 4 <= buffer.length; offset += 4) {
|
||||
brands.push(buffer.subarray(offset, offset + 4).toString("ascii"));
|
||||
}
|
||||
if (brands.some((brand) => HEIC_BRANDS.has(brand))) {
|
||||
return "image/heic";
|
||||
}
|
||||
if (brands.some((brand) => HEIF_BRANDS.has(brand))) {
|
||||
return "image/heif";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Sniffs supported inline image formats from decoded bytes. */
|
||||
export function sniffInlineImageMime(buffer: Buffer): string | undefined {
|
||||
return (
|
||||
IMAGE_SIGNATURES.find((signature) => signature.matches(buffer))?.mime ??
|
||||
sniffIsoBmffImageMime(buffer)
|
||||
);
|
||||
}
|
||||
|
||||
function isImageMimeType(value: string): boolean {
|
||||
return value.trim().toLowerCase().startsWith("image/");
|
||||
}
|
||||
|
||||
export type SanitizedInlineImageBase64 = {
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
};
|
||||
|
||||
/** Canonicalizes trusted inline image base64 and rejects malformed or non-image payloads. */
|
||||
export function sanitizeInlineImageBase64(params: {
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
}): SanitizedInlineImageBase64 | undefined {
|
||||
if (!isImageMimeType(params.mimeType)) {
|
||||
return undefined;
|
||||
}
|
||||
const canonicalPayload = canonicalizeBase64(params.base64);
|
||||
if (!canonicalPayload) {
|
||||
return undefined;
|
||||
}
|
||||
const sniffedMimeType = sniffInlineImageMime(
|
||||
Buffer.from(canonicalPayload.slice(0, IMAGE_SIGNATURE_PREFIX_BASE64_CHARS), "base64"),
|
||||
);
|
||||
if (!sniffedMimeType) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mimeType: sniffedMimeType,
|
||||
base64: canonicalPayload,
|
||||
};
|
||||
}
|
||||
|
||||
function parseInlineImageDataUrl(value: string):
|
||||
| {
|
||||
metadata: string[];
|
||||
payload: string;
|
||||
}
|
||||
| undefined {
|
||||
if (!startsWithDataUrl(value)) {
|
||||
return { metadata: [], payload: value };
|
||||
}
|
||||
const commaIndex = value.indexOf(",");
|
||||
if (commaIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
metadata: value
|
||||
.slice(INLINE_IMAGE_DATA_URL_PREFIX.length, commaIndex)
|
||||
.split(";")
|
||||
.map((part) => part.trim()),
|
||||
payload: value.slice(commaIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function metadataAllowsImageBase64(metadata: string[]): boolean {
|
||||
const [mimeType, ...options] = metadata;
|
||||
return (
|
||||
mimeType !== undefined &&
|
||||
isImageMimeType(mimeType) &&
|
||||
options.some((part) => part.toLowerCase() === "base64")
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeInlineImageDataUrlWithAllowedMimes(
|
||||
imageUrl: string,
|
||||
allowedMimes?: Set<string>,
|
||||
): string | undefined {
|
||||
const parsed = parseInlineImageDataUrl(imageUrl);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.metadata.length === 0) {
|
||||
return imageUrl;
|
||||
}
|
||||
if (!metadataAllowsImageBase64(parsed.metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [mimeType] = parsed.metadata;
|
||||
const sanitized = sanitizeInlineImageBase64({
|
||||
mimeType: mimeType ?? "",
|
||||
base64: parsed.payload,
|
||||
});
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
if (allowedMimes && !allowedMimes.has(sanitized.mimeType)) {
|
||||
return undefined;
|
||||
}
|
||||
// Trust the byte signature over caller-supplied metadata before reinlining.
|
||||
return `data:${sanitized.mimeType};base64,${sanitized.base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes trusted inline image data URLs for persistence.
|
||||
* Accepts every image signature supported by `sanitizeInlineImageBase64`.
|
||||
*/
|
||||
export function sanitizeInlineImageDataUrlForStorage(imageUrl: string): string | undefined {
|
||||
return sanitizeInlineImageDataUrlWithAllowedMimes(imageUrl);
|
||||
}
|
||||
|
||||
/** Canonicalizes provider-safe inline image data URLs and rejects unsupported formats. */
|
||||
export function sanitizeInlineImageDataUrl(imageUrl: string): string | undefined {
|
||||
return sanitizeInlineImageDataUrlWithAllowedMimes(imageUrl, INLINE_IMAGE_DATA_URL_MIMES);
|
||||
}
|
||||
41
packages/media-core/src/lazy-import.ts
Normal file
41
packages/media-core/src/lazy-import.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Cached async loader used by runtime boundaries that should import on first use. */
|
||||
export type LazyPromiseLoader<T> = {
|
||||
load(): Promise<T>;
|
||||
clear(): void;
|
||||
};
|
||||
|
||||
/** Controls whether a failed first import stays cached or is retried later. */
|
||||
export type LazyPromiseLoaderOptions = {
|
||||
cacheRejections?: boolean;
|
||||
};
|
||||
|
||||
/** Creates a single-flight promise cache around a lazy import or other async loader. */
|
||||
export function createLazyImportLoader<T>(
|
||||
load: () => Promise<T>,
|
||||
options: LazyPromiseLoaderOptions = {},
|
||||
): LazyPromiseLoader<T> {
|
||||
let promise: Promise<T> | undefined;
|
||||
|
||||
const createPromise = (): Promise<T> => {
|
||||
const loaded = Promise.resolve().then(load);
|
||||
if (options.cacheRejections !== true) {
|
||||
// Failed optional-runtime imports should retry after install/config changes.
|
||||
void loaded.catch(() => {
|
||||
if (promise === loaded) {
|
||||
promise = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return loaded;
|
||||
};
|
||||
|
||||
return {
|
||||
async load(): Promise<T> {
|
||||
promise ??= createPromise();
|
||||
return await promise;
|
||||
},
|
||||
clear(): void {
|
||||
promise = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
13
packages/media-core/src/media-source-url.ts
Normal file
13
packages/media-core/src/media-source-url.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Media Core module implements media source url behavior.
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
const MXC_URL_RE = /^mxc:\/\//i;
|
||||
const BUFFER_URL_RE = /^buffer:\/\//i;
|
||||
|
||||
/** Returns true for remote media URLs that should stay URL-backed instead of local-file-backed. */
|
||||
export function isPassThroughRemoteMediaSource(value: string | null | undefined): boolean {
|
||||
const normalized = value?.trim() ?? "";
|
||||
return (
|
||||
Boolean(normalized) &&
|
||||
(HTTP_URL_RE.test(normalized) || MXC_URL_RE.test(normalized) || BUFFER_URL_RE.test(normalized))
|
||||
);
|
||||
}
|
||||
350
packages/media-core/src/mime.test.ts
Normal file
350
packages/media-core/src/mime.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
// Media Core tests cover mime behavior.
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mediaKindFromMime } from "./constants.js";
|
||||
import {
|
||||
detectMime,
|
||||
extensionForMime,
|
||||
FILE_TYPE_SNIFF_MAX_BYTES,
|
||||
imageMimeFromFormat,
|
||||
isAudioFileName,
|
||||
isGifMedia,
|
||||
kindFromMime,
|
||||
mimeTypeFromFilePath,
|
||||
normalizeMimeType,
|
||||
sliceMimeSniffBuffer,
|
||||
} from "./mime.js";
|
||||
|
||||
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"[Content_Types].xml",
|
||||
`<Types><Override PartName="${opts.partPath}" ContentType="${opts.mainMime}.main+xml"/></Types>`,
|
||||
);
|
||||
zip.file(opts.partPath.slice(1), "<xml/>");
|
||||
return await zip.generateAsync({ type: "nodebuffer" });
|
||||
}
|
||||
|
||||
describe("mime detection", () => {
|
||||
async function expectDetectedMime(params: {
|
||||
input: Parameters<typeof detectMime>[0];
|
||||
expected: string;
|
||||
}) {
|
||||
expect(await detectMime(params.input)).toBe(params.expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ format: "jpg", expected: "image/jpeg" },
|
||||
{ format: "jpeg", expected: "image/jpeg" },
|
||||
{ format: "png", expected: "image/png" },
|
||||
{ format: "webp", expected: "image/webp" },
|
||||
{ format: "gif", expected: "image/gif" },
|
||||
{ format: "unknown", expected: undefined },
|
||||
])("maps $format image format", ({ format, expected }) => {
|
||||
expect(imageMimeFromFormat(format)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "detects docx from buffer",
|
||||
mainMime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
partPath: "/word/document.xml",
|
||||
expected: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
},
|
||||
{
|
||||
name: "detects pptx from buffer",
|
||||
mainMime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
partPath: "/ppt/presentation.xml",
|
||||
expected: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
},
|
||||
] as const)("$name", async ({ mainMime, partPath, expected }) => {
|
||||
await expectDetectedMime({
|
||||
input: {
|
||||
buffer: await makeOoxmlZip({ mainMime, partPath }),
|
||||
filePath: "/tmp/file.bin",
|
||||
},
|
||||
expected,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "prefers extension mapping over generic zip",
|
||||
input: async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("hello.txt", "hi");
|
||||
return {
|
||||
buffer: await zip.generateAsync({ type: "nodebuffer" }),
|
||||
filePath: "/tmp/file.xlsx",
|
||||
};
|
||||
},
|
||||
expected: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
},
|
||||
{
|
||||
name: "does not let image extensions override generic zip bytes",
|
||||
input: async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("hello.txt", "hi");
|
||||
return {
|
||||
buffer: await zip.generateAsync({ type: "nodebuffer" }),
|
||||
filePath: "/tmp/fake.png",
|
||||
};
|
||||
},
|
||||
expected: "application/zip",
|
||||
},
|
||||
{
|
||||
name: "does not let image headers override generic zip bytes",
|
||||
input: async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("hello.txt", "hi");
|
||||
return {
|
||||
buffer: await zip.generateAsync({ type: "nodebuffer" }),
|
||||
headerMime: "image/png",
|
||||
};
|
||||
},
|
||||
expected: "application/zip",
|
||||
},
|
||||
{
|
||||
name: "uses extension mapping for JavaScript assets",
|
||||
input: async () => ({
|
||||
filePath: "/tmp/a2ui.bundle.js",
|
||||
}),
|
||||
expected: "text/javascript",
|
||||
},
|
||||
{
|
||||
name: "uses extension mapping for YAML assets",
|
||||
input: async () => ({
|
||||
filePath: "/tmp/config.yml",
|
||||
}),
|
||||
expected: "application/yaml",
|
||||
},
|
||||
{
|
||||
name: "uses extension mapping for YAML documents",
|
||||
input: async () => ({
|
||||
filePath: "/tmp/config.yaml",
|
||||
}),
|
||||
expected: "application/yaml",
|
||||
},
|
||||
] as const)("$name", async ({ input, expected }) => {
|
||||
await expectDetectedMime({
|
||||
input: await input(),
|
||||
expected,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects HTML files by extension (no magic bytes)", async () => {
|
||||
const buf = Buffer.from("<!DOCTYPE html><html><body>test</body></html>");
|
||||
const mime = await detectMime({ buffer: buf, filePath: "/tmp/report.html" });
|
||||
expect(mime).toBe("text/html");
|
||||
});
|
||||
|
||||
it("detects .htm files by extension", async () => {
|
||||
const buf = Buffer.from("<html><body>test</body></html>");
|
||||
const mime = await detectMime({ buffer: buf, filePath: "/tmp/page.htm" });
|
||||
expect(mime).toBe("text/html");
|
||||
});
|
||||
|
||||
it("detects XML files by extension", async () => {
|
||||
const mime = await detectMime({ filePath: "/tmp/data.xml" });
|
||||
expect(mime).toBe("text/xml");
|
||||
});
|
||||
|
||||
it("detects CSS files by extension", async () => {
|
||||
const mime = await detectMime({ filePath: "/tmp/style.css" });
|
||||
expect(mime).toBe("text/css");
|
||||
});
|
||||
|
||||
it("detects AAC from a bare filename when buffer sniffing is inconclusive", async () => {
|
||||
const mime = await detectMime({ buffer: Buffer.alloc(16), filePath: "voice.aac" });
|
||||
expect(mime).toBe("audio/aac");
|
||||
});
|
||||
|
||||
it("detects Apple CAF audio by magic bytes when file-type does not recognize the container", async () => {
|
||||
// CAF files start with the four-byte ASCII tag "caff". `file-type` v22 has
|
||||
// no native CAF detector, so without the manual magic-byte fallback the
|
||||
// host-local-media validator drops `afconvert`-produced voice-memo CAFs as
|
||||
// unknown binary blobs. Regression guard for the iMessage voice-memo
|
||||
// pre-transcode path.
|
||||
const buf = Buffer.concat([Buffer.from("caff", "ascii"), Buffer.alloc(60)]);
|
||||
const mime = await detectMime({ buffer: buf });
|
||||
expect(mime).toBe("audio/x-caf");
|
||||
});
|
||||
|
||||
it("returns audio/x-caf when extension and CAF magic bytes both agree", async () => {
|
||||
const buf = Buffer.concat([Buffer.from("caff", "ascii"), Buffer.alloc(60)]);
|
||||
const mime = await detectMime({ buffer: buf, filePath: "/tmp/voice.caf" });
|
||||
expect(mime).toBe("audio/x-caf");
|
||||
});
|
||||
|
||||
it("caps dependency sniffing to a bounded prefix", () => {
|
||||
const small = Buffer.alloc(32);
|
||||
const large = Buffer.alloc(FILE_TYPE_SNIFF_MAX_BYTES + 16);
|
||||
|
||||
expect(sliceMimeSniffBuffer(small)).toBe(small);
|
||||
expect(sliceMimeSniffBuffer(large)).toHaveLength(FILE_TYPE_SNIFF_MAX_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mimeTypeFromFilePath", () => {
|
||||
it.each([
|
||||
{ filePath: "image.bmp", expected: "image/bmp" },
|
||||
{ filePath: "photo.jpg", expected: "image/jpeg" },
|
||||
{ filePath: "photo.JPG", expected: "image/jpeg" },
|
||||
{ filePath: "voice.mp3", expected: "audio/mpeg" },
|
||||
{ filePath: "voice.wav", expected: "audio/wav" },
|
||||
{ filePath: "clip.avi", expected: "video/x-msvideo" },
|
||||
{ filePath: "clip.mkv", expected: "video/x-matroska" },
|
||||
{ filePath: "clip.webm", expected: "video/webm" },
|
||||
{ filePath: "clip.flv", expected: "video/x-flv" },
|
||||
{ filePath: "clip.wmv", expected: "video/x-ms-wmv" },
|
||||
{ filePath: "debug.log", expected: "text/plain" },
|
||||
{ filePath: "config.yml", expected: "application/yaml" },
|
||||
{ filePath: "config.yaml", expected: "application/yaml" },
|
||||
{ filePath: "page.xml", expected: "text/xml" },
|
||||
{ filePath: "unknown.bin", expected: undefined },
|
||||
] as const)("maps $filePath", ({ filePath, expected }) => {
|
||||
expect(mimeTypeFromFilePath(filePath)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionForMime", () => {
|
||||
function expectMimeExtensionCase(
|
||||
mime: Parameters<typeof extensionForMime>[0],
|
||||
expected: ReturnType<typeof extensionForMime>,
|
||||
) {
|
||||
expect(extensionForMime(mime)).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ mime: "image/jpeg", expected: ".jpg" },
|
||||
{ mime: "image/jpg", expected: ".jpg" },
|
||||
{ mime: "image/bmp", expected: ".bmp" },
|
||||
{ mime: "image/png", expected: ".png" },
|
||||
{ mime: "image/svg+xml", expected: ".svg" },
|
||||
{ mime: "image/webp", expected: ".webp" },
|
||||
{ mime: "image/gif", expected: ".gif" },
|
||||
{ mime: "image/heic", expected: ".heic" },
|
||||
{ mime: "audio/mpeg", expected: ".mp3" },
|
||||
{ mime: "audio/mp3", expected: ".mp3" },
|
||||
{ mime: "audio/ogg", expected: ".ogg" },
|
||||
{ mime: "audio/x-wav", expected: ".wav" },
|
||||
{ mime: "audio/webm", expected: ".webm" },
|
||||
{ mime: "audio/x-m4a", expected: ".m4a" },
|
||||
{ mime: "audio/mp4", expected: ".m4a" },
|
||||
{ mime: "video/x-msvideo", expected: ".avi" },
|
||||
{ mime: "video/mp4", expected: ".mp4" },
|
||||
{ mime: "video/x-matroska", expected: ".mkv" },
|
||||
{ mime: "video/webm", expected: ".webm" },
|
||||
{ mime: "video/x-flv", expected: ".flv" },
|
||||
{ mime: "video/x-ms-wmv", expected: ".wmv" },
|
||||
{ mime: "video/quicktime", expected: ".mov" },
|
||||
{ mime: "application/pdf", expected: ".pdf" },
|
||||
{ mime: "application/yaml", expected: ".yaml" },
|
||||
{ mime: "text/plain", expected: ".txt" },
|
||||
{ mime: "text/markdown", expected: ".md" },
|
||||
{ mime: "text/html", expected: ".html" },
|
||||
{ mime: "text/xml", expected: ".xml" },
|
||||
{ mime: "text/css", expected: ".css" },
|
||||
{ mime: "application/xml", expected: ".xml" },
|
||||
{ mime: "IMAGE/JPEG", expected: ".jpg" },
|
||||
{ mime: "Audio/X-M4A", expected: ".m4a" },
|
||||
{ mime: "Video/QuickTime", expected: ".mov" },
|
||||
{ mime: "video/unknown", expected: undefined },
|
||||
{ mime: "application/x-custom", expected: undefined },
|
||||
{ mime: null, expected: undefined },
|
||||
{ mime: undefined, expected: undefined },
|
||||
] as const)("maps $mime to extension", ({ mime, expected }) => {
|
||||
expectMimeExtensionCase(mime, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAudioFileName", () => {
|
||||
function expectAudioFileNameCase(fileName: string, expected: boolean) {
|
||||
expect(isAudioFileName(fileName)).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ fileName: "voice.mp3", expected: true },
|
||||
{ fileName: "voice.caf", expected: true },
|
||||
{ fileName: "voice.bin", expected: false },
|
||||
] as const)("matches audio extension for $fileName", ({ fileName, expected }) => {
|
||||
expectAudioFileNameCase(fileName, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGifMedia", () => {
|
||||
it.each([
|
||||
{
|
||||
opts: { contentType: "image/gif; charset=binary" },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
opts: { contentType: " IMAGE/GIF " },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
opts: { contentType: "image/png" },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
opts: { fileName: "animation.GIF" },
|
||||
expected: true,
|
||||
},
|
||||
] as const)("detects GIF media from normalized metadata %#", ({ opts, expected }) => {
|
||||
expect(isGifMedia(opts)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeMimeType", () => {
|
||||
function expectNormalizedMimeCase(
|
||||
input: Parameters<typeof normalizeMimeType>[0],
|
||||
expected: ReturnType<typeof normalizeMimeType>,
|
||||
) {
|
||||
expect(normalizeMimeType(input)).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ input: "Audio/MP4; codecs=mp4a.40.2", expected: "audio/mp4" },
|
||||
{ input: "image/apng", expected: "image/png" },
|
||||
{ input: " ", expected: undefined },
|
||||
{ input: null, expected: undefined },
|
||||
{ input: undefined, expected: undefined },
|
||||
] as const)("normalizes $input", ({ input, expected }) => {
|
||||
expectNormalizedMimeCase(input, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaKindFromMime", () => {
|
||||
function expectMediaKindCase(
|
||||
mime: Parameters<typeof mediaKindFromMime>[0],
|
||||
expected: ReturnType<typeof mediaKindFromMime>,
|
||||
) {
|
||||
expect(mediaKindFromMime(mime)).toBe(expected);
|
||||
}
|
||||
|
||||
function expectMimeKindCase(
|
||||
mime: Parameters<typeof kindFromMime>[0],
|
||||
expected: ReturnType<typeof kindFromMime>,
|
||||
) {
|
||||
expect(kindFromMime(mime)).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ mime: "text/plain", expected: "document" },
|
||||
{ mime: "text/csv", expected: "document" },
|
||||
{ mime: "text/html; charset=utf-8", expected: "document" },
|
||||
{ mime: "model/gltf+json", expected: undefined },
|
||||
{ mime: null, expected: undefined },
|
||||
{ mime: undefined, expected: undefined },
|
||||
] as const)("classifies $mime", ({ mime, expected }) => {
|
||||
expectMediaKindCase(mime, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mime: " Audio/Ogg; codecs=opus ", expected: "audio" },
|
||||
{ mime: undefined, expected: undefined },
|
||||
{ mime: "model/gltf+json", expected: undefined },
|
||||
] as const)("maps kindFromMime($mime) => $expected", ({ mime, expected }) => {
|
||||
expectMimeKindCase(mime, expected);
|
||||
});
|
||||
});
|
||||
289
packages/media-core/src/mime.ts
Normal file
289
packages/media-core/src/mime.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Media Core module implements mime behavior.
|
||||
import path from "node:path";
|
||||
import { type MediaKind, mediaKindFromMime } from "./constants.js";
|
||||
import { createLazyImportLoader } from "./lazy-import.js";
|
||||
|
||||
/** Maximum byte prefix passed to dependency MIME sniffers for bounded memory/CPU work. */
|
||||
export const FILE_TYPE_SNIFF_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
// Map common mimes to preferred file extensions.
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/jpg": ".jpg",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/wav": ".wav",
|
||||
"audio/wave": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/flac": ".flac",
|
||||
"audio/aac": ".aac",
|
||||
"audio/opus": ".opus",
|
||||
"audio/webm": ".webm",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-caf": ".caf",
|
||||
"video/x-msvideo": ".avi",
|
||||
"video/mp4": ".mp4",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/webm": ".webm",
|
||||
"video/x-flv": ".flv",
|
||||
"video/x-ms-wmv": ".wmv",
|
||||
"video/quicktime": ".mov",
|
||||
"application/pdf": ".pdf",
|
||||
"application/json": ".json",
|
||||
"application/yaml": ".yaml",
|
||||
"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/markdown": ".md",
|
||||
"text/html": ".html",
|
||||
"text/xml": ".xml",
|
||||
"text/css": ".css",
|
||||
"application/xml": ".xml",
|
||||
};
|
||||
|
||||
function buildMimeByExt(): Record<string, string> {
|
||||
const byExt: Record<string, string> = {};
|
||||
for (const [mime, ext] of Object.entries(EXT_BY_MIME)) {
|
||||
byExt[ext] ??= mime;
|
||||
}
|
||||
return byExt;
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
...buildMimeByExt(),
|
||||
// Canonical extension mappings for common MIME aliases
|
||||
".jpg": "image/jpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".webm": "video/webm",
|
||||
// Additional extension aliases
|
||||
".jpeg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".log": "text/plain",
|
||||
".htm": "text/html",
|
||||
".xml": "text/xml",
|
||||
".yml": "application/yaml",
|
||||
};
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
".aac",
|
||||
".caf",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
]);
|
||||
|
||||
const fileTypeModuleLoader = createLazyImportLoader(() => import("file-type"));
|
||||
|
||||
/** Normalizes MIME strings by dropping parameters, lowercasing, and folding APNG to PNG. */
|
||||
export function normalizeMimeType(mime?: string | null): string | undefined {
|
||||
if (!mime) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
|
||||
if (cleaned === "image/apng") {
|
||||
return "image/png";
|
||||
}
|
||||
return cleaned || undefined;
|
||||
}
|
||||
|
||||
/** Returns the bounded buffer prefix used for dependency MIME sniffing. */
|
||||
export function sliceMimeSniffBuffer(buffer: Buffer): Buffer {
|
||||
if (buffer.byteLength <= FILE_TYPE_SNIFF_MAX_BYTES) {
|
||||
return buffer;
|
||||
}
|
||||
return buffer.subarray(0, FILE_TYPE_SNIFF_MAX_BYTES);
|
||||
}
|
||||
|
||||
async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
|
||||
if (!buffer) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { fileTypeFromBuffer } = await fileTypeModuleLoader.load();
|
||||
const type = await fileTypeFromBuffer(sliceMimeSniffBuffer(buffer));
|
||||
if (type?.mime) {
|
||||
return normalizeMimeType(type.mime);
|
||||
}
|
||||
} catch {
|
||||
// fall through to manual magic-byte sniffs
|
||||
}
|
||||
return sniffKnownAudioMagic(buffer);
|
||||
}
|
||||
|
||||
// Fallbacks for audio containers `file-type` doesn't recognize natively (e.g.
|
||||
// Apple's CAF, used by iMessage voice memos when produced by `afconvert`).
|
||||
// Without this the host-local-media validator drops these buffers as unknown
|
||||
// binary blobs because the sniff returns undefined, even though the file is
|
||||
// a valid audio container.
|
||||
function sniffKnownAudioMagic(buffer: Buffer): string | undefined {
|
||||
if (buffer.byteLength >= 4 && buffer.toString("ascii", 0, 4) === "caff") {
|
||||
return "audio/x-caf";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extracts a lowercase extension from a local path or HTTP URL pathname. */
|
||||
export function getFileExtension(filePath?: string | null): string | undefined {
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
const url = new URL(filePath);
|
||||
return path.extname(url.pathname).toLowerCase() || undefined;
|
||||
}
|
||||
} catch {
|
||||
// fall back to plain path parsing
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ext || undefined;
|
||||
}
|
||||
|
||||
/** Maps a file path or URL extension to the preferred MIME type when known. */
|
||||
export function mimeTypeFromFilePath(filePath?: string | null): string | undefined {
|
||||
const ext = getFileExtension(filePath);
|
||||
if (!ext) {
|
||||
return undefined;
|
||||
}
|
||||
return MIME_BY_EXT[ext];
|
||||
}
|
||||
|
||||
/** Returns true when a filename extension is a supported audio container. */
|
||||
export function isAudioFileName(fileName?: string | null): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
if (!ext) {
|
||||
return false;
|
||||
}
|
||||
return AUDIO_FILE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/** Detects the best MIME type from bytes, file path, and header metadata. */
|
||||
export function detectMime(opts: {
|
||||
buffer?: Buffer;
|
||||
headerMime?: string | null;
|
||||
filePath?: string;
|
||||
}): Promise<string | undefined> {
|
||||
return detectMimeImpl(opts);
|
||||
}
|
||||
|
||||
function isGenericMime(mime?: string): boolean {
|
||||
if (!mime) {
|
||||
return true;
|
||||
}
|
||||
const m = mime.toLowerCase();
|
||||
return m === "application/octet-stream" || m === "application/zip";
|
||||
}
|
||||
|
||||
function isImageMime(mime?: string): boolean {
|
||||
return mediaKindFromMime(normalizeMimeType(mime)) === "image";
|
||||
}
|
||||
|
||||
async function detectMimeImpl(opts: {
|
||||
buffer?: Buffer;
|
||||
headerMime?: string | null;
|
||||
filePath?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const ext = getFileExtension(opts.filePath);
|
||||
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
||||
|
||||
const headerMime = normalizeMimeType(opts.headerMime);
|
||||
const sniffed = await sniffMime(opts.buffer);
|
||||
const sniffedGenericContainer = sniffed && isGenericMime(sniffed);
|
||||
const trustedExtMime = sniffedGenericContainer && isImageMime(extMime) ? undefined : extMime;
|
||||
const trustedHeaderMime =
|
||||
sniffedGenericContainer && isImageMime(headerMime) ? undefined : headerMime;
|
||||
|
||||
// Prefer sniffed types, but don't let generic container types override a more
|
||||
// specific extension mapping (e.g. XLSX vs ZIP).
|
||||
if (sniffed && (!isGenericMime(sniffed) || !trustedExtMime)) {
|
||||
return sniffed;
|
||||
}
|
||||
if (trustedExtMime) {
|
||||
return trustedExtMime;
|
||||
}
|
||||
if (trustedHeaderMime && !isGenericMime(trustedHeaderMime)) {
|
||||
return trustedHeaderMime;
|
||||
}
|
||||
if (sniffed) {
|
||||
return sniffed;
|
||||
}
|
||||
if (trustedHeaderMime) {
|
||||
return trustedHeaderMime;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns the preferred file extension for a normalized or raw MIME string. */
|
||||
export function extensionForMime(mime?: string | null): string | undefined {
|
||||
const normalized = normalizeMimeType(mime);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return EXT_BY_MIME[normalized];
|
||||
}
|
||||
|
||||
/** Returns true when content type or filename identifies GIF media. */
|
||||
export function isGifMedia(opts: {
|
||||
contentType?: string | null;
|
||||
fileName?: string | null;
|
||||
}): boolean {
|
||||
if (normalizeMimeType(opts.contentType) === "image/gif") {
|
||||
return true;
|
||||
}
|
||||
const ext = getFileExtension(opts.fileName);
|
||||
return ext === ".gif";
|
||||
}
|
||||
|
||||
/** Maps image format labels from encoders/probes to MIME types. */
|
||||
export function imageMimeFromFormat(format?: string | null): string | undefined {
|
||||
if (!format) {
|
||||
return undefined;
|
||||
}
|
||||
switch (format.toLowerCase()) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "heic":
|
||||
return "image/heic";
|
||||
case "heif":
|
||||
return "image/heif";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes a MIME string before classifying it into a media family. */
|
||||
export function kindFromMime(mime?: string | null): MediaKind | undefined {
|
||||
return mediaKindFromMime(normalizeMimeType(mime));
|
||||
}
|
||||
27
packages/media-core/src/read-byte-stream-with-limit.test.ts
Normal file
27
packages/media-core/src/read-byte-stream-with-limit.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Media Core tests cover read byte stream with limit behavior.
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readByteStreamWithLimit } from "./read-byte-stream-with-limit.js";
|
||||
|
||||
describe("readByteStreamWithLimit", () => {
|
||||
it("returns concatenated bytes up to the limit", async () => {
|
||||
const buffer = await readByteStreamWithLimit(Readable.from([Buffer.from("ab"), "cd"]), {
|
||||
maxBytes: 4,
|
||||
});
|
||||
|
||||
expect(buffer).toEqual(Buffer.from("abcd"));
|
||||
});
|
||||
|
||||
it("throws and destroys node streams after overflow", async () => {
|
||||
const stream = Readable.from([Buffer.alloc(4), Buffer.alloc(4)]);
|
||||
const destroySpy = vi.spyOn(stream, "destroy");
|
||||
|
||||
await expect(
|
||||
readByteStreamWithLimit(stream, {
|
||||
maxBytes: 7,
|
||||
onOverflow: ({ size, maxBytes }) => new Error(`too large ${size}/${maxBytes}`),
|
||||
}),
|
||||
).rejects.toThrow("too large 8/7");
|
||||
expect(destroySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
82
packages/media-core/src/read-byte-stream-with-limit.ts
Normal file
82
packages/media-core/src/read-byte-stream-with-limit.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** Details passed to byte-stream overflow error factories. */
|
||||
export type ByteStreamLimitOverflow = {
|
||||
size: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
/** Options for reading an async byte stream under a hard byte cap. */
|
||||
export type ReadByteStreamWithLimitOptions = {
|
||||
maxBytes: number;
|
||||
onOverflow?: (params: ByteStreamLimitOverflow) => Error;
|
||||
};
|
||||
|
||||
function normalizeByteChunk(chunk: unknown): Buffer {
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
return chunk;
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
if (ArrayBuffer.isView(chunk)) {
|
||||
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
throw new TypeError(`Unsupported byte stream chunk: ${typeof chunk}`);
|
||||
}
|
||||
|
||||
function destroyReadableOnOverflow(stream: unknown, err: Error): void {
|
||||
const readable = stream as {
|
||||
destroy?: (error?: Error) => unknown;
|
||||
cancel?: (reason?: unknown) => unknown;
|
||||
};
|
||||
// Stop upstream producers immediately after overflow; otherwise large media
|
||||
// streams can continue buffering after the caller has already failed.
|
||||
if (typeof readable.destroy === "function") {
|
||||
try {
|
||||
readable.destroy(err);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (typeof readable.cancel === "function") {
|
||||
try {
|
||||
void readable.cancel(err);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads and concatenates an async byte stream, throwing once the byte cap is exceeded. */
|
||||
export async function readByteStreamWithLimit(
|
||||
stream: AsyncIterable<unknown>,
|
||||
opts: ReadByteStreamWithLimitOptions,
|
||||
): Promise<Buffer> {
|
||||
const { maxBytes } = opts;
|
||||
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
|
||||
throw new RangeError(`maxBytes must be a non-negative finite number: ${maxBytes}`);
|
||||
}
|
||||
|
||||
const onOverflow =
|
||||
opts.onOverflow ??
|
||||
((params: ByteStreamLimitOverflow) =>
|
||||
new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`));
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = normalizeByteChunk(chunk);
|
||||
if (buffer.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
const nextTotal = total + buffer.byteLength;
|
||||
if (nextTotal > maxBytes) {
|
||||
const err = onOverflow({ size: nextTotal, maxBytes });
|
||||
destroyReadableOnOverflow(stream, err);
|
||||
throw err;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
total = nextTotal;
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
Reference in New Issue
Block a user