* 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.
193 lines
5.6 KiB
TypeScript
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);
|
|
}
|