generated from boboko/starter
Feat: Moving File Handling To Core
This commit is contained in:
@@ -3,20 +3,22 @@
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\File\Models\File;
|
||||
use Modules\Core\File\Services\FileService;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
||||
@@ -80,17 +82,29 @@ public function add(string $locale, Request $request): View|JsonResponse
|
||||
* each value so the cart/order still reads correctly if the product's
|
||||
* fields are edited later.
|
||||
*
|
||||
* A `file` answer is the opaque reference returned by the host's upload
|
||||
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
|
||||
* decide which files are acceptable (that's the host's upload endpoint) —
|
||||
* it only checks the reference is genuine and the file still exists.
|
||||
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||
* (CustomFieldUploadController) already created via boboko-core's
|
||||
* FileService — never the file's bytes, disk, or path, all of which
|
||||
* FileService alone is the source of truth for. A shopper can't point
|
||||
* this at someone else's file: the id must resolve to a File that is
|
||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||
* rejected. Attaching the File to the real CartLine it belongs to
|
||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||
* CartLineAdded) — not here, since this method only builds the meta
|
||||
* $this->cart->addLine() is about to receive, before any CartLine
|
||||
* actually exists to own anything.
|
||||
*
|
||||
* Two adds with identical answers merge into one line (Lunar matches
|
||||
* existing lines on meta); different answers stay separate lines.
|
||||
*/
|
||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
{
|
||||
$fields = collect($variant->product?->custom_fields ?? [])->keyBy('key');
|
||||
$fields = collect($variant->product?->custom_fields ?? [])
|
||||
->keyBy('key')
|
||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
@@ -100,15 +114,14 @@ private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
'string',
|
||||
match ($field['type']) {
|
||||
'textarea' => 'max:2000',
|
||||
'file' => function (string $attribute, mixed $value, Closure $fail) {
|
||||
if ($this->decodeUpload($value) === null) {
|
||||
...match ($field['type']) {
|
||||
'textarea' => ['string', 'max:2000'],
|
||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||
if (! $this->isFileAnswerValid($value)) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
},
|
||||
default => 'max:255',
|
||||
}],
|
||||
default => ['string', 'max:255'],
|
||||
},
|
||||
])->all(),
|
||||
[],
|
||||
@@ -122,7 +135,7 @@ private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
'label' => $field['label'],
|
||||
'type' => $field['type'],
|
||||
...($field['type'] === 'file'
|
||||
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
|
||||
? ['file_id' => (int) $validated[$field['key']]]
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
@@ -132,31 +145,38 @@ private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: string, path: string, name: string, mime: ?string}|null
|
||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||
* resolves it to the single current-locale string cart/order line meta
|
||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||
* ProductDocumentLocalizer uses for every other translated field (an
|
||||
* empty string for the current locale still falls through to the
|
||||
* store's default language, rather than showing blank). A product
|
||||
* saved before labels became translatable still has a plain string
|
||||
* here, returned as-is.
|
||||
*/
|
||||
private function decodeUpload(string $reference): ?array
|
||||
private function resolveLabel(mixed $label): string
|
||||
{
|
||||
try {
|
||||
$upload = json_decode(Crypt::decryptString($reference), true);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
if (! is_array($label)) {
|
||||
return (string) $label;
|
||||
}
|
||||
|
||||
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
|
||||
return null;
|
||||
}
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
|
||||
return filled($label[$locale] ?? null)
|
||||
? $label[$locale]
|
||||
: ($label[$fallbackLocale] ?? '');
|
||||
}
|
||||
|
||||
private function fileAnswer(array $upload): array
|
||||
private function isFileAnswerValid(mixed $fileId): bool
|
||||
{
|
||||
return [
|
||||
'value' => $upload['name'],
|
||||
'disk' => $upload['disk'],
|
||||
'path' => $upload['path'],
|
||||
'mime' => $upload['mime'] ?? null,
|
||||
];
|
||||
$file = File::find($fileId);
|
||||
|
||||
return $file !== null
|
||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||
&& $file->owner_id === null
|
||||
&& app(FileService::class)->exists($file);
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Serves a file answer to a product custom field (a cart/order line's
|
||||
* meta.custom_fields, see CartController::customFieldsMeta()) from its private
|
||||
* disk. Only reachable through a temporary signed URL — generated per render
|
||||
* by checkout::partials.line-custom-fields — so disk and path in the query
|
||||
* can't be tampered with, and a link stops working once it expires.
|
||||
*/
|
||||
class CustomFieldFileController extends Controller
|
||||
{
|
||||
public function __invoke(string $locale, Request $request): StreamedResponse
|
||||
{
|
||||
$disk = Storage::disk((string) $request->query('disk'));
|
||||
$path = (string) $request->query('path');
|
||||
|
||||
abort_unless($disk->exists($path), 404);
|
||||
|
||||
return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user