diff --git a/package-lock.json b/package-lock.json index e0b1e09..e6ab64d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -586,26 +586,6 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==" }, - "node_modules/body-parser": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", - "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", - "dependencies": { - "bytes": "3.1.0", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.7.0", - "raw-body": "2.4.0", - "type-is": "~1.6.17" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -872,14 +852,6 @@ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, - "node_modules/bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -1815,6 +1787,48 @@ "resolved": "https://registry.npmjs.org/express-locale/-/express-locale-2.0.0.tgz", "integrity": "sha512-SUwsgOTV8lplAkgyfKSwKH3ZVSKUrAa+b48acPGXpwWX211WZ/BYWX2WcZO57+xLOqPYb6rSNpWCFnhGrt0moA==" }, + "node_modules/express/node_modules/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "dependencies": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "dependencies": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -3570,20 +3584,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", - "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", - "dependencies": { - "bytes": "3.1.0", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/read-only-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", @@ -4993,23 +4993,6 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==" }, - "body-parser": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", - "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", - "requires": { - "bytes": "3.1.0", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.7.0", - "raw-body": "2.4.0", - "type-is": "~1.6.17" - } - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -5239,11 +5222,6 @@ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, - "bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" - }, "cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -5988,6 +5966,41 @@ "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" + }, + "dependencies": { + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + } } }, "express-handlebars": { @@ -7312,17 +7325,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, - "raw-body": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", - "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", - "requires": { - "bytes": "3.1.0", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, "read-only-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", diff --git a/public/assets/js/account.js b/public/assets/js/account.js index 23618d4..e5afb9f 100644 --- a/public/assets/js/account.js +++ b/public/assets/js/account.js @@ -1,26 +1,3 @@ - -document.getElementById('remove-discord-connection')?.addEventListener('click', () => { - // TODO: Refresh access token if expired (move this to the backend maybe?) - - const tokenType = document.cookie.split('; ').find(row => row.startsWith('token_type=')).split('=')[1]; - const accessToken = document.cookie.split('; ').find(row => row.startsWith('access_token=')).split('=')[1]; - - fetch('https://api.pretendo.cc/v1/connections/remove/discord', { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `${tokenType} ${decodeURIComponent(accessToken)}` - } - }) - .then(response => response.json()) - .then(({ status }) => { - if (status === 200) { - location.reload(); - } - }) - .catch(console.log); -}); - const onlineFilesModal = document.querySelector('.modal-wrapper#onlinefiles'); const onlineFilesModalButtonConfirm = document.getElementById('onlineFilesConfirmButton'); const onlineFilesModalButtonClose = document.getElementById('onlineFilesCloseButton'); diff --git a/src/routes/account.js b/src/routes/account.js index 1162331..71b8b87 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -147,6 +147,7 @@ router.get('/logout', async(_request, response) => { }); router.get('/connect/discord', requireLoginMiddleware, async (request, response) => { + const { pnid } = request; let tokens; try { // Attempt to get OAuth2 tokens @@ -166,8 +167,52 @@ router.get('/connect/discord', requireLoginMiddleware, async (request, response) try { await util.updateDiscordConnection(discordUser, request, response); + const priceId = pnid.get('connections.stripe.price_id'); + + if (priceId && priceId.trim() !== '') { + const price = await stripe.prices.retrieve(priceId); + const product = await stripe.products.retrieve(price.product); + const discordRoleId = product.metadata.discord_role_id; + const discordId = discordUser.id; + + await util.assignDiscordMemberSupporterRole(discordId, discordRoleId); + + if (product.metadata.beta === 'true') { + await util.assignDiscordMemberTesterRole(discordId); + } + } + response.cookie('success_message', 'Discord account linked successfully', { domain: '.pretendo.network' }); - response.redirect('/account'); + return response.redirect('/account'); + } catch (error) { + response.cookie('error_message', error.message, { domain: '.pretendo.network' }); + return response.redirect('/account'); + } +}); + +router.get('/remove/discord', requireLoginMiddleware, async (request, response) => { + const { account, pnid } = request; + + try { + await util.removeDiscordConnection(request, response); + + const priceId = pnid.get('connections.stripe.price_id'); + + if (priceId && priceId.trim() !== '') { + const price = await stripe.prices.retrieve(priceId); + const product = await stripe.products.retrieve(price.product); + const discordRoleId = product.metadata.discord_role_id; + const discordId = account.connections.discord.id; + + await util.removeDiscordMemberSupporterRole(discordId, discordRoleId); + + if (product.metadata.beta === 'true') { + await util.removeDiscordMemberTesterRole(discordId); + } + } + + response.cookie('success_message', 'Discord account removed successfully', { domain: '.pretendo.network' }); + return response.redirect('/account'); } catch (error) { response.cookie('error_message', error.message, { domain: '.pretendo.network' }); return response.redirect('/account'); @@ -298,8 +343,8 @@ router.post('/stripe/checkout/:priceId', requireLoginMiddleware, async (request, const pnid = await database.PNID.findOne({ pid }); if (pnid.get('access_level') >= 2) { - response.cookie('error_message', 'Staff members do not need to purchase tiers', { domain: '.pretendo.network' }); - return response.redirect('/account'); + //response.cookie('error_message', 'Staff members do not need to purchase tiers', { domain: '.pretendo.network' }); + //return response.redirect('/account'); } try { @@ -365,14 +410,15 @@ router.post('/stripe/unsubscribe', requireLoginMiddleware, async (request, respo return response.redirect('/account'); }); -router.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (request, response) => { +router.post('/stripe/webhook', async (request, response) => { const stripeSignature = request.headers['stripe-signature']; let event; try { - event = stripe.webhooks.constructEvent(request.body, stripeSignature, config.stripe.webhook_secret); - } catch (err) { - return response.status(400).send(`Webhook Error: ${err.message}`); + event = stripe.webhooks.constructEvent(request.rawBody, stripeSignature, config.stripe.webhook_secret); + } catch (error) { + logger.error(error.message); + return response.status(400).send(`Webhook Error: ${error.message}`); } await util.handleStripeEvent(event); diff --git a/src/server.js b/src/server.js index 7d882bb..002ca73 100644 --- a/src/server.js +++ b/src/server.js @@ -20,7 +20,8 @@ const stripe = new Stripe(config.stripe.secret_key); logger.info('Setting up Middleware'); app.use(morgan('dev')); -app.use(express.json()); +//app.use(express.json()); +app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); app.use(express.urlencoded({ extended: true })); diff --git a/src/util.js b/src/util.js index 3b6bb97..6598b31 100644 --- a/src/util.js +++ b/src/util.js @@ -4,11 +4,14 @@ const got = require('got'); const crypto = require('crypto'); const Stripe = require('stripe'); const { marked } = require('marked'); +const { REST: DiscordRest } = require('@discordjs/rest'); +const { Routes: DiscordRoutes } = require('discord-api-types/v10'); const mailer = require('./mailer'); const database = require('./database'); const logger = require('./logger'); const config = require('../config.json'); +const discordRest = new DiscordRest({ version: '10' }).setToken(config.discord.bot_token); const stripe = new Stripe(config.stripe.secret_key); @@ -78,7 +81,7 @@ function apiGetRequest(path, headers) { }); } -function apiPostGetRequest(path, headers, json) { +function apiPostRequest(path, headers, json) { return got.post(`https://api.pretendo.cc${path}`, { responseType: 'json', throwHttpErrors: false, @@ -90,7 +93,7 @@ function apiPostGetRequest(path, headers, json) { }); } -function apiDeleteGetRequest(path, headers, json) { +function apiDeleteRequest(path, headers, json) { return got.delete(`https://api.pretendo.cc${path}`, { throwHttpErrors: false, https: { @@ -102,7 +105,7 @@ function apiDeleteGetRequest(path, headers, json) { } async function register(registerData) { - const apiResponse = await apiPostGetRequest('/v1/register', {}, registerData); + const apiResponse = await apiPostRequest('/v1/register', {}, registerData); if (apiResponse.statusCode !== 200) { throw new Error(apiResponse.body.error); @@ -112,7 +115,7 @@ async function register(registerData) { } async function login(username, password) { - const apiResponse = await apiPostGetRequest('/v1/login', {}, { + const apiResponse = await apiPostRequest('/v1/login', {}, { username, password, grant_type: 'password' @@ -126,7 +129,7 @@ async function login(username, password) { } async function refreshLogin(request, response) { - const apiResponse = await apiPostGetRequest('/v1/login', {}, { + const apiResponse = await apiPostRequest('/v1/login', {}, { refresh_token: request.cookies.refresh_token, grant_type: 'refresh_token' }); @@ -166,7 +169,7 @@ async function getUserAccountData(request, response, fromRetry=false) { } async function updateDiscordConnection(discordUser, request, response, fromRetry=false) { - const apiResponse = await apiPostGetRequest('/v1/connections/add/discord', { + const apiResponse = await apiPostRequest('/v1/connections/add/discord', { 'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}` }, { data: { @@ -185,6 +188,22 @@ async function updateDiscordConnection(discordUser, request, response, fromRetry } } +async function removeDiscordConnection(request, response, fromRetry = false) { + const apiResponse = await apiDeleteRequest('/v1/connections/remove/discord', { + 'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}` + }); + + if (apiResponse.statusCode !== 200 && fromRetry === true) { + // TODO: Error message + throw new Error('Bad'); + } + + if (apiResponse.statusCode !== 200) { + await refreshLogin(request, response); + await removeDiscordConnection(request, response, true); + } +} + function nintendoPasswordHash(password, pid) { const pidBuffer = Buffer.alloc(4); pidBuffer.writeUInt32LE(pid); @@ -270,9 +289,19 @@ async function handleStripeEvent(event) { } const currentSubscriptionId = pnid.get('connections.stripe.subscription_id'); + const discordId = pnid.get('connections.discord.id'); if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) { - // Canceling old subscription, do nothing but update webhook date + // Canceling old subscription, do nothing but update webhook date and remove Discord roles + if (product.metadata.beta === 'true') { + removeDiscordMemberTesterRole(discordId).catch(error => { + logger.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} | - ${error.message}`); + }); + } + + removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch(error => { + logger.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} | - ${error.message}`); + }); const updateData = { 'connections.stripe.latest_webhook_timestamp': event.created @@ -302,6 +331,10 @@ async function handleStripeEvent(event) { if (pnid.access_level < 2) { // only change access level if not staff member updateData.access_level = 1; } + + assignDiscordMemberTesterRole(discordId).catch(error => { + logger.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} | - ${error.message}`); + }); break; case 'canceled': // Subscription was canceled @@ -309,6 +342,10 @@ async function handleStripeEvent(event) { if (pnid.access_level < 2) { // only change access level if not staff member updateData.access_level = 0; } + + removeDiscordMemberTesterRole(discordId).catch(error => { + logger.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} | - ${error.message}`); + }); break; default: @@ -353,6 +390,10 @@ async function handleStripeEvent(event) { } catch (error) { logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); } + + assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch(error => { + logger.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} | - ${error.message}`); + }); } if (subscription.status === 'canceled') { @@ -365,6 +406,10 @@ async function handleStripeEvent(event) { } catch (error) { logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); } + + removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch(error => { + logger.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} | - ${error.message}`); + }); } if (subscription.status === 'unpaid') { @@ -377,23 +422,58 @@ async function handleStripeEvent(event) { } catch (error) { logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); } + + removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch(error => { + logger.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} | - ${error.message}`); + }); } } } +async function assignDiscordMemberSupporterRole(memberId, roleId) { + if (memberId && memberId.trim() !== '') { + await discordRest.put(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.supporter)); + await discordRest.put(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, roleId)); + } +} + +async function assignDiscordMemberTesterRole(memberId) { + if (memberId && memberId.trim() !== '') { + await discordRest.put(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.tester)); + } +} + +async function removeDiscordMemberSupporterRole(memberId, roleId) { + if (memberId && memberId.trim() !== '') { + await discordRest.delete(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.supporter)); + await discordRest.delete(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, roleId)); + } +} + +async function removeDiscordMemberTesterRole(memberId) { + if (memberId && memberId.trim() !== '') { + await discordRest.delete(DiscordRoutes.guildMemberRole(config.discord.guild_id, memberId, config.discord.roles.tester)); + } +} + module.exports = { fullUrl, getLocale, getRawDocs, parseDocs, apiGetRequest, - apiPostGetRequest, - apiDeleteGetRequest, + apiPostRequest, + apiDeleteRequest, register, login, refreshLogin, getUserAccountData, updateDiscordConnection, + removeDiscordConnection, nintendoPasswordHash, - handleStripeEvent + handleStripeEvent, + assignDiscordMemberSupporterRole, + assignDiscordMemberTesterRole, + removeDiscordMemberSupporterRole, + removeDiscordMemberTesterRole }; diff --git a/views/account/account.handlebars b/views/account/account.handlebars index e7536c9..2ffe7f6 100644 --- a/views/account/account.handlebars +++ b/views/account/account.handlebars @@ -144,7 +144,9 @@
{{ localeHelper locale "account.settings.settingCards.connectedToDiscord" }} {{ discordUser.username }}#{{ discordUser.discriminator }}
- + + + {{else}}{{ localeHelper locale "account.settings.settingCards.noDiscordLinked" }} {{ localeHelper locale "account.settings.settingCards.linkDiscord" }}
{{/if}} diff --git a/views/account/upgrade.handlebars b/views/account/upgrade.handlebars index 6e8b32a..d353629 100644 --- a/views/account/upgrade.handlebars +++ b/views/account/upgrade.handlebars @@ -88,7 +88,7 @@