Moved stripe listener
This commit is contained in:
parent
d772db6436
commit
a17c5cf686
|
@ -1,7 +1,7 @@
|
||||||
# Ports
|
# Ports
|
||||||
SITE_PORT="8080"
|
SITE_PORT="8080"
|
||||||
API_PORT="8081"
|
API_PORT="8081"
|
||||||
HOOK_PORT="8082"
|
STRIPE_LISTENER_PORT="8082"
|
||||||
|
|
||||||
# Domains
|
# Domains
|
||||||
SITE_DOMAIN="https://www.example.com"
|
SITE_DOMAIN="https://www.example.com"
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
|
@ -276,7 +276,7 @@ class Cart { constructor() {
|
||||||
if (self.payment_method()==='USD')
|
if (self.payment_method()==='USD')
|
||||||
window.location = `/shop/checkout/stripe/?order=${parsedRes.id}`
|
window.location = `/shop/checkout/stripe/?order=${parsedRes.id}`
|
||||||
else if (self.payment_method()==='XMR')
|
else if (self.payment_method()==='XMR')
|
||||||
window.location = `/shop/checkout/monero/?order=${parsedRes.id}`
|
window.location = `/shop/order/monero/?order=${parsedRes.id}`
|
||||||
else {
|
else {
|
||||||
alert(`Invalid payment method: ${self.payment_method()}`)
|
alert(`Invalid payment method: ${self.payment_method()}`)
|
||||||
self.isLoading(false)
|
self.isLoading(false)
|
||||||
|
|
|
@ -1,121 +0,0 @@
|
||||||
---
|
|
||||||
layout: layouts/base.njk
|
|
||||||
title: Checkout
|
|
||||||
---
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#qr_wrapper {
|
|
||||||
max-width: 80vh;
|
|
||||||
width: 80vw;
|
|
||||||
height: 80vh;
|
|
||||||
max-height: 80vw;
|
|
||||||
padding: calc(1.5vh + 1.5vw);
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
#qr_wrapper div {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<link rel="stylesheet" href="/assets/shop.css">
|
|
||||||
<h1><img src="/assets/img/monero.svg" style="width:1em;height:1em"> Monero Checkout</h1>
|
|
||||||
<table class="tal" style="width:100%"><thead>
|
|
||||||
<th>Item</th>
|
|
||||||
<th class="tar">Price</th>
|
|
||||||
<th class="tar">Qty</th>
|
|
||||||
<th class="tar">Sub</th>
|
|
||||||
</thead><tbody data-bind="foreach:items"><tr>
|
|
||||||
<td data-bind="text:name"></td>
|
|
||||||
<td class="tar" data-bind="text:price"></td>
|
|
||||||
<td class="tar" data-bind="text:qty" style="width:2rem"></td>
|
|
||||||
<td class="tar" data-bind="text:subtotal"></td>
|
|
||||||
</tr></tbody></table>
|
|
||||||
<hr>
|
|
||||||
<div class="flex" style="align-items:flex-start">
|
|
||||||
<p id="shipping-container">
|
|
||||||
<b>📦 <span class="ul">Shipping</span></b><br>
|
|
||||||
<span data-bind="text:shipname"></span>
|
|
||||||
<br><span data-bind="text:addr1"></span>
|
|
||||||
<br><span data-bind="text:addr2"></span>
|
|
||||||
<br><span data-bind="text:city"></span>,
|
|
||||||
<span data-bind="text:state"></span>
|
|
||||||
<span data-bind="text:zip"></span>
|
|
||||||
</p>
|
|
||||||
<p id="contact-container">
|
|
||||||
<b>📇 <span class="ul">Contact</ul></b><br>
|
|
||||||
<span data-bind="text:contactname"></span>
|
|
||||||
<br><span data-bind="text:phone"></span>
|
|
||||||
<br><span data-bind="text:email"></span>
|
|
||||||
</p>
|
|
||||||
<div id="totals-container"><table><tbody>
|
|
||||||
<tr>
|
|
||||||
<td><b>Subtotal:</b></td>
|
|
||||||
<td class="tar"><b data-bind="text:subtotal"></b></td>
|
|
||||||
</tr><tr>
|
|
||||||
<td>Tax:</td>
|
|
||||||
<td class="tar" data-bind="text:tax"></td>
|
|
||||||
</tr><tr>
|
|
||||||
<td>Shipping:</td>
|
|
||||||
<td class="tar" data-bind="text:shipping"></td>
|
|
||||||
</tr><tr style="border-top:.1vh solid">
|
|
||||||
<td>Total:</td>
|
|
||||||
<td class="tar" data-bind="text:total"></td>
|
|
||||||
</tr><tr>
|
|
||||||
<td><b>Monero:</b></td>
|
|
||||||
<td class="tar"><b data-bind="text:totalxmr_pretty"></b>
|
|
||||||
</td></tr>
|
|
||||||
</tbody></table></div>
|
|
||||||
</div>
|
|
||||||
<p data-bind="hidden:isPaid">To change order details, <a href="#" onclick="cancel()">cancel it and return to your cart</a>.</p>
|
|
||||||
<hr>
|
|
||||||
<h2>Payment status</h2>
|
|
||||||
<p>Your order is for <code data-bind="text:totalxmr_pretty"></code>.
|
|
||||||
<br><code data-bind="text:submitted_pretty"></code> have been submitted to the blockchain.
|
|
||||||
<br><code data-bind="text:unlocked_pretty"></code> have been unlocked.
|
|
||||||
</p>
|
|
||||||
<p data-bind="hidden:isPaid">Your order will ship when <span data-bind="text:totalxmr_pretty"></span> have been unlocked. (Transactions unlock after receiving 10 confirmations.)</p>
|
|
||||||
<p data-bind="visible:isPaid">Your payment has been confirmed. We will ship your order as soon as possible and send an email with the tracking info.</p>
|
|
||||||
<h3 data-bind="visible:transactions">Transactions</h3>
|
|
||||||
<table data-bind="visible:transactions">
|
|
||||||
<thead>
|
|
||||||
<th class="tal">Date</th>
|
|
||||||
<th class="tal">Time</th>
|
|
||||||
<th class="tal">Amt</th>
|
|
||||||
<th class="tal">Fee</th>
|
|
||||||
<th class="tal">Confs</th>
|
|
||||||
<th class="tal">Block</th>
|
|
||||||
<th class="tal">Stat</th>
|
|
||||||
</thead>
|
|
||||||
<tbody data-bind="foreach:transactions"><tr>
|
|
||||||
<td class="tal" data-bind="text:date"></td>
|
|
||||||
<td class="tal" data-bind="text:time"></td>
|
|
||||||
<td class="tal" data-bind="text:amount"></td>
|
|
||||||
<td class="tal" data-bind="text:fee"></td>
|
|
||||||
<td class="tal" data-bind="text:confirmations"></td>
|
|
||||||
<td class="tal" data-bind="text:height"></td>
|
|
||||||
<td class="tal" style="cursor:default">
|
|
||||||
<span data-bind="visible:locked" title="LOCKED: Wait for 10 confirmations for this transaction to unlock">⏲️</span>
|
|
||||||
<span data-bind="visible:double_spend_seen" title="DOUBLE SPENT! Double-spend has been detected! This transaction is invalid. Contact {{metadata.sales.email}} to resolve.">⛔</span>
|
|
||||||
<span data-bind="hidden:(locked||double_spend_seen)" title="CONFIRMED! This transaction is valid and has been confirmed by the blockchain.">✅</span>
|
|
||||||
</td>
|
|
||||||
</tr></tbody>
|
|
||||||
</table>
|
|
||||||
<p><i data-bind="text:checkingStatus"></i></p>
|
|
||||||
<hr>
|
|
||||||
<div data-bind="hidden:isPaid">
|
|
||||||
<h2>Monero payment</h2>
|
|
||||||
<p>Please send <code data-bind="text:unsubmitted"></code> XMR to this address: </p>
|
|
||||||
<pre><code data-bind="text:xmr_address"></code></pre>
|
|
||||||
<p>You can also click, tap, or scan this qr code:</p>
|
|
||||||
<a data-bind="attr:{href:xmr_uri}"><div id="qr_wrapper"><div id="xmr_qr"></div></div></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_DOMAIN = '{{env.API_DOMAIN}}'
|
|
||||||
const MONERO_CHECKOUT_POLL_SECS = '{{env.MONERO_CHECKOUT_POLL_SECS}}'
|
|
||||||
</script>
|
|
||||||
<script src="/assets/scripts/lib/qrcode.min.js" integrity="sha256-CxytvzTC2wdC92a5wqFc1DTkbS+uuQ8N2NgxGcuqoDc="></script>
|
|
||||||
<script src="/assets/scripts/lib/knockout-3.5.1.min.js" integrity="sha256-6JV7sYKlBHsHvqCkn9IrEWFLGrmsW4KG/LIln0hljnM="></script>
|
|
||||||
<script src="/assets/scripts/lib/socket-io_4-5-4.min.js" integrity="sha256-GKNqkn2sVGULGLkD+Ph3ghngLhOUblgdmz4eSZX3Q1s="></script>
|
|
||||||
<script src="/assets/scripts/checkout/monero.js" defer></script>
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: Checkout
|
||||||
|
---
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#qr_wrapper {
|
||||||
|
max-width: 80vh;
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vh;
|
||||||
|
max-height: 80vw;
|
||||||
|
padding: calc(1.5vh + 1.5vw);
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
#qr_wrapper div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/assets/shop.css">
|
||||||
|
<h1><img src="/assets/img/monero.svg" style="width:1em;height:1em"> Monero Checkout</h1>
|
||||||
|
<table class="tal" style="width:100%"><thead>
|
||||||
|
<th>Item</th>
|
||||||
|
<th class="tar">Price</th>
|
||||||
|
<th class="tar">Qty</th>
|
||||||
|
<th class="tar">Sub</th>
|
||||||
|
</thead><tbody data-bind="foreach:items"><tr>
|
||||||
|
<td data-bind="text:name"></td>
|
||||||
|
<td class="tar" data-bind="text:price"></td>
|
||||||
|
<td class="tar" data-bind="text:qty" style="width:2rem"></td>
|
||||||
|
<td class="tar" data-bind="text:subtotal"></td>
|
||||||
|
</tr></tbody></table>
|
||||||
|
<hr>
|
||||||
|
<div class="flex" style="align-items:flex-start">
|
||||||
|
<p id="shipping-container">
|
||||||
|
<b>📦 <span class="ul">Shipping</span></b><br>
|
||||||
|
<span data-bind="text:shipname"></span>
|
||||||
|
<br><span data-bind="text:addr1"></span>
|
||||||
|
<br><span data-bind="text:addr2"></span>
|
||||||
|
<br><span data-bind="text:city"></span>,
|
||||||
|
<span data-bind="text:state"></span>
|
||||||
|
<span data-bind="text:zip"></span>
|
||||||
|
</p>
|
||||||
|
<p id="contact-container">
|
||||||
|
<b>📇 <span class="ul">Contact</ul></b><br>
|
||||||
|
<span data-bind="text:contactname"></span>
|
||||||
|
<br><span data-bind="text:phone"></span>
|
||||||
|
<br><span data-bind="text:email"></span>
|
||||||
|
</p>
|
||||||
|
<div id="totals-container"><table><tbody>
|
||||||
|
<tr>
|
||||||
|
<td><b>Subtotal:</b></td>
|
||||||
|
<td class="tar"><b data-bind="text:subtotal"></b></td>
|
||||||
|
</tr><tr>
|
||||||
|
<td>Tax:</td>
|
||||||
|
<td class="tar" data-bind="text:tax"></td>
|
||||||
|
</tr><tr>
|
||||||
|
<td>Shipping:</td>
|
||||||
|
<td class="tar" data-bind="text:shipping"></td>
|
||||||
|
</tr><tr style="border-top:.1vh solid">
|
||||||
|
<td>Total:</td>
|
||||||
|
<td class="tar" data-bind="text:total"></td>
|
||||||
|
</tr><tr>
|
||||||
|
<td><b>Monero:</b></td>
|
||||||
|
<td class="tar"><b data-bind="text:totalxmr_pretty"></b>
|
||||||
|
</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<p data-bind="hidden:isPaid">To change order details, <a href="#" onclick="cancel()">cancel it and return to your cart</a>.</p>
|
||||||
|
<hr>
|
||||||
|
<h2>Payment status</h2>
|
||||||
|
<p>Your order is for <code data-bind="text:totalxmr_pretty"></code>.
|
||||||
|
<br><code data-bind="text:submitted_pretty"></code> have been submitted to the blockchain.
|
||||||
|
<br><code data-bind="text:unlocked_pretty"></code> have been unlocked.
|
||||||
|
</p>
|
||||||
|
<p data-bind="hidden:isPaid">Your order will ship when <span data-bind="text:totalxmr_pretty"></span> have been unlocked. (Transactions unlock after receiving 10 confirmations.)</p>
|
||||||
|
<p data-bind="visible:isPaid">Your payment has been confirmed. We will ship your order as soon as possible and send an email with the tracking info.</p>
|
||||||
|
<h3 data-bind="visible:transactions">Transactions</h3>
|
||||||
|
<table data-bind="visible:transactions">
|
||||||
|
<thead>
|
||||||
|
<th class="tal">Date</th>
|
||||||
|
<th class="tal">Time</th>
|
||||||
|
<th class="tal">Amt</th>
|
||||||
|
<th class="tal">Fee</th>
|
||||||
|
<th class="tal">Confs</th>
|
||||||
|
<th class="tal">Block</th>
|
||||||
|
<th class="tal">Stat</th>
|
||||||
|
</thead>
|
||||||
|
<tbody data-bind="foreach:transactions"><tr>
|
||||||
|
<td class="tal" data-bind="text:date"></td>
|
||||||
|
<td class="tal" data-bind="text:time"></td>
|
||||||
|
<td class="tal" data-bind="text:amount"></td>
|
||||||
|
<td class="tal" data-bind="text:fee"></td>
|
||||||
|
<td class="tal" data-bind="text:confirmations"></td>
|
||||||
|
<td class="tal" data-bind="text:height"></td>
|
||||||
|
<td class="tal" style="cursor:default">
|
||||||
|
<span data-bind="visible:locked" title="LOCKED: Wait for 10 confirmations for this transaction to unlock">⏲️</span>
|
||||||
|
<span data-bind="visible:double_spend_seen" title="DOUBLE SPENT! Double-spend has been detected! This transaction is invalid. Contact {{metadata.sales.email}} to resolve.">⛔</span>
|
||||||
|
<span data-bind="hidden:(locked||double_spend_seen)" title="CONFIRMED! This transaction is valid and has been confirmed by the blockchain.">✅</span>
|
||||||
|
</td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table>
|
||||||
|
<p><i data-bind="text:checkingStatus"></i></p>
|
||||||
|
<hr>
|
||||||
|
<div data-bind="hidden:isPaid">
|
||||||
|
<h2>Monero payment</h2>
|
||||||
|
<p>Please send <code data-bind="text:unsubmitted"></code> XMR to this address: </p>
|
||||||
|
<pre><code data-bind="text:xmr_address"></code></pre>
|
||||||
|
<p>You can also click, tap, or scan this qr code:</p>
|
||||||
|
<a data-bind="attr:{href:xmr_uri}"><div id="qr_wrapper"><div id="xmr_qr"></div></div></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_DOMAIN = '{{env.API_DOMAIN}}'
|
||||||
|
const MONERO_CHECKOUT_POLL_SECS = '{{env.MONERO_CHECKOUT_POLL_SECS}}'
|
||||||
|
</script>
|
||||||
|
<script src="/assets/scripts/lib/qrcode.min.js" integrity="sha256-CxytvzTC2wdC92a5wqFc1DTkbS+uuQ8N2NgxGcuqoDc="></script>
|
||||||
|
<script src="/assets/scripts/lib/knockout-3.5.1.min.js" integrity="sha256-6JV7sYKlBHsHvqCkn9IrEWFLGrmsW4KG/LIln0hljnM="></script>
|
||||||
|
<script src="/assets/scripts/lib/socket-io_4-5-4.min.js" integrity="sha256-GKNqkn2sVGULGLkD+Ph3ghngLhOUblgdmz4eSZX3Q1s="></script>
|
||||||
|
<script src="/assets/scripts/order/monero.js" defer></script>
|
|
@ -64,14 +64,14 @@ services:
|
||||||
- "./api:/app/api:ro"
|
- "./api:/app/api:ro"
|
||||||
- "./lib:/app/lib:ro"
|
- "./lib:/app/lib:ro"
|
||||||
|
|
||||||
stripe-hook:
|
stripe-listener:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build: .
|
build: .
|
||||||
command: stripe-webhook
|
command: stripe-listener
|
||||||
container_name: my_stripe-webhook
|
container_name: my_stripe-listener
|
||||||
environment:
|
environment:
|
||||||
- STRIPE_SEC=${STRIPE_SEC}
|
- STRIPE_SEC=${STRIPE_SEC}
|
||||||
- HOOK_PORT=${HOOK_PORT}
|
- STRIPE_LISTENER_PORT=${STRIPE_LISTENER_PORT}
|
||||||
- SALES_EMAIL=${SALES_EMAIL}
|
- SALES_EMAIL=${SALES_EMAIL}
|
||||||
- MAIL_SERVER=${MAIL_SERVER}
|
- MAIL_SERVER=${MAIL_SERVER}
|
||||||
- MAIL_USER=${MAIL_USER}
|
- MAIL_USER=${MAIL_USER}
|
||||||
|
@ -81,7 +81,8 @@ services:
|
||||||
- "./_src:/app/_src"
|
- "./_src:/app/_src"
|
||||||
- "./orders:/app/orders"
|
- "./orders:/app/orders"
|
||||||
- "./sold:/app/sold"
|
- "./sold:/app/sold"
|
||||||
- "./stripe-webhook:/app/stripe-webhook:ro"
|
- "./listeners:/app/listeners:ro"
|
||||||
|
- "./hooks:/app/hooks:ro"
|
||||||
- "./lib:/app/lib:ro"
|
- "./lib:/app/lib:ro"
|
||||||
|
|
||||||
moneropay:
|
moneropay:
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use strict'
|
||||||
|
require('dotenv').config()
|
||||||
|
const fs = require('fs').promises
|
||||||
|
const express = require('express')
|
||||||
|
const app = express()
|
||||||
|
const ORDERS_DIR = `${__dirname}/../orders`
|
||||||
|
app.listen(process.env.MONERO_LISTENER_PORT)
|
||||||
|
.post('/', express.json(), async (req) => {
|
||||||
|
if (req.body.data.object.object==='charge') {
|
||||||
|
|
||||||
|
// Check if paid
|
||||||
|
if (!req.body.data.object.paid)
|
||||||
|
return console.log(`[${req.body.id}] Charge unpaid!`)
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
const orderId = req.body.data.object.metadata.id
|
||||||
|
if (orderId == null)
|
||||||
|
return console.error(`[${req.body.id}] Charge has no metadata.id!`)
|
||||||
|
else
|
||||||
|
console.log(`[${req.body.id}] Charge paid for order ${orderId}`)
|
||||||
|
|
||||||
|
// Get order file
|
||||||
|
const orderFile = `${ORDERS_DIR}/${orderId}.json`
|
||||||
|
let order; try {
|
||||||
|
order = await JSON.parse(await fs.readFile(orderFile))
|
||||||
|
} catch (err) {
|
||||||
|
return console.error(`[${req.body.id}] Failed to retrieve order from ${orderFile}:\n${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save paidDate to order
|
||||||
|
try { order.paidDate = new Date()
|
||||||
|
fs.writeFile(orderFile, JSON.stringify(order,null,2))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${req.body.id}] Failed to write paidDate to ${orderFile}:\n${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email customer
|
||||||
|
require('../hooks/email-customer')(order)
|
||||||
|
|
||||||
|
// Ntfy sales team
|
||||||
|
require('../hooks/ntfy-sales')(order)
|
||||||
|
|
||||||
|
// Email sales team
|
||||||
|
require('../hooks/email-sales')(order)
|
||||||
|
|
||||||
|
// Remove single products from store
|
||||||
|
require('./remove-sid')(order)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
|
@ -3,14 +3,13 @@ require('dotenv').config()
|
||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
const ORDERS_DIR = `${__dirname}/../orders`
|
const ORDERS_DIR = `${__dirname}/../orders`
|
||||||
|
|
||||||
// Start webhook forwarder so we don't need a public endpoint
|
// Start webhook forwarder so we don't need a public endpoint
|
||||||
require('../lib/run.js')(`stripe listen --api-key '${process.env.STRIPE_RES}' --forward-to 'http://localhost:${process.env.HOOK_PORT}/' --format 'JSON'`)
|
require('../lib/run.js')(`stripe listen --api-key '${process.env.STRIPE_RES}' --forward-to 'http://localhost:${process.env.HOOK_PORT}/' --format 'JSON'`)
|
||||||
|
|
||||||
// Receive that webhook
|
// Receive that webhook
|
||||||
app.listen(process.env.HOOK_PORT)
|
app.listen(process.env.STRIPE_LISTENER_PORT)
|
||||||
|
|
||||||
app.post('/', express.json(), async (req) => {
|
app.post('/', express.json(), async (req) => {
|
||||||
if (req.body.data.object.object==='charge') {
|
if (req.body.data.object.object==='charge') {
|
||||||
|
@ -42,16 +41,16 @@ app.post('/', express.json(), async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email customer
|
// Email customer
|
||||||
require('./email-customer')(order)
|
require('../hooks/email-customer')(order)
|
||||||
|
|
||||||
// Ntfy sales team
|
// Ntfy sales team
|
||||||
require('./ntfy-sales')(order)
|
require('../hooks/ntfy-sales')(order)
|
||||||
|
|
||||||
// Email sales team
|
// Email sales team
|
||||||
require('./email-sales')(order)
|
require('../hooks/email-sales')(order)
|
||||||
|
|
||||||
// Remove single products from store
|
// Remove single products from store
|
||||||
require('./remove-sid')(order)
|
require('../hooks/remove-sid')(order)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,8 @@
|
||||||
"serve": "npx @11ty/eleventy --quiet --serve",
|
"serve": "npx @11ty/eleventy --quiet --serve",
|
||||||
"json": "npx @11ty/eleventy --to=json",
|
"json": "npx @11ty/eleventy --to=json",
|
||||||
"api": "node api/index.js",
|
"api": "node api/index.js",
|
||||||
"stripe-webhook": "node stripe-webhook/index.js"
|
"stripe-listener": "node listeners/stripe.js",
|
||||||
|
"monero-listener": "node listeners/monero.js"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Keith Irwin",
|
"name": "Keith Irwin",
|
||||||
|
|
Loading…
Reference in New Issue