diff --git a/.env.example b/.env.example index 5e5cf8d..b425773 100644 --- a/.env.example +++ b/.env.example @@ -96,3 +96,12 @@ WHISPER_HOST_PORT=8090 VITE_REVERB_HOST=localhost VITE_REVERB_SCHEME=http +# Demo user created on every container start (db:seed via entrypoint) +SEED_USER_NAME="Demo User" +SEED_USER_EMAIL=demo@example.com +SEED_USER_PASSWORD=password + +# Uncomment for Docker bind mounts + Vite HMR (no rebuild for PHP/Blade/CSS/JS): +# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml +# VITE_HOST_PORT=5173 + diff --git a/Dockerfile b/Dockerfile index 4149b9b..bb4e702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,17 @@ # syntax=docker/dockerfile:1 +FROM composer:2 AS vendor + +WORKDIR /app + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + FROM node:22-bookworm AS assets WORKDIR /app @@ -7,6 +19,8 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci +COPY --from=vendor /app/vendor ./vendor +COPY composer.json composer.lock ./ COPY vite.config.js ./ COPY resources ./resources COPY public ./public @@ -42,13 +56,8 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app +COPY --from=vendor /app/vendor ./vendor COPY composer.json composer.lock ./ -RUN composer install \ - --no-dev \ - --no-scripts \ - --no-autoloader \ - --prefer-dist \ - --no-interaction COPY . . COPY --from=assets /app/public/build ./public/build diff --git a/README.md b/README.md index 4b2368b..4949eba 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server). Audio never leaves your machine. -Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai). +Built with Laravel 13, Blade, Livewire, Flux UI, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai). ## Features +- User accounts with login and open registration (each user only sees their own recordings) - 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 @@ -79,10 +80,22 @@ On first start the app container will: - create `database/database.sqlite` if needed - run migrations +- seed a demo user (see below) - start FrankenPHP on port **8080** Whisper may take a minute or two while the model downloads. +### Demo login + +Every container start runs `db:seed`, which ensures this user exists: + +| Field | Default | +| --- | --- | +| Email | `demo@example.com` | +| Password | `password` | + +Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`. + ### 5. Open the app | URL | Purpose | @@ -110,6 +123,27 @@ docker compose up -d Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host. +### Live reload while developing + +Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR): + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d +``` + +Or set once in `.env`: + +```env +COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml +``` + +Then a normal `docker compose up -d` enables: + +- host source mounted at `/app` (PHP, Blade, routes, etc. without rebuild) +- `vite` on port **5173** for CSS/JS hot reload and Blade refresh +- `queue:listen` so worker code picks up changes between jobs + +Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`. ## Services and ports | Service | Host port | Role | @@ -118,6 +152,7 @@ Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the ho | `reverb` | `8081` | WebSockets for live transcription status | | `whisper` | `8090` | faster-whisper HTTP API | | `queue` | — | `php artisan queue:work` for transcription jobs | +| `vite` | `5173` | Vite HMR (dev overlay only) | ### Persistent data @@ -162,11 +197,14 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a ## Usage -1. Open **Recordings → Upload** and drop one or many audio files. -2. Transcription starts automatically (the `queue` service must be running). -3. Watch live progress on the list or detail page; stop or restart anytime. -4. Search by title, artist, or transcript text. -5. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list. +1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account. +2. Open **Recordings → Upload** and drop one or many audio files. +3. Transcription starts automatically (the `queue` service must be running). +4. Watch live progress on the list or detail page; stop or restart anytime. +5. Search by title, artist, or transcript text. +6. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list. + +The demo user is re-seeded on every container start. Any recordings with no owner are assigned to that demo user. Later registered users only see their own uploads. Finished transcripts are stored on each recording and are included in search. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..3c7c00c --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,33 @@ + $input + */ + public function create(array $input): User + { + Validator::make($input, [ + ...$this->profileRules(), + 'password' => $this->passwordRules(), + ])->validate(); + + return User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => $input['password'], + ]); + } +} diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..3678865 --- /dev/null +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,19 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..8fda5dd --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,29 @@ + $input + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => $input['password'], + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..4a0306d --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,35 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..62f58fa --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,61 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users')->ignore($user->id), + ], + ])->validateWithBag('updateProfileInformation'); + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/app/Concerns/PasswordValidationRules.php b/app/Concerns/PasswordValidationRules.php new file mode 100644 index 0000000..7409ed8 --- /dev/null +++ b/app/Concerns/PasswordValidationRules.php @@ -0,0 +1,29 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } + + /** + * Get the validation rules used to validate the current password. + * + * @return array|string> + */ + protected function currentPasswordRules(): array + { + return ['required', 'string', 'current_password']; + } +} diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php new file mode 100644 index 0000000..a9c069b --- /dev/null +++ b/app/Concerns/ProfileValidationRules.php @@ -0,0 +1,51 @@ +|string>> + */ + protected function profileRules(?int $userId = null): array + { + return [ + 'name' => $this->nameRules(), + 'email' => $this->emailRules($userId), + ]; + } + + /** + * Get the validation rules used to validate user names. + * + * @return array|string> + */ + protected function nameRules(): array + { + return ['required', 'string', 'max:255']; + } + + /** + * Get the validation rules used to validate user emails. + * + * @return array|string> + */ + protected function emailRules(?int $userId = null): array + { + return [ + 'required', + 'string', + 'email', + 'max:255', + $userId === null + ? Rule::unique(User::class) + : Rule::unique(User::class)->ignore($userId), + ]; + } +} diff --git a/app/Events/RecordingTranscriptionUpdated.php b/app/Events/RecordingTranscriptionUpdated.php index ad8206b..166b441 100644 --- a/app/Events/RecordingTranscriptionUpdated.php +++ b/app/Events/RecordingTranscriptionUpdated.php @@ -3,8 +3,8 @@ namespace App\Events; use App\Models\Recording; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -21,12 +21,12 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow /** * Get the channels the event should broadcast on. * - * @return array + * @return array */ public function broadcastOn(): array { return [ - new Channel('recordings'), + new PrivateChannel('recording.'.$this->recording->id), ]; } diff --git a/app/Http/Controllers/CancelTranscriptionController.php b/app/Http/Controllers/CancelTranscriptionController.php index 5bfd55e..d605b9c 100644 --- a/app/Http/Controllers/CancelTranscriptionController.php +++ b/app/Http/Controllers/CancelTranscriptionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Recording; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Gate; class CancelTranscriptionController extends Controller { @@ -12,6 +13,8 @@ class CancelTranscriptionController extends Controller */ public function __invoke(Recording $recording): RedirectResponse { + Gate::authorize('transcribe', $recording); + if (! $recording->isTranscribing()) { return back()->with('error', 'No transcription is currently running.'); } diff --git a/app/Http/Controllers/RecordingController.php b/app/Http/Controllers/RecordingController.php index 0fcf8e2..e2e4f53 100644 --- a/app/Http/Controllers/RecordingController.php +++ b/app/Http/Controllers/RecordingController.php @@ -8,6 +8,7 @@ use App\Services\Mp3MetadataService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; @@ -18,12 +19,14 @@ class RecordingController extends Controller */ public function index(Request $request): View { - Recording::query() + $user = $request->user(); + + $user->recordings() ->whereIn('transcription_status', ['pending', 'processing']) ->orderBy('id') ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); - $query = Recording::query()->latest(); + $query = $user->recordings()->latest(); if ($search = $request->string('q')->trim()->toString()) { $query->search($search); @@ -31,7 +34,7 @@ class RecordingController extends Controller $recordings = $query->paginate(20)->withQueryString(); - $pendingCount = Recording::query() + $pendingCount = $user->recordings() ->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) ->get() ->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob()) @@ -47,9 +50,9 @@ class RecordingController extends Controller /** * Show the upload form. */ - public function create(): View + public function create(Request $request): View { - $existingFingerprints = Recording::query() + $existingFingerprints = $request->user()->recordings() ->get(['original_filename', 'file_size_bytes']) ->map(fn (Recording $recording) => $this->uploadFingerprint( $recording->original_filename, @@ -75,6 +78,7 @@ class RecordingController extends Controller fn ($file) => $file instanceof UploadedFile, )); + $user = $request->user(); $titleOverride = $request->string('title')->trim()->toString(); $recordings = []; $skippedDuplicates = 0; @@ -89,8 +93,8 @@ class RecordingController extends Controller if ( isset($seenHashes[$hash]) - || Recording::query()->where('content_hash', $hash)->exists() - || Recording::query() + || $user->recordings()->where('content_hash', $hash)->exists() + || $user->recordings() ->where('original_filename', $file->getClientOriginalName()) ->where('file_size_bytes', $file->getSize() ?: 0) ->exists() @@ -106,7 +110,7 @@ class RecordingController extends Controller ? $titleOverride : null; - $recordings[] = $this->storeUploadedRecording($file, $metadata, $hash, $title); + $recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title); } if ($recordings === [] && $skippedDuplicates > 0) { @@ -149,6 +153,8 @@ class RecordingController extends Controller */ public function show(Recording $recording): View { + Gate::authorize('view', $recording); + $recording->recoverOrphanedTranscription(); $recording->refresh(); @@ -160,6 +166,8 @@ class RecordingController extends Controller */ public function destroy(Recording $recording): RedirectResponse { + Gate::authorize('delete', $recording); + $recording->deleteFile(); $recording->delete(); @@ -172,6 +180,7 @@ class RecordingController extends Controller * Persist a single uploaded audio file as a recording. */ private function storeUploadedRecording( + Request $request, UploadedFile $file, Mp3MetadataService $metadata, string $contentHash, @@ -185,6 +194,7 @@ class RecordingController extends Controller ?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); $recording = Recording::create([ + 'user_id' => $request->user()->id, 'title' => $title, 'original_filename' => $file->getClientOriginalName(), 'file_path' => $path, diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php index ee75190..996d7be 100644 --- a/app/Http/Controllers/TranscribeController.php +++ b/app/Http/Controllers/TranscribeController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Http\Requests\TranscribeRecordingRequest; use App\Models\Recording; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Gate; class TranscribeController extends Controller { @@ -15,6 +16,8 @@ class TranscribeController extends Controller */ public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse { + Gate::authorize('transcribe', $recording); + $recording->queueLocalTranscription(); return back()->with('success', 'Transcription started. Progress updates below.'); diff --git a/app/Http/Controllers/TranscribePendingController.php b/app/Http/Controllers/TranscribePendingController.php index 6c997a0..8dae6f2 100644 --- a/app/Http/Controllers/TranscribePendingController.php +++ b/app/Http/Controllers/TranscribePendingController.php @@ -4,17 +4,18 @@ namespace App\Http\Controllers; use App\Models\Recording; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; class TranscribePendingController extends Controller { /** * Queue local transcription for recordings that still need a transcript. */ - public function __invoke(): RedirectResponse + public function __invoke(Request $request): RedirectResponse { $queued = 0; - Recording::query() + $request->user()->recordings() ->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) ->orderBy('id') ->each(function (Recording $recording) use (&$queued): void { diff --git a/app/Http/Controllers/TranscriptionStatusController.php b/app/Http/Controllers/TranscriptionStatusController.php index 449d9c1..466c23c 100644 --- a/app/Http/Controllers/TranscriptionStatusController.php +++ b/app/Http/Controllers/TranscriptionStatusController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Recording; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Gate; class TranscriptionStatusController extends Controller { @@ -12,6 +13,8 @@ class TranscriptionStatusController extends Controller */ public function __invoke(Recording $recording): JsonResponse { + Gate::authorize('view', $recording); + $recording = $recording->fresh(); if ($recording->recoverOrphanedTranscription()) { diff --git a/app/Http/Requests/StoreRecordingRequest.php b/app/Http/Requests/StoreRecordingRequest.php index 526103e..1b3c10d 100644 --- a/app/Http/Requests/StoreRecordingRequest.php +++ b/app/Http/Requests/StoreRecordingRequest.php @@ -37,7 +37,7 @@ class StoreRecordingRequest extends FormRequest public function authorize(): bool { - return true; + return $this->user() !== null; } /** diff --git a/app/Http/Requests/TranscribeRecordingRequest.php b/app/Http/Requests/TranscribeRecordingRequest.php index 1bae41d..fb6d5b8 100644 --- a/app/Http/Requests/TranscribeRecordingRequest.php +++ b/app/Http/Requests/TranscribeRecordingRequest.php @@ -8,7 +8,9 @@ class TranscribeRecordingRequest extends FormRequest { public function authorize(): bool { - return true; + $recording = $this->route('recording'); + + return $recording !== null && $this->user()?->can('transcribe', $recording) === true; } /** diff --git a/app/Listeners/AssignOrphanedRecordings.php b/app/Listeners/AssignOrphanedRecordings.php new file mode 100644 index 0000000..74959c0 --- /dev/null +++ b/app/Listeners/AssignOrphanedRecordings.php @@ -0,0 +1,24 @@ +count() !== 1) { + return; + } + + Recording::query() + ->whereNull('user_id') + ->update(['user_id' => $event->user->id]); + } +} diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php new file mode 100644 index 0000000..457de43 --- /dev/null +++ b/app/Livewire/Actions/Logout.php @@ -0,0 +1,24 @@ +logout(); + + Session::invalidate(); + Session::regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Models/Recording.php b/app/Models/Recording.php index 5cd510b..06eeee8 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -21,6 +22,7 @@ class Recording extends Model * @var list */ protected $fillable = [ + 'user_id', 'title', 'original_filename', 'file_path', @@ -53,9 +55,18 @@ class Recording extends Model 'duration_seconds' => 'integer', 'file_size_bytes' => 'integer', 'transcription_percent' => 'integer', + 'user_id' => 'integer', ]; } + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + /** * Human-readable duration (m:ss). */ diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..77041e0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,14 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Str; #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] @@ -29,4 +30,24 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + /** + * @return HasMany + */ + public function recordings(): HasMany + { + return $this->hasMany(Recording::class); + } + + /** + * Initials for Flux avatar components. + */ + public function initials(): string + { + return Str::of($this->name) + ->explode(' ') + ->take(2) + ->map(fn (string $part) => Str::substr($part, 0, 1)) + ->implode(''); + } } diff --git a/app/Policies/RecordingPolicy.php b/app/Policies/RecordingPolicy.php new file mode 100644 index 0000000..d1f822b --- /dev/null +++ b/app/Policies/RecordingPolicy.php @@ -0,0 +1,57 @@ +user_id === $user->id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Recording $recording): bool + { + return $recording->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Recording $recording): bool + { + return $recording->user_id === $user->id; + } + + /** + * Determine whether the user can start or stop transcription. + */ + public function transcribe(User $user, Recording $recording): bool + { + return $recording->user_id === $user->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..f3a6e59 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,11 @@ namespace App\Providers; +use App\Listeners\AssignOrphanedRecordings; +use Illuminate\Auth\Events\Registered; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Illuminate\Validation\Rules\Password; class AppServiceProvider extends ServiceProvider { @@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Password::defaults(fn () => Password::min(8)); + + Event::listen(Registered::class, AssignOrphanedRecordings::class); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..7e0f28c --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,65 @@ +configureActions(); + $this->configureViews(); + $this->configureRateLimiting(); + } + + /** + * Configure Fortify actions. + */ + private function configureActions(): void + { + Fortify::createUsersUsing(CreateNewUser::class); + Fortify::resetUserPasswordsUsing(ResetUserPassword::class); + } + + /** + * Configure Fortify views. + */ + private function configureViews(): void + { + Fortify::loginView(fn () => view('pages::auth.login')); + Fortify::registerView(fn () => view('pages::auth.register')); + Fortify::resetPasswordView(fn () => view('pages::auth.reset-password')); + Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password')); + } + + /** + * Configure rate limiting. + */ + private function configureRateLimiting(): void + { + RateLimiter::for('login', function (Request $request) { + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); + + return Limit::perMinute(5)->by($throttleKey); + }); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 99719a4..159f8f2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,7 +13,8 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->redirectGuestsTo(fn () => route('login')); + $middleware->redirectUsersTo(fn () => route('recordings.index')); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..5ffd769 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,9 @@ =7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -490,6 +595,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -1528,6 +1681,70 @@ }, "time": "2026-08-06T13:39:04+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "c04ca2998631e1816f2e94a78b668f3a59b3962d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/c04ca2998631e1816f2e94a78b668f3a59b3962d", + "reference": "c04ca2998631e1816f2e94a78b668f3a59b3962d", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.2.6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-08-07T14:07:45+00:00" + }, { "name": "laravel/framework", "version": "v13.25.0", @@ -1755,6 +1972,74 @@ }, "time": "2026-08-11T13:56:22+00:00" }, + { + "name": "laravel/passkeys", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.22", @@ -2582,6 +2867,148 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "livewire/flux", + "version": "v2.16.0", + "source": { + "type": "git", + "url": "https://github.com/livewire/flux.git", + "reference": "b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/flux/zipball/b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8", + "reference": "b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1|^0.2|^0.3", + "livewire/livewire": "^3.7.4|^4.0|dev-main", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "livewire/blaze": "<1.0.0-beta.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flux": "Flux\\Flux" + }, + "providers": [ + "Flux\\FluxServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Flux\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "The official UI component library for Livewire.", + "keywords": [ + "components", + "flux", + "laravel", + "livewire", + "ui" + ], + "support": { + "issues": "https://github.com/livewire/flux/issues", + "source": "https://github.com/livewire/flux/tree/v2.16.0" + }, + "time": "2026-08-09T17:23:35+00:00" + }, + { + "name": "livewire/livewire", + "version": "v4.4.0", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "514b29d5a23594d4e4846494f580268b20c2f11e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e", + "reference": "514b29d5a23594d4e4846494f580268b20c2f11e", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v4.4.0" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2026-08-10T15:24:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3158,6 +3585,251 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3233,6 +3905,105 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -4574,6 +5345,186 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-15T18:56:27+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-06T16:21:11+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -5717,6 +6668,90 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-iconv": "*" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.41.0", @@ -6523,6 +7558,173 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:40:56+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", + "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T15:02:39+00:00" + }, { "name": "symfony/routing", "version": "v8.1.2", @@ -6603,6 +7805,105 @@ ], "time": "2026-07-22T15:42:13+00:00" }, + { + "name": "symfony/serializer", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ec3ae778e49a4cee5b937a779056fa23c3e834fb", + "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-06T09:53:08+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.1", @@ -6955,6 +8256,88 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/uid", "version": "v8.1.4", @@ -7332,6 +8715,229 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:19:49+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.5", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-31T15:00:08+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000..bd7b515 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,167 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/recordings', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings configure Fortify's passkey (WebAuthn) support. Passkeys + | allow users to sign in without needing to remember credentials since + | they use public-key cryptography - making them immune to breaches. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'timeout' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + ], + +]; diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..e6e0e73 --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,282 @@ + [ + resource_path('views/components'), + resource_path('views/livewire'), + ], + + /* + |--------------------------------------------------------------------------- + | Component Namespaces + |--------------------------------------------------------------------------- + | + | This value sets default namespaces that will be used to resolve view-based + | components like single-file and multi-file components. These folders'll + | also be referenced when creating new components via the make command. + | + */ + + 'component_namespaces' => [ + 'layouts' => resource_path('views/layouts'), + 'pages' => resource_path('views/pages'), + ], + + /* + |--------------------------------------------------------------------------- + | Page Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component as + | an entire page via `Route::livewire('/post/create', 'pages::create-post')`. + | In this case, the content of pages::create-post will render into $slot. + | + */ + + 'component_layout' => 'layouts::app.header', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'component_placeholder' => null, // Example: 'placeholders::skeleton' + + /* + |--------------------------------------------------------------------------- + | Make Command + |--------------------------------------------------------------------------- + | This value determines the default configuration for the artisan make command + | You can configure the component type (sfc, mfc, class) and whether to use + | the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names. + | + */ + + 'make_command' => [ + 'type' => 'sfc', // Options: 'sfc', 'mfc', 'class' + 'emoji' => true, // Options: true, false + 'with' => [ + 'js' => false, + 'css' => false, + 'test' => false, + ], + ], + + /* + |--------------------------------------------------------------------------- + | Class Namespace + |--------------------------------------------------------------------------- + | + | This value sets the root class namespace for Livewire component classes in + | your application. This value will change where component auto-discovery + | finds components. It's also referenced by the file creation commands. + | + */ + + 'class_namespace' => 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | Class Path + |--------------------------------------------------------------------------- + | + | This value is used to specify the path where Livewire component class files + | are created when running creation commands like `artisan make:livewire`. + | This path is customizable to match your projects directory structure. + | + */ + + 'class_path' => app_path('Livewire'), + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' + 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', + + /* + |--------------------------------------------------------------------------- + | CSP Safe + |--------------------------------------------------------------------------- + | + | This config is used to determine if Livewire will use the CSP-safe version + | of Alpine in its bundle. This is useful for applications that are using + | strict Content Security Policy (CSP) to protect against XSS attacks. + | + */ + + 'csp_safe' => false, + + /* + |--------------------------------------------------------------------------- + | Payload Guards + |--------------------------------------------------------------------------- + | + | These settings protect against malicious or oversized payloads that could + | cause denial of service. The default values should feel reasonable for + | most web applications. Each can be set to null to disable the limit. + | + */ + + 'payload' => [ + 'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes + 'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths + 'max_calls' => 50, // Maximum method calls per request + 'max_components' => 200, // Maximum components per batch request + ], +]; diff --git a/database/migrations/2026_08_12_160100_add_two_factor_columns_to_users_table.php b/database/migrations/2026_08_12_160100_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..45739ef --- /dev/null +++ b/database/migrations/2026_08_12_160100_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_12_160357_add_user_id_to_recordings_table.php b/database/migrations/2026_08_12_160357_add_user_id_to_recordings_table.php new file mode 100644 index 0000000..f7a84cd --- /dev/null +++ b/database/migrations/2026_08_12_160357_add_user_id_to_recordings_table.php @@ -0,0 +1,34 @@ +foreignId('user_id') + ->nullable() + ->after('id') + ->constrained() + ->nullOnDelete(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('recordings', function (Blueprint $table) { + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..62e06ad 100755 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Recording; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -15,11 +16,21 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + $email = (string) env('SEED_USER_EMAIL', 'demo@example.com'); + $name = (string) env('SEED_USER_NAME', 'Demo User'); + $password = (string) env('SEED_USER_PASSWORD', 'password'); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $user = User::query()->updateOrCreate( + ['email' => $email], + [ + 'name' => $name, + 'password' => $password, + 'email_verified_at' => now(), + ], + ); + + Recording::query() + ->whereNull('user_id') + ->update(['user_id' => $user->id]); } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d6a747f --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,58 @@ +# Local development overlay: bind-mount source + Vite HMR. +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d +# Or set in .env: +# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml + +services: + app: + environment: + KEEP_VITE_HOT: "true" + volumes: + - .:/app + depends_on: + vite: + condition: service_started + + queue: + # Reload PHP between jobs so code mounts take effect without restarting. + command: + - php + - artisan + - queue:listen + - database + - --sleep=1 + - --tries=1 + - --timeout=${TRANSCRIPTION_TIMEOUT:-600} + environment: + KEEP_VITE_HOT: "true" + volumes: + - .:/app + + reverb: + environment: + KEEP_VITE_HOT: "true" + volumes: + - .:/app + + vite: + image: node:22-bookworm + container_name: andytranscribe-vite + working_dir: /app + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173" + ports: + - "${VITE_HOST_PORT:-5173}:5173" + environment: + VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe} + VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key} + VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} + VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081} + VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http} + VITE_USE_POLLING: "true" + volumes: + - .:/app + - vite-node-modules:/app/node_modules + restart: unless-stopped + +volumes: + vite-node-modules: diff --git a/docker-compose.yml b/docker-compose.yml index ba8363c..a11f7c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,9 @@ x-app-env: &app-env LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base} TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600} DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660} + SEED_USER_NAME: ${SEED_USER_NAME:-Demo User} + SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com} + SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password} services: app: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ad779d5..2c4cf57 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,8 +17,11 @@ if [ ! -f database/database.sqlite ]; then touch database/database.sqlite fi -# Never use a host Vite HMR file inside the container image. -rm -f public/hot +# Production images should not honor a leftover Vite HMR file from the host. +# Development bind mounts keep public/hot so the Vite container can drive assets. +if [ "${KEEP_VITE_HOT:-false}" != "true" ]; then + rm -f public/hot +fi # Bind mounts may arrive as root-owned; keep the app and host tooling writable. chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true @@ -28,6 +31,12 @@ if [ -z "${APP_KEY:-}" ]; then exit 1 fi +# Bind-mounted trees may lack vendor/ (image files are shadowed by the mount). +if [ ! -f vendor/autoload.php ]; then + composer install --prefer-dist --no-interaction +fi + php artisan migrate --force --no-interaction +php artisan db:seed --force --no-interaction exec "$@" diff --git a/docker/php.ini b/docker/php.ini index bba989a..3839c16 100644 --- a/docker/php.ini +++ b/docker/php.ini @@ -4,3 +4,7 @@ post_max_size = 10G memory_limit = 512M max_execution_time = 3600 max_input_time = 3600 + +; Pick up bind-mounted PHP changes without restarting FrankenPHP. +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 diff --git a/resources/css/app.css b/resources/css/app.css index f4eaaa2..406abdf 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,8 @@ @import 'tailwindcss'; +@import '../../vendor/livewire/flux/dist/flux.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../vendor/livewire/flux/stubs/**/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../views'; @source '../js'; @@ -8,4 +10,9 @@ @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --color-accent: var(--color-teal-700); + --color-accent-content: var(--color-teal-700); + --color-accent-foreground: var(--color-white); } + +@custom-variant dark (&:where(.dark, .dark *)); diff --git a/resources/js/app.js b/resources/js/app.js index 8a519c4..2f9ea71 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,11 +1,7 @@ -import Alpine from 'alpinejs'; import './echo'; import { recordingsIndex, transcriptionMonitor } from './transcription'; import { uploadDropzone } from './upload'; -window.Alpine = Alpine; window.transcriptionMonitor = transcriptionMonitor; window.recordingsIndex = recordingsIndex; window.uploadDropzone = uploadDropzone; - -Alpine.start(); diff --git a/resources/js/echo.js b/resources/js/echo.js index 9349afa..65b8ad9 100644 --- a/resources/js/echo.js +++ b/resources/js/echo.js @@ -11,4 +11,10 @@ window.Echo = new Echo({ wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], + authEndpoint: '/broadcasting/auth', + auth: { + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'), + }, + }, }); diff --git a/resources/js/transcription.js b/resources/js/transcription.js index fed7bd4..0abed6c 100644 --- a/resources/js/transcription.js +++ b/resources/js/transcription.js @@ -3,15 +3,15 @@ */ const BADGE_CLASSES = { - done: 'bg-teal-50 text-teal-800 ring-teal-600/20', - processing: 'bg-amber-50 text-amber-800 ring-amber-600/20', - pending: 'bg-amber-50 text-amber-800 ring-amber-600/20', - failed: 'bg-red-50 text-red-800 ring-red-600/20', - cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20', + done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', + processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', + pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', + failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', + cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', }; export function badgeClassFor(status) { - return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20'; + return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30'; } export function formatElapsed(seconds) { @@ -56,17 +56,26 @@ function formatWordCount(count) { return n > 0 ? n.toLocaleString() : '—'; } -function subscribeToRecordings(handler) { +function subscribeToRecording(recordingId, handler) { if (!window.Echo) { return () => {}; } - const channel = window.Echo.channel('recordings'); + const channelName = 'recording.' + recordingId; + const channel = window.Echo.private(channelName); channel.listen('.RecordingTranscriptionUpdated', handler); return () => { - window.Echo.leave('recordings'); + window.Echo.leave(channelName); + }; +} + +function subscribeToRecordings(recordingIds, handler) { + const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler)); + + return () => { + leaveFns.forEach((leave) => leave()); }; } @@ -94,7 +103,7 @@ export function transcriptionMonitor({ statusUrl, initial }) { }, start() { - this.leaveChannel = subscribeToRecordings((event) => { + this.leaveChannel = subscribeToRecording(this.status.id, (event) => { if (Number(event.id) !== Number(this.status.id)) { return; } @@ -202,7 +211,7 @@ export function recordingsIndex({ recordings, pendingCount }) { leaveChannel: null, start() { - this.leaveChannel = subscribeToRecordings((event) => { + this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => { this.applyPayload(event); }); }, diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php new file mode 100644 index 0000000..0adc3a2 --- /dev/null +++ b/resources/views/components/app-logo-icon.blade.php @@ -0,0 +1,8 @@ + + + diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php new file mode 100644 index 0000000..31c2688 --- /dev/null +++ b/resources/views/components/app-logo.blade.php @@ -0,0 +1,17 @@ +@props([ + 'sidebar' => false, +]) + +@if($sidebar) + + + + + +@else + + + + + +@endif diff --git a/resources/views/components/appearance-toggle.blade.php b/resources/views/components/appearance-toggle.blade.php new file mode 100644 index 0000000..1ac929b --- /dev/null +++ b/resources/views/components/appearance-toggle.blade.php @@ -0,0 +1,13 @@ + + + + + + + + + {{ __('Light') }} + {{ __('Dark') }} + {{ __('System') }} + + diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php new file mode 100644 index 0000000..e596a3f --- /dev/null +++ b/resources/views/components/auth-header.blade.php @@ -0,0 +1,9 @@ +@props([ + 'title', + 'description', +]) + +
+ {{ $title }} + {{ $description }} +
diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php new file mode 100644 index 0000000..98e0011 --- /dev/null +++ b/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,9 @@ +@props([ + 'status', +]) + +@if ($status) +
merge(['class' => 'font-medium text-sm text-green-600']) }}> + {{ $status }} +
+@endif diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php new file mode 100644 index 0000000..6eda49f --- /dev/null +++ b/resources/views/components/desktop-user-menu.blade.php @@ -0,0 +1,31 @@ +@auth + + + {{ auth()->user()->name }} + + + + +
+ +
+ {{ auth()->user()->name }} + {{ auth()->user()->email }} +
+
+ +
+ @csrf + + {{ __('Log out') }} + +
+
+
+@endauth diff --git a/resources/views/components/disk-space-bar.blade.php b/resources/views/components/disk-space-bar.blade.php index eb81405..94d5772 100644 --- a/resources/views/components/disk-space-bar.blade.php +++ b/resources/views/components/disk-space-bar.blade.php @@ -1,18 +1,18 @@ @php /** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */ @endphp -
+
-
- Disk space +
+ Disk space {{ $disk['free_human'] }} free - · + · {{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
- + - - - @yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) + @include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null]) - + -
-
- + +
+ + + AndyTranscribe - + + + + {{ __('Recordings') }} + + + {{ __('Upload') }} + + + + + + + + @auth + + @endauth
-
+ + + + + + AndyTranscribe + + + + + + + {{ __('Recordings') }} + + + {{ __('Upload') }} + + +
@if (session('success')) -
+
{{ session('success') }}
@endif @if (session('error')) -
+
{{ session('error') }}
@endif @if ($errors->any()) -
+
    @foreach ($errors->all() as $error)
  • {{ $error }}
  • @@ -49,5 +83,7 @@ @yield('content')
+ + @fluxScripts diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php new file mode 100644 index 0000000..a7f31da --- /dev/null +++ b/resources/views/layouts/app/header.blade.php @@ -0,0 +1,29 @@ + + + + @include('partials.head') + + + + + + +
+ + AndyTranscribe + + + + @auth + + @endauth +
+
+ +
+ {{ $slot }} +
+ + @fluxScripts + + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..7150091 --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php new file mode 100644 index 0000000..c5cf75a --- /dev/null +++ b/resources/views/layouts/auth/card.blade.php @@ -0,0 +1,33 @@ + + + + @include('partials.head') + + + + + @persist('toast') + + + + @endpersist + + @fluxScripts + + diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php new file mode 100644 index 0000000..fd066e1 --- /dev/null +++ b/resources/views/layouts/auth/simple.blade.php @@ -0,0 +1,27 @@ + + + + @include('partials.head') + + + + + + @fluxScripts + + diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php new file mode 100644 index 0000000..a25d1d6 --- /dev/null +++ b/resources/views/layouts/auth/split.blade.php @@ -0,0 +1,50 @@ + + + + @include('partials.head') + + +
+ + +
+ + @persist('toast') + + + + @endpersist + + @fluxScripts + + diff --git a/resources/views/pages/auth/confirm-password.blade.php b/resources/views/pages/auth/confirm-password.blade.php new file mode 100644 index 0000000..04fe30e --- /dev/null +++ b/resources/views/pages/auth/confirm-password.blade.php @@ -0,0 +1,38 @@ + +
+ + + + + {{-- @chisel-passkeys --}} + + {{-- @end-chisel-passkeys --}} + +
+ @csrf + + + + + {{ __('Confirm') }} + + +
+
diff --git a/resources/views/pages/auth/forgot-password.blade.php b/resources/views/pages/auth/forgot-password.blade.php new file mode 100644 index 0000000..5f1cdad --- /dev/null +++ b/resources/views/pages/auth/forgot-password.blade.php @@ -0,0 +1,31 @@ + +
+ + + + + +
+ @csrf + + + + + + {{ __('Email password reset link') }} + + + +
+ {{ __('Or, return to') }} + {{ __('log in') }} +
+
+
diff --git a/resources/views/pages/auth/login.blade.php b/resources/views/pages/auth/login.blade.php new file mode 100644 index 0000000..358889a --- /dev/null +++ b/resources/views/pages/auth/login.blade.php @@ -0,0 +1,53 @@ + +
+ + + + +
+ @csrf + + + +
+ + + @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @endif +
+ + + +
+ + {{ __('Log in') }} + +
+ + +
+ {{ __('Don\'t have an account?') }} + {{ __('Sign up') }} +
+
+
diff --git a/resources/views/pages/auth/register.blade.php b/resources/views/pages/auth/register.blade.php new file mode 100644 index 0000000..30ddb89 --- /dev/null +++ b/resources/views/pages/auth/register.blade.php @@ -0,0 +1,69 @@ + +
+ + + + + +
+ @csrf + + + + + + + + + + + + +
+ + {{ __('Create account') }} + +
+ + +
+ {{ __('Already have an account?') }} + {{ __('Log in') }} +
+
+
diff --git a/resources/views/pages/auth/reset-password.blade.php b/resources/views/pages/auth/reset-password.blade.php new file mode 100644 index 0000000..86001bd --- /dev/null +++ b/resources/views/pages/auth/reset-password.blade.php @@ -0,0 +1,54 @@ + +
+ + + + + +
+ @csrf + + + + + + + + + + + + +
+ + {{ __('Reset password') }} + +
+ +
+
diff --git a/resources/views/pages/auth/two-factor-challenge.blade.php b/resources/views/pages/auth/two-factor-challenge.blade.php new file mode 100644 index 0000000..febe7d5 --- /dev/null +++ b/resources/views/pages/auth/two-factor-challenge.blade.php @@ -0,0 +1,101 @@ + +
+
+
+ +
+ +
+ +
+ +
+ @csrf + +
+
+
+ +
+
+ +
+
+ +
+ + @error('recovery_code') + + {{ $message }} + + @enderror +
+ + + {{ __('Continue') }} + +
+ +
+ {{ __('or you can') }} +
+ {{ __('login using a recovery code') }} + {{ __('login using an authentication code') }} +
+
+
+
+
+
diff --git a/resources/views/pages/auth/verify-email.blade.php b/resources/views/pages/auth/verify-email.blade.php new file mode 100644 index 0000000..50e7b44 --- /dev/null +++ b/resources/views/pages/auth/verify-email.blade.php @@ -0,0 +1,29 @@ + +
+ + {{ __('Please verify your email address by clicking on the link we just emailed to you.') }} + + + @if (session('status') == 'verification-link-sent') + + {{ __('A new verification link has been sent to the email address you provided during registration.') }} + + @endif + +
+
+ @csrf + + {{ __('Resend verification email') }} + +
+ +
+ @csrf + + {{ __('Log out') }} + +
+
+
+
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php new file mode 100644 index 0000000..b23d7bd --- /dev/null +++ b/resources/views/partials/head.blade.php @@ -0,0 +1,12 @@ + + + + + + {{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }} + + + + +@vite(['resources/css/app.css', 'resources/js/app.js']) +@fluxAppearance diff --git a/resources/views/recordings/create.blade.php b/resources/views/recordings/create.blade.php index 098d18b..9955232 100644 --- a/resources/views/recordings/create.blade.php +++ b/resources/views/recordings/create.blade.php @@ -5,7 +5,7 @@ @section('content')

Upload recordings

-

+

Drop one or many pocket-recorder files. Embedded metadata is extracted when available. Duplicate files (same name and size, or identical content) are skipped.

@@ -17,12 +17,12 @@ enctype="multipart/form-data" x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))" @submit="ensureFilesSelected($event)" - class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm" + class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800" > @csrf
- +
-

Drop audio files here

-

or click to browse

-

MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files

+

Drop audio files here

+

or click to browse

+

MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files

-

+

-
-
    +
      -
    • +
-
+
Transcription stopped. Choose an engine above to start again.
-
+

Transcription failed

-

+

@@ -170,7 +170,7 @@

No transcript yet. Transcription starts automatically after upload, or use the button above.

diff --git a/routes/channels.php b/routes/channels.php index 50187d5..e9a5147 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -1,17 +1,12 @@ id === (int) $id; +Broadcast::channel('recording.{recordingId}', function (User $user, int $recordingId): bool { + return Recording::query() + ->whereKey($recordingId) + ->where('user_id', $user->id) + ->exists(); }); diff --git a/routes/web.php b/routes/web.php index a32d464..e4bd195 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,13 +7,16 @@ use App\Http\Controllers\TranscribePendingController; use App\Http\Controllers\TranscriptionStatusController; use Illuminate\Support\Facades\Route; -Route::redirect('/', '/recordings'); +Route::redirect('/', '/recordings')->name('home'); -Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); -Route::post('recordings/transcribe-pending', TranscribePendingController::class) - ->name('recordings.transcribe-pending'); -Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe'); -Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class) - ->name('recordings.transcribe.cancel'); -Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) - ->name('recordings.transcription-status'); +Route::middleware('auth')->group(function (): void { + Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); + Route::post('recordings/transcribe-pending', TranscribePendingController::class) + ->name('recordings.transcribe-pending'); + Route::post('recordings/{recording}/transcribe', TranscribeController::class) + ->name('recordings.transcribe'); + Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class) + ->name('recordings.transcribe.cancel'); + Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) + ->name('recordings.transcription-status'); +}); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..9d31eb5 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,109 @@ +get(route('recordings.index')) + ->assertRedirect(route('login')); + } + + public function test_login_page_is_shown(): void + { + $this->get(route('login')) + ->assertOk() + ->assertSee('Log in'); + } + + public function test_register_page_is_shown(): void + { + $this->get(route('register')) + ->assertOk() + ->assertSee('Create an account'); + } + + public function test_users_can_register_and_are_authenticated(): void + { + $response = $this->post(route('register.store'), [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertRedirect(route('recordings.index')); + $this->assertAuthenticated(); + $this->assertDatabaseHas('users', ['email' => 'ada@example.com']); + } + + public function test_users_can_log_in(): void + { + $user = User::factory()->create([ + 'email' => 'ben@example.com', + 'password' => 'password', + ]); + + $response = $this->post(route('login.store'), [ + 'email' => 'ben@example.com', + 'password' => 'password', + ]); + + $response->assertRedirect(route('recordings.index')); + $this->assertAuthenticatedAs($user); + } + + public function test_first_registered_user_claims_orphaned_recordings(): void + { + Recording::query()->create([ + 'title' => 'Orphan', + 'original_filename' => 'orphan.mp3', + 'file_path' => 'recordings/orphan.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + 'user_id' => null, + ]); + + $this->post(route('register.store'), [ + 'name' => 'First User', + 'email' => 'first@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertRedirect(route('recordings.index')); + + $user = User::query()->where('email', 'first@example.com')->first(); + $this->assertNotNull($user); + $this->assertSame($user->id, Recording::query()->first()->user_id); + } + + public function test_second_registered_user_does_not_claim_orphans(): void + { + User::factory()->create(); + + Recording::query()->create([ + 'title' => 'Still orphan', + 'original_filename' => 'still.mp3', + 'file_path' => 'recordings/still.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + 'user_id' => null, + ]); + + $this->post(route('register.store'), [ + 'name' => 'Second User', + 'email' => 'second@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ])->assertRedirect(route('recordings.index')); + + $this->assertNull(Recording::query()->where('title', 'Still orphan')->value('user_id')); + } +} diff --git a/tests/Feature/DemoUserSeederTest.php b/tests/Feature/DemoUserSeederTest.php new file mode 100644 index 0000000..1e17743 --- /dev/null +++ b/tests/Feature/DemoUserSeederTest.php @@ -0,0 +1,53 @@ +seed(DatabaseSeeder::class); + + $user = User::query()->where('email', 'demo@example.com')->first(); + + $this->assertNotNull($user); + $this->assertSame('Demo User', $user->name); + $this->assertTrue(Hash::check('password', $user->password)); + } + + public function test_seeder_is_idempotent(): void + { + $this->seed(DatabaseSeeder::class); + $this->seed(DatabaseSeeder::class); + + $this->assertSame(1, User::query()->where('email', 'demo@example.com')->count()); + } + + public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void + { + Recording::query()->create([ + 'title' => 'Orphan', + 'original_filename' => 'orphan.mp3', + 'file_path' => 'recordings/orphan.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + 'user_id' => null, + ]); + + $this->seed(DatabaseSeeder::class); + + $user = User::query()->where('email', 'demo@example.com')->first(); + + $this->assertNotNull($user); + $this->assertSame($user->id, Recording::query()->where('title', 'Orphan')->value('user_id')); + } +} diff --git a/tests/Feature/DiskSpaceTest.php b/tests/Feature/DiskSpaceTest.php index 8eef6a2..3a6e95e 100644 --- a/tests/Feature/DiskSpaceTest.php +++ b/tests/Feature/DiskSpaceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\User; use App\Services\DiskSpaceService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -11,6 +12,16 @@ class DiskSpaceTest extends TestCase { use RefreshDatabase; + protected User $user; + + protected function setUp(): void + { + parent::setUp(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user); + } + public function test_disk_space_service_returns_snapshot_for_storage_path(): void { Cache::flush(); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index c7677b6..dc7c1e9 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,17 +2,19 @@ namespace Tests\Feature; -// use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { /** - * A basic test example. + * Guests hitting / are sent toward recordings, then login. */ - public function test_the_application_returns_a_successful_response(): void + public function test_the_application_redirects_guests_to_login(): void { $this->get('/') ->assertRedirect('/recordings'); + + $this->get('/recordings') + ->assertRedirect(route('login')); } } diff --git a/tests/Feature/RecordingDuplicateUploadTest.php b/tests/Feature/RecordingDuplicateUploadTest.php index 42d9e67..a2a5224 100644 --- a/tests/Feature/RecordingDuplicateUploadTest.php +++ b/tests/Feature/RecordingDuplicateUploadTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Jobs\TranscribeRecording; use App\Models\Recording; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; @@ -14,6 +15,16 @@ class RecordingDuplicateUploadTest extends TestCase { use RefreshDatabase; + protected User $user; + + protected function setUp(): void + { + parent::setUp(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user); + } + public function test_duplicate_content_hash_is_skipped_on_upload(): void { Storage::fake('local'); @@ -62,6 +73,7 @@ class RecordingDuplicateUploadTest extends TestCase public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void { Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Existing', 'original_filename' => 'note.mp3', 'file_path' => 'recordings/note.mp3', @@ -82,6 +94,7 @@ class RecordingDuplicateUploadTest extends TestCase Bus::fake(); Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Legacy', 'original_filename' => 'legacy.mp3', 'file_path' => 'recordings/legacy.mp3', diff --git a/tests/Feature/RecordingOwnershipTest.php b/tests/Feature/RecordingOwnershipTest.php new file mode 100644 index 0000000..9a539e2 --- /dev/null +++ b/tests/Feature/RecordingOwnershipTest.php @@ -0,0 +1,83 @@ +create(); + $other = User::factory()->create(); + + Recording::query()->create([ + 'user_id' => $owner->id, + 'title' => 'Mine', + 'original_filename' => 'mine.mp3', + 'file_path' => 'recordings/mine.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'done', + ]); + + Recording::query()->create([ + 'user_id' => $other->id, + 'title' => 'Theirs', + 'original_filename' => 'theirs.mp3', + 'file_path' => 'recordings/theirs.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'done', + ]); + + $this->actingAs($owner) + ->get(route('recordings.index')) + ->assertOk() + ->assertSee('Mine') + ->assertDontSee('Theirs'); + } + + public function test_user_cannot_view_another_users_recording(): void + { + $owner = User::factory()->create(); + $intruder = User::factory()->create(); + + $recording = Recording::query()->create([ + 'user_id' => $owner->id, + 'title' => 'Private', + 'original_filename' => 'private.mp3', + 'file_path' => 'recordings/private.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'done', + ]); + + $this->actingAs($intruder) + ->get(route('recordings.show', $recording)) + ->assertForbidden(); + } + + public function test_user_cannot_delete_another_users_recording(): void + { + $owner = User::factory()->create(); + $intruder = User::factory()->create(); + + $recording = Recording::query()->create([ + 'user_id' => $owner->id, + 'title' => 'Keep me', + 'original_filename' => 'keep.mp3', + 'file_path' => 'recordings/keep.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'done', + ]); + + $this->actingAs($intruder) + ->delete(route('recordings.destroy', $recording)) + ->assertForbidden(); + + $this->assertDatabaseHas('recordings', ['id' => $recording->id]); + } +} diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index 18143d3..1a94733 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Http\Requests\StoreRecordingRequest; use App\Jobs\TranscribeRecording; use App\Models\Recording; +use App\Models\User; use App\Services\TranscriptionService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -17,9 +18,20 @@ class RecordingUploadTest extends TestCase { use RefreshDatabase; + protected User $user; + + protected function setUp(): void + { + parent::setUp(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user); + } + public function test_index_shows_recordings(): void { Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Pocket note', 'original_filename' => 'note.mp3', 'file_path' => 'recordings/note.mp3', @@ -150,6 +162,7 @@ class RecordingUploadTest extends TestCase public function test_user_can_queue_local_transcription(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Dictation', 'original_filename' => 'dictation.mp3', 'file_path' => 'recordings/dictation.mp3', @@ -173,6 +186,7 @@ class RecordingUploadTest extends TestCase public function test_transcription_status_endpoint_returns_progress(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Live status', 'original_filename' => 'live.mp3', 'file_path' => 'recordings/live.mp3', @@ -201,6 +215,7 @@ class RecordingUploadTest extends TestCase Transcription::fake(['Hello from the recorder.']); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Sample', 'original_filename' => 'sample.mp3', 'file_path' => 'recordings/sample.mp3', @@ -229,6 +244,7 @@ class RecordingUploadTest extends TestCase }); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Bad', 'original_filename' => 'bad.mp3', 'file_path' => 'recordings/bad.mp3', @@ -252,6 +268,7 @@ class RecordingUploadTest extends TestCase public function test_orphaned_processing_is_recovered_on_status_poll(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Stuck', 'original_filename' => 'stuck.mp3', 'file_path' => 'recordings/stuck.mp3', @@ -277,6 +294,7 @@ class RecordingUploadTest extends TestCase public function test_recent_processing_is_not_marked_orphaned(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Still working', 'original_filename' => 'working.mp3', 'file_path' => 'recordings/working.mp3', @@ -303,6 +321,7 @@ class RecordingUploadTest extends TestCase Transcription::fake(['Recovered transcript.']); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Restart me', 'original_filename' => 'restart.mp3', 'file_path' => 'recordings/restart.mp3', @@ -324,6 +343,7 @@ class RecordingUploadTest extends TestCase public function test_user_can_stop_an_active_transcription(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Stop me', 'original_filename' => 'stop.mp3', 'file_path' => 'recordings/stop.mp3', @@ -350,6 +370,7 @@ class RecordingUploadTest extends TestCase Transcription::fake(['Restarted transcript.']); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Restart me while busy', 'original_filename' => 'switch.mp3', 'file_path' => 'recordings/switch.mp3', @@ -377,6 +398,7 @@ class RecordingUploadTest extends TestCase Transcription::fake(['Should be ignored.']); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Ignore late job', 'original_filename' => 'ignore.mp3', 'file_path' => 'recordings/ignore.mp3', @@ -400,6 +422,7 @@ class RecordingUploadTest extends TestCase public function test_recordings_can_be_searched_by_transcript(): void { Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Office chat', 'original_filename' => 'office.mp3', 'file_path' => 'recordings/office.mp3', @@ -409,6 +432,7 @@ class RecordingUploadTest extends TestCase ]); Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Kitchen note', 'original_filename' => 'kitchen.mp3', 'file_path' => 'recordings/kitchen.mp3', @@ -429,6 +453,7 @@ class RecordingUploadTest extends TestCase Bus::fake(); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Keep me', 'original_filename' => 'keep.mp3', 'file_path' => 'recordings/keep.mp3', @@ -454,6 +479,7 @@ class RecordingUploadTest extends TestCase Bus::fake(); Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Needs work', 'original_filename' => 'needs.mp3', 'file_path' => 'recordings/needs.mp3', @@ -462,6 +488,7 @@ class RecordingUploadTest extends TestCase ]); Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Already done', 'original_filename' => 'done.mp3', 'file_path' => 'recordings/done.mp3', @@ -481,6 +508,7 @@ class RecordingUploadTest extends TestCase public function test_index_shows_human_status_labels(): void { Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Label check', 'original_filename' => 'label.mp3', 'file_path' => 'recordings/label.mp3', diff --git a/tests/Feature/TranscriptionBroadcastTest.php b/tests/Feature/TranscriptionBroadcastTest.php index 96830d4..8dd46ca 100644 --- a/tests/Feature/TranscriptionBroadcastTest.php +++ b/tests/Feature/TranscriptionBroadcastTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Events\RecordingTranscriptionUpdated; use App\Jobs\TranscribeRecording; use App\Models\Recording; +use App\Models\User; use App\Services\TranscriptionService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -16,11 +17,22 @@ class TranscriptionBroadcastTest extends TestCase { use RefreshDatabase; + protected User $user; + + protected function setUp(): void + { + parent::setUp(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user); + } + public function test_report_progress_broadcasts_transcription_updated(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Broadcast progress', 'original_filename' => 'progress.mp3', 'file_path' => 'recordings/progress.mp3', @@ -48,6 +60,7 @@ class TranscriptionBroadcastTest extends TestCase Transcription::fake(['Hello from the recorder.']); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Sample', 'original_filename' => 'sample.mp3', 'file_path' => 'recordings/sample.mp3', @@ -81,6 +94,7 @@ class TranscriptionBroadcastTest extends TestCase }); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Bad', 'original_filename' => 'bad.mp3', 'file_path' => 'recordings/bad.mp3', @@ -111,6 +125,7 @@ class TranscriptionBroadcastTest extends TestCase Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Cancel me', 'original_filename' => 'cancel.mp3', 'file_path' => 'recordings/cancel.mp3', @@ -128,9 +143,10 @@ class TranscriptionBroadcastTest extends TestCase }); } - public function test_broadcast_event_uses_public_recordings_channel(): void + public function test_broadcast_event_uses_private_recording_channel(): void { $recording = Recording::query()->create([ + 'user_id' => $this->user->id, 'title' => 'Channel', 'original_filename' => 'channel.mp3', 'file_path' => 'recordings/channel.mp3', @@ -141,6 +157,6 @@ class TranscriptionBroadcastTest extends TestCase $event = new RecordingTranscriptionUpdated($recording); $this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs()); - $this->assertSame('recordings', $event->broadcastOn()[0]->name); + $this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name); } } diff --git a/vite.config.js b/vite.config.js index 1fd66d5..4838998 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,8 +17,15 @@ export default defineConfig({ tailwindcss(), ], server: { + host: process.env.VITE_HOST || true, + port: Number(process.env.VITE_PORT || 5173), + strictPort: true, + hmr: { + host: process.env.VITE_HMR_HOST || 'localhost', + }, watch: { ignored: ['**/storage/framework/views/**'], + usePolling: process.env.VITE_USE_POLLING === 'true', }, }, });