Files
paseo/packages/app/src/attachments/utils.ts
T
Mohamed BoudraandGitHub 9c40cb7637 Render provider images from paths with spaces (#2074)
* fix(server): render image paths with spaces

Raw spaces in local Markdown image destinations made the renderer treat provider images as text. Emit encoded file URIs for spaced paths so the client parses and resolves them as images.

* fix(server): encode all absolute image paths

Provider image paths without spaces could still contain URI-significant characters such as fragment or query markers. Route every absolute local path through the encoded file URI boundary.

* fix(server): encode network image paths

Windows network-share paths bypassed the absolute local path handling. Normalize UNC and extended UNC paths before encoding them as file URIs.

* fix(app): preserve network image paths

Host-based file URIs were decoded as relative paths. Restore the UNC prefix so provider images on network shares resolve through the file RPC correctly.

* fix(server): preserve double-slash POSIX image paths

Classify only backslash-prefixed paths as Windows network paths so POSIX double-leading-slash paths retain their filesystem semantics.
2026-07-15 00:10:24 +02:00

193 lines
5.6 KiB
TypeScript

import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
import { isRasterImageMimeType } from "./file-types";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
}
export function normalizeMimeType(input: string | undefined | null): string {
if (!input) {
return "image/jpeg";
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : "image/jpeg";
}
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
if (!match) {
throw new Error("Malformed data URL for attachment.");
}
const metadata = match[1] ?? "";
const base64 = match[2]?.replace(/\s/g, "");
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
if (!isBase64) {
throw new Error("Attachment data URL is not base64 encoded.");
}
if (!base64) {
throw new Error("Attachment data URL is missing base64 payload.");
}
return {
mimeType: normalizeMimeType(mimeTypeRaw),
base64,
};
}
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
export function parseImageDataUrl(
uri: string,
): { mimeType: string; base64: string; cacheKey: string } | null {
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
return null;
}
try {
const parsed = parseDataUrl(uri);
if (!isRasterImageMimeType(parsed.mimeType)) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
};
} catch {
return null;
}
}
export function createImageSourceCacheKey(source: string): string {
return parseImageDataUrl(source)?.cacheKey ?? source;
}
export function getFileNameFromPath(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
const fileName = normalized.split("/").pop()?.trim();
return fileName || null;
}
export function createPreviewAttachmentId(input: {
mimeType: string;
path?: string | null;
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
return `preview_${size || contentLength || "unknown"}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result while encoding attachment."));
return;
}
const payload = reader.result.split(",", 2)[1];
if (!payload) {
reject(new Error("Attachment FileReader result did not contain base64 payload."));
return;
}
resolve(payload);
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read attachment blob."));
});
reader.readAsDataURL(blob);
});
}
export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
}
function decodeFilePathSource(source: string): string {
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
function normalizeWindowsDrivePath(path: string): string {
if (!/^[A-Za-z]:[\\/]/.test(path)) {
return path;
}
return path.replace(/\\/g, "/");
}
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
}
export function fileUriToPath(uri: string): string {
if (!uri.startsWith("file://")) {
return uri;
}
const fileSource = uri.slice("file://".length);
const decodedPath = decodeFilePathSource(fileSource);
if (!fileSource.startsWith("/")) {
return `\\\\${decodedPath.replace(/\//g, "\\")}`;
}
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
}
export function getFileExtensionFromName(fileName: string | null | undefined): string {
if (!fileName) {
return "";
}
const idx = fileName.lastIndexOf(".");
if (idx <= 0 || idx === fileName.length - 1) {
return "";
}
return fileName.slice(idx);
}