Stu Mason
Stu Mason

Activity

StuMason/cleanconnect
TidyLinker.com
TypeScript
Issue Resolved

Issue #34 closed: Private R2 bucket images not displaying - implement Laravel proxy route

Problem

Job posting photos are stored in a private R2 bucket (r2-private), but the signed URLs generated by temporaryUrl() point to R2's internal S3-compatible endpoint (*.r2.cloudflarestorage.com), which is not publicly accessible.

Current behaviour: Images appear broken because the browser cannot access the internal R2 endpoint.

Example URL generated:

https://tidylinker-private.18626a53a22a2ce5377ae20f9adeda92.r2.cloudflarestorage.com/path/to/file.jpeg?X-Amz-Algorithm=...

This endpoint is for server-side API calls only, not browser access.

Affected Code

  • App\Models\JobPostingPhoto::getFileUrlAttribute() - generates the broken URL
  • App\Models\JobPostingPhoto::getSignedUrl() - uses Storage::disk('r2-private')->temporaryUrl()

Solution: Laravel Proxy Route

Implement a signed Laravel route that streams files from R2 through the application:

1. Create Controller

// app/Http/Controllers/PrivateFileController.php
class PrivateFileController extends Controller
{
    public function jobPhoto(Request $request, JobPostingPhoto $photo)
    {
        // Authorization: check user can view this job's photos
        // e.g., job owner, or cleaner who has quoted/been invited
        
        $stream = Storage::disk('r2-private')->readStream($photo->file_path);
        
        return response()->stream(
            fn () => fpassthru($stream),
            200,
            [
                'Content-Type' => Storage::disk('r2-private')->mimeType($photo->file_path),
                'Content-Disposition' => 'inline',
                'Cache-Control' => 'private, max-age=3600',
            ]
        );
    }
}

2. Add Signed Route

// routes/web.php
Route::get('/files/job-photos/{photo}', [PrivateFileController::class, 'jobPhoto'])
    ->name('files.job-photo')
    ->middleware(['signed']);

3. Update Model to Generate Signed Laravel URLs

// App\Models\JobPostingPhoto
public function getFileUrlAttribute(): string
{
    return URL::signedRoute('files.job-photo', ['photo' => $this->id], now()->addHour());
}

Benefits

  • Files remain truly private (only accessible through the app)
  • Authorization can be enforced per-request
  • URLs are signed and time-limited
  • Works with any S3-compatible storage without public access

Considerations

  • Slightly higher latency (request goes through Laravel)
  • Increased server bandwidth usage
  • Consider caching headers to reduce repeated fetches
  • May want to add authorization logic (job owner, quoted cleaners, etc.)
  • app/Models/JobPostingPhoto.php
  • app/Actions/Job/UploadJobPhoto.php
  • config/filesystems.php (r2-private disk)