Skip to content

Commit 5e12a4e

Browse files
fix: support gcs uploads under ubla
1 parent 68a67ea commit 5e12a4e

13 files changed

Lines changed: 910 additions & 41 deletions

File tree

ATTACHMENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,24 @@ ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx,txt
175175
176176
# Storage configuration
177177
FILESYSTEM_DISK=public
178+
179+
# Google Cloud Storage (optional)
180+
TODO_ATTACHMENTS_DISK=gcs
181+
GOOGLE_CLOUD_PROJECT_ID=your-project-id
182+
GOOGLE_CLOUD_STORAGE_BUCKET=your-bucket-name
183+
GOOGLE_CLOUD_STORAGE_ROOT=null
184+
GOOGLE_CLOUD_STORAGE_KEY_FILE=/absolute/path/to/service-account.json
185+
# or provide raw JSON via GOOGLE_CLOUD_STORAGE_KEY_JSON
186+
GOOGLE_CLOUD_STORAGE_VISIBILITY=public
187+
GOOGLE_CLOUD_STORAGE_URL=https://storage.googleapis.com/your-bucket-name
178188
```
179189

190+
### Google Cloud Storage Setup
191+
- **Service Account**: Create a service account with `Storage Object Admin` access and download its JSON key.
192+
- **Disk Selection**: Update `.env` to set `TODO_ATTACHMENTS_DISK=gcs` and provide the bucket credentials above.
193+
- **Public Access**: Ensure the bucket grants appropriate read access when using public visibility.
194+
- **Uniform Bucket-Level Access (UBLA)**: If UBLA is enabled on your bucket, leave object-level ACLs to GCS. The application avoids setting per-object ACLs automatically, so no additional configuration is needed.
195+
180196
### Dependencies
181197
- **Required**: Laravel Storage, GD extension, Lucide React (icons)
182198
- **Optional**: Intervention/Image (for better thumbnail quality)

app/Http/Controllers/TodoController.php

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Models\TodoChecklistItem;
99
use App\Models\User;
1010
use Illuminate\Http\Request;
11+
use Illuminate\Http\UploadedFile;
1112
use Illuminate\Support\Facades\Auth;
1213
use Illuminate\Support\Facades\DB;
1314
use Illuminate\Support\Facades\Storage;
@@ -191,6 +192,8 @@ public function store(Request $request)
191192

192193
// Handle file attachments
193194
if ($request->hasFile('attachments')) {
195+
$disk = $this->attachmentDisk();
196+
194197
foreach ($request->file('attachments') as $file) {
195198
$originalName = $file->getClientOriginalName();
196199
$mimeType = $file->getMimeType();
@@ -201,15 +204,20 @@ public function store(Request $request)
201204
$filePath = "todos/{$todo->id}/attachments/{$fileName}";
202205

203206
// Store the file
204-
Storage::disk('public')->put($filePath, file_get_contents($file));
207+
Storage::disk($disk)->putFileAs(
208+
"todos/{$todo->id}/attachments",
209+
$file,
210+
$fileName,
211+
$this->fsWriteOptions($disk)
212+
);
205213

206214
// Determine file type
207215
$type = TodoAttachment::determineType($mimeType);
208216

209217
// Generate thumbnail for images
210218
$thumbnailPath = null;
211219
if ($type === 'image') {
212-
$thumbnailPath = $this->generateThumbnail($filePath, $todo->id);
220+
$thumbnailPath = $this->generateThumbnail($file, $fileName, $todo->id, $disk);
213221
}
214222

215223
// Create attachment record
@@ -404,6 +412,8 @@ public function update(Request $request, Todo $todo)
404412

405413
// Handle new file attachments
406414
if ($request->hasFile('attachments')) {
415+
$disk = $this->attachmentDisk();
416+
407417
foreach ($request->file('attachments') as $file) {
408418
$originalName = $file->getClientOriginalName();
409419
$mimeType = $file->getMimeType();
@@ -414,15 +424,20 @@ public function update(Request $request, Todo $todo)
414424
$filePath = "todos/{$todo->id}/attachments/{$fileName}";
415425

416426
// Store the file
417-
Storage::disk('public')->put($filePath, file_get_contents($file));
427+
Storage::disk($disk)->putFileAs(
428+
"todos/{$todo->id}/attachments",
429+
$file,
430+
$fileName,
431+
$this->fsWriteOptions($disk)
432+
);
418433

419434
// Determine file type
420435
$type = TodoAttachment::determineType($mimeType);
421436

422437
// Generate thumbnail for images
423438
$thumbnailPath = null;
424439
if ($type === 'image') {
425-
$thumbnailPath = $this->generateThumbnail($filePath, $todo->id);
440+
$thumbnailPath = $this->generateThumbnail($file, $fileName, $todo->id, $disk);
426441
}
427442

428443
// Create attachment record
@@ -760,6 +775,7 @@ public function uploadAttachment(Request $request, Todo $todo)
760775
]);
761776

762777
$file = $request->file('file');
778+
$disk = $this->attachmentDisk();
763779
$originalName = $file->getClientOriginalName();
764780
$mimeType = $file->getMimeType();
765781
$fileSize = $file->getSize();
@@ -769,15 +785,20 @@ public function uploadAttachment(Request $request, Todo $todo)
769785
$filePath = "todos/{$todo->id}/attachments/{$fileName}";
770786

771787
// Store the file
772-
Storage::disk('public')->put($filePath, file_get_contents($file));
788+
Storage::disk($disk)->putFileAs(
789+
"todos/{$todo->id}/attachments",
790+
$file,
791+
$fileName,
792+
$this->fsWriteOptions($disk)
793+
);
773794

774795
// Determine file type
775796
$type = TodoAttachment::determineType($mimeType);
776797

777798
// Generate thumbnail for images
778799
$thumbnailPath = null;
779800
if ($type === 'image') {
780-
$thumbnailPath = $this->generateThumbnail($filePath, $todo->id);
801+
$thumbnailPath = $this->generateThumbnail($file, $fileName, $todo->id, $disk);
781802
}
782803

783804
// Create attachment record
@@ -812,12 +833,14 @@ public function deleteAttachment(TodoAttachment $attachment)
812833
}
813834

814835
// Delete files from storage
815-
if (Storage::disk('public')->exists($attachment->file_path)) {
816-
Storage::disk('public')->delete($attachment->file_path);
836+
$disk = $this->attachmentDisk();
837+
838+
if (Storage::disk($disk)->exists($attachment->file_path)) {
839+
Storage::disk($disk)->delete($attachment->file_path);
817840
}
818841

819-
if ($attachment->thumbnail_path && Storage::disk('public')->exists($attachment->thumbnail_path)) {
820-
Storage::disk('public')->delete($attachment->thumbnail_path);
842+
if ($attachment->thumbnail_path && Storage::disk($disk)->exists($attachment->thumbnail_path)) {
843+
Storage::disk($disk)->delete($attachment->thumbnail_path);
821844
}
822845

823846
// Delete attachment record
@@ -836,47 +859,55 @@ public function downloadAttachment(TodoAttachment $attachment)
836859
abort(403);
837860
}
838861

839-
if (! Storage::disk('public')->exists($attachment->file_path)) {
862+
$disk = $this->attachmentDisk();
863+
864+
if (! Storage::disk($disk)->exists($attachment->file_path)) {
840865
abort(404, 'File not found');
841866
}
842867

843-
$filePath = Storage::disk('public')->path($attachment->file_path);
844-
845-
return response()->download($filePath, $attachment->original_name);
868+
return Storage::disk($disk)->download($attachment->file_path, $attachment->original_name);
846869
}
847870

848871
/**
849872
* Generate thumbnail for image files.
850873
*/
851-
private function generateThumbnail(string $filePath, int $todoId): ?string
874+
private function generateThumbnail(UploadedFile $file, string $fileName, int $todoId, string $disk): ?string
852875
{
853876
try {
854-
$fullPath = Storage::disk('public')->path($filePath);
855-
$thumbnailFileName = 'thumb_'.basename($filePath);
877+
$thumbnailFileName = 'thumb_'.$fileName;
856878
$thumbnailPath = "todos/{$todoId}/thumbnails/{$thumbnailFileName}";
857-
$thumbnailFullPath = Storage::disk('public')->path($thumbnailPath);
858-
859-
// Create thumbnails directory if it doesn't exist
860-
$thumbnailDir = dirname($thumbnailFullPath);
861-
if (! is_dir($thumbnailDir)) {
862-
mkdir($thumbnailDir, 0755, true);
863-
}
879+
$temporaryThumbnail = tempnam(sys_get_temp_dir(), 'todo-thumb-');
864880

865881
// Create thumbnail using intervention/image if available, otherwise use basic PHP
866882
$imageClass = 'Intervention\\Image\\ImageManagerStatic';
867883
if (class_exists($imageClass)) {
868-
$image = $imageClass::make($fullPath);
884+
$image = $imageClass::make($file->getRealPath());
869885
$image->fit(200, 200, function ($constraint) {
870886
$constraint->upsize();
871887
});
872-
$image->save($thumbnailFullPath);
888+
$image->save($temporaryThumbnail);
873889
} else {
874890
// Fallback to basic thumbnail generation
875-
$this->createBasicThumbnail($fullPath, $thumbnailFullPath);
891+
$this->createBasicThumbnail($file->getRealPath(), $temporaryThumbnail);
892+
}
893+
894+
if (! file_exists($temporaryThumbnail)) {
895+
return null;
876896
}
877897

898+
Storage::disk($disk)->put(
899+
$thumbnailPath,
900+
file_get_contents($temporaryThumbnail),
901+
$this->fsWriteOptions($disk)
902+
);
903+
@unlink($temporaryThumbnail);
904+
878905
return $thumbnailPath;
879906
} catch (\Exception $e) {
907+
if (isset($temporaryThumbnail) && file_exists($temporaryThumbnail)) {
908+
@unlink($temporaryThumbnail);
909+
}
910+
880911
// If thumbnail generation fails, return null
881912
return null;
882913
}
@@ -942,4 +973,24 @@ private function createBasicThumbnail(string $source, string $destination): void
942973
imagedestroy($sourceImage);
943974
imagedestroy($thumbnail);
944975
}
976+
977+
private function attachmentDisk(): string
978+
{
979+
return config('todo.attachments_disk', 'public');
980+
}
981+
982+
private function fsWriteOptions(string $disk): array
983+
{
984+
// With GCS + Uniform Bucket-Level Access, per-object ACLs are not allowed.
985+
if ($disk === 'gcs') {
986+
return [];
987+
}
988+
989+
return ['visibility' => $this->attachmentVisibility($disk)];
990+
}
991+
992+
private function attachmentVisibility(string $disk): string
993+
{
994+
return config("filesystems.disks.{$disk}.visibility", 'public');
995+
}
945996
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Support\Facades\Storage;
7+
use Illuminate\Support\Str;
8+
use Inertia\Inertia;
9+
10+
class UploadTestController extends Controller
11+
{
12+
public function show(Request $request)
13+
{
14+
return Inertia::render('Upload/Test', [
15+
'uploadedUrl' => $request->session()->get('uploaded_url'),
16+
'uploadedPath' => $request->session()->get('uploaded_path'),
17+
'disk' => config('todo.attachments_disk', 'public'),
18+
]);
19+
}
20+
21+
public function store(Request $request)
22+
{
23+
$validated = $request->validate([
24+
'file' => 'required|file|max:10240',
25+
]);
26+
27+
$disk = config('todo.attachments_disk', 'public');
28+
$file = $validated['file'];
29+
$fileName = Str::uuid().'.'.$file->getClientOriginalExtension();
30+
$filePath = 'uploads/tests/'.$fileName;
31+
32+
Storage::disk($disk)->putFileAs('uploads/tests', $file, $fileName, [
33+
'visibility' => config("filesystems.disks.{$disk}.visibility", 'public'),
34+
]);
35+
36+
$storage = Storage::disk($disk);
37+
try {
38+
if (method_exists($storage, 'url')) {
39+
$url = $storage->url($filePath);
40+
} else {
41+
throw new \RuntimeException('Disk does not support url method');
42+
}
43+
} catch (\Throwable $exception) {
44+
$baseUrl = config("filesystems.disks.{$disk}.url")
45+
?? sprintf('https://storage.googleapis.com/%s', config("filesystems.disks.{$disk}.bucket"));
46+
47+
$url = rtrim($baseUrl, '/').'/'.ltrim($filePath, '/');
48+
}
49+
50+
return redirect()
51+
->route('upload.show')
52+
->with('uploaded_url', $url)
53+
->with('uploaded_path', $filePath)
54+
->with('success', 'File uploaded.');
55+
}
56+
}

app/Models/TodoAttachment.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@ public function todo(): BelongsTo
3939

4040
public function getUrlAttribute(): string
4141
{
42-
return Storage::url($this->file_path);
42+
return Storage::disk(config('todo.attachments_disk', 'public'))->url($this->file_path);
4343
}
4444

4545
public function getThumbnailUrlAttribute(): ?string
4646
{
47-
return $this->thumbnail_path ? Storage::url($this->thumbnail_path) : null;
47+
if (! $this->thumbnail_path) {
48+
return null;
49+
}
50+
51+
return Storage::disk(config('todo.attachments_disk', 'public'))->url($this->thumbnail_path);
4852
}
4953

5054
public function getFormattedFileSizeAttribute(): string

0 commit comments

Comments
 (0)