diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..53d1c14 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22 diff --git a/AGENTS.md b/AGENTS.md index 4ebfbb4..3a11552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ ## Build, Test, and Development Commands +- **Node version requirement**: Before running `pnpm` commands (especially checks/tests), run `nvm use v22` in this repository. + - `pnpm dev` – start the Next.js development server with hot reload. - `pnpm build` – create a production build. - `pnpm start` – run the production bundle locally. diff --git a/components/access-keys/new-item.tsx b/components/access-keys/new-item.tsx index 0a8d403..7de2f90 100644 --- a/components/access-keys/new-item.tsx +++ b/components/access-keys/new-item.tsx @@ -47,8 +47,14 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic React.useEffect(() => { if (visible) { - setAccessKey(makeRandomString(20)) - setSecretKey(makeRandomString(40)) + try { + setAccessKey(makeRandomString(20)) + setSecretKey(makeRandomString(40)) + } catch { + setAccessKey("") + setSecretKey("") + message.error(t("Failed to generate secure random keys")) + } setName("") setDescription("") setExpiry(null) @@ -63,7 +69,7 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic setPolicy("{}") }) } - }, [visible, api]) + }, [visible, api, message, t]) const closeModal = () => { onVisibleChange(false) diff --git a/lib/delete-task.ts b/lib/delete-task.ts index 804a226..0f249a0 100644 --- a/lib/delete-task.ts +++ b/lib/delete-task.ts @@ -1,5 +1,6 @@ import { DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, ListObjectVersionsCommand, S3Client } from "@aws-sdk/client-s3" import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager" +import { createTaskId } from "./task-id" export type DeleteStatus = "pending" | "running" | "completed" | "failed" | "canceled" @@ -203,7 +204,7 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo prefix?: string, ): DeleteTask[] => items.map((item) => ({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${item.key}-${item.versionId ?? "latest"}`, + id: createTaskId(`${item.key}-${item.versionId ?? "latest"}`), kind: "delete" as const, key: item.key, versionId: item.versionId, @@ -244,7 +245,7 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo bucketName: string, options?: { forceDelete?: boolean }, ): FolderDeleteTask => ({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${prefix}`, + id: createTaskId(prefix), kind: "delete-folder" as const, bucketName, prefix, diff --git a/lib/functions.ts b/lib/functions.ts index 7a16423..85fdf32 100644 --- a/lib/functions.ts +++ b/lib/functions.ts @@ -33,19 +33,26 @@ function convertToBytes(value: string, unit: string, fromK8s = false): number { } export function makeRandomString(length = 20): string { - let initstr = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" - const arr = initstr.split("") + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + if (length <= 0) return "" - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)) - const current = arr[i] - const swapWith = arr[j] - if (current !== undefined && swapWith !== undefined) { - arr[i] = swapWith - arr[j] = current + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + throw new Error("Secure random generator is not available") + } + + const result: string[] = [] + const alphabetLength = alphabet.length + const maxByte = 256 - (256 % alphabetLength) + + while (result.length < length) { + const bytes = cryptoApi.getRandomValues(new Uint8Array(length)) + for (const byte of bytes) { + if (byte >= maxByte) continue + result.push(alphabet[byte % alphabetLength] ?? "") + if (result.length === length) break } } - initstr = arr.join("") - return initstr.slice(0, length) + return result.join("") } diff --git a/lib/task-id.ts b/lib/task-id.ts new file mode 100644 index 0000000..3ddc0ad --- /dev/null +++ b/lib/task-id.ts @@ -0,0 +1,35 @@ +const FALLBACK_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + +function createSecureSuffix(length = 12): string { + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + throw new Error("Secure random generator is not available") + } + + const chars: string[] = [] + const alphabetLength = FALLBACK_ALPHABET.length + const maxByte = 256 - (256 % alphabetLength) + + while (chars.length < length) { + const bytes = cryptoApi.getRandomValues(new Uint8Array(length)) + for (const byte of bytes) { + if (byte >= maxByte) continue + chars.push(FALLBACK_ALPHABET[byte % alphabetLength] ?? "") + if (chars.length === length) break + } + } + + return chars.join("") +} + +export function createTaskId(seed: string): string { + const prefix = Date.now() + const cryptoApi = globalThis.crypto + + if (cryptoApi?.randomUUID) { + return `${prefix}-${cryptoApi.randomUUID()}-${seed}` + } + + return `${prefix}-${createSecureSuffix()}-${seed}` +} + diff --git a/lib/upload-task.ts b/lib/upload-task.ts index 38386ff..1efb698 100644 --- a/lib/upload-task.ts +++ b/lib/upload-task.ts @@ -7,6 +7,7 @@ import { } from "@aws-sdk/client-s3" import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager" import { formatBytes } from "./functions" +import { createTaskId } from "./task-id" export type UploadStatus = "pending" | "running" | "completed" | "failed" | "canceled" | "paused" @@ -105,7 +106,7 @@ export function createUploadTaskHelpers(s3Client: S3Client, config: UploadTaskCo existKeys.add(`${bucketName}/${item.key}`) const displayName = item.key.split("/").pop() ?? item.key return { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${item.key}`, + id: createTaskId(item.key), kind: "upload" as const, file: item.file, key: item.key,