Files
timebank-cc-public/references/WIRECHAT_PRIVATE_ATTACHMENTS.md

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 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:

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

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.