diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts index 6d8dcc2..3af6ad7 100644 --- a/apps/web/e2e/upload-review.spec.ts +++ b/apps/web/e2e/upload-review.spec.ts @@ -38,6 +38,15 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { const req = new Request(input, init); const url = new URL(req.url); if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null }); + if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') { + const body = await req.json(); + const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0)); + const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' })); + document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`; + document.documentElement.dataset.coverBytes = String(bytes.length); + image.close(); + return json({ items: [files[0], missing] }); + } if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') { const body = await req.json(); Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null }); @@ -59,11 +68,12 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source); } if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') { + if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400); if (url.searchParams.has('batch')) { document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1); return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' }); } - const statuses = url.searchParams.getAll('status'); + const statuses = url.searchParams.get('status')?.split(',') ?? []; const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status)); return json({ items: visible, next_cursor: 'more' }); } @@ -253,3 +263,21 @@ test('starting another queue preview pauses the previous track', async ({ page } await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click(); await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true); }); + +test('oversized album cover is reduced before saving the batch', async ({ page }) => { + await setup(page, 'queue'); + const png = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + canvas.width = 3000; canvas.height = 1800; + canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/png').split(',')[1]!; + }); + // A valid image with trailing padding reproduces an oversized input without a large fixture in Git. + const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]); + await page.getByRole('button', { name: 'На всю пачку', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover }); + await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click(); + await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229'); + expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024); +}); diff --git a/apps/web/src/features/uploads/BatchEditDialog.tsx b/apps/web/src/features/uploads/BatchEditDialog.tsx index 4816064..6b7b7c3 100644 --- a/apps/web/src/features/uploads/BatchEditDialog.tsx +++ b/apps/web/src/features/uploads/BatchEditDialog.tsx @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; import { useErrorText } from '../../shared/lib/errors'; import { useUploadsApi } from './queries'; +import { coverBase64 } from './files'; import { useTranslation } from 'react-i18next'; import { Button, Dialog, Field, FormError } from '../../shared/ui/ui'; import s from '../../pages/page.module.css'; @@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string const [album, setAlbum] = useState(''); const [year, setYear] = useState(''); const [cover, setCover] = useState(undefined); + const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover }); const save = useMutation({ mutationFn: () => api.updateBatch(batchId, { ...(artist.trim() ? { artist: artist.trim() } : {}), @@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string onSuccess: onSaved, }); const pickCover = (file: File | undefined) => { + save.reset(); + prepare.reset(); + setCover(undefined); if (!file) { - setCover(undefined); return; } - const reader = new FileReader(); - reader.onload = () => { - const data = String(reader.result ?? ''); - const comma = data.indexOf(','); - setCover(comma >= 0 ? data.slice(comma + 1) : data); - }; - reader.readAsDataURL(file); + prepare.mutate(file); }; return ( {t('common:form.cancel')} - @@ -57,12 +55,12 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string - - {save.error ? errorText(save.error) : null} + {(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null} ); diff --git a/apps/web/src/features/uploads/api.test.ts b/apps/web/src/features/uploads/api.test.ts new file mode 100644 index 0000000..56d16db --- /dev/null +++ b/apps/web/src/features/uploads/api.test.ts @@ -0,0 +1,20 @@ +import { createApiClient } from '@pipu/api-client'; +import { describe, expect, it } from 'vitest'; + +describe('upload status query serialization', () => { + it('sends comma-separated statuses for own and admin queues, including batch selection', async () => { + const urls: URL[] = []; + const client = createApiClient({ baseUrl: 'https://pipu.test', fetch: async (input) => { + urls.push(new URL((input as Request).url)); + return new Response(JSON.stringify({ items: [], next_cursor: null }), { headers: { 'Content-Type': 'application/json' } }); + } }); + await client.GET('/api/v1/uploads/mine', { params: { query: { status: ['pending_review', 'imported', 'expired'], limit: 50 } } }); + await client.GET('/api/v1/admin/uploads', { params: { query: { status: ['uploading', 'processing'], limit: 100 } } }); + await client.GET('/api/v1/uploads/mine', { params: { query: { batch: '00000000-0000-4000-8000-000000000001', status: ['uploading', 'processing', 'pending_review'] } } }); + expect(urls.map((url) => url.searchParams.getAll('status'))).toEqual([ + ['pending_review,imported,expired'], ['uploading,processing'], ['uploading,processing,pending_review'], + ]); + expect(urls[0]!.searchParams.get('limit')).toBe('50'); + expect(urls[2]!.searchParams.get('batch')).toBe('00000000-0000-4000-8000-000000000001'); + }); +}); diff --git a/apps/web/src/features/uploads/files.ts b/apps/web/src/features/uploads/files.ts index dc2b2a5..ec4244f 100644 --- a/apps/web/src/features/uploads/files.ts +++ b/apps/web/src/features/uploads/files.ts @@ -1,4 +1,31 @@ // Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5). +import { ApiError } from '@pipu/core'; + +const MAX_COVER_BYTES = 8 * 1024 * 1024; + +/** Reduce oversized artwork before base64 expansion; small covers keep their original bytes. */ +async function prepareCover(file: File): Promise { + if (file.size <= MAX_COVER_BYTES) return file; + let image: ImageBitmap; + try { image = await createImageBitmap(file); } + catch { throw new ApiError(422, 'validation.cover_type'); } + try { + const scale = Math.min(1, 2048 / Math.max(image.width, image.height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(image.width * scale)); + canvas.height = Math.max(1, Math.round(image.height * scale)); + const context = canvas.getContext('2d'); + if (!context) throw new ApiError(422, 'validation.cover_type'); + context.fillStyle = '#fff'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + for (const quality of [0.9, 0.75]) { + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality)); + if (blob && blob.size <= MAX_COVER_BYTES) return blob; + } + throw new ApiError(422, 'validation.cover_too_large'); + } finally { image.close(); } +} export interface PickedFile { file: File; @@ -40,12 +67,13 @@ export function albumFolder(relPath: string): string { return at < 0 ? '' : path.slice(0, at); } -export function coverBase64(file: File): Promise { +export async function coverBase64(file: File): Promise { + const prepared = await prepareCover(file); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]!); reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); + reader.readAsDataURL(prepared); }); } diff --git a/docs/uploads.md b/docs/uploads.md index 32485a2..c70ada3 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -91,6 +91,9 @@ в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио. + Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей + перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки. + Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса. ## 6. Обработка (`processing`) diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 523c488..a8e5c52 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -15,5 +15,10 @@ export interface ApiClientOptions { /** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */ export function createApiClient(opts: ApiClientOptions = {}): ApiClient { - return createClient({ baseUrl: opts.baseUrl ?? '', fetch: opts.fetch }); + return createClient({ + baseUrl: opts.baseUrl ?? '', + fetch: opts.fetch, + // OpenAPI query arrays (UploadStatusFilter) use form + explode: false. + querySerializer: { array: { style: 'form', explode: false } }, + }); } diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 3130c44..781161e 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -83,6 +83,15 @@ describe('AuthSession', () => { expect(states).toEqual(['signed_in']); }); + it('reports proxy HTML 413 as a request size error', async () => { + const s = server({ 'POST /api/v1/auth/login': () => new Response('Request Entity Too Large', { status: 413 }) }); + const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' }); + const error = await auth.login('alice', 'password').catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(413); + expect((error as ApiError).code).toBe('common.request_too_large'); + }); + it('turns problem+json into ApiError with field errors', async () => { const s = server({ 'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'), diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 765d187..302988e 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -33,7 +33,7 @@ export function toApiError(error: unknown, response?: Response): ApiError { if (typeof p.code === 'string') { return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []); } - if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request'); + if (response) return new ApiError(response.status, response.status === 413 ? 'common.request_too_large' : response.status >= 500 ? 'common.internal' : 'common.bad_request'); return new ApiError(0, 'common.network'); } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 8866400..2814a43 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -2,6 +2,7 @@ "common": { "internal": "Something went wrong on the server. Please try again", "bad_request": "Invalid request", + "request_too_large": "The request exceeds the server size limit. Reduce the amount of data being sent.", "not_found": "Not found", "method_not_allowed": "This action is not supported", "network": "Cannot reach the server. Check your network" @@ -26,6 +27,8 @@ "forbidden": "You don't have permission to do this" }, "validation": { + "cover_type": "Could not read the cover. Choose a JPEG, PNG or WebP image.", + "cover_too_large": "Could not reduce the cover below 8 MB. Choose a smaller image.", "user_archive_invalid": "Invalid user file: check the version, usernames, roles and password hashes. No changes were saved.", "password_too_short": "The password must be at least {{min}} characters", "password_too_long": "The password must be at most {{max}} characters", diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 976e855..8cca12b 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -2,6 +2,7 @@ "common": { "internal": "Что-то пошло не так на сервере. Попробуйте ещё раз", "bad_request": "Некорректный запрос", + "request_too_large": "Размер запроса превышает лимит сервера. Уменьшите объём отправляемых данных.", "not_found": "Не найдено", "method_not_allowed": "Действие не поддерживается", "network": "Нет связи с сервером. Проверьте сеть" @@ -26,6 +27,8 @@ "forbidden": "Недостаточно прав для этого действия" }, "validation": { + "cover_type": "Не удалось прочитать обложку. Выберите изображение JPEG, PNG или WebP.", + "cover_too_large": "Не удалось уменьшить обложку до 8 МБ. Выберите изображение меньшего размера.", "user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.", "password_too_short": "Пароль должен быть не короче {{min}} символов", "password_too_long": "Пароль должен быть не длиннее {{max}} символов", diff --git a/services/api/internal/uploads/query_test.go b/services/api/internal/uploads/query_test.go new file mode 100644 index 0000000..3644bd8 --- /dev/null +++ b/services/api/internal/uploads/query_test.go @@ -0,0 +1,38 @@ +package uploads + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "pipu/api/internal/api/gen" +) + +type uploadQueryHandler struct { + gen.Unimplemented + statuses []gen.UploadStatus +} + +func (h *uploadQueryHandler) ListMyUploads(w http.ResponseWriter, _ *http.Request, p gen.ListMyUploadsParams) { + h.statuses = *p.Status + w.WriteHeader(http.StatusNoContent) +} + +func (h *uploadQueryHandler) ListUploads(w http.ResponseWriter, _ *http.Request, p gen.ListUploadsParams) { + h.statuses = *p.Status + w.WriteHeader(http.StatusNoContent) +} + +func TestUploadFiltersBindCommaSeparatedStatuses(t *testing.T) { + for _, path := range []string{"/api/v1/uploads/mine", "/api/v1/admin/uploads"} { + t.Run(path, func(t *testing.T) { + h := &uploadQueryHandler{} + rec := httptest.NewRecorder() + gen.Handler(h).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path+"?status=pending_review,imported,expired&limit=50", nil)) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + require.Equal(t, []gen.UploadStatus{gen.UploadStatusPendingReview, gen.UploadStatusImported, gen.UploadStatusExpired}, h.statuses) + }) + } +}