Merx – Plugin to create online shops with Kirby 3

Thank you! This works great. Maybe an improvement also for the Cookbook?

@bartvandebiezen See the docs: https://getkirby.com/docs/reference/objects/page/update#example

@tobiasfabian I’ve noticed that ‘completed’ invoices are actually public kirby pages, correct ?

What do you reccomend in order to avoid anybody but the user accessing the order page?

As far as I can understand Babyreport example page seems to use robots.txt to hopefully avoid the orders master page to be crawled, and it also seems like the order master page is not http visitable, not sure by which method is this achieve.

But I assume that individual orders are still visitable, and robots.txt could be potentially ignored by a crawler.

Is this correct? are there any other safeguards in place or reccomendations on how to deal with that?

Danke

2 Likes

hey!

i am curious if anybody has dealt with different shipping options in merx.
e. g. 2 different options for each:
shipping to europe
shipping worldwide

any thoughts on that would be helpful! i am especially interested in when in the checkout flow to add the shipping and how to get the right shipping options for the buyer’s location.

thank you very much in advance!

when i am done implementing it myself, i am happy to share how i solved it.

sigi

hi @sigi ,
see here:

edit: i guess the question was about different shipping options… for a page I’m currently working on, I’m letting the user choose their shipping method before they go to checkout. in my case, that’s “national” and “international”, which both have pages with a regular product template at content/shop/shipping/.

here’s the part of my controller:

// controllers/cart.php
if (kirby()->request()->method() === 'POST') {
    try {
        merx()->cart()->add([
          'id' => $_POST['shippingMethodId']
        ]);
      go('/checkout');
    } catch (Exception $ex) {
      echo $ex->getMessage();
      dump($ex->getDetails());
    }
}

return function ($page) {

    $shippingMethods = page('shop')->children()->find('shipping')->children();

    return [
        'shippingMethods' => $shippingMethods
    ];

};

++ in the template:

// templates/cart.php
// …
<?php foreach ($shippingMethods as $shippingMethod): ?>
    <input 
        type="radio" 
        name="shippingMethodId"
        id="<?= $shippingMethod->slug(); ?>" 
        value="<?= $shippingMethod->id(); ?>"
        required
    >
<?php endforeach; ?>

this is preeeettty basic + cann for sure use some improvement but hope it helps…

since the order page is only really needed to confirm a successful order, I could imagine it would be possible to only display its contents for logged in users after a certain timeout period, or only render it once upon a successful order?

Another idea would be to simply not have the order page contain any valuable information, for example blackout name/address and only display something relevant like the status of the order (‘confirmed’, ‘shipped’)…

A confirmation email with full order details should be sent anyways, I believe. One could generate some random hash for every completed order, and have the confirmation mail include a link to the order page with the parameter attached, without which the page won’t display…?
ex. https://yoursite.com/orders/ucvrarofkwltkjhv?5581672209 -> page renders normally
ex. https://yoursite.com/orders/ucvrarofkwltkjhv -> redirects to homepage

maybe a combination does the trick: display the order page once after a completed order, afterwards only to logged in users, except if the hash string matches.

if you want to go even further, i guess using a secure enough string, one could provide the option to change order details, i.e if a customer made a typo in their address or something, without needing to log in – but I’m faar from a security expert so I will not be taking any chances with this! :smiley:

edit:
thanks for bringing this point up. i’ve implemented this in a very basic way and seems to be working:

// config.php
<?php
return [
'hooks' => [
'ww.merx.completePayment:after' => function ($orderPage) {
    $hash = bin2hex(random_bytes(16));
    $urlWithHash = url($orderPage->url(), ['params' => ['q' => $hash]]);
    $orderPage->update([
      'hash' => $hash,
    ]);
    sendConfirmationMail($orderPage, $urlWithHash);
    go($urlWithHash);
  },
],
[
'email' => $your_email_settings,
]

function sendConfirmationMail($orderPage, $urlWithHash) {
  kirby()->email([
    'from' => 'test@your.site',
    'to' => (string)$orderPage->email(),
    'subject' => 'Thanks for your order!',
    'body'=> 'Dear ' . $orderPage->name() . ', you paid ' . formatPrice($orderPage->cart()->getSum()) . '. Order summary: ' . $urlWithHash,
  ]);
//  templates/order.php
<?php 
if (!kirby()->user()) {
    $hashparam = param('q');
    if ($hashparam !== $page->hash()->toString()) {
        sleep(1); // increased safety measure idk if it's necessary
        go('/'); 
    };
}
?>

<!-- order page -->

edit2: updated the snippets above to properly redirect the customer to the order page after their purchase

4 Likes

I (not a back-ender) just published my first Kirby + Merx website! :sweat_smile:

Dutch: https://theknitwitstable.nl
English https://theknitwitstable.nl/en

  • Kirby + Merx
  • iDeal (via Stripe) and PayPal
  • Multilingual
  • Light and Dark Mode
  • No JavaScript
  • Easy keyboard navigation
8 Likes

Thank you for bringing the security topic on the table. That’s definitely a very important topic. I’m no security expert so I’m very interested in your opinion.

Merx generates a random id/slug for every order page. The id is a random 16 characters long lowercase alphanumeric string (Str::random(16, 'alphaNum')). These are 36^16 possibilities ((10 numbers + 26 lowercase letters) × 16 characters) – tell me if I am wrong… I think it would take some time for an attacker to try each possible URL. But to be honest, I don’t know if this is enough security.

I like your idea @bruno. To show the page only with a valid hash prevents some potential security issues. If order pages accidentally appear in a sitemap (or similar) and some an attacker follows the order page links she/he will be redirected to the homepage because he/she does not know the hash.


For a current project I added another layer of security. The user has to enter his/her email address to show the invoice page. Which looks like this:


I created a working draft for security advices:

I would be pleased if you have further contributions.

As a side note: You don’t necessarily have to show the invoice details on the order page. You could for example send an email after a successful purchase and don’t render the order page at all.

2 Likes

I am not saying this is the way to go but I just finished working on a eCommerce site where the order page is “unpublished” after 10 mins. Is that bad for the customer? Could be. On the other hand they have received an email with the details. Just sharing the idea here.

PS Well done with your plugin. Its looking really good.

1 Like

thanks for the mention! :innocent: I’ve updated the snippets posted above, so the behaviour of my shop after a successful purchase is now to 1) generate an auth hash, 2) redirect the customer to the order page with the hash as a parameter, 3) and send them a mail with the link, also using the hash. I will also make sure to 4) add a robots header (or better yet, a corresponding robots.txt file, since the header will not be output if a request is redirected) and 5) exclude the order pages in any sitemap generated, as explained on the merx security page.

After briefly running this concept by a friend of mine who is a compsci student, I feel like this process is secure enough to show details like name and address on the order page – but all of this is untested, please don’t take my word for it. I’d be very interested to hear more opinions on this.

1 Like

Hi @bruno, thank you for your help! Definitely gonna look if I can implement some of your procedure into my checkout flow!

Letting the customer choose between national/international before checkout seems like a pretty smart idea to me to solve the problem with the shipping location and the different options.

Have a great day, Sigi

Really like the website! It’s beautiful and definitely doesn’t look like every other webshop!

1 Like

make sure to remove the previously existing shipping item from the cart if the customer leaves the checkout process and returns to cart later:

// controllers/cart.php
<?php 

foreach(merx()->cart() as $item):
    if (page($item['id'])->parent()->slug() == 'shipping'):
        merx()->cart()->remove($item['id']);
    endif;
endforeach;

I’m trying to implement stock management following the tutorial:

But when I complete an order, I get the following error:

Whoops \ Exception \ ErrorException (E_ERROR)
Cannot redeclare sendConfirmationMail() (previously declared in REDACTED/site/config/config.php:137)

my hook looks like this:

foreach($orderPage->cart() as $cartItem) {
    $cartItemPage = page($cartItem['id']);
    // make sure the item is a product, not a shipping option
    if ($cartItemPage->parent()->slug() == 'editions') {
      $newStock = $cartItemPage->stock()->toInt() - (int)$cartItem['quantity'];
      $cartItemPage->update([
        'stock' => (int)$newStock,
      ]);
    }
};
sendConfirmationMail($orderPage);

and the function in question:

function sendConfirmationMail($orderPage, $urlWithHash) {
  kirby()->email([
    'from' => 'test@your.site',
    'to' => (string)$orderPage->email(),
    'subject' => 'Thanks for your order!',
    'body'=> 'Dear ' . $orderPage->name() . ', you paid ' . formatPrice($orderPage->cart()->getSum()) . '. Order summary: ' . $orderPage->url(),
  ]);

the quantity gets updated successfully, but the confirmation mail isn’t sent… without the stock update section, everything else working well.

You can wrap the function into an if condition:

if (!function_exists('sendConfirmationMail')) {,
  function sendConfirmationMail($orderPage, $urlWithHash) {
    …
  }
}
1 Like

hmm, now it’s stuck in some sort of endless loading loop which to get out of i have to restart my server

edit:

edit2:
if i switch things around so that sendConfirmationMail() comes first in the hook, i get
Call to undefined function sendConfirmationMail()…

edit3:
solved, I made the mistake of defining sendConfirmationMail() after the config return statement. still thanks for your help :~)

Hello again, I am really struggling with the success controller, I found in the babyreport project. As it is recommended there, I initialize the payment in shop-api/submit and redirect to /success afterwards. The success controller is called like expected, where a $_GET parameter is passed to completePayment, but in my case $_GET is always empty. Hence, the payment completion fails, because source data is missing. Where should the controller receive the $_GET request from?

Admittedly, very rarely it is working and creating a source object in stripe, but unfortunately it’s not reliable…

Would be glad about any kind of hints :surfing_man:t2:‍♂
edit: currently I am dealing with credit-card and sepa-debit purchases

I used the checkout controller (only the if statement) from the other example: https://github.com/wagnerwagner/merx-examples/blob/plainkit/site/controllers/checkout.php

Would that help?

Thanks for your response!
Unfortunately it didn’t help but through considering your hint, I realize that the $_GET request containing an empty array is received. But it isn’t needed anyway, because the source is called somehow else.

Anyway, the sources are created in stripe more reliable now. As I deep down the error trace I noticed some problem in stripe communication. In general the Stripe Charge can’t be created for some reason… Maybe because of http version and the missing SSL certificate in localhost.

If I have one item with a quantity of 5 in the cart, cart()->count() returns 1.

I’d like to implement a Cart method that returns the actual count of items in the cart, i.e

class CountedCart extends Cart {
  public function properCount() {
    $count = 0;
    foreach ($this->data() as $data) {
      $count += $data['quantity'];
    }
    return $count;
  }
}

I don’t have a lot of experience with classes in PHP. Would this even be possible? Otherwise I’m just gonna fall back to something like

function properCount($items) {
  $count = 0;
  foreach ($items as $item) {
    $count += $item['quantity'];
  }
  return $count;
} 

properCount(cart()->data())
1 Like