132 lines
5.4 KiB
Markdown
132 lines
5.4 KiB
Markdown
# 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/<filename>`. 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.
|