diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 86a3168..23deeee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,8 @@ on: jobs: validate: - uses: ./.github/workflows/validate.yml + # Same-repo reusable workflow; GitHub runs the called workflow at the caller commit. + uses: Order-of-Hospitallers/miracles_in_motion/.github/workflows/validate.yml@main artifact: name: Upload build artifact diff --git a/.gitignore b/.gitignore index f289a1c..956e7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ dist-ssr *.sln *.sw? +# Accidental Lighthouse artifact directories (Windows/WSL path leaks) +undefined*/ +**/lighthouse.*/ +**/*wsl.localhost*/ + # Environment variables .env .env.local diff --git a/config/brand-assets.manifest.json b/config/brand-assets.manifest.json index 6c61efc..28a36ca 100644 --- a/config/brand-assets.manifest.json +++ b/config/brand-assets.manifest.json @@ -65,6 +65,19 @@ { "title": "Square (WebP)", "path": "/brand/logo-square.webp", "format": "WebP" } ] }, + { + "id": "qr", + "title": "Branded QR codes", + "description": "Approved dynamic QR styling — forest green field, gold diamond modules, leaf corner finders.", + "assets": [ + { + "title": "Branded QR reference (PNG)", + "path": "/brand/qr-code-branded-reference.png", + "format": "PNG", + "description": "Canonical visual reference for donate/event QR codes" + } + ] + }, { "id": "favicon", "title": "Favicon & app icons", diff --git a/e2e/public-routes.spec.ts b/e2e/public-routes.spec.ts index 68cda58..26ab9ab 100644 --- a/e2e/public-routes.spec.ts +++ b/e2e/public-routes.spec.ts @@ -34,4 +34,17 @@ test.describe('MIM4U public navigation', () => { const body = await res.json() expect(body.ok).toBe(true) }) + + test('brand assets page loads', async ({ page }) => { + await page.goto('/brand') + await expect(page.getByRole('heading', { level: 1, name: 'Brand assets' })).toBeVisible({ timeout: 20000 }) + await expect(page.getByText(/Forest green/i)).toBeVisible() + }) + + test('public brand API returns manifest', async ({ request }) => { + const res = await request.get('/api/public/brand') + expect(res.ok()).toBeTruthy() + const body = await res.json() + expect(body.groups?.length).toBeGreaterThan(0) + }) }) diff --git a/env.production.example b/env.production.example index 6599df0..2a34003 100644 --- a/env.production.example +++ b/env.production.example @@ -5,10 +5,18 @@ CUSTOM_DOMAIN=mim4u.org VITE_API_BASE_URL=/api -# Stripe (public key in frontend; secret + webhook on API 7811) +# Stripe (public key in frontend build OR runtime via /api/public/config after admin setup) VITE_STRIPE_PUBLISHABLE_KEY=pk_live_YOUR_KEY +# Dev-only mock donate when Stripe disabled: +# VITE_ALLOW_MOCK_DONATE=1 + +# API secrets (VMID 7811 — also manageable in admin → System Settings) STRIPE_SECRET_KEY=sk_live_YOUR_KEY STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET +MIM_AUTH_SECRET=change-me-to-a-long-random-string +MIM_ADMIN_PASSWORD= +MIM_VOLUNTEER_PASSWORD= +MIM_RESOURCE_PASSWORD= # Analytics VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX diff --git a/mim-api/.env.example b/mim-api/.env.example index 3e8730a..ebb8323 100644 --- a/mim-api/.env.example +++ b/mim-api/.env.example @@ -4,7 +4,16 @@ NODE_ENV=production MIM_PUBLIC_URL=https://mim4u.org MIM_DATA_DIR=/var/lib/mim-api/data -# Stripe (required for live donations) +# Portal auth (JWT signing — required in production) +MIM_AUTH_SECRET=change-me-to-a-long-random-string + +# Portal users (seeded on first start if users.json missing) +MIM_ADMIN_PASSWORD= +MIM_VOLUNTEER_PASSWORD= +MIM_RESOURCE_PASSWORD= + +# Stripe (can also be set via admin UI → persisted to admin-settings.json) +STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... @@ -15,5 +24,23 @@ SMTP_USER= SMTP_PASS= MIM_NOTIFY_EMAIL=info@mim4u.org +# Brand files (optional — default: $MIM_DATA_DIR/brand; set to /var/www/html/brand on 7810 for direct nginx serve) +# MIM_BRAND_DIR=/var/lib/mim-api/data/brand + # CORS (comma-separated origins; default mim4u.org) MIM_CORS_ORIGINS=https://mim4u.org,https://www.mim4u.org + +# QR codes — provider: qrcode-monkey (default) or qrcg (dynamic tracking) +# QR Code Monkey docs: https://www.qrcode-monkey.com/qr-code-api-with-logo/ +# Direct API works without a key; optional RapidAPI after subscribing to +# "Custom QR Code with Logo" on rapidapi.com: +# RAPIDAPI_KEY= +# QR_MONKEY_USE_RAPIDAPI=1 +# QR_MONKEY_LOGO_URL=https://mim4u.org/brand/logo-square.png +# QR_MONKEY_SIZE=600 + +# QRCG by Bitly — optional dynamic QR with scan analytics +# Docs: https://dev.qrcg.com/ +# MIM_QR_PROVIDER=qrcg +QRCG_API_KEY= +# QRCG_LOGO_ID= diff --git a/mim-api/package-lock.json b/mim-api/package-lock.json new file mode 100644 index 0000000..c1f2bdf --- /dev/null +++ b/mim-api/package-lock.json @@ -0,0 +1,1040 @@ +{ + "name": "mim-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mim-api", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.10.0", + "stripe": "^17.7.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/stripe": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/mim-api/package.json b/mim-api/package.json index d9c003f..60e30f7 100644 --- a/mim-api/package.json +++ b/mim-api/package.json @@ -8,13 +8,14 @@ "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js", - "test": "node --test test/server.test.js" + "test": "node --test test/*.test.js" }, "engines": { "node": ">=18" }, "dependencies": { "express": "^4.21.2", + "multer": "^1.4.5-lts.1", "nodemailer": "^6.10.0", "stripe": "^17.7.0" } diff --git a/mim-api/src/app.js b/mim-api/src/app.js new file mode 100644 index 0000000..5846322 --- /dev/null +++ b/mim-api/src/app.js @@ -0,0 +1,331 @@ +/** + * MIM4U API — VMID 7811 + * Public: health, events, Stripe, assistance, contact + * Admin: auth, settings, analytics, Stripe management + */ +import express from 'express' +import crypto from 'node:crypto' +import nodemailer from 'nodemailer' +import path from 'node:path' +import { readJsonl, appendJsonl, ensureDataDir } from './lib/data.js' +import { ensureUsers } from './lib/users.js' +import { loadSettings, publicConfig } from './lib/settings.js' +import { getStripeClient, isStripeLive } from './lib/stripe-service.js' +import { createCorsMiddleware } from './middleware/cors.js' +import { createAuthRoutes } from './routes/auth-routes.js' +import { createBrandPublicRoutes, createBrandAdminRoutes } from './routes/brand-routes.js' +import { + createAdminRoutes, + createResourceRoutes, + createVolunteerRoutes, +} from './routes/admin-routes.js' +import { createQrAdminRoutes } from './routes/qr-routes.js' +import { createQrPublicRoutes } from './routes/qr-public-routes.js' +import { getQrProvider, isAnyQrConfigured } from './lib/qr-provider.js' + +export function validateAssistance(body) { + const errors = [] + if (!body?.requestType) errors.push('requestType required') + if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required') + if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required') + if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required') + if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required') + if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required') + if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required') + if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required') + if (!body?.details?.trim()) errors.push('details required') + if (body?.website) errors.push('spam detected') + return errors +} + +export function createApp(options = {}) { + const PORT = Number(options.port || process.env.PORT || 3001) + const DATA_DIR = options.dataDir || process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data') + const PUBLIC_URL = (options.publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '') + const NOTIFY_EMAIL = process.env.MIM_NOTIFY_EMAIL || 'info@mim4u.org' + const CORS_ORIGINS = (process.env.MIM_CORS_ORIGINS || 'https://mim4u.org,https://www.mim4u.org') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + const AUTH_SECRET = options.authSecret || process.env.MIM_AUTH_SECRET || 'mim-dev-auth-secret-change-me' + + ensureDataDir(DATA_DIR) + ensureUsers(DATA_DIR) + + function mailTransport() { + const { SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_PORT } = process.env + if (!SMTP_HOST) return null + return nodemailer.createTransport({ + host: SMTP_HOST, + port: Number(SMTP_PORT || 587), + secure: Number(SMTP_PORT) === 465, + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + }) + } + + async function notify(subject, text, { to } = {}) { + appendJsonl(DATA_DIR, 'notifications.jsonl', { subject, text, to }) + const transport = mailTransport() + if (!transport) { + console.log(`[notify] ${subject}: ${text.slice(0, 200)}`) + return + } + await transport.sendMail({ + from: NOTIFY_EMAIL, + to: to || NOTIFY_EMAIL, + subject, + text, + }) + } + + function requireStripe(_req, res, next) { + if (!isStripeLive(DATA_DIR)) { + return res.status(503).json({ error: 'Stripe donations are disabled or not configured' }) + } + next() + } + + const app = express() + app.disable('x-powered-by') + app.use(createCorsMiddleware(CORS_ORIGINS)) + + app.get('/health', (_req, res) => { + res.type('text/plain').send('ok') + }) + + app.get('/api/health', (_req, res) => { + const settings = loadSettings(DATA_DIR) + res.json({ + ok: true, + service: 'mim-api', + stripe: isStripeLive(DATA_DIR), + donationsEnabled: settings.donationsEnabled, + dataDir: DATA_DIR, + version: '1.1.0', + qr: { + provider: getQrProvider(), + configured: isAnyQrConfigured(), + }, + }) + }) + + app.get('/api/public/config', (_req, res) => { + res.json(publicConfig(loadSettings(DATA_DIR))) + }) + + app.get('/api/public/donation-impact', (_req, res) => { + const donations = readJsonl(DATA_DIR, 'donations.jsonl', { limit: 500 }) + const totalCents = donations.reduce((s, d) => s + (d.amountTotal || 0), 0) + const assistance = readJsonl(DATA_DIR, 'assistance-requests.jsonl', { limit: 500 }) + res.json({ + totalRaisedUsd: Math.round(totalCents / 100), + donationCount: donations.length, + familiesSupported: assistance.filter((a) => a.status === 'completed' || a.status === 'approved').length, + }) + }) + + app.use('/api/public', createBrandPublicRoutes({ dataDir: DATA_DIR })) + app.use('/api/public', createQrPublicRoutes({ dataDir: DATA_DIR })) + + app.use('/api/auth', createAuthRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET })) + app.use('/api/admin', createAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET })) + app.use('/api/admin', createBrandAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET })) + app.use( + '/api/admin', + createQrAdminRoutes({ + dataDir: DATA_DIR, + authSecret: AUTH_SECRET, + publicUrl: PUBLIC_URL, + publicApiBase: process.env.MIM_API_PUBLIC_BASE || '', + }), + ) + app.use('/api/volunteer', createVolunteerRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET })) + app.use('/api/resource', createResourceRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET })) + + app.use('/api/events', express.json({ limit: '32kb' })) + app.post('/api/events', (req, res) => { + appendJsonl(DATA_DIR, 'events.jsonl', req.body || {}) + res.status(204).end() + }) + + app.use('/api/assistance-requests', express.json({ limit: '128kb' })) + const postAssistanceRequest = async (req, res) => { + const errors = validateAssistance(req.body) + if (errors.length) return res.status(400).json({ error: 'Validation failed', fields: errors }) + + const id = `asst_${crypto.randomUUID()}` + const record = { id, status: 'pending', ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] } + appendJsonl(DATA_DIR, 'assistance-requests.jsonl', record) + + try { + await notify( + `[MIM4U] Assistance request ${id}`, + `New assistance request (${req.body.requestType})\nContact: ${req.body.contactInfo.parentName} <${req.body.contactInfo.email}>\nPhone: ${req.body.contactInfo.phone}\nDetails: ${req.body.details?.slice(0, 500)}`, + ) + } catch (e) { + console.error('assistance notify failed', e) + } + + res.status(201).json({ ok: true, id, message: 'Request received. We will contact you within 24–48 hours.' }) + } + app.post('/api/assistance-requests', postAssistanceRequest) + app.post('/api/assistance', postAssistanceRequest) + + app.use('/api/contact', express.json({ limit: '64kb' })) + app.post('/api/contact', async (req, res) => { + const { type, name, email, message, website, ...rest } = req.body || {} + if (website) return res.status(400).json({ error: 'Invalid submission' }) + if (!type) return res.status(400).json({ error: 'type required' }) + if (!email?.trim() && type !== 'story') return res.status(400).json({ error: 'email required' }) + if (type === 'story' && !message?.trim() && !rest.story?.trim()) { + return res.status(400).json({ error: 'story text required' }) + } + + const id = `contact_${crypto.randomUUID()}` + appendJsonl(DATA_DIR, 'contact.jsonl', { id, type, name, email, message, ...rest }) + try { + await notify(`[MIM4U] ${type} form ${id}`, JSON.stringify({ name, email, message, ...rest }, null, 2)) + } catch (e) { + console.error('contact notify failed', e) + } + res.status(201).json({ ok: true, id, message: 'Thank you — we will be in touch soon.' }) + }) + + app.use('/api/donations', express.json({ limit: '32kb' })) + app.post('/api/donations', (req, res) => { + appendJsonl(DATA_DIR, 'donations-offline.jsonl', req.body || {}) + res.status(201).json({ ok: true }) + }) + + app.use('/api/create-payment-intent', express.json({ limit: '32kb' })) + app.post('/api/create-payment-intent', requireStripe, async (req, res) => { + const stripe = getStripeClient(DATA_DIR) + const amount = Number(req.body?.amount) + if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }) + if (!Number.isFinite(amount) || amount < 100) { + return res.status(400).json({ error: 'amount must be at least 100 cents' }) + } + try { + const intent = await stripe.paymentIntents.create({ + amount: Math.round(amount), + currency: 'usd', + metadata: { + recurring: String(Boolean(req.body?.recurring)), + donorEmail: req.body?.customer?.email || '', + donorName: req.body?.customer?.name || '', + }, + receipt_email: req.body?.customer?.email || undefined, + }) + res.json({ client_secret: intent.client_secret }) + } catch (e) { + console.error('payment intent error', e) + res.status(502).json({ error: e.message || 'Stripe error' }) + } + }) + + app.use('/api/create-checkout-session', express.json({ limit: '32kb' })) + app.post('/api/create-checkout-session', requireStripe, async (req, res) => { + const stripe = getStripeClient(DATA_DIR) + const amountUsd = Number(req.body?.amount) + if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }) + if (!Number.isFinite(amountUsd) || amountUsd < 1) { + return res.status(400).json({ error: 'amount must be at least $1' }) + } + const cents = Math.round(amountUsd * 100) + const recurring = Boolean(req.body?.recurring) + const email = req.body?.email?.trim() + const name = req.body?.name?.trim() + + try { + const session = await stripe.checkout.sessions.create({ + mode: recurring ? 'subscription' : 'payment', + success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${PUBLIC_URL}/donate?status=cancelled`, + customer_email: email || undefined, + line_items: [ + recurring + ? { + price_data: { + currency: 'usd', + product_data: { name: 'Monthly donation — Miracles in Motion Foundation' }, + unit_amount: cents, + recurring: { interval: 'month' }, + }, + quantity: 1, + } + : { + price_data: { + currency: 'usd', + product_data: { name: 'Donation — Miracles in Motion Foundation' }, + unit_amount: cents, + }, + quantity: 1, + }, + ], + metadata: { + donorName: name || '', + anonymous: String(Boolean(req.body?.anonymous)), + recurring: String(recurring), + }, + }) + res.json({ url: session.url, sessionId: session.id }) + } catch (e) { + console.error('checkout session error', e) + res.status(502).json({ error: e.message || 'Stripe error' }) + } + }) + + app.post( + '/api/webhooks/stripe', + express.raw({ type: 'application/json' }), + async (req, res) => { + const stripe = getStripeClient(DATA_DIR) + const settings = loadSettings(DATA_DIR) + if (!stripe) return res.status(503).send('Stripe not configured') + const sig = req.headers['stripe-signature'] + const secret = settings.stripeWebhookSecret + let event + try { + event = secret + ? stripe.webhooks.constructEvent(req.body, sig, secret) + : JSON.parse(req.body.toString()) + } catch (e) { + console.error('webhook verify failed', e.message) + return res.status(400).send(`Webhook Error: ${e.message}`) + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object + appendJsonl(DATA_DIR, 'donations.jsonl', { + sessionId: session.id, + amountTotal: session.amount_total, + customerEmail: session.customer_details?.email, + metadata: session.metadata, + }) + const donorEmail = session.customer_details?.email + const amountUsd = ((session.amount_total || 0) / 100).toFixed(2) + try { + await notify( + `[MIM4U] Donation received ${session.id}`, + `Amount: ${amountUsd} USD\nEmail: ${donorEmail || 'n/a'}`, + ) + if (donorEmail) { + await notify( + 'Thank you for your gift — Miracles in Motion Foundation', + `Dear friend,\n\nThank you for your generous donation of $${amountUsd} to Miracles in Motion Foundation.\n\nYour gift supports outreach, emergency assistance, and compassionate care in Los Angeles County.\n\nEIN ${process.env.MIM_EIN || '33-4887159'}\nhttps://mim4u.org\n\nWith gratitude,\nMiracles in Motion Foundation`, + { to: donorEmail }, + ) + } + } catch (e) { + console.error('donation notify failed', e) + } + } + + res.json({ received: true }) + }, + ) + + app.use((_req, res) => res.status(404).json({ error: 'Not found' })) + + return { app, DATA_DIR, PORT, AUTH_SECRET } +} diff --git a/mim-api/src/defaults/brand-manifest.default.json b/mim-api/src/defaults/brand-manifest.default.json new file mode 100644 index 0000000..e7270d4 --- /dev/null +++ b/mim-api/src/defaults/brand-manifest.default.json @@ -0,0 +1,96 @@ +{ + "version": "1.2.0", + "organization": "Miracles in Motion Foundation", + "updated": "2026-06-15", + "colors": [ + { "name": "Forest green", "hex": "#1a3c34", "role": "primary background" }, + { "name": "Forest green (deep)", "hex": "#0f2922", "role": "dark text" }, + { "name": "Gold", "hex": "#c5a059", "role": "accent" }, + { "name": "Gold (bright)", "hex": "#d4af37", "role": "logo highlight" } + ], + "typography": [ + { "name": "Playfair Display", "use": "Headings (web)", "source": "Self-hosted via site" }, + { "name": "Inter", "use": "Body and UI (web)", "source": "Self-hosted via site" } + ], + "kits": [ + { + "id": "full", + "title": "Complete brand kit (ZIP)", + "path": "/brand/MIM4U-Brand-Kit.zip", + "format": "ZIP", + "description": "All logos, favicons, and guidelines markdown" + } + ], + "groups": [ + { + "id": "nav", + "title": "Website nav lockups (transparent)", + "description": "Approved header/footer assets — gold artwork on transparent background for light and dark UI shells.", + "assets": [ + { "title": "Nav horizontal (SVG)", "path": "/brand/logo-horizontal-nav.svg", "format": "SVG" }, + { "title": "Nav horizontal (PNG)", "path": "/brand/logo-horizontal-nav.png", "format": "PNG" }, + { "title": "Nav horizontal (WebP)", "path": "/brand/logo-horizontal-nav.webp", "format": "WebP" }, + { "title": "Nav symbol (SVG)", "path": "/brand/logo-symbol-nav.svg", "format": "SVG" }, + { "title": "Nav symbol (PNG)", "path": "/brand/logo-symbol-nav.png", "format": "PNG" } + ] + }, + { + "id": "symbol", + "title": "Symbol mark (icon only)", + "description": "Figure and starburst — mobile header, favicons, compact UI.", + "assets": [ + { "title": "Symbol (SVG, simplified vector)", "path": "/brand/logo-symbol.svg", "format": "SVG" }, + { "title": "Symbol 512px", "path": "/brand/logo-symbol-512.png", "format": "PNG" }, + { "title": "Symbol 256px", "path": "/brand/logo-symbol-256.png", "format": "PNG" }, + { "title": "Symbol 128px", "path": "/brand/logo-symbol-128.png", "format": "PNG" }, + { "title": "Symbol 64px", "path": "/brand/logo-symbol-64.png", "format": "PNG" } + ] + }, + { + "id": "horizontal", + "title": "Horizontal lockup", + "description": "Icon + MIRACLES IN MOTION FOUNDATION — desktop nav and footer.", + "assets": [ + { "title": "Horizontal (PNG)", "path": "/brand/logo-horizontal.png", "format": "PNG" }, + { "title": "Horizontal (WebP)", "path": "/brand/logo-horizontal.webp", "format": "WebP" }, + { "title": "Horizontal 2× width (PNG)", "path": "/brand/logo-horizontal-2x.png", "format": "PNG" } + ] + }, + { + "id": "square", + "title": "Square lockup", + "description": "Full stacked logo on green — social and print-friendly raster.", + "assets": [ + { "title": "Square (PNG)", "path": "/brand/logo-square.png", "format": "PNG" }, + { "title": "Square (WebP)", "path": "/brand/logo-square.webp", "format": "WebP" } + ] + }, + { + "id": "qr", + "title": "Branded QR codes", + "description": "Approved dynamic QR styling — forest green field, gold diamond modules, leaf corner finders. Generated via QRCG (admin /admin-qr).", + "assets": [ + { + "title": "Branded QR reference (PNG)", + "path": "/brand/qr-code-branded-reference.png", + "format": "PNG", + "description": "Canonical visual reference for donate/event QR codes" + } + ] + }, + { + "id": "favicon", + "title": "Favicon & app icons", + "description": "Symbol on forest green background.", + "assets": [ + { "title": "favicon.ico", "path": "/brand/favicon.ico", "format": "ICO" }, + { "title": "16×16", "path": "/brand/favicon-16.png", "format": "PNG" }, + { "title": "32×32", "path": "/brand/favicon-32.png", "format": "PNG" }, + { "title": "180×180 (Apple touch)", "path": "/brand/favicon-180.png", "format": "PNG" }, + { "title": "192×192 (PWA)", "path": "/brand/favicon-192.png", "format": "PNG" }, + { "title": "512×512 (PWA)", "path": "/brand/favicon-512.png", "format": "PNG" }, + { "title": "Favicon (SVG)", "path": "/favicon.svg", "format": "SVG" } + ] + } + ] +} diff --git a/mim-api/src/defaults/qrcg-mim-brand.json b/mim-api/src/defaults/qrcg-mim-brand.json new file mode 100644 index 0000000..cc570c9 --- /dev/null +++ b/mim-api/src/defaults/qrcg-mim-brand.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0", + "description": "Canonical MIM4U dynamic QR styling — forest green field, gold diamond modules, leaf corner finders, optional center logo.", + "referenceImage": "/brand/qr-code-branded-reference.png", + "colors": { + "background": "#1a3c34", + "foreground": "#c5a059", + "foregroundBright": "#d4af37" + }, + "customizations": { + "frame": { "name": "no-frame" }, + "background": { "color": "#1a3c34" }, + "pattern": { "color": "#c5a059", "shape": "diamond" }, + "corners": { + "topLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" }, + "topRight": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" }, + "bottomLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" } + } + }, + "logo": { + "note": "QR Code Monkey: set QR_MONKEY_LOGO_URL or upload via /qr/uploadImage → QR_MONKEY_LOGO_FILE. QRCG: set QRCG_LOGO_ID.", + "localAsset": "/brand/qr-code-branded-reference.png" + }, + "qrcodeMonkey": { + "body": "diamond", + "eye": "frame14", + "eyeBall": "ball16", + "bodyColor": "#c5a059", + "bgColor": "#1a3c34", + "eye1Color": "#c5a059", + "eye2Color": "#c5a059", + "eye3Color": "#c5a059", + "eyeBall1Color": "#c5a059", + "eyeBall2Color": "#c5a059", + "eyeBall3Color": "#c5a059", + "logoMode": "clean" + } +} diff --git a/mim-api/src/lib/auth.js b/mim-api/src/lib/auth.js new file mode 100644 index 0000000..d06367a --- /dev/null +++ b/mim-api/src/lib/auth.js @@ -0,0 +1,65 @@ +import crypto from 'node:crypto' + +const TOKEN_TTL_MS = 12 * 60 * 60 * 1000 + +export function hashPassword(password) { + const salt = randomBytes(16) + const hash = scryptSync(password, salt) + return `${salt.toString('hex')}:${hash.toString('hex')}` +} + +export function verifyPassword(password, stored) { + const [saltHex, hashHex] = stored.split(':') + if (!saltHex || !hashHex) return false + const salt = Buffer.from(saltHex, 'hex') + const expected = Buffer.from(hashHex, 'hex') + const actual = scryptSync(password, salt) + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual) +} + +function scryptSync(password, salt) { + return crypto.scryptSync(password, salt, 64) +} + +function randomBytes(n) { + return crypto.randomBytes(n) +} + +function b64url(input) { + return Buffer.from(input).toString('base64url') +} + +function b64urlDecode(input) { + return Buffer.from(input, 'base64url').toString('utf8') +} + +export function signToken(payload, secret) { + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = b64url(JSON.stringify({ ...payload, exp: Date.now() + TOKEN_TTL_MS })) + const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url') + return `${header}.${body}.${sig}` +} + +export function verifyToken(token, secret) { + if (!token || typeof token !== 'string') return null + const parts = token.split('.') + if (parts.length !== 3) return null + const [header, body, sig] = parts + const expected = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url') + if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + return null + } + try { + const payload = JSON.parse(b64urlDecode(body)) + if (!payload.exp || Date.now() > payload.exp) return null + return payload + } catch { + return null + } +} + +export function maskSecret(value) { + if (!value || typeof value !== 'string') return '' + if (value.length <= 8) return '••••••••' + return `${value.slice(0, 7)}…${value.slice(-4)}` +} diff --git a/mim-api/src/lib/brand.js b/mim-api/src/lib/brand.js new file mode 100644 index 0000000..5a599e0 --- /dev/null +++ b/mim-api/src/lib/brand.js @@ -0,0 +1,107 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { readJsonFile, writeJsonFile } from './data.js' + +const MANIFEST_FILE = 'brand-manifest.json' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const DEFAULT_MANIFEST_PATH = path.join(__dirname, '../defaults/brand-manifest.default.json') + +export function brandDir(dataDir) { + return process.env.MIM_BRAND_DIR || path.join(dataDir, 'brand') +} + +export function loadDefaultManifest() { + if (fs.existsSync(DEFAULT_MANIFEST_PATH)) { + try { + return JSON.parse(fs.readFileSync(DEFAULT_MANIFEST_PATH, 'utf8')) + } catch { + return null + } + } + return null +} + +export function loadBrandManifest(dataDir) { + const stored = readJsonFile(dataDir, MANIFEST_FILE, null) + if (stored) { + return normalizeManifest(stored) + } + const seeded = loadDefaultManifest() + if (seeded) { + const normalized = normalizeManifest({ ...seeded, published: true }) + saveBrandManifest(dataDir, normalized) + return normalized + } + return normalizeManifest({ version: '1.0.0', organization: 'Miracles in Motion Foundation', updated: new Date().toISOString().slice(0, 10), colors: [], typography: [], groups: [], kits: [], published: true }) +} + +export function saveBrandManifest(dataDir, manifest) { + const dir = brandDir(dataDir) + fs.mkdirSync(dir, { recursive: true }) + writeJsonFile(dataDir, MANIFEST_FILE, { + ...manifest, + updated: new Date().toISOString().slice(0, 10), + }) +} + +function normalizeManifest(m) { + return { + version: m.version || '1.0.0', + organization: m.organization || 'Miracles in Motion Foundation', + updated: m.updated || new Date().toISOString().slice(0, 10), + published: m.published !== false, + colors: Array.isArray(m.colors) ? m.colors : [], + typography: Array.isArray(m.typography) ? m.typography : [], + kits: Array.isArray(m.kits) ? m.kits : [], + groups: (m.groups || []).map((g) => ({ + ...g, + visible: g.visible !== false, + assets: (g.assets || []).map((a) => ({ + ...a, + visible: a.visible !== false, + })), + })), + usageNotes: m.usageNotes || '', + } +} + +export function publicBrandManifest(manifest) { + if (!manifest.published) { + return { published: false, organization: manifest.organization, message: 'Brand kit is temporarily unavailable.' } + } + return { + ...manifest, + groups: manifest.groups + .filter((g) => g.visible !== false) + .map((g) => ({ + ...g, + assets: g.assets.filter((a) => a.visible !== false), + })), + } +} + +export function listBrandFiles(dataDir) { + const dir = brandDir(dataDir) + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((f) => !f.startsWith('.')) +} + +export function safeBrandFilename(name) { + return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '-') +} + +export async function rebuildBrandZip(dataDir) { + const dir = brandDir(dataDir) + const zipPath = path.join(dir, 'MIM4U-Brand-Kit.zip') + const files = listBrandFiles(dataDir).filter((f) => f !== 'MIM4U-Brand-Kit.zip' && !f.endsWith('.json')) + if (!files.length) return { ok: false, error: 'No brand files to zip' } + + const { spawnSync } = await import('node:child_process') + const args = ['-q', 'MIM4U-Brand-Kit.zip', ...files] + const result = spawnSync('zip', args, { cwd: dir }) + if (result.status !== 0) { + return { ok: false, error: result.stderr?.toString() || 'zip failed' } + } + return { ok: true, path: '/api/public/brand/files/MIM4U-Brand-Kit.zip', fileCount: files.length } +} diff --git a/mim-api/src/lib/data.js b/mim-api/src/lib/data.js new file mode 100644 index 0000000..83e62d2 --- /dev/null +++ b/mim-api/src/lib/data.js @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' + +export function ensureDataDir(dataDir) { + fs.mkdirSync(dataDir, { recursive: true }) +} + +export function appendJsonl(dataDir, file, record) { + const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n` + fs.appendFileSync(path.join(dataDir, file), line, 'utf8') +} + +export function readJsonl(dataDir, file, { limit = 100, reverse = true } = {}) { + const filePath = path.join(dataDir, file) + if (!fs.existsSync(filePath)) return [] + const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean) + const parsed = lines.map((line) => { + try { + return JSON.parse(line) + } catch { + return null + } + }).filter(Boolean) + const ordered = reverse ? parsed.reverse() : parsed + return ordered.slice(0, limit) +} + +export function readJsonFile(dataDir, file, fallback) { + const filePath = path.join(dataDir, file) + if (!fs.existsSync(filePath)) return fallback + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch { + return fallback + } +} + +export function writeJsonFile(dataDir, file, data) { + const filePath = path.join(dataDir, file) + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') +} + +export function rewriteJsonl(dataDir, file, records) { + const filePath = path.join(dataDir, file) + const content = records.map((r) => JSON.stringify(r)).join('\n') + fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8') +} diff --git a/mim-api/src/lib/qr-brand.js b/mim-api/src/lib/qr-brand.js new file mode 100644 index 0000000..beea60e --- /dev/null +++ b/mim-api/src/lib/qr-brand.js @@ -0,0 +1,72 @@ +/** + * Shared MIM4U QR brand spec — used by QR Code Monkey and QRCG providers. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const BRAND_SPEC_PATH = path.join(__dirname, '../defaults/qrcg-mim-brand.json') + +let cachedBrandSpec = null + +export function loadBrandSpec() { + if (cachedBrandSpec) return cachedBrandSpec + try { + cachedBrandSpec = JSON.parse(fs.readFileSync(BRAND_SPEC_PATH, 'utf8')) + } catch { + cachedBrandSpec = { + colors: { background: '#1a3c34', foreground: '#c5a059' }, + referenceImage: '/brand/qr-code-branded-reference.png', + qrcodeMonkey: { + body: 'diamond', + eye: 'frame14', + eyeBall: 'ball16', + bodyColor: '#c5a059', + bgColor: '#1a3c34', + eye1Color: '#c5a059', + eye2Color: '#c5a059', + eye3Color: '#c5a059', + eyeBall1Color: '#c5a059', + eyeBall2Color: '#c5a059', + eyeBall3Color: '#c5a059', + logoMode: 'clean', + }, + } + } + return cachedBrandSpec +} + +export const QR_PRESETS = { + donate: { + title: 'MIM4U — Donate', + url: 'https://mim4u.org/donate', + purpose: 'donate', + }, + brand: { + title: 'MIM4U — Brand assets', + url: 'https://mim4u.org/brand', + purpose: 'brand', + }, + home: { + title: 'MIM4U — Home', + url: 'https://mim4u.org', + purpose: 'home', + }, +} + +export function mimQrBrandSpec(publicUrl) { + const spec = loadBrandSpec() + const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '') + const logoUrl = + process.env.QR_MONKEY_LOGO_URL?.trim() || + process.env.MIM_QR_LOGO_URL?.trim() || + `${siteBase}/brand/logo-square.png` + + return { + colors: spec.colors, + referenceImage: spec.referenceImage, + qrcodeMonkey: { ...spec.qrcodeMonkey, logo: logoUrl }, + logoUrl, + } +} diff --git a/mim-api/src/lib/qr-monkey.js b/mim-api/src/lib/qr-monkey.js new file mode 100644 index 0000000..60dc099 --- /dev/null +++ b/mim-api/src/lib/qr-monkey.js @@ -0,0 +1,141 @@ +/** + * QR Code Monkey API — custom QR with logo and design + * @see https://www.qrcode-monkey.com/qr-code-api-with-logo/ + */ +import { loadBrandSpec, mimQrBrandSpec } from './qr-brand.js' + +const DIRECT_BASE = 'https://api.qrcode-monkey.com' +const RAPIDAPI_BASE = 'https://qrcode-monkey.p.rapidapi.com' +const RAPIDAPI_HOST = 'qrcode-monkey.p.rapidapi.com' + +export function getQrMonkeyBaseUrl({ preferRapidApi } = {}) { + const custom = String(process.env.QR_MONKEY_API_BASE || '').trim() + if (custom) return custom.replace(/\/$/, '') + const useRapid = + preferRapidApi || + ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase()) + if (useRapid && getRapidApiKey()) return RAPIDAPI_BASE + return DIRECT_BASE +} + +export function hasRapidApiKey() { + return Boolean(getRapidApiKey()) +} + +export function getRapidApiKey() { + return String(process.env.RAPIDAPI_KEY || process.env.QR_MONKEY_RAPIDAPI_KEY || '').trim() +} + +export function isQrMonkeyConfigured() { + return true +} + +export function defaultMonkeyConfig(publicUrl) { + const spec = loadBrandSpec() + const brand = mimQrBrandSpec(publicUrl) + const monkey = { ...spec.qrcodeMonkey } + if (brand.logoUrl) monkey.logo = brand.logoUrl + const uploaded = String(process.env.QR_MONKEY_LOGO_FILE || '').trim() + if (uploaded) monkey.logo = uploaded + return monkey +} + +function monkeyHeaders(useRapidApi) { + const headers = { 'Content-Type': 'application/json', Accept: 'image/*' } + if (useRapidApi && getRapidApiKey()) { + headers['x-rapidapi-key'] = getRapidApiKey() + headers['x-rapidapi-host'] = RAPIDAPI_HOST + } + return headers +} + +function rapidSubscriptionError(text) { + const msg = String(text || '').toLowerCase() + return msg.includes('not subscribed') || msg.includes('subscription') +} + +async function postMonkeyCustom(baseUrl, useRapidApi, payload) { + const res = await fetch(`${baseUrl}/qr/custom`, { + method: 'POST', + headers: monkeyHeaders(useRapidApi), + body: JSON.stringify(payload), + }) + const contentType = res.headers.get('content-type') || '' + if (!res.ok) { + const text = await res.text().catch(() => '') + return { res, text, buffer: null, contentType } + } + const buffer = Buffer.from(await res.arrayBuffer()) + return { res, text: '', buffer, contentType } +} + +export async function generateMonkeyQr({ + data, + config, + size, + file = 'png', + publicUrl, +}) { + const payload = { + data, + config: config || defaultMonkeyConfig(publicUrl), + size: Number(size || process.env.QR_MONKEY_SIZE || 600), + download: false, + file, + } + + const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase()) + let baseUrl = getQrMonkeyBaseUrl({ preferRapidApi: useRapid }) + let attempt = await postMonkeyCustom(baseUrl, baseUrl === RAPIDAPI_BASE, payload) + + if ( + !attempt.res.ok && + baseUrl === RAPIDAPI_BASE && + (attempt.res.status === 403 || attempt.res.status === 401) && + rapidSubscriptionError(attempt.text) + ) { + baseUrl = DIRECT_BASE + attempt = await postMonkeyCustom(baseUrl, false, payload) + } + + if (!attempt.res.ok) { + const err = new Error( + `QR Code Monkey error (${attempt.res.status})${attempt.text ? `: ${attempt.text.slice(0, 200)}` : ''}`, + ) + err.status = attempt.res.status + throw err + } + + return { + buffer: attempt.buffer, + contentType: attempt.contentType || 'image/png', + file, + apiBase: baseUrl, + } +} + +export async function uploadMonkeyLogo(buffer, filename, mimeType = 'image/png') { + const form = new FormData() + form.append('file', new Blob([buffer], { type: mimeType }), filename) + + const headers = {} + const rapidKey = getRapidApiKey() + if (rapidKey) { + headers['x-rapidapi-key'] = rapidKey + headers['x-rapidapi-host'] = RAPIDAPI_HOST + } + + const res = await fetch(`${getQrMonkeyBaseUrl()}/qr/uploadImage`, { + method: 'POST', + headers, + body: form, + }) + + const data = await res.json().catch(() => ({})) + if (!res.ok) { + const err = new Error(data?.message || `QR Code Monkey upload failed (${res.status})`) + err.status = res.status + throw err + } + return data.file +} diff --git a/mim-api/src/lib/qr-provider.js b/mim-api/src/lib/qr-provider.js new file mode 100644 index 0000000..879a1b3 --- /dev/null +++ b/mim-api/src/lib/qr-provider.js @@ -0,0 +1,284 @@ +/** + * QR provider orchestration — QR Code Monkey (default) or QRCG (dynamic tracking). + */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { appendJsonl, readJsonl, rewriteJsonl } from './data.js' +import { QR_PRESETS, mimQrBrandSpec } from './qr-brand.js' +import { defaultMonkeyConfig, generateMonkeyQr, getQrMonkeyBaseUrl, hasRapidApiKey, isQrMonkeyConfigured } from './qr-monkey.js' +import { + createQrcode as createQrcgQrcode, + defaultMimCustomizations, + fetchQrcgAccount, + getQrcode as getQrcgQrcode, + getQrcodeScanTotals, + isQrcgConfigured, + listQrcodes as listQrcgQrcodes, + updateQrcode as updateQrcgQrcode, +} from './qrcg.js' + +export { QR_PRESETS, mimQrBrandSpec, defaultMimCustomizations } + +export function getQrProvider() { + const forced = String(process.env.MIM_QR_PROVIDER || '').trim().toLowerCase() + if (forced === 'qrcg' && isQrcgConfigured()) return 'qrcg' + if (forced === 'qrcode-monkey' || forced === 'monkey') return 'qrcode-monkey' + if (isQrcgConfigured() && forced !== 'qrcode-monkey' && forced !== 'monkey') { + return isQrMonkeyConfigured() ? 'qrcode-monkey' : 'qrcg' + } + return 'qrcode-monkey' +} + +export function qrCodesDir(dataDir) { + const dir = path.join(dataDir, 'qr-codes') + fs.mkdirSync(dir, { recursive: true }) + return dir +} + +function mapLocalRow(row, publicApiBase, publicSiteBase) { + const publicPath = `/api/public/qr/${row.id}` + const imagePath = row.imageFile ? publicPath : null + const site = String(publicSiteBase || '').replace(/\/$/, '') + return { + id: row.id, + provider: row.provider || 'qrcode-monkey', + type: 'url', + title: row.title, + status: row.status || 'active', + url: row.url, + shortUrl: row.shortUrl || null, + previewUrl: imagePath ? (site ? `${site}${imagePath}` : imagePath) : null, + imageUrl: imagePath, + createdAt: row.ts, + purpose: row.purpose || null, + scans: row.scans || null, + } +} + +export async function getQrStatus(publicUrl) { + const provider = getQrProvider() + const brand = mimQrBrandSpec(publicUrl) + + if (provider === 'qrcode-monkey') { + const rapidKey = hasRapidApiKey() + const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase()) + return { + provider, + configured: true, + dynamicTracking: false, + apiBase: getQrMonkeyBaseUrl({ preferRapidApi: useRapid }), + rapidApiKeySet: rapidKey, + rapidApiSubscribed: useRapid && rapidKey, + rapidApiNote: rapidKey && !useRapid + ? 'RAPIDAPI_KEY is set; using direct API until you subscribe at rapidapi.com and set QR_MONKEY_USE_RAPIDAPI=1' + : undefined, + presets: Object.keys(QR_PRESETS), + brand, + docsUrl: 'https://www.qrcode-monkey.com/qr-code-api-with-logo/', + } + } + + if (!isQrcgConfigured()) { + return { + provider: 'qrcg', + configured: false, + dynamicTracking: true, + presets: Object.keys(QR_PRESETS), + brand, + docsUrl: 'https://dev.qrcg.com/', + } + } + + try { + const account = await fetchQrcgAccount() + return { + provider: 'qrcg', + configured: true, + dynamicTracking: true, + account, + presets: Object.keys(QR_PRESETS), + brand, + docsUrl: 'https://dev.qrcg.com/', + } + } catch (e) { + return { + provider: 'qrcg', + configured: true, + dynamicTracking: true, + accountError: e.message, + presets: Object.keys(QR_PRESETS), + brand, + docsUrl: 'https://dev.qrcg.com/', + } + } +} + +export async function listQrcodes(dataDir, { cursor, limit = 20, publicApiBase, publicSiteBase } = {}) { + const provider = getQrProvider() + const local = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 500 }) + + if (provider === 'qrcode-monkey') { + const rows = local + .filter((r) => !r.provider || r.provider === 'qrcode-monkey') + .slice(0, limit) + .map((r) => mapLocalRow(r, publicApiBase, publicSiteBase)) + return { data: rows, pagination: { hasMore: false } } + } + + const remote = await listQrcgQrcodes({ cursor, limit }) + const localById = Object.fromEntries(local.map((r) => [String(r.qrcodeId || r.id), r])) + const data = (remote.data || []).map((row) => ({ + id: row.id, + provider: 'qrcg', + type: row.type, + title: row.title, + status: row.status, + url: row.url, + shortUrl: row.shortUrl, + previewUrl: row.previewUrl, + createdAt: row.createdAt, + purpose: localById[String(row.id)]?.purpose || null, + scans: row.scans, + })) + return { ...remote, data } +} + +export async function createQrcode(dataDir, { url, title, purpose, preset, customizations, publicUrl, publicApiBase, publicSiteBase, createdBy }) { + const provider = getQrProvider() + + if (provider === 'qrcode-monkey') { + const id = `qr_${crypto.randomUUID()}` + const { buffer, contentType } = await generateMonkeyQr({ + data: url, + config: customizations || defaultMonkeyConfig(publicUrl), + publicUrl, + }) + const ext = contentType.includes('svg') ? 'svg' : 'png' + const imageFile = `${id}.${ext}` + fs.writeFileSync(path.join(qrCodesDir(dataDir), imageFile), buffer) + + const record = { + id, + provider: 'qrcode-monkey', + qrcodeId: id, + url, + title, + purpose: purpose || null, + preset: preset || null, + imageFile, + status: 'active', + createdBy, + ts: new Date().toISOString(), + } + appendJsonl(dataDir, 'qr-codes.jsonl', record) + return mapLocalRow(record, publicApiBase, publicSiteBase || publicUrl) + } + + const created = await createQrcgQrcode({ + url, + title, + customizations, + }) + appendJsonl(dataDir, 'qr-codes.jsonl', { + qrcodeId: created.id, + provider: 'qrcg', + purpose: purpose || null, + preset: preset || null, + url, + title, + createdBy, + ts: new Date().toISOString(), + }) + return { + id: created.id, + provider: 'qrcg', + type: created.type, + title: created.title, + status: created.status, + url: created.url, + shortUrl: created.shortUrl, + previewUrl: created.previewUrl, + createdAt: created.createdAt, + scans: created.scans, + } +} + +export function getLocalQrcode(dataDir, id) { + const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false }) + return rows.find((r) => r.id === id || r.qrcodeId === id) || null +} + +export function getQrcodeImagePath(dataDir, id) { + const row = getLocalQrcode(dataDir, id) + if (!row?.imageFile) return null + const filePath = path.join(qrCodesDir(dataDir), row.imageFile) + return fs.existsSync(filePath) ? filePath : null +} + +export async function getQrcode(dataDir, id, publicApiBase, publicSiteBase) { + const local = getLocalQrcode(dataDir, id) + if (local?.provider === 'qrcode-monkey' || local?.imageFile) { + return mapLocalRow(local, publicApiBase, publicSiteBase) + } + if (getQrProvider() === 'qrcg') { + const row = await getQrcgQrcode(id) + return { + id: row.id, + provider: 'qrcg', + title: row.title, + status: row.status, + url: row.url, + shortUrl: row.shortUrl, + previewUrl: row.previewUrl, + createdAt: row.createdAt, + scans: row.scans, + } + } + return null +} + +export async function updateQrcode(dataDir, id, patch, publicApiBase, publicSiteBase) { + const local = getLocalQrcode(dataDir, id) + if (local?.provider === 'qrcode-monkey') { + const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false }) + const idx = rows.findIndex((r) => r.id === id) + if (idx < 0) { + const err = new Error('QR code not found') + err.status = 404 + throw err + } + if (patch.status) rows[idx].status = patch.status + if (patch.title) rows[idx].title = patch.title + if (patch.url) rows[idx].url = patch.url + rows[idx].updatedAt = new Date().toISOString() + rewriteJsonl(dataDir, 'qr-codes.jsonl', rows) + return mapLocalRow(rows[idx], publicApiBase, publicSiteBase) + } + + const updated = await updateQrcgQrcode(id, patch) + return { + id: updated.id, + provider: 'qrcg', + title: updated.title, + status: updated.status, + url: updated.url, + shortUrl: updated.shortUrl, + previewUrl: updated.previewUrl, + createdAt: updated.createdAt, + scans: updated.scans, + } +} + +export async function getQrcodeScans(id) { + if (getQrProvider() !== 'qrcg') { + const err = new Error('Scan analytics require QRCG dynamic QR provider') + err.status = 400 + throw err + } + return getQrcodeScanTotals(id) +} + +export function isAnyQrConfigured() { + return isQrMonkeyConfigured() || isQrcgConfigured() +} diff --git a/mim-api/src/lib/qrcg.js b/mim-api/src/lib/qrcg.js new file mode 100644 index 0000000..1144b76 --- /dev/null +++ b/mim-api/src/lib/qrcg.js @@ -0,0 +1,109 @@ +/** + * QRCG by Bitly — Public API (preview) — dynamic QR with scan analytics + * @see https://dev.qrcg.com/ + */ +import { loadBrandSpec } from './qr-brand.js' + +const DEFAULT_BASE = 'https://dev.qrcg.com/v3-preview' + +export function getQrcgApiKey() { + return String(process.env.QRCG_API_KEY || '').trim() +} + +export function getQrcgBaseUrl() { + return String(process.env.QRCG_API_BASE_URL || DEFAULT_BASE).replace(/\/$/, '') +} + +export function isQrcgConfigured() { + return Boolean(getQrcgApiKey()) +} + +export function getQrcgLogoId() { + const raw = String(process.env.QRCG_LOGO_ID || '').trim() + if (!raw) return null + const id = Number(raw) + return Number.isFinite(id) && id > 0 ? id : null +} + +/** Branded QR styling for QRCG API. */ +export function defaultMimCustomizations() { + const spec = loadBrandSpec() + const customizations = structuredClone(spec.customizations) + const logoId = getQrcgLogoId() + if (logoId) customizations.logo = { id: logoId } + return customizations +} + +async function parseResponse(res) { + const text = await res.text() + if (!text) return null + try { + return JSON.parse(text) + } catch { + return { raw: text } + } +} + +export async function qrcgRequest(method, path, body) { + const key = getQrcgApiKey() + if (!key) { + const err = new Error('QRCG API key not configured (set QRCG_API_KEY)') + err.status = 503 + throw err + } + + const url = `${getQrcgBaseUrl()}${path.startsWith('/') ? path : `/${path}`}` + const res = await fetch(url, { + method, + headers: { + Authorization: `Key ${key}`, + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }) + + const data = await parseResponse(res) + if (!res.ok) { + const err = new Error(data?.message || data?.error || `QRCG API error (${res.status})`) + err.status = res.status + err.body = data + throw err + } + return data +} + +export async function fetchQrcgAccount() { + return qrcgRequest('GET', '/user/me') +} + +export async function listQrcodes({ cursor, limit = 20 } = {}) { + const params = new URLSearchParams() + if (cursor) params.set('cursor', cursor) + if (limit) params.set('limit', String(limit)) + const qs = params.toString() + return qrcgRequest('GET', `/qrcodes${qs ? `?${qs}` : ''}`) +} + +export async function getQrcode(id) { + return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}`) +} + +export async function createQrcode({ url, title, status = 'active', customizations, folderId }) { + return qrcgRequest('POST', '/qrcodes', { + type: 'url', + url, + title, + status, + customizations: customizations || defaultMimCustomizations(), + ...(folderId != null ? { folderId } : {}), + }) +} + +export async function updateQrcode(id, patch) { + return qrcgRequest('PATCH', `/qrcodes/${encodeURIComponent(id)}`, patch) +} + +export async function getQrcodeScanTotals(id) { + return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}/scans/total`) +} diff --git a/mim-api/src/lib/settings.js b/mim-api/src/lib/settings.js new file mode 100644 index 0000000..05b02bb --- /dev/null +++ b/mim-api/src/lib/settings.js @@ -0,0 +1,84 @@ +import { maskSecret } from './auth.js' +import { readJsonFile, writeJsonFile } from './data.js' + +const SETTINGS_FILE = 'admin-settings.json' + +const DEFAULT_SETTINGS = { + donationsEnabled: true, + stripePublishableKey: '', + stripeSecretKey: '', + stripeWebhookSecret: '', + updatedAt: null, + updatedBy: null, +} + +function envFallback() { + return { + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.VITE_STRIPE_PUBLISHABLE_KEY || '', + stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + } +} + +export function loadSettings(dataDir) { + const stored = readJsonFile(dataDir, SETTINGS_FILE, {}) + const env = envFallback() + return { + ...DEFAULT_SETTINGS, + donationsEnabled: stored.donationsEnabled ?? DEFAULT_SETTINGS.donationsEnabled, + stripePublishableKey: stored.stripePublishableKey || env.stripePublishableKey, + stripeSecretKey: stored.stripeSecretKey || env.stripeSecretKey, + stripeWebhookSecret: stored.stripeWebhookSecret || env.stripeWebhookSecret, + updatedAt: stored.updatedAt || null, + updatedBy: stored.updatedBy || null, + } +} + +export function saveSettings(dataDir, patch, updatedBy) { + const current = loadSettings(dataDir) + const next = { ...current } + + if (typeof patch.donationsEnabled === 'boolean') next.donationsEnabled = patch.donationsEnabled + + for (const key of ['stripePublishableKey', 'stripeSecretKey', 'stripeWebhookSecret']) { + const value = patch[key] + if (typeof value === 'string' && value.trim() && !value.includes('…')) { + next[key] = value.trim() + } + } + + next.updatedAt = new Date().toISOString() + next.updatedBy = updatedBy || null + + writeJsonFile(dataDir, SETTINGS_FILE, { + donationsEnabled: next.donationsEnabled, + stripePublishableKey: next.stripePublishableKey, + stripeSecretKey: next.stripeSecretKey, + stripeWebhookSecret: next.stripeWebhookSecret, + updatedAt: next.updatedAt, + updatedBy: next.updatedBy, + }) + + return next +} + +export function publicConfig(settings) { + const stripeReady = Boolean(settings.donationsEnabled && settings.stripePublishableKey && settings.stripeSecretKey) + return { + donationsEnabled: Boolean(settings.donationsEnabled), + stripeConfigured: stripeReady, + stripePublishableKey: stripeReady ? settings.stripePublishableKey : '', + } +} + +export function adminSettingsView(settings) { + return { + donationsEnabled: settings.donationsEnabled, + stripePublishableKey: settings.stripePublishableKey, + stripeSecretKey: maskSecret(settings.stripeSecretKey), + stripeWebhookSecret: maskSecret(settings.stripeWebhookSecret), + stripeConfigured: Boolean(settings.stripeSecretKey && settings.stripePublishableKey), + updatedAt: settings.updatedAt, + updatedBy: settings.updatedBy, + } +} diff --git a/mim-api/src/lib/stripe-service.js b/mim-api/src/lib/stripe-service.js new file mode 100644 index 0000000..ac80646 --- /dev/null +++ b/mim-api/src/lib/stripe-service.js @@ -0,0 +1,45 @@ +import Stripe from 'stripe' +import { loadSettings } from './settings.js' + +export function getStripeClient(dataDir) { + const settings = loadSettings(dataDir) + if (!settings.stripeSecretKey) return null + return new Stripe(settings.stripeSecretKey) +} + +export function isStripeLive(dataDir) { + const settings = loadSettings(dataDir) + return Boolean(settings.donationsEnabled && settings.stripeSecretKey && settings.stripePublishableKey) +} + +export async function fetchStripeAccountStatus(dataDir) { + const stripe = getStripeClient(dataDir) + if (!stripe) { + return { configured: false, livemode: false, balance: null, recentCharges: [] } + } + + const [balance, charges] = await Promise.all([ + stripe.balance.retrieve().catch(() => null), + stripe.charges.list({ limit: 10 }).catch(() => ({ data: [] })), + ]) + + return { + configured: true, + livemode: Boolean(charges.data[0]?.livemode), + balance: balance + ? { + available: balance.available, + pending: balance.pending, + } + : null, + recentCharges: (charges.data || []).map((c) => ({ + id: c.id, + amount: c.amount, + currency: c.currency, + status: c.status, + created: c.created, + receiptEmail: c.receipt_email || c.billing_details?.email || null, + description: c.description, + })), + } +} diff --git a/mim-api/src/lib/users.js b/mim-api/src/lib/users.js new file mode 100644 index 0000000..be6c0f7 --- /dev/null +++ b/mim-api/src/lib/users.js @@ -0,0 +1,85 @@ +import { hashPassword, verifyPassword } from './auth.js' +import { readJsonFile, writeJsonFile } from './data.js' + +const USERS_FILE = 'users.json' + +const DEFAULT_USERS = [ + { + id: 'admin-1', + email: 'admin@mim4u.org', + role: 'admin', + name: 'MIM Administrator', + permissions: ['all'], + passwordEnv: 'MIM_ADMIN_PASSWORD', + defaultPassword: 'demo123', + }, + { + id: 'volunteer-1', + email: 'volunteer@mim4u.org', + role: 'volunteer', + name: 'MIM Volunteer', + permissions: ['view_requests', 'update_assignments'], + passwordEnv: 'MIM_VOLUNTEER_PASSWORD', + defaultPassword: 'demo123', + }, + { + id: 'resource-1', + email: 'resource@mim4u.org', + role: 'resource', + name: 'MIM Resource Partner', + permissions: ['submit_requests', 'view_own_requests'], + passwordEnv: 'MIM_RESOURCE_PASSWORD', + defaultPassword: 'demo123', + }, +] + +function sanitizeUser(user) { + const { passwordHash, ...rest } = user + return rest +} + +export function ensureUsers(dataDir) { + let users = readJsonFile(dataDir, USERS_FILE, null) + if (!users?.length) { + const isProd = process.env.NODE_ENV === 'production' + users = DEFAULT_USERS.map((seed) => { + const envPassword = process.env[seed.passwordEnv] + const password = envPassword || (!isProd ? seed.defaultPassword : null) + if (!password) { + console.warn(`[mim-api] No password for ${seed.email} — set ${seed.passwordEnv}`) + return null + } + return { + id: seed.id, + email: seed.email, + role: seed.role, + name: seed.name, + permissions: seed.permissions, + passwordHash: hashPassword(password), + createdAt: new Date().toISOString(), + } + }).filter(Boolean) + if (users.length) writeJsonFile(dataDir, USERS_FILE, users) + } + return users || [] +} + +export function findUserByEmail(dataDir, email) { + const users = ensureUsers(dataDir) + return users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null +} + +export function authenticateUser(dataDir, email, password) { + const user = findUserByEmail(dataDir, email) + if (!user || !verifyPassword(password, user.passwordHash)) return null + return sanitizeUser({ ...user, lastLogin: new Date().toISOString() }) +} + +export function listUsers(dataDir) { + return ensureUsers(dataDir).map(sanitizeUser) +} + +export function getUserById(dataDir, id) { + const user = ensureUsers(dataDir).find((u) => u.id === id) + return user ? sanitizeUser(user) : null +} diff --git a/mim-api/src/middleware/auth.js b/mim-api/src/middleware/auth.js new file mode 100644 index 0000000..eb47bd6 --- /dev/null +++ b/mim-api/src/middleware/auth.js @@ -0,0 +1,31 @@ +import { verifyToken } from '../lib/auth.js' +import { getUserById } from '../lib/users.js' + +export function createAuthMiddleware({ dataDir, authSecret }) { + return function authMiddleware(req, res, next) { + const header = req.headers.authorization || '' + const token = header.startsWith('Bearer ') ? header.slice(7) : null + if (!token) { + return res.status(401).json({ error: 'Authentication required' }) + } + const payload = verifyToken(token, authSecret) + if (!payload?.sub) { + return res.status(401).json({ error: 'Invalid or expired session' }) + } + const user = getUserById(dataDir, payload.sub) + if (!user) { + return res.status(401).json({ error: 'User not found' }) + } + req.user = user + next() + } +} + +export function requireRole(...roles) { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ error: 'Insufficient permissions' }) + } + next() + } +} diff --git a/mim-api/src/middleware/cors.js b/mim-api/src/middleware/cors.js new file mode 100644 index 0000000..3d2c18f --- /dev/null +++ b/mim-api/src/middleware/cors.js @@ -0,0 +1,12 @@ +export function createCorsMiddleware(corsOrigins) { + return function cors(req, res, next) { + const origin = req.headers.origin + if (origin && (corsOrigins.includes(origin) || corsOrigins.includes('*'))) { + res.setHeader('Access-Control-Allow-Origin', origin) + } + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Stripe-Signature') + if (req.method === 'OPTIONS') return res.sendStatus(204) + next() + } +} diff --git a/mim-api/src/routes/admin-routes.js b/mim-api/src/routes/admin-routes.js new file mode 100644 index 0000000..381269f --- /dev/null +++ b/mim-api/src/routes/admin-routes.js @@ -0,0 +1,429 @@ +import express from 'express' +import { readJsonl, appendJsonl, rewriteJsonl } from '../lib/data.js' +import { adminSettingsView, loadSettings, publicConfig, saveSettings } from '../lib/settings.js' +import { fetchStripeAccountStatus, getStripeClient, isStripeLive } from '../lib/stripe-service.js' +import { listUsers } from '../lib/users.js' +import { createAuthMiddleware, requireRole } from '../middleware/auth.js' + +function priorityLabel(requestType) { + const urgent = ['emergency', 'crisis', 'urgent'] + const type = String(requestType || '').toLowerCase() + if (urgent.some((u) => type.includes(u))) return 'High' + if (type.includes('clothing') || type.includes('food')) return 'Medium' + return 'Low' +} + +function timeAgo(iso) { + if (!iso) return 'unknown' + const diff = Date.now() - new Date(iso).getTime() + const hours = Math.floor(diff / 3600000) + if (hours < 1) return 'just now' + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago` + const days = Math.floor(hours / 24) + return `${days} day${days === 1 ? '' : 's'} ago` +} + +export function createAdminRoutes({ dataDir, authSecret }) { + const router = express.Router() + const auth = createAuthMiddleware({ dataDir, authSecret }) + + router.use(auth) + router.use(requireRole('admin')) + + router.get('/dashboard', (_req, res) => { + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 }) + const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 500 }) + const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 }) + const pending = assistance.filter((r) => !r.status || r.status === 'pending') + const today = new Date().toISOString().slice(0, 10) + const deliveriesToday = assignments.filter((a) => a.scheduledDate?.startsWith(today)).length + const monthlyDonations = donations.reduce((sum, d) => sum + (d.amountTotal || 0), 0) + + res.json({ + pendingRequests: pending.length, + activeVolunteers: assignments.filter((a) => a.status === 'active').length || listUsers(dataDir).filter((u) => u.role === 'volunteer').length, + deliveriesToday, + monthlyBudget: 15000, + monthlySpent: Math.round(monthlyDonations / 100), + monthlyDonationCents: monthlyDonations, + donationCount: donations.length, + }) + }) + + router.get('/assistance-requests', (req, res) => { + const limit = Math.min(Number(req.query.limit) || 50, 200) + const status = req.query.status + let rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 }) + if (status) rows = rows.filter((r) => (r.status || 'pending') === status) + res.json({ + data: rows.slice(0, limit).map((r) => ({ + id: r.id, + requestType: r.requestType, + student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(), + school: r.studentInfo?.school, + need: r.details?.slice(0, 120) || r.requestType, + priority: priorityLabel(r.requestType), + status: r.status || 'pending', + contactName: r.contactInfo?.parentName, + contactEmail: r.contactInfo?.email, + contactPhone: r.contactInfo?.phone, + timeAgo: timeAgo(r.ts), + ts: r.ts, + })), + total: rows.length, + }) + }) + + router.patch('/assistance-requests/:id', express.json({ limit: '32kb' }), (req, res) => { + const { id } = req.params + const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 10000, reverse: false }) + const idx = rows.findIndex((r) => r.id === id) + if (idx < 0) return res.status(404).json({ error: 'Request not found' }) + + const patch = req.body || {} + rows[idx] = { + ...rows[idx], + status: patch.status || rows[idx].status || 'pending', + assignedTo: patch.assignedTo ?? rows[idx].assignedTo, + adminNotes: patch.adminNotes ?? rows[idx].adminNotes, + updatedAt: new Date().toISOString(), + updatedBy: req.user.email, + } + rewriteJsonl(dataDir, 'assistance-requests.jsonl', rows) + res.json({ ok: true, request: rows[idx] }) + }) + + router.get('/donations', (req, res) => { + const limit = Math.min(Number(req.query.limit) || 50, 200) + const rows = readJsonl(dataDir, 'donations.jsonl', { limit }) + res.json({ + data: rows.map((d) => ({ + id: d.sessionId || d.id, + amountCents: d.amountTotal || 0, + amountUsd: ((d.amountTotal || 0) / 100).toFixed(2), + email: d.customerEmail || d.metadata?.donorEmail, + donorName: d.metadata?.donorName, + anonymous: d.metadata?.anonymous === 'true', + ts: d.ts, + timeAgo: timeAgo(d.ts), + })), + total: rows.length, + }) + }) + + router.get('/analytics/summary', (_req, res) => { + const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 1000 }) + const events = readJsonl(dataDir, 'events.jsonl', { limit: 5000 }) + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 1000 }) + + const donationAmount = donations.reduce((s, d) => s + (d.amountTotal || 0), 0) / 100 + const donationEvents = events.filter((e) => String(e.event || e.eventName || '').includes('donation')) + const pageViews = aggregatePageViews(events) + + res.json({ + donationMetrics: { + amount: Math.round(donationAmount), + count: donations.length, + recurring: donations.filter((d) => d.metadata?.recurring === 'true').length, + }, + pageViews, + userEngagement: { + sessions: events.filter((e) => e.event === 'page_view' || e.eventName === 'page_view').length || events.length, + avgDuration: 185, + bounceRate: 0.34, + }, + conversionRates: { + donation: donations.length / Math.max(pageViews.find((p) => p.page === 'Donate')?.views || 1, 1), + volunteer: assistance.length / Math.max(events.length, 1), + contact: readJsonl(dataDir, 'contact.jsonl', { limit: 100 }).length / Math.max(events.length, 1), + }, + familiesHelped: assistance.filter((a) => a.status === 'completed').length, + activeVolunteers: listUsers(dataDir).filter((u) => u.role === 'volunteer').length, + }) + }) + + router.get('/analytics/activity', (_req, res) => { + const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 15 }) + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 15 }) + const events = readJsonl(dataDir, 'events.jsonl', { limit: 15 }) + + const feed = [ + ...donations.map((d) => ({ + type: 'donation', + title: `Donation received — $${((d.amountTotal || 0) / 100).toFixed(2)}`, + detail: d.customerEmail || 'Anonymous donor', + ts: d.ts, + timeAgo: timeAgo(d.ts), + })), + ...assistance.map((a) => ({ + type: 'assistance', + title: `Assistance request — ${a.requestType}`, + detail: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(), + ts: a.ts, + timeAgo: timeAgo(a.ts), + })), + ...events.map((e) => ({ + type: 'event', + title: String(e.event || e.eventName || 'Site event'), + detail: e.page || e.path || '', + ts: e.ts, + timeAgo: timeAgo(e.ts), + })), + ] + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, 30) + + res.json({ feed }) + }) + + router.get('/analytics/advanced', (_req, res) => { + const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 2000 }) + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 2000 }) + const monthly = buildMonthlySeries(donations, assistance) + res.json({ + impactMetrics: monthly, + predictions: { + nextMonthDonations: monthly.length ? Math.round(monthly[monthly.length - 1].donations * 1.08) : 0, + studentsServedTrend: monthly.length ? monthly[monthly.length - 1].studentsServed : 0, + }, + geographic: aggregateSchools(assistance), + }) + }) + + router.get('/feeds/recent', (_req, res) => { + const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 20 }) + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 20 }) + res.json({ + donations: donations.map((d) => ({ + id: d.sessionId, + amountUsd: ((d.amountTotal || 0) / 100).toFixed(2), + email: d.customerEmail, + ts: d.ts, + timeAgo: timeAgo(d.ts), + })), + assistance: assistance.map((a) => ({ + id: a.id, + type: a.requestType, + student: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(), + school: a.studentInfo?.school, + status: a.status || 'pending', + ts: a.ts, + timeAgo: timeAgo(a.ts), + })), + generatedAt: new Date().toISOString(), + }) + }) + + router.get('/settings', (_req, res) => { + res.json(adminSettingsView(loadSettings(dataDir))) + }) + + router.patch('/settings', express.json({ limit: '32kb' }), (req, res) => { + const next = saveSettings(dataDir, req.body || {}, req.user.email) + res.json(adminSettingsView(next)) + }) + + router.get('/stripe/status', async (_req, res) => { + try { + const status = await fetchStripeAccountStatus(dataDir) + const settings = loadSettings(dataDir) + res.json({ + ...status, + donationsEnabled: settings.donationsEnabled, + publishableKeySet: Boolean(settings.stripePublishableKey), + }) + } catch (e) { + res.status(502).json({ error: e.message || 'Stripe status unavailable' }) + } + }) + + router.get('/volunteers/assignments', (_req, res) => { + const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 }) + if (!rows.length) { + return res.json({ data: seedAssignmentsFromAssistance(dataDir), seeded: true }) + } + res.json({ data: rows }) + }) + + router.patch('/volunteers/assignments/:id', express.json({ limit: '16kb' }), (req, res) => { + const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false }) + const idx = rows.findIndex((r) => r.id === req.params.id) + if (idx < 0) return res.status(404).json({ error: 'Assignment not found' }) + rows[idx] = { ...rows[idx], ...req.body, updatedAt: new Date().toISOString() } + rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows) + res.json({ ok: true, assignment: rows[idx] }) + }) + + router.get('/partners/requests', (_req, res) => { + const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 }) + res.json({ + data: rows.map((r) => ({ + id: r.id, + partner: r.studentInfo?.school || 'Partner org', + student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(), + status: r.status || 'pending', + submitted: timeAgo(r.ts), + type: r.requestType, + })), + }) + }) + + router.get('/training/modules', (_req, res) => { + res.json({ + modules: [ + { id: 'onboarding', title: 'Volunteer Onboarding', progress: 100, durationMin: 45 }, + { id: 'safety', title: 'Safety & Confidentiality', progress: 85, durationMin: 30 }, + { id: 'assistance', title: 'Assistance Intake Workflow', progress: 60, durationMin: 25 }, + { id: 'donor-care', title: 'Donor Stewardship Basics', progress: 40, durationMin: 20 }, + ], + }) + }) + + return router +} + +function aggregatePageViews(events) { + const counts = {} + for (const e of events) { + const page = e.page || e.path || e.properties?.page + if (!page) continue + const label = String(page).replace(/^\//, '').split('/')[0] || 'Home' + const key = label.charAt(0).toUpperCase() + label.slice(1) + counts[key] = (counts[key] || 0) + 1 + } + if (!Object.keys(counts).length) { + return [ + { page: 'Home', views: 0, trend: 0 }, + { page: 'Donate', views: 0, trend: 0 }, + ] + } + return Object.entries(counts).map(([page, views]) => ({ page, views, trend: 0 })) +} + +function buildMonthlySeries(donations, assistance) { + const buckets = {} + for (const d of donations) { + const month = (d.ts || '').slice(0, 7) + if (!month) continue + buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 } + buckets[month].donations += (d.amountTotal || 0) / 100 + } + for (const a of assistance) { + const month = (a.ts || '').slice(0, 7) + if (!month) continue + buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 } + buckets[month].studentsServed += 1 + buckets[month].resourcesAllocated += 1 + } + return Object.values(buckets) + .sort((a, b) => a.month.localeCompare(b.month)) + .slice(-6) + .map((b) => ({ ...b, donations: Math.round(b.donations), efficiency: 0.85 + Math.min(b.studentsServed / 400, 0.1) })) +} + +function aggregateSchools(assistance) { + const schools = {} + for (const a of assistance) { + const school = a.studentInfo?.school || 'Unknown' + schools[school] = (schools[school] || 0) + 1 + } + return Object.entries(schools) + .map(([region, count]) => ({ region, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 8) +} + +function seedAssignmentsFromAssistance(dataDir) { + const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 5 }) + return assistance.map((a, i) => ({ + id: `asgn_${a.id || i}`, + student: `${a.studentInfo?.firstName || 'Student'} ${a.studentInfo?.lastName || ''}`.trim(), + items: a.requestType || 'Essentials kit', + school: a.studentInfo?.school || 'Los Angeles County', + deadline: i === 0 ? 'Tomorrow' : i === 1 ? 'Friday' : 'Next week', + status: 'pending', + scheduledDate: new Date().toISOString().slice(0, 10), + assistanceRequestId: a.id, + })) +} + +export function createVolunteerRoutes({ dataDir, authSecret }) { + const router = express.Router() + const auth = createAuthMiddleware({ dataDir, authSecret }) + router.use(auth) + router.use(requireRole('volunteer', 'admin')) + + router.get('/assignments', (_req, res) => { + let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 }) + if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir) + res.json({ data: rows }) + }) + + router.patch('/assignments/:id', express.json({ limit: '16kb' }), (req, res) => { + let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false }) + if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir) + const idx = rows.findIndex((r) => r.id === req.params.id) + if (idx < 0) return res.status(404).json({ error: 'Assignment not found' }) + rows[idx] = { ...rows[idx], status: req.body?.status || rows[idx].status, updatedAt: new Date().toISOString() } + rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows) + res.json({ ok: true, assignment: rows[idx] }) + }) + + router.get('/schedule', (_req, res) => { + const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 50 }) + const today = new Date() + res.json({ + date: today.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }), + tasks: (assignments.length ? assignments : seedAssignmentsFromAssistance(dataDir)).slice(0, 5).map((a, i) => ({ + id: a.id, + time: ['9:00 AM', '1:00 PM', '3:30 PM'][i % 3], + task: `Delivery — ${a.items || a.student}`, + location: a.school || 'Los Angeles County', + students: a.student ? 1 : null, + status: a.status || 'pending', + })), + }) + }) + + router.get('/stats', (_req, res) => { + const completed = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 }).filter((a) => a.status === 'completed') + res.json({ + familiesHelped: completed.length || readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 }).filter((a) => a.status === 'completed').length, + kitsAssembled: completed.length, + deliveries: completed.length, + hoursVolunteered: completed.length * 2, + }) + }) + + return router +} + +export function createResourceRoutes({ dataDir, authSecret }) { + const router = express.Router() + const auth = createAuthMiddleware({ dataDir, authSecret }) + router.use(auth) + router.use(requireRole('resource', 'admin')) + + router.get('/requests', (_req, res) => { + const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 }) + res.json({ + data: rows.map((r) => ({ + id: r.id, + title: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(), + type: r.requestType, + status: r.status || 'pending', + submitted: timeAgo(r.ts), + school: r.studentInfo?.school, + })), + summary: { + pending: rows.filter((r) => !r.status || r.status === 'pending').length, + approved: rows.filter((r) => r.status === 'approved').length, + completed: rows.filter((r) => r.status === 'completed').length, + }, + }) + }) + + return router +} + +export { isStripeLive, getStripeClient, publicConfig } diff --git a/mim-api/src/routes/auth-routes.js b/mim-api/src/routes/auth-routes.js new file mode 100644 index 0000000..d83126a --- /dev/null +++ b/mim-api/src/routes/auth-routes.js @@ -0,0 +1,34 @@ +import express from 'express' +import { signToken, verifyToken } from '../lib/auth.js' +import { authenticateUser, getUserById } from '../lib/users.js' + +export function createAuthRoutes({ dataDir, authSecret }) { + const router = express.Router() + + router.post('/login', express.json({ limit: '16kb' }), (req, res) => { + const email = req.body?.email?.trim() + const password = req.body?.password + if (!email || !password) { + return res.status(400).json({ error: 'email and password required' }) + } + const user = authenticateUser(dataDir, email, password) + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }) + } + const token = signToken({ sub: user.id, role: user.role, email: user.email }, authSecret) + res.json({ token, user }) + }) + + router.get('/me', (req, res) => { + const header = req.headers.authorization || '' + const token = header.startsWith('Bearer ') ? header.slice(7) : null + if (!token) return res.status(401).json({ error: 'Authentication required' }) + const payload = verifyToken(token, authSecret) + if (!payload?.sub) return res.status(401).json({ error: 'Invalid or expired session' }) + const user = getUserById(dataDir, payload.sub) + if (!user) return res.status(401).json({ error: 'User not found' }) + res.json({ user }) + }) + + return router +} diff --git a/mim-api/src/routes/brand-routes.js b/mim-api/src/routes/brand-routes.js new file mode 100644 index 0000000..3e45f90 --- /dev/null +++ b/mim-api/src/routes/brand-routes.js @@ -0,0 +1,126 @@ +import express from 'express' +import fs from 'node:fs' +import path from 'node:path' +import multer from 'multer' +import { + brandDir, + loadBrandManifest, + publicBrandManifest, + rebuildBrandZip, + safeBrandFilename, + saveBrandManifest, +} from '../lib/brand.js' +import { createAuthMiddleware, requireRole } from '../middleware/auth.js' + +export function createBrandPublicRoutes({ dataDir }) { + const router = express.Router() + const filesDir = brandDir(dataDir) + fs.mkdirSync(filesDir, { recursive: true }) + + router.get('/brand', (_req, res) => { + res.json(publicBrandManifest(loadBrandManifest(dataDir))) + }) + + router.use('/brand/files', express.static(filesDir, { + maxAge: '1h', + setHeaders(res, filePath) { + if (filePath.endsWith('.svg')) res.setHeader('Content-Type', 'image/svg+xml') + }, + })) + + return router +} + +export function createBrandAdminRoutes({ dataDir, authSecret }) { + const router = express.Router() + const filesDir = brandDir(dataDir) + fs.mkdirSync(filesDir, { recursive: true }) + const auth = createAuthMiddleware({ dataDir, authSecret }) + + const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, filesDir), + filename: (_req, file, cb) => cb(null, safeBrandFilename(file.originalname)), + }) + const upload = multer({ + storage, + limits: { fileSize: 25 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ok = /\.(png|jpe?g|webp|svg|ico|zip|pdf|md)$/i.test(file.originalname) + cb(ok ? null : new Error('File type not allowed'), ok) + }, + }) + + router.use(auth) + router.use(requireRole('admin')) + + router.get('/brand', (_req, res) => { + const manifest = loadBrandManifest(dataDir) + res.json({ + manifest, + files: fs.existsSync(filesDir) + ? fs.readdirSync(filesDir).map((name) => { + const stat = fs.statSync(path.join(filesDir, name)) + return { name, size: stat.size, mtime: stat.mtime.toISOString() } + }) + : [], + brandDir: filesDir, + }) + }) + + router.put('/brand', express.json({ limit: '512kb' }), (req, res) => { + const next = saveBrandManifest(dataDir, req.body) + res.json({ ok: true, manifest: next }) + }) + + router.patch('/brand', express.json({ limit: '128kb' }), (req, res) => { + const current = loadBrandManifest(dataDir) + const patch = req.body || {} + const next = saveBrandManifest(dataDir, { + ...current, + ...patch, + colors: patch.colors ?? current.colors, + typography: patch.typography ?? current.typography, + groups: patch.groups ?? current.groups, + kits: patch.kits ?? current.kits, + }) + res.json({ ok: true, manifest: next }) + }) + + router.post('/brand/upload', (req, res) => { + upload.single('file')(req, res, (err) => { + if (err) return res.status(400).json({ error: err.message || 'Upload failed' }) + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }) + const publicPath = `/api/public/brand/files/${req.file.filename}` + const legacyPath = `/brand/${req.file.filename}` + res.status(201).json({ + ok: true, + filename: req.file.filename, + size: req.file.size, + path: publicPath, + legacyPath, + url: publicPath, + }) + }) + }) + + router.delete('/brand/files/:filename', (req, res) => { + const filename = safeBrandFilename(req.params.filename) + const filePath = path.join(filesDir, filename) + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' }) + fs.unlinkSync(filePath) + res.json({ ok: true }) + }) + + router.post('/brand/rebuild-zip', async (_req, res) => { + const result = await rebuildBrandZip(dataDir) + if (!result.ok) return res.status(500).json(result) + const manifest = loadBrandManifest(dataDir) + const kits = manifest.kits?.length + ? manifest.kits.map((k) => (k.id === 'full' ? { ...k, path: result.path } : k)) + : [{ id: 'full', title: 'Complete brand kit (ZIP)', path: result.path, format: 'ZIP', description: 'All logos and guidelines' }] + saveBrandManifest(dataDir, { ...manifest, kits }) + res.json(result) + }) + + return router +} diff --git a/mim-api/src/routes/qr-public-routes.js b/mim-api/src/routes/qr-public-routes.js new file mode 100644 index 0000000..91c7540 --- /dev/null +++ b/mim-api/src/routes/qr-public-routes.js @@ -0,0 +1,16 @@ +import express from 'express' +import { getQrcodeImagePath } from '../lib/qr-provider.js' + +/** Public serve for generated QR PNGs (opaque UUID ids). */ +export function createQrPublicRoutes({ dataDir }) { + const router = express.Router() + + router.get('/qr/:id', (req, res) => { + const filePath = getQrcodeImagePath(dataDir, req.params.id) + if (!filePath) return res.status(404).json({ error: 'QR image not found' }) + res.setHeader('Cache-Control', 'public, max-age=3600') + res.sendFile(filePath) + }) + + return router +} diff --git a/mim-api/src/routes/qr-routes.js b/mim-api/src/routes/qr-routes.js new file mode 100644 index 0000000..5a7dd84 --- /dev/null +++ b/mim-api/src/routes/qr-routes.js @@ -0,0 +1,164 @@ +import express from 'express' +import { + QR_PRESETS, + createQrcode, + defaultMimCustomizations, + getLocalQrcode, + getQrcode, + getQrcodeImagePath, + getQrcodeScans, + getQrStatus, + listQrcodes, + mimQrBrandSpec, + updateQrcode, +} from '../lib/qr-provider.js' +import { createAuthMiddleware, requireRole } from '../middleware/auth.js' + +export function createQrAdminRoutes({ dataDir, authSecret, publicUrl, publicApiBase }) { + const router = express.Router() + const auth = createAuthMiddleware({ dataDir, authSecret }) + const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '') + const apiBase = publicApiBase || '' + const listOpts = { publicApiBase: apiBase, publicSiteBase: siteBase } + + router.use(auth) + router.use(requireRole('admin')) + + router.get('/qrcodes/status', async (_req, res) => { + try { + res.json(await getQrStatus(siteBase)) + } catch (e) { + res.status(502).json({ error: e.message || 'Failed to load QR status' }) + } + }) + + router.get('/qrcodes/presets', (_req, res) => { + const presets = Object.fromEntries( + Object.entries(QR_PRESETS).map(([key, p]) => [ + key, + { ...p, url: p.url.replace('https://mim4u.org', siteBase) }, + ]), + ) + res.json({ + presets, + brand: mimQrBrandSpec(siteBase), + defaultCustomizations: defaultMimCustomizations(), + }) + }) + + router.get('/qrcodes', async (req, res) => { + try { + const result = await listQrcodes(dataDir, { + cursor: req.query.cursor, + limit: Math.min(Number(req.query.limit) || 20, 50), + ...listOpts, + }) + res.json(result) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to list QR codes' }) + } + }) + + router.post('/qrcodes', express.json({ limit: '32kb' }), async (req, res) => { + const url = String(req.body?.url || '').trim() + const title = String(req.body?.title || 'MIM4U QR Code').trim().slice(0, 150) + if (!url || !/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: 'url must be a valid http(s) URL' }) + } + + try { + const created = await createQrcode(dataDir, { + url, + title, + purpose: req.body?.purpose || null, + customizations: req.body?.customizations, + publicUrl: siteBase, + ...listOpts, + createdBy: req.user?.email, + }) + res.status(201).json(created) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to create QR code', details: e.body }) + } + }) + + router.post('/qrcodes/presets/:preset', express.json({ limit: '8kb' }), async (req, res) => { + const preset = QR_PRESETS[req.params.preset] + if (!preset) return res.status(404).json({ error: 'Unknown preset' }) + + const url = String(req.body?.url || preset.url).replace('https://mim4u.org', siteBase) + const title = String(req.body?.title || preset.title).trim().slice(0, 150) + + try { + const created = await createQrcode(dataDir, { + url, + title, + purpose: preset.purpose, + preset: req.params.preset, + customizations: req.body?.customizations, + publicUrl: siteBase, + ...listOpts, + createdBy: req.user?.email, + }) + res.status(201).json(created) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to create preset QR code', details: e.body }) + } + }) + + router.get('/qrcodes/:id/image', (req, res) => { + const filePath = getQrcodeImagePath(dataDir, req.params.id) + if (!filePath) return res.status(404).json({ error: 'QR image not found' }) + res.sendFile(filePath) + }) + + router.get('/qrcodes/:id', async (req, res) => { + try { + const row = await getQrcode(dataDir, req.params.id, apiBase, siteBase) + if (!row) return res.status(404).json({ error: 'QR code not found' }) + res.json(row) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to fetch QR code' }) + } + }) + + router.patch('/qrcodes/:id', express.json({ limit: '32kb' }), async (req, res) => { + const patch = {} + if (req.body?.status === 'active' || req.body?.status === 'paused') patch.status = req.body.status + if (req.body?.url) patch.url = req.body.url + if (req.body?.title) patch.title = String(req.body.title).slice(0, 150) + if (req.body?.customizations) patch.customizations = req.body.customizations + + if (!Object.keys(patch).length) { + return res.status(400).json({ error: 'No valid fields to update' }) + } + + const local = getLocalQrcode(dataDir, req.params.id) + if (local?.provider === 'qrcode-monkey' && (patch.url || patch.customizations)) { + return res.status(400).json({ + error: 'QR Code Monkey codes are static images — create a new code to change URL or design', + }) + } + + try { + const updated = await updateQrcode(dataDir, req.params.id, patch, apiBase, siteBase) + res.json(updated) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to update QR code' }) + } + }) + + router.get('/qrcodes/:id/scans', async (req, res) => { + try { + const totals = await getQrcodeScans(req.params.id) + res.json(totals) + } catch (e) { + res.status(e.status || 502).json({ error: e.message || 'Failed to fetch scan totals' }) + } + }) + + return router +} + +/** @deprecated use createQrAdminRoutes */ +export const createQrcgAdminRoutes = createQrAdminRoutes diff --git a/mim-api/src/server.js b/mim-api/src/server.js index f41e372..2b37a1a 100644 --- a/mim-api/src/server.js +++ b/mim-api/src/server.js @@ -1,288 +1,7 @@ -/** - * MIM4U API — VMID 7811 - * Routes: health, events, Stripe checkout/intent/webhook, assistance, contact, donations log - */ -import express from 'express' -import fs from 'node:fs' -import path from 'node:path' -import crypto from 'node:crypto' -import nodemailer from 'nodemailer' -import Stripe from 'stripe' +import { createApp } from './app.js' -const PORT = Number(process.env.PORT || 3001) -const DATA_DIR = process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data') -const PUBLIC_URL = (process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '') -const NOTIFY_EMAIL = process.env.MIM_NOTIFY_EMAIL || 'info@mim4u.org' -const CORS_ORIGINS = (process.env.MIM_CORS_ORIGINS || 'https://mim4u.org,https://www.mim4u.org') - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - -const stripeKey = process.env.STRIPE_SECRET_KEY -const stripe = stripeKey ? new Stripe(stripeKey) : null - -fs.mkdirSync(DATA_DIR, { recursive: true }) - -function appendJsonl(file, record) { - const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n` - fs.appendFileSync(path.join(DATA_DIR, file), line, 'utf8') -} - -function mailTransport() { - const { SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_PORT } = process.env - if (!SMTP_HOST) return null - return nodemailer.createTransport({ - host: SMTP_HOST, - port: Number(SMTP_PORT || 587), - secure: Number(SMTP_PORT) === 465, - auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, - }) -} - -async function notify(subject, text, { to } = {}) { - appendJsonl('notifications.jsonl', { subject, text, to }) - const transport = mailTransport() - if (!transport) { - console.log(`[notify] ${subject}: ${text.slice(0, 200)}`) - return - } - await transport.sendMail({ - from: NOTIFY_EMAIL, - to: to || NOTIFY_EMAIL, - subject, - text, - }) -} - -function cors(req, res, next) { - const origin = req.headers.origin - if (origin && (CORS_ORIGINS.includes(origin) || CORS_ORIGINS.includes('*'))) { - res.setHeader('Access-Control-Allow-Origin', origin) - } - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Stripe-Signature') - if (req.method === 'OPTIONS') return res.sendStatus(204) - next() -} - -function requireStripe(_req, res, next) { - if (!stripe) { - return res.status(503).json({ error: 'Stripe not configured on API host (STRIPE_SECRET_KEY)' }) - } - next() -} - -function validateAssistance(body) { - const errors = [] - if (!body?.requestType) errors.push('requestType required') - if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required') - if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required') - if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required') - if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required') - if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required') - if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required') - if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required') - if (!body?.details?.trim()) errors.push('details required') - if (body?.website) errors.push('spam detected') - return errors -} - -const app = express() -app.disable('x-powered-by') -app.use(cors) - -app.get('/health', (_req, res) => { - res.type('text/plain').send('ok') -}) - -app.get('/api/health', (_req, res) => { - res.json({ - ok: true, - service: 'mim-api', - stripe: Boolean(stripe), - dataDir: DATA_DIR, - version: '1.0.0', - }) -}) - -app.use('/api/events', express.json({ limit: '32kb' })) -app.post('/api/events', (req, res) => { - appendJsonl('events.jsonl', req.body || {}) - res.status(204).end() -}) - -app.use('/api/assistance-requests', express.json({ limit: '128kb' })) -const postAssistanceRequest = async (req, res) => { - const errors = validateAssistance(req.body) - if (errors.length) return res.status(400).json({ error: 'Validation failed', fields: errors }) - - const id = `asst_${crypto.randomUUID()}` - const record = { id, ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] } - appendJsonl('assistance-requests.jsonl', record) - - try { - await notify( - `[MIM4U] Assistance request ${id}`, - `New assistance request (${req.body.requestType})\nContact: ${req.body.contactInfo.parentName} <${req.body.contactInfo.email}>\nPhone: ${req.body.contactInfo.phone}\nDetails: ${req.body.details?.slice(0, 500)}`, - ) - } catch (e) { - console.error('assistance notify failed', e) - } - - res.status(201).json({ ok: true, id, message: 'Request received. We will contact you within 24–48 hours.' }) -} -app.post('/api/assistance-requests', postAssistanceRequest) -app.post('/api/assistance', postAssistanceRequest) - -app.use('/api/contact', express.json({ limit: '64kb' })) -app.post('/api/contact', async (req, res) => { - const { type, name, email, message, website, ...rest } = req.body || {} - if (website) return res.status(400).json({ error: 'Invalid submission' }) - if (!type) return res.status(400).json({ error: 'type required' }) - if (!email?.trim() && type !== 'story') return res.status(400).json({ error: 'email required' }) - if (type === 'story' && !message?.trim() && !rest.story?.trim()) { - return res.status(400).json({ error: 'story text required' }) - } - - const id = `contact_${crypto.randomUUID()}` - appendJsonl('contact.jsonl', { id, type, name, email, message, ...rest }) - try { - await notify(`[MIM4U] ${type} form ${id}`, JSON.stringify({ name, email, message, ...rest }, null, 2)) - } catch (e) { - console.error('contact notify failed', e) - } - res.status(201).json({ ok: true, id, message: 'Thank you — we will be in touch soon.' }) -}) - -app.use('/api/donations', express.json({ limit: '32kb' })) -app.post('/api/donations', (req, res) => { - appendJsonl('donations-offline.jsonl', req.body || {}) - res.status(201).json({ ok: true }) -}) - -app.use('/api/create-payment-intent', express.json({ limit: '32kb' })) -app.post('/api/create-payment-intent', requireStripe, async (req, res) => { - const amount = Number(req.body?.amount) - if (!Number.isFinite(amount) || amount < 100) { - return res.status(400).json({ error: 'amount must be at least 100 cents' }) - } - try { - const intent = await stripe.paymentIntents.create({ - amount: Math.round(amount), - currency: 'usd', - metadata: { - recurring: String(Boolean(req.body?.recurring)), - donorEmail: req.body?.customer?.email || '', - donorName: req.body?.customer?.name || '', - }, - receipt_email: req.body?.customer?.email || undefined, - }) - res.json({ client_secret: intent.client_secret }) - } catch (e) { - console.error('payment intent error', e) - res.status(502).json({ error: e.message || 'Stripe error' }) - } -}) - -app.use('/api/create-checkout-session', express.json({ limit: '32kb' })) -app.post('/api/create-checkout-session', requireStripe, async (req, res) => { - const amountUsd = Number(req.body?.amount) - if (!Number.isFinite(amountUsd) || amountUsd < 1) { - return res.status(400).json({ error: 'amount must be at least $1' }) - } - const cents = Math.round(amountUsd * 100) - const recurring = Boolean(req.body?.recurring) - const email = req.body?.email?.trim() - const name = req.body?.name?.trim() - - try { - const session = await stripe.checkout.sessions.create({ - mode: recurring ? 'subscription' : 'payment', - success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${PUBLIC_URL}/donate?status=cancelled`, - customer_email: email || undefined, - line_items: [ - recurring - ? { - price_data: { - currency: 'usd', - product_data: { name: 'Monthly donation — Miracles in Motion Foundation' }, - unit_amount: cents, - recurring: { interval: 'month' }, - }, - quantity: 1, - } - : { - price_data: { - currency: 'usd', - product_data: { name: 'Donation — Miracles in Motion Foundation' }, - unit_amount: cents, - }, - quantity: 1, - }, - ], - metadata: { - donorName: name || '', - anonymous: String(Boolean(req.body?.anonymous)), - }, - }) - res.json({ url: session.url, sessionId: session.id }) - } catch (e) { - console.error('checkout session error', e) - res.status(502).json({ error: e.message || 'Stripe error' }) - } -}) - -app.post( - '/api/webhooks/stripe', - express.raw({ type: 'application/json' }), - async (req, res) => { - if (!stripe) return res.status(503).send('Stripe not configured') - const sig = req.headers['stripe-signature'] - const secret = process.env.STRIPE_WEBHOOK_SECRET - let event - try { - event = secret - ? stripe.webhooks.constructEvent(req.body, sig, secret) - : JSON.parse(req.body.toString()) - } catch (e) { - console.error('webhook verify failed', e.message) - return res.status(400).send(`Webhook Error: ${e.message}`) - } - - if (event.type === 'checkout.session.completed') { - const session = event.data.object - appendJsonl('donations.jsonl', { - sessionId: session.id, - amountTotal: session.amount_total, - customerEmail: session.customer_details?.email, - metadata: session.metadata, - }) - const donorEmail = session.customer_details?.email - const amountUsd = ((session.amount_total || 0) / 100).toFixed(2) - try { - await notify( - `[MIM4U] Donation received ${session.id}`, - `Amount: ${amountUsd} USD\nEmail: ${donorEmail || 'n/a'}`, - ) - if (donorEmail) { - await notify( - 'Thank you for your gift — Miracles in Motion Foundation', - `Dear friend,\n\nThank you for your generous donation of $${amountUsd} to Miracles in Motion Foundation.\n\nYour gift supports outreach, emergency assistance, and compassionate care in Los Angeles County.\n\nEIN ${process.env.MIM_EIN || '33-4887159'}\nhttps://mim4u.org\n\nWith gratitude,\nMiracles in Motion Foundation`, - { to: donorEmail }, - ) - } - } catch (e) { - console.error('donation notify failed', e) - } - } - - res.json({ received: true }) - }, -) - -app.use((_req, res) => res.status(404).json({ error: 'Not found' })) +const { app, PORT, DATA_DIR } = createApp() app.listen(PORT, '0.0.0.0', () => { - console.log(`mim-api listening on :${PORT} (stripe=${Boolean(stripe)}) data=${DATA_DIR}`) + console.log(`mim-api listening on :${PORT} data=${DATA_DIR}`) }) diff --git a/mim-api/test/qr-brand.test.js b/mim-api/test/qr-brand.test.js new file mode 100644 index 0000000..6c3dfd6 --- /dev/null +++ b/mim-api/test/qr-brand.test.js @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { defaultMimCustomizations } from '../src/lib/qrcg.js' +import { defaultMonkeyConfig } from '../src/lib/qr-monkey.js' +import { mimQrBrandSpec } from '../src/lib/qr-brand.js' +import { getQrProvider } from '../src/lib/qr-provider.js' + +describe('qr brand customizations', () => { + it('qrcg defaults use forest green / gold diamond styling', () => { + const c = defaultMimCustomizations() + assert.equal(c.background.color, '#1a3c34') + assert.equal(c.pattern.color, '#c5a059') + assert.equal(c.pattern.shape, 'diamond') + }) + + it('qrcode monkey defaults match approved reference', () => { + const c = defaultMonkeyConfig('https://mim4u.org') + assert.equal(c.bgColor, '#1a3c34') + assert.equal(c.bodyColor, '#c5a059') + assert.equal(c.body, 'diamond') + assert.equal(c.eye, 'frame14') + assert.match(c.logo, /logo-square/) + }) + + it('exposes brand spec with reference image path', () => { + const spec = mimQrBrandSpec('https://mim4u.org') + assert.equal(spec.referenceImage, '/brand/qr-code-branded-reference.png') + assert.equal(spec.colors.background, '#1a3c34') + }) + + it('defaults to qrcode-monkey provider', () => { + const prev = process.env.MIM_QR_PROVIDER + delete process.env.MIM_QR_PROVIDER + assert.equal(getQrProvider(), 'qrcode-monkey') + if (prev) process.env.MIM_QR_PROVIDER = prev + }) +}) diff --git a/mim-api/test/server.test.js b/mim-api/test/server.test.js index 00c6f85..d02edcf 100644 --- a/mim-api/test/server.test.js +++ b/mim-api/test/server.test.js @@ -1,21 +1,9 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' - -/** Inline validation mirror — keep in sync with server.js validateAssistance */ -function validateAssistance(body) { - const errors = [] - if (!body?.requestType) errors.push('requestType required') - if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required') - if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required') - if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required') - if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required') - if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required') - if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required') - if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required') - if (!body?.details?.trim()) errors.push('details required') - if (body?.website) errors.push('spam detected') - return errors -} +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createApp, validateAssistance } from '../src/app.js' describe('assistance validation shape', () => { it('requires core fields', () => { @@ -28,3 +16,113 @@ describe('assistance validation shape', () => { assert.ok(errors.includes('spam detected')) }) }) + +describe('admin auth and settings', () => { + it('logs in admin and updates stripe settings', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-test-')) + const { app, AUTH_SECRET } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 }) + const server = app.listen(0) + const { port } = server.address() + + const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@mim4u.org', password: 'demo123' }), + }) + assert.equal(loginRes.status, 200) + const { token } = await loginRes.json() + assert.ok(token) + + const settingsRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, { + headers: { Authorization: `Bearer ${token}` }, + }) + assert.equal(settingsRes.status, 200) + + const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + donationsEnabled: true, + stripePublishableKey: 'pk_test_abc', + stripeSecretKey: 'sk_test_abc', + stripeWebhookSecret: 'whsec_test', + }), + }) + assert.equal(patchRes.status, 200) + const patched = await patchRes.json() + assert.equal(patched.donationsEnabled, true) + assert.match(patched.stripeSecretKey, /…/) + + const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/config`) + const pub = await publicRes.json() + assert.equal(pub.stripeConfigured, true) + assert.equal(pub.stripePublishableKey, 'pk_test_abc') + + server.close() + fs.rmSync(tmp, { recursive: true, force: true }) + assert.ok(AUTH_SECRET) + }) +}) + +describe('qr admin routes', () => { + it('reports qrcode-monkey as default provider', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-qr-')) + const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 }) + const server = app.listen(0) + const { port } = server.address() + + const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@mim4u.org', password: 'demo123' }), + }) + const { token } = await loginRes.json() + + const statusRes = await fetch(`http://127.0.0.1:${port}/api/admin/qrcodes/status`, { + headers: { Authorization: `Bearer ${token}` }, + }) + assert.equal(statusRes.status, 200) + const status = await statusRes.json() + assert.equal(status.provider, 'qrcode-monkey') + assert.equal(status.configured, true) + assert.equal(status.dynamicTracking, false) + + server.close() + fs.rmSync(tmp, { recursive: true, force: true }) + }) +}) +describe('brand manifest', () => { + it('seeds and serves public brand manifest', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-brand-')) + const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 }) + const server = app.listen(0) + const { port } = server.address() + + const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/brand`) + assert.equal(publicRes.status, 200) + const manifest = await publicRes.json() + assert.ok(manifest.groups?.length > 0) + assert.ok(manifest.colors?.length > 0) + + const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@mim4u.org', password: 'demo123' }), + }) + const { token } = await loginRes.json() + + const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/brand`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ published: false }), + }) + assert.equal(patchRes.status, 200) + + const hidden = await fetch(`http://127.0.0.1:${port}/api/public/brand`) + const hiddenJson = await hidden.json() + assert.equal(hiddenJson.published, false) + + server.close() + fs.rmSync(tmp, { recursive: true, force: true }) + }) +}) diff --git a/public/brand/MIM4U-Brand-Kit.zip b/public/brand/MIM4U-Brand-Kit.zip index 876ada0..8a0cd9c 100644 Binary files a/public/brand/MIM4U-Brand-Kit.zip and b/public/brand/MIM4U-Brand-Kit.zip differ diff --git a/public/brand/logo-horizontal-nav.png b/public/brand/logo-horizontal-nav.png index b9f4507..0f403b9 100644 Binary files a/public/brand/logo-horizontal-nav.png and b/public/brand/logo-horizontal-nav.png differ diff --git a/public/brand/logo-horizontal-nav.svg b/public/brand/logo-horizontal-nav.svg index ad63e1f..ffef662 100644 --- a/public/brand/logo-horizontal-nav.svg +++ b/public/brand/logo-horizontal-nav.svg @@ -1,4 +1,4 @@ diff --git a/public/brand/logo-symbol-nav.png b/public/brand/logo-symbol-nav.png index 4220201..5816f1a 100644 Binary files a/public/brand/logo-symbol-nav.png and b/public/brand/logo-symbol-nav.png differ diff --git a/public/brand/logo-symbol-nav.svg b/public/brand/logo-symbol-nav.svg index a5ed23f..3685096 100644 --- a/public/brand/logo-symbol-nav.svg +++ b/public/brand/logo-symbol-nav.svg @@ -1,4 +1,4 @@ diff --git a/public/brand/qr-code-branded-reference.png b/public/brand/qr-code-branded-reference.png new file mode 100644 index 0000000..e8eeb99 Binary files /dev/null and b/public/brand/qr-code-branded-reference.png differ diff --git a/reports/status/lighthouse-site-a11y-latest.json b/reports/status/lighthouse-site-a11y-latest.json index ba9b4cb..1c720da 100644 --- a/reports/status/lighthouse-site-a11y-latest.json +++ b/reports/status/lighthouse-site-a11y-latest.json @@ -4,14 +4,14 @@ "mainDocumentUrl": "https://mim4u.org/", "finalDisplayedUrl": "https://mim4u.org/", "finalUrl": "https://mim4u.org/", - "fetchTime": "2026-06-16T18:09:54.130Z", + "fetchTime": "2026-06-16T20:14:13.041Z", "gatherMode": "navigation", "runWarnings": [], "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36", "environment": { "networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36", "hostUserAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36", - "benchmarkIndex": 2086.5, + "benchmarkIndex": 1433.5, "credits": { "axe-core": "4.12.1" } @@ -53,7 +53,7 @@ "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more about the First Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/first-contentful-paint/).", "score": 0.76, "scoreDisplayMode": "numeric", - "numericValue": 2256.506, + "numericValue": 2257.4135, "numericUnit": "millisecond", "displayValue": "2.3 s", "scoringOptions": { @@ -67,7 +67,7 @@ "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn more about the Largest Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)", "score": 0.88, "scoreDisplayMode": "numeric", - "numericValue": 2556.506, + "numericValue": 2557.4135, "numericUnit": "millisecond", "displayValue": "2.6 s", "scoringOptions": { @@ -79,11 +79,11 @@ "id": "speed-index", "title": "Speed Index", "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more about the Speed Index metric](https://developer.chrome.com/docs/lighthouse/performance/speed-index/).", - "score": 0.99, + "score": 0.92, "scoreDisplayMode": "numeric", - "numericValue": 2256.506, + "numericValue": 3143.7528692654632, "numericUnit": "millisecond", - "displayValue": "2.3 s", + "displayValue": "3.1 s", "scoringOptions": { "p10": 3387, "median": 5800 @@ -97,8 +97,8 @@ "scoreDisplayMode": "informative", "details": { "type": "screenshot", - "timing": 1106, - "timestamp": 841305543710, + "timing": 2284, + "timestamp": 848765711994, "data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAHyAPoDASIAAhEBAxEB/8QAHAAAAQUBAQEAAAAAAAAAAAAAAAEDBAUGAgcI/8QATRAAAgEDAwIDBgIHBAcFBgcAAQIDAAQRBRIhBjETQVEHFCJhcZEygRUjQlJTodEIFjOSJGJyk5SxwVWCotLhFzR0g7LwJSc1N1TC8f/EABkBAQEBAQEBAAAAAAAAAAAAAAABAgMEBf/EAC4RAQACAgEDAwIFAwUAAAAAAAABAgMREgQhMRNBUTJhBSIzcYEUQrE0kaHB8P/aAAwDAQACEQMRAD8A+o6KKKAoqNqE0kFnLJCY/FUZUSEhT8jjn8+azB1bWIppdRawLWssUSJCXA2tulySRnA5iyecgHGTgUGwopq1kMtvG7EFmUEkAgZ+h5p2gKKKKApDS0hoEoooqIDSUppKDk1ya7rg0HJrk10a5NBwa4NdmuDRHBrg+ddswVSWIAHJJrNXHW/TMM7RSa3ZeIpwQsm7B+oqJNojy0Brmq/Tdd0vVGK6ff287Dnaj8/arCh5JSGugpY4UEmleKRRlkIFFNntXNdHtSKpY4UZNQcUGpQspSM8D5UxJG0bYcYNBwKKKKitFUbUZHhsLiSJo0kWNijSnCBscZ+Wak1hOrej36y1sJ1DNIvTFmoK2CSbVu5SMl5SDkqvAC+oJPlXRXkfR/tfW20LWLb2q31hqWppcFLK309IriWUHuoEeUHOMEkGvTOmNM6h1bQpLzV7eKza/ZSumuQ4t7dB+riYkfiPxFj5MRwQCK+ZPZbd9JWP9oiS5klW00KO8nTT8rujLElIwT5LzkE/KvuUduKCPp1rDZ2kcVvCkKgZKKoXnz7cZ+lSaKKAooooCkNLSGgSiiiogNJS0lUJ51wfOuz3rk96g4NcmuzXJoGzXDU4aal7UR4J/aJ6uu4Ly26cspXhheIT3LKcFwSQqfTgk+uR6V5xpGlWY0+1udQ1BrZrncYlWAyDapwSxBGCSCAMH54Fbr+0T07ctq1rr1vG0luYhBPgZ8MgkqT8jnH5fOsF05qscEBs9RtvfLAt4ixb9jI/qreWcAEeY+YBHSvh83qPrnk2mn2w0+W0ms7h5IpU8WKQr4bjDFeRk4OVPma9w6Y1F9R0mKWU5lHwOfUjzrwrT7ybUbtZZQMnCpGgwqKOyqPICvbek7R7PSo45BiRjvYehP8A9ivn05evOvD6WLj6MNNf3kOj6VJdzJJJt2gJGMvI7EKqL8ySAPrTXTeqy6tpnvN1bJZzeNLCYPGEhBRypyQAM/CTiu9Tso9a0l7VpXhclXSRMbo5EYMrDPHDAHBqj0/pGQ9SQ63rD6c93blmQ2VqYTI5UrvkJZsnaSABj88DHsVe3sYjlBXgN5U/YRhYt5HxGmL6UPMAvIXzqRYuGh2ftLU9xnZeqr5JHUaEzBSRn9IWwz/460ETG906KWSLwndA+zcG2n0yOD+VZKboeaSV38Tp74mJ+LQkJ/M7+a1drF+j9KhgYxExRhP1UYjQkD9lQTtHyqivopcUYrA0Neee0+41rXbeTpPpMNHdXi7L7UWB8OyhPfB85GHAUdgcnHFeh0gABziuivG9V9jOkjpvpvpjSrREsrW+W9vL1wPEfYORnvuc4HoAD8q9kUYAHpS0UBRRRQFFFFAUlLRQc0UtJUQUhpaDVVya5auzXJ7VEcGuTXZrk0HBpuQZFOmuSKgptRtVnjZJFDKwwVIyCKwl17O9AknMn6MjVicnYzIPsCBXp0kYamGgBPajFqRbzDGaL0vYaa4a0tURh+0csR+ZrVW8e0AVIEIHlXYUCprSxGnILIcqSp+VK80rDDSHH2pSK4NGnOOOKRWZGDIcH1rqkxQ0fF7NjGEPzpiV3lbMjZ/5VzSig5waXFLijFQXW1fQUbV9BRmjNVobV9BRtX0H2paKA2r+6PtRtX90UZpc00E2r6D7UbV9B9qXNGaACr+6PtRsX90falozRCbF/dH2o2L+6PtS5ozV0E2L+6PtS7F/dH2paM0CeGn7q/ajw0/dX7V1mig58NP3F+1J4afuL9q7paaHIij841+1Hgx/w1+1dilpoN+DF/DT7UeDF/DT7U5RTQb8CL+Gn+UUngRfwk/yinaKaDfu8P8ACT/KKPd4f4Uf+UU6KKaDXu8P8KP/ACij3eH+FH/lFO0VdBr3eH+FH/lFHu8P8KP/ACinaKagNe7w/wAGP/KKX3eH+DH/AJRTlFNQI2KWiioooooogpcUlLQFFFFUFFFFAUtJS0BS0UUBRRRQFLSCloClyaSigXJozQBmjFAuaWucUtB1RSUUC0UmaXNUFFFFAUUUUEeiiisqzvUNhLf63pirGjwpFMX8QNsBzHj8JHPfGfnVZJfaxPqc0BguPdBcRbN0BGwC4VTg7e23J7ngZ47VtaKxNNzvbUWYW0/TUDLMs97LLDHtcSwZ8T9eQV7funOR5Y5xWpv5tTj1bT47K1ilsJN/vUrPhosD4cDzyasqKtaajW0m2/ZkNci1RrnXTaA+A0cO0FHLNwc7COPrTWpavrFt+kHBkUR+JgG2+GMCRRGVb9sspJIyfyraVxLFHNGY5kWRD3VhkH8qzNJ9pWLfMMZc6p1AEiFrG7ReJKFnlt2VpACmzcgQkZy/kuducitJqE2px6lp0dlawy2cjMLuVnwYhjgqPPJqyorUVmPdJt9mX1qLdrUzX0OoSx+DH7n7qH+F8tu5XgN+HluMfnTM+o66J79Ykl3Is21Pdjtj2sBEVbHx7lJJHP5YrXilqTT4k5MdJd6/CzKJJZdxlRW92Hw7ZkVW4HOVZj6cZHagX2u2zEzGeeMO24i15CJcKpIAHJaMs38xWxopwn5Xl9mObUNckZpYROsSEsI2tv8AEHvLIAcjI/V4Pr500kmrW0t3cCW5jSO2kkSFo/1bFJ3JHI4JTAznzz5VtqbuIIrmF4biJJYnGGR1DKfqDT05+Tl9lPFd6pJ0w97Zwx3GoSq01vDJ8A2k5RT25C47+dR+pIb280zSVezWW4a4Rp4BIyoPgbILAHAz549K0oAAAAwBRVmu41MpvTDaVd67BLpdsEkFt4ab/Gjdjks29Sdp/CAACWGeDzmi2vNcuEsJJmvBJFd4k2QFEkBibAIKg7d2ByOM9zjI3NFZjHPyvL7Mhp99rlz7vGzzorzRiSZrTYy5jdpF2kdgwQBvnjJqG9/rt20wc30ECXFvIrJAQ4UykOuNvICgEgbvqQcVvQMUtX05+Tl9mLt73WIXtYljmRS/Ce7FhJm4cPubHwYTaw7Zz59qsum7nVZbjGpl2SSASDdDs8Nt7Db29ADzWioqxSYnyk237CiiiujIoooogooooDNLSUYopiikzSisqp+p+pdJ6XsVvddujaWrNs8UxO6hvIEqDj86aterdDuLqyt11BI576MS2sc6NCZ1PYoHA3dx2rBf2oP/ANqbn/4qH/6qzXUUEHUnVHs00bUpv0SlraxXcMsg3NdttT4IyuQv4P2iDyOPWxDna0xOn0BRXkFr1f1H1R1H1zY6C7250VRBYxxpGfFn+L4pC/kSmMDGAfWonU3X/UPTPUfTn974ptK0O6sQLua0jSUJdnO4FsN8IwDgc4OeaLzjy9hur+ztJoIbq6ghlnbbEkkgVpD6KD3P0qTXz914Z7v2g+yyeXVPfGuLiRkuYdm0qXXay4GORjvmtNpHVGu9TdX9cadY6k9jaaBCILfbFGzSTYYb3LKcjKHgY4NNHPu9brOdc9X6d0XpcN/q0V3JDLOsCi2i8Rtxz35HHFeL3ntQ6j1LoXo7WLG+9yvrvVP0deqkKNHLgj4sMCRkEdiO5q39q2r9e9JdL6pqNxrUMavqyRWHgRRsRbsr/C+5O/C9ucg89qukm/bs90jYOisM4YAjIwa6ryTU+odeHt0sOm7fV5YtJutONw8QhiJVsPyrFc/sjvmsintF6zXpXqgWspvbvS9ZFmbz3ZWeK3O7LlVAUkbRzjzqaJvD6JrK9VdbWPTWv6FpN9aXrzazN4FtLEqGMNuUHdlgRjcD2NZPp3Vuqdd6+1mxstUP93dPngkivDbI4uFKgyQhgADnJ+IcjHzqt9u4duvPZgImCyHU2CsRkA74ecUJt23D2ilFeK/3+1jQOt+v9N1C7bUrHRtOF9aiWNEcNsQ7SUAyMv8Ayqv1Lr7qXQ/Zl0t1ndagLp768C3loYEEZibeQEwNwICDnJ5NXRzh71RXkWj651Bqntj6s6a/TtxDplrZJPbbYIS8TMIzwSnP4z3zx96yGke2DX7npzR9OmeM67ea0dLe9WJf8NSmXC/h3/rAB5cU0c4h9GUo71kul5Opk6l1u21mFpNEHhvp91IYxIcj40YJ6HscCtcBUbgtFFFAUUUVQUUUUBRRR5UQUUUUBRRRQR6UUlLUaljvar0dN110sdGiv47FWlSVpWhMp+HkADcKqOqvZpJrtt0jImqpa6p08UMVwtuWSXbs7ruyOUHn61b+0rryx6E0y2uryFrmS4mWFIY5FVuQfiOf2eO9ZrqLrHULX2vdK6ZY6rG2g6lbvNNGFjZcqH7PjOMqM8+VHOePunD2dX+ndZX/AFB0zr40x9SAN9btaCaN2/eXLDac5PnyT5cVa9Q9G3Or6Tq2myalHNa39skJF3b+KY3VceKCGHxHv2GCBir5eotEawmvl1jTjZQtskuBcp4aN6M2cA/I09aa1pd48K2mpWU7TRmWMRTqxdB3YYPI+Yo1qHnsvskjt7Xo1dJ1Zobjpt2eN7iHxVn3MGbIDLjnOMHsasR7Pbmx13X9U0PVktpdctRFdrNb7wJQMeKmGGDyTg55NbG01vSry493tNTsp5+f1cU6s3HfgHNMRdT6DKl48Wtaa6WWPenW5QrBkkDec4XkHv6UTjDzzVPY6j9LdN6Jo+qLaxaPde+GWa38Rp5c5JOGXAz/ACxWj9qvRNz150xb6QNRisWSdJ3l8AybioIwBuGO/qa00WuaTLp8V/Hqdk1lM2yO4E6+G5zjCtnB5BHFUnWvUVvB0fqtzous2qX8dlNc2rRSRyFzGpY4U5DDjB4+1DVdK6XoS8l9p1h1g+pwZtrT3Q2otj8Qw2W3b+OW9Kx9/wBEXHS+l6/Yza26L1VfBmvYbbw0sTlnJkYuRsI+HyyTjIzWw9lfWEerdDdP3HUGrWf6Zv0c7JJEjeU+IwG1OM8AdhW+YhQSxAA5JNVNRMbeQdNv13Ye0DS7GXqO26l0OWORryaO0SJbcAfCCy5G4nGBn148603X3Q9z1T1J0xqkOpRWi6Jce8iJoDIZSWQ4zuGB8Hoe9ayx1bTdQkZLDULS5dc5WGZXIwcHsa4h1vSp7v3SHU7GS6yV8FJ0L5Hcbc5zRdRrTE/+zXx+uupdd1C/instcszZTWawFSibVXIfcecJ6edQ/wD2UNc9OaD05qesC50PSbo3Kxrb7ZZgC2xHbcRgbmBwORjtW9bqTQ1uLq3bWdNE9qC1xGbpN0IBwS4zleSO/rXT9Q6LHBbTPq+nLDcnEEjXKBZT2+E5+L8qHGrxzQp3k/tF9aJp97awTz6ekMMsq+IpcLECAARkgg8Z8jV9N7FdMPRVjo9vqNxBqdpdm/j1NUG/xzjLFc9vhXjP7I5r0ebXtIg1L9Hz6rYR3+0v7s9wgl24znaTnGATn0py01fTbyye8s9QtJ7RMhp4plZFx3ywOBRIrHuhdNabq1lEz67rP6UuioQMkAgjUDz2Ank+ZJ+mKvc1BsdU0+/kmjsb61uZIeJVhmVyn+0AePzrm11nTLuXwrXUbOeXcV2RzqxyO4wD3qNLDNLkVWx61pcszQxalZPKrbWRZ1LA+hGe9Zj2eatql/ca+2sa7oWp28Nxm3OmyBvAjO74ZCOAePPJ4PNDbdUCq2x1rS7+Vo7HUrK5kUbisM6uQPXAPao0fVnTzW80669pTQwsElkF5GVjY9gxzweDwaq7XlFNwzRzxJLC6yROAyuhyGB7EHzFOZoCiiigKSlooEopaKIjClooqK8a/tRg/wBy9IPkNVi/+l6Y67WK4/tBdAqwSRPdZcjuMgSf9a9f1jSrDWrFrPVrOC8tWIJimQMpI7HBqAOktAF9Z3q6TaC6s1C28oTDRAZ4U+Xc/c1dszTc7eC2oCSe3WJQBGEchR2z+s8qlXHS1q/sBseptOhlGvWujGCOSNyAImkPifCOM7Wk5+f0r20dGdO7r8/oi0zf8Xfw/wCPzn4/3ufWrHSdH0/SNPFjplpFbWYziBBhBnvgU2zw+Xz91UEh6L9jb6GFXUfGgEBj/Ecqvif+LGfzqVoRSLWfbsJdqgwOcHjuk/8AUfevbdP6X0PTrmG4stKtIJoAwhZIwPCDHLbP3c+eMUl10poF3f3N7c6RZS3dynhzStEC0i4xhj58cU2cJeBdN2lvD0B7K9fudSt7ZdPv5YxBcvsjlLytk7uylQpOTWj0rpjSbH2f9b3x1LStW1GWO/vYzZyiVbMSxEFVPfkDk4GcVvNS6FtWlsrK003SpemokcvpUybEExORKp2tyBuG3juTUDp32VaRpvU2raobK1t7O+tBZnTYCzQlcgszZxknAGAAMZ75ptOEw8e1yKOD2AezqeJFSf8AS6nxAPizum8/yH2Fewf2h5b+H2Tau2mGRWJjWYx9xEXG7t5evyzWom6K6bm0u202XRrN7C2cyQW5TKRse5UdgeT9zV60ET25geNXhK7CjjcCuMYOe9Gop208Wv8AStDuOibPqLpu+kOs23Tj2llBbTZAHhEudg5DDLc+uPPFYfXwsX9njoOXTONSGpqYWj/H4m6XOMc5yBX0T0/oXTmj3N2ug2Gm2lwcC4FsiqwzyA2OR9K7tuldCtrqK5t9Js45opGljKxjEbt+JlHYE+optJpt5HpECTf2iutPeY0d10aMkMM4bwoQT/zrz+zijP8AZa1KQopkGqgBiOR8advufvX06vTGipqt1qa6bbrqF0hjnuAuHkUgAgnzGAPsKjL0T00ukPpS6LZDTXkErW3h/qy4/a29s0Jp/wBvKLxVn9vHs5aYCQvoW5t3OT4cp5/OsjqN1pFhf+1VdVivJOnZ9RghWKycIWud7NgMeBypz+Xyr1fXOgdRuPal0/r+nxaaui6XZm093eeRZCCHGVAQgY3jHPl5Vtz0toTaNJpLaRZHTZG3vbmEbGbOdxHmc+feqnGZ28StCq+2nX49RlFtbN0sfHNtIXMaBE53nlmC/tcUdI3Go9DdUaX0J1VFHfaYBNdaTqNtw8alJM8emC/HcEjkjFe0J0b03HIkkeh6ejrAbYFYFH6o5ynzByeK50/T+mJdTkNhHpkt/DCYGEbK8kUWcFMZJVecY4FF4S8X6Tk1H2d9UaD0nrCRav01qt2LnSL6IfrI3YjBPy+IZ+TZB8qyd695H0d7X/0eXXOsxCbw+/hGWTd+XbPyr6b/AEHoOnz2961lZW8lupjglYBfBDd1TPC5+WKTSem9BsVu5dM06zjTUBm4aNQVuAc8t5NnJ+9E4T4eOdWjwOrPY0dC+GcxKv6ruYNse7OP2dpb+dU/TNvC+i+3F3jRmWe4CkjOOZTxXvenaH09pOowCzs7G2vvBMUIUASCIHJVB3C89hxTL9LdL6bZak0mnWFra3ozes+ESbn9sk4PJ8/Wi8O+2R9jnUel6Z7NOi7LVNQhgvL+NorWKRvilIcjA+4H5gV6lWTg6T6Xv7HSm02y057Own8a1eBVcRsGBIjYfh+JRnHoa1lRqu4gUuaSg0aLmikooFopM0uaCPRSZoqLDNdd9Y2PR9hay3cUtzdXk621paw43zSHsOeAPU1nrz2saXpcXUY1i0nt7jRDEsyRHxFkaT8KoxC5Oc9wBxkEio3tv6W1fWo+ntY6egW7v9DvRdC1LBTKuVJAzxnKD8s1D9pOj637SfZ3d29vos+l3UckdxDBeOgkmdc5HwkgDBOCTyfQd652m2500kHX3hdQ6No2s6W1hdaxbmeybxxIjNjJjc7QVbt2BHPeqC29r0s+g9S6kOnJMaDceBcoLwEEbtpKkqM8+WO3pUbVdF1Xq/r3ojUn026sbDQ4Dc3T3CbD4x2nwlHdsFByOMHvWX0vpvXU6F9p1o+jX63Oq3pls4zCczKznBH/AK0SZs9EuPahbwXfSZk0q4XTeoyFtbkyLuViFxuT0+Ic5/Kkj9pFzL1Z1J0/DoJku9GgNwXW7GyVcA9yg28MPXn71htc0DWptG9kkcWkX7vo7xtfKITmAL4QOfX8Lds9qttJ0jVYfav17qsml3y6ff6eYraYwnErhUGAO+eDQ3K0m9sdsnQ+n9Vpoly2lz3Atp8zKHhbJBwMfGBjvx3+uL5+vJF9pkPSH6MXdNaG8iuveOCu0kArt4OR6mvHbjpLqJ/7O9t0+uiX51hNSMjW3hHcE3M27PbGCK3N/pGrQ+2rQupotJvbjS20r3ZmiQbo5MNwysRt7jk0OVv8JUHtekk6Q6l106CB+g7wWk1v75y/xBSyts9SOMdqsT7T0hvekY77RpoLXqRFNvMJ1YxuQpwy47ZYc5/KvM7XpjqKP2ce0bTJtDv11DU9RWe1iWMsJV8UEkHtgBSeceVW/Umg6zcx+yUwaTfv+h/Da+AhOYMeEDn/ACN2z2+dE5WTNK1O0sup/afJoWiLYa/Z2zzXF2160iSNhmDKpTA8mxjvxwKa6f8AahrWg+yPROo9c0/9MJdTvFLde9bHUmRwCy+HgDjHB9KkaF09q8/XntNlfTrqC21m0aGznmjKpI2wr38uT50/0P07JP7Fv7m9UWcml3EiS26tdMihpWkZ4/D5+LBwf61SN+z0y21W8uNXtLeOygexmtPeWu0ud208YULs5zng5GQD9Kk9Q6tDoWhX+q3SSyQWcLTOkS7mYKM4ArzfoaXqjpz2OwXR0r9J69bqLdLQvsPhRyFFBPyGTWq6l1DX7/2aS6h0zbCLXrizjmhgfDFGbaWUZ4JAJxnzxUbieyt0f2kw3/Uei6RNpzwSa1Y+/WMni5DDDHY4KgqcKe2RVXYe1ua80LqbUY+nH/8AwCYxXKC8GGAzkqSo9Dxj7VmdL0LqB/aN0D1Be6Nq2y2spItQnuCJJPGMbgsQGJCkuMduPIVG6e6c1y36K9qFpPo9+lzqtxJJZRmE5mDFgMfcd6rHKz0a79p2n+59Mfo60mvNR6iVWs7QsE2r+00jc7QOewPY4rI+zPxD/aA6+M0SRSGCIsiNuAPwdjgZ+wqp0zpDXbBvZpr66Vdyto0BtNQswuJogS3xqp/EPiPb0FaXoHS9Vt/bX1dq97pV5a6fqMCC3mkj+FiAvBI7Hg96G5mY2ge1N7y/9tfRWkOiPZPBcMsRnZA5aN1LEhTtIA4Iz+Wal2XXmkdDdM6lZaVpN7JpHTl2tlcmaY+Jl2OWTcuHG4njIOMcYxT/AF1pmpS+2/o/WLfTL2fTLCCRLi4ihLKhcOB9e4zisj1rovXHUvT3WVlq2k6hd3vviNp+xkFsLdXH4F3DL488E44yO1CdxM6azqLUdGm9tHSLtonvOp3lk0lhqHvrIqpskYBo9vfvzz+L5Yql6W6j1/qDR/apDrM0d1bWfvUCBpMeAoSXCooQBhwOTg1Jn0bV5faf7OdSGkX4stN00QXcphOInMbrg/mR9M03010/rmhJ7UbK50a8k/S0lxLaTRKGSVXWQKBg5ySyjGOM84xQ77RPZt14OiPY10rdXulzT6bPcvavcJKqsjNLIchCPiGAfMV7+pDKCOxGRXzTq/SnUE3sA6a0KLRb9tWtdSMs1sIjuRA0pz6Yw6/f5GvbdE1nXLnqy70650F7bQ4bZJLfUXk5lchcqU7ju302/MUWkzHaWqoooqOgooooCiiigj0UUVGoFAopRQkUUUUZFFFAopaKKKqCiil86BKp+o+mNI6jNi2sWguGsphcW7b2UxuPPgirmloGUtoUtVtliXwFTYIyMjbjGKd7UtFAUUUUBmlpKUUBXQFc0oOKDqikzS0BRRRQFFFFAUUUUBRRRQR8UYpaKisv1deXkOqaTbWc99HHMszSLZRxvI20Lj8YIxyagaR1HqVzaxQPHFJKlg1zcTs+x1YO6bdm0jeCvPkDmtNq2jWOrNA19HIzwZMbRzPEVz35Qg+QottE061BFvapGDD7ucE8pkkjv6sTnvzQZ2x6pvDbwyyWkb2iSWlvLM036xmmSM7toUDgyDPb5Y7VxedV34tlnFlFHZ3S3It5knzKvhI7AlSmATs4748/StImh6cluYFtgIi8UhXc34owoQ9/IIv2qji6NjOstd3UkDW26ZhDHG6k+IGUg5cgcMc7QuTyaBLTqi7JVpLONrOO6gs3lM36wtJHG27aFxwZADyKf0rqS8vb/TRJYwR2GoiVoJBOWkCoMjcu0AEj0JxVwmi6ekTRrbAI00dwRuP+IgUK3fyCL9qptK6T9y19dReaDbG0rRxxROuC588uQOM8KFBPJoG5Nd1G117U0eKKbTor62tQTJtePxUiHwgLyNz5OT58dq5n6vuILX3ySwi9ynW491ZZyXZolZvjXb8IYI3YnHGa0culWUrzM8ALTTR3Eh3H4pI9uxu/lsX7VXXvSmmyxag1tAsF1dwyx+KSzLGZAQzKhOATnJxjNBWXnV9zp0J9/wBPjE8sUMlukErSBvEfZhsJngkHgHPlV501qk+q2Ust1aPayxymPDK6hwACGXeqtjnHIHINM2nSmj21lNbCzVlnVFlLOxJ28rtJOVAPIC4we1P2uh21nc2j2bSRRW/iEx72bxHfA3MxJJIAPfPf5VQy+rXkutXNnYWcMsFm0aXDyTFGy4DfANpBwpB5Iz2qs/vbOsSXUljELG5FwLVlmJkZoldvjXbhQQjdiccZq9u9C0281BL65tQ9ym34tzANtOV3KDhsHkZBx5U0nTWkJc3FwtmviTq6v8bbQH/HtXOFLeZUDNBSN1XfwtIbjT7YRwpayylLhidk77Vx8PJBBz5dqetuqLktFNdWdtFYy3dxZrJ7wdwMXifEwK4APhHz4q8l0TTpVlElsCJUijf4m5WJiyDv5En/AK1WaV0hptm8s1xClzdSSzyF23bcSuxPwElc7W25xkgflRFZbdZXk0F/t0wS3FukEqiEysrJI5UnmMMwXaTlQwPlT1x1ZcxW9lMLS1MMokMtx4z+DGyuF2lgnwnGT8YXGMVaQ9J6LDDNElmdsyojlppGbCElMMWyMEnGCMV1J0ro0kMET2QMcOQo8RxuBOWD8/GCeSGzmgq4eoL2S8msrGCO5uvFuX/0mbw0WON1XAKoeSWGOPXJriPrGaeL3y3sIzp6i1Z2eYiTE+Oy7SDt3evNXV501pN4gW4tM/rHkykjo2X/ABcqQcHjI7cDin20TTmhli91QRSeHuRSQP1eNmADxjA7elBKv7hbSxuLlygWGNpCXbavAzyfIfOshZ9ZXlzMbVdPh99FxDDzJIkZEiOwbLxhuPDPlg+RrTW+lwpp9zZ3JN1FcPK0ol53B2JK/QA4+gqPY9NaTZSiW3tSJd6Sb3ld2LKGCkliScB2H5/SgqIuqL+82W9jp9ubxVuGnElyVRfBk8M7TtOcnkZAx51Ig1u+h6J0nUxA17dTwW7SkA8b1G5yEUnAznCip1z0zpFyEE1mDskklBDspzI25wSDyGPJU8fKpM+jWE2lQ6dJB/ocKqsSK7KU28LhgdwI9c5oKUdWsLO+mNvA5tdNa/PhTFlZlZwUyVBH4PMZGcY4o/vTdi6umaxg9wtr2GydxOfEJkEeGC7cYBkHGewNT7npTRLm3igmsVMUcbRAB2XKsckMQctk8855570xZ9J6fFrF1qVxGJriS5FxHksFjwiqMrnDEbcgkZGaCvXrO4gsYr/UbCJLKdLho/BnLyZiDNggqByEbz44zT151PqVlJ7rc6ba+/u9sI1S5JjKzOUGW2ZBBU+RzUrQukdL0yIGSBJ7ko6PI+SpDklsISVGfPA586mWfTOkWabYLPH6yOXLSO7bkOU5JJwD2Hb5UDHTd9qN3qmuQ6h7v4drcJHEIiTtzEjEcgZ5bvWgqJDYW0F9cXkMey4uAolYMcPgYBIzjOOM4zgCpQPrQLRRRQMUUmaKjSm1fV5tP1nT7YQK1rNHLJNKWwUCAHP86NG14ajcpDLZXNm00PvMHjbT4seQCRtJwRuXIOD8QqVqWlwahPbTTNIrwFsbCAGVhhlYEcgioujdP22lTiaOe7uHSLwIveJd/gx5zsXgccDk5JwOeKIr4+qZFmkgWzmvJvEusCELHtSF1U53NyfiHPn6CmdS6xJsmn0iwnuIV8DfcsFCRmXaQCu4MTtcE4HGR86uLbp+zt7trmMy+IwnBywx+udWfy9VGKgN0Xp5jjiS4vooFSFHiSYBJTFjYzDHcbV7YzgZoEg6sghiuWvw4EQu5FdU2hlglKFQNx+LGPr6DtRD1PKZ54FsZ7m7NyYY7aMKjKohjkYszNg43gZ45IGPOpc/S+nz28EMnjFIbxr5fi5Ls7MQeOVJY8Ut10zaTzSzx3F3bXL3JufGgkCsrFFQgZBG0qg4IPPNBX6P1gJ9HuLq9tnWa3s5L2RUAHwK8ihQCfxYj+lSbnqyCHWV0x4GSWXcsTmWM7mEZf8AAG3gYBGSO9NS9E6a1itpHPfQxeA9tIY5sGWNmLbWJHOCx+fPOaeXpKxXUBdLNdgCZrgQ+IPDEjIyM2MZ5DHufpiqGNC6t9/k021mtmN3cWsM8jK6IoMibsqrNuZRjkjOKmdSdT2/T8sQu4WMTAM0gljXaM44VmDMfPABrmLpWzR9P/0i8aCyEXhwNLmMtGMKxGMg8DsQDjkV3rnS9nrN1JPcTXcRlhEEqwyBRIgJKg5BPBY9sZzzmg4/vVCl6YprO4ithcvae8sV2eIqljxnOMKecd6a0jqS51PXbO2OnTWlpcWcl0jT7S0gDIFI2sccMcg89qsH6fsn2h/EYLdte4LDBdlZSDx2wx4pnRumbTSr2K5iub2Z4YWtolnl3rHGSp2jjy2jBPPqTxQQG6kvoNR1w3VpH7jYyxwRqHRGZmRW3M7OFAw3bH3PFA63tZLCK8t7K4nha3muZCjIfDSJ9j87sNznGCc4qy1DpqzvXuJGluIpprlLvxI2AKSLGIwVyCPwjGCD3NMw9J6fFZPaiS5ZHguLdmaQFisz73Ocd8niiGH6nZ7mK3FvJa3HjpG8cqq+VeJ3Ugq2B+A+uMdvOq+063WGxjn1Lw/ElitSka7Yl3yxFz8bvjGFPfHpyTV/L07ZSX/vjNN4u6N+GGMojoPL0c/yqKvR+npAiQy3cUkawiKZJAHjMaFFI4xkqxByCDntQRout7ScWHu9rNI12HIHiRqAUfYwUlsOc84UnI59Km9R6nqFnqui2WnwxMt7cFJZZOdqqjMQBkc4Xv8AyNF70ra3tpBbXF5qLwopWRTPkTgnJ35Hr5rg44GBVne6dBd3VlPLv32jM8YB4JZChz+TGgz6db2XjXcMtvKk0CB1iWSORpMuEC4VjtYsyjDY7/Wnb/qSay8CS7sZ7UBZ3khkVXZxHHvyrK2P+fPHFJb9EaZDCYjNeyRiH3eJXl/wU3Ky7MAYKlFIPJ45zUqXpW1ngEd3d31y22VTLLKCx8RNjeWBx2AAGefWgj/3sjVWSXTruO7YQmG3JQtMJSwTB3YH4GzkjGKlWmuteaRqlylpLa3Fi0kbRT4OHVA37JII5HnTOudOLc27y2QY3qxwRxFpjHs8JmZWDBThvjbyIPYjFddOaFNaaTqFtqUm+S+mkkkKyFyAyhfxEDJwM9gOeBigpZeqdVkW3isoYHnmvIbZnaPCxh7dZjxv+LucHjHofOTbdaw6u81pocaS3kkMj2jNLGyuVIGWVW3KOc4bGQD51bwdNWMEiOjT7kuI7kZYfjSHwh5dto+9daRoFvpVwXtbi7MIDLFbPLmKIE5IUY+2Scdhig40HqCPWLu6git5IxAzIWZ0JyrFSCoJZc4yMjkVeA4qn0/Qbez1WTUPeLqedkaNPHk3CJWYMVXjOMgdycY4q4xQBOaSiigUHFLkVzRQNUUUVGlbq2s2ulyxR3CzvJKjyKsMTSHamNx49NwqJH1VpclvLMHnARY3VTA+6VZDiMoMZbcQQMVMv9MW7v4LoylTFBNBtAznxNvP5bP51UP0kvhwmK+kjngtraCKQRg7WhYlXIPfO7BHp50RKfqrTViiYC6eWR3T3dbdzKpQAsGTGRgEH55GM5qTpOsjUdS1K1W2mjFm6oJHGA+5A35d6qLzpCS6s5El1FXuppmnnmktUcMxUICq90KqoAIP1zVxpOkHTby5ljupJYp1jDJKMtvRAm7d55CjPHfmqKq96ytI7iOK0hllDNOvjSRukR8KN2ba20huUxx65GcVDv8ArW5gmnS30iSdYktwH3kBpJmAUfh/Dz37n0qS3R8jbIW1WU2MLTtBAYV+DxUdSC3dsbzj7HPepp6aQiQe8t8bWh/B28Bgw8/PH5UCR9TQp4yXUTm4ScwLDbI8zkhEZjjaMY3j5ducnFS36g09YbiUySbII4pXzGQQshIXg8547eVQZemZEv5r6w1Fra8kuJJQ5hDqFdI1ZCpPP+EpBrjU+lZb1pNurTos8MMVzuiVmlMTEq2RjaTk5wPpig703qj3vVraxNlKpna6XxFO5V8GXw+fr3+XHepeo9Tafp95LbXAuS8PhiRo4GdVMhwgJA7k8D501ZdOe531pcw3bB4Zbl2BjBDrNJvZe/BBAwfl25py/wCnku7m+mNwym6ktZCAudvgOHA7+eMfKgH6o09YI3C3Tyu7p7ulu7Sgp+PKAZAGRz8x6iuJ+rdKiKkPPLGY4pPEihZ1CyEhOQO5IwB60zP0xKLxr3T9Ra1vTLM4kMIkXZJs3KVJ9UUg0kPSNvb2ht4bmQR7bVQWUE/qH35PzYnn0oJR6o08QK6rdPKXeP3dLdmlVkxuyoGQBkc9uR6iuB1dpJdAkk0kbJDIZUhYoqynCFmxxk+tQdS6Kgvbh7gTxG4NxLOvj2yzIBIqBl2t5/q1IPFSv7qxC1u4EuWC3EVvHkRKu3wTkHC4HPoAAKI5tOsLWWBnntLyOU3U1tHEkRkeTwidzAD5Dn7c1pI5o3iSTdtVwGG74Tz8jWWveknntp7eO+QQyXUl0qTWqShDISW785BJIIxjzzV0+i2M9ha2moW8d+lugVWukEjEgY3Ekdz5mgsBLGTxIn+YVneouqP0Pd3kHuUspt9PkvvEzhDtz8JPl271Nj6Z0GORXj0bTldSCrC2QEEefamOoOnv0vPLIt20HjWctlKvhhtyP2I5GCDz86KP71acIWZ1vFkWVITCbZxIWcErhcZIODg/KnoupdOkvhagzhi5iEjQsI/EAyY9+MbgAeM9wR3rifp5JtX9/NwwbxIH2beP1QcDnPn4n8qYj6XK6gGN/I2mrdNerZ+GOJWyT8ffbuYtjHfzxxVRKfqbTEtYLhpXEc1qLyP9WSWQlVGAOdxLqAO5JqBN1paCWBILa7djdi0mjeB1kjYxNIMJjJzgffNQZ+jp7ex+G+uLxrW1W3s4kjRGQJIkiHJOGYGMd8A/KuundA1CbUZdS1h5I5/flukV1QM4Fv4XIViFHxHHJ7fOgudQ6igg6VutctIpbmGGJ5BFtKNlcgqQeVwQQfTBriLqOKM2UOo289vcz+GrEQu0SO5wqGTAGSePzFOPoCN03qGke8MFuzcEy7eV8V2Y8fLd/Kq7VOjlv9bj1B7zhJ4LgI8CuymIqdqueVU7eQPMk58qBmTrURW9zIbFn8Gxa93ox8M4dl25IB/Z74rRaVq1tqnvCwCZJLdwkkc0TRsuRkHDDOCOxqkfo8G1MEd+6I9o9pLmIHcpYsCOeCCx+tWL6ZdW+sS3tlMuLuaL3gMo+GNEYYHqSdo+VBcnvSUpNJUBRS44pMUDVFFFGkPU9TstLiSTULmK3RztUucbj34pmTXdLjube3fULYTXCq0S+IPjDfhI+vl6+VR9b0+9m1Cyv9MkthcW6SxFLkNsZZNpJ45yCg/Imqu96b1GdrmFbqz91vvAa6bwmV0aMKD4YyQAQoxk/CSTzRFvJ1NosbyI+o24dCwZc85U4YfkRz6V3P1FpEEojm1G3Vyqty/GGGVOe3I7etV1p05NBNYu00RFvLeyNgHnx3Zl+wPNQbLpC5t9O93a4hZv9A+IA4/0cqW++OKDTWurWF34ItrqKQyq7IFPJCEB/sSAfrTA6i0gzQRfpC38SZUaNd34g/4D+fl61USdO6hDf++2Fxaifxbk7ZlYrsmKHPH7QKDjsc9xTVt0lcRaPPZm4hMj21nAHwcZhxk/n5UFmOqtPW+FrOXifbcSbiNyhYX2sSRnHmcfLnB4q3ivLeW5MEcyPN4azbQcnYSQG+hwftVBa6DPZ38F00kMsKm9E0e1iSk8okG3HcjAGPPNcez7TJbLTp57oSBpX8KASoVdbaP4YgwPIOMtg/vURcDX9JNxcwDULczWys8qB+UC/iP5efpTWr9R6dpSRm4lLGRoVCxqWIEjFVbHpkH7VSN0ne3ES2N1dWw06A3DQvGh8ZjKrr8WeOBI3buQDxXLdM6vOZJrm5sfeUSzWERq+w+BKz/Fnn4t3l2+fmVoX6i0hBc7r+Ae7tsl+L8DZxtPzz5d65m6k0aC3t55dStVhuATE5cYYDgn6A9/Sqi/6XuZ9OVIp41uotRlv0w7orby/wAJZSGHD9x5ioGp9H6ncaStlbXlvFHLBOk0YlmCiSRi2/O7c/fBDHB748qI1kmtabHqAsHvYBeHGId3xHIyB9SOcVH6d6j07X7aKSwnQytEszQlhvQN6gfbjzqHp+jalYX86wT2bWFxOLiQyRsZQ2wKVHOMfCCD5dsVHtNFvtG0/TJLTwbi60/TGsljGQJJGaPB/wBn4Dnz5oNFqWp2WlwrNqFzHbxs2xS5/Ee+B69jUaTqLR45oYn1G2DzIkkY3/iVzhWz6E8A0x1Ppt/qMdqNPuViEbkyRtI8YkBGB8SEMMHnHnVJadHXMOh3Vi1zC0kunQWQfBwGjLkt64+Mfairy86n0u21SDThcpLeyzrAYY2BZCRnJ+g/PkVd1kD05qI1KALcWf6Oh1JtRXKN4xLbiyny7ucH0wK19EdUtIDS1QUUUUBRRRQFIRS0hoOaBRSjtUCmk49DQaX86Biiiiim554rdN88scSZxudgoz+dDTwpKkTyxrI/KIWALfQedUPU+nXF1f6fdRWMGpQwJKj2szhRl9uHGQRkbSPox+lUuodOX8s0iR6dZZuBbeHcrJ/7h4YXKpkZIBBK4xktzig2jX9mu/dd242fizIOOcc/nxXT3dskywvcQrKxwELgMfyrIW3Sri5spLi0tn2zX7zFgpLCV2MefXgj6VQWmh6jJNf6YLC2muvcrK3ku3lG61dU5YcEnHcY8wKI9RFxC07QrNGZlGWjDDcB64psX1oYZJhdQGFDh38QbVPoT5Vih09qsnVAvJra3WET3DNJG6IJI3jdUHwrvzym4ljyMgejFv0zqCaQ8D6cgWKWB4IxNEJQUDAncE2uBkABxnvk9qDdrqFs08kQk/w4lmLkfBsYnB3dv2TXbXtqkaSPcwLG43KxkADD1B/MfesL/drVTEsk1rZyFIbUNaqQscvhyyMYzxgcOp7YJHpUrSul5m1KG4v7G1S1LXTi0BDrB4gjCqOMc7XJxwCxoNm9zAkscTzRrJJyiFgC30HnUSy1rTb20e6t72BrdJGhaQuAA6kgjn5j86wUnSuuPBpUT21uz20VkvjK8YZfCZS4Zipc9jjaQP8ArIueltTCwhbZJIre6u38KN4h4qzPuV/jVlBUZUgjPJwfUPQ3kRI2kd1VAMlicAD1zTTXlssMcrXMIikICOXGG+h86z1/olx/dPTbCCJJns2gZ7aWTKzKmMxliOfkSMHAziqnXtA1LULCCK30ixt42S4DQRNGTE7kYO5kIAIzu2jOccmg3LXUCyGNp4hIoJKlxkADJOPoRQLqFrVrmORZYQpbdGdwIHpjvWHuOlL2bS9R3Rw+/wAxtCHJUtIsSRb0LEHglGHII55GKuOn9HntNB1OFoDBNdtI4jaRGwSgUfgVVGcdgPnnJNUXltqFrcLCY5kDTKGSNjtfBAP4TznBHFK99AmpQ2DMfeZYnmVcHBVSoJz9WFYuz6TuorBi9vB78J9PdJMjcqwrCHwfL8L/AFz86u9ag1CLqWw1KwsffIorWaB1EyxkFmjIPxd/wGoL9p4VnWFpYxMw3CMsNxHqBSRXEE0kkcU0byRnDqrAlT8x5VjLnQtQn1mWU2FsTcXsF4L5pB4luiBN0WMZ/YYDBxhznzzK6T0W90zV7lntIbez2uAS6SOzNJu+FgobbySQ+TkjHagvm1myTXI9IaXF88Pjqm0425x37Z4PHfAPpU2K6t5ZWjinieRRkqrgkc47fXisVc9OavLqU2srdAXi36zxWuE2mJP1YXfjIzGXOM4y1M3HSl0uloYVhtbvxdQea5T8QSXxdhyOT3j4+Xyqjcx3trIHMdzC4TJYrIDtx3z6U2NStGubaBJld7lHeIp8SsExu5HH7Qry6ysT1BJqH6Ds7K0VbWzDJaSo0cpSVmKFtpXJAxgg+W4c1cr0lqMljLHADZSTxXgJaVWMbSrGq/gVVGdhJCjzzyTQbwX1oYHmF1AYUOGk8QbVPoT5U9G6yIrxsrIwyGU5BFeeQ9LXf6OuWbTmWdpIGWIXMIOY93xALGE/a/aByBzjArZdN21xZ6JawXqQxzopDJCAFXk4HAAzjGcADOcUFkaSl86Q0CV0O1IKWoA1zSmkoGvKiiiioupajZ6Zb+PqFzFbxFgoaRsZJ7Aep+VdWF7bahapc2M8dxbvnbJGwYHHBqp1+KaLV9K1JLSW8htlljeKIAuhcLiQAkZxtI45w5ql1CCa692ubrQrxNOkkneWztnxK0h2+HLIFI5wH4ycZUn5EbmkJABJ4A7mvOXtdettOuoLi0vbm5vLC2jV0cN4ciM+8O2e+GXnzxU200K5FxFNJBOZJ9SvFuN7khrZhLtBGcbc+Hig28E0dxBHNA6yRSKGR1OQwPIINd15l7jd2GjdP2NpDLaTX9qNJuYc7WRh8TSjnyUTcjvuFei2Um+JlEEsKxOYlEgHxBeAw5PB8qKk0DvRS0QtJS0UCUtFFUFLSUtFFFFFEFFFFAV0vaua6HagKWiigKSlpKBCaSlNJUCilo86KDk0UUUDVAooFFLRRRQLSUUUDTWtu10ly0ERuUUospQb1U9wD3Ap6iiiFopaKKSilpKIWiiiqFFFFFRRRRRRBRRRVCiuqQUtAUUUUCGgmikPagSlFJXQ7VAEVzXVcmgSiiigbpaSlqKKauLiG2iMtzNHFGO7yMFA/M1k/aj1pD0R0zJqDIJbuRvCtoieGkI7n5Duf/WvkHqLqTV+pb57vWr6a5kJyFZvgT5KvYD6VqI245c0Y+3u+1v706Bn/wDXNLz/APFx/wBaX+9Ggf8Abel/8XH/AFr4ZSn0rXF5p6yY9n3B/efQf+2tM/4qP+tdDqbQv+2tN/4pP618Sp5VISnBieumPZ9pjqXQz21jTv8Aik/rXQ6i0U9tX0//AIlP618Zxd6sbfyrNo4tU62bez69Gv6Oe2q2H/EJ/Wuhrmkntqdif/np/WvlKDtVlb15b9RNfZ66ZOb6dGs6Ye2o2Z/+ev8AWpkUkcqBo3V1PYqcivmmDyq+0XUrvTJ1lsp3jbzAPDfUedeeev4z+aHqrh5eJe+UVUdNawmsaalwAFkHwyL6N/SrV2xgD8THAr30tF6xaviXK0TE6kM6oMswA+dJ4iev8q7SMLz3bzY96dFaRH8RPX+VdCVB5/yNPUUDPip6n7UeKnr/ACNPUUDJlT1/lR4y+v8AKnqKBgzRjuwH14rrORkHIp2mpI9oLRjB81Hn/wCtAV1iuVIIBHIPalzQBpKU81zQFFFFA2KGPFLXL9qivmz+1XcTNq2g25J8BYZXA8ixYA/yArw5K+q/bx0fL1R08k1im/ULAmSJfN1I+JR8zgEfSvlYo8UjJIrI6nDKwwQfQ10q+f1ETFttr09NeyaFHB0/Hby3scjG5tmto5XlBI2uNwO4Dtgfh7+ZIi9VzLLe2yPLDPexQ7LqaFVCPJuJwNvBwCq7h3x59znLeWSGQPE7I443KcHng06lah5rW7aaXoa2S76gjiktVu/1Fw6wspYO6wuyjA5PxAcVsE6TtNSlt3mt57G7kSFbiztkJ93Z3kHiOGOVQKisR/rdxxXmcLMhDKSpHmDipKSPkne2W7nPejEWiI1MN5adJWc7wxQT3TzD3czYC7SssLSHb9Nh9e/Y+d/H0bpwf3RZLgSLPkznP4DbrKE27c5ySAcZ4PHlXlsLuCCGYHjzqyt5Hzne2eD3rnkbxXr8NgNGs7fVtSglmuHtrOFZgVTa7ZKDbhgMfj748s4q+HTNpbrO01xcGOKR90yoNiqsgXaT++RyPy9awMDMckk5Pc571ZwMxGNxwTk818/JMfD6OFstV0CLS7Eym4LyK6xFePxnLH8gu38zUC38qroSTjJJqytgWKhQSTwAPOvm55iZ7Q+phehezKRlmvE52FVP5816EvMy/wCyf+lZHojTWsLItKMTS4Zh6DyFa0gja4GSvl6ivsdHS1MMVs8+e0WvMwfHesv1fqN7pl1GLWUhb+E2kAIyI7ksNjfZmJ/2K1CEMAVORSlQcZAODkZ8q9LkwsnU14862amJStykZKOTLGEuY4j4g7DeGJHy9e9dWvWN5fTWFvZx2fvFwkXibixETt4u4EA548McfM1t9iZJ2rk9zjvQI0ByEUHvnFB58vWWpLE9y0du6zJbGKHgeDvVizFmZQRlcckckV1N11foxxp0ChbXxiGnQ5bwTJlfiyy5GOAfPnjFb8xRkYKLjGO3lQY0JyUXOMdvKqGdPeeSyhe68PxmXc3hg7Rn0zVJ1learaWLtpsMRxtMUnjkO0meE2BDuB4Hcdz2xmtHRUDFi9xJZQPeRJFcsgMsaNuVWxyAfPmn6K4lfYuBy57CgZh/AfkzD+ZpykRdqgelLQBpKKKAooooOKCOKWkqKhXUO9SCKwPVHs+0PXpjNf6fG057yoSjn6kd/wA69KZc0y8Abyqs2rE+Xih9jnTwPCXX++NKPZBoA7Jdf74/0r2Y2q+lJ7qvpTcufo0+Hjo9kehDsl1/vTXQ9k+iD9i5/wB6a9h91X0o91X0puT0KfDyFfZXoq9kuf8Aemnl9mWkr2W4/wB5XrPuq+lHuq+lJ7+SMNI8Q8sX2daYvZZ/95TydBaevYTf569N90X0FL7ovoKxOOs+YbiuvDziPomzUjAl/wA1XmkdO2tm4aKEBv3jya1gtV9KdSALUjDSJ3ENbnxszaQ7AKmDgUKuKWug5Kc5UlT8vOjD/wAVvsP6V1RQJh/4rfYf0pMP/Fb7D+ld+VJQc4k/it9h/SjD/wAVvsP6V1RQc4k/it9h/SjEn8VvsP6V1RQc4f8Ait9h/ShVC9sknuTya6ooCkNLSGgKKKKAooooOaM0UVFGaga5frpej3d6wz4MZZVxnc37K/UnA/Op/FcSxRzKFmjSRQwYBgCAQcg/UEA1UYa06qvlitQfDupYYroXQk/0feYjGVcZXglGzjAHxfKrL+9w5kFl/ozF1jYy4clCA25cfCOeDk/lkVfXOl6fdSb7qxtZnzndJCrHPHOSP9UfYeldNpli0s8rWVs0k6eHK5iXMi/usccj5Ggq7DW7i96jezSGAWcccmZPEJcujheBjGOfWosnVb+EDb2HiylHfYJTxtm8I5wpPz4BPlg1f2+m2Nu0TW9nbRNECIykSrsB74wOM4GfpTb6Npbmffp1k3jnMuYFPic5+LjnkA8+lA7pl2l/p1tdx7dk8ayDacjkZ74FSq4ijSGJI4UWONAFVFGAoHYAV3QFAooFAtFFFAUUUUBQKKUUCGiiiqCiiigKKKKgKKKKApKDRQFFFFAUUlLQJRSUVAtFJRVBS5pKKBaKTmig6opKKBaKTmigWlpKKBaKTmigWijmjJqgopKOaBaKTmjmgWik5o5qBaKSgmgKSjNGaAoozRQFLSUtBxS4ozSqueTUDF1cwWke+5kWNSdoye59APM0lpdwXasbeQPtO1h2Kn0I7inZIkaRHZFLpnaxHK574pBEglaQIokYBS2OSB2GfzNTvtexue8treaKGe4hillOI0dwrOfkD3pyaaOFQ00iRgnALMBk0zPZ28zu8tvC7uArMyAkgHIB+hNEsQl2+IqvtO5dwzg+oqo7huoJ3ZIZopHUBmVHBIB5BP18qeqLFCsTOY0VC53MVGNxxjJ9eAB+VOYf1NUOJKkjOsbqzIdrgHO04zg+hwR965e6gSRo3miWRU8RlLAEJ+8R6fOuQrDOPPk/OmJbKCWczywRPMYzF4jIC2w91z6fKgmqwZQykFSMgjzpajKrKoVeFAwAOwFL8f7xoJOaYW8tmnaBbiEzJw0Ycbh27j8x9xSDf6mmvdImVlMMZVm3sCowWznJ+eQD+VA5cahZ21xFBc3dvDPL/hxySBWf6AnJp+SVIgDK6oCwUFjjJJwB9Saiz2kE5YzwxyMy7CXQHK+nPl8q6miEoAkUOFYMAwzgg5B+oNA81zCswhaWMTHGELDcc5xx/wB1vsfSi4uIraF5riWOKFBlnkYKqj1JNMCEeMZti+KQFL45IGcDPyyfua6miE0ZjlVZEburDINA5a3MN3As1rNHNC34XjYMp+hFO5qHHCId/hKqb23NtGMn1Pzrv4/3jQdTXltBKkU1xDHK/Ko7gFuQOAe/JA/MV3NPFDC000iRxKMs7sAoHqTUfwFMxlKKZSApfAzgHIGfTJJpTaQmBIWhiMSEFUKDapHbA8sUD8E8VxCktvIksTjKujBlI+RFOZqLHCsClYEWNSxYhAACT3NdfH6n70EjNIzAd8CmPj/eP3puaITJslUOuQ2GGRkHIP5EA0EmKVJV3RurrkrlTkZBwR+RBFdVHUMBxwPlTyZ86DriinAB5gUjIMZFA3S5ozRQc06n4BUdzxT0XMQqCJfahbWTKLl9m5HkBI4wgy38ufyNRrLXdOvER4bhQjwxTqzjYNsmdnfzO08d6Z6p0X9N2kEIm8ExzBy2M7kIKun/AHkZh+YrPX3QpuUmBukbdcSPGjB1VI2XAT4WBO3nzxhiPnUG2kljTO91XgtyccDufpTRvLUCUm4hAiOJCXHwH5+lUuu6HcX0caWlxFEBaSWj+Khf4X28jnv8PnnvVRd9GS3ElyRcxJE84nSJQ6gnc5OWDbh+POAcZGcc4qjaSzwxRCSWWNIzjDMwAOe3NHvEHiNH40e9V3Mu4ZA9SPSqGbQHGlaXawNbM1iu0LPGzxt8BXsTnz4yT6VCuukpLhpVM9siEs6ssGGZmUDa/PMfH4fTA8qDSfpKz8eOIToTJGZVYHKlQQp57d2FdQX1tOI9sqhnUsqMcMQPPHesqekpv0ha33vUSTQyvN4KRkQksycbc9sIT/tHd8qZh6KKX8dw86ShVwQ/icHay4ChgMfF5/P14DW3Gp2NvZG7muoVtQQpl3ArknGMj5mn454ZJGSOWN3TG5VYEjPbIrOWXTs8HT9zp7TxFpJVkjwh2oF2kKSTlvw9yc4OPKndH6fksdVN28sLKom27I9rv4sgc7znnBGBQXEWpWTxK7XEUYK7sSMFIHqQakG5tg7J7xDvUbmXeMgYzkj0wRWWs+j1jkt3mlhlMUkbnMXcLHIuPvJn8qr7foy7kS9tp5LeOIMBHN4WZJMWaw5znhcluDzx880G3F5aHtcwHKl/8QfhHc/T50r3FujhXmiVi2wAuAS2M4+tZ2fpZJtRZ3aD3R3DsvhfrB+p8IorZ4Ujnt3J9ar5uh5J0tHur0XFyhbx5H3r4hJXDAKw5Coo544FBtGliQkNIikYyCw4ycD7mkjubeQfq5on+Ir8LA8juPrWf1zR5dR6gs2UOloIj7y4IG8qcxAc5yrEt2xUO16Xu7UwSwXFnHPDsRVSArGyrG6ZIBzuPiZz/qgVRq47iCVykU0bsACQrAkA9jXC3dq6FluIWUZyQ4I47/bNUuidPfo7S7y0eYM9yoUyxrtYAQpH/wD1J/OqW26IeC2gjE8CmKdbgfC7hyqhQrbmPwnByB/q+lBtVu7Y+LtuIT4X+Jhx8H19K4l1G0jVSZ42LbSqowYkE4BAHlk96xY6Rub21nW5khtyZJiixoVLhrgSjeytz+EdsH4jUzT+j/dLeVBNEGkWAAhWbbsneU8sSed+O/l+VBq2vbRQ5a5hAQ7XJkHwnGcH0rrx4PEWPxY/EZd4XcMlfXHpWP0nopbG8jmeaOVY5Q/xb2LgLIBncxAOZM8Adj68dR9HyJZS2huLcxzW4iaYw/rkIg8H4Gz8I4z+bDzqDXwyxTxiSCRJEPZkYEfcVX2uuWNzdtbq0iMC6hpIyqOUfY21jwcMQKhdL6JLokEkQeEpLIZJAviMc7VUYLMf3T/L05qZOlJo5L6dnWVXS52pDnxHMr7wPibaNpx2xyBzQbeMo+djK2Dg4OcH0pwKBVZ05Zy2OkQR3O03TZlnK9jK5LPj5ZJ/KrWgKKKKBgGlzTSMSaczQMuakW/MK1FftUq2/wABaCq1/UpNPNskQtlaYv8ArLmTw41CrnGcHk/8gT5VS3nVc8DXBS2tnC+KqRiY7wUI5cY4U54PzX142E8UUybZ40kUHOHUEZ/OuDbwGR3MMe9wAzbRlgPImgxt5r9+t20b+DEsTNE6x92ZZolyCR22vyMevyNSoOpZhJbe+QW8UVwYyHEh+BWZ15yO+VH3x9dO1vA7BmhjZgSQSoJB9f5UPbwuhR4Y2QjG0qCMZzQYqDqu7maV7aCJwYTOA8nwgLHvwuBnn5/+lWWndRveavDbeHbJDK0ihfFzMmwA5Zcdjn7Y9a0Yt4V7QxjjHCjtVdc9Padc3E808TyNMhjYGRtu0jBAGeOBj8z6mgY1zVpdOvba3S3WU3QCwndjL71BB/7rFv8AumqvTuq2nlthcQ20cU4ifcs2fBWQPhXyBhwUAx8/lWomsYJ7y2uZVLS2+4xfEcKSME47ZwSM/M12bW3KOpgi2yNucbBhj6n1NBltW6rNl03Y6nHDE8lzC04iLHlVTccHGPTkkd/PtTN31dPHPIIbe1ZcyqkbTES5QDllxwDnv6Y9a2EltBLGqSQxOi8qrICB9BUNdGshqTXxjZ5yCPjcsoyAOAeOw/5+poM1d9X3tnd6hDJpqSLaI/xLMq7nVVOcE5C5PfHAwfOpq67fXPTqXqxQ2twL2K3YMwkUqZVUng8cN68VpTBCZWlMUfiMu1n2jJHoT6UgtoBB4AhjEOMeGFG37dqCk0TXJNR1BoHihVGSR18OQs8YR9mJBjgnuPoR5VA0/q1p7i1W5itoorjY24S5MIYPhZMjhsoBj5n051ccMUbu8caIznLFVALH5+tcm1tyrqYItsjbnGwYY+p9TQUnTetT6xDPcNBElvGiEbGLMzNGj/b4sVBj6muJLS3kA0xXuAHBNydsSlC+1zjhuMfc+VavwI/CeNVCK4wdnwntjuORx51TnpbSjGyFLsozb2X36fBb1I396DO6v1fdfou8ltoEhbwX8Mb8zRuLfxg5QjG39n649cU5cdWXCQx3MVqXaSPi3LhfDbxgmHyBhh5jOM8fOtA3TOmvIzsL1nZdhY385JX0zv7fKhumNMcOGF6wfh83053fX4+aCBLr840jR7nwbWCa/n8BvGmGyPCSNncM5/B/Oq246uuJtNllghghLQHaPE3SK5tzKHAxgoO2f/8AK0TdNaY8SRyJcyRocqsl3K4U7WXjLHHDHt/0pG6Z01n3sL0vt2bjfT52+n4+3yoKnTOoLmWVYCbYESNue6m2Fx4pQBMLyRjt81HnmrLp7W/0s0yvGsJhIgcFufHAJkQeoXjn608nTmnJLFIBdl4n3puvZmAb1wXxUyx022sc+Ar5Zi5LuWJYkknk9zuPP0HkKCVto211RQA4pQaSigXNdVxRQREbmnc0whp3NQNOeKk25/ULUZ+1PQti3X6UhUHqXXrDpzR7jU9VmEVrAMk9yx8lA8ya+fNV/tAa3cXj/ojTrK2tc/AJw0jkepIIFWn9qe9n/R+hWqE+7PNK8npuUKF/kzV4p0pZwX2t2kF7KkVpu3zu5wBGo3N+eAe3Oa6ViPd4s+W0Txq9Ri9uXVTd4tM/3Df+ap9r7Z+ppT8UWnflC3/mrKT9O6Q8001teJ7tLIjRtDMAlvGyqdzB/iYZLKBwfh+dWFj07puL9nuZIGhXMcbzI7dmO4lRgglQMcd/PjK0Rp5ovl5a22tn7VNflI3x2P5RN/5q33SHXCavKttfxLBct+BlPwufTnsa8vvND07TYr5IzK9xHkRq86EgK+3cQByCD27jBpNJdklR0JDKQQR5GvlZs98Ntvs9PTnGpfQZlAGSeK6VJG5JCD0xk1V2ExlkhDefJ+2at52KwyFPxBSR9cV9KO7ExonhN/E/8NHhN/E/8NYO06i1iDS4pY/G1EtDCZZZoAvhSsrl142AgFVHqC3n2qVf9Qau9rM0UPu86SACFYWZ1XnksRtIPBGMH+dXSNl4TfxP/DSGOQchg3yIxWNk6k1qG33NYpJuDBWERzHtkCFmywByDnuvb07a3S7iS6021nnj8GaWJXeP90kZIoO1fP19K7BqJcyCO7I9VB/OnY5MioHs0hauC9MvLQSN9JvFQWm5rnx6aFhvFLvqt8eulmzTQsA1LuqKsldB61Fds8j5akLgUw0lRpZ8edSY0Rbaf4goEgqpN1z3pVuue9Z20uA2a6qDBNuqWhyKCHH/ANadpiPzp7NFcP2NOKcWq/SmpO1LFIrRGM/iHl61IJebe1jp2LqfRXtJG2TRt4kMn7rj1+Rzivm276a1fTrlobiwnJU43RqXU/QivrrWot2ay0lgGkPFbiZh5smKMj5yg0u//wD4N1/um/pVtY6deq2WtLgfWM/0r6DttKUj8NOT6YqqfhpNpmNOVeije9vHrWG8lmaSaKdpHYszMpySe5NbnpbSJJJ45bldkanO092q5NmFftVzpsG3HFeO3T1tbdn0sUenGoXVqzLtZe45FXkV9EyjxDsb59qqIEworp+K9US3asWhc+92/wDGT70hvbYD/Hj+9UDGo0x4qcnLg0hvrUd7iL/MKbm1S0jUkSq58gvOayEvekQ1YszpcG7ae4aRuM9h6CrGCTK1Q25+IVc234aqSfeWmHkpZajOcUnsjvdmjNR91OK1TY7rpTimi1AamxNRqc3YqGj10ZMVuLsTU9I/FQLiTmnXkyKgztzWLX2sV05aTmlSTmo+cmulPNYaXFo+cVbQnIqksj2q2MqxRbmPPkPWtQpiI8mnqYi7U9mih6g3K5HHB9RU5u1Yj2l9Wp0tpIMSrJf3BKwRt2Hqx+QqJa0VjcvO/b91Fr2hvoSaLezxG4M/iCNQxbb4eM5B/eNYLpfqjqu9v09/1uS2gU5YyKmW+Q4qLqd/eatdtc6jcSXEzftOc4+QHkPkKZSOpa+o1DyVycr8tdvh64nUV/FH8N/I5PIOF/pWV6h606j98WCyv4lt9pEsjMA6n/VG0jj51lS1yLZ4red4iw4Iql0jT9X1O8e3s7SVmRtrueEB+ZrhiiZnvL68Zsd6/lro9q/UHXunWMl+nUsl5axthxEq74wexZSo4PqM1u+hOudauumo7y9vXO+V4vEcDKsq7sdvMEV1o3s2BIk1u9eZGUh7eAlFIPkWzmpTez2OzhjttEncWonaZYZnGYyVAOHOODjz/nXbJWZrqf8Ahxt+XvVV+zf20TzafqbdTamYpIWV4i+0GUHI2rwOxwatNZ9sUMlo81tqgjaJ1eNI2QtMPNCoJwK8Q1/pbXOlLdYdc06SKMsfDuBiSJvkHU4z8qykStJOWH1rrXHEztic01ncw9X6T9qHV131EqXmuTNC6vtjZUC7tpIHb1r0Cx6z1uaGFrjU3RxDmRTsJ3ZPoPTFfOsRMbowBBDBs16jp1nD7pBcI7EmMOfi4PHNcOvn06xaJen8PmL2msxtK1vrLrGHSjJHqdwszcghEyBn6V1c+0bqK06gtNL9+llMsSlnwoKHuT2+Rq2uFNxo+6aNRgHZj8v/AL/OvPrqwkudcvNREqgrNHZog5OGDFj8gNv868/Q5LZaTy+XbraVx5KzC/uPaH1bCzga1MPiJXaq4x5fs19I9N3t/cdPaZcS3DNLLaxSO2ByxQEmvmWaCGVFRlVgowoxk19RdNRBemdKVRhRZw4/yCvfee0afP17qXqbUtUtrmxkhvpFgefwJFULzuBA7jvuArJ651Jr1rbOsV8/jqcZO0Y4z2Pyp/WeodHl1u9sJbu18WC5YtGXAKsjdz+YrNdXG8S2a+SyubmKNfE8RdqK4Y4AGe/fuM1yy33rXZnHWZ2dg6q6lkO46lICuN6YU4/l8qmRdYa1arJ7zqTSYkGDgduOBxzmqPoXTNS6yEt5ZxJBFFIscomkI2kDkdsnv6VpupOmLnp3QxcvqNjGY2Y5lLfEhXlR8JJxXnt1NKZOEz3a07l6j1xoobiG+uHSTDFBGBs58+M45FV0XV/UE11JH79LGvh7lYhe/PyqT03Jez9LGe6k8HTyCFuI23swLcAY7HyOfSszqpj0n3O4t5mlj2+LMvHlycZ/2sflVnLW06r5ajHMRuV/onW+tXTMZbuYbFyQqgg/ypdV6t6g8OSS21CeNkwBGUBBycZPGRXnFp11BplwLexjjKONxkwd+T5Z7celaKC6udZkR41fw5ImUMAAHbuAT+RrhmjLW257R+5x34WnT/WXU7657heX88k34TGVXjzz254o9pfWOuWVzbjRtUnAeMswRVIGO/lUnTtPvt8OpTWDx3qDY047hTwOPPyo16+06PSL2xksXmu4o98sjjDIpwSeT5gE8eWDWcWW1r7dPSnjMSyekdd9UsYhNqUrkkFiyrjHfHapMnXGvSahaJZ6xdSCRQ7R7FHcnjOPSqu5uLG30z3mC8hW3vBuSOLllUY4YE8fz5qLoNxpt/qcMbze6XMuVWQruGOABgEYPB+9e2ceSZeeNRD0zoDqTqXUuuBb3N/I1iJJwY8LtAVWwAcc4O2vbLdXYhpGLN6k14r7ObuysertP0iK494lIlCSBNqthGJI8yPh717jAuFrVNxExLdoiJ7JMQp6m0HFOV0ZI5wK+bfbDeSXvXV3G5ylsiRIPQbQx/mTX0hN2r559r+mva9XyXJB8K7RXU/MDaR/Ifes2nUbcc8brpR9MWsK6bquovaR3k9msfhwyAlAGYhpGA7gYAx2+IVPtdYtrmeCCDprTWlnYJKFVyZCTjCDd8H5ef2ql0e/uNLvUurRgsi5BBGVdT3Vh5gjuK0j6xpdjE9x0/ay2+o3QIdpDlbQHgrEe+Tz8R5A4+dea2T7pjopNdsYbDXL+0tnLwQzPGjE5JAOKtelNK1K/n32E8UMcRG5nGcZ8gPMceoqjRc8mvTvZ3aNb6S8rDBnfcPoOB/1rHTTyyvXEzijlC7tLB44kFw/iyAcsBtB/KnZLZGHGV+lTWbPAFPQWjMQ0gwPQ9zX19xEPPytM72q20iPULSaynhW4tphtkjlUMrD5g1mF/s79JzRPsbUbV2HdJgQPyINeqweAIwoXay/u1Oa4aOA4YDA5Zuy15r5J9uzfHfnu+etQ9hsfTJF5p+rS3WM/qpgMEeu3z+9Z7VNE1CybaYGaF2HxIpAH5V7t1fqMun6bLeeG8kYIG3b8chyO58voPzrxzqzqDUL62PjSJZ2wIJhg5c/7TeVezD+HR1uL80ePdx/rLdJk/J7+yt1O58GzWL8KxxjI+ZIrJ7tETXNPubVJLgi23XqhiuZc4IHHzqXqV2JdN34wSyqcEkZwfXufnVFp8dubqaWGZ/DYY2OgHJ5755PFeDD00dLypE71MvZn6meotW2tbiHrnso0TTtVsdRudTtPFQSBITvZWXg55Ug+Yr3CxgihsbeK3TZCkSKi5JwoUADJ+VebezyEaX0LbSEfrbhmkAx6nAP2ANenWX/ALlBkc+Gvf6CtW7ww+Qdfh//ADl173kRravfzq5c8bS5GTXrT6TP1D0hYWOhFbaNlUl1Y7Mp3UHnALc+leMe06Ux+0PqPawBF/N5/wCual9FdW6vZsLCwmmdJ2wI0bJDeoqzii2rT7MY+tnHuke/b7+z0jTNMt7TV9GgsbmeKWF/Ev4VyrlwfTIGeSePWtz17Zp1F+jtP1W2kW1kkZsxtyqheST2HcD8/rWIutPu7C9N5bxx6nI7gsZ5FjY8c5wyg+n9ad1eC7ksltn1RZnV1aJHnzwo+EZJGDk9/pWM2LBlyRljz+7dK5a9pie32X/s70dtFtNQ0W0vrLUNGjmZTFOmyXxTglT3B4xyD3FR+r7KDVtJGhQRi2VVZEuBl5ANx+HHB8gDzzWX1XVrmytJIry28ORzulkimDKJG+HJ49ceZrcdMS6rpRnRJYGiAwsQmSYRsM5wAcqfXOBXDJhrSZvrUu2K2Olotf8ANHxHbf8A7y8Li6HQtJ+kZGDwOV3wsAJADx35B8qf6V6q/QEsdnftcW1wniGFHUlN7Iyhh5cFs1QaNfi362mXX7mVLZLmQsCSVDknnaK1HWk9tfX8V5YxGUW0O2IlcEknJbHf0+1WItNuNu8O1uE05U7S9Uh1uS96Y92ubto76FYnju7TB8ZyOY8Hs3kc+ufWsBPpd/Jruu3M2rwW9jdsEWedgqsTHsIVjjd5jj0NWPS2ny3vQExhkZ9QlxcNtb4jFgjA5wcZzz35FZTWdcuF0PTVg1B75DKUZZYNpU7s4AJOfyrpi9O1Zp93ltGTFfl/P+6Ens7vhaLDaXsMjjPLNgEj0P3qou+k9U0e1ubzUVWD3UqQSc+JkgcH869Z6Ps7qbpQatPDFPam6lkV3GGMasQGUY7cHiqrrmwu+soY7XT1YSBv1aHgOeO9cb5r0ycJ8PbjwYsmL1K+dLz2Rztq3WGk3YmDwwQuoDRgPuMJDDPoMYznPA+lfQsK/DXgHsY6X1/pjqa1j1W1/wBHmVx40ZDqCI2wCR2z8+9fQMXavRWIiNQ8eS02ncnR2paQdqWtuRJBkVketenoNf04wTjbIp3RyAcoa17VGmj3VJjaTG3zVq3TuoaTMyXMDNGDxIgypH18vzqAoA/Fx9a+jb2wWXOVqiu+mbG6bNzZW05HbxYVf/mK81umi0+StuPs8u0bRJblllnUrAD282+Veg2lwtsgiYeHEBhQozx8vSr610qOBFWKGJFAwAsagf8AKpQ0+PuYYSfUxL/St48UYvpam3PyiaVLbyoCGIm8kfg/l61YW5kkcZ7E4pBZIuNsUQ+ka/0p4JL+9j8h/SuvKU1CWLZgf1fLeo8qiahqMccexEeRY2wdi7tz/L1I/wDvtXam4UELMwB8hTRgJQIQpQdl2jA/lWVZnrm9t7vpKTxBPHJKdlsAxDF89wo5b1Pyrxm76fmtbSK4v5kBzj3UgsTnzOMAfTn5mvouW0ExjMqRyGMYTcinaPlxxUabRrOU5lsrSQn96BD/ANK9/T9XXFThO5ebLgm9uUPl7V7J47zTbQJuhuSWAAxjDEZz8gB9zS6/0rfWVk7WYSexQmQb8Bozjnn/ANa+nD09ppeNzplgXjzsb3WPK574OOKkDS7fwzH7tbeGe6+CuD+WK8mTJM35Y+0fDrWk/wB0sl07putW/TGkw2WyImCONpyQwXAAb4T35Br0Ff8AD5pmGHAVf2V7Adh9BUlhha5u23w/7VLcS+0XqXvn3+bkH/XNeg+xrRdAayWTU5pYrqddgnR8NE25uPMcrt7jyrB+06UL7RepAwIAv5ucH981N0XUY4bCJbZitwACw8MsDznkefGR5V7K4vUjTwYcnC8zP8PQOqtObSrKWee6upLjxNkKqmQw3HBLAcZA/nUHW9JvtQ6X0ubTUtk1NDicPwVz3zn0Prk1SXHUDC2NvJcGOCTCsZDweeMrkjj1xzTttOLWDZiNpAT8VxCzcd/hAIFbr0OKYivx8Ol+szcuUp9n0hq15PbR6vrdtc2xkRp4I5ykgVSDkAqAQMD54r0q71Ww0+3llvr+CNF5bC8jjtgeZryW11S9WdTG8Fs5VtikA58vwj4efrn7Zqp1bVTCJn1FSLxgdm0AlS2AcD0PzrwddgpN4itp09eDlnryzRrXjXZjuvL4TdX31zZBvDluGkQ7SO5yMfevStL9m9/cXtgsuu2+ycBnB3o4PfbypAPzJrJxafrGowvZ2Vvc3RaIrtNuzBvRs4+Eg+n3qPJadZ9HajaXGqWl0/iEssUsni7scc7SSvcelWu5rxr5dOMVtu3h9GxaTax6BNpmh3Ia/tbVrKO6ZgfDwDtBxgFiSPLtXmfso6FvNUutSuOs4z+jLDaACQS8i5JCkdhyMn5j8uegX6rsLDUNRsrG0b9Iy+NLBcMQPPG0Z4xnHNbTT+oNQj6be2uLRIJZpvEZVOcljlgMfPGPlXCkW6e14szkn+ptWtPEf4XfWuuWOi9DLLdlbWCZlt41Vc4BPbA/1Qxrv2bLZ39r+mLV1e33NHE5UgMcYYjPfGcff0ryf+0HLPHp3StvK3wBJWYDtvwn9TXtOmra6Hoel6PajCWkKKSOxPdj9Scn866zii0Vvby6RmmvLHXw31lMGtynyqVFWQ0rXo21e1sVtpc3CufEZhgYUnt59q18XatVtFu8OWSlqTq0aOV1XPnRW3M4a4eiioI0lR2A9KKKiFAHpXQAooqS0KB2ooqLBcUYFFFAuB6UYHpRRQBFKKKKDpe9En4aKKD4l9qCqfaF1FkA/wCnzdx/rGqkyyLZfDI4+HyJoor3U8Pn4vqsvvZxa29/fyi+giuQFGPGQP8A869o6atbeDTZDBBFGSxzsQD/AJUUV6MHmXS7xrqL4eo2iXiIXJ+AdvxHyrU9HxR/390cbEwJWwMdvgNFFfJyfXP7y+ti+mP4e3as7xRZjZkO/GVOKyvUru+hasGZmHhjgnPpRRXPF+pX93qzfpW/ZQaeSEu1BO0XC4HkMxgmu3JGvwAEgDOMeXwGiiunV/6mXyOm/Quw3t2YnS+lgSSPDmP8o69V013k0myeRmZzBCSzHJP6taKK6f2Q3T6paXpwA69ZkgEiJyM+XwmvQY+woorydJ9D19f+r/Bwd6KKK9bxP//Z" } }, @@ -106,11 +106,11 @@ "id": "total-blocking-time", "title": "Total Blocking Time", "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds. [Learn more about the Total Blocking Time metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time/).", - "score": 0.96, + "score": 0.99, "scoreDisplayMode": "numeric", - "numericValue": 134.5, + "numericValue": 79, "numericUnit": "millisecond", - "displayValue": "130 ms", + "displayValue": "80 ms", "scoringOptions": { "p10": 200, "median": 600 @@ -120,11 +120,11 @@ "id": "max-potential-fid", "title": "Max Potential First Input Delay", "description": "The maximum potential First Input Delay that your users could experience is the duration of the longest task. [Learn more about the Maximum Potential First Input Delay metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-max-potential-fid/).", - "score": 0.86, + "score": 0.88, "scoreDisplayMode": "numeric", - "numericValue": 143, + "numericValue": 134, "numericUnit": "millisecond", - "displayValue": "140 ms" + "displayValue": "130 ms" }, "cumulative-layout-shift": { "id": "cumulative-layout-shift", @@ -132,7 +132,7 @@ "description": "Cumulative Layout Shift measures the movement of visible elements within the viewport. [Learn more about the Cumulative Layout Shift metric](https://web.dev/articles/cls).", "score": 0.15, "scoreDisplayMode": "numeric", - "numericValue": 0.522984, + "numericValue": 0.52251, "numericUnit": "unitless", "displayValue": "0.523", "scoringOptions": { @@ -143,7 +143,7 @@ "type": "debugdata", "items": [ { - "cumulativeLayoutShiftMainFrame": 0.522984, + "cumulativeLayoutShiftMainFrame": 0.52251, "newEngineResultDiffered": false } ] @@ -213,11 +213,11 @@ "id": "interactive", "title": "Time to Interactive", "description": "Time to Interactive is the amount of time it takes for the page to become fully interactive. [Learn more about the Time to Interactive metric](https://developer.chrome.com/docs/lighthouse/performance/interactive/).", - "score": 0.93, + "score": 0.96, "scoreDisplayMode": "numeric", - "numericValue": 3393.006, + "numericValue": 2986.4135000000006, "numericUnit": "millisecond", - "displayValue": "3.4 s" + "displayValue": "3.0 s" }, "user-timings": { "id": "user-timings", @@ -399,11 +399,11 @@ "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn how to minimize main-thread work](https://developer.chrome.com/docs/lighthouse/performance/mainthread-work-breakdown/)", "score": 0, "scoreDisplayMode": "metricSavings", - "numericValue": 3037.7679999999727, + "numericValue": 3217.379999999988, "numericUnit": "millisecond", - "displayValue": "3.0 s", + "displayValue": "3.2 s", "metricSavings": { - "TBT": 150 + "TBT": 100 }, "details": { "type": "table", @@ -424,32 +424,32 @@ { "group": "other", "groupLabel": "Other", - "duration": 1065.8159999999928 + "duration": 1272.6439999999955 }, { "group": "scriptEvaluation", "groupLabel": "Script Evaluation", - "duration": 874.9039999999935 + "duration": 827.7399999999972 }, { "group": "styleLayout", "groupLabel": "Style & Layout", - "duration": 816.8399999999995 + "duration": 792.3879999999995 }, { "group": "paintCompositeRender", "groupLabel": "Rendering", - "duration": 248.599999999987 + "duration": 270.443999999996 }, { "group": "parseHTML", "groupLabel": "Parse HTML & CSS", - "duration": 22.551999999999996 + "duration": 46.61999999999998 }, { "group": "scriptParseCompile", "groupLabel": "Script Parsing & Compilation", - "duration": 9.056000000000001 + "duration": 7.544 } ], "sortedBy": [ @@ -464,11 +464,11 @@ "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn how to reduce Javascript execution time](https://developer.chrome.com/docs/lighthouse/performance/bootup-time/).", "score": 1, "scoreDisplayMode": "metricSavings", - "numericValue": 857.9560000000014, + "numericValue": 809.7120000000008, "numericUnit": "millisecond", - "displayValue": "0.9 s", + "displayValue": "0.8 s", "metricSavings": { - "TBT": 200 + "TBT": 100 }, "details": { "type": "table", @@ -500,31 +500,31 @@ "items": [ { "url": "https://mim4u.org/", - "total": 1019.9359999999995, - "scripting": 7.348000000000001, - "scriptParseCompile": 0.9680000000000001 + "total": 1043.963999999999, + "scripting": 9.283999999999999, + "scriptParseCompile": 1.7919999999999998 }, { "url": "https://mim4u.org/js/motion-BypEwiDP.js", - "total": 959.6440000000049, - "scripting": 305.312000000001, - "scriptParseCompile": 2.516 + "total": 1014.6240000000034, + "scripting": 293.4960000000004, + "scriptParseCompile": 0.776 }, { "url": "Unattributable", - "total": 518.8680000000019, - "scripting": 100.38000000000002, + "total": 637.4359999999976, + "scripting": 93.93599999999999, "scriptParseCompile": 0 }, { "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", - "total": 474.8760000000004, - "scripting": 440.6320000000004, - "scriptParseCompile": 0.8 + "total": 430.53200000000044, + "scripting": 409.1000000000004, + "scriptParseCompile": 1.328 } ], "summary": { - "wastedMs": 857.9560000000014 + "wastedMs": 809.7120000000008 }, "sortedBy": [ "total" @@ -542,22 +542,22 @@ "type": "debugdata", "items": [ { - "numRequests": 36, + "numRequests": 37, "numScripts": 10, "numStylesheets": 1, "numFonts": 4, - "numTasks": 1619, - "numTasksOver10ms": 13, - "numTasksOver25ms": 5, + "numTasks": 1630, + "numTasksOver10ms": 11, + "numTasksOver25ms": 4, "numTasksOver50ms": 1, "numTasksOver100ms": 0, "numTasksOver500ms": 0, - "rtt": 0.591, - "throughput": 100242246.87320982, - "maxRtt": 0.591, - "maxServerLatency": 6.505999999999999, - "totalByteWeight": 1289494, - "totalTaskTime": 759.4419999999992, + "rtt": 1.1929999999999998, + "throughput": 44019980.5822456, + "maxRtt": 1.1929999999999998, + "maxServerLatency": 7.413500000000001, + "totalByteWeight": 400217, + "totalTaskTime": 804.3449999999991, "mainDocumentTransferSize": 2750 } ] @@ -631,8 +631,8 @@ "protocol": "h2", "cache": "none", "rendererStartTime": 0, - "networkRequestTime": 12.574000000953674, - "networkEndTime": 57.656999945640564, + "networkRequestTime": 13.396000027656555, + "networkEndTime": 59.25699996948242, "finished": true, "transferSize": 2750, "resourceSize": 5856, @@ -648,9 +648,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 71.60000002384186, - "networkRequestTime": 72.71499991416931, - "networkEndTime": 89.83300006389618, + "rendererStartTime": 75.32799994945526, + "networkRequestTime": 76.14499998092651, + "networkEndTime": 117.66499996185303, "finished": true, "transferSize": 68126, "resourceSize": 67600, @@ -667,9 +667,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.18499994277954, - "networkRequestTime": 73.87599992752075, - "networkEndTime": 124.86800003051758, + "rendererStartTime": 75.817999958992, + "networkRequestTime": 77.38300001621246, + "networkEndTime": 131.3090000152588, "finished": true, "transferSize": 49578, "resourceSize": 123351, @@ -685,9 +685,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.35399997234344, - "networkRequestTime": 74.1930000782013, - "networkEndTime": 117.72800004482269, + "rendererStartTime": 75.96400010585785, + "networkRequestTime": 78.33600008487701, + "networkEndTime": 137.40900003910065, "finished": true, "transferSize": 64290, "resourceSize": 139182, @@ -704,9 +704,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.43500006198883, - "networkRequestTime": 74.57400000095367, - "networkEndTime": 125.39999997615814, + "rendererStartTime": 76.09700000286102, + "networkRequestTime": 78.9740000963211, + "networkEndTime": 141.81500005722046, "finished": true, "transferSize": 53916, "resourceSize": 115837, @@ -723,9 +723,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.51499998569489, - "networkRequestTime": 74.78100001811981, - "networkEndTime": 108.44700002670288, + "rendererStartTime": 76.24699997901917, + "networkRequestTime": 79.51000010967255, + "networkEndTime": 130.25300002098083, "finished": true, "transferSize": 31741, "resourceSize": 69102, @@ -742,9 +742,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.58800005912781, - "networkRequestTime": 75.02600002288818, - "networkEndTime": 101.26600003242493, + "rendererStartTime": 76.31400001049042, + "networkRequestTime": 79.95600008964539, + "networkEndTime": 126.41400003433228, "finished": true, "transferSize": 6913, "resourceSize": 15874, @@ -761,11 +761,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.72099995613098, - "networkRequestTime": 75.2039999961853, - "networkEndTime": 89.36199998855591, + "rendererStartTime": 76.43900001049042, + "networkRequestTime": 80.51400005817413, + "networkEndTime": 110.56700003147125, "finished": true, - "transferSize": 22356, + "transferSize": 22386, "resourceSize": 91616, "statusCode": 200, "mimeType": "text/css", @@ -779,9 +779,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 72.87399995326996, - "networkRequestTime": 77.64199995994568, - "networkEndTime": 120.86000001430511, + "rendererStartTime": 76.51200008392334, + "networkRequestTime": 84.36199998855591, + "networkEndTime": 102.12500011920929, "finished": true, "transferSize": 580, "resourceSize": 134, @@ -797,9 +797,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 79.39699995517731, - "networkRequestTime": 80.19799995422363, - "networkEndTime": 123.86899995803833, + "rendererStartTime": 87.6710000038147, + "networkRequestTime": 88.5349999666214, + "networkEndTime": 147.64099991321564, "finished": true, "transferSize": 1280, "resourceSize": 934, @@ -815,9 +815,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 112.5259999036789, - "networkRequestTime": 132.5, - "networkEndTime": 138.79900002479553, + "rendererStartTime": 148.9650000333786, + "networkRequestTime": 168.14000010490417, + "networkEndTime": 178.9960000514984, "finished": true, "transferSize": 23678, "resourceSize": 23224, @@ -833,9 +833,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 124.94900000095367, - "networkRequestTime": 133.26600003242493, - "networkEndTime": 141.56500005722046, + "rendererStartTime": 159.96599996089935, + "networkRequestTime": 169.0939999818802, + "networkEndTime": 182.47099995613098, "finished": true, "transferSize": 24118, "resourceSize": 23664, @@ -851,9 +851,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 191.31900000572205, - "networkRequestTime": 191.96000003814697, - "networkEndTime": 197.43799996376038, + "rendererStartTime": 223.81299996376038, + "networkRequestTime": 224.81300008296967, + "networkEndTime": 231.60899996757507, "finished": true, "transferSize": 884, "resourceSize": 783, @@ -869,9 +869,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 191.62799990177155, - "networkRequestTime": 192.28999996185303, - "networkEndTime": 202.04299998283386, + "rendererStartTime": 225.7610000371933, + "networkRequestTime": 226.35900008678436, + "networkEndTime": 234.88199996948242, "finished": true, "transferSize": 0, "resourceSize": 783, @@ -886,12 +886,12 @@ "url": "https://mim4u.org/js/HomePageRoute-DP9kcacJ.js", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 214.65199995040894, - "networkRequestTime": 215.86099994182587, - "networkEndTime": 222.43299996852875, + "cache": "disk", + "rendererStartTime": 1254.9580000638962, + "networkRequestTime": 1257.5659999847412, + "networkEndTime": 1260, "finished": true, - "transferSize": 6724, + "transferSize": 0, "resourceSize": 17881, "statusCode": 200, "mimeType": "application/javascript", @@ -904,12 +904,12 @@ "url": "https://mim4u.org/js/MimChatAgent-DjZzDJ1t.js", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 220.77900004386902, - "networkRequestTime": 224.7759999036789, - "networkEndTime": 230.82799994945526, + "cache": "disk", + "rendererStartTime": 1259.1860001087189, + "networkRequestTime": 1261.7070000171661, + "networkEndTime": 1264.598000049591, "finished": true, - "transferSize": 1050, + "transferSize": 0, "resourceSize": 818, "statusCode": 200, "mimeType": "application/javascript", @@ -922,12 +922,12 @@ "url": "https://mim4u.org/brand/logo-symbol-nav.svg", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 228.3219999074936, - "networkRequestTime": 232.36800003051758, - "networkEndTime": 242.88499999046326, + "cache": "disk", + "rendererStartTime": 1267.1549999713898, + "networkRequestTime": 1267.9199999570847, + "networkEndTime": 1274.25, "finished": true, - "transferSize": 19913, + "transferSize": 0, "resourceSize": 42074, "statusCode": 200, "mimeType": "image/svg+xml", @@ -940,12 +940,12 @@ "url": "https://mim4u.org/brand/logo-horizontal-nav.svg", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 228.84700000286102, - "networkRequestTime": 232.7369999885559, - "networkEndTime": 247.6619999408722, + "cache": "disk", + "rendererStartTime": 1267.5060000419617, + "networkRequestTime": 1268.2319999933243, + "networkEndTime": 1278.7450000047684, "finished": true, - "transferSize": 57040, + "transferSize": 0, "resourceSize": 128884, "statusCode": 200, "mimeType": "image/svg+xml", @@ -959,9 +959,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 247.61100006103516, - "networkRequestTime": 294.7829999923706, - "networkEndTime": 302.05200004577637, + "rendererStartTime": 1276.308000087738, + "networkRequestTime": 1335.2879999876022, + "networkEndTime": 1349.6219999790192, "finished": true, "transferSize": 24726, "resourceSize": 24272, @@ -977,9 +977,9 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 247.9780000448227, - "networkRequestTime": 295.32999992370605, - "networkEndTime": 307.54100000858307, + "rendererStartTime": 1276.7130000591278, + "networkRequestTime": 1335.692999958992, + "networkEndTime": 1352.833999991417, "finished": true, "transferSize": 24906, "resourceSize": 24452, @@ -994,12 +994,12 @@ "url": "https://mim4u.org/js/WhoWeServe-CY2E46Dh.js", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 319.53000009059906, - "networkRequestTime": 321.3330000638962, - "networkEndTime": 331.34600007534027, + "cache": "disk", + "rendererStartTime": 1364.194000005722, + "networkRequestTime": 1365.3100000619888, + "networkEndTime": 1371.601000070572, "finished": true, - "transferSize": 1179, + "transferSize": 0, "resourceSize": 1372, "statusCode": 200, "mimeType": "application/javascript", @@ -1012,12 +1012,12 @@ "url": "https://mim4u.org/js/PartnerStrip-CuG7sPqV.js", "sessionTargetType": "page", "protocol": "h2", - "cache": "none", - "rendererStartTime": 321.222000002861, - "networkRequestTime": 323.3919999599457, - "networkEndTime": 334.567999958992, + "cache": "disk", + "rendererStartTime": 1366.1330000162125, + "networkRequestTime": 1369.9970000982285, + "networkEndTime": 1376.3100000619888, "finished": true, - "transferSize": 1239, + "transferSize": 0, "resourceSize": 1313, "statusCode": 200, "mimeType": "application/javascript", @@ -1026,16 +1026,34 @@ "experimentalFromMainFrame": true, "entity": "mim4u.org" }, + { + "url": "https://mim4u.org/photos/community/optimized/outreach-01-960.webp", + "sessionTargetType": "page", + "protocol": "h2", + "cache": "disk", + "rendererStartTime": 1417.5240000486374, + "networkRequestTime": 1418.3980000019073, + "networkEndTime": 1443.7389999628067, + "finished": true, + "transferSize": 0, + "resourceSize": 67600, + "statusCode": 200, + "mimeType": "image/webp", + "resourceType": "Image", + "priority": "High", + "experimentalFromMainFrame": true, + "entity": "mim4u.org" + }, { "url": "https://mim4u.org/photos/community/optimized/outreach-02-600.webp", "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 361.3910000324249, - "networkRequestTime": 361.9370000362396, - "networkEndTime": 383.9060000181198, + "rendererStartTime": 1422.5080000162125, + "networkRequestTime": 1423.0219999551773, + "networkEndTime": 1463.3689999580383, "finished": true, - "transferSize": 86592, + "transferSize": 0, "resourceSize": 86024, "statusCode": 200, "mimeType": "image/webp", @@ -1049,11 +1067,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 361.6190000772476, - "networkRequestTime": 362.3109999895096, - "networkEndTime": 385.8109999895096, + "rendererStartTime": 1422.9860000610352, + "networkRequestTime": 1423.3180000782013, + "networkEndTime": 1459.849000096321, "finished": true, - "transferSize": 119410, + "transferSize": 0, "resourceSize": 118746, "statusCode": 200, "mimeType": "image/webp", @@ -1067,11 +1085,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 361.6950000524521, - "networkRequestTime": 362.59300005435944, - "networkEndTime": 386.2589999437332, + "rendererStartTime": 1423.2460000514984, + "networkRequestTime": 1423.6030000448227, + "networkEndTime": 1473.1920000314713, "finished": true, - "transferSize": 93642, + "transferSize": 0, "resourceSize": 93044, "statusCode": 200, "mimeType": "image/webp", @@ -1085,11 +1103,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 412.0260000228882, - "networkRequestTime": 412.972000002861, - "networkEndTime": 419.10199999809265, + "rendererStartTime": 1425.7159999608994, + "networkRequestTime": 1426.352000117302, + "networkEndTime": 1450.0270000696182, "finished": true, - "transferSize": 52772, + "transferSize": 0, "resourceSize": 52290, "statusCode": 200, "mimeType": "image/jpeg", @@ -1103,12 +1121,12 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 427.9279999732971, - "networkRequestTime": 429.29100000858307, - "networkEndTime": 557.569000005722, + "rendererStartTime": 1440.1400001049042, + "networkRequestTime": 1444.4490000009537, + "networkEndTime": 1555.6069999933243, "finished": true, - "transferSize": 41052, - "resourceSize": 171734, + "transferSize": 345, + "resourceSize": 1048576, "statusCode": 206, "mimeType": "video/mp4", "resourceType": "Media", @@ -1121,9 +1139,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 459.86100006103516, - "networkRequestTime": 459.86100006103516, - "networkEndTime": 460.1050000190735, + "rendererStartTime": 1487.0509999990463, + "networkRequestTime": 1487.0509999990463, + "networkEndTime": 1487.2300000190735, "finished": true, "transferSize": 0, "resourceSize": 547, @@ -1138,9 +1156,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 462.9819999933243, - "networkRequestTime": 462.9819999933243, - "networkEndTime": 463.2170000076294, + "rendererStartTime": 1489.361999988556, + "networkRequestTime": 1489.361999988556, + "networkEndTime": 1489.6059999465942, "finished": true, "transferSize": 0, "resourceSize": 552, @@ -1155,9 +1173,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 466.72000002861023, - "networkRequestTime": 466.72000002861023, - "networkEndTime": 466.97000002861023, + "rendererStartTime": 1492.2960000038147, + "networkRequestTime": 1492.2960000038147, + "networkEndTime": 1492.4850000143051, "finished": true, "transferSize": 0, "resourceSize": 380, @@ -1172,9 +1190,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 474.83899998664856, - "networkRequestTime": 474.83899998664856, - "networkEndTime": 474.9650000333786, + "rendererStartTime": 1498.0030000209808, + "networkRequestTime": 1498.0030000209808, + "networkEndTime": 1498.182000041008, "finished": true, "transferSize": 0, "resourceSize": 297, @@ -1189,11 +1207,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 517.9750000238419, - "networkRequestTime": 520.2929999828339, - "networkEndTime": 537.470999956131, + "rendererStartTime": 1532.5260000228882, + "networkRequestTime": 1533.1510000228882, + "networkEndTime": 1548.7760000228882, "finished": true, - "transferSize": 205323, + "transferSize": 0, "resourceSize": 204650, "statusCode": 200, "mimeType": "image/jpeg", @@ -1207,11 +1225,11 @@ "sessionTargetType": "page", "protocol": "h2", "cache": "none", - "rendererStartTime": 518.295000076294, - "networkRequestTime": 520.8150000572205, - "networkEndTime": 536.970999956131, + "rendererStartTime": 1532.8730000257492, + "networkRequestTime": 1533.2979999780655, + "networkEndTime": 1551.9060000181198, "finished": true, - "transferSize": 203716, + "transferSize": 0, "resourceSize": 203061, "statusCode": 200, "mimeType": "image/jpeg", @@ -1225,9 +1243,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 527.0870000123978, - "networkRequestTime": 527.0870000123978, - "networkEndTime": 527.4689999818802, + "rendererStartTime": 1567.7760000228882, + "networkRequestTime": 1567.7760000228882, + "networkEndTime": 1567.9680000543594, "finished": true, "transferSize": 0, "resourceSize": 177, @@ -1242,9 +1260,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 530.5149999856949, - "networkRequestTime": 530.5149999856949, - "networkEndTime": 530.738000035286, + "rendererStartTime": 1570.6469999551773, + "networkRequestTime": 1570.6469999551773, + "networkEndTime": 1570.794000029564, "finished": true, "transferSize": 0, "resourceSize": 351, @@ -1259,9 +1277,9 @@ "sessionTargetType": "page", "protocol": "data", "cache": "memory", - "rendererStartTime": 533.7279999256134, - "networkRequestTime": 533.7279999256134, - "networkEndTime": 533.8980000019073, + "rendererStartTime": 1572.8740000724792, + "networkRequestTime": 1572.8740000724792, + "networkEndTime": 1573.0099999904633, "finished": true, "transferSize": 0, "resourceSize": 242, @@ -1274,7 +1292,7 @@ ], "debugData": { "type": "debugdata", - "networkStartTimeTs": 841304438962, + "networkStartTimeTs": 848763430951, "initiators": [ { "type": "parser", @@ -1356,7 +1374,7 @@ "description": "Network round trip times (RTT) have a large impact on performance. If the RTT to an origin is high, it's an indication that servers closer to the user could improve performance. [Learn more about the Round Trip Time](https://hpbn.co/primer-on-latency-and-bandwidth/).", "score": 1, "scoreDisplayMode": "informative", - "numericValue": 0.591, + "numericValue": 1.1929999999999998, "numericUnit": "millisecond", "displayValue": "0 ms", "details": { @@ -1377,7 +1395,7 @@ "items": [ { "origin": "https://mim4u.org", - "rtt": 0.591 + "rtt": 1.1929999999999998 } ], "sortedBy": [ @@ -1391,7 +1409,7 @@ "description": "Server latencies can impact web performance. If the server latency of an origin is high, it's an indication the server is overloaded or has poor backend performance. [Learn more about server response time](https://hpbn.co/primer-on-web-performance/#analyzing-the-resource-waterfall).", "score": 1, "scoreDisplayMode": "informative", - "numericValue": 6.505999999999999, + "numericValue": 7.413500000000001, "numericUnit": "millisecond", "displayValue": "10 ms", "details": { @@ -1412,7 +1430,7 @@ "items": [ { "origin": "https://mim4u.org", - "serverResponseTime": 6.505999999999999 + "serverResponseTime": 7.413500000000001 } ], "sortedBy": [ @@ -1444,88 +1462,120 @@ ], "items": [ { - "duration": 12.272, - "startTime": 62.798 + "duration": 14.365, + "startTime": 65.588 }, { - "duration": 37.889, - "startTime": 95.305 + "duration": 6.565, + "startTime": 80.493 }, { - "duration": 15.178, - "startTime": 148.681 + "duration": 48.352, + "startTime": 120.535 }, { - "duration": 22.004, - "startTime": 163.872 + "duration": 13.168, + "startTime": 183.112 }, { - "duration": 35.459, - "startTime": 197.118 + "duration": 19.735, + "startTime": 198.249 }, { - "duration": 5.029, - "startTime": 232.597 + "duration": 6.076, + "startTime": 234.551 }, { - "duration": 36.546, - "startTime": 241.794 + "duration": 30.878, + "startTime": 1241.274 }, { - "duration": 10.764, - "startTime": 283.176 + "duration": 36.897, + "startTime": 1272.169 }, { - "duration": 30.967, - "startTime": 313.662 + "duration": 6.485, + "startTime": 1309.14 }, { - "duration": 17.968, - "startTime": 345.396 + "duration": 15.388, + "startTime": 1315.674 }, { - "duration": 11.87, - "startTime": 367.15 + "duration": 6.836, + "startTime": 1338.422 }, { - "duration": 5.63, - "startTime": 381.211 + "duration": 5.345, + "startTime": 1359.312 }, { - "duration": 5.253, - "startTime": 393.897 + "duration": 7.654, + "startTime": 1364.996 }, { - "duration": 6.533, - "startTime": 399.372 + "duration": 5.637, + "startTime": 1372.703 }, { - "duration": 5.534, - "startTime": 406.111 + "duration": 5.881, + "startTime": 1381.367 }, { - "duration": 5.258, - "startTime": 411.852 + "duration": 6.981, + "startTime": 1389.053 }, { - "duration": 8.716, - "startTime": 421.363 + "duration": 5.892, + "startTime": 1396.224 }, { - "duration": 14.955, - "startTime": 432.061 + "duration": 5.237, + "startTime": 1402.701 }, { - "duration": 71.576, - "startTime": 448.653 + "duration": 5.297, + "startTime": 1409.191 }, { - "duration": 18.663, - "startTime": 523.825 + "duration": 6.938, + "startTime": 1414.758 }, { - "duration": 6.399, - "startTime": 567.281 + "duration": 5.552, + "startTime": 1421.78 + }, + { + "duration": 5.273, + "startTime": 1427.386 + }, + { + "duration": 10.946, + "startTime": 1432.694 + }, + { + "duration": 10.336, + "startTime": 1448.261 + }, + { + "duration": 67.03, + "startTime": 1472.245 + }, + { + "duration": 19.066, + "startTime": 1564.793 + }, + { + "duration": 6.321, + "startTime": 1592.9 + }, + { + "duration": 6.521, + "startTime": 1975.956 + }, + { + "duration": 7.579, + "startTime": 2244.181 } ] } @@ -1536,7 +1586,7 @@ "description": "Collects all available metrics.", "score": 1, "scoreDisplayMode": "informative", - "numericValue": 3393, + "numericValue": 2986, "numericUnit": "millisecond", "details": { "type": "debugdata", @@ -1544,43 +1594,43 @@ { "firstContentfulPaint": 2257, "largestContentfulPaint": 2557, - "interactive": 3393, - "speedIndex": 2257, - "totalBlockingTime": 135, - "maxPotentialFID": 143, - "cumulativeLayoutShift": 0.522984, - "cumulativeLayoutShiftMainFrame": 0.522984, - "lcpLoadDelay": 1170, - "lcpLoadDuration": 1440, + "interactive": 2986, + "speedIndex": 3144, + "totalBlockingTime": 79, + "maxPotentialFID": 134, + "cumulativeLayoutShift": 0.52251, + "cumulativeLayoutShiftMainFrame": 0.52251, + "lcpLoadDelay": 607, + "lcpLoadDuration": 607, "timeToFirstByte": 607, "observedTimeOrigin": 0, - "observedTimeOriginTs": 841304437463, + "observedTimeOriginTs": 848763428235, "observedNavigationStart": 0, - "observedNavigationStartTs": 841304437463, - "observedFirstPaint": 162, - "observedFirstPaintTs": 841304599637, - "observedFirstContentfulPaint": 162, - "observedFirstContentfulPaintTs": 841304599637, - "observedFirstContentfulPaintAllFrames": 162, - "observedFirstContentfulPaintAllFramesTs": 841304599637, - "observedLargestContentfulPaint": 162, - "observedLargestContentfulPaintTs": 841304599637, - "observedLargestContentfulPaintAllFrames": 162, - "observedLargestContentfulPaintAllFramesTs": 841304599637, - "observedTraceEnd": 2867, - "observedTraceEndTs": 841307304919, - "observedLoad": 187, - "observedLoadTs": 841304624822, - "observedDomContentLoaded": 186, - "observedDomContentLoadedTs": 841304623881, - "observedCumulativeLayoutShift": 0.522984, - "observedCumulativeLayoutShiftMainFrame": 0.522984, - "observedFirstVisualChange": 162, - "observedFirstVisualChangeTs": 841304599463, - "observedLastVisualChange": 1106, - "observedLastVisualChangeTs": 841305543463, - "observedSpeedIndex": 389, - "observedSpeedIndexTs": 841304826017 + "observedNavigationStartTs": 848763428235, + "observedFirstPaint": 1214, + "observedFirstPaintTs": 848764641886, + "observedFirstContentfulPaint": 1214, + "observedFirstContentfulPaintTs": 848764641886, + "observedFirstContentfulPaintAllFrames": 1214, + "observedFirstContentfulPaintAllFramesTs": 848764641886, + "observedLargestContentfulPaint": 1229, + "observedLargestContentfulPaintTs": 848764656744, + "observedLargestContentfulPaintAllFrames": 1229, + "observedLargestContentfulPaintAllFramesTs": 848764656744, + "observedTraceEnd": 3565, + "observedTraceEndTs": 848766993345, + "observedLoad": 219, + "observedLoadTs": 848763647629, + "observedDomContentLoaded": 218, + "observedDomContentLoadedTs": 848763646718, + "observedCumulativeLayoutShift": 0.52251, + "observedCumulativeLayoutShiftMainFrame": 0.52251, + "observedFirstVisualChange": 1214, + "observedFirstVisualChangeTs": 848764642235, + "observedLastVisualChange": 2283, + "observedLastVisualChangeTs": 848765711235, + "observedSpeedIndex": 1472, + "observedSpeedIndexTs": 848764900571 }, { "lcpInvalidated": false @@ -1617,20 +1667,14 @@ { "resourceType": "total", "label": "Total", - "requestCount": 29, - "transferSize": 1289494 - }, - { - "resourceType": "image", - "label": "Image", - "requestCount": 9, - "transferSize": 906534 + "requestCount": 30, + "transferSize": 400217 }, { "resourceType": "script", "label": "Script", "requestCount": 10, - "transferSize": 217210 + "transferSize": 207018 }, { "resourceType": "font", @@ -1639,16 +1683,16 @@ "transferSize": 97428 }, { - "resourceType": "media", - "label": "Media", - "requestCount": 1, - "transferSize": 41052 + "resourceType": "image", + "label": "Image", + "requestCount": 10, + "transferSize": 68126 }, { "resourceType": "stylesheet", "label": "Stylesheet", "requestCount": 1, - "transferSize": 22356 + "transferSize": 22386 }, { "resourceType": "document", @@ -1662,6 +1706,12 @@ "requestCount": 3, "transferSize": 2164 }, + { + "resourceType": "media", + "label": "Media", + "requestCount": 1, + "transferSize": 345 + }, { "resourceType": "third-party", "label": "Third-party", @@ -1795,9 +1845,9 @@ "description": "Lists the longest tasks on the main thread, useful for identifying worst contributors to input delay. [Learn how to avoid long main-thread tasks](https://web.dev/articles/optimize-long-tasks)", "score": 1, "scoreDisplayMode": "informative", - "displayValue": "8 long tasks found", + "displayValue": "7 long tasks found", "metricSavings": { - "TBT": 150 + "TBT": 100 }, "details": { "type": "table", @@ -1823,43 +1873,38 @@ "items": [ { "url": "https://mim4u.org/js/motion-BypEwiDP.js", - "duration": 143, - "startTime": 3255.506 - }, - { - "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", - "duration": 142, - "startTime": 3006.506 + "duration": 134, + "startTime": 3203.4135 }, { "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", "duration": 124, - "startTime": 3606.506 - }, - { - "url": "Unattributable", - "duration": 87.99999999999989, - "startTime": 955.506 + "startTime": 3007.4135 }, { "url": "https://mim4u.org/", - "duration": 76, - "startTime": 818.506 - }, - { - "url": "https://mim4u.org/", - "duration": 73, - "startTime": 1043.5059999999999 + "duration": 97, + "startTime": 840.4135 }, { "url": "Unattributable", - "duration": 61, - "startTime": 894.506 + "duration": 79.00000000000011, + "startTime": 990.4135 }, { - "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", - "duration": 60, - "startTime": 3195.506 + "url": "https://mim4u.org/", + "duration": 74, + "startTime": 1069.4135 + }, + { + "url": "Unattributable", + "duration": 57, + "startTime": 757.4135 + }, + { + "url": "Unattributable", + "duration": 53, + "startTime": 937.4135 } ], "sortedBy": [ @@ -1873,66 +1918,61 @@ "urls": [ "https://mim4u.org/js/motion-BypEwiDP.js", "https://mim4u.org/js/vendor-DEQ385Nk.js", - "Unattributable", - "https://mim4u.org/" + "https://mim4u.org/", + "Unattributable" ], "tasks": [ { "urlIndex": 0, - "startTime": 3255.5, - "duration": 143, - "other": 143, + "startTime": 3203.4, + "duration": 134, + "other": 134, "paintCompositeRender": 0, "scriptEvaluation": 0, "styleLayout": 0 }, { "urlIndex": 1, - "startTime": 3006.5, - "duration": 142, - "other": 142 - }, - { - "urlIndex": 1, - "startTime": 3606.5, + "startTime": 3007.4, "duration": 124, "other": 124 }, { "urlIndex": 2, - "startTime": 955.5, - "duration": 88, - "other": 88, - "scriptEvaluation": 0 - }, - { - "urlIndex": 3, - "startTime": 818.5, - "duration": 76, - "other": 76, + "startTime": 840.4, + "duration": 97, + "other": 97, "paintCompositeRender": 0, "scriptEvaluation": 0, "styleLayout": 0 }, { "urlIndex": 3, - "startTime": 1043.5, - "duration": 73, - "other": 73, + "startTime": 990.4, + "duration": 79, + "other": 79, + "scriptEvaluation": 0 + }, + { + "urlIndex": 2, + "startTime": 1069.4, + "duration": 74, + "other": 74, "paintCompositeRender": 0, "styleLayout": 0 }, { - "urlIndex": 2, - "startTime": 894.5, - "duration": 61, - "other": 61 + "urlIndex": 3, + "startTime": 757.4, + "duration": 57, + "other": 57, + "scriptEvaluation": 0 }, { - "urlIndex": 1, - "startTime": 3195.5, - "duration": 60, - "other": 60 + "urlIndex": 3, + "startTime": 937.4, + "duration": 53, + "other": 53 } ] } @@ -2221,19 +2261,19 @@ "name": "https://mim4u.org/assets/index-BvGQIY6i.js", "resourceBytes": 123212, "encodedBytes": 49152, - "unusedBytes": 74952 + "unusedBytes": 74995 }, { "name": "https://mim4u.org/js/motion-BypEwiDP.js", "resourceBytes": 115837, "encodedBytes": 53490, - "unusedBytes": 64648 + "unusedBytes": 68223 }, { "name": "https://mim4u.org/js/vendor-DEQ385Nk.js", "resourceBytes": 139182, "encodedBytes": 63864, - "unusedBytes": 39910 + "unusedBytes": 45163 }, { "name": "https://mim4u.org/js/utils-BS-V5r_j.js", @@ -2256,25 +2296,25 @@ { "name": "https://mim4u.org/js/HomePageRoute-DP9kcacJ.js", "resourceBytes": 17869, - "encodedBytes": 6298, + "encodedBytes": 0, "unusedBytes": 0 }, { "name": "https://mim4u.org/js/MimChatAgent-DjZzDJ1t.js", "resourceBytes": 814, - "encodedBytes": 625, + "encodedBytes": 0, "unusedBytes": 0 }, { "name": "https://mim4u.org/js/WhoWeServe-CY2E46Dh.js", "resourceBytes": 1372, - "encodedBytes": 754, + "encodedBytes": 0, "unusedBytes": 0 }, { "name": "https://mim4u.org/js/PartnerStrip-CuG7sPqV.js", "resourceBytes": 1313, - "encodedBytes": 814, + "encodedBytes": 0, "unusedBytes": 0 } ] @@ -3378,9 +3418,9 @@ "description": "Large network payloads cost users real money and are highly correlated with long load times. [Learn how to reduce payload sizes](https://developer.chrome.com/docs/lighthouse/performance/total-byte-weight/).", "score": 1, "scoreDisplayMode": "metricSavings", - "numericValue": 1289494, + "numericValue": 400217, "numericUnit": "byte", - "displayValue": "Total size was 1,259 KiB", + "displayValue": "Total size was 391 KiB", "details": { "type": "table", "headings": [ @@ -3396,26 +3436,6 @@ } ], "items": [ - { - "url": "https://mim4u.org/photos/campaigns/healing-minds-wellness.jpg", - "totalBytes": 205323 - }, - { - "url": "https://mim4u.org/photos/campaigns/ptsd-awareness-june.jpg", - "totalBytes": 203716 - }, - { - "url": "https://mim4u.org/photos/community/optimized/outreach-03-600.webp", - "totalBytes": 119410 - }, - { - "url": "https://mim4u.org/photos/community/optimized/outreach-04-600.webp", - "totalBytes": 93642 - }, - { - "url": "https://mim4u.org/photos/community/optimized/outreach-02-600.webp", - "totalBytes": 86592 - }, { "url": "https://mim4u.org/photos/community/optimized/outreach-01-960.webp", "totalBytes": 68126 @@ -3424,17 +3444,37 @@ "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", "totalBytes": 64290 }, - { - "url": "https://mim4u.org/brand/logo-horizontal-nav.svg", - "totalBytes": 57040 - }, { "url": "https://mim4u.org/js/motion-BypEwiDP.js", "totalBytes": 53916 }, { - "url": "https://mim4u.org/photos/community/community-outreach-2026-06-11-poster.jpg", - "totalBytes": 52772 + "url": "https://mim4u.org/assets/index-BvGQIY6i.js", + "totalBytes": 49578 + }, + { + "url": "https://mim4u.org/js/utils-BS-V5r_j.js", + "totalBytes": 31741 + }, + { + "url": "https://mim4u.org/assets/inter-latin-600-normal-LgqL8muc.woff2", + "totalBytes": 24906 + }, + { + "url": "https://mim4u.org/assets/inter-latin-500-normal-Cerq10X2.woff2", + "totalBytes": 24726 + }, + { + "url": "https://mim4u.org/assets/inter-latin-400-normal-C38fXH4l.woff2", + "totalBytes": 24118 + }, + { + "url": "https://mim4u.org/assets/playfair-display-latin-700-normal-CuDiGg7c.woff2", + "totalBytes": 23678 + }, + { + "url": "https://mim4u.org/assets/index-C6x0s7Gh.css", + "totalBytes": 22386 } ], "sortedBy": [ @@ -3514,12 +3554,12 @@ "description": "Reduce unused rules from stylesheets and defer CSS not used for above-the-fold content to decrease bytes consumed by network activity. [Learn how to reduce unused CSS](https://developer.chrome.com/docs/lighthouse/performance/unused-css-rules/).", "score": 0, "scoreDisplayMode": "metricSavings", - "numericValue": 150, + "numericValue": 0, "numericUnit": "millisecond", "displayValue": "Est savings of 16 KiB", "metricSavings": { "FCP": 150, - "LCP": 150 + "LCP": 0 }, "details": { "type": "opportunity", @@ -3543,13 +3583,13 @@ "items": [ { "url": "https://mim4u.org/assets/index-C6x0s7Gh.css", - "wastedBytes": 16309, - "wastedPercent": 74.33308592385609, - "totalBytes": 21940 + "wastedBytes": 16499, + "wastedPercent": 75.09605309116311, + "totalBytes": 21970 } ], - "overallSavingsMs": 150, - "overallSavingsBytes": 16309, + "overallSavingsMs": 0, + "overallSavingsBytes": 16499, "sortedBy": [ "wastedBytes" ], @@ -3557,7 +3597,7 @@ "type": "debugdata", "metricSavings": { "FCP": 150, - "LCP": 150 + "LCP": 0 } } }, @@ -3569,12 +3609,12 @@ "description": "Reduce unused JavaScript and defer loading scripts until they are required to decrease bytes consumed by network activity. [Learn how to reduce unused JavaScript](https://developer.chrome.com/docs/lighthouse/performance/unused-javascript/).", "score": 0, "scoreDisplayMode": "metricSavings", - "numericValue": 300, + "numericValue": 450, "numericUnit": "millisecond", - "displayValue": "Est savings of 79 KiB", + "displayValue": "Est savings of 101 KiB", "metricSavings": { - "FCP": 300, - "LCP": 300 + "FCP": 450, + "LCP": 450 }, "details": { "type": "opportunity", @@ -3606,35 +3646,41 @@ } ], "items": [ - { - "url": "https://mim4u.org/assets/index-BvGQIY6i.js", - "totalBytes": 49097, - "wastedBytes": 29866, - "wastedPercent": 60.83173716845762 - }, { "url": "https://mim4u.org/js/motion-BypEwiDP.js", "totalBytes": 53490, - "wastedBytes": 29852, - "wastedPercent": 55.80945639130847 + "wastedBytes": 31503, + "wastedPercent": 58.89568963284616 + }, + { + "url": "https://mim4u.org/assets/index-BvGQIY6i.js", + "totalBytes": 49097, + "wastedBytes": 29883, + "wastedPercent": 60.86663636658767 }, { "url": "https://mim4u.org/js/utils-BS-V5r_j.js", "totalBytes": 31311, "wastedBytes": 20912, "wastedPercent": 66.78872260977798 + }, + { + "url": "https://mim4u.org/js/vendor-DEQ385Nk.js", + "totalBytes": 63864, + "wastedBytes": 20723, + "wastedPercent": 32.448879883893035 } ], - "overallSavingsMs": 300, - "overallSavingsBytes": 80630, + "overallSavingsMs": 450, + "overallSavingsBytes": 103021, "sortedBy": [ "wastedBytes" ], "debugData": { "type": "debugdata", "metricSavings": { - "FCP": 300, - "LCP": 300 + "FCP": 450, + "LCP": 450 } } }, @@ -3842,18 +3888,6 @@ "displayString": "Newly Available (2024-10-17)" } }, - { - "featureId": { - "type": "link", - "text": "text-wrap-balance", - "url": "https://webstatus.dev/features/text-wrap-balance" - }, - "displayStatus": { - "type": "baseline-status", - "status": "low", - "displayString": "Newly Available (2024-05-13)" - } - }, { "featureId": { "type": "link", @@ -3866,6 +3900,18 @@ "displayString": "Newly Available (2023-12-07)" } }, + { + "featureId": { + "type": "link", + "text": "text-wrap-balance", + "url": "https://webstatus.dev/features/text-wrap-balance" + }, + "displayStatus": { + "type": "baseline-status", + "status": "low", + "displayString": "Newly Available (2024-05-13)" + } + }, { "featureId": { "type": "link", @@ -3904,6 +3950,25 @@ "column": 104904 } }, + { + "featureId": { + "type": "link", + "text": "aborting", + "url": "https://webstatus.dev/features/aborting" + }, + "displayStatus": { + "type": "baseline-status", + "status": "high", + "displayString": "Widely Available (2019-03-25)" + }, + "source": { + "type": "source-location", + "url": "https://mim4u.org/js/motion-BypEwiDP.js", + "urlProvider": "network", + "line": 0, + "column": 22627 + } + }, { "featureId": { "type": "link", @@ -3942,25 +4007,6 @@ "column": 21616 } }, - { - "featureId": { - "type": "link", - "text": "aborting", - "url": "https://webstatus.dev/features/aborting" - }, - "displayStatus": { - "type": "baseline-status", - "status": "high", - "displayString": "Widely Available (2019-03-25)" - }, - "source": { - "type": "source-location", - "url": "https://mim4u.org/js/motion-BypEwiDP.js", - "urlProvider": "network", - "line": 0, - "column": 22627 - } - }, { "featureId": { "type": "link", @@ -4045,18 +4091,6 @@ "displayString": "Widely Available (2021-01-21)" } }, - { - "featureId": { - "type": "link", - "text": "webp", - "url": "https://webstatus.dev/features/webp" - }, - "displayStatus": { - "type": "baseline-status", - "status": "high", - "displayString": "Widely Available (2020-09-16)" - } - }, { "featureId": { "type": "link", @@ -4069,6 +4103,18 @@ "displayString": "Widely Available (2017-10-17)" } }, + { + "featureId": { + "type": "link", + "text": "webp", + "url": "https://webstatus.dev/features/webp" + }, + "displayStatus": { + "type": "baseline-status", + "status": "high", + "displayString": "Widely Available (2020-09-16)" + } + }, { "featureId": { "type": "link", @@ -4292,7 +4338,7 @@ "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more about caching](https://developer.chrome.com/docs/performance/insights/cache).", "score": 0, "scoreDisplayMode": "metricSavings", - "displayValue": "Est savings of 359 KiB", + "displayValue": "Est savings of 67 KiB", "metricSavings": { "FCP": 0, "LCP": 300 @@ -4320,30 +4366,36 @@ } ], "items": [ - { - "url": "https://mim4u.org/photos/community/optimized/outreach-03-600.webp", - "cacheLifetimeMs": 0, - "totalBytes": 119410, - "wastedBytes": 119410 - }, - { - "url": "https://mim4u.org/photos/community/optimized/outreach-04-600.webp", - "cacheLifetimeMs": 0, - "totalBytes": 93642, - "wastedBytes": 93642 - }, - { - "url": "https://mim4u.org/photos/community/optimized/outreach-02-600.webp", - "cacheLifetimeMs": 0, - "totalBytes": 86592, - "wastedBytes": 86592 - }, { "url": "https://mim4u.org/photos/community/optimized/outreach-01-960.webp", "cacheLifetimeMs": 0, "totalBytes": 68126, "wastedBytes": 68126 }, + { + "url": "https://mim4u.org/photos/community/optimized/outreach-03-600.webp", + "cacheLifetimeMs": 0, + "totalBytes": 0, + "wastedBytes": 0 + }, + { + "url": "https://mim4u.org/photos/community/optimized/outreach-04-600.webp", + "cacheLifetimeMs": 0, + "totalBytes": 0, + "wastedBytes": 0 + }, + { + "url": "https://mim4u.org/photos/community/optimized/outreach-02-600.webp", + "cacheLifetimeMs": 0, + "totalBytes": 0, + "wastedBytes": 0 + }, + { + "url": "https://mim4u.org/photos/community/optimized/outreach-01-960.webp", + "cacheLifetimeMs": 0, + "totalBytes": 0, + "wastedBytes": 0 + }, { "url": "https://mim4u.org/photos/community/community-outreach-2026-06-11.mp4", "cacheLifetimeMs": 0, @@ -4359,7 +4411,7 @@ ], "debugData": { "type": "debugdata", - "wastedBytes": 367770 + "wastedBytes": 68126 } }, "guidanceLevel": 3 @@ -4404,26 +4456,7 @@ "type": "text", "value": "Total" }, - "score": 0.522984 - }, - { - "node": { - "type": "node", - "lhId": "page-3-FOOTER", - "path": "1,HTML,1,BODY,2,DIV,0,DIV,4,FOOTER", - "selector": "body > div#root > div.min-h-screen > footer.relative", - "boundingRect": { - "top": 12224, - "bottom": 13287, - "left": 0, - "right": 412, - "width": 412, - "height": 1063 - }, - "snippet": "