generated from boboko/starter
Feat: Moving File Handling To Core
This commit is contained in:
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Http\Controllers\CustomFieldUploadController;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Lunar\Models\CartLine;
|
|
||||||
use Lunar\Models\OrderLine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom-field photos are uploaded the moment a shopper picks them (see
|
|
||||||
* CustomFieldUploadController), before add-to-cart — so a shopper who picks a
|
|
||||||
* photo and then leaves, or picks a different one, leaves a file nobody
|
|
||||||
* references. This deletes those: any upload older than the grace period that
|
|
||||||
* no cart line or order line's meta.custom_fields points to.
|
|
||||||
*
|
|
||||||
* The grace period covers a shopper still on the product page with a picked
|
|
||||||
* but not-yet-added photo. A file referenced by a cart line is kept for as
|
|
||||||
* long as that cart line exists; once the line (or its cart) is removed, the
|
|
||||||
* next run deletes the file. Order line references are kept indefinitely.
|
|
||||||
*/
|
|
||||||
class PruneCustomFieldUploads extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'custom-fields:prune-uploads {--hours=24 : Only delete unreferenced uploads older than this}';
|
|
||||||
|
|
||||||
protected $description = 'Delete custom-field photo uploads not referenced by any cart or order line';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$disk = Storage::disk(CustomFieldUploadController::DISK);
|
|
||||||
$referenced = $this->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<string, true>
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,20 +3,22 @@
|
|||||||
namespace App\Http\Controllers\Checkout;
|
namespace App\Http\Controllers\Checkout;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\CustomFieldUploadController;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Encryption\DecryptException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
use Lunar\Models\CartLine;
|
use Lunar\Models\CartLine;
|
||||||
use Lunar\Models\ProductVariant;
|
use Lunar\Models\ProductVariant;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||||
use Modules\Core\Cart\Services\CartService;
|
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
|
* 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
|
* each value so the cart/order still reads correctly if the product's
|
||||||
* fields are edited later.
|
* fields are edited later.
|
||||||
*
|
*
|
||||||
* A `file` answer is the opaque reference returned by the host's upload
|
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||||
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
|
* (CustomFieldUploadController) already created via boboko-core's
|
||||||
* decide which files are acceptable (that's the host's upload endpoint) —
|
* FileService — never the file's bytes, disk, or path, all of which
|
||||||
* it only checks the reference is genuine and the file still exists.
|
* 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
|
* Two adds with identical answers merge into one line (Lunar matches
|
||||||
* existing lines on meta); different answers stay separate lines.
|
* existing lines on meta); different answers stay separate lines.
|
||||||
*/
|
*/
|
||||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
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()) {
|
if ($fields->isEmpty()) {
|
||||||
return [];
|
return [];
|
||||||
@@ -100,15 +114,14 @@ private function customFieldsMeta(ProductVariant $variant, array $input): array
|
|||||||
$input,
|
$input,
|
||||||
$fields->map(fn (array $field) => [
|
$fields->map(fn (array $field) => [
|
||||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||||
'string',
|
...match ($field['type']) {
|
||||||
match ($field['type']) {
|
'textarea' => ['string', 'max:2000'],
|
||||||
'textarea' => 'max:2000',
|
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||||
'file' => function (string $attribute, mixed $value, Closure $fail) {
|
if (! $this->isFileAnswerValid($value)) {
|
||||||
if ($this->decodeUpload($value) === null) {
|
|
||||||
$fail('validation.uploaded')->translate();
|
$fail('validation.uploaded')->translate();
|
||||||
}
|
}
|
||||||
},
|
}],
|
||||||
default => 'max:255',
|
default => ['string', 'max:255'],
|
||||||
},
|
},
|
||||||
])->all(),
|
])->all(),
|
||||||
[],
|
[],
|
||||||
@@ -122,7 +135,7 @@ private function customFieldsMeta(ProductVariant $variant, array $input): array
|
|||||||
'label' => $field['label'],
|
'label' => $field['label'],
|
||||||
'type' => $field['type'],
|
'type' => $field['type'],
|
||||||
...($field['type'] === 'file'
|
...($field['type'] === 'file'
|
||||||
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
|
? ['file_id' => (int) $validated[$field['key']]]
|
||||||
: ['value' => $validated[$field['key']]]),
|
: ['value' => $validated[$field['key']]]),
|
||||||
])
|
])
|
||||||
->values()
|
->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 {
|
if (! is_array($label)) {
|
||||||
$upload = json_decode(Crypt::decryptString($reference), true);
|
return (string) $label;
|
||||||
} catch (DecryptException) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
|
$locale = App::getLocale();
|
||||||
return null;
|
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||||
|
|
||||||
|
return filled($label[$locale] ?? null)
|
||||||
|
? $label[$locale]
|
||||||
|
: ($label[$fallbackLocale] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
|
private function isFileAnswerValid(mixed $fileId): bool
|
||||||
}
|
|
||||||
|
|
||||||
private function fileAnswer(array $upload): array
|
|
||||||
{
|
{
|
||||||
return [
|
$file = File::find($fileId);
|
||||||
'value' => $upload['name'],
|
|
||||||
'disk' => $upload['disk'],
|
return $file !== null
|
||||||
'path' => $upload['path'],
|
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||||
'mime' => $upload['mime'] ?? null,
|
&& $file->owner_id === null
|
||||||
];
|
&& app(FileService::class)->exists($file);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the shopper's photo for a product custom field of type `file` (see
|
* 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
|
* 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.
|
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||||
*
|
*
|
||||||
* Which files are acceptable is a per-site decision (this site: photographs),
|
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||||
* so it lives here in the storefront, not in the checkout module. The module's
|
* site: photographs — so it lives here as this app's own policy, extending
|
||||||
* add-to-cart endpoint only ever receives the opaque `reference` returned
|
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||||
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
|
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||||
* neither forge a reference to some other private file nor read the path —
|
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||||
* and copies it onto the cart line's meta (see Checkout\CartController::
|
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||||
* customFieldsMeta()).
|
* 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,
|
* 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
|
* never reachable by a public URL except through FileService's own signed
|
||||||
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
|
* 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 PURPOSE = 'custom-field-upload';
|
||||||
|
|
||||||
public const DIRECTORY = 'custom-field-uploads';
|
|
||||||
|
|
||||||
public const MAX_KILOBYTES = 10240;
|
public const MAX_KILOBYTES = 10240;
|
||||||
|
|
||||||
@@ -42,33 +41,22 @@ public static function accept(): string
|
|||||||
return '.'.implode(',.', self::EXTENSIONS);
|
return '.'.implode(',.', self::EXTENSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(string $locale, Request $request): JsonResponse
|
protected function purpose(): string
|
||||||
{
|
{
|
||||||
|
return self::PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validationRules(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// `label` is the admin-authored field label, only used as the
|
// `label` is the admin-authored field label, only used as the
|
||||||
// :attribute in the validation message shown next to that field.
|
// :attribute in the validation message shown next to that field.
|
||||||
$validator = Validator::make(
|
protected function validationAttributes(Request $request): array
|
||||||
$request->all(),
|
{
|
||||||
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
|
return ['file' => (string) $request->input('label', 'file')];
|
||||||
[],
|
|
||||||
['file' => (string) $request->input('label', 'file')],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json(['error' => $validator->errors()->first('file')], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$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(),
|
|
||||||
])),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public function show(string $locale, int $id)
|
|||||||
$product = $this->mergeJustSubmittedReview($product);
|
$product = $this->mergeJustSubmittedReview($product);
|
||||||
|
|
||||||
$collection = $product['collections'][0] ?? null;
|
$collection = $product['collections'][0] ?? null;
|
||||||
|
// dd($product);
|
||||||
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||||
|
|
||||||
return view('product.show', [
|
return view('product.show', [
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Controller } from '@hotwired/stimulus'
|
|||||||
|
|
||||||
// One product custom field of type `file` (see x-product-custom-fields).
|
// One product custom field of type `file` (see x-product-custom-fields).
|
||||||
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
||||||
// as soon as it's picked, then writes the returned opaque reference into the
|
// as soon as it's picked, then writes the returned File row's id (boboko-core's
|
||||||
// hidden input the add-to-cart form actually submits — the checkout module
|
// Modules\Core\File\Models\File) into the hidden input the add-to-cart form
|
||||||
// never receives the file itself.
|
// actually submits — the checkout module never receives the file itself.
|
||||||
//
|
//
|
||||||
// While uploading, the file input is marked invalid via setCustomValidity(),
|
// While uploading, the file input is marked invalid via setCustomValidity(),
|
||||||
// so the browser's own form validation blocks add-to-cart until the reference
|
// so the browser's own form validation blocks add-to-cart until the id is in
|
||||||
// is in place. A failed upload clears the input, so `required` blocks it too.
|
// place. A failed upload clears the input, so `required` blocks it too.
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['file', 'reference', 'preview', 'error']
|
static targets = ['file', 'reference', 'preview', 'error']
|
||||||
static values = {
|
static values = {
|
||||||
@@ -52,12 +52,12 @@ export default class extends Controller {
|
|||||||
})
|
})
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
if (!response.ok || !data?.reference) {
|
if (!response.ok || !data?.file_id) {
|
||||||
this.fail(data?.error)
|
this.fail(data?.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.referenceTarget.value = data.reference
|
this.referenceTarget.value = data.file_id
|
||||||
this.showPreview(file)
|
this.showPreview(file)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A newer pick superseded this upload — reset() already handled it.
|
// A newer pick superseded this upload — reset() already handled it.
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
|
|
||||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||||
CartController::customFieldsMeta()) — label/value pairs. A file answer links
|
CartController::customFieldsMeta()). A file answer only carries a File id
|
||||||
to the file through a temporary signed URL (CustomFieldFileController),
|
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
||||||
minted fresh on every render, with a thumbnail when the browser can display
|
path — never duplicated into meta), resolved here and linked through
|
||||||
the format (HEIC can't be shown outside Safari, so it gets the name only).
|
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
|
@php
|
||||||
$fields = $line->meta['custom_fields'] ?? [];
|
$fields = $line->meta['custom_fields'] ?? [];
|
||||||
@@ -19,19 +21,24 @@
|
|||||||
<dt>{{ $field['label'] }}</dt>
|
<dt>{{ $field['label'] }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@if ($field['type'] === 'file')
|
@if ($field['type'] === 'file')
|
||||||
|
@php
|
||||||
|
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
||||||
|
@endphp
|
||||||
|
@if ($file)
|
||||||
@php
|
@php
|
||||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||||
'checkout.custom-field-file',
|
'files.download',
|
||||||
now()->addHours(2),
|
now()->addHours(2),
|
||||||
['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']],
|
['file' => $file->id],
|
||||||
);
|
);
|
||||||
@endphp
|
@endphp
|
||||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||||
@if (in_array($field['mime'] ?? null, $previewable, true))
|
@if (in_array($file->mime, $previewable, true))
|
||||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||||
@endif
|
@endif
|
||||||
<span>{{ $field['value'] }}</span>
|
<span>{{ $file->original_name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
@else
|
@else
|
||||||
{{ $field['value'] }}
|
{{ $field['value'] }}
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
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)
|
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
|
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||||
itself has no name. It still carries `required`, so the browser's own
|
Models\File) is submitted with the form — the file input itself has no
|
||||||
validation blocks add-to-cart until a photo is picked, and the controller
|
name. It still carries `required`, so the browser's own validation blocks
|
||||||
marks it invalid (setCustomValidity) while the upload is in flight.
|
add-to-cart until a photo is picked, and the controller marks it invalid
|
||||||
|
(setCustomValidity) while the upload is in flight.
|
||||||
--}}
|
--}}
|
||||||
@props(['fields' => []])
|
@props(['fields' => []])
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Checkout\CartController;
|
use App\Http\Controllers\Checkout\CartController;
|
||||||
use App\Http\Controllers\Checkout\CheckoutController;
|
use App\Http\Controllers\Checkout\CheckoutController;
|
||||||
use App\Http\Controllers\Checkout\CustomFieldFileController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -53,12 +52,6 @@
|
|||||||
->whereNumber('line')
|
->whereNumber('line')
|
||||||
->name('checkout.cart.remove');
|
->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'])
|
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
|
||||||
->name('checkout.cart.coupon.apply');
|
->name('checkout.cart.coupon.apply');
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\CustomFieldUploadController;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@@ -9,5 +10,7 @@
|
|||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
// Deletes custom-field photo uploads (CustomFieldUploadController) older than
|
// Deletes custom-field photo uploads (CustomFieldUploadController) older than
|
||||||
// 24h that no cart or order line references — see PruneCustomFieldUploads.
|
// 24h that no cart or order line references — boboko-core's generic
|
||||||
Schedule::command('custom-fields:prune-uploads')->dailyAt('04:00');
|
// 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');
|
||||||
|
|||||||
+12
-7
@@ -24,6 +24,18 @@
|
|||||||
// redirects a request with no matching locale segment first.
|
// redirects a request with no matching locale segment first.
|
||||||
Route::get('/', [HomeController::class, 'index'])->middleware('locale');
|
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}')
|
Route::prefix('{locale}')
|
||||||
->middleware('locale')
|
->middleware('locale')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
@@ -39,13 +51,6 @@
|
|||||||
'product.stock-check',
|
'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(
|
Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name(
|
||||||
'product.reviews.store',
|
'product.reviews.store',
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user