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 URLApp\Models\JobPostingPhoto::getSignedUrl()- usesStorage::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.)
Related Files
app/Models/JobPostingPhoto.phpapp/Actions/Job/UploadJobPhoto.phpconfig/filesystems.php(r2-private disk)