Merge branch 'master' into Shipping-Updates

This commit is contained in:
2026-09-30 13:18:01 +03:00
29 changed files with 927 additions and 205 deletions
+13 -1
View File
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
'last_name',
'admin',
'email',
'otp_code',
'otp_code_hash',
'otp_expires_at',
];
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
'admin' => 'bool',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'otp_code_hash' => 'hashed',
'otp_expires_at' => 'datetime',
];
// Overrides (doesn't merge with) Lunar\Admin\Models\Staff's own
// $hidden — repeats its password/remember_token here so this class
// doesn't silently drop that protection while adding otp_code_hash/
// otp_expires_at, which the base model has no reason to know about.
protected $hidden = [
'password',
'remember_token',
'otp_code_hash',
'otp_expires_at',
];
}
+11 -3
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff;
@@ -27,7 +28,10 @@ class OtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$staff->otp_code = $code;
// otp_code_hash's 'hashed' cast (see Staff's own $casts) hashes
// this automatically on assignment, same as password — never
// stored or compared in plaintext.
$staff->otp_code_hash = $code;
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save();
@@ -44,11 +48,15 @@ class OtpService
return null;
}
if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) {
if (! $staff->otp_code_hash || ! $staff->otp_expires_at || now()->isAfter($staff->otp_expires_at)) {
return null;
}
$staff->otp_code = null;
if (! Hash::check($code, $staff->otp_code_hash)) {
return null;
}
$staff->otp_code_hash = null;
$staff->otp_expires_at = null;
$staff->save();
+17 -9
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
* at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged.
* row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged. otp_code_hash
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
* on App\Models\User hashes it automatically on assignment, same as
* password), not the code itself — compared via Hash::check().
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
@@ -87,7 +91,7 @@ class UserOtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) {
$user->otp_code = $code;
$user->otp_code_hash = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
@@ -96,8 +100,12 @@ class UserOtpService
// class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned.
//
// Hashed even in the cache (not just on the DB-backed path)
// — a code sitting in Cache::get()-able storage is the same
// exposure as a plaintext DB column if anything can read it.
Cache::put($this->pendingKey($email), [
'code' => $code,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES));
@@ -154,15 +162,15 @@ class UserOtpService
return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
if (! Hash::check($code, $user->otp_code_hash)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_code_hash = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
@@ -172,7 +180,7 @@ class UserOtpService
return null;
}
$user->otp_code = null;
$user->otp_code_hash = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
@@ -200,7 +208,7 @@ class UserOtpService
return null;
}
if (! hash_equals((string) $pending['code'], $code)) {
if (! Hash::check($code, $pending['code_hash'])) {
$pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_bank_transfer_heading' => [
'Bank transfer details',
'Στοιχεία τραπεζικής μεταφοράς',
],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [
'Choose a Box Now locker',
@@ -28,6 +28,7 @@ use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* The checkout page — one page, sections (contact / billing / shipping /
@@ -554,6 +555,8 @@ class CheckoutController extends Controller
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
'bankTransferInstructions' => app(StoreDetailsService::class)
->bankTransferInstructionsFor($order, $locale),
]);
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Database\Seeder;
use Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder;
use Modules\Core\Localization\Models\LanguageLine;
use ReflectionClass;
use ReflectionMethod;
/**
* The reverse of the translation seeders: copies lines added in the Filament
* Language Lines UI (e.g. while building the storefront) into the matching
* seeder's lines(), so they ship with core and every app gets them.
*
* Only adds keys the seeder doesn't have yet — a key that already exists in
* the seeder is left alone even if its text was edited in the database.
* New lines are appended at the end of lines() under a marker comment, to be
* moved into the right section by hand.
*
* Writes into the seeder files the app actually loaded, so it only makes
* sense in local mode, where vendor/boboko/core is a symlink to the
* ../boboko-core checkout. Against an installed copy it refuses to write;
* --dry-run works anywhere.
*/
class PullTranslationsCommand extends Command
{
protected $signature = 'boboko:translations:pull {--dry-run : Show what would be added without writing}';
protected $description = 'Add translation lines that exist in the database but not in the core translation seeders';
/** @var array<string, class-string<Seeder>> */
private const SEEDERS = [
'storefront' => StorefrontTranslationsSeeder::class,
'checkout' => CheckoutTranslationsSeeder::class,
'validation' => ValidationTranslationsSeeder::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$total = 0;
foreach (self::SEEDERS as $group => $seederClass) {
$file = realpath((new ReflectionClass($seederClass))->getFileName());
if (! $dryRun && str_contains($file, '/vendor/')) {
$this->error("{$file} is an installed copy, not your ../boboko-core checkout.");
$this->line('Switch to local mode first (bin/core-mode local), or use --dry-run.');
return self::FAILURE;
}
$known = (new ReflectionMethod($seederClass, 'lines'))->invoke(new $seederClass);
$missing = LanguageLine::query()
->where('group', $group)
->whereNotIn('key', array_keys($known))
->orderBy('key')
->get();
if ($missing->isEmpty()) {
$this->line("{$group}: nothing missing");
continue;
}
$entries = '';
foreach ($missing as $line) {
$en = $line->text['en'] ?? '';
$el = $line->text['el'] ?? '';
if ($en === '' || $el === '') {
$this->warn(" {$group}.{$line->key} has no ".($en === '' ? 'English' : 'Greek').' text — added empty, fill it in');
}
$entries .= $this->entry($line->key, $en, $el);
$this->info(" + {$group}.{$line->key}");
}
$total += $missing->count();
if (! $dryRun && ! $this->append($file, $entries)) {
return self::FAILURE;
}
}
$this->newLine();
$this->line($dryRun
? "{$total} line(s) would be added."
: "{$total} line(s) added — move them into the right section and commit core.");
return self::SUCCESS;
}
/**
* One lines() entry in the seeders' own style: single line when short,
* split over several lines when long.
*/
private function entry(string $key, string $en, string $el): string
{
[$key, $en, $el] = array_map(fn (string $value) => var_export($value, true), [$key, $en, $el]);
$single = " {$key} => [{$en}, {$el}],\n";
if (mb_strlen($single) <= 120) {
return $single;
}
return " {$key} => [\n {$en},\n {$el},\n ],\n";
}
/**
* Inserts the entries right before the closing `];` of lines(), then
* lints the file and restores the original if the result doesn't parse.
*/
private function append(string $file, string $entries): bool
{
$original = file_get_contents($file);
$method = strpos($original, 'function lines(): array');
$close = $method === false ? false : strpos($original, "\n ];\n }", $method);
if ($close === false) {
$this->error("Couldn't find the end of lines() in {$file} — add these by hand.");
return false;
}
$before = rtrim(substr($original, 0, $close));
// The last existing entry doesn't always have a trailing comma.
if (! str_ends_with($before, ',') && ! str_ends_with($before, '[')) {
$before .= ',';
}
$updated = $before
."\n\n // ── Pulled from the database (boboko:translations:pull) — move into the right section ──\n"
.$entries
.substr($original, $close + 1);
file_put_contents($file, $updated);
exec(PHP_BINARY.' -l '.escapeshellarg($file).' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
file_put_contents($file, $original);
$this->error("Writing {$file} produced invalid PHP — restored the original:");
$this->line(implode("\n", $output));
return false;
}
return true;
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ class CorePlugin implements Plugin
});
LunarStaff::addActivitylogExcept([
'otp_code',
'otp_code_hash',
'otp_expires_at',
'password',
'remember_token',
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
// secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on
// its own 10-minute window.
'otp_code' => null,
'otp_code_hash' => null,
'otp_expires_at' => null,
'otp_attempts' => 0,
]);
@@ -23,7 +23,7 @@ use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
* hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
* Auth\Services\UserOtpService's otp_code_hash/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed
@@ -98,6 +98,14 @@ class StorefrontTranslationsSeeder extends Seeder
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Error pages ─────────────────────────────────────────────
'errors.404_title' => ['Page not found', 'Η σελίδα δεν βρέθηκε'],
'errors.404_text' => [
'The page you are looking for does not exist or has been moved.',
'Η σελίδα που αναζητάς δεν υπάρχει ή έχει μετακινηθεί.',
],
'errors.back_home' => ['Back to home', 'Επιστροφή στην αρχική'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
@@ -39,8 +39,26 @@ class TransactionRecorder
* elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces).
*/
/**
* Idempotent on (order_id, type, reference): a successful payment
* outcome can legitimately be reported twice for the same gateway
* reference — e.g. Stripe's pay()/handleCallback() both call
* resultFromIntent() and both dispatch PaymentCaptured once a
* PaymentIntent reaches "succeeded" (checkout's synchronous capture,
* then the webhook confirming the same outcome asynchronously) — so
* this returns the existing row instead of writing a duplicate.
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{
$existing = $order->transactions()
->where('type', $type)
->where('reference', $result->reference)
->first();
if ($existing !== null) {
return $existing;
}
return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type,
@@ -33,6 +33,17 @@ class StripeWebhookMiddleware
$secret
);
} catch (UnexpectedValueException|SignatureVerificationException $e) {
\Illuminate\Support\Facades\Log::error('Stripe webhook signature verification failed', [
'signature_header' => $stripeSig,
'secret_prefix' => substr((string) $secret, 0, 12),
'secret_length' => strlen((string) $secret),
'body_length' => strlen($request->getContent()),
'body_sha256' => hash('sha256', $request->getContent()),
'body_raw' => $request->getContent(),
'content_type' => $request->header('Content-Type'),
'content_encoding' => $request->header('Content-Encoding'),
]);
abort(400, $e->getMessage());
}
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language;
use Modules\Core\Command\PullTranslationsCommand;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
@@ -46,5 +47,9 @@ class LocalizationServiceProvider extends ServiceProvider
}
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
if ($this->app->runningInConsole()) {
$this->commands([PullTranslationsCommand::class]);
}
}
}
+27 -3
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Lunar\Models\Order;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails;
@@ -39,8 +40,8 @@ class StoreDetailsService
}
/**
* Null for any non-bank-transfer order — the confirmation email only
* shows this block when there's actually a wire to send (see
* Null for any non-bank-transfer order — the confirmation email and page
* only show this block when there's actually a wire to send (see
* BankTransferPaymentDriver's own docblock for why a bank transfer
* order stays at 'awaiting_payment' until staff confirm the wire
* arrived). Null also when the store hasn't filled the field in for
@@ -66,7 +67,30 @@ class StoreDetailsService
return null;
}
return RichContentRenderer::make($content)->toHtml();
return $this->fillOrderReference(
RichContentRenderer::make($content)->toHtml(),
$order,
);
}
/**
* Replaces a `{order_reference}` (or `{{ order_reference }}`) the shop
* owner typed into the instructions with the order's display reference
* (OrderReferenceDisplay — same form as the email subject).
*
* A plain text replace rather than RichContentRenderer::mergeTags():
* that only fills genuine Tiptap mergeTag nodes, which this editor never
* creates — Lunar's TranslatedText can't pass mergeTags() through to its
* per-locale RichEditors, so the placeholder is always stored as
* ordinary typed text.
*/
private function fillOrderReference(string $html, Order $order): string
{
return preg_replace(
'/\{\{?\s*order_reference\s*\}\}?/',
e(OrderReferenceDisplay::resolve($order)),
$html,
);
}
public function update(array $attributes): StoreDetails