# WireChat Private Attachment Serving ## Overview WireChat attachments (images, files, videos) are stored on the `local` disk, making them inaccessible via any public URL. They are served through an authenticated Laravel route that verifies conversation membership before delivering the file. ## Storage Configuration In `config/timebank_cc.php`: ```php 'attachments' => [ 'storage_folder' => 'attachments', 'storage_disk' => 'local', 'disk_visibility' => 'public', // 'local' disk ignores visibility; files are never web-accessible ... ], ``` Files are stored at `storage/app/attachments/`. The `public` disk (`storage/app/public/`) is not used, so no file is reachable via `storage/` symlink or any direct URL. > **Note on `disk_visibility`:** The WireChat package uses this value to decide whether to call `temporaryUrl()` (for `private`) or `url()` (for `public`) when generating attachment URLs. Setting it to `'public'` with the `local` disk prevents the package from calling `temporaryUrl()`, which the local driver does not support. The authenticated route below replaces URL generation entirely. ## Route Defined in `routes/web.php`, inside the `LaravelLocalization` group: ```php Route::get('/chat-attachments/{attachment}', [WireChatAttachmentController::class, 'serve']) ->middleware(['auth.any:admin,bank,organization,web']) ->name('wirechat.attachment.serve'); ``` - Locale-prefixed (e.g. `/nl/chat-attachments/3`). - Protected by `auth.any` middleware — requires an active session on any guard. - Route model binding resolves `{attachment}` to `Namu\WireChat\Models\Attachment` (configured in `RouteServiceProvider`). ## Controller `app/Http/Controllers/WireChatAttachmentController.php` ```php public function serve(Request $request, Attachment $attachment) { $profile = getActiveProfile(); abort_unless($profile !== null, 403); $message = $attachment->attachable; // polymorphic: WireChat Message $conversation = $message?->conversation; abort_unless($message !== null, 404); abort_unless($conversation !== null, 404); abort_unless($profile->belongsToConversation($conversation), 403); $disk = Storage::disk(WireChat::storageDisk()); abort_unless($disk->exists($attachment->file_path), 404); $absolutePath = $disk->path($attachment->file_path); $fileSize = filesize($absolutePath); return response()->stream(function () use ($absolutePath) { while (ob_get_level() > 0) { ob_end_clean(); // discard any stray middleware OB content } readfile($absolutePath); }, 200, [ 'Content-Type' => $attachment->mime_type, 'Content-Length' => $fileSize, 'Cache-Control' => 'private, max-age=3600', 'Accept-Ranges' => 'none', ]); } ``` ### Authorization checks (in order) | Check | Failure | |---|---| | Active profile exists in session | 403 | | Attachment belongs to a message | 404 | | Message belongs to a conversation | 404 | | Active profile is a participant in that conversation | 403 | | File exists on disk | 404 | ### Why `StreamedResponse` instead of `response()->file()` `BinaryFileResponse` (returned by `response()->file()`) writes to `php://output`, which respects PHP's active output buffers. During a normal request, middleware can leave content in an output buffer (e.g. 4 bytes of whitespace from a Blade evaluation). `BinaryFileResponse` then appends the file content to that buffered output and flushes everything together, prepending garbage bytes to the binary file. `StreamedResponse` with `ob_end_clean()` inside the callback clears all active OB levels at send-time, ensuring `readfile()` writes directly to stdout with no preceding content. ## View Templates The vendor WireChat partials are overridden in `resources/views/vendor/wirechat/` to use the authenticated route instead of `$attachment->url`: | Partial | Change | |---|---| | `chat/partials/image.blade.php` | `$attachmentUrl = route('wirechat.attachment.serve', $attachment)` used for all `src` attributes and the lightbox link | | `chat/partials/file.blade.php` | `href="{{ route('wirechat.attachment.serve', $attachment) }}"` on the download link | | `chat/partials/body.blade.php` | `source="{{ route('wirechat.attachment.serve', $attachment) }}"` on the video component | ## Route Model Binding `Namu\WireChat\Models\Attachment` lives outside the default `App\Models` namespace. An explicit binding is registered in `RouteServiceProvider::boot()`: ```php use Namu\WireChat\Models\Attachment as WireChatAttachment; Route::model('attachment', WireChatAttachment::class); ``` Without this, Laravel would attempt to resolve `App\Models\Attachment` and return 404 for every request. ## Debugbar Exclusion `config/debugbar.php` excludes the attachment route to prevent Debugbar from intercepting binary responses: ```php 'except' => [ 'telescope*', 'horizon*', '*/chat-attachments/*', ], ``` ## Security Properties - **No public URL exists** for any attachment file. - **Authentication required** — unauthenticated requests are redirected to login. - **Conversation membership enforced** — a user cannot access attachments from conversations they are not part of, even if they know the attachment ID. - **File existence verified** before serving — prevents path traversal or stale DB records causing errors. - **`Cache-Control: private`** — CDNs and shared caches will not store attachment responses.