diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index c32609d67..fcf805f79 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { arrayMove } from "@dnd-kit/sortable"; import { createPersistStorage, defaultStorage } from "@multica/core/platform"; +import { createSafeId } from "@multica/core/utils"; import type { DataRouter } from "react-router-dom"; import { createTabRouter } from "../routes"; @@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string { const DEFAULT_PATH = "/issues"; function createId(): string { - return crypto.randomUUID(); + return createSafeId(); } function makeTab(path: string, title: string, icon: string): Tab { diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 26b3ae504..30f348e3a 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -52,6 +52,7 @@ import type { ReorderPinsRequest, } from "../types"; import { type Logger, noopLogger } from "../logger"; +import { createRequestId } from "../utils"; export interface ApiClientOptions { logger?: Logger; @@ -108,7 +109,7 @@ export class ApiClient { } private async fetch(path: string, init?: RequestInit): Promise { - const rid = crypto.randomUUID().slice(0, 8); + const rid = createRequestId(); const start = Date.now(); const method = init?.method ?? "GET"; @@ -610,7 +611,7 @@ export class ApiClient { if (opts?.issueId) formData.append("issue_id", opts.issueId); if (opts?.commentId) formData.append("comment_id", opts.commentId); - const rid = crypto.randomUUID().slice(0, 8); + const rid = createRequestId(); const start = Date.now(); this.logger.info("→ POST /api/upload-file", { rid }); diff --git a/packages/core/utils.test.ts b/packages/core/utils.test.ts new file mode 100644 index 000000000..e409b1d6f --- /dev/null +++ b/packages/core/utils.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequestId, createSafeId, generateUUID } from "./utils"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("utils id helpers", () => { + it("generateUUID returns a valid UUID v4", () => { + const id = generateUUID(); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it("createSafeId falls back when crypto.randomUUID is unavailable", () => { + vi.stubGlobal("crypto", { + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = i; + return arr; + }, + }); + + const id = createSafeId(); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it("createRequestId defaults to length 8 and respects custom length", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab"); + + expect(createRequestId()).toBe("12345678"); + expect(createRequestId(12)).toBe("123456781234"); + }); +}); diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 096f0f5d5..072812c0d 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string { const days = Math.floor(hours / 24); return `${days}d ago`; } + +export function generateUUID(): string { + const cryptoObj = globalThis.crypto; + + if (!cryptoObj?.getRandomValues) { + throw new Error("Secure UUID generation requires crypto.getRandomValues"); + } + + const bytes = new Uint8Array(16); + cryptoObj.getRandomValues(bytes); + + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1 + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts. + */ +export function createSafeId(): string { + const cryptoObj = globalThis.crypto; + + if (cryptoObj?.randomUUID) { + try { + return cryptoObj.randomUUID(); + } catch { + // Fall through to fallback. + } + } + + return generateUUID(); +} + +/** Request id helper used for logs/tracing headers. */ +export function createRequestId(length = 8): string { + return createSafeId().replace(/-/g, "").slice(0, length); +} diff --git a/packages/views/editor/extensions/file-upload.ts b/packages/views/editor/extensions/file-upload.ts index 9f89cbda3..d2874a62b 100644 --- a/packages/views/editor/extensions/file-upload.ts +++ b/packages/views/editor/extensions/file-upload.ts @@ -1,6 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; +import { createSafeId } from "@multica/core/utils"; /** Find and remove a fileCard node by uploadId. */ @@ -109,7 +110,7 @@ export async function uploadAndInsertFile( } } else { // Non-image: insert skeleton fileCard → upload → finalize with real URL - const uploadId = crypto.randomUUID(); + const uploadId = createSafeId(); const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId }; const insertContent = { type: "fileCard", attrs: cardAttrs }; if (pos !== undefined) {