feat: Implement Secure R2 Storage with Private Bucket and Proxy Controller

This commit is contained in:
dyzulk
2026-01-04 18:07:22 +07:00
parent b4ea949bf2
commit 4dc4b3d498
5 changed files with 102 additions and 7 deletions

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\TicketAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class AttachmentController extends Controller
{
/**
* Download a private attachment.
*/
public function download(Request $request, TicketAttachment $attachment)
{
$user = $request->user();
// 1. Authorization Logic
// Load relationships needed for checking
$attachment->load(['reply.ticket']);
$ticket = $attachment->reply->ticket;
// Check if user is owner of the ticket OR is Admin/Owner
if ($ticket->user_id !== $user->id && !$user->isAdminOrOwner()) {
abort(403, 'Unauthorized access to this attachment.');
}
// 2. Fetch File from Private R2 Bucket
// We assume new uploads store 'relative/path.ext' in DB
// But legacy uploads stored 'https://cdn...'
$path = $attachment->file_path;
$disk = 'r2-private';
// Detect if it's a full public URL (Legacy) or Relative Path (New)
if (filter_var($path, FILTER_VALIDATE_URL)) {
// It's a URL. It might be on the Public Bucket (old uploads).
// Strategy: Redirect to public URL? Or try to serve it?
// Since legacy files are on Public Bucket, we can just redirect or return URL.
// But if specific requirement is "Attachment Private", we should ideally migrate them.
// For now, if it's a URL, we assume it's public and redirect.
return redirect($path);
}
// It is a relative path intended for Private Bucket
if (!Storage::disk($disk)->exists($path)) {
abort(404, 'File not found on secure storage.');
}
return Storage::disk($disk)->download($path, $attachment->file_name);
}
}

View File

@@ -82,12 +82,12 @@ class TicketController extends Controller
// Handle Attachments
if ($request->hasFile('attachments')) {
foreach ($request->file('attachments') as $file) {
$path = $file->store('ticket-attachments', 'r2');
$url = Storage::disk('r2')->url($path);
$path = $file->store('ticket-attachments', 'r2-private');
// $url = Storage::disk('r2')->url($path); // Removed public URL generation
TicketAttachment::create([
'ticket_reply_id' => $reply->id,
'file_name' => $file->getClientOriginalName(),
'file_path' => $url,
'file_path' => $path, // Store relative path
'file_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
]);
@@ -170,12 +170,12 @@ class TicketController extends Controller
// Handle Attachments
if ($request->hasFile('attachments')) {
foreach ($request->file('attachments') as $file) {
$path = $file->store('ticket-attachments', 'r2');
$url = Storage::disk('r2')->url($path);
$path = $file->store('ticket-attachments', 'r2-private');
// $url = Storage::disk('r2')->url($path);
TicketAttachment::create([
'ticket_reply_id' => $reply->id,
'file_name' => $file->getClientOriginalName(),
'file_path' => $url,
'file_path' => $path, // Store relative path
'file_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
]);

View File

@@ -22,4 +22,18 @@ class TicketAttachment extends Model
{
return $this->belongsTo(TicketReply::class, 'ticket_reply_id');
}
protected $appends = ['download_url'];
public function getDownloadUrlAttribute()
{
// Legacy: if it's already a full URL, return it
if (filter_var($this->file_path, FILTER_VALIDATE_URL)) {
return $this->file_path;
}
// Secure: return the API endpoint
// Assuming route prefix is /api (standard Laravel)
return url("/api/support/attachments/{$this->id}");
}
}