feat: allow apps to generate their own vapid key pair

This commit is contained in:
Nicolas Meienberger 2023-04-20 08:58:05 +02:00 committed by Nicolas Meienberger
parent 3e9e7ce808
commit 4dd01eb31b
6 changed files with 112 additions and 9 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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'}

View file

@ -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<typeof formFieldSchema>;
*
* @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<string, string>} - 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<AppInfo[]>} - 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}`);
}

View file

@ -15,6 +15,7 @@ interface IProps {
domain?: string;
exposable?: boolean;
forceExpose?: boolean;
generateVapidKeys?: boolean;
supportedArchitectures?: Architecture[];
}
@ -34,7 +35,17 @@ const createAppConfig = (props?: Partial<AppInfo>) =>
});
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) {

View file

@ -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,
};
};