5 Commits
21 changed files with 188 additions and 471 deletions
@@ -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;
}
}
@@ -75,16 +75,13 @@ public function update(string $locale, Request $request): RedirectResponse
$invoice = $request->boolean('invoice');
$customer = $this->account->updateProfile($user, [
$this->account->updateProfile($user, [
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'company_name' => $invoice ? $data['company_name'] : null,
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
]);
// Written directly: core's updateProfile() allowlists `vat_no`, but
// Lunar's column is `tax_identifier`, so it can't go through there yet.
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
if (filled($data['line_one'] ?? null)) {
$addressData = [
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
@@ -103,28 +100,20 @@ public function update(string $locale, Request $request): RedirectResponse
: $this->account->createAddress($user, $addressData);
}
$this->updateRecoveryConsent($customer, $request->boolean('recovery_consent'));
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
return redirect()->route('account')->with('status', __('storefront.account.saved'));
}
/**
* "Email me a reminder if I don't finish my order", as a standing choice.
* Stored on the customer in the same meta shape the checkout writes (see
* CheckoutController::rememberRecoveryConsent()), and applied to the
* "Email me a reminder if I don't finish my order", as a standing
* choice — stored on the customer via boboko-core's
* CustomerAccountService::setRecoveryConsent(), and applied to the
* current cart too, so opting out stops reminders for it right away.
*/
private function updateRecoveryConsent($customer, bool $consent): void
private function updateRecoveryConsent($user, bool $consent): void
{
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
$this->account->setRecoveryConsent($user, $consent);
// Only an existing cart; never create one just to record this.
$cart = app(CartService::class)->current();
@@ -3,43 +3,36 @@
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Mail\EmailChangeCodeMail;
use App\Mail\EmailChangedNoticeMail;
use App\Services\GuestOrderClaimer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
use Modules\Core\Customer\Services\CustomerEmailChangeService;
/**
* Changing the login email: new email → 6-digit code sent to that address →
* switched. The email is the login, so it only changes once the shopper has
* proved they can receive mail there; a typo can never lock them out.
*
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
*
* Core has no email-change flow, so the pending change lives in the session
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
* Changing the login email — a thin wrapper over boboko-core's
* Customer\Services\CustomerEmailChangeService, which owns the actual
* request/confirm mechanics, throttling, pending-change storage, and
* mailables. This controller's own job is just the storefront's session-
* scoped "which email did I just ask to switch to" UI state (so the
* .code/.resend pages know which address to show/resend to) and
* translating the service's exceptions into the flash-message flow the
* views expect.
*/
class EmailController extends Controller
{
private const SESSION_KEY = 'email_change';
private const EXPIRY_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
private const SESSION_KEY = 'pending_email_change';
public function edit(string $locale, Request $request): View
{
return view('account.email', ['user' => $request->user()]);
}
public function send(string $locale, Request $request): RedirectResponse
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$user = $request->user();
@@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse
'email',
'max:255',
Rule::notIn([$user->email]),
Rule::unique($user->getTable(), 'email')->ignore($user->id),
],
], [
'email.not_in' => __('storefront.account.email_same'),
'email.unique' => __('storefront.account.email_taken'),
]);
if (! $this->sendCode($request, $validated['email'])) {
try {
$emailChange->request($user, $validated['email']);
} catch (EmailAlreadyTakenException) {
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (OtpThrottledException) {
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
}
$request->session()->put(self::SESSION_KEY, $validated['email']);
return redirect()->route('account.email.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
return view('account.email-code', ['email' => $pending['email']]);
return view('account.email-code', ['email' => $pendingEmail]);
}
public function resend(string $locale, Request $request): RedirectResponse
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
if (! $this->sendCode($request, $pending['email'])) {
try {
$emailChange->request($request->user(), $pendingEmail);
} catch (EmailAlreadyTakenException) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (OtpThrottledException) {
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, GuestOrderClaimer $orders): RedirectResponse
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
$validated = $request->validate(['code' => ['required', 'digits:6']]);
$valid = $pending['code_hash'] !== null
&& now()->timestamp < $pending['expires_at']
&& Hash::check($validated['code'], $pending['code_hash']);
if (! $valid) {
// Too many wrong guesses burns the code; only "resend" helps then.
$pending['attempts']++;
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
$pending['code_hash'] = null;
}
$request->session()->put(self::SESSION_KEY, $pending);
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$user = $request->user();
// Someone may have signed up with this address since the code was sent.
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
try {
$emailChange->confirm($request->user(), $validated['code']);
} catch (EmailAlreadyTakenException) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (InvalidEmailChangeCodeException) {
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$oldEmail = $user->email;
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
// Lets the owner notice if someone else changed it from a hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
// The code just proved they own the new address too.
$orders->claim($user);
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
}
private function sendCode(Request $request, string $email): bool
{
$limiterKey = 'email-change:'.$request->user()->id;
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
return false;
}
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put(self::SESSION_KEY, [
'email' => $email,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
]);
Mail::to($email)->send(new EmailChangeCodeMail($code));
return true;
}
}
@@ -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
@@ -242,7 +242,7 @@ public function saveAddress(string $locale, Request $request): JsonResponse
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
if (Auth::check()) {
$this->rememberRecoveryConsent($request->boolean('recovery_consent'));
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
}
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
@@ -665,29 +665,6 @@ private function prefillFromAccount(Cart $cart): Cart
return $cart;
}
/**
* The shopper's latest reminder choice, kept on their customer record
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
* cart) so their next checkout starts from it. The storefront's account
* page reads/writes the same keys. Candidate for a boboko-core method.
*/
private function rememberRecoveryConsent(bool $consent): void
{
$customer = $this->account->customer(Auth::user());
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
return;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
private function storeCountry(): ?Country
{
if (self::STORE_COUNTRY_ISO3 === null) {
@@ -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', [
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Listeners;
use App\Services\GuestOrderClaimer;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. UserAuthenticated only fires after a valid login code.
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The code that confirms a new login email — see Account\EmailController.
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
}
public function content(): Content
{
return new Content(view: 'emails.email-change-code');
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once the login email has changed (see
* Account\EmailController), so the owner notices a takeover. The new address
* is shown masked, e.g. "n•••@example.com".
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε');
}
public function content(): Content
{
return new Content(view: 'emails.email-changed-notice');
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email matches
* the account's email. Only ever called right after the shopper has proved
* they own that email (a login code, or the code confirming an email change),
* which is what makes matching on email safe.
*
* Orders already belonging to any customer or user are never touched.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable $user): int
{
$customer = $user->latestCustomer();
if (! $customer || ! $user->email) {
return 0;
}
return Order::query()
->whereNotNull('placed_at')
->whereNull('customer_id')
->whereNull('user_id')
->whereHas('billingAddress', fn ($query) => $query
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
->update([
'customer_id' => $customer->id,
'user_id' => $user->id,
]);
}
}
@@ -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.
@@ -42,7 +42,8 @@
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(App\Listeners\ClaimGuestOrdersOnLogin), so it shows in their history. --}}
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
@@ -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 @@
<dd>
@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
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($field['mime'] ?? null, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $field['value'] }}</span>
</a>
@if ($file)
@php
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'files.download',
now()->addHours(2),
['file' => $file->id],
);
@endphp
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($file->mime, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
@@ -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' => []])
-7
View File
@@ -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');
+5 -2
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\CustomFieldUploadController;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@@ -9,5 +10,7 @@
})->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');
+12 -7
View File
@@ -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',
);