From f13461f1bc0cc4ff3e37a3e627921bea3a063e48 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Wed, 15 Jul 2026 08:27:10 -0600 Subject: [PATCH 01/15] Fix regex worker isolation failures (#330) --- src/core/i18n/translations/de.json | 2 + src/core/i18n/translations/en.json | 2 + src/core/i18n/translations/fr.json | 2 + src/core/i18n/translations/ja.json | 2 + src/core/i18n/translations/ko.json | 2 + src/core/i18n/translations/zh-CN.json | 2 + src/core/i18n/translations/zh-TW.json | 2 + src/features/pipeline/adapter-registry.ts | 3 - src/features/tools/regex-tester/page.tsx | 17 +++++- .../tools/regex-tester/regex-test-task.ts | 37 ++++++++++-- .../bf-016-tool-action-semantics.test.tsx | 8 ++- tests/guards/regex-worker-guard.test.ts | 5 ++ tests/helpers/regex-test-worker-mock.ts | 31 ++++++++++ tests/unit/pipeline-foundation.test.ts | 8 ++- tests/unit/regex-test-task.test.ts | 57 ++++++++++++++++--- 15 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 tests/helpers/regex-test-worker-mock.ts diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index dab260cf..10047f04 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -1154,6 +1154,8 @@ "group_prefix": "Gruppe", "error_invalid_regex": "Ungueltiger regulaerer Ausdruck", "error_match_limit": "Trefferlimit ueberschritten. Der Ausdruck koennte endlos leere Zeichenfolgen finden.", + "error_worker_timeout": "Die Regex-Auswertung hat das Sicherheitszeitlimit ueberschritten. Vereinfachen Sie den Ausdruck oder kuerzen Sie den Testtext.", + "error_safe_evaluation_unavailable": "Die sichere Regex-Auswertung ist nicht verfuegbar, weil der isolierte Worker nicht ausgefuehrt werden konnte. Pruefen Sie die Browser- oder Inhaltssicherheitseinstellungen.", "timing_label": "Dauer: {ms} ms", "evaluating_label": "Wird ausgewertet...", "performance_warning_title": "Leistungswarnung", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 6003b4e4..53b52dbf 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -1182,6 +1182,8 @@ "group_prefix": "Group", "error_invalid_regex": "Invalid regex", "error_match_limit": "Match limit exceeded. The regex may be matching endless zero-length strings.", + "error_worker_timeout": "Regex evaluation exceeded the safety timeout. Simplify the pattern or use a smaller test input.", + "error_safe_evaluation_unavailable": "Safe regex evaluation is unavailable because the isolated worker could not run. Check your browser or content-security settings and try again.", "timing_label": "{ms} ms", "evaluating_label": "Evaluating...", "performance_warning_title": "Performance warning", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index c2b63180..037776ee 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -1154,6 +1154,8 @@ "group_prefix": "Groupe", "error_invalid_regex": "Expression régulière invalide", "error_match_limit": "Limite de correspondances dépassée. L'expression peut faire des correspondances infinies sur une chaîne vide.", + "error_worker_timeout": "L'évaluation de l'expression a dépassé le délai de sécurité. Simplifiez-la ou réduisez le texte de test.", + "error_safe_evaluation_unavailable": "L'évaluation sécurisée est indisponible car le worker isolé n'a pas pu s'exécuter. Vérifiez les paramètres du navigateur ou de sécurité du contenu.", "timing_label": "Duree : {ms} ms", "evaluating_label": "Evaluation...", "performance_warning_title": "Avertissement de performance", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index e34add08..2a261633 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -1154,6 +1154,8 @@ "group_prefix": "グループ", "error_invalid_regex": "無効な正規表現", "error_match_limit": "一致数が上限を超えました。空文字列への無限一致が発生している可能性があります。", + "error_worker_timeout": "正規表現の評価が安全タイムアウトを超えました。パターンを簡略化するか、テスト文字列を短くしてください。", + "error_safe_evaluation_unavailable": "分離された Worker を実行できないため、安全な正規表現評価を利用できません。ブラウザーまたはコンテンツセキュリティ設定を確認してください。", "timing_label": "{ms} ミリ秒", "evaluating_label": "評価中...", "performance_warning_title": "パフォーマンス警告", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index d4514509..d0be6796 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -1154,6 +1154,8 @@ "group_prefix": "그룹", "error_invalid_regex": "잘못된 정규식", "error_match_limit": "일치 개수가 한도를 초과했습니다. 빈 문자열을 무한히 매칭하고 있을 수 있습니다.", + "error_worker_timeout": "정규식 평가가 안전 시간 제한을 초과했습니다. 패턴을 단순화하거나 테스트 입력을 줄이세요.", + "error_safe_evaluation_unavailable": "격리된 Worker를 실행할 수 없어 안전한 정규식 평가를 사용할 수 없습니다. 브라우저 또는 콘텐츠 보안 설정을 확인하세요.", "timing_label": "시간: {ms} ms", "evaluating_label": "평가 중...", "performance_warning_title": "성능 경고", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 0ec4036a..77da0687 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -1154,6 +1154,8 @@ "group_prefix": "分组", "error_invalid_regex": "正则表达式无效", "error_match_limit": "匹配数量超限,正则可能正在无限匹配空字符串。", + "error_worker_timeout": "正则表达式求值超过安全时限。请简化表达式或缩短测试文本。", + "error_safe_evaluation_unavailable": "隔离 Worker 无法运行,当前不能安全地执行正则表达式。请检查浏览器或内容安全策略设置后重试。", "timing_label": "{ms} 毫秒", "evaluating_label": "正在评估...", "performance_warning_title": "性能警告", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 0f00a554..eccc4273 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -1154,6 +1154,8 @@ "group_prefix": "群組", "error_invalid_regex": "正則表達式無效", "error_match_limit": "比對數量超出上限,正則可能正在無限比對空字串。", + "error_worker_timeout": "正則表達式求值超過安全時限。請簡化表達式或縮短測試文字。", + "error_safe_evaluation_unavailable": "隔離 Worker 無法執行,目前無法安全地執行正則表達式。請檢查瀏覽器或內容安全策略設定後再試一次。", "timing_label": "{ms} 毫秒", "evaluating_label": "正在評估...", "performance_warning_title": "效能警告", diff --git a/src/features/pipeline/adapter-registry.ts b/src/features/pipeline/adapter-registry.ts index 2b58e718..73670c37 100644 --- a/src/features/pipeline/adapter-registry.ts +++ b/src/features/pipeline/adapter-registry.ts @@ -16,7 +16,6 @@ import { decodeJwtParts } from "@/features/tools/jwt-decoder/utils" import { runNdjsonTransform, type NdjsonMessages, type NdjsonMode } from "@/features/tools/ndjson-formatter/utils" import { diffOpenApiSpecs, formatOpenApiDiffReport } from "@/features/tools/openapi-diff/logic" import { runRegexTestTask } from "@/features/tools/regex-tester/regex-test-task" -import { testRegexPattern } from "@/features/tools/regex-tester/utils" import { convertCase, type CaseStyle } from "@/features/tools/slugify-case-converter/utils" import { parseTimestampHeuristic } from "@/features/tools/unix-timestamp/utils" import { convertYamlJson, type YamlJsonMode } from "@/features/tools/yaml-json-converter/utils" @@ -608,8 +607,6 @@ const regexTesterAdapter: PipelineToolAdapter = { if (!/^[gimsuy]*$/.test(flags)) return fail("flags must contain only g, i, m, s, u, or y.") if (new Set(flags).size !== flags.length) return fail("flags must not contain duplicates.") if (!Number.isFinite(maxMatches) || maxMatches < 1 || maxMatches > 5000) return fail("maxMatches must be between 1 and 5000.") - const validation = testRegexPattern(pattern, flags, "") - if (!validation.ok) return fail("pattern must be a valid JavaScript regular expression.") return ok() }, async run(input, options) { diff --git a/src/features/tools/regex-tester/page.tsx b/src/features/tools/regex-tester/page.tsx index caea8fcb..984f2ddb 100644 --- a/src/features/tools/regex-tester/page.tsx +++ b/src/features/tools/regex-tester/page.tsx @@ -54,7 +54,12 @@ export function RegexTesterPage() { setMatches(result.matches) setError(result.limited ? toolT.error_match_limit : null) } else { - setError(result.error || toolT.error_invalid_regex) + const localizedTaskError = result.errorCode === "worker_timeout" + ? toolT.error_worker_timeout + : result.errorCode === "safe_evaluation_unavailable" + ? toolT.error_safe_evaluation_unavailable + : null + setError(localizedTaskError || result.error || toolT.error_invalid_regex) setMatches([]) } }) @@ -74,7 +79,15 @@ export function RegexTesterPage() { return () => { controller.abort() } - }, [flags, pattern, testString, toolT.error_invalid_regex, toolT.error_match_limit]) + }, [ + flags, + pattern, + testString, + toolT.error_invalid_regex, + toolT.error_match_limit, + toolT.error_safe_evaluation_unavailable, + toolT.error_worker_timeout, + ]) const handleClear = () => { setPattern("") diff --git a/src/features/tools/regex-tester/regex-test-task.ts b/src/features/tools/regex-tester/regex-test-task.ts index a9e417ca..2c506d08 100644 --- a/src/features/tools/regex-tester/regex-test-task.ts +++ b/src/features/tools/regex-tester/regex-test-task.ts @@ -1,5 +1,5 @@ import { runWorkerTask, WorkerTaskError } from "@/core/workers/run-worker-task" -import { testRegexPattern, type RegexTestResult } from "./utils" +import { type RegexTestResult } from "./utils" type RegexTestWorkerInput = { pattern: string @@ -14,10 +14,23 @@ type RegexTestTaskOptions = { maxMatches?: number } -function timeoutResult(message: string): RegexTestResult { +export type RegexTestTaskErrorCode = "worker_timeout" | "safe_evaluation_unavailable" + +export type RegexTestTaskResult = RegexTestResult & { + errorCode?: RegexTestTaskErrorCode + workerErrorCode?: string +} + +function failureResult( + errorCode: RegexTestTaskErrorCode, + workerErrorCode: string, + message: string, +): RegexTestTaskResult { return { ok: false, error: message, + errorCode, + workerErrorCode, matches: [], limited: false, elapsedMs: 0, @@ -25,14 +38,22 @@ function timeoutResult(message: string): RegexTestResult { } } +function unavailableResult(workerErrorCode: string): RegexTestTaskResult { + return failureResult( + "safe_evaluation_unavailable", + workerErrorCode, + "Safe regex evaluation is unavailable because the isolated worker could not run. Check browser or content-security settings and try again.", + ) +} + export async function runRegexTestTask( pattern: string, flags: string, testString: string, options: RegexTestTaskOptions = {}, -): Promise { +): Promise { if (typeof Worker === "undefined") { - return testRegexPattern(pattern, flags, testString, options.maxMatches) + return unavailableResult("WORKER_UNAVAILABLE") } try { @@ -43,11 +64,15 @@ export async function runRegexTestTask( ) } catch (error) { if (error instanceof WorkerTaskError && error.code === "WORKER_TIMEOUT") { - return timeoutResult("Regex evaluation was stopped after the safety timeout. Simplify the pattern or test a smaller input.") + return failureResult( + "worker_timeout", + error.code, + "Regex evaluation was stopped after the safety timeout. Simplify the pattern or test a smaller input.", + ) } if (error instanceof WorkerTaskError && error.code === "WORKER_ABORTED") { throw error } - return testRegexPattern(pattern, flags, testString, options.maxMatches) + return unavailableResult(error instanceof WorkerTaskError ? error.code : "WORKER_UNKNOWN_ERROR") } } diff --git a/tests/component/bf-016-tool-action-semantics.test.tsx b/tests/component/bf-016-tool-action-semantics.test.tsx index 804e18b2..2feec6c2 100644 --- a/tests/component/bf-016-tool-action-semantics.test.tsx +++ b/tests/component/bf-016-tool-action-semantics.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" import { Base64Page } from "@/features/tools/base64-encode-decode/page" @@ -7,6 +7,7 @@ import { ImageResizerPage } from "@/features/tools/image-resizer/page" import { JsonFormatterPage } from "@/features/tools/json-formatter/page" import { RegexTesterPage } from "@/features/tools/regex-tester/page" import { YouTubeThumbnailGrabberPage } from "@/features/tools/youtube-thumbnail-grabber/page" +import { RegexTestWorkerMock } from "../helpers/regex-test-worker-mock" const clipboardWriteMock = vi.fn() const downloadJsonOutputMock = vi.fn() @@ -139,6 +140,10 @@ class MockImage { } describe("BF-016 shared tool action semantics", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + beforeEach(() => { vi.clearAllMocks() installMemoryStorage() @@ -230,6 +235,7 @@ describe("BF-016 shared tool action semantics", () => { }) it("keeps Regex Tester Sample and Clear deterministic after invalid and valid states", async () => { + vi.stubGlobal("Worker", RegexTestWorkerMock) renderEnglish() expect(screen.getByRole("button", { name: "Sample" })).toHaveAttribute( diff --git a/tests/guards/regex-worker-guard.test.ts b/tests/guards/regex-worker-guard.test.ts index ce448050..78982881 100644 --- a/tests/guards/regex-worker-guard.test.ts +++ b/tests/guards/regex-worker-guard.test.ts @@ -10,6 +10,7 @@ describe("regex worker guard", () => { const page = read("src/features/tools/regex-tester/page.tsx") const task = read("src/features/tools/regex-tester/regex-test-task.ts") const worker = read("src/features/tools/regex-tester/regex-test-worker.ts") + const pipelineAdapters = read("src/features/pipeline/adapter-registry.ts") expect(page).toContain("evaluationAbortControllerRef") expect(page).toContain("runRegexTestTask") @@ -17,6 +18,10 @@ describe("regex worker guard", () => { expect(task).toContain("new Worker(new URL(\"./regex-test-worker.ts\", import.meta.url)") expect(task).toContain("timeoutMs ?? 1_000") expect(task).toContain("WORKER_TIMEOUT") + expect(task).toContain("safe_evaluation_unavailable") + expect(task).not.toContain("testRegexPattern") expect(worker).toContain("testRegexPattern(pattern, flags, testString, maxMatches)") + expect(pipelineAdapters).toContain("runRegexTestTask(pattern, flags, input") + expect(pipelineAdapters).not.toContain("testRegexPattern") }) }) diff --git a/tests/helpers/regex-test-worker-mock.ts b/tests/helpers/regex-test-worker-mock.ts new file mode 100644 index 00000000..90a33fae --- /dev/null +++ b/tests/helpers/regex-test-worker-mock.ts @@ -0,0 +1,31 @@ +import { testRegexPattern } from "@/features/tools/regex-tester/utils" + +type RegexWorkerInput = { + pattern: string + flags: string + testString: string + maxMatches?: number +} + +export class RegexTestWorkerMock { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + onmessageerror: ((event: MessageEvent) => void) | null = null + private terminated = false + + postMessage(input: RegexWorkerInput) { + queueMicrotask(() => { + if (this.terminated) return + this.onmessage?.({ + data: { + ok: true, + value: testRegexPattern(input.pattern, input.flags, input.testString, input.maxMatches), + }, + } as MessageEvent) + }) + } + + terminate() { + this.terminated = true + } +} diff --git a/tests/unit/pipeline-foundation.test.ts b/tests/unit/pipeline-foundation.test.ts index 1781e1e8..e194deb3 100644 --- a/tests/unit/pipeline-foundation.test.ts +++ b/tests/unit/pipeline-foundation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { getPipelineAdapter, getPipelineAdapterKeys, PIPELINE_TOOL_ADAPTERS } from "@/features/pipeline/adapter-registry" import { TOOL_MANIFESTS } from "@/core/registry" import { createPortableRecipe, decodeRecipeFromUrlParam, encodeRecipeForShareUrl, encodeRecipeForUrl, recipeContainsRuntimeInput } from "@/features/pipeline/recipe-codec" @@ -10,6 +10,7 @@ import { createSavedRecipeRecord, isRecipeStoreAvailable } from "@/features/pipe import { DEFAULT_RECIPE_SETTINGS, type PipelineToolAdapter, type RecipeDocument } from "@/features/pipeline/recipe-types" import { getStepCompatibilityHints } from "@/features/tools/pipeline-builder/logic" import { WORKFLOW_DEFINITIONS } from "@/core/workflows/workflow-hubs" +import { RegexTestWorkerMock } from "../helpers/regex-test-worker-mock" function buildRecipe(overrides: Partial = {}): RecipeDocument { const base: RecipeDocument = { @@ -43,6 +44,10 @@ function buildRecipe(overrides: Partial = {}): RecipeDocument { } describe("pipeline foundation", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it("exposes the first deterministic adapter set", () => { expect(getPipelineAdapterKeys()).toEqual([ "json_formatter", @@ -375,6 +380,7 @@ describe("pipeline foundation", () => { }) it("runs regex summary and env parser adapters", async () => { + vi.stubGlobal("Worker", RegexTestWorkerMock) const regexRecipe = buildRecipe({ steps: [{ id: "regex", diff --git a/tests/unit/regex-test-task.test.ts b/tests/unit/regex-test-task.test.ts index fcd2178b..2075b2b1 100644 --- a/tests/unit/regex-test-task.test.ts +++ b/tests/unit/regex-test-task.test.ts @@ -2,20 +2,34 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { runRegexTestTask } from "@/features/tools/regex-tester/regex-test-task" class MockRegexWorker { - static mode: "success" | "error" | "idle" = "success" + static mode: "success" | "taskError" | "runtimeError" | "messageError" | "postError" | "idle" = "success" + static throwOnCreate = false onmessage: ((event: MessageEvent) => void) | null = null onerror: ((event: ErrorEvent) => void) | null = null onmessageerror: ((event: MessageEvent) => void) | null = null terminated = false + constructor() { + if (MockRegexWorker.throwOnCreate) throw new Error("worker blocked") + } + postMessage() { + if (MockRegexWorker.mode === "postError") throw new Error("cannot clone") if (MockRegexWorker.mode === "idle") return queueMicrotask(() => { if (this.terminated) return - if (MockRegexWorker.mode === "error") { + if (MockRegexWorker.mode === "taskError") { this.onmessage?.({ data: { ok: false, error: "REGEX_WORKER_FAILED" } } as MessageEvent) return } + if (MockRegexWorker.mode === "runtimeError") { + this.onerror?.({ message: "worker crashed", error: new Error("worker crashed") } as ErrorEvent) + return + } + if (MockRegexWorker.mode === "messageError") { + this.onmessageerror?.({ data: null } as MessageEvent) + return + } this.onmessage?.({ data: { ok: true, @@ -41,6 +55,7 @@ describe("runRegexTestTask", () => { vi.unstubAllGlobals() vi.useRealTimers() MockRegexWorker.mode = "success" + MockRegexWorker.throwOnCreate = false }) it("uses the worker result when workers are available", async () => { @@ -52,14 +67,41 @@ describe("runRegexTestTask", () => { }) }) - it("falls back to sync evaluation on non-timeout worker failures", async () => { - MockRegexWorker.mode = "error" + it("fails closed when Worker is unavailable", async () => { + vi.stubGlobal("Worker", undefined) + + await expect(runRegexTestTask("a", "g", "abc")).resolves.toMatchObject({ + ok: false, + errorCode: "safe_evaluation_unavailable", + workerErrorCode: "WORKER_UNAVAILABLE", + }) + }) + + it.each([ + ["taskError", "REGEX_WORKER_FAILED"], + ["runtimeError", "WORKER_RUNTIME_ERROR"], + ["messageError", "WORKER_MESSAGE_ERROR"], + ["postError", "WORKER_POST_MESSAGE_FAILED"], + ] as const)("fails closed on %s worker failures", async (mode, workerErrorCode) => { + MockRegexWorker.mode = mode vi.stubGlobal("Worker", MockRegexWorker) - const result = await runRegexTestTask("a", "g", "abc") + await expect(runRegexTestTask("a", "g", "abc")).resolves.toMatchObject({ + ok: false, + errorCode: "safe_evaluation_unavailable", + workerErrorCode, + }) + }) - expect(result.ok).toBe(true) - if (result.ok) expect(result.matches[0].match).toBe("a") + it("fails closed when worker construction is blocked", async () => { + MockRegexWorker.throwOnCreate = true + vi.stubGlobal("Worker", MockRegexWorker) + + await expect(runRegexTestTask("a", "g", "abc")).resolves.toMatchObject({ + ok: false, + errorCode: "safe_evaluation_unavailable", + workerErrorCode: "WORKER_CREATE_FAILED", + }) }) it("returns an interrupted result on worker timeout instead of falling back to main-thread evaluation", async () => { @@ -72,6 +114,7 @@ describe("runRegexTestTask", () => { await expect(task).resolves.toMatchObject({ ok: false, + errorCode: "worker_timeout", error: expect.stringContaining("stopped after the safety timeout"), }) }) From d41f578a3ba5e6f442be87d63f00edebc049e879 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Wed, 15 Jul 2026 08:32:09 -0600 Subject: [PATCH 02/15] Reject disguised SVG raster uploads (#311) --- src/core/files/file-input-policy.ts | 115 ++++++++++++++++-- src/core/i18n/translations/de.json | 8 +- src/core/i18n/translations/en.json | 8 +- src/core/i18n/translations/fr.json | 8 +- src/core/i18n/translations/ja.json | 8 +- src/core/i18n/translations/ko.json | 8 +- src/core/i18n/translations/zh-CN.json | 8 +- src/core/i18n/translations/zh-TW.json | 8 +- src/core/utils/image-canvas-utils.ts | 4 +- .../tools/ascii-art-generator/page.tsx | 15 +-- .../tools/image-average-color-finder/page.tsx | 21 ++-- src/features/tools/image-base64/page.tsx | 4 +- src/features/tools/instagram-filters/page.tsx | 21 ++-- .../tools/instagram-post-generator/page.tsx | 21 ++-- .../tools/instagram-story-generator/page.tsx | 21 ++-- .../qr-code-generator/browser-actions.ts | 13 +- .../tools/qr-code-generator/constants.ts | 1 - src/features/tools/qr-code-generator/page.tsx | 32 +++-- src/features/tools/tweet-generator/page.tsx | 21 ++-- .../qr-code-generator-logo-upload.test.tsx | 101 +++++++++++++++ tests/guards/file-input-safety-guard.test.ts | 56 ++++++++- tests/unit/file-input-policy.test.ts | 41 ++++++- 22 files changed, 419 insertions(+), 124 deletions(-) create mode 100644 tests/component/qr-code-generator-logo-upload.test.tsx diff --git a/src/core/files/file-input-policy.ts b/src/core/files/file-input-policy.ts index 238099a9..69ab52eb 100644 --- a/src/core/files/file-input-policy.ts +++ b/src/core/files/file-input-policy.ts @@ -28,6 +28,8 @@ export type FileValidationResult = | { ok: true; file: File } | { ok: false; reason: "empty" | "too_large" | "unsupported_type"; message: string } +type RasterImageMime = "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/avif" + const TEXT_EXTENSIONS = [ ".txt", ".json", @@ -123,13 +125,13 @@ export const FILE_INPUT_POLICIES = { }, "scan-image": { id: "scan-image", - accept: "image/*", - description: "Up to 20 image pages, 12 MB and 24 MP each", + accept: ".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp", + description: "Up to 20 PNG, JPEG, or WebP pages, 12 MB and 24 MP each", maxBytes: 12 * 1024 * 1024, maxPixels: 24_000_000, maxFiles: 20, - allowedMimePrefixes: ["image/"], allowedExtensions: [".png", ".jpg", ".jpeg", ".webp"], + allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"], }, "recipe-json": { id: "recipe-json", @@ -163,13 +165,87 @@ function fileExtension(file: File): string { function hasAllowedFileType(file: File, policy: FileInputPolicy): boolean { const mime = file.type.toLowerCase() const extension = fileExtension(file) - const hasTypeRules = Boolean(policy.allowedMimePrefixes?.length || policy.allowedMimeTypes?.length || policy.allowedExtensions?.length) + const hasExtensionRules = Boolean(policy.allowedExtensions?.length) + const hasMimeRules = Boolean(policy.allowedMimePrefixes?.length || policy.allowedMimeTypes?.length) + const hasTypeRules = hasExtensionRules || hasMimeRules if (!hasTypeRules) return true - if (extension && policy.allowedExtensions?.length && !policy.allowedExtensions.includes(extension)) return false - if (mime && policy.allowedMimePrefixes?.some((prefix) => mime.startsWith(prefix))) return true - if (mime && policy.allowedMimeTypes?.includes(mime)) return true - if (extension && policy.allowedExtensions?.includes(extension)) return true - return false + + const extensionAllowed = Boolean(extension && policy.allowedExtensions?.includes(extension)) + const mimeAllowed = Boolean( + mime && ( + policy.allowedMimePrefixes?.some((prefix) => mime.startsWith(prefix)) + || policy.allowedMimeTypes?.includes(mime) + ), + ) + + if (extension && hasExtensionRules) { + if (!extensionAllowed) return false + + // Browsers often assign generic or vendor MIME types to extension-matched files. + // Raster content is checked separately against its file signature before decoding. + return !(mime.startsWith("image/") && hasMimeRules && !mimeAllowed) + } + + return mimeAllowed +} + +const RASTER_POLICY_IDS = new Set([ + "image-standard", + "image-compact", + "image-logo", + "scan-image", +]) + +const RASTER_MIME_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", +} + +function startsWithBytes(bytes: Uint8Array, signature: readonly number[]): boolean { + return signature.every((value, index) => bytes[index] === value) +} + +export function detectRasterImageMime(input: ArrayBuffer | Uint8Array): RasterImageMime | null { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input) + if (startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWithBytes(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWithBytes(bytes, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) + || startsWithBytes(bytes, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) return "image/gif" + if (startsWithBytes(bytes, [0x52, 0x49, 0x46, 0x46]) + && startsWithBytes(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) return "image/webp" + + const hasIsoFileTypeBox = startsWithBytes(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) + if (hasIsoFileTypeBox) { + const brands = new TextDecoder("ascii").decode(bytes.subarray(8, 64)) + if (brands.includes("avif") || brands.includes("avis")) return "image/avif" + } + return null +} + +function validateRasterFileBytes(file: File, policy: FileInputPolicy, input: ArrayBuffer | Uint8Array): FileValidationResult { + if (!RASTER_POLICY_IDS.has(policy.id)) return { ok: true, file } + + const detectedMime = detectRasterImageMime(input) + const extensionMime = RASTER_MIME_BY_EXTENSION[fileExtension(file)] + const declaredMime = file.type.toLowerCase() + const detectedMimeAllowed = detectedMime && ( + policy.allowedMimeTypes?.includes(detectedMime) + || policy.allowedMimePrefixes?.some((prefix) => detectedMime.startsWith(prefix)) + ) + + const declaredMimeConflicts = declaredMime.startsWith("image/") && declaredMime !== detectedMime + if (!detectedMime || !detectedMimeAllowed || (extensionMime && extensionMime !== detectedMime) || declaredMimeConflicts) { + return { + ok: false, + reason: "unsupported_type", + message: `File content does not match a supported raster image. Supported input: ${policy.description}.`, + } + } + return { ok: true, file } } export function validateFileAgainstPolicy(file: File, policy: FileInputPolicy): FileValidationResult { @@ -202,7 +278,26 @@ export async function readTextFileWithPolicy(file: File, policy: FileInputPolicy export async function readArrayBufferWithPolicy(file: File, policy: FileInputPolicy): Promise { const validation = validateFileAgainstPolicy(file, policy) if (!validation.ok) throw new Error(validation.message) - return file.arrayBuffer() + const buffer = await file.arrayBuffer() + const contentValidation = validateRasterFileBytes(file, policy, buffer) + if (!contentValidation.ok) throw new Error(contentValidation.message) + return buffer +} + +export async function validateFileContentAgainstPolicy(file: File, policy: FileInputPolicy): Promise { + const validation = validateFileAgainstPolicy(file, policy) + if (!validation.ok || !RASTER_POLICY_IDS.has(policy.id)) return validation + + try { + const signatureBytes = await file.slice(0, 64).arrayBuffer() + return validateRasterFileBytes(file, policy, signatureBytes) + } catch { + return { + ok: false, + reason: "unsupported_type", + message: "Unable to verify the selected file content.", + } + } } export function filterFilesByPolicy(files: Iterable, policy: FileInputPolicy): { accepted: File[]; rejected: Array<{ file: File; message: string }> } { diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 10047f04..f62175d1 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 → Bild", "image_input": "Bildeingabe", "drop_text": "Ziehen Sie ein Bild hierher oder klicken Sie", - "supports": "Unterstützt PNG, JPG, SVG, WebP", + "supports": "Unterstützt PNG, JPG, WebP, GIF und AVIF. Konvertieren Sie SVG-Dateien zuerst mit dem SVG-zu-PNG-Werkzeug.", "base64_output": "Base64-Ausgabe", "base64_input": "Base64-Eingabe", "image_preview": "Bildvorschau", @@ -910,7 +910,7 @@ "copy_format_success": "{format} in die Zwischenablage kopiert", "invalid_base64": "Ungültige Base64-Bildnutzlast.", "invalid_file_title": "Ungültige Datei", - "invalid_file_desc": "Bitte wählen Sie eine Bilddatei (PNG, JPG, SVG, WebP)", + "invalid_file_desc": "Wählen Sie eine PNG-, JPG-, WebP-, GIF- oder AVIF-Datei. Konvertieren Sie SVG-Dateien zuerst mit dem SVG-zu-PNG-Werkzeug.", "file_too_large_title": "Datei zu groß", "file_too_large_desc": "Bitte wählen Sie ein Bild unter 10 MB", "copied_output_desc": "{format} in die Zwischenablage kopiert", @@ -1097,9 +1097,9 @@ "logo_toggle": "Logo einbetten", "logo_upload": "Logo hochladen", "logo_remove": "Logo entfernen", - "logo_hint": "Verwenden Sie ein transparentes PNG-/SVG-Logo für beste Lesbarkeit.", + "logo_hint": "Verwenden Sie ein transparentes PNG-, JPEG-, WebP- oder GIF-Logo. Konvertieren Sie SVG-Logos zuerst mit dem SVG-zu-PNG-Werkzeug.", "logo_size": "Logogröße:", - "logo_invalid": "Bitte laden Sie eine Bilddatei hoch.", + "logo_invalid": "Bitte laden Sie ein gültiges PNG-, JPEG-, WebP- oder GIF-Logo hoch. SVG-Dateien werden hier nicht unterstützt.", "logo_too_large": "Die Logodatei darf maximal 2 MB groß sein.", "downloaded_png": "QR-Code als PNG gespeichert", "downloaded_svg": "QR-Code als SVG gespeichert", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 53b52dbf..bb0005a5 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -922,7 +922,7 @@ "decode_tab": "Base64 → Image", "image_input": "Image Input", "drop_text": "Drop an image here or click to browse", - "supports": "Supports PNG, JPG, SVG, WebP", + "supports": "Supports PNG, JPG, WebP, GIF, and AVIF. Convert SVG files with the SVG to PNG tool first.", "base64_output": "Base64 Output", "base64_input": "Base64 Input", "image_preview": "Image Preview", @@ -938,7 +938,7 @@ "copy_format_success": "{format} copied to clipboard", "invalid_base64": "Invalid Base64 image payload.", "invalid_file_title": "Invalid file", - "invalid_file_desc": "Please select an image file (PNG, JPG, SVG, WebP)", + "invalid_file_desc": "Please select a PNG, JPG, WebP, GIF, or AVIF file. Convert SVG files with the SVG to PNG tool first.", "file_too_large_title": "File too large", "file_too_large_desc": "Please select an image smaller than 10MB", "copied_output_desc": "{format} copied to clipboard", @@ -1125,9 +1125,9 @@ "logo_toggle": "Embed logo", "logo_upload": "Upload logo", "logo_remove": "Remove logo", - "logo_hint": "Use transparent PNG/SVG logo for best readability.", + "logo_hint": "Use a transparent PNG, JPEG, WebP, or GIF logo. Convert SVG logos with the SVG to PNG tool first.", "logo_size": "Logo size:", - "logo_invalid": "Please upload an image file.", + "logo_invalid": "Please upload a valid PNG, JPEG, WebP, or GIF logo. SVG files are not supported here.", "logo_too_large": "Logo file must be 2MB or smaller.", "downloaded_png": "QR code saved as PNG", "downloaded_svg": "QR code saved as SVG", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 037776ee..3bf39c69 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 vers image", "image_input": "Entrée Image", "drop_text": "Déposez une image ici ou cliquez pour parcourir", - "supports": "Prend en charge PNG, JPG, SVG, WebP", + "supports": "Prend en charge PNG, JPG, WebP, GIF et AVIF. Convertissez d'abord les fichiers SVG avec l'outil SVG vers PNG.", "base64_output": "Sortie Base64", "base64_input": "Entrée Base64", "image_preview": "Aperçu de l'image", @@ -910,7 +910,7 @@ "copy_format_success": "{format} copié dans le presse-papiers", "invalid_base64": "Charge utile d'image Base64 invalide.", "invalid_file_title": "Fichier invalide", - "invalid_file_desc": "Veuillez sélectionner un fichier image (PNG, JPG, SVG, WebP)", + "invalid_file_desc": "Sélectionnez un fichier PNG, JPG, WebP, GIF ou AVIF. Convertissez d'abord les fichiers SVG avec l'outil SVG vers PNG.", "file_too_large_title": "Fichier trop volumineux", "file_too_large_desc": "Veuillez sélectionner une image inférieure à 10 Mo", "copied_output_desc": "{format} copié dans le presse-papiers", @@ -1097,9 +1097,9 @@ "logo_toggle": "Intégrer un logo", "logo_upload": "Importer un logo", "logo_remove": "Supprimer le logo", - "logo_hint": "Utilisez un logo PNG/SVG transparent pour une meilleure lisibilité.", + "logo_hint": "Utilisez un logo PNG, JPEG, WebP ou GIF transparent. Convertissez d'abord les logos SVG avec l'outil SVG vers PNG.", "logo_size": "Taille du logo :", - "logo_invalid": "Veuillez importer un fichier image.", + "logo_invalid": "Importez un logo PNG, JPEG, WebP ou GIF valide. Les fichiers SVG ne sont pas pris en charge ici.", "logo_too_large": "Le fichier logo doit faire 2 Mo maximum.", "downloaded_png": "Code QR enregistré en PNG", "downloaded_svg": "Code QR enregistré en SVG", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 2a261633..179ed420 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 → 画像", "image_input": "画像入力", "drop_text": "ここに画像をドロップするか、クリックして参照します", - "supports": "PNG, JPG, SVG, WebP をサポート", + "supports": "PNG、JPG、WebP、GIF、AVIF に対応しています。SVG ファイルは先に SVG から PNG 変換ツールで変換してください。", "base64_output": "Base64 出力", "base64_input": "Base64 入力", "image_preview": "画像プレビュー", @@ -910,7 +910,7 @@ "copy_format_success": "{format} をクリップボードにコピーしました", "invalid_base64": "Base64 画像データが無効です。", "invalid_file_title": "無効なファイルです", - "invalid_file_desc": "画像ファイル(PNG、JPG、SVG、WebP)を選択してください", + "invalid_file_desc": "PNG、JPG、WebP、GIF、AVIF ファイルを選択してください。SVG ファイルは先に SVG から PNG 変換ツールで変換してください。", "file_too_large_title": "ファイルが大きすぎます", "file_too_large_desc": "10MB 未満の画像を選択してください", "copied_output_desc": "{format} をクリップボードにコピーしました", @@ -1097,9 +1097,9 @@ "logo_toggle": "ロゴを埋め込む", "logo_upload": "ロゴをアップロード", "logo_remove": "ロゴを削除", - "logo_hint": "可読性のため、透過 PNG/SVG ロゴの使用を推奨します。", + "logo_hint": "透明な PNG、JPEG、WebP、GIF ロゴを使用してください。SVG ロゴは先に SVG から PNG 変換ツールで変換してください。", "logo_size": "ロゴサイズ:", - "logo_invalid": "画像ファイルをアップロードしてください。", + "logo_invalid": "有効な PNG、JPEG、WebP、GIF ロゴをアップロードしてください。ここでは SVG ファイルを使用できません。", "logo_too_large": "ロゴファイルは 2MB 以下にしてください。", "downloaded_png": "QRコードを PNG として保存しました", "downloaded_svg": "QRコードを SVG として保存しました", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index d0be6796..4ebe5a23 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 → 이미지", "image_input": "이미지 입력", "drop_text": "여기에 이미지를 놓거나 클릭하여 찾아보기", - "supports": "PNG, JPG, SVG, WebP 지원", + "supports": "PNG, JPG, WebP, GIF 및 AVIF를 지원합니다. SVG 파일은 먼저 SVG-PNG 변환 도구로 변환하세요.", "base64_output": "Base64 출력", "base64_input": "Base64 입력", "image_preview": "이미지 미리보기", @@ -910,7 +910,7 @@ "copy_format_success": "{format} 형식을 클립보드에 복사했습니다", "invalid_base64": "잘못된 Base64 이미지 데이터입니다.", "invalid_file_title": "잘못된 파일", - "invalid_file_desc": "이미지 파일(PNG, JPG, SVG, WebP)을 선택해 주세요", + "invalid_file_desc": "PNG, JPG, WebP, GIF 또는 AVIF 파일을 선택하세요. SVG 파일은 먼저 SVG-PNG 변환 도구로 변환하세요.", "file_too_large_title": "파일이 너무 큽니다", "file_too_large_desc": "10MB 미만의 이미지를 선택해 주세요", "copied_output_desc": "{format} 형식을 클립보드에 복사했습니다", @@ -1097,9 +1097,9 @@ "logo_toggle": "로고 삽입", "logo_upload": "로고 업로드", "logo_remove": "로고 제거", - "logo_hint": "가독성을 위해 투명 PNG/SVG 로고 사용을 권장합니다.", + "logo_hint": "투명한 PNG, JPEG, WebP 또는 GIF 로고를 사용하세요. SVG 로고는 먼저 SVG-PNG 변환 도구로 변환하세요.", "logo_size": "로고 크기:", - "logo_invalid": "이미지 파일을 업로드해 주세요.", + "logo_invalid": "올바른 PNG, JPEG, WebP 또는 GIF 로고를 업로드하세요. 여기서는 SVG 파일을 지원하지 않습니다.", "logo_too_large": "로고 파일은 2MB 이하여야 합니다.", "downloaded_png": "QR 코드가 PNG로 저장되었습니다", "downloaded_svg": "QR 코드가 SVG로 저장되었습니다", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 77da0687..8eec08df 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 → 图片", "image_input": "图片输入", "drop_text": "将图片拖放到此处或点击浏览", - "supports": "支持 PNG、JPG、SVG、WebP", + "supports": "支持 PNG、JPG、WebP、GIF 和 AVIF。SVG 文件请先使用 SVG 转 PNG 工具转换。", "base64_output": "Base64 输出", "base64_input": "Base64 输入", "image_preview": "图片预览", @@ -910,7 +910,7 @@ "copy_format_success": "已将 {format} 复制到剪贴板", "invalid_base64": "Base64 图片载荷无效。", "invalid_file_title": "文件无效", - "invalid_file_desc": "请选择图片文件(PNG、JPG、SVG、WebP)", + "invalid_file_desc": "请选择 PNG、JPG、WebP、GIF 或 AVIF 文件。SVG 文件请先使用 SVG 转 PNG 工具转换。", "file_too_large_title": "文件过大", "file_too_large_desc": "请选择小于 10MB 的图片", "copied_output_desc": "已将 {format} 复制到剪贴板", @@ -1097,9 +1097,9 @@ "logo_toggle": "嵌入 Logo", "logo_upload": "上传 Logo", "logo_remove": "移除 Logo", - "logo_hint": "建议使用透明 PNG/SVG Logo 以获得最佳可读性。", + "logo_hint": "请使用透明的 PNG、JPEG、WebP 或 GIF Logo。SVG Logo 请先通过 SVG 转 PNG 工具转换。", "logo_size": "Logo 大小:", - "logo_invalid": "请上传图片文件。", + "logo_invalid": "请上传有效的 PNG、JPEG、WebP 或 GIF Logo;此处不支持 SVG 文件。", "logo_too_large": "Logo 文件大小必须不超过 2MB。", "downloaded_png": "QR 码已保存为 PNG", "downloaded_svg": "QR 码已保存为 SVG", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index eccc4273..0e7dc66b 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -894,7 +894,7 @@ "decode_tab": "Base64 → 圖片", "image_input": "圖片輸入", "drop_text": "將圖片拖曳至此處或點擊瀏覽", - "supports": "支援 PNG、JPG、SVG、WebP", + "supports": "支援 PNG、JPG、WebP、GIF 和 AVIF。SVG 檔案請先使用 SVG 轉 PNG 工具轉換。", "base64_output": "Base64 輸出", "base64_input": "Base64 輸入", "image_preview": "圖片預覽", @@ -910,7 +910,7 @@ "copy_format_success": "已將 {format} 複製到剪貼簿", "invalid_base64": "Base64 圖像內容無效。", "invalid_file_title": "檔案無效", - "invalid_file_desc": "請選擇圖片檔案(PNG、JPG、SVG、WebP)", + "invalid_file_desc": "請選擇 PNG、JPG、WebP、GIF 或 AVIF 檔案。SVG 檔案請先使用 SVG 轉 PNG 工具轉換。", "file_too_large_title": "檔案過大", "file_too_large_desc": "請選擇小於 10MB 的圖片", "copied_output_desc": "已將 {format} 複製到剪貼簿", @@ -1097,9 +1097,9 @@ "logo_toggle": "嵌入 Logo", "logo_upload": "上傳 Logo", "logo_remove": "移除 Logo", - "logo_hint": "建議使用透明 PNG/SVG Logo 以獲得最佳可讀性。", + "logo_hint": "請使用透明的 PNG、JPEG、WebP 或 GIF Logo。SVG Logo 請先透過 SVG 轉 PNG 工具轉換。", "logo_size": "Logo 大小:", - "logo_invalid": "請上傳圖片檔案。", + "logo_invalid": "請上傳有效的 PNG、JPEG、WebP 或 GIF Logo;此處不支援 SVG 檔案。", "logo_too_large": "Logo 檔案大小必須不超過 2MB。", "downloaded_png": "QR Code 已儲存為 PNG", "downloaded_svg": "QR Code 已儲存為 SVG", diff --git a/src/core/utils/image-canvas-utils.ts b/src/core/utils/image-canvas-utils.ts index 67924f07..01d26ab5 100644 --- a/src/core/utils/image-canvas-utils.ts +++ b/src/core/utils/image-canvas-utils.ts @@ -1,11 +1,11 @@ -import { FILE_INPUT_POLICIES, formatPixelLimit, validateFileAgainstPolicy, type FileInputPolicy } from "@/core/files/file-input-policy" +import { FILE_INPUT_POLICIES, formatPixelLimit, validateFileContentAgainstPolicy, type FileInputPolicy } from "@/core/files/file-input-policy" export async function fileToDataUrl( file: File, policy: FileInputPolicy = FILE_INPUT_POLICIES["image-standard"], options: { signal?: AbortSignal } = {}, ): Promise { - const validation = validateFileAgainstPolicy(file, policy) + const validation = await validateFileContentAgainstPolicy(file, policy) if (!validation.ok) throw new Error(validation.message) return new Promise((resolve, reject) => { const reader = new FileReader() diff --git a/src/features/tools/ascii-art-generator/page.tsx b/src/features/tools/ascii-art-generator/page.tsx index e7a50f30..8cbea0f1 100644 --- a/src/features/tools/ascii-art-generator/page.tsx +++ b/src/features/tools/ascii-art-generator/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button" import { useLang } from "@/core/i18n/lang-provider" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/file-input-policy" +import { fileToDataUrl } from "@/core/utils/image-canvas-utils" const ASCII_CHARS = ["@", "#", "S", "%", "?", "*", "+", ";", ":", ",", "."] @@ -123,14 +124,13 @@ export function AsciiArtGeneratorPage() { } }, [input, mode]) - const handleImageUpload = (file: File) => { + const handleImageUpload = async (file: File) => { const validation = validateFileAgainstPolicy(file, FILE_INPUT_POLICIES["image-compact"]) if (!validation.ok) { toast.error(validation.reason === "unsupported_type" ? t.common.image_file_required : validation.message) return } const img = new Image() - let objectUrl = "" img.onload = () => { const canvas = canvasRef.current! canvas.width = img.width @@ -138,14 +138,15 @@ export function AsciiArtGeneratorPage() { const ctx = canvas.getContext("2d")! ctx.drawImage(img, 0, 0) setOutput(imageToAscii(canvas, asciiWidth)) - URL.revokeObjectURL(objectUrl) } img.onerror = () => { - URL.revokeObjectURL(objectUrl) toast.error(t.common.image_file_read_failed) } - objectUrl = URL.createObjectURL(file) - img.src = objectUrl + try { + img.src = await fileToDataUrl(file, FILE_INPUT_POLICIES["image-compact"]) + } catch (error) { + toast.error(error instanceof Error ? error.message : t.common.image_file_read_failed) + } } return ( @@ -184,7 +185,7 @@ export function AsciiArtGeneratorPage() { - e.target.files?.[0] && handleImageUpload(e.target.files[0])} /> + e.target.files?.[0] && void handleImageUpload(e.target.files[0])} />
setAsciiWidth(Number(e.target.value))} className="w-full" /> diff --git a/src/features/tools/image-average-color-finder/page.tsx b/src/features/tools/image-average-color-finder/page.tsx index b20c8aa4..bbc5696c 100644 --- a/src/features/tools/image-average-color-finder/page.tsx +++ b/src/features/tools/image-average-color-finder/page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Copy, Download, Eraser, Image as ImageIcon, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" +import { FILE_INPUT_POLICIES, formatFilePolicyLimit, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" @@ -11,7 +12,7 @@ import { averageHexFromPixels, averageRgbFromPixels, rgbToString } from "@/core/ import { createDemoImageDataUrl, fileToDataUrl, getImageDataForAnalysis } from "@/core/utils/image-canvas-utils" import { safeClipboardWrite } from "@/core/clipboard/clipboard" -const MAX_FILE_SIZE = 10 * 1024 * 1024 +const IMAGE_FILE_POLICY = FILE_INPUT_POLICIES["image-compact"] export function ImageAverageColorFinderPage() { const { t } = useLang() @@ -47,17 +48,16 @@ export function ImageAverageColorFinderPage() { } const handleFile = async (file: File) => { - if (!file.type.startsWith("image/")) { - toast.error(t.common.image_file_required) - return - } - if (file.size > MAX_FILE_SIZE) { - toast.error((t.common.image_file_too_large).replace("{size}", "10MB")) + const validation = validateFileAgainstPolicy(file, IMAGE_FILE_POLICY) + if (!validation.ok) { + toast.error(validation.reason === "too_large" + ? t.common.image_file_too_large.replace("{size}", formatFilePolicyLimit(IMAGE_FILE_POLICY)) + : t.common.image_file_required) return } try { - const dataUrl = await fileToDataUrl(file) + const dataUrl = await fileToDataUrl(file, IMAGE_FILE_POLICY) setImageSrc(dataUrl) setFileName(file.name) await analyzeFromSource(dataUrl) @@ -162,10 +162,11 @@ export function ImageAverageColorFinderPage() { { - const file = event.target.files?.[0] + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" if (file) void handleFile(file) }} /> diff --git a/src/features/tools/image-base64/page.tsx b/src/features/tools/image-base64/page.tsx index 7c4248e9..7de99fa0 100644 --- a/src/features/tools/image-base64/page.tsx +++ b/src/features/tools/image-base64/page.tsx @@ -118,8 +118,8 @@ export function ImageBase64Page() { return } const mimeFromName = getMimeFromFileName(file.name) - const effectiveMime = file.type && file.type.startsWith("image/") ? file.type : mimeFromName - if (!effectiveMime || !effectiveMime.startsWith("image/")) { + const effectiveMime = file.type || mimeFromName + if (!effectiveMime) { toast.error(text("invalid_file_title"), { description: text("invalid_file_desc"), }) diff --git a/src/features/tools/instagram-filters/page.tsx b/src/features/tools/instagram-filters/page.tsx index eaacceac..097961fa 100644 --- a/src/features/tools/instagram-filters/page.tsx +++ b/src/features/tools/instagram-filters/page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Camera, Copy, Download, Eraser, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" +import { FILE_INPUT_POLICIES, formatFilePolicyLimit, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" @@ -17,7 +18,7 @@ import { type InstagramFilterPreset, } from "@/core/utils/instagram-tool-utils" -const MAX_FILE_SIZE = 12 * 1024 * 1024 +const IMAGE_FILE_POLICY = FILE_INPUT_POLICIES["image-standard"] const DEFAULT_PRESET_ID = "clarendon" function toHexAlphaColor(hexColor: string, alpha: number): string { @@ -122,17 +123,16 @@ export function InstagramFiltersPage() { } const handleFile = async (file: File) => { - if (!file.type.startsWith("image/")) { - toast.error(t.common.image_file_required) - return - } - if (file.size > MAX_FILE_SIZE) { - toast.error((t.common.image_file_too_large).replace("{size}", "12MB")) + const validation = validateFileAgainstPolicy(file, IMAGE_FILE_POLICY) + if (!validation.ok) { + toast.error(validation.reason === "too_large" + ? t.common.image_file_too_large.replace("{size}", formatFilePolicyLimit(IMAGE_FILE_POLICY)) + : t.common.image_file_required) return } try { - const src = await fileToDataUrl(file) + const src = await fileToDataUrl(file, IMAGE_FILE_POLICY) setSourceImage(src) setFileName(file.name) } catch { @@ -209,10 +209,11 @@ export function InstagramFiltersPage() { { - const file = event.target.files?.[0] + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" if (file) void handleFile(file) }} /> diff --git a/src/features/tools/instagram-post-generator/page.tsx b/src/features/tools/instagram-post-generator/page.tsx index 96362024..bf475cd6 100644 --- a/src/features/tools/instagram-post-generator/page.tsx +++ b/src/features/tools/instagram-post-generator/page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Copy, Download, Eraser, Instagram, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" +import { FILE_INPUT_POLICIES, formatFilePolicyLimit, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" @@ -17,7 +18,7 @@ import { type SocialTheme, } from "@/core/utils/social-media-utils" -const MAX_FILE_SIZE = 12 * 1024 * 1024 +const IMAGE_FILE_POLICY = FILE_INPUT_POLICIES["image-standard"] const DEFAULT_STATE = { username: "s42.lab", @@ -193,16 +194,15 @@ export function InstagramPostGeneratorPage() { ) const handleFile = async (file: File) => { - if (!file.type.startsWith("image/")) { - toast.error(t.common.image_file_required) - return - } - if (file.size > MAX_FILE_SIZE) { - toast.error((t.common.image_file_too_large).replace("{size}", "12MB")) + const validation = validateFileAgainstPolicy(file, IMAGE_FILE_POLICY) + if (!validation.ok) { + toast.error(validation.reason === "too_large" + ? t.common.image_file_too_large.replace("{size}", formatFilePolicyLimit(IMAGE_FILE_POLICY)) + : t.common.image_file_required) return } try { - const dataUrl = await fileToDataUrl(file) + const dataUrl = await fileToDataUrl(file, IMAGE_FILE_POLICY) setImageSrc(dataUrl) setFileName(file.name) } catch { @@ -294,10 +294,11 @@ export function InstagramPostGeneratorPage() { { - const file = event.target.files?.[0] + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" if (file) void handleFile(file) }} /> diff --git a/src/features/tools/instagram-story-generator/page.tsx b/src/features/tools/instagram-story-generator/page.tsx index 04b708e6..bd5667ff 100644 --- a/src/features/tools/instagram-story-generator/page.tsx +++ b/src/features/tools/instagram-story-generator/page.tsx @@ -5,6 +5,7 @@ import * as React from "react" import { Copy, Download, Eraser, Instagram, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" +import { FILE_INPUT_POLICIES, formatFilePolicyLimit, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" @@ -17,7 +18,7 @@ import { type SocialTheme, } from "@/core/utils/social-media-utils" -const MAX_FILE_SIZE = 12 * 1024 * 1024 +const IMAGE_FILE_POLICY = FILE_INPUT_POLICIES["image-standard"] const DEFAULT_STATE = { username: "s42.lab", @@ -216,16 +217,15 @@ export function InstagramStoryGeneratorPage() { ) const handleFile = async (file: File) => { - if (!file.type.startsWith("image/")) { - toast.error(t.common.image_file_required) - return - } - if (file.size > MAX_FILE_SIZE) { - toast.error((t.common.image_file_too_large).replace("{size}", "12MB")) + const validation = validateFileAgainstPolicy(file, IMAGE_FILE_POLICY) + if (!validation.ok) { + toast.error(validation.reason === "too_large" + ? t.common.image_file_too_large.replace("{size}", formatFilePolicyLimit(IMAGE_FILE_POLICY)) + : t.common.image_file_required) return } try { - const dataUrl = await fileToDataUrl(file) + const dataUrl = await fileToDataUrl(file, IMAGE_FILE_POLICY) setImageSrc(dataUrl) setFileName(file.name) } catch { @@ -321,10 +321,11 @@ export function InstagramStoryGeneratorPage() { { - const file = event.target.files?.[0] + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" if (file) void handleFile(file) }} /> diff --git a/src/features/tools/qr-code-generator/browser-actions.ts b/src/features/tools/qr-code-generator/browser-actions.ts index 83fa23ce..3b1f88e8 100644 --- a/src/features/tools/qr-code-generator/browser-actions.ts +++ b/src/features/tools/qr-code-generator/browser-actions.ts @@ -1,5 +1,6 @@ import type { ErrorCorrectionLevel } from "./types" -import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/file-input-policy" +import { FILE_INPUT_POLICIES } from "@/core/files/file-input-policy" +import { fileToDataUrl } from "@/core/utils/image-canvas-utils" let qrCodePromise: Promise | null = null let toastPromise: Promise | null = null @@ -53,15 +54,7 @@ export function injectLogoIntoSvg(svg: string, options: { dataUrl: string; size: } export function readFileAsDataUrl(file: File): Promise { - const validation = validateFileAgainstPolicy(file, FILE_INPUT_POLICIES["image-logo"]) - if (!validation.ok) return Promise.reject(new Error(validation.message)) - - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result || "")) - reader.onerror = () => reject(reader.error || new Error("Failed to read file")) - reader.readAsDataURL(file) - }) + return fileToDataUrl(file, FILE_INPUT_POLICIES["image-logo"]) } export function downloadDataUrl(dataUrl: string, filename: string) { diff --git a/src/features/tools/qr-code-generator/constants.ts b/src/features/tools/qr-code-generator/constants.ts index 149c5e73..7be23a93 100644 --- a/src/features/tools/qr-code-generator/constants.ts +++ b/src/features/tools/qr-code-generator/constants.ts @@ -12,7 +12,6 @@ export const BUTTON_SIZE_CLASS = { sm: "h-9 px-3", } as const -export const MAX_LOGO_SIZE = 2 * 1024 * 1024 export const DEFAULT_QR_TEXT = "https://example.com/r/42" export const SAMPLE_QR_TEXT = "https://example.com/qr?id=42" diff --git a/src/features/tools/qr-code-generator/page.tsx b/src/features/tools/qr-code-generator/page.tsx index 8048d8a3..03fefca5 100644 --- a/src/features/tools/qr-code-generator/page.tsx +++ b/src/features/tools/qr-code-generator/page.tsx @@ -17,6 +17,7 @@ import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-actio import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" import { RelatedTools } from "@/core/seo/components/related-tools" import { safeClipboardWrite } from "@/core/clipboard/clipboard" +import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { buildQrSvg, downloadDataUrl, @@ -27,9 +28,11 @@ import { loadToast, readFileAsDataUrl, } from "./browser-actions" -import { BUTTON_BASE_CLASS, BUTTON_SIZE_CLASS, BUTTON_VARIANT_CLASS, DEFAULT_QR_TEXT, MAX_LOGO_SIZE, PRESETS, SAMPLE_QR_TEXT } from "./constants" +import { BUTTON_BASE_CLASS, BUTTON_SIZE_CLASS, BUTTON_VARIANT_CLASS, DEFAULT_QR_TEXT, PRESETS, SAMPLE_QR_TEXT } from "./constants" import type { ErrorCorrectionLevel, QrPreset } from "./types" +const LOGO_FILE_POLICY = FILE_INPUT_POLICIES["image-logo"] + function joinClasses(...values: Array) { return values.filter(Boolean).join(" ") } @@ -151,22 +154,23 @@ export function QrCodeGeneratorPage() { setActivePreset(preset.id) } - const handleLogoUpload = (file: File | null) => { + const handleLogoUpload = async (file: File | null) => { if (!file) return - if (!file.type.startsWith("image/")) { - void notifyError(textFor("logo_invalid")) - return - } - if (file.size > MAX_LOGO_SIZE) { - void notifyError(textFor("logo_too_large")) + + const validation = validateFileAgainstPolicy(file, LOGO_FILE_POLICY) + if (!validation.ok) { + await notifyError(textFor(validation.reason === "too_large" ? "logo_too_large" : "logo_invalid")) return } - void readFileAsDataUrl(file).then((dataUrl) => { + try { + const dataUrl = await readFileAsDataUrl(file) setLogoDataUrl(dataUrl) setLogoName(file.name) setLogoEnabled(true) - }) + } catch { + await notifyError(textFor("logo_invalid")) + } } const handleRemoveLogo = () => { @@ -389,8 +393,12 @@ export function QrCodeGeneratorPage() { ref={logoInputRef} type="file" className="hidden" - accept="image/*" - onChange={(event) => handleLogoUpload(event.target.files?.[0] || null)} + accept={LOGO_FILE_POLICY.accept} + onChange={(event) => { + const file = event.currentTarget.files?.[0] || null + event.currentTarget.value = "" + void handleLogoUpload(file) + }} />
diff --git a/src/features/tools/tweet-generator/page.tsx b/src/features/tools/tweet-generator/page.tsx index 842bbd1c..2b08e4b3 100644 --- a/src/features/tools/tweet-generator/page.tsx +++ b/src/features/tools/tweet-generator/page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Copy, Download, Eraser, MessageCircle, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" +import { FILE_INPUT_POLICIES, formatFilePolicyLimit, validateFileAgainstPolicy } from "@/core/files/file-input-policy" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" @@ -17,7 +18,7 @@ import { type SocialTheme, } from "@/core/utils/social-media-utils" -const MAX_FILE_SIZE = 5 * 1024 * 1024 +const AVATAR_FILE_POLICY = FILE_INPUT_POLICIES["image-logo"] const DEFAULT_STATE = { displayName: "S42 Lab", @@ -235,16 +236,15 @@ export function TweetGeneratorPage() { ) const handleAvatarFile = async (file: File) => { - if (!file.type.startsWith("image/")) { - toast.error(t.common.image_file_required) - return - } - if (file.size > MAX_FILE_SIZE) { - toast.error((t.common.image_file_too_large).replace("{size}", "5MB")) + const validation = validateFileAgainstPolicy(file, AVATAR_FILE_POLICY) + if (!validation.ok) { + toast.error(validation.reason === "too_large" + ? t.common.image_file_too_large.replace("{size}", formatFilePolicyLimit(AVATAR_FILE_POLICY)) + : t.common.image_file_required) return } try { - const dataUrl = await fileToDataUrl(file) + const dataUrl = await fileToDataUrl(file, AVATAR_FILE_POLICY) setAvatarDataUrl(dataUrl) setAvatarName(file.name) } catch { @@ -384,10 +384,11 @@ export function TweetGeneratorPage() { { - const file = event.target.files?.[0] + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" if (file) void handleAvatarFile(file) }} /> diff --git a/tests/component/qr-code-generator-logo-upload.test.tsx b/tests/component/qr-code-generator-logo-upload.test.tsx new file mode 100644 index 00000000..4770b8e5 --- /dev/null +++ b/tests/component/qr-code-generator-logo-upload.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { FILE_INPUT_POLICIES } from "@/core/files/file-input-policy" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { QrCodeGeneratorPage } from "@/features/tools/qr-code-generator/page" + +const { + readFileAsDataUrlMock, + toastErrorMock, + toastSuccessMock, + toCanvasMock, +} = vi.hoisted(() => ({ + readFileAsDataUrlMock: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + toCanvasMock: vi.fn(), +})) + +vi.mock("@/core/seo/components/related-tools", () => ({ + RelatedTools: () => null, +})) + +vi.mock("next/navigation", () => ({ + usePathname: () => "/en/qr-code-generator", +})) + +vi.mock("@/features/tools/qr-code-generator/browser-actions", () => ({ + buildQrSvg: vi.fn(), + downloadDataUrl: vi.fn(), + downloadSvg: vi.fn(), + drawRoundedRect: vi.fn(), + loadImage: vi.fn(), + loadQRCode: async () => ({ toCanvas: toCanvasMock }), + loadToast: async () => ({ + error: toastErrorMock, + success: toastSuccessMock, + }), + readFileAsDataUrl: (file: File) => readFileAsDataUrlMock(file), +})) + +function renderPage() { + return render( + + + , + ) +} + +describe("QR code generator logo uploads", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal("ResizeObserver", class { + observe() {} + unobserve() {} + disconnect() {} + }) + toCanvasMock.mockResolvedValue(undefined) + HTMLCanvasElement.prototype.getContext = vi.fn(() => null) as unknown as HTMLCanvasElement["getContext"] + HTMLCanvasElement.prototype.toDataURL = vi.fn(() => "data:image/png;base64,qr-code") + }) + + it("limits the picker to raster logo formats and points SVG users to the converter", () => { + const { container } = renderPage() + const input = container.querySelector('input[type="file"]') + + expect(input).not.toBeNull() + expect(input).toHaveAttribute("accept", FILE_INPUT_POLICIES["image-logo"].accept) + expect(input).not.toHaveAttribute("accept", "image/*") + expect(screen.getByText(/Convert SVG logos with the SVG to PNG tool first/i)).toBeInTheDocument() + }) + + it("reports strict raster validation failures without enabling a rejected SVG logo", async () => { + const { container } = renderPage() + const input = container.querySelector('input[type="file"]') + const disguisedSvg = new File([""], "logo.png", { type: "image/png" }) + readFileAsDataUrlMock.mockRejectedValueOnce(new Error("File content does not match a supported raster image.")) + + fireEvent.change(input!, { target: { files: [disguisedSvg] } }) + + await waitFor(() => expect(readFileAsDataUrlMock).toHaveBeenCalledWith(disguisedSvg)) + await waitFor(() => expect(toastErrorMock).toHaveBeenCalledWith( + "Please upload a valid PNG, JPEG, WebP, or GIF logo. SVG files are not supported here.", + )) + expect(screen.queryByText("logo.png")).not.toBeInTheDocument() + expect(screen.getByRole("switch")).not.toBeChecked() + }) + + it("enables a logo only after raster content validation succeeds", async () => { + const { container } = renderPage() + const input = container.querySelector('input[type="file"]') + const logo = new File(["png"], "logo.png", { type: "image/png" }) + readFileAsDataUrlMock.mockResolvedValueOnce("data:image/png;base64,logo") + + fireEvent.change(input!, { target: { files: [logo] } }) + + expect(await screen.findByText("logo.png")).toBeInTheDocument() + expect(screen.getByRole("switch")).toBeChecked() + expect(toastErrorMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/guards/file-input-safety-guard.test.ts b/tests/guards/file-input-safety-guard.test.ts index af00f463..77c73d89 100644 --- a/tests/guards/file-input-safety-guard.test.ts +++ b/tests/guards/file-input-safety-guard.test.ts @@ -9,7 +9,6 @@ const TEXT_FILE_PATTERN = /\.(ts|tsx)$/ const ALLOWED_RAW_FILE_READERS = new Set([ "src/core/files/file-input-policy.ts", "src/core/utils/image-canvas-utils.ts", - "src/features/tools/qr-code-generator/browser-actions.ts", "src/features/tools/image-resizer/image-resize-task.ts", "src/features/tools/scanned-pdf-converter/scan-enhance-task.ts", ]) @@ -66,6 +65,61 @@ describe("file input safety guard", () => { } }) + it("requires generic raster readers to verify file signatures before decoding", () => { + const policySource = read("src/core/files/file-input-policy.ts") + const imageUtilsSource = read("src/core/utils/image-canvas-utils.ts") + const qrActionsSource = read("src/features/tools/qr-code-generator/browser-actions.ts") + + expect(policySource).toContain("detectRasterImageMime") + expect(policySource).toContain("validateFileContentAgainstPolicy") + expect(policySource).not.toContain('"scan-image": {\n id: "scan-image",\n accept: "image/*"') + expect(imageUtilsSource).toContain("validateFileContentAgainstPolicy(file, policy)") + expect(qrActionsSource).toContain('fileToDataUrl(file, FILE_INPUT_POLICIES["image-logo"])') + expect(qrActionsSource).not.toContain("new FileReader") + }) + + it("keeps every raster-only picker aligned with an explicit shared policy", () => { + const wildcardImagePickers = sourceFiles().filter((file) => read(file).includes('accept="image/*"')) + expect(wildcardImagePickers).toEqual([]) + + const filesByPolicy = new Map([ + ["src/features/tools/ascii-art-generator/page.tsx", "image-compact"], + ["src/features/tools/image-average-color-finder/page.tsx", "image-compact"], + ["src/features/tools/image-base64/page.tsx", "image-compact"], + ["src/features/tools/instagram-filters/page.tsx", "image-standard"], + ["src/features/tools/instagram-post-generator/page.tsx", "image-standard"], + ["src/features/tools/instagram-story-generator/page.tsx", "image-standard"], + ["src/features/tools/qr-code-generator/page.tsx", "image-logo"], + ["src/features/tools/tweet-generator/page.tsx", "image-logo"], + ]) + + for (const [file, policyId] of filesByPolicy) { + const source = read(file) + expect(source, file).toContain(`FILE_INPUT_POLICIES["${policyId}"]`) + expect(source, file).toContain("accept={") + expect(source, file).not.toContain('accept="image/*"') + expect(source, file).not.toContain('file.type.startsWith("image/")') + } + }) + + it("directs SVG users from raster upload copy to the SVG-to-PNG workflow", () => { + for (const locale of ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"]) { + const translations = JSON.parse(read(`src/core/i18n/translations/${locale}.json`)) as { + tools: Record> + } + const rasterUploadCopy = [ + translations.tools.image_base64.supports, + translations.tools.image_base64.invalid_file_desc, + translations.tools.qr_code_generator.logo_hint, + ] + + for (const copy of rasterUploadCopy) { + expect(copy, locale).toContain("SVG") + expect(copy.lastIndexOf("PNG"), locale).toBeGreaterThan(copy.lastIndexOf("SVG")) + } + } + }) + it("keeps representative image tools on shared upload policy and status UI", () => { const files = [ "src/features/tools/image-cropper/page.tsx", diff --git a/tests/unit/file-input-policy.test.ts b/tests/unit/file-input-policy.test.ts index 1b69d474..70e5823d 100644 --- a/tests/unit/file-input-policy.test.ts +++ b/tests/unit/file-input-policy.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from "vitest" import { FILE_INPUT_POLICIES, + detectRasterImageMime, filterFilesByPolicy, formatFilePolicyLimit, formatPixelLimit, readArrayBufferWithPolicy, readTextFileWithPolicy, validateFileAgainstPolicy, + validateFileContentAgainstPolicy, } from "@/core/files/file-input-policy" +const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + describe("file-input-policy", () => { it("declares limits for each supported file input class", () => { expect(FILE_INPUT_POLICIES.text.maxBytes).toBe(2 * 1024 * 1024) @@ -29,9 +33,15 @@ describe("file-input-policy", () => { expect(validateFileAgainstPolicy(new File(["{}"], "payload.exe", { type: "application/octet-stream" }), textPolicy)).toMatchObject({ ok: false, reason: "unsupported_type" }) }) - it("accepts safe extension fallbacks when browsers omit MIME type", () => { + it("accepts extension-matched files when browsers omit or generalize MIME types", async () => { expect(validateFileAgainstPolicy(new File(["{}"], "payload.json", { type: "" }), FILE_INPUT_POLICIES.text)).toMatchObject({ ok: true }) expect(validateFileAgainstPolicy(new File([""], "icon.svg", { type: "" }), FILE_INPUT_POLICIES.svg)).toMatchObject({ ok: true }) + expect(validateFileAgainstPolicy(new File(["a,b"], "data.csv", { type: "application/vnd.ms-excel" }), FILE_INPUT_POLICIES["csv-json"])).toMatchObject({ ok: true }) + expect(validateFileAgainstPolicy(new File(["{}"], "payload.json", { type: "application/octet-stream" }), FILE_INPUT_POLICIES.text)).toMatchObject({ ok: true }) + + const genericPng = new File([PNG_SIGNATURE], "safe.png", { type: "application/octet-stream" }) + expect(validateFileAgainstPolicy(genericPng, FILE_INPUT_POLICIES["image-standard"])).toMatchObject({ ok: true }) + await expect(validateFileContentAgainstPolicy(genericPng, FILE_INPUT_POLICIES["image-standard"])).resolves.toMatchObject({ ok: true }) }) it("keeps raw SVG out of generic raster image policies", () => { @@ -48,11 +58,40 @@ describe("file-input-policy", () => { ok: false, reason: "unsupported_type", }) + expect(validateFileAgainstPolicy(new File([""], "unsafe.png", { type: "image/svg+xml" }), policy)).toMatchObject({ + ok: false, + reason: "unsupported_type", + }) } expect(validateFileAgainstPolicy(new File([""], "safe.svg", { type: "image/svg+xml" }), FILE_INPUT_POLICIES.svg)).toMatchObject({ ok: true }) }) + it("detects supported raster signatures and rejects spoofed raster content", async () => { + expect(detectRasterImageMime(PNG_SIGNATURE)).toBe("image/png") + expect(detectRasterImageMime(new TextEncoder().encode(""))).toBeNull() + + const extensionFallback = new File([PNG_SIGNATURE], "safe.png", { type: "" }) + await expect(validateFileContentAgainstPolicy(extensionFallback, FILE_INPUT_POLICIES["image-standard"])).resolves.toMatchObject({ ok: true }) + + const svgBytes = new TextEncoder().encode("\ufeff ") + const spoofedRaster = new File([svgBytes], "unsafe.png", { type: "image/png" }) + await expect(validateFileContentAgainstPolicy(spoofedRaster, FILE_INPUT_POLICIES["image-standard"])).resolves.toMatchObject({ + ok: false, + reason: "unsupported_type", + }) + await expect(readArrayBufferWithPolicy(spoofedRaster, FILE_INPUT_POLICIES["image-standard"])).rejects.toThrow(/does not match/) + }) + + it("requires raster extensions, declared MIME types, and signatures to agree", async () => { + const mismatchedExtension = new File([PNG_SIGNATURE], "unsafe.jpg", { type: "image/png" }) + await expect(validateFileContentAgainstPolicy(mismatchedExtension, FILE_INPUT_POLICIES["image-standard"])).resolves.toMatchObject({ ok: false }) + + const svgMimeWithRasterExtension = new File([PNG_SIGNATURE], "unsafe.png", { type: "image/svg+xml" }) + expect(validateFileAgainstPolicy(svgMimeWithRasterExtension, FILE_INPUT_POLICIES["scan-image"])).toMatchObject({ ok: false }) + expect(FILE_INPUT_POLICIES["scan-image"].accept).not.toContain("image/*") + }) + it("reads only files that pass policy validation", async () => { await expect(readTextFileWithPolicy(new File(["alpha"], "sample.txt", { type: "text/plain" }))).resolves.toBe("alpha") await expect(readArrayBufferWithPolicy(new File([new Uint8Array([1, 2])], "sample.bin"), FILE_INPUT_POLICIES["hash-file"])).resolves.toBeInstanceOf(ArrayBuffer) From 4f286ac77684c8cf3ed7921a6e798340340a6c5a Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Wed, 15 Jul 2026 08:44:18 -0600 Subject: [PATCH 03/15] Validate TOTP secrets and counters (#331) --- src/core/i18n/translations/de.json | 16 +- src/core/i18n/translations/en.json | 16 +- src/core/i18n/translations/fr.json | 16 +- src/core/i18n/translations/ja.json | 16 +- src/core/i18n/translations/ko.json | 16 +- src/core/i18n/translations/zh-CN.json | 16 +- src/core/i18n/translations/zh-TW.json | 16 +- .../tools/totp-generator/browser-actions.ts | 4 + src/features/tools/totp-generator/logic.ts | 190 +++++++++++ src/features/tools/totp-generator/page.tsx | 314 +++++++++++------- tests/component/totp-generator-page.test.tsx | 111 +++++++ tests/unit/totp-generator-logic.test.ts | 86 +++++ 12 files changed, 683 insertions(+), 134 deletions(-) create mode 100644 src/features/tools/totp-generator/browser-actions.ts create mode 100644 src/features/tools/totp-generator/logic.ts create mode 100644 tests/component/totp-generator-page.test.tsx create mode 100644 tests/unit/totp-generator-logic.test.ts diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index f62175d1..363416fb 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -1684,13 +1684,25 @@ "refreshes_in": "Aktualisierung in {seconds} Sekunden", "mode": "Modus", "digits": "Stellen", - "secret_key": "Geheimschlüssel (Base32)", + "secret_key": "Geheimschlüssel (Base32 ohne Padding oder Trennzeichen)", "period": "Periode (Sekunden)", "counter": "Zähler", "refresh": "Aktualisieren", "generate_random": "Zufällig generieren", "settings": "Einstellungen", - "increment": "Erhoehen" + "increment": "Erhoehen", + "error_secret_required": "Geben Sie einen Base32-Geheimschlüssel ein.", + "error_secret_invalid_characters": "Verwenden Sie nur ASCII A-Z und 2-7. Leerzeichen, Trennzeichen, Satzzeichen und Unicode sind nicht erlaubt.", + "error_secret_padding_unsupported": "Base32-Padding wird nicht unterstützt. Entfernen Sie abschließende =-Zeichen.", + "error_secret_invalid_length": "Der Base32-Geheimschlüssel ohne Padding hat eine ungültige Länge.", + "error_secret_invalid_padding_bits": "Der Base32-Geheimschlüssel enthält nicht-null gesetzte ungenutzte Bits und ist nicht kanonisch.", + "error_period_required": "Geben Sie eine TOTP-Periode ein.", + "error_period_invalid_integer": "Die TOTP-Periode muss eine ganze Zahl sein.", + "error_period_out_of_range": "Die TOTP-Periode muss zwischen 15 und 120 Sekunden liegen.", + "error_counter_required": "Geben Sie einen HOTP-Zähler ein.", + "error_counter_invalid_integer": "Der HOTP-Zähler muss eine ganze Zahl sein.", + "error_counter_out_of_range": "Der HOTP-Zähler muss zwischen 0 und 9007199254740991 liegen.", + "error_generation_failed": "Das Einmalpasswort konnte in diesem Browser nicht erzeugt werden." }, "openapi_mock": { "title": "OpenAPI-Mock-Spielplatz", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index bb0005a5..bb2aefba 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -1736,13 +1736,25 @@ "refreshes_in": "Refreshes in {seconds} seconds", "mode": "Mode", "digits": "Digits", - "secret_key": "Secret Key (Base32)", + "secret_key": "Secret Key (unpadded Base32, no separators)", "period": "Period (seconds)", "counter": "Counter", "refresh": "Refresh", "generate_random": "Generate Random", "settings": "Settings", - "increment": "Increment" + "increment": "Increment", + "error_secret_required": "Enter a Base32 secret.", + "error_secret_invalid_characters": "Use only ASCII letters A-Z and digits 2-7. Spaces, separators, punctuation, and Unicode are not accepted.", + "error_secret_padding_unsupported": "Padded Base32 is not supported. Remove trailing = characters.", + "error_secret_invalid_length": "The unpadded Base32 secret has an invalid length.", + "error_secret_invalid_padding_bits": "The Base32 secret has non-zero unused bits and is not canonical.", + "error_period_required": "Enter a TOTP period.", + "error_period_invalid_integer": "The TOTP period must be a whole number.", + "error_period_out_of_range": "The TOTP period must be between 15 and 120 seconds.", + "error_counter_required": "Enter a HOTP counter.", + "error_counter_invalid_integer": "The HOTP counter must be a whole number.", + "error_counter_out_of_range": "The HOTP counter must be between 0 and 9007199254740991.", + "error_generation_failed": "The one-time password could not be generated in this browser." }, "openapi_mock": { "title": "OpenAPI Mock Playground", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 3bf39c69..109ada1e 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -1684,13 +1684,25 @@ "refreshes_in": "Actualisation dans {seconds} secondes", "mode": "mode", "digits": "Chiffres", - "secret_key": "Clé secrète (Base32)", + "secret_key": "Clé secrète (Base32 sans remplissage ni séparateur)", "period": "Période (secondes)", "counter": "Compteur", "refresh": "Actualiser", "generate_random": "Générer aléatoirement", "settings": "Parametres", - "increment": "Incrementer" + "increment": "Incrementer", + "error_secret_required": "Saisissez une clé secrète Base32.", + "error_secret_invalid_characters": "Utilisez uniquement les caractères ASCII A-Z et 2-7. Les espaces, séparateurs, signes de ponctuation et caractères Unicode sont refusés.", + "error_secret_padding_unsupported": "Le remplissage Base32 n'est pas pris en charge. Supprimez les caractères = finaux.", + "error_secret_invalid_length": "La longueur de la clé Base32 sans remplissage est invalide.", + "error_secret_invalid_padding_bits": "La clé Base32 contient des bits inutilisés non nuls et n'est pas canonique.", + "error_period_required": "Saisissez une période TOTP.", + "error_period_invalid_integer": "La période TOTP doit être un nombre entier.", + "error_period_out_of_range": "La période TOTP doit être comprise entre 15 et 120 secondes.", + "error_counter_required": "Saisissez un compteur HOTP.", + "error_counter_invalid_integer": "Le compteur HOTP doit être un nombre entier.", + "error_counter_out_of_range": "Le compteur HOTP doit être compris entre 0 et 9007199254740991.", + "error_generation_failed": "Le mot de passe à usage unique n'a pas pu être généré dans ce navigateur." }, "openapi_mock": { "title": "Playground OpenAPI Mock", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 179ed420..c60cd207 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -1684,13 +1684,25 @@ "refreshes_in": "{seconds} 秒後に更新", "mode": "モード", "digits": "桁数", - "secret_key": "シークレットキー (Base32)", + "secret_key": "シークレットキー(パディングなし Base32、区切りなし)", "period": "周期(秒)", "counter": "カウンター", "refresh": "更新", "generate_random": "ランダム生成", "settings": "設定", - "increment": "増加" + "increment": "増加", + "error_secret_required": "Base32 シークレットを入力してください。", + "error_secret_invalid_characters": "ASCII の A-Z と 2-7 のみ使用できます。空白、区切り、記号、Unicode は使用できません。", + "error_secret_padding_unsupported": "パディング付き Base32 は未対応です。末尾の = を削除してください。", + "error_secret_invalid_length": "パディングなし Base32 シークレットの長さが無効です。", + "error_secret_invalid_padding_bits": "Base32 シークレットの未使用ビットがゼロではなく、正規のエンコードではありません。", + "error_period_required": "TOTP の周期を入力してください。", + "error_period_invalid_integer": "TOTP の周期は整数で指定してください。", + "error_period_out_of_range": "TOTP の周期は 15~120 秒で指定してください。", + "error_counter_required": "HOTP カウンターを入力してください。", + "error_counter_invalid_integer": "HOTP カウンターは整数で指定してください。", + "error_counter_out_of_range": "HOTP カウンターは 0~9007199254740991 で指定してください。", + "error_generation_failed": "このブラウザーではワンタイムパスワードを生成できませんでした。" }, "openapi_mock": { "title": "OpenAPIモックプレイグラウンド", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 4ebe5a23..0af0ce13 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -1684,13 +1684,25 @@ "refreshes_in": "{seconds}초 후 갱신", "mode": "모드", "digits": "자릿수", - "secret_key": "비밀 키 (Base32)", + "secret_key": "비밀 키(패딩 없는 Base32, 구분자 없음)", "period": "주기(초)", "counter": "카운터", "refresh": "새로고침", "generate_random": "랜덤 생성", "settings": "설정", - "increment": "증가" + "increment": "증가", + "error_secret_required": "Base32 비밀 키를 입력하세요.", + "error_secret_invalid_characters": "ASCII A-Z와 2-7만 사용할 수 있습니다. 공백, 구분자, 문장 부호 및 Unicode는 허용되지 않습니다.", + "error_secret_padding_unsupported": "패딩된 Base32는 지원하지 않습니다. 끝의 = 문자를 제거하세요.", + "error_secret_invalid_length": "패딩 없는 Base32 비밀 키의 길이가 잘못되었습니다.", + "error_secret_invalid_padding_bits": "Base32 비밀 키의 사용하지 않는 비트가 0이 아니므로 정규 인코딩이 아닙니다.", + "error_period_required": "TOTP 주기를 입력하세요.", + "error_period_invalid_integer": "TOTP 주기는 정수여야 합니다.", + "error_period_out_of_range": "TOTP 주기는 15초에서 120초 사이여야 합니다.", + "error_counter_required": "HOTP 카운터를 입력하세요.", + "error_counter_invalid_integer": "HOTP 카운터는 정수여야 합니다.", + "error_counter_out_of_range": "HOTP 카운터는 0에서 9007199254740991 사이여야 합니다.", + "error_generation_failed": "이 브라우저에서 일회용 비밀번호를 생성할 수 없습니다." }, "openapi_mock": { "title": "OpenAPI Mock 플레이그라운드", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 8eec08df..9ec19260 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -1684,13 +1684,25 @@ "refreshes_in": "{seconds} 秒后刷新", "mode": "模式", "digits": "位数", - "secret_key": "密钥 (Base32)", + "secret_key": "密钥(无填充 Base32,不允许分隔符)", "period": "周期(秒)", "counter": "计数器", "refresh": "刷新", "generate_random": "生成随机密钥", "settings": "设置", - "increment": "递增" + "increment": "递增", + "error_secret_required": "请输入 Base32 密钥。", + "error_secret_invalid_characters": "只能使用 ASCII 字母 A-Z 和数字 2-7,不接受空格、分隔符、标点或 Unicode 字符。", + "error_secret_padding_unsupported": "不支持带填充的 Base32,请删除末尾的 = 字符。", + "error_secret_invalid_length": "无填充 Base32 密钥的长度无效。", + "error_secret_invalid_padding_bits": "Base32 密钥包含非零的未使用位,不是规范编码。", + "error_period_required": "请输入 TOTP 周期。", + "error_period_invalid_integer": "TOTP 周期必须是整数。", + "error_period_out_of_range": "TOTP 周期必须在 15 到 120 秒之间。", + "error_counter_required": "请输入 HOTP 计数器。", + "error_counter_invalid_integer": "HOTP 计数器必须是整数。", + "error_counter_out_of_range": "HOTP 计数器必须在 0 到 9007199254740991 之间。", + "error_generation_failed": "此浏览器无法生成一次性密码。" }, "openapi_mock": { "title": "OpenAPI Mock 测试台", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 0e7dc66b..1eed093f 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -1684,13 +1684,25 @@ "refreshes_in": "{seconds} 秒後重新整理", "mode": "模式", "digits": "位數", - "secret_key": "密鑰 (Base32)", + "secret_key": "密鑰(無填充 Base32,不允許分隔符)", "period": "週期(秒)", "counter": "計數器", "refresh": "重新整理", "generate_random": "產生隨機密鑰", "settings": "設定", - "increment": "遞增" + "increment": "遞增", + "error_secret_required": "請輸入 Base32 密鑰。", + "error_secret_invalid_characters": "只能使用 ASCII 字母 A-Z 和數字 2-7,不接受空格、分隔符、標點或 Unicode 字元。", + "error_secret_padding_unsupported": "不支援帶填充的 Base32,請移除結尾的 = 字元。", + "error_secret_invalid_length": "無填充 Base32 密鑰的長度無效。", + "error_secret_invalid_padding_bits": "Base32 密鑰包含非零的未使用位元,不是規範編碼。", + "error_period_required": "請輸入 TOTP 週期。", + "error_period_invalid_integer": "TOTP 週期必須是整數。", + "error_period_out_of_range": "TOTP 週期必須介於 15 到 120 秒。", + "error_counter_required": "請輸入 HOTP 計數器。", + "error_counter_invalid_integer": "HOTP 計數器必須是整數。", + "error_counter_out_of_range": "HOTP 計數器必須介於 0 到 9007199254740991。", + "error_generation_failed": "此瀏覽器無法產生一次性密碼。" }, "openapi_mock": { "title": "OpenAPI Mock 測試台", diff --git a/src/features/tools/totp-generator/browser-actions.ts b/src/features/tools/totp-generator/browser-actions.ts new file mode 100644 index 00000000..0d13b300 --- /dev/null +++ b/src/features/tools/totp-generator/browser-actions.ts @@ -0,0 +1,4 @@ +export function startTotpTicker(onTick: () => void): () => void { + const intervalId = window.setInterval(onTick, 1_000) + return () => window.clearInterval(intervalId) +} diff --git a/src/features/tools/totp-generator/logic.ts b/src/features/tools/totp-generator/logic.ts new file mode 100644 index 00000000..dd6f8716 --- /dev/null +++ b/src/features/tools/totp-generator/logic.ts @@ -0,0 +1,190 @@ +export const MIN_TOTP_PERIOD = 15 +export const MAX_TOTP_PERIOD = 120 +export const MAX_HOTP_COUNTER = Number.MAX_SAFE_INTEGER + +export type OtpValidationErrorCode = + | "secret_required" + | "secret_invalid_characters" + | "secret_padding_unsupported" + | "secret_invalid_length" + | "secret_invalid_padding_bits" + | "period_required" + | "period_invalid_integer" + | "period_out_of_range" + | "counter_required" + | "counter_invalid_integer" + | "counter_out_of_range" + | "digits_invalid" + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errorCode: OtpValidationErrorCode } + +export class OtpValidationError extends Error { + code: OtpValidationErrorCode + + constructor(code: OtpValidationErrorCode) { + super(code) + this.name = "OtpValidationError" + this.code = code + } +} + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +const VALID_UNPADDED_LENGTH_REMAINDERS = new Set([0, 2, 4, 5, 7]) +const UNUSED_BITS_BY_LENGTH_REMAINDER: Record = { + 2: 2, + 4: 4, + 5: 1, + 7: 3, +} + +function invalid(errorCode: OtpValidationErrorCode): ValidationResult { + return { ok: false, errorCode } +} + +export function decodeBase32Strict(input: string): ValidationResult { + if (input.length === 0) return invalid("secret_required") + if (input.includes("=")) return invalid("secret_padding_unsupported") + if (!/^[A-Za-z2-7]+$/.test(input)) return invalid("secret_invalid_characters") + + const normalized = input.toUpperCase() + const lengthRemainder = normalized.length % 8 + if (!VALID_UNPADDED_LENGTH_REMAINDERS.has(lengthRemainder)) return invalid("secret_invalid_length") + + const unusedBits = UNUSED_BITS_BY_LENGTH_REMAINDER[lengthRemainder] ?? 0 + const lastValue = BASE32_ALPHABET.indexOf(normalized.at(-1) ?? "") + if (unusedBits > 0 && (lastValue & ((1 << unusedBits) - 1)) !== 0) { + return invalid("secret_invalid_padding_bits") + } + + const bytes: number[] = [] + let bitBuffer = 0 + let bitCount = 0 + for (const character of normalized) { + bitBuffer = (bitBuffer << 5) | BASE32_ALPHABET.indexOf(character) + bitCount += 5 + if (bitCount >= 8) { + bitCount -= 8 + bytes.push((bitBuffer >> bitCount) & 0xff) + bitBuffer &= (1 << bitCount) - 1 + } + } + + if (bytes.length === 0) return invalid("secret_required") + return { ok: true, value: new Uint8Array(bytes) } +} + +function parseUnsignedIntegerInput( + input: string, + requiredCode: OtpValidationErrorCode, + integerCode: OtpValidationErrorCode, +): ValidationResult { + if (input.length === 0) return invalid(requiredCode) + if (!/^\d+$/.test(input)) return invalid(integerCode) + const value = Number(input) + if (!Number.isSafeInteger(value)) return invalid(integerCode) + return { ok: true, value } +} + +export function parseTotpPeriod(input: string): ValidationResult { + const parsed = parseUnsignedIntegerInput(input, "period_required", "period_invalid_integer") + if (!parsed.ok) return parsed + if (parsed.value < MIN_TOTP_PERIOD || parsed.value > MAX_TOTP_PERIOD) return invalid("period_out_of_range") + return parsed +} + +export function parseHotpCounter(input: string): ValidationResult { + const parsed = parseUnsignedIntegerInput(input, "counter_required", "counter_invalid_integer") + if (!parsed.ok) return parsed + if (parsed.value < 0 || parsed.value > MAX_HOTP_COUNTER) return invalid("counter_out_of_range") + return parsed +} + +function requireValidDigits(digits: number): void { + if (digits !== 6 && digits !== 8) throw new OtpValidationError("digits_invalid") +} + +function requireValidSecret(secret: string): Uint8Array { + const result = decodeBase32Strict(secret) + if (!result.ok) throw new OtpValidationError(result.errorCode) + return result.value +} + +function requireValidCounter(counter: number): void { + if (!Number.isSafeInteger(counter) || counter < 0 || counter > MAX_HOTP_COUNTER) { + throw new OtpValidationError("counter_out_of_range") + } +} + +function encodeCounter(counter: number): Uint8Array { + requireValidCounter(counter) + const bytes = new Uint8Array(8) + let remaining = counter + for (let index = bytes.length - 1; index >= 0; index -= 1) { + bytes[index] = remaining % 256 + remaining = Math.floor(remaining / 256) + } + return bytes +} + +function copyForWebCrypto(bytes: Uint8Array): Uint8Array { + // In jsdom, this uses the same typed-array realm as TextEncoder and SubtleCrypto. + const ByteArray = new TextEncoder().encode("").constructor as Uint8ArrayConstructor + const copy = new ByteArray(bytes.length) + copy.set(bytes) + return copy as Uint8Array +} + +async function hmacSha1(key: Uint8Array, message: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + copyForWebCrypto(key), + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"], + ) + const signature = await crypto.subtle.sign("HMAC", cryptoKey, copyForWebCrypto(message)) + return new Uint8Array(signature) +} + +export async function generateHOTP(secret: string, counter: number, digits = 6): Promise { + requireValidDigits(digits) + const key = requireValidSecret(secret) + const hash = await hmacSha1(key, encodeCounter(counter)) + const offset = hash[hash.length - 1] & 0x0f + const binary = (hash[offset] & 0x7f) * 0x1000000 + + hash[offset + 1] * 0x10000 + + hash[offset + 2] * 0x100 + + hash[offset + 3] + return (binary % (10 ** digits)).toString().padStart(digits, "0") +} + +export async function generateTOTP(secret: string, timeSeconds: number, digits = 6, period = 30): Promise { + if (!Number.isSafeInteger(period)) throw new OtpValidationError("period_invalid_integer") + if (period < MIN_TOTP_PERIOD || period > MAX_TOTP_PERIOD) { + throw new OtpValidationError("period_out_of_range") + } + if (!Number.isFinite(timeSeconds) || timeSeconds < 0) throw new OtpValidationError("counter_out_of_range") + const counter = Math.floor(timeSeconds / period) + return generateHOTP(secret, counter, digits) +} + +export function generateRandomSecret(byteLength = 20): string { + if (!Number.isSafeInteger(byteLength) || byteLength < 1) throw new RangeError("byteLength must be a positive integer") + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)) + let encoded = "" + let bitBuffer = 0 + let bitCount = 0 + for (const byte of bytes) { + bitBuffer = (bitBuffer << 8) | byte + bitCount += 8 + while (bitCount >= 5) { + bitCount -= 5 + encoded += BASE32_ALPHABET[(bitBuffer >> bitCount) & 0x1f] + } + bitBuffer &= (1 << bitCount) - 1 + } + if (bitCount > 0) encoded += BASE32_ALPHABET[(bitBuffer << (5 - bitCount)) & 0x1f] + return encoded +} diff --git a/src/features/tools/totp-generator/page.tsx b/src/features/tools/totp-generator/page.tsx index 5cdf3b54..112e5be7 100644 --- a/src/features/tools/totp-generator/page.tsx +++ b/src/features/tools/totp-generator/page.tsx @@ -1,121 +1,98 @@ "use client" import * as React from "react" -import { KeyRound, Copy, RefreshCw, Timer } from "lucide-react" +import { Copy, KeyRound, RefreshCw, Timer } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { useLang } from "@/core/i18n/lang-provider" import { RelatedTools } from "@/core/seo/components/related-tools" import { SensitiveInputWarning } from "@/features/tool-shell/sensitive-input-warning" -import { safeClipboardWrite } from "@/core/clipboard/clipboard" - -// ─── TOTP Engine (RFC 6238) ───────────────────────────────────────────────── - -function base32Decode(str: string): Uint8Array { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - const clean = str.toUpperCase().replace(/[^A-Z2-7]/g, "") - let bits = "" - for (const c of clean) { - const val = alphabet.indexOf(c) - if (val === -1) continue - bits += val.toString(2).padStart(5, "0") - } - const bytes = new Uint8Array(Math.floor(bits.length / 8)) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2) - } - return bytes -} - -async function hmacSha1(key: Uint8Array, message: Uint8Array): Promise { - const cryptoKey = await crypto.subtle.importKey("raw", key.buffer as ArrayBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]) - const sig = await crypto.subtle.sign("HMAC", cryptoKey, message.buffer as ArrayBuffer) - return new Uint8Array(sig) -} - -async function generateTOTP(secret: string, time: number, digits: number = 6, period: number = 30): Promise { - const key = base32Decode(secret) - const counter = Math.floor(time / period) - const counterBytes = new Uint8Array(8) - let c = counter - for (let i = 7; i >= 0; i--) { - counterBytes[i] = c & 0xff - c = Math.floor(c / 256) - } - - const hash = await hmacSha1(key, counterBytes) - const offset = hash[hash.length - 1] & 0x0f - const binary = ((hash[offset] & 0x7f) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3] - const otp = binary % Math.pow(10, digits) - return otp.toString().padStart(digits, "0") -} - -async function generateHOTP(secret: string, counter: number, digits: number = 6): Promise { - const key = base32Decode(secret) - const counterBytes = new Uint8Array(8) - let c = counter - for (let i = 7; i >= 0; i--) { - counterBytes[i] = c & 0xff - c = Math.floor(c / 256) - } - - const hash = await hmacSha1(key, counterBytes) - const offset = hash[hash.length - 1] & 0x0f - const binary = ((hash[offset] & 0x7f) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3] - const otp = binary % Math.pow(10, digits) - return otp.toString().padStart(digits, "0") -} - -function generateRandomSecret(): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - let secret = "" - const bytes = crypto.getRandomValues(new Uint8Array(20)) - for (const b of bytes) secret += chars[b % 32] - return secret -} +import { + MAX_HOTP_COUNTER, + MAX_TOTP_PERIOD, + MIN_TOTP_PERIOD, + decodeBase32Strict, + generateHOTP, + generateRandomSecret, + generateTOTP, + parseHotpCounter, + parseTotpPeriod, +} from "./logic" +import { startTotpTicker } from "./browser-actions" +import { ToolPageContainer } from "@/components/layout/page-container" export function TotpGeneratorPage() { const { t } = useLang() + const toolT = t.tools["totp_generator"] as Record const [secret, setSecret] = React.useState("JBSWY3DPEHPK3PXP") const [mode, setMode] = React.useState<"totp" | "hotp">("totp") const [digits, setDigits] = React.useState(6) - const [period, setPeriod] = React.useState(30) - const [counter, setCounter] = React.useState(0) + const [periodInput, setPeriodInput] = React.useState("30") + const [counterInput, setCounterInput] = React.useState("0") const [otp, setOtp] = React.useState("") const [remaining, setRemaining] = React.useState(30) + const [runtimeError, setRuntimeError] = React.useState(null) + const generationRequestRef = React.useRef(0) + + const secretValidation = React.useMemo(() => decodeBase32Strict(secret), [secret]) + const periodValidation = React.useMemo(() => parseTotpPeriod(periodInput), [periodInput]) + const counterValidation = React.useMemo(() => parseHotpCounter(counterInput), [counterInput]) + const secretError = secretValidation.ok ? null : toolT[`error_${secretValidation.errorCode}`] + const periodError = periodValidation.ok ? null : toolT[`error_${periodValidation.errorCode}`] + const counterError = counterValidation.ok ? null : toolT[`error_${counterValidation.errorCode}`] + const activeValidationError = secretError || (mode === "totp" ? periodError : counterError) + const [refreshPrefix, refreshSuffix = ""] = toolT.refreshes_in.split("{seconds}") const generate = React.useCallback(async () => { - if (!secret.trim()) { setOtp(""); return } + const requestId = ++generationRequestRef.current + const hasNumericError = mode === "totp" ? !periodValidation.ok : !counterValidation.ok + if (!secretValidation.ok || hasNumericError) { + setOtp("") + setRuntimeError(null) + if (mode === "totp") setRemaining(periodValidation.ok ? periodValidation.value : 0) + return + } + try { + let code: string if (mode === "totp") { + if (!periodValidation.ok) return const now = Math.floor(Date.now() / 1000) - const code = await generateTOTP(secret, now, digits, period) - setOtp(code) - setRemaining(period - (now % period)) + code = await generateTOTP(secret, now, digits, periodValidation.value) + if (requestId !== generationRequestRef.current) return + setRemaining(periodValidation.value - (now % periodValidation.value)) } else { - const code = await generateHOTP(secret, counter, digits) - setOtp(code) + if (!counterValidation.ok) return + code = await generateHOTP(secret, counterValidation.value, digits) } - } catch { setOtp("ERROR") } - }, [secret, mode, digits, period, counter]) + if (requestId !== generationRequestRef.current) return + setOtp(code) + setRuntimeError(null) + } catch { + if (requestId !== generationRequestRef.current) return + setOtp("") + setRuntimeError(toolT.error_generation_failed) + } + }, [counterValidation, digits, mode, periodValidation, secret, secretValidation, toolT.error_generation_failed]) - React.useEffect(() => { generate() }, [generate]) + React.useEffect(() => { + void generate() + }, [generate]) - // Auto-refresh TOTP React.useEffect(() => { - if (mode !== "totp") return - const interval = setInterval(() => { + if (mode !== "totp" || !periodValidation.ok || !secretValidation.ok) return + return startTotpTicker(() => { const now = Math.floor(Date.now() / 1000) - const rem = period - (now % period) - setRemaining(rem) - if (rem === period) generate() - }, 1000) - return () => clearInterval(interval) - }, [mode, period, generate]) + const nextRemaining = periodValidation.value - (now % periodValidation.value) + setRemaining(nextRemaining) + if (nextRemaining === periodValidation.value) void generate() + }) + }, [generate, mode, periodValidation, secretValidation]) const handleCopy = async () => { - if (!otp || otp === "ERROR") return + if (!otp || activeValidationError || runtimeError) return const result = await safeClipboardWrite(otp) if (!result.ok) { toast.error(t.common.copy_failed) @@ -124,56 +101,95 @@ export function TotpGeneratorPage() { toast.success(t.common.copied) } - const toolT = t.tools["totp_generator"] as Record - return ( -
+
-

+

{toolT.title}

-

{toolT.description}

+

{toolT.description}

- {/* OTP Display */} -
-
{otp || "------"}
- {mode === "totp" && ( +
+
+ {otp || "------"} +
+ {runtimeError &&

{runtimeError}

} + {mode === "totp" && periodValidation.ok && (
- {toolT.refreshes_in.replace("{seconds}", "")}{remaining}s -
-
+ + {refreshPrefix} + {remaining} + {refreshSuffix} + +
+
)}
- - + +
- {/* Settings */} -
-

{toolT.settings}

+
+

{toolT.settings}

-
+
- + {toolT.mode}
- - + +
- + {toolT.digits}
- {[6, 8].map((d) => ( - + {[6, 8].map((digitCount) => ( + ))}
@@ -181,24 +197,82 @@ export function TotpGeneratorPage() {
- - + +
- setSecret(e.target.value)} placeholder="JBSWY3DPEHPK3PXP" /> + { + generationRequestRef.current += 1 + setSecret(event.target.value) + }} + placeholder="JBSWY3DPEHPK3PXP" + aria-invalid={Boolean(secretError)} + aria-describedby={secretError ? "totp-secret-error" : undefined} + /> + {secretError && }
{mode === "totp" ? (
- - setPeriod(Number(e.target.value))} /> + + { + generationRequestRef.current += 1 + setPeriodInput(event.target.value) + }} + aria-invalid={Boolean(periodError)} + aria-describedby={periodError ? "totp-period-error" : undefined} + /> + {periodError && }
) : (
- - + +
- setCounter(Number(e.target.value))} /> + { + generationRequestRef.current += 1 + setCounterInput(event.target.value) + }} + aria-invalid={Boolean(counterError)} + aria-describedby={counterError ? "hotp-counter-error" : undefined} + /> + {counterError && }
)}
diff --git a/tests/component/totp-generator-page.test.tsx b/tests/component/totp-generator-page.test.tsx new file mode 100644 index 00000000..e0b3e57f --- /dev/null +++ b/tests/component/totp-generator-page.test.tsx @@ -0,0 +1,111 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { TotpGeneratorPage } from "@/features/tools/totp-generator/page" + +const mocks = vi.hoisted(() => ({ + clipboardWrite: vi.fn(), + generateHOTP: vi.fn(), + generateTOTP: vi.fn(), +})) + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => {children}, +})) + +vi.mock("@/core/seo/components/related-tools", () => ({ + RelatedTools: () => null, +})) + +vi.mock("@/core/clipboard/clipboard", () => ({ + safeClipboardWrite: (value: string) => mocks.clipboardWrite(value), +})) + +vi.mock("@/features/tools/totp-generator/logic", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateHOTP: (...args: Parameters) => mocks.generateHOTP(...args), + generateTOTP: (...args: Parameters) => mocks.generateTOTP(...args), + } +}) + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +function renderPage() { + return render( + + + , + ) +} + +describe("TotpGeneratorPage", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.clipboardWrite.mockResolvedValue({ ok: true }) + mocks.generateTOTP.mockResolvedValue("123456") + mocks.generateHOTP.mockResolvedValue("654321") + }) + + it("clears and disables generated output when the Base32 secret is invalid", async () => { + renderPage() + + expect(await screen.findByText("123456")).toBeInTheDocument() + const secretInput = screen.getByLabelText(/Secret Key/) as HTMLInputElement + fireEvent.change(secretInput, { target: { value: "JBSWY3DPEHPK3PXP0" } }) + + expect(await screen.findByText(/ASCII letters A-Z and digits 2-7/)).toHaveAttribute("role", "alert") + expect(secretInput).toHaveAttribute("aria-invalid", "true") + expect(screen.getByText("------")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Copy" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled() + expect(mocks.clipboardWrite).not.toHaveBeenCalled() + }) + + it("preserves blank and invalid numeric inputs instead of coercing them", async () => { + renderPage() + await screen.findByText("123456") + + const periodInput = screen.getByLabelText("Period (seconds)") as HTMLInputElement + fireEvent.change(periodInput, { target: { value: "" } }) + expect(periodInput.value).toBe("") + expect(await screen.findByText("Enter a TOTP period.")).toBeInTheDocument() + expect(screen.getByText("------")).toBeInTheDocument() + + fireEvent.change(periodInput, { target: { value: "30" } }) + await waitFor(() => expect(screen.getByText("123456")).toBeInTheDocument()) + + fireEvent.click(screen.getByRole("button", { name: "HOTP" })) + const counterInput = screen.getByLabelText("Counter") as HTMLInputElement + fireEvent.change(counterInput, { target: { value: "-1" } }) + expect(await screen.findByText(/HOTP counter must be a whole number/)).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Increment" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Copy" })).toBeDisabled() + }) + + it("does not let an earlier async result overwrite a newly invalid state", async () => { + let resolveGeneration: ((value: string) => void) | undefined + mocks.generateTOTP.mockImplementationOnce(() => new Promise((resolve) => { + resolveGeneration = resolve + })) + renderPage() + + fireEvent.change(screen.getByLabelText(/Secret Key/), { target: { value: "invalid-0" } }) + expect(await screen.findByText(/ASCII letters A-Z and digits 2-7/)).toBeInTheDocument() + + await act(async () => { + resolveGeneration?.("999999") + await Promise.resolve() + }) + + expect(screen.queryByText("999999")).not.toBeInTheDocument() + expect(screen.getByText("------")).toBeInTheDocument() + }) +}) diff --git a/tests/unit/totp-generator-logic.test.ts b/tests/unit/totp-generator-logic.test.ts new file mode 100644 index 00000000..658db971 --- /dev/null +++ b/tests/unit/totp-generator-logic.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest" +import { + MAX_HOTP_COUNTER, + OtpValidationError, + decodeBase32Strict, + generateHOTP, + generateRandomSecret, + generateTOTP, + parseHotpCounter, + parseTotpPeriod, +} from "@/features/tools/totp-generator/logic" + +const RFC_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +describe("strict Base32 decoding", () => { + it("decodes ASCII Base32 without silently changing the input", () => { + const decoded = decodeBase32Strict("jbswy3dpehpk3pxp") + expect(decoded).toMatchObject({ ok: true }) + if (decoded.ok) expect(Array.from(decoded.value)).toEqual([72, 101, 108, 108, 111, 33, 222, 173, 190, 239]) + }) + + it.each([ + ["", "secret_required"], + ["JBSWY3DPEHPK3PXP0", "secret_invalid_characters"], + ["JBSWY3DPEHPK3PXP1", "secret_invalid_characters"], + ["JBSWY3DP-EHPK3PXP", "secret_invalid_characters"], + ["JBSWY3DP EHPK3PXP", "secret_invalid_characters"], + ["JBSWY3DPEHPK3PXPß", "secret_invalid_characters"], + ["JBSWY3DPEHPK3PXPı", "secret_invalid_characters"], + ["MY======", "secret_padding_unsupported"], + ["A", "secret_invalid_length"], + ["MZ", "secret_invalid_padding_bits"], + ])("rejects %j with %s", (input, errorCode) => { + expect(decodeBase32Strict(input)).toEqual({ ok: false, errorCode }) + }) +}) + +describe("OTP numeric validation", () => { + it.each(["", "0", "14", "121", "30.5", "-30", "Infinity", " 30"])("rejects invalid TOTP period %j", (input) => { + expect(parseTotpPeriod(input).ok).toBe(false) + }) + + it.each(["15", "30", "120"])("accepts TOTP period %s", (input) => { + expect(parseTotpPeriod(input)).toEqual({ ok: true, value: Number(input) }) + }) + + it.each(["", "-1", "1.5", "NaN", "Infinity", `${MAX_HOTP_COUNTER + 1}`])("rejects invalid HOTP counter %j", (input) => { + expect(parseHotpCounter(input).ok).toBe(false) + }) + + it.each(["0", `${2 ** 32 - 1}`, `${2 ** 32}`, `${MAX_HOTP_COUNTER}`])("accepts HOTP counter %s", (input) => { + expect(parseHotpCounter(input)).toEqual({ ok: true, value: Number(input) }) + }) +}) + +describe("RFC-compatible OTP generation", () => { + it("passes the RFC 4226 HOTP SHA-1 vectors", async () => { + const expected = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"] + await expect(Promise.all(expected.map((_, counter) => generateHOTP(RFC_SECRET, counter)))).resolves.toEqual(expected) + }) + + it.each([ + [59, "94287082"], + [1_111_111_109, "07081804"], + [1_111_111_111, "14050471"], + [1_234_567_890, "89005924"], + [2_000_000_000, "69279037"], + [20_000_000_000, "65353130"], + ])("passes the RFC 6238 SHA-1 vector at %s", async (time, expected) => { + await expect(generateTOTP(RFC_SECRET, time, 8, 30)).resolves.toBe(expected) + }) + + it("encodes counters across the 32-bit boundary and rejects unsafe counters", async () => { + await expect(generateHOTP(RFC_SECRET, 2 ** 32)).resolves.toMatch(/^\d{6}$/) + await expect(generateHOTP(RFC_SECRET, MAX_HOTP_COUNTER)).resolves.toMatch(/^\d{6}$/) + await expect(generateHOTP(RFC_SECRET, MAX_HOTP_COUNTER + 1)).rejects.toMatchObject({ + code: "counter_out_of_range", + } satisfies Partial) + }) + + it("encodes random bytes as a canonical Base32 secret", () => { + const secret = generateRandomSecret() + expect(secret).toMatch(/^[A-Z2-7]{32}$/) + expect(decodeBase32Strict(secret)).toMatchObject({ ok: true }) + }) +}) From 06c9d0390aa93091218f38cc6d0a8ceb988cb041 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Wed, 15 Jul 2026 08:48:43 -0600 Subject: [PATCH 04/15] Generate direct tool route redirects (#327) --- public/_redirects | 144 +++++++++++++++++++ scripts/gates/check-legacy-routes.js | 14 +- scripts/generators/generate-legacy-routes.js | 36 ++++- src/app/[lang]/csv-to-json/page.tsx | 30 ++++ src/core/registry/tool-aliases.json | 3 +- src/core/routing/legacy-routes.json | 6 + src/generated/tool-index.json | 19 ++- tests/guards/legacy-routes.test.ts | 18 +++ 8 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 src/app/[lang]/csv-to-json/page.tsx diff --git a/public/_redirects b/public/_redirects index 127da197..1f7c6eef 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,5 +1,142 @@ /security.txt /.well-known/security.txt 301 /en / 301 +/json-formatter /en/json-formatter 301 +/xml-formatter /en/xml-formatter 301 +/sql-formatter /en/sql-formatter 301 +/javascript-formatter /en/javascript-formatter 301 +/javascript-minifier /en/javascript-minifier 301 +/html-minifier /en/html-minifier 301 +/html-encoder-decoder /en/html-encoder-decoder 301 +/html-css-beautifier /en/html-css-beautifier 301 +/html-formatter /en/html-formatter 301 +/yaml-json-converter /en/yaml-json-converter 301 +/markdown-preview /en/markdown-preview 301 +/html-to-markdown /en/html-to-markdown 301 +/json-to-typescript /en/json-to-typescript 301 +/css-minifier /en/css-minifier 301 +/svg-optimizer /en/svg-optimizer 301 +/jsonpath-playground /en/jsonpath-playground 301 +/openapi-viewer /en/openapi-viewer 301 +/json-diff-viewer /en/json-diff-viewer 301 +/csv-json-converter /en/csv-json-converter 301 +/base64-encode-decode /en/base64-encode-decode 301 +/base-encoding-converter /en/base-encoding-converter 301 +/url-encode-decode /en/url-encode-decode 301 +/jwt-decoder /en/jwt-decoder 301 +/jwt-workbench /en/jwt-workbench 301 +/hash-generator /en/hash-generator 301 +/md5-generator /en/md5-generator 301 +/text-diff-checker /en/text-diff-checker 301 +/multiple-whitespace-remover /en/multiple-whitespace-remover 301 +/letter-counter /en/letter-counter 301 +/bionic-reading-converter /en/bionic-reading-converter 301 +/google-fonts-pair-finder /en/google-fonts-pair-finder 301 +/text-to-handwriting-converter /en/text-to-handwriting-converter 301 +/code-to-image-converter /en/code-to-image-converter 301 +/image-base64 /en/image-base64 301 +/unix-timestamp /en/unix-timestamp 301 +/uuid-generator /en/uuid-generator 301 +/lorem-ipsum /en/lorem-ipsum 301 +/password-generator /en/password-generator 301 +/color-converter /en/color-converter 301 +/react-native-shadow-generator /en/react-native-shadow-generator 301 +/ai-color-palette-generator /en/ai-color-palette-generator 301 +/color-mixer /en/color-mixer 301 +/color-shades-generator /en/color-shades-generator 301 +/image-average-color-finder /en/image-average-color-finder 301 +/image-caption-generator /en/image-caption-generator 301 +/image-color-extractor /en/image-color-extractor 301 +/image-color-picker /en/image-color-picker 301 +/image-cropper /en/image-cropper 301 +/image-filters /en/image-filters 301 +/instagram-filters /en/instagram-filters 301 +/instagram-post-generator /en/instagram-post-generator 301 +/instagram-story-generator /en/instagram-story-generator 301 +/open-graph-meta-generator /en/open-graph-meta-generator 301 +/tweet-generator /en/tweet-generator 301 +/tweet-to-image-converter /en/tweet-to-image-converter 301 +/twitter-ad-revenue-generator /en/twitter-ad-revenue-generator 301 +/instagram-photo-downloader /en/instagram-photo-downloader 301 +/vimeo-thumbnail-grabber /en/vimeo-thumbnail-grabber 301 +/youtube-thumbnail-grabber /en/youtube-thumbnail-grabber 301 +/image-resizer /en/image-resizer 301 +/photo-censor /en/photo-censor 301 +/scanned-pdf-converter /en/scanned-pdf-converter 301 +/svg-blob-generator /en/svg-blob-generator 301 +/svg-pattern-generator /en/svg-pattern-generator 301 +/svg-stroke-to-fill-converter /en/svg-stroke-to-fill-converter 301 +/svg-to-png-converter /en/svg-to-png-converter 301 +/css-background-pattern-generator /en/css-background-pattern-generator 301 +/css-border-radius-generator /en/css-border-radius-generator 301 +/css-box-shadow-generator /en/css-box-shadow-generator 301 +/css-checkbox-generator /en/css-checkbox-generator 301 +/css-clip-path-generator /en/css-clip-path-generator 301 +/css-cubic-bezier-generator /en/css-cubic-bezier-generator 301 +/css-glassmorphism-generator /en/css-glassmorphism-generator 301 +/css-gradient-generator /en/css-gradient-generator 301 +/css-loader-generator /en/css-loader-generator 301 +/css-switch-generator /en/css-switch-generator 301 +/css-text-glitch-effect-generator /en/css-text-glitch-effect-generator 301 +/css-triangle-generator /en/css-triangle-generator 301 +/qr-code-generator /en/qr-code-generator 301 +/barcode-generator /en/barcode-generator 301 +/fake-iban-generator /en/fake-iban-generator 301 +/list-randomizer /en/list-randomizer 301 +/ascii-art-generator /en/ascii-art-generator 301 +/env-parser /en/env-parser 301 +/id-generator /en/id-generator 301 +/regex-tester /en/regex-tester 301 +/regex-generator /en/regex-generator 301 +/crontab-generator /en/crontab-generator 301 +/user-agent-parser /en/user-agent-parser 301 +/cron-visualizer /en/cron-visualizer 301 +/http-status-codes /en/http-status-codes 301 +/chmod-calculator /en/chmod-calculator 301 +/cidr-subnet-calculator /en/cidr-subnet-calculator 301 +/url-parser /en/url-parser 301 +/certificate-decoder /en/certificate-decoder 301 +/http-request-builder /en/http-request-builder 301 +/curl-to-code /en/curl-to-code 301 +/ndjson-formatter /en/ndjson-formatter 301 +/jwt-verifier /en/jwt-verifier 301 +/slugify-case-converter /en/slugify-case-converter 301 +/invisible-characters-detector /en/invisible-characters-detector 301 +/robots-txt-tester /en/robots-txt-tester 301 +/csp-parser /en/csp-parser 301 +/csv-diff /en/csv-diff 301 +/header-diff /en/header-diff 301 +/security-header-analyzer /en/security-header-analyzer 301 +/totp-generator /en/totp-generator 301 +/openapi-mock /en/openapi-mock 301 +/docker-run-to-compose /en/docker-run-to-compose 301 +/local-log-parser /en/local-log-parser 301 +/jq-playground /en/jq-playground 301 +/log-scrubber /en/log-scrubber 301 +/gzip-brotli-lab /en/gzip-brotli-lab 301 +/yaml-merge-patch-explorer /en/yaml-merge-patch-explorer 301 +/yq-playground /en/yq-playground 301 +/structured-data-visualizer /en/structured-data-visualizer 301 +/har-viewer-sanitizer /en/har-viewer-sanitizer 301 +/pipeline-builder /en/pipeline-builder 301 +/saml-decoder /en/saml-decoder 301 +/asn1-der-inspector /en/asn1-der-inspector 301 +/hex-bytes-workbench /en/hex-bytes-workbench 301 +/unicode-inspector /en/unicode-inspector 301 +/public-key-jwk-helper /en/public-key-jwk-helper 301 +/json-schema-workbench /en/json-schema-workbench 301 +/openapi-diff /en/openapi-diff 301 +/graphql-workbench /en/graphql-workbench 301 +/messagepack-inspector /en/messagepack-inspector 301 +/seo-metadata-workbench /en/seo-metadata-workbench 301 +/devops-yaml-validator /en/devops-yaml-validator 301 +/oauth-jwks-workbench /en/oauth-jwks-workbench 301 +/image-privacy-workbench /en/image-privacy-workbench 301 +/cron-expression-generator /en/crontab-generator 301 +/tls-chain-inspector /en/certificate-decoder 301 +/csv-to-json /en/csv-json-converter 301 +/web-api /en/web-api-network 301 +/design-media /en/images-svg-css 301 +/convert-encode /en/all-tools 301 /en/cron-expression-generator /en/crontab-generator 301 /zh-CN/cron-expression-generator /zh-CN/crontab-generator 301 /zh-TW/cron-expression-generator /zh-TW/crontab-generator 301 @@ -14,6 +151,13 @@ /ko/tls-chain-inspector /ko/certificate-decoder 301 /de/tls-chain-inspector /de/certificate-decoder 301 /fr/tls-chain-inspector /fr/certificate-decoder 301 +/en/csv-to-json /en/csv-json-converter 301 +/zh-CN/csv-to-json /zh-CN/csv-json-converter 301 +/zh-TW/csv-to-json /zh-TW/csv-json-converter 301 +/ja/csv-to-json /ja/csv-json-converter 301 +/ko/csv-to-json /ko/csv-json-converter 301 +/de/csv-to-json /de/csv-json-converter 301 +/fr/csv-to-json /fr/csv-json-converter 301 /en/web-api /en/web-api-network 301 /zh-CN/web-api /zh-CN/web-api-network 301 /zh-TW/web-api /zh-TW/web-api-network 301 diff --git a/scripts/gates/check-legacy-routes.js b/scripts/gates/check-legacy-routes.js index 80597cd3..f2928c37 100644 --- a/scripts/gates/check-legacy-routes.js +++ b/scripts/gates/check-legacy-routes.js @@ -24,8 +24,9 @@ function sitemapContainsSlugUrl(sitemap, slug) { } function main() { - const { routes, taxonomyRedirects } = checkGeneratedLegacyRoutes() + const { routes, taxonomyRedirects, canonicalToolSlugs: generatedCanonicalToolSlugs } = checkGeneratedLegacyRoutes() const canonicalToolSlugs = new Set(loadOrderedToolManifests().map((tool) => tool.slug)) + const redirects = fs.readFileSync(path.join(ROOT, "public/_redirects"), "utf8") const routeGroups = readJson(ROUTE_GROUPS_PATH) const sitemapRouteSlugs = new Set([ ...(routeGroups.hubSlugs || []), @@ -33,6 +34,15 @@ function main() { ]) const problems = [] + for (const slug of generatedCanonicalToolSlugs) { + if (!canonicalToolSlugs.has(slug)) { + problems.push(`${slug}: generated direct-entry redirect is not a canonical tool`) + } + if (!redirects.includes(`/${slug} /en/${slug} 301`)) { + problems.push(`${slug}: missing default-locale direct-entry redirect`) + } + } + for (const route of routes) { if (canonicalToolSlugs.has(route.sourceSlug)) { problems.push(`${route.sourceSlug}: source is still a canonical tool slug`) @@ -83,7 +93,7 @@ function main() { process.exit(1) } - console.log(`[check:legacy-routes] OK: ${routes.length} legacy route(s) and ${Object.keys(taxonomyRedirects).length} legacy taxonomy route(s) have valid targets, redirects, and sitemap exclusions`) + console.log(`[check:legacy-routes] OK: ${generatedCanonicalToolSlugs.length} direct tool route(s), ${routes.length} legacy route(s), and ${Object.keys(taxonomyRedirects).length} legacy taxonomy route(s) have valid targets, redirects, and sitemap exclusions`) } main() diff --git a/scripts/generators/generate-legacy-routes.js b/scripts/generators/generate-legacy-routes.js index b4ed0696..5671ed80 100644 --- a/scripts/generators/generate-legacy-routes.js +++ b/scripts/generators/generate-legacy-routes.js @@ -1,6 +1,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" +import { loadOrderedToolManifests } from "../lib/tool-manifest-lib.js" const ROOT = process.cwd() const LEGACY_ROUTES_PATH = path.join(ROOT, "src/core/routing/legacy-routes.json") @@ -8,6 +9,7 @@ const LEGACY_TAXONOMY_REDIRECTS_PATH = path.join(ROOT, "src/core/routing/legacy- const TOOL_ALIASES_PATH = path.join(ROOT, "src/core/registry/tool-aliases.json") const REDIRECTS_PATH = path.join(ROOT, "public/_redirects") const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"] +const DEFAULT_LOCALE = "en" const REDIRECT_STATUSES = new Set([301, 302]) const STATIC_REDIRECT_LINES = [ "/security.txt /.well-known/security.txt 301", @@ -92,9 +94,25 @@ export function loadLegacyTaxonomyRedirects() { return validated } -function buildRedirectLines(routes, taxonomyRedirects = loadLegacyTaxonomyRedirects()) { +function loadCanonicalToolSlugs() { + return loadOrderedToolManifests().map((tool) => tool.slug) +} + +function buildRedirectLines( + routes, + taxonomyRedirects = loadLegacyTaxonomyRedirects(), + canonicalToolSlugs = loadCanonicalToolSlugs(), +) { return [ ...STATIC_REDIRECT_LINES, + ...canonicalToolSlugs.map((slug) => `/${slug} /${DEFAULT_LOCALE}/${slug} 301`), + ...routes.flatMap((route) => { + if (!REDIRECT_STATUSES.has(route.status)) return [] + return [`/${route.sourceSlug} /${DEFAULT_LOCALE}/${route.targetSlug} ${route.status}`] + }), + ...Object.entries(taxonomyRedirects).map(([sourceSlug, targetSlug]) => ( + `/${sourceSlug} /${DEFAULT_LOCALE}/${targetSlug} 301` + )), ...routes.flatMap((route) => { if (!REDIRECT_STATUSES.has(route.status)) return [] return LOCALES.map((locale) => `/${locale}/${route.sourceSlug} /${locale}/${route.targetSlug} ${route.status}`) @@ -105,8 +123,12 @@ function buildRedirectLines(routes, taxonomyRedirects = loadLegacyTaxonomyRedire ] } -export function buildRedirectsSource(routes = loadLegacyRoutes(), taxonomyRedirects = loadLegacyTaxonomyRedirects()) { - const lines = buildRedirectLines(routes, taxonomyRedirects) +export function buildRedirectsSource( + routes = loadLegacyRoutes(), + taxonomyRedirects = loadLegacyTaxonomyRedirects(), + canonicalToolSlugs = loadCanonicalToolSlugs(), +) { + const lines = buildRedirectLines(routes, taxonomyRedirects, canonicalToolSlugs) return `${lines.join("\n")}\n` } @@ -114,8 +136,9 @@ export function buildRedirectsSource(routes = loadLegacyRoutes(), taxonomyRedire export function checkGeneratedLegacyRoutes() { const routes = loadLegacyRoutes() const taxonomyRedirects = loadLegacyTaxonomyRedirects() + const canonicalToolSlugs = loadCanonicalToolSlugs() const expectedAliases = buildToolAliasesSource(routes) - const expectedRedirects = buildRedirectsSource(routes, taxonomyRedirects) + const expectedRedirects = buildRedirectsSource(routes, taxonomyRedirects, canonicalToolSlugs) const problems = [] if (!fs.existsSync(TOOL_ALIASES_PATH) || readText(TOOL_ALIASES_PATH) !== expectedAliases) { @@ -131,14 +154,15 @@ export function checkGeneratedLegacyRoutes() { process.exit(1) } - return { routes, taxonomyRedirects } + return { routes, taxonomyRedirects, canonicalToolSlugs } } function writeGeneratedFiles() { const routes = loadLegacyRoutes() const taxonomyRedirects = loadLegacyTaxonomyRedirects() + const canonicalToolSlugs = loadCanonicalToolSlugs() fs.writeFileSync(TOOL_ALIASES_PATH, buildToolAliasesSource(routes)) - fs.writeFileSync(REDIRECTS_PATH, buildRedirectsSource(routes, taxonomyRedirects)) + fs.writeFileSync(REDIRECTS_PATH, buildRedirectsSource(routes, taxonomyRedirects, canonicalToolSlugs)) console.log(`[generate:legacy-routes] wrote ${path.relative(ROOT, TOOL_ALIASES_PATH)}`) console.log(`[generate:legacy-routes] wrote ${path.relative(ROOT, REDIRECTS_PATH)}`) } diff --git a/src/app/[lang]/csv-to-json/page.tsx b/src/app/[lang]/csv-to-json/page.tsx new file mode 100644 index 00000000..4fb1b95b --- /dev/null +++ b/src/app/[lang]/csv-to-json/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { isValidLocale } from "@/core/i18n/i18n" +import { buildLegacyToolRedirectMetadata, getLegacyToolRedirectCopy } from "@/core/routing/legacy-tool-redirect" +import { LegacyToolRedirectPage } from "@/core/seo/components/legacy-tool-redirect-page" + +type PageProps = { + params: Promise<{ lang: string }> +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { lang } = await params + if (!isValidLocale(lang)) notFound() + return buildLegacyToolRedirectMetadata(lang, "csv-json-converter") +} + +export default async function CsvToJsonRedirect({ params }: PageProps) { + const { lang } = await params + if (!isValidLocale(lang)) notFound() + + const copy = getLegacyToolRedirectCopy(lang) + return ( + + ) +} diff --git a/src/core/registry/tool-aliases.json b/src/core/registry/tool-aliases.json index 9f7dca9f..819e65f4 100644 --- a/src/core/registry/tool-aliases.json +++ b/src/core/registry/tool-aliases.json @@ -1,4 +1,5 @@ { "cron-expression-generator": "crontab-generator", - "tls-chain-inspector": "certificate-decoder" + "tls-chain-inspector": "certificate-decoder", + "csv-to-json": "csv-json-converter" } diff --git a/src/core/routing/legacy-routes.json b/src/core/routing/legacy-routes.json index 78f728db..3e482cdf 100644 --- a/src/core/routing/legacy-routes.json +++ b/src/core/routing/legacy-routes.json @@ -10,5 +10,11 @@ "targetSlug": "certificate-decoder", "status": 301, "reason": "merged" + }, + { + "sourceSlug": "csv-to-json", + "targetSlug": "csv-json-converter", + "status": 301, + "reason": "renamed" } ] diff --git a/src/generated/tool-index.json b/src/generated/tool-index.json index 98a369e5..3297f795 100644 --- a/src/generated/tool-index.json +++ b/src/generated/tool-index.json @@ -1,8 +1,8 @@ { "counts": { "canonicalTools": 131, - "aliasRoutes": 2, - "routeDirs": 200, + "aliasRoutes": 3, + "routeDirs": 201, "unknownRouteDirs": 0 }, "canonicalTools": [ @@ -5673,6 +5673,13 @@ "inStaticGroup": false, "inHubGroup": false }, + { + "slug": "csv-to-json", + "targetSlug": "csv-json-converter", + "pagePath": "src/app/[lang]/csv-to-json/page.tsx", + "inStaticGroup": false, + "inHubGroup": false + }, { "slug": "tls-chain-inspector", "targetSlug": "certificate-decoder", @@ -6082,6 +6089,14 @@ "inHubGroup": false, "inStaticGroup": false }, + { + "slug": "csv-to-json", + "hasPage": true, + "kind": "alias-tool", + "targetSlug": "csv-json-converter", + "inHubGroup": false, + "inStaticGroup": false + }, { "slug": "curl-to-code", "hasPage": true, diff --git a/tests/guards/legacy-routes.test.ts b/tests/guards/legacy-routes.test.ts index 783e64d0..0c461e5e 100644 --- a/tests/guards/legacy-routes.test.ts +++ b/tests/guards/legacy-routes.test.ts @@ -21,6 +21,11 @@ describe("legacy route redirects", () => { status: 301, reason: "merged", }) + expect(getLegacyRouteBySourceSlug("csv-to-json")).toMatchObject({ + targetSlug: "csv-json-converter", + status: 301, + reason: "renamed", + }) }) it("keeps generated aliases and redirects aligned with the legacy route manifest", () => { @@ -32,12 +37,25 @@ describe("legacy route redirects", () => { ) for (const route of redirectRoutes) { + expect(redirects).toContain(`/${route.sourceSlug} /en/${route.targetSlug} ${route.status}`) for (const locale of LOCALES) { expect(redirects).toContain(`/${locale}/${route.sourceSlug} /${locale}/${route.targetSlug} ${route.status}`) } } }) + it("redirects every locale-free canonical tool path to the default locale", () => { + const redirects = read("public/_redirects") + + for (const tool of TOOL_REGISTRY) { + expect(redirects).toContain(`/${tool.slug} /en/${tool.slug} 301`) + } + + expect(redirects).toContain("/qr-code-generator /en/qr-code-generator 301") + expect(redirects).toContain("/json-formatter /en/json-formatter 301") + expect(redirects).toContain("/base64-encode-decode /en/base64-encode-decode 301") + }) + it("keeps every legacy source out of sitemap while preserving valid targets", () => { const canonicalSlugs = new Set(TOOL_REGISTRY.map((tool) => tool.slug)) const sitemapUrls = sitemap().map((entry) => entry.url) From c22d6e0697c0bfb82fff026bdbe4304dd86923ba Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Wed, 15 Jul 2026 08:47:07 -0600 Subject: [PATCH 05/15] Validate ID generator inputs (#334) --- src/core/i18n/translations/de.json | 13 +- src/core/i18n/translations/en.json | 13 +- src/core/i18n/translations/fr.json | 13 +- src/core/i18n/translations/ja.json | 13 +- src/core/i18n/translations/ko.json | 13 +- src/core/i18n/translations/zh-CN.json | 13 +- src/core/i18n/translations/zh-TW.json | 13 +- src/features/tools/id-generator/logic.test.ts | 169 ++++++++++ src/features/tools/id-generator/logic.ts | 303 ++++++++++++++++++ src/features/tools/id-generator/page.tsx | 250 ++++++--------- tests/component/id-generator-page.test.tsx | 158 +++++++++ 11 files changed, 801 insertions(+), 170 deletions(-) create mode 100644 src/features/tools/id-generator/logic.test.ts create mode 100644 src/features/tools/id-generator/logic.ts create mode 100644 tests/component/id-generator-page.test.tsx diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 363416fb..c600d6b4 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -1362,8 +1362,8 @@ "case_label": "Groß-/Kleinschreibung", "case_lowercase": "Kleinbuchstaben", "case_uppercase": "Großbuchstaben", - "length_label": "Länge", - "alphabet_label": "Zeichensatz", + "length_label": "Länge (1-256)", + "alphabet_label": "Zeichensatz (2-256 eindeutige Unicode-Codepunkte)", "about_heading": "Über {type}", "about_uuid_v4": "UUID v4 verwendet 122 Zufallsbits, daher sind Kollisionen praktisch ausgeschlossen.", "about_uuid_v7": "UUID v7 enthält einen Unix-Millisekunden-Zeitstempel und lässt sich dadurch nach Erstellungszeit sortieren.", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID erzeugt kompakte, URL-freundliche eindeutige IDs. Das Standardformat mit 21 Zeichen liefert etwa 126 Bit Entropie.", "generated_heading": "Generierte IDs", "embedded_timestamp": "Eingebetteter Zeitstempel: {value}", - "copied_desc": "{count} ID-Werte kopiert." + "copied_desc": "{count} ID-Werte kopiert.", + "error_quantity_invalid": "Die Anzahl muss eine ganze Zahl zwischen 1 und 1000 sein.", + "error_nanoid_size_invalid": "Die NanoID-Länge muss eine ganze Zahl zwischen 1 und 256 sein.", + "error_alphabet_too_short": "Der Zeichensatz muss mindestens 2 eindeutige Unicode-Codepunkte enthalten.", + "error_alphabet_too_long": "Der Zeichensatz darf höchstens 256 Unicode-Codepunkte enthalten.", + "error_alphabet_duplicate": "Der Zeichensatz darf keine doppelten Unicode-Codepunkte enthalten.", + "error_alphabet_invalid_unicode": "Der Zeichensatz enthält einen ungepaarten Unicode-Surrogatwert.", + "error_generation_failed": "Mit diesen Einstellungen konnten keine IDs erzeugt werden." }, "certificate_decoder": { "title": "Zertifikats-Decoder", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index bb2aefba..97c87c96 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -1390,8 +1390,8 @@ "case_label": "Case", "case_lowercase": "Lowercase", "case_uppercase": "Uppercase", - "length_label": "Length", - "alphabet_label": "Alphabet", + "length_label": "Length (1-256)", + "alphabet_label": "Alphabet (2-256 unique Unicode code points)", "about_heading": "About {type}", "about_uuid_v4": "UUID v4 uses 122 random bits, so collisions are vanishingly unlikely.", "about_uuid_v7": "UUID v7 embeds a Unix millisecond timestamp, which keeps identifiers sortable by creation time.", @@ -1399,7 +1399,14 @@ "about_nanoid": "NanoID generates compact, URL-friendly unique IDs. The default 21-character format provides about 126 bits of entropy.", "generated_heading": "Generated IDs", "embedded_timestamp": "Embedded timestamp: {value}", - "copied_desc": "{count} ID values copied." + "copied_desc": "{count} ID values copied.", + "error_quantity_invalid": "Quantity must be a whole number from 1 to 1000.", + "error_nanoid_size_invalid": "NanoID length must be a whole number from 1 to 256.", + "error_alphabet_too_short": "Alphabet must contain at least 2 unique Unicode code points.", + "error_alphabet_too_long": "Alphabet cannot contain more than 256 Unicode code points.", + "error_alphabet_duplicate": "Alphabet must not contain duplicate Unicode code points.", + "error_alphabet_invalid_unicode": "Alphabet contains an unpaired Unicode surrogate.", + "error_generation_failed": "Unable to generate IDs with these settings." }, "certificate_decoder": { "title": "Certificate Decoder", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 109ada1e..aa77a519 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -1362,8 +1362,8 @@ "case_label": "Casse", "case_lowercase": "Minuscules", "case_uppercase": "Majuscules", - "length_label": "Longueur", - "alphabet_label": "Jeu de caractères", + "length_label": "Longueur (1-256)", + "alphabet_label": "Alphabet (2 à 256 points de code Unicode uniques)", "about_heading": "À propos de {type}", "about_uuid_v4": "UUID v4 utilise 122 bits aléatoires, ce qui rend les collisions extrêmement improbables.", "about_uuid_v7": "UUID v7 intègre un horodatage Unix en millisecondes, ce qui facilite le tri par date de création.", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID génère des identifiants uniques compacts et compatibles avec les URL. Le format par défaut de 21 caractères offre environ 126 bits d'entropie.", "generated_heading": "ID générés", "embedded_timestamp": "Horodatage intégré : {value}", - "copied_desc": "{count} identifiants copiés." + "copied_desc": "{count} identifiants copiés.", + "error_quantity_invalid": "La quantité doit être un nombre entier compris entre 1 et 1000.", + "error_nanoid_size_invalid": "La longueur NanoID doit être un nombre entier compris entre 1 et 256.", + "error_alphabet_too_short": "L'alphabet doit contenir au moins 2 points de code Unicode uniques.", + "error_alphabet_too_long": "L'alphabet ne peut pas contenir plus de 256 points de code Unicode.", + "error_alphabet_duplicate": "L'alphabet ne doit pas contenir de points de code Unicode en double.", + "error_alphabet_invalid_unicode": "L'alphabet contient un substitut Unicode non apparié.", + "error_generation_failed": "Impossible de générer des ID avec ces paramètres." }, "certificate_decoder": { "title": "Décodeur de certificat", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index c60cd207..498cdeb9 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -1362,8 +1362,8 @@ "case_label": "文字ケース", "case_lowercase": "小文字", "case_uppercase": "大文字", - "length_label": "長さ", - "alphabet_label": "文字セット", + "length_label": "長さ(1〜256)", + "alphabet_label": "文字セット(重複しない Unicode コードポイント 2〜256 個)", "about_heading": "{type} について", "about_uuid_v4": "UUID v4 は 122 ビットの乱数を使うため、衝突確率は極めて低くなります。", "about_uuid_v7": "UUID v7 には Unix ミリ秒タイムスタンプが埋め込まれており、生成時刻順に並べやすくなります。", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID は URL フレンドリーでコンパクトな一意 ID を生成します。デフォルトの 21 文字で約 126 ビットのエントロピーがあります。", "generated_heading": "生成された ID", "embedded_timestamp": "埋め込みタイムスタンプ: {value}", - "copied_desc": "{count} 件の ID をコピーしました。" + "copied_desc": "{count} 件の ID をコピーしました。", + "error_quantity_invalid": "件数には 1 から 1000 までの整数を入力してください。", + "error_nanoid_size_invalid": "NanoID の長さには 1 から 256 までの整数を入力してください。", + "error_alphabet_too_short": "文字セットには重複しない Unicode コードポイントが 2 個以上必要です。", + "error_alphabet_too_long": "文字セットに指定できる Unicode コードポイントは 256 個までです。", + "error_alphabet_duplicate": "文字セットに同じ Unicode コードポイントを重複して指定できません。", + "error_alphabet_invalid_unicode": "文字セットに対応するペアのない Unicode サロゲートが含まれています。", + "error_generation_failed": "この設定では ID を生成できませんでした。" }, "certificate_decoder": { "title": "証明書デコーダー", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 0af0ce13..c726220b 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -1362,8 +1362,8 @@ "case_label": "문자 케이스", "case_lowercase": "소문자", "case_uppercase": "대문자", - "length_label": "길이", - "alphabet_label": "문자 집합", + "length_label": "길이(1-256)", + "alphabet_label": "문자 집합(중복 없는 유니코드 코드 포인트 2-256개)", "about_heading": "{type} 정보", "about_uuid_v4": "UUID v4는 122비트 무작위 값을 사용하므로 충돌 가능성이 매우 낮습니다.", "about_uuid_v7": "UUID v7은 Unix 밀리초 타임스탬프를 포함하므로 생성 시간 순으로 정렬하기 쉽습니다.", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID는 URL 친화적인 짧은 고유 ID를 생성합니다. 기본 21자 형식은 약 126비트의 엔트로피를 제공합니다.", "generated_heading": "생성된 ID", "embedded_timestamp": "내장 타임스탬프: {value}", - "copied_desc": "ID {count}개를 복사했습니다." + "copied_desc": "ID {count}개를 복사했습니다.", + "error_quantity_invalid": "수량은 1에서 1000 사이의 정수여야 합니다.", + "error_nanoid_size_invalid": "NanoID 길이는 1에서 256 사이의 정수여야 합니다.", + "error_alphabet_too_short": "문자 집합에는 중복 없는 유니코드 코드 포인트가 2개 이상 필요합니다.", + "error_alphabet_too_long": "문자 집합에는 유니코드 코드 포인트를 256개까지만 사용할 수 있습니다.", + "error_alphabet_duplicate": "문자 집합에 같은 유니코드 코드 포인트를 중복해서 사용할 수 없습니다.", + "error_alphabet_invalid_unicode": "문자 집합에 짝이 없는 유니코드 서로게이트가 포함되어 있습니다.", + "error_generation_failed": "이 설정으로 ID를 생성할 수 없습니다." }, "certificate_decoder": { "title": "인증서 디코더", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 9ec19260..33f53e30 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -1362,8 +1362,8 @@ "case_label": "大小写", "case_lowercase": "小写", "case_uppercase": "大写", - "length_label": "长度", - "alphabet_label": "字符集", + "length_label": "长度(1-256)", + "alphabet_label": "字符集(2-256 个不重复的 Unicode 码点)", "about_heading": "关于 {type}", "about_uuid_v4": "UUID v4 使用 122 位随机比特,发生碰撞的概率极低。", "about_uuid_v7": "UUID v7 在前缀中嵌入 Unix 毫秒时间戳,因此可以按生成时间排序。", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID 生成紧凑且适合 URL 的唯一标识符,默认 21 个字符约有 126 位熵。", "generated_heading": "已生成的 ID", "embedded_timestamp": "嵌入时间戳:{value}", - "copied_desc": "已复制 {count} 个 ID。" + "copied_desc": "已复制 {count} 个 ID。", + "error_quantity_invalid": "数量必须是 1 到 1000 之间的整数。", + "error_nanoid_size_invalid": "NanoID 长度必须是 1 到 256 之间的整数。", + "error_alphabet_too_short": "字符集至少需要 2 个不重复的 Unicode 码点。", + "error_alphabet_too_long": "字符集不能超过 256 个 Unicode 码点。", + "error_alphabet_duplicate": "字符集不能包含重复的 Unicode 码点。", + "error_alphabet_invalid_unicode": "字符集包含未配对的 Unicode 代理项。", + "error_generation_failed": "无法使用当前设置生成 ID。" }, "certificate_decoder": { "title": "证书解码器", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 1eed093f..8d987911 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -1362,8 +1362,8 @@ "case_label": "大小寫", "case_lowercase": "小寫", "case_uppercase": "大寫", - "length_label": "長度", - "alphabet_label": "字元集", + "length_label": "長度(1-256)", + "alphabet_label": "字元集(2-256 個不重複的 Unicode 碼位)", "about_heading": "關於 {type}", "about_uuid_v4": "UUID v4 使用 122 位隨機位元,碰撞機率極低。", "about_uuid_v7": "UUID v7 會嵌入 Unix 毫秒時間戳,因此可依建立時間排序。", @@ -1371,7 +1371,14 @@ "about_nanoid": "NanoID 會產生精簡且適合 URL 的唯一識別碼,預設 21 個字元約有 126 位熵。", "generated_heading": "已產生的 ID", "embedded_timestamp": "內嵌時間戳:{value}", - "copied_desc": "已複製 {count} 個 ID。" + "copied_desc": "已複製 {count} 個 ID。", + "error_quantity_invalid": "數量必須是 1 到 1000 之間的整數。", + "error_nanoid_size_invalid": "NanoID 長度必須是 1 到 256 之間的整數。", + "error_alphabet_too_short": "字元集至少需要 2 個不重複的 Unicode 碼位。", + "error_alphabet_too_long": "字元集不能超過 256 個 Unicode 碼位。", + "error_alphabet_duplicate": "字元集不能包含重複的 Unicode 碼位。", + "error_alphabet_invalid_unicode": "字元集包含未配對的 Unicode 代理項。", + "error_generation_failed": "無法使用目前設定產生 ID。" }, "certificate_decoder": { "title": "憑證解碼器", diff --git a/src/features/tools/id-generator/logic.test.ts b/src/features/tools/id-generator/logic.test.ts new file mode 100644 index 00000000..8bea62fb --- /dev/null +++ b/src/features/tools/id-generator/logic.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest" +import { + NANOID_DEFAULT_ALPHABET, + extractULIDTimestamp, + extractUUIDv7Timestamp, + generateIdBatch, + generateNanoID, + generateULID, + generateUUIDv7, + validateIdGeneratorSettings, + validateNanoIdAlphabet, + type RandomFill, + type RawIdGeneratorSettings, + type ValidatedIdGeneratorSettings, +} from "./logic" + +function rawSettings(overrides: Partial = {}): RawIdGeneratorSettings { + return { + quantity: "5", + idType: "nanoid", + caseFormat: "lowercase", + nanoidSize: "21", + nanoidAlphabet: NANOID_DEFAULT_ALPHABET, + ...overrides, + } +} + +function makeAlphabet(length: number): string { + return Array.from({ length }, (_, index) => String.fromCodePoint(0x1000 + index)).join("") +} + +function sequenceFill(...values: number[]): RandomFill { + let offset = 0 + return (bytes) => { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = values[offset % values.length] + offset += 1 + } + } +} + +const zeroFill: RandomFill = (bytes) => bytes.fill(0) + +describe("id-generator validation", () => { + it("enforces alphabet boundaries in Unicode code points", () => { + expect(validateNanoIdAlphabet("")).toEqual({ ok: false, code: "error_alphabet_too_short" }) + expect(validateNanoIdAlphabet("a")).toEqual({ ok: false, code: "error_alphabet_too_short" }) + expect(validateNanoIdAlphabet(makeAlphabet(2))).toMatchObject({ ok: true }) + expect(validateNanoIdAlphabet(makeAlphabet(255))).toMatchObject({ ok: true }) + expect(validateNanoIdAlphabet(makeAlphabet(256))).toMatchObject({ ok: true }) + expect(validateNanoIdAlphabet(makeAlphabet(257))).toEqual({ ok: false, code: "error_alphabet_too_long" }) + }) + + it("rejects duplicate symbols and unpaired surrogates", () => { + expect(validateNanoIdAlphabet("abca")).toEqual({ ok: false, code: "error_alphabet_duplicate" }) + expect(validateNanoIdAlphabet("a\ud800b")).toEqual({ ok: false, code: "error_alphabet_invalid_unicode" }) + expect(validateNanoIdAlphabet("😀🚀")).toEqual({ ok: true, symbols: ["😀", "🚀"] }) + }) + + it.each(["", "0", "-1", "1.5", "NaN", "Infinity", "1001"])( + "rejects invalid batch quantity %j", + (quantity) => { + const result = validateIdGeneratorSettings(rawSettings({ quantity })) + expect(result).toEqual({ ok: false, errors: { quantity: "error_quantity_invalid" } }) + }, + ) + + it.each(["", "0", "-1", "1.5", "NaN", "Infinity", "257"])( + "rejects invalid NanoID size %j", + (nanoidSize) => { + const result = validateIdGeneratorSettings(rawSettings({ nanoidSize })) + expect(result).toEqual({ ok: false, errors: { nanoidSize: "error_nanoid_size_invalid" } }) + }, + ) + + it("accepts numeric endpoints and ignores NanoID-only fields for other ID types", () => { + expect(validateIdGeneratorSettings(rawSettings({ quantity: "1", nanoidSize: "1" }))).toMatchObject({ ok: true }) + expect(validateIdGeneratorSettings(rawSettings({ quantity: "1000", nanoidSize: "256" }))).toMatchObject({ ok: true }) + expect(validateIdGeneratorSettings(rawSettings({ + idType: "uuid-v4", + nanoidSize: "", + nanoidAlphabet: "", + }))).toMatchObject({ ok: true }) + }) +}) + +describe("NanoID generation", () => { + it("uses rejection sampling for non-power-of-two alphabets", () => { + expect(generateNanoID(3, "abc", sequenceFill(3, 2, 1, 0, 3))).toBe("cba") + }) + + it("rejects byte 255 for a 255-symbol alphabet", () => { + const alphabet = makeAlphabet(255) + const symbols = Array.from(alphabet) + + expect(generateNanoID(1, alphabet, sequenceFill(255, 254))).toBe(symbols[254]) + }) + + it("can select the final symbol from a 256-symbol alphabet", () => { + const alphabet = makeAlphabet(256) + const symbols = Array.from(alphabet) + + expect(generateNanoID(1, alphabet, sequenceFill(255, 0))).toBe(symbols[255]) + }) + + it("counts and emits non-BMP symbols as complete code points", () => { + const id = generateNanoID(4, "😀🚀", sequenceFill(0, 1, 0, 1)) + + expect(id).toBe("😀🚀😀🚀") + expect(Array.from(id)).toHaveLength(4) + }) + + it("defensively rejects invalid direct generation arguments", () => { + expect(() => generateNanoID(0, "ab", zeroFill)).toThrow("NanoID size") + expect(() => generateNanoID(1, "", zeroFill)).toThrow("error_alphabet_too_short") + expect(() => generateNanoID(1, "aab", zeroFill)).toThrow("error_alphabet_duplicate") + }) +}) + +describe("ID algorithm regressions", () => { + it("generates and extracts a valid UUID v7 timestamp", () => { + const timestamp = 0x0123456789ab + const uuid = generateUUIDv7(timestamp, zeroFill) + + expect(uuid).toBe("01234567-89ab-7000-8000-000000000000") + expect(extractUUIDv7Timestamp(uuid)?.getTime()).toBe(timestamp) + expect(extractUUIDv7Timestamp("01234567-89ab-7000-0000-000000000000")).toBeNull() + expect(extractUUIDv7Timestamp("not-a-uuid")).toBeNull() + }) + + it("generates and extracts a valid ULID timestamp", () => { + const timestamp = 1_700_000_000_123 + const ulid = generateULID(timestamp, zeroFill) + + expect(ulid).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/) + expect(extractULIDTimestamp(ulid)?.getTime()).toBe(timestamp) + expect(extractULIDTimestamp(`8${"0".repeat(25)}`)).toBeNull() + expect(extractULIDTimestamp(`${"0".repeat(25)}I`)).toBeNull() + }) + + it("keeps UUID v4 batch generation and case formatting stable", () => { + const settings: ValidatedIdGeneratorSettings = { + quantity: 2, + idType: "uuid-v4", + caseFormat: "uppercase", + nanoidSize: 21, + nanoidAlphabet: NANOID_DEFAULT_ALPHABET, + } + + expect(generateIdBatch(settings, { + uuidV4: () => "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + })).toEqual([ + "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE", + "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE", + ]) + }) + + it("rejects invalid batch settings before allocating or looping", () => { + const settings: ValidatedIdGeneratorSettings = { + quantity: Number.NaN, + idType: "nanoid", + caseFormat: "lowercase", + nanoidSize: 21, + nanoidAlphabet: NANOID_DEFAULT_ALPHABET, + } + + expect(() => generateIdBatch(settings, { randomFill: zeroFill })).toThrow("Quantity") + }) +}) diff --git a/src/features/tools/id-generator/logic.ts b/src/features/tools/id-generator/logic.ts new file mode 100644 index 00000000..23c9224f --- /dev/null +++ b/src/features/tools/id-generator/logic.ts @@ -0,0 +1,303 @@ +import { v4 as uuidv4 } from "uuid" + +export const NANOID_DEFAULT_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-" +export const MIN_BATCH_QUANTITY = 1 +export const MAX_BATCH_QUANTITY = 1000 +export const MIN_NANOID_SIZE = 1 +export const MAX_NANOID_SIZE = 256 +export const MIN_NANOID_ALPHABET_SIZE = 2 +export const MAX_NANOID_ALPHABET_SIZE = 256 + +const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const MAX_48_BIT_TIMESTAMP = 0xffffffffffff + +export type IDType = "uuid-v4" | "uuid-v7" | "ulid" | "nanoid" +export type IDCaseFormat = "lowercase" | "uppercase" +export type IdGeneratorField = "quantity" | "nanoidSize" | "nanoidAlphabet" +export type IdGeneratorErrorCode = + | "error_quantity_invalid" + | "error_nanoid_size_invalid" + | "error_alphabet_too_short" + | "error_alphabet_too_long" + | "error_alphabet_duplicate" + | "error_alphabet_invalid_unicode" + +export type IdGeneratorFieldErrors = Partial> + +export interface RawIdGeneratorSettings { + quantity: string + idType: IDType + caseFormat: IDCaseFormat + nanoidSize: string + nanoidAlphabet: string +} + +export interface ValidatedIdGeneratorSettings { + quantity: number + idType: IDType + caseFormat: IDCaseFormat + nanoidSize: number + nanoidAlphabet: string +} + +export type IdGeneratorValidationResult = + | { ok: true; value: ValidatedIdGeneratorSettings; errors: IdGeneratorFieldErrors } + | { ok: false; errors: IdGeneratorFieldErrors } + +export type RandomFill = (bytes: Uint8Array) => void + +export interface IdGeneratorDependencies { + now?: () => number + randomFill?: RandomFill + uuidV4?: () => string +} + +export type NanoIdAlphabetValidation = + | { ok: true; symbols: string[] } + | { ok: false; code: Extract } + +function defaultRandomFill(bytes: Uint8Array): void { + crypto.getRandomValues(bytes) +} + +function parseBoundedInteger(value: string, min: number, max: number): number | null { + if (value.trim() === "") return null + + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < min || parsed > max) { + return null + } + + return parsed +} + +function isUnicodeScalar(symbol: string): boolean { + const codePoint = symbol.codePointAt(0) + return codePoint !== undefined && (codePoint < 0xd800 || codePoint > 0xdfff) +} + +export function validateNanoIdAlphabet(alphabet: string): NanoIdAlphabetValidation { + const symbols = Array.from(alphabet) + + if (symbols.some((symbol) => !isUnicodeScalar(symbol))) { + return { ok: false, code: "error_alphabet_invalid_unicode" } + } + if (symbols.length < MIN_NANOID_ALPHABET_SIZE) { + return { ok: false, code: "error_alphabet_too_short" } + } + if (symbols.length > MAX_NANOID_ALPHABET_SIZE) { + return { ok: false, code: "error_alphabet_too_long" } + } + if (new Set(symbols).size !== symbols.length) { + return { ok: false, code: "error_alphabet_duplicate" } + } + + return { ok: true, symbols } +} + +export function validateIdGeneratorSettings(raw: RawIdGeneratorSettings): IdGeneratorValidationResult { + const errors: IdGeneratorFieldErrors = {} + const quantity = parseBoundedInteger(raw.quantity, MIN_BATCH_QUANTITY, MAX_BATCH_QUANTITY) + + if (quantity === null) { + errors.quantity = "error_quantity_invalid" + } + + let nanoidSize = 21 + if (raw.idType === "nanoid") { + const parsedSize = parseBoundedInteger(raw.nanoidSize, MIN_NANOID_SIZE, MAX_NANOID_SIZE) + if (parsedSize === null) { + errors.nanoidSize = "error_nanoid_size_invalid" + } else { + nanoidSize = parsedSize + } + + const alphabetValidation = validateNanoIdAlphabet(raw.nanoidAlphabet) + if (!alphabetValidation.ok) { + errors.nanoidAlphabet = alphabetValidation.code + } + } + + if (Object.keys(errors).length > 0 || quantity === null) { + return { ok: false, errors } + } + + return { + ok: true, + errors, + value: { + quantity, + idType: raw.idType, + caseFormat: raw.caseFormat, + nanoidSize, + nanoidAlphabet: raw.nanoidAlphabet, + }, + } +} + +function assertTimestamp(timestamp: number): void { + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_48_BIT_TIMESTAMP) { + throw new RangeError("Timestamp must be a non-negative 48-bit integer.") + } +} + +function byteToHex(byte: number): string { + return byte.toString(16).padStart(2, "0") +} + +export function generateUUIDv7(timestamp: number = Date.now(), randomFill: RandomFill = defaultRandomFill): string { + assertTimestamp(timestamp) + + const timestampHex = timestamp.toString(16).padStart(12, "0") + const randomBytes = new Uint8Array(10) + randomFill(randomBytes) + randomBytes[0] = (randomBytes[0] & 0x0f) | 0x70 + randomBytes[2] = (randomBytes[2] & 0x3f) | 0x80 + const randomHex = Array.from(randomBytes, byteToHex).join("") + + return ( + `${timestampHex.slice(0, 8)}-${timestampHex.slice(8, 12)}-` + + `${randomHex.slice(0, 4)}-${randomHex.slice(4, 8)}-${randomHex.slice(8, 20)}` + ) +} + +export function extractUUIDv7Timestamp(uuid: string): Date | null { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid)) { + return null + } + + const timestamp = Number.parseInt(uuid.replace(/-/g, "").slice(0, 12), 16) + return new Date(timestamp) +} + +export function generateULID(timestamp: number = Date.now(), randomFill: RandomFill = defaultRandomFill): string { + assertTimestamp(timestamp) + + let timePart = "" + let remainingTime = timestamp + for (let i = 0; i < 10; i++) { + timePart = ULID_ENCODING[remainingTime % 32] + timePart + remainingTime = Math.floor(remainingTime / 32) + } + + const randomBytes = new Uint8Array(10) + randomFill(randomBytes) + let randomPart = "" + let buffer = 0 + let bufferedBits = 0 + + for (const byte of randomBytes) { + buffer = (buffer << 8) | byte + bufferedBits += 8 + + while (bufferedBits >= 5) { + bufferedBits -= 5 + randomPart += ULID_ENCODING[(buffer >>> bufferedBits) & 0x1f] + buffer &= (1 << bufferedBits) - 1 + } + } + + return timePart + randomPart +} + +export function extractULIDTimestamp(ulid: string): Date | null { + if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i.test(ulid)) return null + + let timestamp = 0 + for (let i = 0; i < 10; i++) { + timestamp = timestamp * 32 + ULID_ENCODING.indexOf(ulid[i].toUpperCase()) + } + + return new Date(timestamp) +} + +function assertNanoIdSize(size: number): void { + if (!Number.isInteger(size) || size < MIN_NANOID_SIZE || size > MAX_NANOID_SIZE) { + throw new RangeError("NanoID size must be an integer from 1 to 256.") + } +} + +function generateNanoIdFromSymbols(size: number, symbols: string[], randomFill: RandomFill): string { + const mask = (1 << Math.ceil(Math.log2(symbols.length))) - 1 + const step = Math.ceil((1.6 * mask * size) / symbols.length) + const output: string[] = [] + + while (output.length < size) { + const bytes = new Uint8Array(step) + randomFill(bytes) + + for (const byte of bytes) { + const symbolIndex = byte & mask + if (symbolIndex < symbols.length) { + output.push(symbols[symbolIndex]) + if (output.length === size) break + } + } + } + + return output.join("") +} + +export function generateNanoID( + size: number = 21, + alphabet: string = NANOID_DEFAULT_ALPHABET, + randomFill: RandomFill = defaultRandomFill, +): string { + assertNanoIdSize(size) + const alphabetValidation = validateNanoIdAlphabet(alphabet) + if (!alphabetValidation.ok) { + throw new RangeError(alphabetValidation.code) + } + + return generateNanoIdFromSymbols(size, alphabetValidation.symbols, randomFill) +} + +export function generateIdBatch( + settings: ValidatedIdGeneratorSettings, + dependencies: IdGeneratorDependencies = {}, +): string[] { + if (!Number.isInteger(settings.quantity) || settings.quantity < MIN_BATCH_QUANTITY || settings.quantity > MAX_BATCH_QUANTITY) { + throw new RangeError("Quantity must be an integer from 1 to 1000.") + } + + const randomFill = dependencies.randomFill ?? defaultRandomFill + const now = dependencies.now ?? Date.now + const createUuidV4 = dependencies.uuidV4 ?? uuidv4 + let nanoIdSymbols: string[] | null = null + + if (settings.idType === "nanoid") { + assertNanoIdSize(settings.nanoidSize) + const alphabetValidation = validateNanoIdAlphabet(settings.nanoidAlphabet) + if (!alphabetValidation.ok) { + throw new RangeError(alphabetValidation.code) + } + nanoIdSymbols = alphabetValidation.symbols + } + + const results: string[] = [] + for (let i = 0; i < settings.quantity; i++) { + let id: string + switch (settings.idType) { + case "uuid-v4": + id = createUuidV4() + break + case "uuid-v7": + id = generateUUIDv7(now(), randomFill) + break + case "ulid": + id = generateULID(now(), randomFill) + break + case "nanoid": + id = generateNanoIdFromSymbols(settings.nanoidSize, nanoIdSymbols as string[], randomFill) + break + } + + results.push(settings.caseFormat === "uppercase" && settings.idType !== "nanoid" ? id.toUpperCase() : id) + } + + return results +} diff --git a/src/features/tools/id-generator/page.tsx b/src/features/tools/id-generator/page.tsx index e02e6618..e92a02a2 100644 --- a/src/features/tools/id-generator/page.tsx +++ b/src/features/tools/id-generator/page.tsx @@ -16,166 +16,76 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { v4 as uuidv4 } from "uuid" - -function byteToHex(byte: number): string { - return byte.toString(16).padStart(2, "0") -} - -// Clean UUID v7 implementation -function generateUUIDv7(): string { - const timestamp = Date.now() - const timestampHex = timestamp.toString(16).padStart(12, "0") - - const randomBytes = crypto.getRandomValues(new Uint8Array(10)) - // Set version to 7 (0111) - randomBytes[0] = (randomBytes[0] & 0x0f) | 0x70 - // Set variant to 10xx - randomBytes[2] = (randomBytes[2] & 0x3f) | 0x80 - - const randHex = Array.from(randomBytes).map((b) => byteToHex(b)).join("") - - return ( - timestampHex.slice(0, 8) + "-" + - timestampHex.slice(8, 12) + "-" + - randHex.slice(0, 4) + "-" + - randHex.slice(4, 8) + "-" + - randHex.slice(8, 20) - ) -} - -// ─── ULID ─────────────────────────────────────────────────────────────────── -const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - -function generateULID(): string { - const now = Date.now() - // 10-char timestamp (48 bits, base32) - let timePart = "" - let t = now - for (let i = 0; i < 10; i++) { - timePart = ULID_ENCODING[t % 32] + timePart - t = Math.floor(t / 32) - } - // 16-char randomness (80 bits, base32) - const randBytes = crypto.getRandomValues(new Uint8Array(10)) - let randPart = "" - // Process 80 bits in groups of 5 - let bits = 0 - let numBits = 0 - let byteIdx = 0 - for (let i = 0; i < 16; i++) { - while (numBits < 5 && byteIdx < 10) { - bits = (bits << 8) | randBytes[byteIdx++] - numBits += 8 - } - const idx = (bits >> (numBits - 5)) & 0x1f - randPart += ULID_ENCODING[idx] - numBits -= 5 - } - return timePart + randPart -} - -function extractULIDTimestamp(ulid: string): Date | null { - if (ulid.length !== 26) return null - let timestamp = 0 - for (let i = 0; i < 10; i++) { - const idx = ULID_ENCODING.indexOf(ulid[i].toUpperCase()) - if (idx === -1) return null - timestamp = timestamp * 32 + idx - } - return new Date(timestamp) -} - -// ─── NanoID ───────────────────────────────────────────────────────────────── -const NANOID_DEFAULT_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-" - -function generateNanoID(size: number = 21, alphabet: string = NANOID_DEFAULT_ALPHABET): string { - const mask = (2 << (31 - Math.clz32((alphabet.length - 1) | 1))) - 1 - const step = Math.ceil((1.6 * mask * size) / alphabet.length) - let id = "" - while (id.length < size) { - const bytes = crypto.getRandomValues(new Uint8Array(step)) - for (let i = 0; i < step && id.length < size; i++) { - const charIdx = bytes[i] & mask - if (charIdx < alphabet.length) { - id += alphabet[charIdx] - } - } - } - return id -} - -// ─── UUID v7 timestamp extraction ────────────────────────────────────────── -function extractUUIDv7Timestamp(uuid: string): Date | null { - const clean = uuid.replace(/-/g, "") - if (clean.length !== 32) return null - // Version check: nibble at position 12 should be '7' - if (clean[12] !== "7") return null - const timestampHex = clean.slice(0, 12) - const timestamp = parseInt(timestampHex, 16) - if (isNaN(timestamp)) return null - return new Date(timestamp) -} - -type IDType = "uuid-v4" | "uuid-v7" | "ulid" | "nanoid" +import { + NANOID_DEFAULT_ALPHABET, + extractULIDTimestamp, + extractUUIDv7Timestamp, + generateIdBatch, + validateIdGeneratorSettings, + type IDCaseFormat, + type IDType, +} from "./logic" export function IdGeneratorPage() { const { t } = useLang() const toolT = t.tools["id_generator"] as Record const [ids, setIds] = React.useState([]) - const [quantity, setQuantity] = React.useState(5) + const [quantity, setQuantity] = React.useState("5") const [idType, setIdType] = React.useState("uuid-v4") - const [nanoidSize, setNanoidSize] = React.useState(21) + const [nanoidSize, setNanoidSize] = React.useState("21") const [nanoidAlphabet, setNanoidAlphabet] = React.useState(NANOID_DEFAULT_ALPHABET) - const [caseFormat, setCaseFormat] = React.useState<"lowercase" | "uppercase">("lowercase") + const [caseFormat, setCaseFormat] = React.useState("lowercase") + const [generationFailed, setGenerationFailed] = React.useState(false) + + const validation = React.useMemo(() => validateIdGeneratorSettings({ + quantity, + idType, + caseFormat, + nanoidSize, + nanoidAlphabet, + }), [quantity, idType, caseFormat, nanoidSize, nanoidAlphabet]) const generate = React.useCallback(() => { - const qty = Math.min(Math.max(1, quantity), 1000) - const results: string[] = [] - for (let i = 0; i < qty; i++) { - let id = "" - switch (idType) { - case "uuid-v4": - id = uuidv4() - break - case "uuid-v7": - id = generateUUIDv7() - break - case "ulid": - id = generateULID() - break - case "nanoid": - id = generateNanoID(nanoidSize, nanoidAlphabet) - break - } - if (caseFormat === "uppercase" && idType !== "nanoid") { - id = id.toUpperCase() - } - results.push(id) + if (!validation.ok) { + setIds([]) + setGenerationFailed(false) + return } - setIds(results) - }, [quantity, idType, caseFormat, nanoidSize, nanoidAlphabet]) + + try { + setIds(generateIdBatch(validation.value)) + setGenerationFailed(false) + } catch { + setIds([]) + setGenerationFailed(true) + } + }, [validation]) React.useEffect(() => { generate() }, [generate]) + const outputIds = React.useMemo( + () => validation.ok && !generationFailed ? ids : [], + [generationFailed, ids, validation.ok], + ) + const handleCopyAll = async () => { - if (ids.length === 0) return - const result = await safeClipboardWrite(ids.join("\n")) + if (outputIds.length === 0) return + const result = await safeClipboardWrite(outputIds.join("\n")) if (!result.ok) { toast.error(t.common.copy_failed) return } toast.success(t.common.copied, { - description: toolT.copied_desc.replace("{count}", String(ids.length)), + description: toolT.copied_desc.replace("{count}", String(outputIds.length)), }) } // Show timestamp for UUID v7 or ULID (first ID only) const timestampInfo = React.useMemo(() => { - if (ids.length === 0) return null - const firstId = ids[0] + if (outputIds.length === 0) return null + const firstId = outputIds[0] if (idType === "uuid-v7") { const date = extractUUIDv7Timestamp(firstId) return date ? toolT.embedded_timestamp.replace("{value}", date.toISOString()) : null @@ -185,7 +95,11 @@ export function IdGeneratorPage() { return date ? toolT.embedded_timestamp.replace("{value}", date.toISOString()) : null } return null - }, [ids, idType, toolT.embedded_timestamp]) + }, [outputIds, idType, toolT.embedded_timestamp]) + + const quantityError = validation.errors.quantity ? toolT[validation.errors.quantity] : null + const nanoidSizeError = validation.errors.nanoidSize ? toolT[validation.errors.nanoidSize] : null + const nanoidAlphabetError = validation.errors.nanoidAlphabet ? toolT[validation.errors.nanoidAlphabet] : null return (
@@ -200,7 +114,7 @@ export function IdGeneratorPage() {

- @@ -214,9 +128,9 @@ export function IdGeneratorPage() {

{toolT.settings_heading}

- + setQuantity(Number(e.target.value))} + onChange={(e) => setQuantity(e.target.value)} + aria-invalid={quantityError ? "true" : undefined} + aria-describedby={quantityError ? "id-generator-quantity-error" : undefined} /> + {quantityError && ( + + )}
{idType !== "nanoid" && (
- - setCaseFormat(v as IDCaseFormat)}> + {toolT.case_lowercase} {toolT.case_uppercase} @@ -253,22 +176,39 @@ export function IdGeneratorPage() { {idType === "nanoid" && ( <>
- + setNanoidSize(Number(e.target.value))} + onChange={(e) => setNanoidSize(e.target.value)} + aria-invalid={nanoidSizeError ? "true" : undefined} + aria-describedby={nanoidSizeError ? "id-generator-nanoid-size-error" : undefined} /> + {nanoidSizeError && ( + + )}
- + setNanoidAlphabet(e.target.value)} + aria-invalid={nanoidAlphabetError ? "true" : undefined} + aria-describedby={nanoidAlphabetError ? "id-generator-nanoid-alphabet-error" : undefined} /> + {nanoidAlphabetError && ( + + )}
)} @@ -290,20 +230,32 @@ export function IdGeneratorPage() {
- {toolT.generated_heading} ({ids.length}) + {toolT.generated_heading} ({outputIds.length}) {timestampInfo && ( {timestampInfo} )}
-
+ {generationFailed && ( +
+ {toolT.error_generation_failed} +
+ )}