5.4 KiB
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:
'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 calltemporaryUrl()(forprivate) orurl()(forpublic) when generating attachment URLs. Setting it to'public'with thelocaldisk prevents the package from callingtemporaryUrl(), 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:
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.anymiddleware — requires an active session on any guard. - Route model binding resolves
{attachment}toNamu\WireChat\Models\Attachment(configured inRouteServiceProvider).
Controller
app/Http/Controllers/WireChatAttachmentController.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():
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:
'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.