diff --git a/Dockerfile b/Dockerfile index 67ffd25..4149b9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ RUN install-php-extensions \ intl \ opcache +COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini + COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app diff --git a/README.md b/README.md index 1bc8f82..50ff36f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https: ## Features -- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB) +- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB) - Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date) - Search recordings by title, artist, or transcript - Queued local transcription (faster-whisper in Docker) diff --git a/app/Http/Requests/StoreRecordingRequest.php b/app/Http/Requests/StoreRecordingRequest.php index 59b9c7b..526103e 100644 --- a/app/Http/Requests/StoreRecordingRequest.php +++ b/app/Http/Requests/StoreRecordingRequest.php @@ -30,6 +30,11 @@ class StoreRecordingRequest extends FormRequest 'aif', ]; + /** + * Maximum upload size accepted by validation (2 GiB). + */ + public const MAX_AUDIO_KILOBYTES = 2 * 1024 * 1024; + public function authorize(): bool { return true; @@ -54,7 +59,7 @@ class StoreRecordingRequest extends FormRequest 'audio' => ['required', 'array', 'min:1', 'max:50'], 'audio.*' => [ 'required', - File::types(self::AUDIO_EXTENSIONS)->max(102400), + File::types(self::AUDIO_EXTENSIONS)->max('2gb'), ], 'title' => ['nullable', 'string', 'max:255'], ]; @@ -71,7 +76,7 @@ class StoreRecordingRequest extends FormRequest 'audio.max' => 'You can upload at most 50 files at once.', 'audio.*.required' => 'Please choose an audio file to upload.', 'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.', - 'audio.*.max' => 'Each audio file may not be larger than 100 MB.', + 'audio.*.max' => 'Each audio file may not be larger than 2 GB.', ]; } } diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 0000000..bba989a --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,6 @@ +; PHP limits for large pocket-recorder uploads (up to 2 GB per file). +upload_max_filesize = 2G +post_max_size = 10G +memory_limit = 512M +max_execution_time = 3600 +max_input_time = 3600 diff --git a/resources/js/upload.js b/resources/js/upload.js index 2f24471..b96ee50 100644 --- a/resources/js/upload.js +++ b/resources/js/upload.js @@ -3,6 +3,8 @@ * before submitting the form. */ +const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024; + export function uploadDropzone({ existingFingerprints = [] } = {}) { const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif']; const known = new Set(existingFingerprints); @@ -50,7 +52,7 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) { continue; } - if (file.size > 100 * 1024 * 1024) { + if (file.size > MAX_FILE_BYTES) { skippedTooLarge++; continue; } @@ -80,7 +82,7 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) { if (skippedUnsupported > 0) { this.error = 'Skipped unsupported file type. Use common audio formats only.'; } else if (skippedTooLarge > 0) { - this.error = 'Skipped a file larger than 100 MB.'; + this.error = 'Skipped a file larger than 2 GB.'; } if (skippedDuplicates > 0) { @@ -153,7 +155,11 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) { return (bytes / 1024).toFixed(1) + ' KB'; } - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + if (bytes < 1024 * 1024 * 1024) { + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }, }; } diff --git a/resources/views/recordings/create.blade.php b/resources/views/recordings/create.blade.php index 12084d6..098d18b 100644 --- a/resources/views/recordings/create.blade.php +++ b/resources/views/recordings/create.blade.php @@ -35,7 +35,7 @@ >
Drop audio files here
or click to browse
-MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 100 MB each · up to 50 files
+MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
assertSame(1, Recording::query()->count()); Bus::assertNothingDispatched(); } + + public function test_stored_recording_persists_content_hash(): void + { + Storage::fake('local'); + Bus::fake(); + + $file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash'); + + $this->post(route('recordings.store'), [ + 'audio' => [$file], + ])->assertRedirect(); + + $recording = Recording::query()->first(); + $this->assertNotNull($recording); + $this->assertSame(hash('sha256', 'payload-for-hash'), $recording->content_hash); + } } diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index 7aa21a0..18143d3 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Http\Requests\StoreRecordingRequest; use App\Jobs\TranscribeRecording; use App\Models\Recording; use App\Services\TranscriptionService; @@ -87,9 +88,29 @@ class RecordingUploadTest extends TestCase $this->get(route('recordings.create')) ->assertOk() ->assertSee('Drop audio files here') + ->assertSee('max 2 GB each', false) ->assertSee('name="audio[]"', false); } + public function test_files_larger_than_two_gigabytes_are_rejected(): void + { + Storage::fake('local'); + Bus::fake(); + + $file = UploadedFile::fake() + ->create('huge.mp3', 10, 'audio/mpeg') + ->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1); + + $this->from(route('recordings.create')) + ->post(route('recordings.store'), [ + 'audio' => [$file], + ]) + ->assertSessionHasErrors(['audio.0']); + + $this->assertSame(0, Recording::query()->count()); + Bus::assertNothingDispatched(); + } + public function test_user_can_upload_wav_and_ogg(): void { Storage::fake('local');