From 4dd01eb31b47b5b4445d6071d851f9cc91e52cf5 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 20 Apr 2023 08:58:05 +0200 Subject: [PATCH] feat: allow apps to generate their own vapid key pair --- .eslintrc.js | 1 + package.json | 2 + pnpm-lock.yaml | 70 ++++++++++++++++++++++++ src/server/services/apps/apps.helpers.ts | 22 +++++--- src/server/tests/apps.factory.ts | 14 ++++- src/server/utils/env-generation.ts | 12 ++++ 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 src/server/utils/env-generation.ts diff --git a/.eslintrc.js b/.eslintrc.js index 59ebddc4..fcb1c806 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -37,6 +37,7 @@ module.exports = { 'no-underscore-dangle': 0, 'arrow-body-style': 0, 'class-methods-use-this': 0, + 'jsdoc/require-returns': 0, }, globals: { JSX: true, diff --git a/package.json b/package.json index 5b655efa..9534f6f0 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "tslib": "^2.4.0", "uuid": "^9.0.0", "validator": "^13.7.0", + "web-push": "^3.5.0", "winston": "^3.7.2", "zod": "^3.21.4", "zustand": "^4.3.6" @@ -100,6 +101,7 @@ "@types/testing-library__jest-dom": "^5.14.5", "@types/uuid": "^9.0.1", "@types/validator": "^13.7.14", + "@types/web-push": "^3.3.2", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", "dotenv-cli": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e21f5323..3a57e9c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ dependencies: validator: specifier: ^13.7.0 version: 13.9.0 + web-push: + specifier: ^3.5.0 + version: 3.5.0 winston: specifier: ^3.7.2 version: 3.8.2 @@ -216,6 +219,9 @@ devDependencies: '@types/validator': specifier: ^13.7.14 version: 13.7.14 + '@types/web-push': + specifier: ^3.3.2 + version: 3.3.2 '@typescript-eslint/eslint-plugin': specifier: ^5.55.0 version: 5.55.0(@typescript-eslint/parser@5.55.0)(eslint@8.36.0)(typescript@5.0.2) @@ -2584,6 +2590,12 @@ packages: resolution: {integrity: sha512-J6OAed6rhN6zyqL9Of6ZMamhlsOEU/poBVvbHr/dKOYKTeuYYMlDkMv+b6UUV0o2i0tw73cgyv/97WTWaUl0/g==} dev: true + /@types/web-push@3.3.2: + resolution: {integrity: sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw==} + dependencies: + '@types/node': 18.15.3 + dev: true + /@types/yargs-parser@21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -3055,6 +3067,15 @@ packages: get-intrinsic: 1.2.0 dev: true + /asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + dependencies: + bn.js: 4.12.0 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + dev: false + /ast-types-flow@0.0.7: resolution: {integrity: sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==} dev: true @@ -3185,6 +3206,10 @@ packages: inherits: 2.0.4 readable-stream: 3.6.0 + /bn.js@4.12.0: + resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} + dev: false + /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -5468,6 +5493,13 @@ packages: - supports-color dev: true + /http_ece@1.1.0: + resolution: {integrity: sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==} + engines: {node: '>=4'} + dependencies: + urlsafe-base64: 1.0.0 + dev: false + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6506,6 +6538,14 @@ packages: safe-buffer: 5.2.1 dev: false + /jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + /jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} dependencies: @@ -6513,6 +6553,13 @@ packages: safe-buffer: 5.2.1 dev: false + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + dev: false + /kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -7120,6 +7167,10 @@ packages: engines: {node: '>=4'} dev: true + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -9098,6 +9149,10 @@ packages: requires-port: 1.0.0 dev: true + /urlsafe-base64@1.0.0: + resolution: {integrity: sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA==} + dev: false + /use-callback-ref@1.3.0(@types/react@18.0.28)(react@18.2.0): resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} engines: {node: '>=10'} @@ -9259,6 +9314,21 @@ packages: '@zxing/text-encoding': 0.9.0 dev: true + /web-push@3.5.0: + resolution: {integrity: sha512-JC0V9hzKTqlDYJ+LTZUXtW7B175qwwaqzbbMSWDxHWxZvd3xY0C2rcotMGDavub2nAAFw+sXTsqR65/KY2A5AQ==} + engines: {node: '>= 6'} + hasBin: true + dependencies: + asn1.js: 5.4.1 + http_ece: 1.1.0 + https-proxy-agent: 5.0.1 + jws: 4.0.0 + minimist: 1.2.8 + urlsafe-base64: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /web-streams-polyfill@3.2.1: resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} engines: {node: '>= 8'} diff --git a/src/server/services/apps/apps.helpers.ts b/src/server/services/apps/apps.helpers.ts index bbdf625e..b2dab717 100644 --- a/src/server/services/apps/apps.helpers.ts +++ b/src/server/services/apps/apps.helpers.ts @@ -2,6 +2,7 @@ import crypto from 'crypto'; import fs from 'fs-extra'; import { z } from 'zod'; import { App } from '@/server/db/schema'; +import { generateVapidKeys } from '@/server/utils/env-generation'; import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers'; import { APP_CATEGORIES, FIELD_TYPES } from './apps.types'; import { getConfig } from '../../core/TipiConfig'; @@ -37,6 +38,7 @@ export const appInfoSchema = z.object({ source: z.string(), website: z.string().optional(), force_expose: z.boolean().optional().default(false), + generate_vapid_keys: z.boolean().optional().default(false), categories: z .nativeEnum(APP_CATEGORIES) .array() @@ -64,7 +66,6 @@ export type FormField = z.infer; * * @param {string} appName - The name of the app. * @throws Will throw an error if the app has an invalid config.json file or if the current system architecture is not supported by the app. - * @returns {AppInfo} - parsed app config data */ export const checkAppRequirements = (appName: string) => { const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`); @@ -86,7 +87,6 @@ export const checkAppRequirements = (appName: string) => { * It reads the file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value. * * @param {string} appName - The name of the app. - * @returns {Map} - A Map containing the key-value pairs of the environment variables. */ export const getEnvMap = (appName: string) => { const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString(); @@ -138,7 +138,6 @@ export const checkEnvFile = (appName: string) => { * * @param {string} name - A name used as input for the hash algorithm. * @param {number} length - The desired length of the random string. - * @returns {string} - A random string of the provided length. */ const getEntropy = (name: string, length: number) => { const hash = crypto.createHash('sha256'); @@ -183,6 +182,17 @@ export const generateEnvFile = (app: App) => { let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`; const envMap = getEnvMap(app.id); + if (parsedConfig.data.generate_vapid_keys) { + if (envMap.has('VAPID_PUBLIC_KEY') && envMap.has('VAPID_PRIVATE_KEY')) { + envFile += `VAPID_PUBLIC_KEY=${envMap.get('VAPID_PUBLIC_KEY')}\n`; + envFile += `VAPID_PRIVATE_KEY=${envMap.get('VAPID_PRIVATE_KEY')}\n`; + } else { + const vapidKeys = generateVapidKeys(); + envFile += `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}\n`; + envFile += `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`; + } + } + parsedConfig.data.form_fields.forEach((field) => { const formValue = castAppConfig(app.config)[field.env_variable]; const envVar = field.env_variable; @@ -223,8 +233,6 @@ export const generateEnvFile = (app: App) => { This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app, parses the config file, filters out any apps that are not available and returns an array of app information. If the config.json file is invalid, it logs an error message. - - @returns {Promise} - Returns a promise that resolves with an array of available apps' information. */ export const getAvailableApps = async () => { const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`); @@ -259,7 +267,6 @@ export const getAvailableApps = async () => { * If the app is not found, it returns null. * * @param {string} id - The app id. - * @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file. */ export const getUpdateInfo = (id: string) => { const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`); @@ -284,7 +291,6 @@ export const getUpdateInfo = (id: string) => { * * @param {string} id - The app id. * @param {App['status']} [status] - The app status. - * @returns {AppInfo | null} - Returns an object with app information or null if the app is not found. */ export const getAppInfo = (id: string, status?: App['status']) => { try { @@ -327,7 +333,7 @@ export const getAppInfo = (id: string, status?: App['status']) => { * @param {boolean} [cleanup=false] - A flag indicating whether to cleanup the app folder before ensuring its existence. * @throws Will throw an error if the app folder cannot be copied from the repository */ -export const ensureAppFolder = (appName: string, cleanup = false) => { +export const ensureAppFolder = (appName: string, cleanup = false): void => { if (cleanup && fileExists(`/runtipi/apps/${appName}`)) { deleteFolder(`/runtipi/apps/${appName}`); } diff --git a/src/server/tests/apps.factory.ts b/src/server/tests/apps.factory.ts index cad046e5..8fea5322 100644 --- a/src/server/tests/apps.factory.ts +++ b/src/server/tests/apps.factory.ts @@ -15,6 +15,7 @@ interface IProps { domain?: string; exposable?: boolean; forceExpose?: boolean; + generateVapidKeys?: boolean; supportedArchitectures?: Architecture[]; } @@ -34,7 +35,17 @@ const createAppConfig = (props?: Partial) => }); const createApp = async (props: IProps, database: TestDatabase) => { - const { installed = false, status = 'running', randomField = false, exposed = false, domain = null, exposable = false, supportedArchitectures, forceExpose = false } = props; + const { + installed = false, + status = 'running', + randomField = false, + exposed = false, + domain = null, + exposable = false, + supportedArchitectures, + forceExpose = false, + generateVapidKeys = false, + } = props; const categories = Object.values(APP_CATEGORIES); @@ -65,6 +76,7 @@ const createApp = async (props: IProps, database: TestDatabase) => { version: String(faker.datatype.number({ min: 1, max: 10 })), https: false, no_gui: false, + generate_vapid_keys: generateVapidKeys, }; if (randomField) { diff --git a/src/server/utils/env-generation.ts b/src/server/utils/env-generation.ts new file mode 100644 index 00000000..25993d15 --- /dev/null +++ b/src/server/utils/env-generation.ts @@ -0,0 +1,12 @@ +import webpush from 'web-push'; + +/** + * Generate VAPID keys + */ +export const generateVapidKeys = () => { + const vapidKeys = webpush.generateVAPIDKeys(); + return { + publicKey: vapidKeys.publicKey, + privateKey: vapidKeys.privateKey, + }; +};