mirror of
https://github.com/dyzulk/trustlab-api.git
synced 2026-01-26 13:22:05 +07:00
feat: Implement Secure R2 Storage with Private Bucket and Proxy Controller
This commit is contained in:
53
app/Http/Controllers/Api/AttachmentController.php
Normal file
53
app/Http/Controllers/Api/AttachmentController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,12 +82,12 @@ class TicketController extends Controller
|
|||||||
// Handle Attachments
|
// Handle Attachments
|
||||||
if ($request->hasFile('attachments')) {
|
if ($request->hasFile('attachments')) {
|
||||||
foreach ($request->file('attachments') as $file) {
|
foreach ($request->file('attachments') as $file) {
|
||||||
$path = $file->store('ticket-attachments', 'r2');
|
$path = $file->store('ticket-attachments', 'r2-private');
|
||||||
$url = Storage::disk('r2')->url($path);
|
// $url = Storage::disk('r2')->url($path); // Removed public URL generation
|
||||||
TicketAttachment::create([
|
TicketAttachment::create([
|
||||||
'ticket_reply_id' => $reply->id,
|
'ticket_reply_id' => $reply->id,
|
||||||
'file_name' => $file->getClientOriginalName(),
|
'file_name' => $file->getClientOriginalName(),
|
||||||
'file_path' => $url,
|
'file_path' => $path, // Store relative path
|
||||||
'file_type' => $file->getClientMimeType(),
|
'file_type' => $file->getClientMimeType(),
|
||||||
'file_size' => $file->getSize(),
|
'file_size' => $file->getSize(),
|
||||||
]);
|
]);
|
||||||
@@ -170,12 +170,12 @@ class TicketController extends Controller
|
|||||||
// Handle Attachments
|
// Handle Attachments
|
||||||
if ($request->hasFile('attachments')) {
|
if ($request->hasFile('attachments')) {
|
||||||
foreach ($request->file('attachments') as $file) {
|
foreach ($request->file('attachments') as $file) {
|
||||||
$path = $file->store('ticket-attachments', 'r2');
|
$path = $file->store('ticket-attachments', 'r2-private');
|
||||||
$url = Storage::disk('r2')->url($path);
|
// $url = Storage::disk('r2')->url($path);
|
||||||
TicketAttachment::create([
|
TicketAttachment::create([
|
||||||
'ticket_reply_id' => $reply->id,
|
'ticket_reply_id' => $reply->id,
|
||||||
'file_name' => $file->getClientOriginalName(),
|
'file_name' => $file->getClientOriginalName(),
|
||||||
'file_path' => $url,
|
'file_path' => $path, // Store relative path
|
||||||
'file_type' => $file->getClientMimeType(),
|
'file_type' => $file->getClientMimeType(),
|
||||||
'file_size' => $file->getSize(),
|
'file_size' => $file->getSize(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -22,4 +22,18 @@ class TicketAttachment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(TicketReply::class, 'ticket_reply_id');
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,34 @@ return [
|
|||||||
'report' => false,
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
'r2' => [
|
|
||||||
|
'r2-public' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('R2_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => 'auto',
|
||||||
|
'bucket' => env('R2_BUCKET'),
|
||||||
|
'url' => env('R2_URL'),
|
||||||
|
'endpoint' => env('R2_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('R2_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'r2-private' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('R2_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => 'auto',
|
||||||
|
'bucket' => env('R2_PRIVATE_BUCKET'),
|
||||||
|
'endpoint' => env('R2_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('R2_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'visibility' => 'private',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'r2' => [ // Legacy alias pointing to r2-public
|
||||||
'driver' => 's3',
|
'driver' => 's3',
|
||||||
'key' => env('R2_ACCESS_KEY_ID'),
|
'key' => env('R2_ACCESS_KEY_ID'),
|
||||||
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ Route::middleware(['auth:sanctum'])->group(function () {
|
|||||||
Route::get('/support/tickets/{id}', [\App\Http\Controllers\Api\TicketController::class, 'show']);
|
Route::get('/support/tickets/{id}', [\App\Http\Controllers\Api\TicketController::class, 'show']);
|
||||||
Route::post('/support/tickets/{id}/reply', [\App\Http\Controllers\Api\TicketController::class, 'reply']);
|
Route::post('/support/tickets/{id}/reply', [\App\Http\Controllers\Api\TicketController::class, 'reply']);
|
||||||
Route::patch('/support/tickets/{id}/close', [\App\Http\Controllers\Api\TicketController::class, 'close']);
|
Route::patch('/support/tickets/{id}/close', [\App\Http\Controllers\Api\TicketController::class, 'close']);
|
||||||
|
Route::get('/support/attachments/{attachment}', [\App\Http\Controllers\Api\AttachmentController::class, 'download']);
|
||||||
|
|
||||||
// User Management (Admin Only)
|
// User Management (Admin Only)
|
||||||
Route::apiResource('/admin/users', UserApiController::class);
|
Route::apiResource('/admin/users', UserApiController::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user