Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
# 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.