Skip to content

Commit 941cb03

Browse files
authored
10.4.0 (#705)
* feat: adding base64file * Fix styling --------- Co-authored-by: binaryk <6833714+binaryk@users.noreply.github.com>
1 parent 9db5cfd commit 941cb03

File tree

3 files changed

+816
-0
lines changed

3 files changed

+816
-0
lines changed

docs-v3/content/docs/6.mcp/3.fields.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,176 @@ Perfect for n8n workflows where files are extracted from emails:
473473

474474
The file will be stored as `expense_receipts/123/Invoice_ABC_Company_Jan_2024.pdf` and the `receipt_filename` column will contain `Invoice_ABC_Company_Jan_2024.pdf`.
475475

476+
## Base64 File Field
477+
478+
The `Base64File` field handles base64-encoded file data, perfect for modern frontend applications that send images as data URIs (signature pads, image editors, webcam captures, canvas-based drawing).
479+
480+
### Basic Usage
481+
482+
```php
483+
use Binaryk\LaravelRestify\Fields\Base64File;
484+
485+
class SignatureRepository extends Repository
486+
{
487+
public function fields(RestifyRequest $request): array
488+
{
489+
return [
490+
Base64File::make('signature')
491+
->path('signatures/'.$request->user()->id)
492+
->disk('s3'),
493+
];
494+
}
495+
}
496+
```
497+
498+
### Request Payload
499+
500+
Send base64 data URI strings directly:
501+
502+
```json
503+
{
504+
"signature": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
505+
}
506+
```
507+
508+
### Features
509+
510+
The `Base64File` field inherits all capabilities from the `File` field:
511+
512+
```php
513+
Base64File::make('signature')
514+
->path('signatures') // Storage subdirectory
515+
->disk('s3') // Storage disk
516+
->storeAs(fn($request) => 'custom-name') // Custom filename
517+
->storeOriginalName('signature_filename') // Store filename in column
518+
->storeSize('signature_size') // Store file size in column
519+
->resolveUsingTemporaryUrl(true, now()->addMinutes(30)) // S3 signed URLs
520+
->resolveUsingFullUrl() // Full storage URL
521+
->prunable() // Delete file when model deleted
522+
->deletable() // Allow manual deletion
523+
```
524+
525+
### MIME Type Detection
526+
527+
The field automatically detects the file extension from the data URI header:
528+
529+
| Data URI Contains | Extension |
530+
|-------------------|-----------|
531+
| `image/png` | `.png` |
532+
| `image/jpeg` or `image/jpg` | `.jpg` |
533+
| `image/gif` | `.gif` |
534+
| `image/webp` | `.webp` |
535+
| `image/svg+xml` | `.svg` |
536+
| `application/pdf` | `.pdf` |
537+
| `text/plain` | `.txt` |
538+
| `application/json` | `.json` |
539+
| (default) | `.bin` |
540+
541+
### Fallback Behavior
542+
543+
The `Base64File` field gracefully handles different input types:
544+
545+
- **Base64 data URI**: Decodes and stores as file
546+
- **URL**: Delegates to parent `File` field behavior (stores URL directly)
547+
- **File upload**: Delegates to parent `File` field behavior (normal upload)
548+
- **Empty/null/invalid**: Ignores and preserves existing value
549+
550+
### Custom Filename
551+
552+
Control the stored filename:
553+
554+
```php
555+
// Static filename (extension auto-appended)
556+
Base64File::make('signature')
557+
->storeAs('user-signature') // Stored as: user-signature.png
558+
559+
// From callback (extension auto-appended)
560+
Base64File::make('signature')
561+
->storeAs(fn($request) => "sig-{$request->user()->id}")
562+
563+
// With extension included
564+
Base64File::make('signature')
565+
->storeAs('signature.png') // Used as-is
566+
```
567+
568+
### Original Name and Size
569+
570+
Track metadata in separate columns:
571+
572+
```php
573+
Base64File::make('signature')
574+
->storeOriginalName('signature_filename') // Stores: "base64-upload.png" or custom name
575+
->storeSize('signature_size') // Stores: decoded file size in bytes
576+
```
577+
578+
When using `storeAs()` with a callback, the custom filename is used for `storeOriginalName()`.
579+
580+
### Prunable Files
581+
582+
Automatically delete the file when the model is deleted:
583+
584+
```php
585+
Base64File::make('signature')
586+
->prunable()
587+
```
588+
589+
When updating a model with a new base64 file and `prunable()` is enabled, the old file is automatically deleted.
590+
591+
### Use Cases
592+
593+
- **Signature pads**: Users draw signatures on canvas
594+
- **Image editors**: Cropped/edited images from canvas
595+
- **Webcam captures**: Photos taken in-browser
596+
- **Screenshot tools**: Clipboard image paste
597+
- **Avatar generators**: Generated avatars from canvas
598+
- **Drawing applications**: User-created artwork
599+
600+
### MCP Integration
601+
602+
Hide base64 fields from MCP responses or use optimized fields:
603+
604+
```php
605+
class SignatureRepository extends Repository
606+
{
607+
public function fields(RestifyRequest $request): array
608+
{
609+
return [
610+
Base64File::make('signature')
611+
->path('signatures')
612+
->resolveUsingTemporaryUrl(true, now()->addMinutes(30))
613+
->hideFromMcp() // Hide from AI agents
614+
->disk('s3'),
615+
616+
field('signature_filename')
617+
->description('Filename of the stored signature'),
618+
];
619+
}
620+
621+
// Optimized fields for MCP
622+
public function fieldsForMcpShow(RestifyRequest $request): array
623+
{
624+
return [
625+
field('signature_url', fn() => Storage::disk('s3')->temporaryUrl(
626+
$this->signature,
627+
now()->addMinutes(30)
628+
)),
629+
];
630+
}
631+
}
632+
```
633+
634+
### Raw Base64 Support
635+
636+
The field also supports raw base64 strings without the data URI prefix:
637+
638+
```json
639+
{
640+
"signature": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
641+
}
642+
```
643+
644+
Note: Without the data URI prefix, the field cannot detect the MIME type and will use `.bin` as the extension. Always prefer the full data URI format.
645+
476646
## Best Practices
477647

478648
### 1. Field Selection Strategy

src/Fields/Base64File.php

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Fields;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
6+
use Closure;
7+
use Illuminate\Support\Facades\Storage;
8+
use Illuminate\Support\Str;
9+
10+
class Base64File extends File
11+
{
12+
public function fillAttribute(RestifyRequest $request, $model, ?int $bulkRow = null)
13+
{
14+
if ($this->storeCallback instanceof Closure) {
15+
return call_user_func($this->storeCallback, $request, $model, $this->attribute);
16+
}
17+
18+
// Delegate to parent for regular file uploads first
19+
if ($this->resolveFileFromRequest($request)) {
20+
return parent::fillAttribute($request, $model, $bulkRow);
21+
}
22+
23+
$input = $request->input($this->attribute);
24+
25+
if (! $input || ! is_string($input)) {
26+
return $this;
27+
}
28+
29+
// Delegate to parent for URL inputs
30+
if (filter_var($input, FILTER_VALIDATE_URL)) {
31+
return parent::fillAttribute($request, $model, $bulkRow);
32+
}
33+
34+
// Handle base64 data
35+
if (! $this->isBase64($input)) {
36+
return $this;
37+
}
38+
39+
if ($this->isPrunable()) {
40+
call_user_func(
41+
$this->deleteCallback,
42+
$request,
43+
$model,
44+
$this->getStorageDisk(),
45+
$this->getStoragePath()
46+
);
47+
}
48+
49+
$result = $this->mergeExtraStorageColumnsForBase64($request, [
50+
$this->attribute => $this->storeBase64File($request),
51+
]);
52+
53+
if (! is_array($result)) {
54+
return $model->{$this->attribute} = $result;
55+
}
56+
57+
foreach ($result as $key => $value) {
58+
if ($model->isFillable($key)) {
59+
$model->{$key} = $value;
60+
}
61+
}
62+
63+
return $this;
64+
}
65+
66+
protected function storeBase64File(RestifyRequest $request): string
67+
{
68+
$base64Data = $request->input($this->attribute);
69+
$imageData = $this->decodeBase64($base64Data);
70+
$extension = $this->detectExtension($base64Data);
71+
72+
$filename = $this->resolveFilename($request, $extension);
73+
$directory = trim($this->getStorageDir(), '/');
74+
$path = $directory ? "{$directory}/{$filename}" : $filename;
75+
76+
Storage::disk($this->getStorageDisk())->put($path, $imageData);
77+
78+
return $path;
79+
}
80+
81+
protected function resolveFilename(RestifyRequest $request, string $extension): string
82+
{
83+
if (! $this->storeAs) {
84+
$this->customFilename = null;
85+
$this->useCustomFilenameForOriginal = false;
86+
87+
return Str::uuid().'.'.$extension;
88+
}
89+
90+
$isCallable = is_callable($this->storeAs);
91+
$filename = $isCallable
92+
? call_user_func($this->storeAs, $request)
93+
: $this->storeAs;
94+
95+
if (empty($filename)) {
96+
$this->customFilename = null;
97+
$this->useCustomFilenameForOriginal = false;
98+
99+
return Str::uuid().'.'.$extension;
100+
}
101+
102+
// Smart extension handling - append if missing
103+
if ($extension && ! str_ends_with(strtolower($filename), '.'.$extension)) {
104+
$filename = $filename.'.'.$extension;
105+
}
106+
107+
$this->customFilename = $filename;
108+
$this->useCustomFilenameForOriginal = $isCallable;
109+
110+
return $filename;
111+
}
112+
113+
protected function mergeExtraStorageColumnsForBase64(RestifyRequest $request, array $attributes): array
114+
{
115+
$base64Data = $request->input($this->attribute);
116+
117+
if ($this->originalNameColumn) {
118+
$attributes[$this->originalNameColumn] = ($this->useCustomFilenameForOriginal && $this->customFilename)
119+
? $this->customFilename
120+
: $this->detectOriginalName($base64Data);
121+
}
122+
123+
if ($this->sizeColumn) {
124+
$attributes[$this->sizeColumn] = $this->calculateDecodedSize($base64Data);
125+
}
126+
127+
return $attributes;
128+
}
129+
130+
protected function detectOriginalName(string $base64Data): string
131+
{
132+
$extension = $this->detectExtension($base64Data);
133+
134+
return 'base64-upload.'.$extension;
135+
}
136+
137+
protected function calculateDecodedSize(string $base64Data): int
138+
{
139+
$parts = explode(',', $base64Data);
140+
$encoded = count($parts) > 1 ? $parts[1] : $parts[0];
141+
142+
return (int) (strlen($encoded) * 3 / 4);
143+
}
144+
145+
protected function isBase64(string $data): bool
146+
{
147+
// Check for data URI format
148+
if (str_starts_with($data, 'data:')) {
149+
return true;
150+
}
151+
152+
// Check for raw base64 (no data URI prefix)
153+
return base64_encode(base64_decode($data, true)) === $data;
154+
}
155+
156+
protected function decodeBase64(string $base64Data): string
157+
{
158+
$parts = explode(',', $base64Data);
159+
160+
return base64_decode(count($parts) > 1 ? $parts[1] : $parts[0]);
161+
}
162+
163+
protected function detectExtension(string $base64Data): string
164+
{
165+
return match (true) {
166+
str_contains($base64Data, 'image/png') => 'png',
167+
str_contains($base64Data, 'image/jpeg'), str_contains($base64Data, 'image/jpg') => 'jpg',
168+
str_contains($base64Data, 'image/gif') => 'gif',
169+
str_contains($base64Data, 'image/webp') => 'webp',
170+
str_contains($base64Data, 'image/svg+xml'), str_contains($base64Data, 'image/svg') => 'svg',
171+
str_contains($base64Data, 'application/pdf') => 'pdf',
172+
str_contains($base64Data, 'text/plain') => 'txt',
173+
str_contains($base64Data, 'application/json') => 'json',
174+
default => 'bin',
175+
};
176+
}
177+
}

0 commit comments

Comments
 (0)