diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9612f6a..b42fe49 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "withGlobalTauri": true, "windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }], "security": { - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'" } }, "bundle": { diff --git a/apps/web/e2e/uploads-csp.spec.ts b/apps/web/e2e/uploads-csp.spec.ts index 7a434e3..2eba932 100644 --- a/apps/web/e2e/uploads-csp.spec.ts +++ b/apps/web/e2e/uploads-csp.spec.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { expect, test } from '@playwright/test'; const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8'); @@ -20,6 +21,10 @@ for (const [platform, policy] of Object.entries(policies)) { await route.fulfill({ contentType: 'application/javascript', body: ` try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; } catch { document.documentElement.dataset.evalBlocked = 'true'; } + WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])).then( + () => { document.documentElement.dataset.wasmBlocked = 'false'; }, + () => { document.documentElement.dataset.wasmBlocked = 'true'; }, + ); ` }); return; } @@ -27,22 +32,37 @@ for (const [platform, policy] of Object.entries(policies)) { await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } }); }); await page.goto('/csp-upload-test'); - const result = await page.evaluate(() => new Promise((resolve, reject) => { + const result = await page.evaluate(async () => { const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' }); - worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; - worker.onmessage = ({ data }) => { - if (data.type === 'progress') return; + try { + const results = []; + // Reuse one worker for empty, known-vector and multi-chunk binary files. + const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251); + for (const [id, bytes] of [['empty', new Uint8Array()], ['abc', new TextEncoder().encode('abc')], ['binary', binary]] as const) { + results.push(await new Promise((resolve, reject) => { + const progress: number[] = []; + worker.onerror = (event) => reject(new Error(event.message)); + worker.onmessage = ({ data }) => { + if (data.type === 'progress') { progress.push(data.done); return; } + resolve({ ...data, progress }); + }; + worker.postMessage({ id, file: new File([bytes], `${id}.flac`) }); + })); + } + return results; + } finally { worker.terminate(); - resolve(data); - }; - worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') }); - })); - expect(result).toEqual({ - id: 'csp-test', type: 'done', - sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + } }); + const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251); + expect(result).toEqual([ + { id: 'empty', type: 'done', sha256: createHash('sha256').update('').digest('hex'), progress: [] }, + { id: 'abc', type: 'done', sha256: createHash('sha256').update('abc').digest('hex'), progress: [] }, + { id: 'binary', type: 'done', sha256: createHash('sha256').update(binary).digest('hex'), progress: expect.arrayContaining([expect.any(Number)]) }, + ]); // A page script observes CSP; Playwright's evaluate bypasses the eval restriction. await page.addScriptTag({ url: '/csp-eval-test.js' }); await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true'); + await expect(page.locator('html')).toHaveAttribute('data-wasm-blocked', 'true'); }); } diff --git a/apps/web/package.json b/apps/web/package.json index 4686a8d..5238f63 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@tanstack/react-query": "^5.80.7", "@tanstack/react-router": "^1.120.20", "@tanstack/react-virtual": "^3.14.21", - "hash-wasm": "^4.12.0", + "js-sha256": "^1.0.0", "music-metadata": "^12.2.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/web/src/features/uploads/hash.worker.ts b/apps/web/src/features/uploads/hash.worker.ts index e8e483f..bab4859 100644 --- a/apps/web/src/features/uploads/hash.worker.ts +++ b/apps/web/src/features/uploads/hash.worker.ts @@ -1,7 +1,7 @@ /// // SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ — -// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс. -import { createSHA256 } from 'hash-wasm'; +// js-sha256 считает чанками в воркере без WebAssembly: работает и со строгим CSP у прокси. +import { sha256 } from 'js-sha256'; export interface HashRequest { id: string; @@ -18,8 +18,7 @@ const ctx = self as unknown as DedicatedWorkerGlobalScope; ctx.onmessage = async (e: MessageEvent) => { const { id, file } = e.data; try { - const h = await createSHA256(); - h.init(); + const h = sha256.create(); const reader = file.stream().getReader(); let done = 0; let lastReport = 0; @@ -33,7 +32,7 @@ ctx.onmessage = async (e: MessageEvent) => { ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse); } } - ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse); + ctx.postMessage({ id, type: 'done', sha256: h.hex() } satisfies HashResponse); } catch (err) { ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse); } diff --git a/deploy/nginx/security-headers.conf b/deploy/nginx/security-headers.conf index 29b10ee..ab6baf9 100644 --- a/deploy/nginx/security-headers.conf +++ b/deploy/nginx/security-headers.conf @@ -3,5 +3,4 @@ add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-Frame-Options "DENY" always; -# hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval. -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; diff --git a/docs/uploads.md b/docs/uploads.md index 8d39511..a24d79f 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -61,7 +61,7 @@ | Уровень | Когда | Как | |---|---|---| -| 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает в браузере (Web Crypto, потоково в Web Worker) и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. | +| 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает потоково в Web Worker через `js-sha256`, без WebAssembly и загрузки всего файла в память, и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. | | 2. Совпадение метаданных | **До загрузки** (теги читаются на клиенте библиотекой `music-metadata`) и после (на сервере по `ffprobe`) | Нормализованные `artist + title` (+ `album`, если есть) и длительность ±2 с против `tracks`. | | 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. | @@ -200,7 +200,7 @@ - Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`). - Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`): - «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути), - выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их + выбор файлов / папки; SHA-256 потоком в Web Worker (`js-sha256`); теги читает `music-metadata` и отдаёт их в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку. Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e429f0a..44f4cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,7 +122,7 @@ importers: version: 19.0.14 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) apps/web: dependencies: @@ -159,9 +159,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.14.21 version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - hash-wasm: - specifier: ^4.12.0 - version: 4.12.0 + js-sha256: + specifier: ^1.0.0 + version: 1.0.0 music-metadata: specifier: ^12.2.3 version: 12.2.3(supports-color@10.2.2) @@ -207,13 +207,13 @@ importers: version: 4.14.0 jsdom: specifier: ^26.1.0 - version: 26.1.0 + version: 26.1.0(supports-color@10.2.2) vite: specifier: ^6.3.5 version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) packages/api-client: dependencies: @@ -233,7 +233,7 @@ importers: devDependencies: vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) packages/design-tokens: {} @@ -2479,9 +2479,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - hash-wasm@4.12.0: - resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -6710,8 +6707,6 @@ snapshots: has-flag@4.0.0: {} - hash-wasm@4.12.0: {} - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -6748,7 +6743,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@10.2.2) @@ -6943,13 +6938,13 @@ snapshots: jsc-safe-url@0.2.4: {} - jsdom@26.1.0: + jsdom@26.1.0(supports-color@10.2.2): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 + http-proxy-agent: 7.0.2(supports-color@10.2.2) https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.28 @@ -8247,7 +8242,7 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): + vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.2.2) @@ -8268,7 +8263,7 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2): + vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.2.2) @@ -8317,7 +8312,7 @@ snapshots: lightningcss: 1.27.0 terser: 5.51.2 - vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): + vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -8340,11 +8335,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) - vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.5 - jsdom: 26.1.0 + jsdom: 26.1.0(supports-color@10.2.2) transitivePeerDependencies: - jiti - less @@ -8359,7 +8354,7 @@ snapshots: - tsx - yaml - vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2): + vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -8382,11 +8377,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) - vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) + vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.6.5 - jsdom: 26.1.0 + jsdom: 26.1.0(supports-color@10.2.2) transitivePeerDependencies: - jiti - less