xiongzhu пре 2 година
родитељ
комит
4e368052b7

+ 11 - 1
.prettierrc

@@ -3,5 +3,15 @@
     "trailingComma": "none",
     "semi": false,
     "printWidth": 120,
-    "tabWidth": 4
+    "tabWidth": 4,
+    "overrides": [
+        {
+            "files": "*.hbs",
+            "options": {
+                "singleQuote": false,
+                "semi": true,
+                "parser": "html"
+            }
+        }
+    ]
 }

BIN
certs/apiclient_cert.p12


+ 25 - 0
certs/apiclient_cert.pem

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEMTCCAxmgAwIBAgIUSczqCepXEAEUepBb2iSzuMgEqIQwDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjMwNDE5MDUzMjAwWhcNMjgwNDE3MDUzMjAwWjCBijETMBEGA1UEAwwK
+MTY0MjI5NDEwNjEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTYwNAYDVQQL
+DC3ljY7lgqjoibrmnK/lk4HkuK3lv4PvvIjmt7HlnLPvvInmnInpmZDlhazlj7gx
+CzAJBgNVBAYMAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEB
+BQADggEPADCCAQoCggEBANq/lEe7w5ILrr0MHI8jRz/JnE86UEyAbsiGZVmzZAYw
+TA/JdHNs46AMlUyg87aAOcatv078ZZGHzTAizMu38JGmoAesiz8TWKr+Hs82YBoM
+9dFkkedjrZbEYB5lZI146tSjQGBIcRgiNh2it4VbsyU93y3qSvPC3COrt5ruO+Hh
+uFqkzyXskPH7tax3PNvgvmI9eiDsNKlfc1udPyUn+u86XbFFPX42aYIoWOHrQCP0
+qyPpFbVKMrxSGF5rewG6i8FMVJAL+DH7f8PnUE0avTwkBW3wCsoKwQCD8B93V4WY
+nh+MDQiHl8I1Dl1pAIDZuEGg76cKDgFCaQl5o1MVo8kCAwEAAaOBuTCBtjAJBgNV
+HRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0
+cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIw
+RTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0
+MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUA
+A4IBAQBcM1eDaBKQiFk7/e8J2FTk6orRpDdRUCBzR/0h1bZtdbWE+du28saN0d28
+r0RlOzRSUJT2KQyzvH+9e/70xf9CD+7CuFV4DCbRIvSQjmSImTyjYbKltybhWQt4
+stpdHRkRroYFUCleV5ZcuWePgTvnpmkodYFKMEiHzL+O6DvDYmwMPThwcx4mwKCY
+Li8PLHRFmWHg9HK232JLKWFJJCpJyGlkzOGEDoSOhMxm6tYRvoaxQbRR/9J1gC3Q
+AxkNmKmuKhsh9j+qmEkNEJutfuRdIY3xfMyJakytg8+lnqBjUB9QE7YkzR0dO8d4
+ikS1SSH04sXkEP1Z5N7KBhqbQNPv
+-----END CERTIFICATE-----

+ 28 - 0
certs/apiclient_key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDav5RHu8OSC669
+DByPI0c/yZxPOlBMgG7IhmVZs2QGMEwPyXRzbOOgDJVMoPO2gDnGrb9O/GWRh80w
+IszLt/CRpqAHrIs/E1iq/h7PNmAaDPXRZJHnY62WxGAeZWSNeOrUo0BgSHEYIjYd
+oreFW7MlPd8t6krzwtwjq7ea7jvh4bhapM8l7JDx+7Wsdzzb4L5iPXog7DSpX3Nb
+nT8lJ/rvOl2xRT1+NmmCKFjh60Aj9Ksj6RW1SjK8Uhhea3sBuovBTFSQC/gx+3/D
+51BNGr08JAVt8ArKCsEAg/Afd1eFmJ4fjA0Ih5fCNQ5daQCA2bhBoO+nCg4BQmkJ
+eaNTFaPJAgMBAAECggEAc2Wvhqwh/tCU7PwPsN8ufhMQl6cupzz1tGlZ3BlLwgwG
+hCmFekLOTid1N4iNKRC56frMupFhz4UxlJw3J50wltWtBH9csfi1xPbsL+oChpuh
+rkX8xnGKKMgN4NsiUZOhZm18z45HwzCBFd86K2r1tcbPDrzTr4ZzdYnoAR5+jqj3
+3L0APNbwelI2mzEg3CiI2IO/eTpapXlgrtqkrQCsVRPc40AmRZTvy8vAnC6rLQX/
+8S8WRw/KtEik3ClJIZdrKA0OR1kxtwCGR43Rqv9tUCvK2qUppY2EY7QCg6OIkmCf
+N4yOhPeb2Z5vPrswUI2guunxDvOEmObDRvUb75rDAQKBgQD7yRsAn2Y2t28yxiSI
+tc1GrXbIe30sj3RaU8pPRuLnQeQW73xfRLDioHb84zniQ3pPmSsPzpAPPVrajnR1
+BMTCKZz9ONg+BK44A03Elit5yZmj8tiAPiBVgEUiwghTM3AmM+9dFubefIs7mO0r
+hVHd923W+/fv9E3EDGpli9qnaQKBgQDeaOkAMIEGs+A6gG/7L8IkgejOnuWO8NCr
+ZWEDUL4h7s6nUd7zwmMXtARctrAvzNjVWqJdBeC50vQuqfiCcigJf5Lrd5Q3Zwiw
+bk++A/qglA2XKyMAWy9Go50/Hgy72Fum9e2p6aFANHINfgpTf/SSmggAbSJd0Ox3
+7RHx3kDtYQKBgCUKI5/G0zmPaJtWjL9Tx1vzXrsFd5ebjulO1D8vVWfhyJUbK/ez
+2jWsl3SCVNyVQqP9C1mq8IRdaUUnmbgxOGS7zG1v9FfDRoHU8pbN5J1o6+IzcmgD
+O6x4TzNayfC7a28jSr4uBU6Lkcv1pkY1uaJmqNDshj/Vd680iF5h4YupAoGAbEZE
+kFwMpFezZKecXHu+lwlLbjN67CCeZaKAHR4Uwe5BWsGHucG7fhGcQgqKMWsXcJ6k
+BodTBQQG7iS/H7o1dZJO0yUu2d3mJAY/+fBz9fK9vwc4DfdWR5xldBHL/zLwQ5Lc
+NejQILKnh5wBqO7VAP3NwW9f2K5aQHQAVXBuyWECgYEA4fV+eSLgXGr0ufuwt/Nz
+Q4ixvnqykfTd7BfOY/acRwvTtjt5LCE/c9oNnSGoRjws0hvceY0ewFYkhV63oP70
+w6m/hizauolBNQwydVr1Pat0N03ha9TXJqigohBy3bD0kBuw1dIZG35ySf30OhBJ
+BL9SvOCdtxm4KKyx5hp6T+o=
+-----END PRIVATE KEY-----

+ 24 - 0
certs/platform_cert.pem

@@ -0,0 +1,24 @@
+-----BEGIN CERTIFICATE-----
+MIIEFDCCAvygAwIBAgIUOLcd3WMB5BSgOavY0U/mHGe7YuUwDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjMwNDE4MDcwMDQwWhcNMjgwNDE2MDcwMDQwWjBuMRgwFgYDVQQDDA9U
+ZW5wYXkuY29tIHNpZ24xEzARBgNVBAoMClRlbnBheS5jb20xHTAbBgNVBAsMFFRl
+bnBheS5jb20gQ0EgQ2VudGVyMQswCQYDVQQGDAJDTjERMA8GA1UEBwwIU2hlblpo
+ZW4wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDStHfUxUIb01xR5axp
+aQe4SdmMPAAgxB+8YGJndSUuIxb4pm0U8p2ssUE0qzIO9mZW4J8tPF+Wkr4vLzp0
+ayHKDSKdl+lgEmNx3WLsuzjPTurdWct8RsEbTP8oCT7mDaOSYva5NrZPjCIyj5nM
+zgfnPSounCjnzulEFgli/VyJQqiolZOt/qkRALIZFMN1NGs3PRlaVzGhRi3KJbVV
+EKXURHrPl2Efy2cvwV4E4iUjgFvRZ3kt3MPCCld6b8EM8pXVhUOxj5WICVNUc565
+ySpqQrbqqNitIwpx+pe/YtF2qoZg6DVdZy+tvexoAp7WWlJSlNbiCWBErKVeqprk
+j1ZdAgMBAAGjgbkwgbYwCQYDVR0TBAIwADALBgNVHQ8EBAMCA/gwgZsGA1UdHwSB
+kzCBkDCBjaCBiqCBh4aBhGh0dHA6Ly9ldmNhLml0cnVzLmNvbS5jbi9wdWJsaWMv
+aXRydXNjcmw/Q0E9MUJENDIyMEU1MERCQzA0QjA2QUQzOTc1NDk4NDZDMDFDM0U4
+RUJEMiZzZz1IQUNDNDcxQjY1NDIyRTEyQjI3QTlEMzNBODdBRDFDREY1OTI2RTE0
+MDM3MTANBgkqhkiG9w0BAQsFAAOCAQEAoSmerBMFyiplNhMgEX3pa9WcIqKFA+aG
+07TGqMT/GdAAakKnk43ukj+yfeIy2g0J6olxS8TR/HUq0zj+DhnHidDfu/m++PZ+
+OiPjEI5Lgu4m0VgJUCcAAQbbXt+YF7TUlptXZZK8VqAaHAqOg15Yu9ykrPbKPkC0
+vW2Mkz1N9zA9axBEVtaZHDylWghbAemsT6OTmU9gH2qONygYjVOVj0rue8gKqQih
+0He/X+EujrvSB7aLP56IvOCBeM18zGg0Wc+P7cWmbgSpheoR3v/3e3F92qV133YH
+b7kXwhk76WqjZaoALdyrsFRnpwj7YzNgnHwtJBrQImFfpPp5L5+kmg==
+-----END CERTIFICATE-----

+ 21 - 7
nest-cli.json

@@ -1,9 +1,23 @@
 {
-  "collection": "@nestjs/schematics",
-  "sourceRoot": "src",
-  "compilerOptions": {
-    "deleteOutDir": true,
-    "plugins": ["@nestjs/swagger"],
-    "assets": ["**/*.p12", "**/*.pem"]
-  }
+    "collection": "@nestjs/schematics",
+    "sourceRoot": "src",
+    "compilerOptions": {
+        "deleteOutDir": true,
+        "plugins": [
+            "@nestjs/swagger"
+        ],
+        "assets": [
+            {
+                "include": "../certs",
+                "outDir": "dist/certs",
+                "watchAssets": true
+            },
+            {
+                "include": "../views",
+                "outDir": "dist/views",
+                "watchAssets": true
+            }
+        ],
+        "watchAssets": true
+    }
 }

+ 1 - 0
package.json

@@ -50,6 +50,7 @@
     "express-basic-auth": "^1.2.1",
     "express-handlebars": "^7.0.6",
     "handlebars": "^4.7.7",
+    "hbs": "^4.2.0",
     "isomorphic-fetch": "^3.0.0",
     "keyv": "^4.5.2",
     "mongodb": "^5.2.0",

+ 10 - 4
src/main.ts

@@ -3,12 +3,14 @@ import { AppModule } from './app.module'
 import { Logger, ValidationPipe } from '@nestjs/common'
 import { ConfigService } from '@nestjs/config'
 import { configureSwaggerDocs } from './helpers/configure-swagger-docs.helper'
-import * as fs from 'fs'
+import { NestExpressApplication } from '@nestjs/platform-express'
+import { writeFileSync } from 'fs'
+import { join } from 'path'
 
 async function bootstrap() {
-    const app = await NestFactory.create(AppModule, {
+    const app = await NestFactory.create<NestExpressApplication>(AppModule, {
         snapshot: true,
-        abortOnError: false
+        abortOnError: true
     })
     const configService = app.get<ConfigService>(ConfigService)
 
@@ -31,11 +33,15 @@ async function bootstrap() {
         methods: 'GET,POST,PUT,PATCH,DELETE',
         credentials: true
     })
+    app.useStaticAssets(join(__dirname, '..', 'public'))
+    app.setBaseViewsDir(join(__dirname, '..', 'views'))
+    app.setViewEngine('hbs')
+
     const port = configService.get<number>('NODE_API_PORT') || 3000
     await app.listen(port)
     Logger.log(`Url for OpenApi: ${await app.getUrl()}/docs`, 'Swagger')
 }
 bootstrap().catch((err) => {
-    fs.writeFileSync('graph.json', PartialGraphHost.toString() ?? '')
+    writeFileSync('graph.json', PartialGraphHost.toString() ?? '')
     process.exit(1)
 })

+ 56 - 2
src/membership/membership.controller.ts

@@ -1,13 +1,24 @@
 import { ApiTags } from '@nestjs/swagger'
 import { Public } from '../auth/public.decorator'
 import { MembershipService } from './membership.service'
-import { BadRequestException, Body, Controller, Get, NotImplementedException, Post, Req } from '@nestjs/common'
+import {
+    BadRequestException,
+    Body,
+    Controller,
+    Get,
+    NotImplementedException,
+    Post,
+    Query,
+    Render,
+    Req
+} from '@nestjs/common'
 import { RenewDto } from './dto/renew.dto'
+import { WeixinService } from 'src/weixin/weixin.service'
 
 @ApiTags('membership')
 @Controller('/membership')
 export class MembershipController {
-    constructor(private readonly membershipService: MembershipService) {}
+    constructor(private readonly membershipService: MembershipService, private readonly weixinService: WeixinService) {}
 
     @Get('/get')
     async getMembership(@Req() req) {
@@ -31,4 +42,47 @@ export class MembershipController {
     async getPlans() {
         return await this.membershipService.getPlans()
     }
+
+    @Public()
+    @Get('/h5pay')
+    @Render('h5pay')
+    async h5pay(@Query() { code, userId, planId }) {
+        if (!code) {
+            return {
+                success: false,
+                message: '缺少code',
+                errorCode: 1,
+                data: '{}'
+            }
+        }
+        if (!userId) {
+            return {
+                success: false,
+                message: '缺少userId',
+                errorCode: 2,
+                data: '{}'
+            }
+        }
+        if (!planId) {
+            return {
+                success: false,
+                message: '缺少planId',
+                errorCode: 3,
+                data: '{}'
+            }
+        }
+        const plans = await this.membershipService.getPlans()
+        const plan = plans.find((p) => p.id === planId)
+        if (!plan) {
+            return {
+                success: false,
+                message: 'planId无效',
+                errorCode: 4,
+                data: '{}'
+            }
+        }
+        const openid = await this.weixinService.code2oenId(code)
+        const data = await this.membershipService.combinedJsapi(userId, planId, openid)
+        return { success: true, openid, errorCode: 0, message: '', data: JSON.stringify(data), price: `${plan.price}` }
+    }
 }

+ 18 - 10
src/weixin/weixin.service.ts

@@ -19,11 +19,11 @@ import {
 import { InjectRepository } from '@nestjs/typeorm'
 import { AccessTokenCache } from './entities/access-token-cache.entity'
 import { LessThan, MoreThan, Not, Repository } from 'typeorm'
-import { addSeconds } from 'date-fns'
-import * as fs from 'node:fs'
+import { addSeconds, differenceInMinutes } from 'date-fns'
+import { readFileSync, writeFileSync, statSync } from 'fs'
 import { JsapiTicketCache } from './entities/jsapi-ticket-cache.entity'
 import { Attach } from './dto/attach.dto'
-import { v4 as uuid } from 'uuid'
+import { join } from 'path'
 
 @Injectable()
 export class WeixinService {
@@ -42,9 +42,10 @@ export class WeixinService {
         ApiConfigKit.putApiConfig(apiConfig)
         ApiConfigKit.devMode = true
         ApiConfigKit.setCurrentAppId(apiConfig.getAppId)
-        this.privateKey = fs.readFileSync(this.weixinConfiguration.certPath + 'apiclient_key.pem')
-        this.publicKey = fs.readFileSync(this.weixinConfiguration.certPath + 'apiclient_cert.pem')
-        this.platformPlublicKey = fs.readFileSync(this.weixinConfiguration.certPath + 'platform_cert.pem')
+        console.log(__dirname)
+        this.privateKey = readFileSync(join(__dirname, '..', '..', 'certs', 'apiclient_key.pem'))
+        this.publicKey = readFileSync(join(__dirname, '..', '..', 'certs', 'apiclient_cert.pem'))
+        this.platformPlublicKey = readFileSync(join(__dirname, '..', '..', 'certs', 'platform_cert.pem'))
         this.getCert()
     }
 
@@ -70,6 +71,9 @@ export class WeixinService {
 
     async refreshAccessToken(): Promise<AccessToken> {
         const res: AccessToken = await AccessTokenApi.getAccessToken()
+        if (res.getErrCode != 0) {
+            throw new InternalServerErrorException(res.getErrMsg)
+        }
         const newToken = await this.accessTokenRepository.save(
             new AccessTokenCache(res.getJson, addSeconds(new Date(), res.getExpiresIn - 300))
         )
@@ -79,6 +83,9 @@ export class WeixinService {
 
     async refreshTicket() {
         const res: JsTicket = await JsTicketApi.getTicket(JsApiType.JSAPI, await this.getAccessToken())
+        if (res.getErrCode != 0) {
+            throw new InternalServerErrorException(res.getErrMsg)
+        }
         const newTicket = await this.jsapiTicketRepository.save(
             new JsapiTicketCache(res.getJson, addSeconds(new Date(), res.getExpiresIn - 300))
         )
@@ -197,6 +204,8 @@ export class WeixinService {
 
     async getCert() {
         try {
+            const certPath = join(__dirname, '..', '..', 'certs', 'platform_cert.pem')
+
             let result = await PayKit.exeGet(
                 WX_DOMAIN.CHINA, //
                 WX_API_TYPE.GET_CERTIFICATES,
@@ -217,7 +226,6 @@ export class WeixinService {
             let verifySignature: boolean = PayKit.verifySign(headers, JSON.stringify(data), this.platformPlublicKey)
             Logger.log(verifySignature, '验证签名')
 
-            let certPath = this.weixinConfiguration.certPath + 'platform_cert.pem'
             result.data.data.sort((a, b) => {
                 return a.effective_time > b.effective_time ? -1 : 1
             })
@@ -228,9 +236,9 @@ export class WeixinService {
                 data.data[0].encrypt_certificate.ciphertext
             )
             // 保存证书
-            fs.writeFileSync(certPath, decrypt)
-            this.platformPlublicKey = fs.readFileSync(this.weixinConfiguration.certPath + 'platform_cert.pem')
-            Logger.log(this.weixinConfiguration.certPath + 'platform_cert.pem', '保存平台证书')
+            writeFileSync(certPath, decrypt)
+            this.platformPlublicKey = readFileSync(certPath)
+            Logger.log(certPath, '保存平台证书')
             return data
         } catch (error) {
             Logger.error(error)

+ 117 - 0
views/h5pay.hbs

@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<html lang="zh-cmn-Hans" class="h-full">
+    <head>
+        <meta charset="UTF-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
+        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
+        <link
+            rel="stylesheet"
+            type="text/css"
+            href="https://res.wx.qq.com/t/wx_fed/weui-source/res/2.5.16/weui.min.css"
+        />
+        <script type="text/javascript" src="https://unpkg.com/jquery@3.3.1/dist/jquery.min.js"></script>
+        <script src="https://unpkg.com/eruda@3.0.0/eruda.js"></script>
+        <script src="https://cdn.tailwindcss.com"></script>
+        <script src="http://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
+        <title>支付</title>
+        <style>
+            body {
+                margin: 0;
+                background-color: var(--weui-BG-0);
+            }
+
+            html {
+            }
+        </style>
+    </head>
+
+    <body class="h-full">
+        <div id="pay" class="weui-msg" style="display: none">
+            <div class="weui-msg__text-area">
+                <p class="weui-msg__desc text-5xl">¥{{price}}</p>
+                <p class="weui-msg__desc-primary text-lg">ChillGPT会员</p>
+            </div>
+            <div class="weui-msg__opr-area">
+                <p class="weui-btn-area">
+                    <a href="javascript:pay();" role="button" class="weui-btn weui-btn_primary">立即支付</a>
+                </p>
+            </div>
+        </div>
+
+        <div id="success" class="weui-msg h-full" style="display: none">
+            <div class="weui-msg__icon-area">
+                <i class="weui-icon-success weui-icon_msg"></i>
+            </div>
+            <div class="weui-msg__text-area">
+                <h2 class="weui-msg__title">支付成功</h2>
+                {{!-- <p class="weui-msg__desc">
+                    内容详情,可根据实际需要安排,如果换行则不超过规定长度,居中展现
+                </p> --}}
+            </div>
+            <div class="weui-msg__opr-area">
+                <p class="weui-btn-area">
+                    <a href="javascript:history.back();" role="button" class="weui-btn weui-btn_primary">关闭</a>
+                </p>
+            </div>
+        </div>
+
+        <div id="error" class="weui-msg" style="display: none">
+            <div class="weui-msg__icon-area">
+                <i class="weui-icon-warn weui-icon_msg"></i>
+            </div>
+            <div class="weui-msg__text-area">
+                <h2 id="errtitle" class="weui-msg__title">操作失败</h2>
+                <p id="errmsg" class="weui-msg__desc">
+                </p>
+            </div>
+            <div class="weui-msg__opr-area">
+                <p class="weui-btn-area">
+                    <a href="javascript:history.back();" role="button" class="weui-btn weui-btn_default">关闭</a>
+                </p>
+            </div>
+        </div>
+
+        <script>
+            var viewData = {
+                success: {{ success }},
+                message: '{{ message }}',
+                errorCode: {{ errorCode }},
+                data: {{ data }},
+            }
+            eruda.init();
+            $.get('/api/weixin/jsapiSign', { url: encodeURI(window.location.href.split('#')[0]), }, function (res) {
+                res.debug = true;
+                res.jsApiList = [
+                    'chooseWXPay',
+                    'updateAppMessageShareData',
+                    'updateTimelineShareData',
+                    'hideAllNonBaseMenuItem',
+                    'scanQRCode',
+                ];
+                wx.config(res);
+                wx.error(function (res) {
+                    console.log('jssdk error', res);
+                });
+            });
+            if (!viewData.success) {
+                $('#errtitle').html('发生错误');
+                $('#errmsg').html(viewData.message);
+                $('#error').show();
+            } else {
+                $('#pay').show();
+                wx.ready(function (res) {
+                    console.log('jssdk ready', res);
+                    pay();
+                });
+            }
+
+            function pay() {
+                var config = viewData.data;
+                config.success = function () {
+                    showSuccess('支付成功');
+                };
+                wx.chooseWXPay(config);
+            }
+        </script>
+    </body>
+</html>

+ 21 - 1
yarn.lock

@@ -3159,6 +3159,11 @@ follow-redirects@^1.15.0:
   resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
   integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
 
+foreachasync@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.npmmirror.com/foreachasync/-/foreachasync-3.0.0.tgz#5502987dc8714be3392097f32e0071c9dee07cf6"
+  integrity sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==
+
 forever-agent@~0.6.1:
   version "0.6.1"
   resolved "https://registry.npmmirror.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@@ -3460,7 +3465,7 @@ grapheme-splitter@^1.0.4:
   resolved "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
   integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
 
-handlebars@^4.7.7:
+handlebars@4.7.7, handlebars@^4.7.7:
   version "4.7.7"
   resolved "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
   integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
@@ -3512,6 +3517,14 @@ has@^1.0.3:
   dependencies:
     function-bind "^1.1.1"
 
+hbs@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.npmmirror.com/hbs/-/hbs-4.2.0.tgz#10e40dcc24d5be7342df9636316896617542a32b"
+  integrity sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==
+  dependencies:
+    handlebars "4.7.7"
+    walk "2.3.15"
+
 hexoid@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
@@ -6599,6 +6612,13 @@ vm2@^3.9.11:
     acorn "^8.7.0"
     acorn-walk "^8.2.0"
 
+walk@2.3.15:
+  version "2.3.15"
+  resolved "https://registry.npmmirror.com/walk/-/walk-2.3.15.tgz#1b4611e959d656426bc521e2da5db3acecae2424"
+  integrity sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==
+  dependencies:
+    foreachasync "^3.0.0"
+
 walker@^1.0.8:
   version "1.0.8"
   resolved "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f"