diff --git a/app/Console/Commands/PruneCustomFieldUploads.php b/app/Console/Commands/PruneCustomFieldUploads.php deleted file mode 100644 index da9872a..0000000 --- a/app/Console/Commands/PruneCustomFieldUploads.php +++ /dev/null @@ -1,73 +0,0 @@ -referencedPaths(); - $cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp(); - $deleted = 0; - - foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) { - if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) { - continue; - } - - $disk->delete($path); - $deleted++; - } - - $this->info("Deleted {$deleted} unreferenced custom-field upload(s)."); - - return self::SUCCESS; - } - - /** - * @return array - */ - private function referencedPaths(): array - { - $paths = []; - - foreach ([CartLine::class, OrderLine::class] as $model) { - $model::query() - ->where('meta', 'like', '%custom_fields%') - ->select('meta') - ->cursor() - ->each(function ($line) use (&$paths) { - foreach ($line->meta['custom_fields'] ?? [] as $field) { - if (! empty($field['path'])) { - $paths[$field['path']] = true; - } - } - }); - } - - return $paths; - } -} diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php index 5b7e248..cda24f4 100644 --- a/app/Http/Controllers/Checkout/CartController.php +++ b/app/Http/Controllers/Checkout/CartController.php @@ -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 diff --git a/app/Http/Controllers/Checkout/CustomFieldFileController.php b/app/Http/Controllers/Checkout/CustomFieldFileController.php deleted file mode 100644 index 65ab178..0000000 --- a/app/Http/Controllers/Checkout/CustomFieldFileController.php +++ /dev/null @@ -1,28 +0,0 @@ -query('disk')); - $path = (string) $request->query('path'); - - abort_unless($disk->exists($path), 404); - - return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']); - } -} diff --git a/app/Http/Controllers/CustomFieldUploadController.php b/app/Http/Controllers/CustomFieldUploadController.php index 3a7dd35..4a47653 100644 --- a/app/Http/Controllers/CustomFieldUploadController.php +++ b/app/Http/Controllers/CustomFieldUploadController.php @@ -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')]; } } diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index db8c259..cb32f9d 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -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', [ diff --git a/resources/js/stimulus/custom-field-upload-controller.js b/resources/js/stimulus/custom-field-upload-controller.js index 4770932..c1dd2d2 100644 --- a/resources/js/stimulus/custom-field-upload-controller.js +++ b/resources/js/stimulus/custom-field-upload-controller.js @@ -2,13 +2,13 @@ import { Controller } from '@hotwired/stimulus' // One product custom field of type `file` (see x-product-custom-fields). // Uploads the photo to the storefront's own endpoint (CustomFieldUploadController) -// as soon as it's picked, then writes the returned opaque reference into the -// hidden input the add-to-cart form actually submits — the checkout module -// never receives the file itself. +// as soon as it's picked, then writes the returned File row's id (boboko-core's +// Modules\Core\File\Models\File) into the hidden input the add-to-cart form +// actually submits — the checkout module never receives the file itself. // // While uploading, the file input is marked invalid via setCustomValidity(), -// so the browser's own form validation blocks add-to-cart until the reference -// is in place. A failed upload clears the input, so `required` blocks it too. +// so the browser's own form validation blocks add-to-cart until the id is in +// place. A failed upload clears the input, so `required` blocks it too. export default class extends Controller { static targets = ['file', 'reference', 'preview', 'error'] static values = { @@ -52,12 +52,12 @@ export default class extends Controller { }) const data = await response.json().catch(() => null) - if (!response.ok || !data?.reference) { + if (!response.ok || !data?.file_id) { this.fail(data?.error) return } - this.referenceTarget.value = data.reference + this.referenceTarget.value = data.file_id this.showPreview(file) } catch (error) { // A newer pick superseded this upload — reset() already handled it. diff --git a/resources/views/checkout/partials/line-custom-fields.blade.php b/resources/views/checkout/partials/line-custom-fields.blade.php index a7d189e..3098b5c 100644 --- a/resources/views/checkout/partials/line-custom-fields.blade.php +++ b/resources/views/checkout/partials/line-custom-fields.blade.php @@ -2,10 +2,12 @@ @include('checkout::partials.line-custom-fields', ['line' => $line]) A cart or order line's custom-field answers (meta.custom_fields, written by - CartController::customFieldsMeta()) — label/value pairs. A file answer links - to the file through a temporary signed URL (CustomFieldFileController), - minted fresh on every render, with a thumbnail when the browser can display - the format (HEIC can't be shown outside Safari, so it gets the name only). + CartController::customFieldsMeta()). A file answer only carries a File id + (Modules\Core\File\Models\File is the source of truth for name/mime/disk/ + path — never duplicated into meta), resolved here and linked through + boboko-core's own signed download route (files.download), minted fresh on + every render, with a thumbnail when the browser can display the format + (HEIC can't be shown outside Safari, so it gets the name only). --}} @php $fields = $line->meta['custom_fields'] ?? []; @@ -20,18 +22,23 @@
@if ($field['type'] === 'file') @php - $fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute( - 'checkout.custom-field-file', - now()->addHours(2), - ['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']], - ); + $file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null); @endphp - - @if (in_array($field['mime'] ?? null, $previewable, true)) - - @endif - {{ $field['value'] }} - + @if ($file) + @php + $fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute( + 'files.download', + now()->addHours(2), + ['file' => $file->id], + ); + @endphp + + @if (in_array($file->mime, $previewable, true)) + + @endif + {{ $file->original_name }} + + @endif @else {{ $field['value'] }} @endif diff --git a/resources/views/components/product-custom-fields.blade.php b/resources/views/components/product-custom-fields.blade.php index 9f42047..a07414f 100644 --- a/resources/views/components/product-custom-fields.blade.php +++ b/resources/views/components/product-custom-fields.blade.php @@ -6,10 +6,11 @@ custom_fields[key]. text → input, textarea → textarea, file → photo upload. A photo is uploaded as soon as it's picked (custom-field-upload-controller.js) - and only the returned reference is submitted with the form — the file input - itself has no name. It still carries `required`, so the browser's own - validation blocks add-to-cart until a photo is picked, and the controller - marks it invalid (setCustomValidity) while the upload is in flight. + and only the resulting File row's id (boboko-core's Modules\Core\File\ + Models\File) is submitted with the form — the file input itself has no + name. It still carries `required`, so the browser's own validation blocks + add-to-cart until a photo is picked, and the controller marks it invalid + (setCustomValidity) while the upload is in flight. --}} @props(['fields' => []]) diff --git a/routes/checkout.php b/routes/checkout.php index 24bc048..47f8f36 100644 --- a/routes/checkout.php +++ b/routes/checkout.php @@ -2,7 +2,6 @@ use App\Http\Controllers\Checkout\CartController; use App\Http\Controllers\Checkout\CheckoutController; -use App\Http\Controllers\Checkout\CustomFieldFileController; use Illuminate\Support\Facades\Route; /* @@ -53,12 +52,6 @@ ->whereNumber('line') ->name('checkout.cart.remove'); - // A line's custom-field file (e.g. the shopper's photo) — signed, - // temporary URLs only; see CustomFieldFileController. - Route::get('cart/custom-field-file', CustomFieldFileController::class) - ->middleware('signed') - ->name('checkout.custom-field-file'); - Route::post('cart/coupon', [CartController::class, 'applyCoupon']) ->name('checkout.cart.coupon.apply'); diff --git a/routes/console.php b/routes/console.php index 2d129d9..9df2b36 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ purpose('Display an inspiring quote'); // Deletes custom-field photo uploads (CustomFieldUploadController) older than -// 24h that no cart or order line references — see PruneCustomFieldUploads. -Schedule::command('custom-fields:prune-uploads')->dailyAt('04:00'); +// 24h that no cart or order line references — boboko-core's generic +// Modules\Core\File\Commands\PruneUnownedFilesCommand, scoped to this +// upload flow's own purpose tag. +Schedule::command('boboko:file:prune-unowned', [CustomFieldUploadController::PURPOSE])->dailyAt('04:00'); diff --git a/routes/web.php b/routes/web.php index 24dca18..8c30c03 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,18 @@ // redirects a request with no matching locale segment first. Route::get('/', [HomeController::class, 'index'])->middleware('locale'); +// No {locale} prefix: the response is plain JSON with no locale-dependent +// content, and boboko-core's Modules\Core\File\Http\Controllers\ +// UploadFileController::store() (which CustomFieldUploadController +// extends) has no concept of a locale route parameter at all — every +// other action in the group below still needs $locale as its literal +// first parameter (the ControllerDispatcher positional-args gotcha), so +// this one route living outside the group avoids that entirely rather +// than forcing this shared, locale-agnostic base class to accept one. +Route::post('/custom-field-uploads', [CustomFieldUploadController::class, 'store']) + ->middleware('throttle:20,1') + ->name('custom-field-upload.store'); + Route::prefix('{locale}') ->middleware('locale') ->group(function () { @@ -39,13 +51,6 @@ 'product.stock-check', ); - // Photo for a product custom field, uploaded as soon as it's picked — - // see CustomFieldUploadController. Throttled: it writes to disk and - // needs no cart/session to call. - Route::post('/custom-field-uploads', [CustomFieldUploadController::class, 'store']) - ->middleware('throttle:20,1') - ->name('custom-field-upload.store'); - Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name( 'product.reviews.store', );