Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user