Feat: Moving File Handling To Core

This commit is contained in:
2026-09-25 13:47:58 +03:00
parent 540da1af24
commit a51e9456d6
11 changed files with 134 additions and 218 deletions
@@ -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']);
}
}
@@ -2,33 +2,32 @@
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Validator;
use Modules\Core\File\Http\Controllers\UploadFileController;
/**
* Stores the shopper's photo for a product custom field of type `file` (see
* boboko-core's Product::$custom_fields) the moment it's picked on the product
* page — before add-to-cart, see custom-field-upload-controller.js.
*
* Which files are acceptable is a per-site decision (this site: photographs),
* so it lives here in the storefront, not in the checkout module. The module's
* add-to-cart endpoint only ever receives the opaque `reference` returned
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
* neither forge a reference to some other private file nor read the path —
* and copies it onto the cart line's meta (see Checkout\CartController::
* customFieldsMeta()).
* Which files are acceptable (extensions, size) is a per-site decision — this
* site: photographs — so it lives here as this app's own policy, extending
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
* endpoint only ever receives the stored File row's own `id` — FileService is
* the single source of truth for disk/path/name/mime, never duplicated into
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
* shopper can't point a cart line at someone else's file: CartController only
* accepts an id that is both unowned and tagged with this exact PURPOSE.
*
* Stored on the private `local` disk: these are customers' personal photos,
* never reachable by a public URL. Uploads nobody added to a cart are removed
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
* never reachable by a public URL except through FileService's own signed
* download route. Uploads nobody adds to a cart are removed by core's
* `boboko:file:prune-unowned custom-field-upload` command.
*/
class CustomFieldUploadController extends Controller
class CustomFieldUploadController extends UploadFileController
{
public const DISK = 'local';
public const DIRECTORY = 'custom-field-uploads';
public const PURPOSE = 'custom-field-upload';
public const MAX_KILOBYTES = 10240;
@@ -42,33 +41,22 @@ public static function accept(): string
return '.'.implode(',.', self::EXTENSIONS);
}
public function store(string $locale, Request $request): JsonResponse
protected function purpose(): string
{
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
$validator = Validator::make(
$request->all(),
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
[],
['file' => (string) $request->input('label', 'file')],
);
return self::PURPOSE;
}
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
protected function validationRules(Request $request): array
{
return [
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
];
}
$file = $request->file('file');
$path = $file->store(self::DIRECTORY, self::DISK);
abort_if($path === false, 500);
return response()->json([
'reference' => Crypt::encryptString(json_encode([
'disk' => self::DISK,
'path' => $path,
'name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
])),
]);
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
protected function validationAttributes(Request $request): array
{
return ['file' => (string) $request->input('label', 'file')];
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ public function show(string $locale, int $id)
$product = $this->mergeJustSubmittedReview($product);
$collection = $product['collections'][0] ?? null;
// dd($product);
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
return view('product.show', [