diff --git a/README.md b/README.md index 2f665cb5..aeb848b8 100644 --- a/README.md +++ b/README.md @@ -52,36 +52,53 @@ This application accepts & depends upon these variables to be passed through environment: ``` - - SINGLE_SIGN_ON: Flag to bypass application level authentication (valid values: true/false, default: false) - - LOCAL_HOST: Device url for connect url links (optional; for connect wallet screen) - - DEVICE_DOMAIN_NAME: Device name/IP for lnmessage connect url feature (optional; for connect wallet screen) - - BITCOIN_NODE_IP: IP address of bitcoin node container (required) - - BITCOIN_NETWORK: Bitcoin network type (optional; for entrypoint.sh; valid values: bitcoin/signet/testnet/regtest) - - APP_CONFIG_DIR: Path for cln-application's configuration file (required; config.json) - - APP_MODE: Mode for logging and other settings (valid values: production/development/testing, default: production) - - APP_CONNECT: Choose how to connect to CLN (valid values: COMMANDO/REST/GRPC, default: COMMANDO) - - APP_PROTOCOL: Protocol on which the application will be served (valid values: http/https, default: http) - - APP_IP: IP address of this application (cln-application) container (required) - - APP_PORT: Port on which this application should be served (required) - - LIGHTNING_IP: IP address of Core lightning node container (required) - - LIGHTNING_PATH: Path for core lightning (optional; required for entrypoint.sh) - - HIDDEN_SERVICE_URL: REST hidden service url (optional; for connect wallet screen; Used for Tor Domain also) - - COMMANDO_CONFIG: Full Path including file name for commando auth with PUBKEY & RUNE (required) - - LIGHTNING_WEBSOCKET_PROTOCOL: Core lightning's web socket or web socket proxy (valid values: ws/wss, default: ws) - - LIGHTNING_WEBSOCKET_PORT: Core lightning's websocket port (required with default APP_CONNECT; from cln's config.json; starting with `bind-addr=ws:`/`wss-bind-addr`) - - LIGHTNING_REST_PROTOCOL: Protocol on which REST is served (valid values: http/https, default: https) - - LIGHTNING_REST_PORT: REST server port (required if APP_CONNECT is REST) - - LIGHTNING_CERTS_DIR: Path for core lightning certificates (Required the PROTOCOL is 'https/wss') - - LIGHTNING_GRPC_PROTOCOL: Core lightning's GRPC protocol (valid values: http/https, default: http) - - LIGHTNING_GRPC_PORT: Core lightning's GRPC port (Required if APP_CONNECT is GRPC) + - BITCOIN_HOST: Hostname/IP address of bitcoin node container (configurable to run lightningd with `--bitcoin-rpcconnect`, default: `localhost`) + - BITCOIN_NETWORK: Bitcoin network type (for entrypoint.sh and lightningd with `--network`; valid values: bitcoin/signet/testnet/regtest; default: `bitcoin`) + + - APP_SINGLE_SIGN_ON: Flag to bypass application level authentication (valid values: true/false, default: false) + - APP_PROTOCOL: Protocol on which the application will be served (valid values: http/https, default: `http`) + - APP_HOST: Hostname/IP address of cln-application's container (default: `localhost`) + - APP_PORT: Port on which this application should be served (default: `2103`) + + - APP_CONFIG_FILE: Path for cln-application's configuration file (default: `./config.json`) + - APP_LOG_FILE: Path for cln-application's log file (default: `./application-cln.log`) + - APP_MODE: Mode for logging and other settings (valid values: production/development/testing, default: `production`) + - APP_CONNECT: Choose how to connect to CLN (valid values: COMMANDO/REST/GRPC, default: `COMMANDO`) + + - LIGHTNING_DATA_DIR: Path for core lightning (used by entrypoint.sh, default: ``) + - LIGHTNING_HOST: IP address of Core lightning node container (used for `COMMANDO` APP_CONNECT, default: `localhost`) + - LIGHTNING_TOR_HOST: REST hidden service url (default: ``) + + - LIGHTNING_VARS_FILE: Full Path including the file name for connection auth with LIGHTNING_PUBKEY & LIGHTNING_RUNE (defult: `./.commando-env`) + - LIGHTNING_WS_PROTOCOL: Core lightning's web socket is serving on ws or serving via WSSProxy (valid values: ws/wss, default: `ws`) + - LIGHTNING_WS_PORT: Core lightning's websocket port (used by `COMMANDO` APP_CONNECT; with `bind-addr=ws:`/`wss-bind-addr` in CLN config; default: `5001`) + - LIGHTNING_WS_CLIENT_KEY_FILE: Client key file path including file name for websocket TLS authentication (used by `COMMANDO` APP_CONNECT and `wss` LIGHTNING_WS_PROTOCOL; default: `./client-key.pem`) + - LIGHTNING_WS_CLIENT_CERT_FILE: Client certificate file path including file name for websocket TLS authentication (used by `COMMANDO` APP_CONNECT and `wss` LIGHTNING_WS_PROTOCOL; default: `./client.pem`) + - LIGHTNING_WS_CA_CERT_FILE: CA certificate file path including file name for websocket TLS authentication (default: `./ca.pem`) + + - LIGHTNING_REST_PROTOCOL: Protocol on which REST is served (valid values: http/https, default: `https`) + - LIGHTNING_REST_HOST: IP address/hostname of Core Lightning REST interface (used if APP_CONNECT is `REST`, default: `localhost`) + - LIGHTNING_REST_TOR_HOST: Tor hidden service URL for Core Lightning REST interface (default: ``) + - LIGHTNING_REST_PORT: REST server port (used if APP_CONNECT is `REST`; default: `3010`) + - LIGHTNING_REST_CLIENT_KEY_FILE: Client key file path including file name for REST TLS authentication (default: `./client-key.pem`) + - LIGHTNING_REST_CLIENT_CERT_FILE: Client certificate file path including file name for REST TLS authentication (default: `./client.pem`) + - LIGHTNING_REST_CA_CERT_FILE: CA certificate file path including file name for REST TLS authentication (used by `REST` APP_CONNECT and `https` LIGHTNING_REST_PROTOCOL; default: `./ca.pem`) + + - LIGHTNING_GRPC_HOST: IP address/hostname of Core Lightning GRPC interface (used if APP_CONNECT is `GRPC`, default: `localhost`) + - LIGHTNING_GRPC_TOR_HOST: Tor hidden service URL for Core Lightning GRPC interface (default: ``) + - LIGHTNING_GRPC_PORT: Core lightning's GRPC port (used if APP_CONNECT is `GRPC`; default: `9736`) + - LIGHTNING_GRPC_PROTO_PATH: URL to directory containing CLN gRPC protocol definitions (default: `https://github.com/ElementsProject/lightning/tree/master/cln-grpc/proto`) + - LIGHTNING_GRPC_CLIENT_KEY_FILE: Client key file path including file name for GRPC TLS authentication (used by `GRPC` APP_CONNECT; default: `./client-key.pem`) + - LIGHTNING_GRPC_CLIENT_CERT_FILE: Client certificate file path including file name for GRPC TLS authentication (used by `GRPC` APP_CONNECT; default: `./client.pem`) + - LIGHTNING_GRPC_CA_CERT_FILE: CA certificate file path including file name for GRPC TLS authentication (used by `GRPC` APP_CONNECT; default: `./ca.pem`) ``` Set these variables either via terminal OR by env.sh script OR by explicitly loading variables from .env files. - Important Note: Environment variables take precedence over config.json variables. Like `SINGLE_SIGN_ON` will take higher precedence over + Important Note: Environment variables take precedence over config.json variables. Like `APP_SINGLE_SIGN_ON` will take higher precedence over `singleSignOn` from config.json. - ### Application Configuration - This is the config.json file which is required by application's frontend. If the file named `config.json` is missing at `APP_CONFIG_DIR` location, one like below will be auto created: + This is the default `config.json` file which is required by application's frontend. If the file `APP_CONFIG_FILE` is missing at the location, one like below will be auto created: ``` { @@ -97,8 +114,8 @@ - ### Commando Authentication - This application utilizes [lnmessage](https://github.com/aaronbarnardsound/lnmessage) and [commando](https://docs.corelightning.org/reference/lightning-commando) for connecting with core lightning node. The connection is trustless and end-to-end encrypted. Commando manages authentication and authorization through runes, which can grant either full or fine-grained permissions. - - The backend server reads `LIGHTNING_PUBKEY` & `LIGHTNING_RUNE` from the `COMMANDO_CONFIG` file for this communication. - - Values can either be set manually or script `entrypoint.sh` can be used to call `getinfo` and `createrune` methods and save values in `COMMANDO_CONFIG`. + - The backend server reads `LIGHTNING_PUBKEY` & `LIGHTNING_RUNE` from the `LIGHTNING_VARS_FILE` file for this communication. + - Values can either be set manually or script `entrypoint.sh` can be used to call `getinfo` and `createrune` methods and save values in `LIGHTNING_VARS_FILE`. - `entrypoint.sh` can only run for the locally installed lightning. If `cln-application` is running remotely then pubkey and rune can be set manually. - Sample commando config should look like: diff --git a/apps/backend/source/controllers/auth.ts b/apps/backend/source/controllers/auth.ts index 6499cbb1..bbf4bf41 100644 --- a/apps/backend/source/controllers/auth.ts +++ b/apps/backend/source/controllers/auth.ts @@ -8,8 +8,8 @@ import handleError from '../shared/error-handler.js'; import { verifyPassword, isAuthenticated, isValidPassword } from '../shared/utils.js'; import { AuthError } from '../models/errors.js'; -class AuthController { - userLogin(req: Request, res: Response, next: NextFunction) { +export class AuthController { + userLogin = async (req: Request, res: Response, next: NextFunction) => { logger.info('Logging in'); try { const vpRes = verifyPassword(req.body.password); @@ -25,9 +25,9 @@ class AuthController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - userLogout(req: Request, res: Response, next: NextFunction) { + userLogout = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Logging out'); res.clearCookie('token'); @@ -35,9 +35,9 @@ class AuthController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - resetPassword(req: Request, res: Response, next: NextFunction) { + resetPassword = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Resetting password'); const isValid = req.body.isValid; @@ -76,14 +76,14 @@ class AuthController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - isUserAuthenticated(req: Request, res: Response, next: NextFunction) { + isUserAuthenticated = async (req: Request, res: Response, next: NextFunction) => { try { const uaRes = isAuthenticated(req.cookies.token); if (req.body.returnResponse) { // Frontend is asking if user is authenticated or not - if (APP_CONSTANTS.SINGLE_SIGN_ON === 'true') { + if (APP_CONSTANTS.APP_SINGLE_SIGN_ON === 'true') { return res.status(201).json({ isAuthenticated: true, isValidPassword: true }); } else { const vpRes = isValidPassword(); @@ -99,7 +99,7 @@ class AuthController { } } else { // Backend APIs are asking if user is authenticated or not - if (uaRes === true || APP_CONSTANTS.SINGLE_SIGN_ON === 'true') { + if (uaRes === true || APP_CONSTANTS.APP_SINGLE_SIGN_ON === 'true') { return next(); } else { return res.status(401).json({ error: 'Unauthorized user' }); @@ -108,7 +108,5 @@ class AuthController { } catch (error: any) { handleError(error, req, res, next); } - } + }; } - -export default new AuthController(); diff --git a/apps/backend/source/controllers/lightning.ts b/apps/backend/source/controllers/lightning.ts index 46f929b1..16c4da01 100755 --- a/apps/backend/source/controllers/lightning.ts +++ b/apps/backend/source/controllers/lightning.ts @@ -1,16 +1,20 @@ import { Request, Response, NextFunction } from 'express'; import handleError from '../shared/error-handler.js'; -import { CLNService, LightningService } from '../service/lightning.service.js'; +import { LightningService } from '../service/lightning.service.js'; import { logger } from '../shared/logger.js'; import { AppConnect, APP_CONSTANTS } from '../shared/consts.js'; -const clnService: LightningService = CLNService; +export class LightningController { + private clnService: LightningService; -class LightningController { - callMethod(req: Request, res: Response, next: NextFunction) { + constructor(clnService: LightningService) { + this.clnService = clnService; + } + + callMethod = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Calling method: ' + req.body.method); - clnService + this.clnService .call(req.body.method, req.body.params) .then((commandRes: any) => { logger.info( @@ -25,7 +29,7 @@ class LightningController { req.body.method === 'listpeers' ) { // Filter out ln message pubkey from peers list - const lnmPubkey = clnService.getLNMsgPubkey(); + const lnmPubkey = this.clnService.getLNMsgPubkey(); commandRes.peers = commandRes.peers.filter((peer: any) => peer.id !== lnmPubkey); res.status(200).json(commandRes); } else { @@ -44,7 +48,5 @@ class LightningController { } catch (error: any) { return handleError(error, req, res, next); } - } + }; } - -export default new LightningController(); diff --git a/apps/backend/source/controllers/shared.ts b/apps/backend/source/controllers/shared.ts index 5a234869..bc4187d2 100644 --- a/apps/backend/source/controllers/shared.ts +++ b/apps/backend/source/controllers/shared.ts @@ -12,15 +12,24 @@ import { import { logger } from '../shared/logger.js'; import handleError from '../shared/error-handler.js'; import { APIError } from '../models/errors.js'; -import { addServerConfig, refreshEnvVariables } from '../shared/utils.js'; -import { CLNService } from '../service/lightning.service.js'; +import { addServerConfig, setEnvVariables } from '../shared/utils.js'; import { ShowRunes } from '../models/showrunes.type.js'; +import { LightningService } from '../service/lightning.service.js'; -class SharedController { - getApplicationSettings(req: Request, res: Response, next: NextFunction) { +export class SharedController { + private clnService: LightningService; + + constructor(clnService: LightningService) { + this.clnService = clnService; + } + + getApplicationSettings = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Getting Application Settings from ' + APP_CONSTANTS.APP_CONFIG_FILE); if (!fs.existsSync(APP_CONSTANTS.APP_CONFIG_FILE)) { + logger.warning( + `Config file ${APP_CONSTANTS.APP_CONFIG_FILE} not found. Creating default config.`, + ); fs.writeFileSync( APP_CONSTANTS.APP_CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2), @@ -39,9 +48,9 @@ class SharedController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - setApplicationSettings(req: Request, res: Response, next: NextFunction) { + setApplicationSettings = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Updating Application Settings: ' + JSON.stringify(req.body)); const config = JSON.parse(fs.readFileSync(APP_CONSTANTS.APP_CONFIG_FILE, 'utf-8')); @@ -55,19 +64,19 @@ class SharedController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - getWalletConnectSettings(req: Request, res: Response, next: NextFunction) { + getWalletConnectSettings = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Getting Connection Settings'); - refreshEnvVariables(); + setEnvVariables(); res.status(200).json(APP_CONSTANTS); } catch (error: any) { handleError(error, req, res, next); } - } + }; - getFiatRate(req: Request, res: Response, next: NextFunction) { + getFiatRate = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Getting Fiat Rate for: ' + req.params.fiatCurrency); const FIAT_VENUE = FIAT_VENUES.hasOwnProperty(req.params.fiatCurrency) @@ -94,12 +103,12 @@ class SharedController { } catch (error: any) { handleError(error, req, res, next); } - } + }; - async saveInvoiceRune(req: Request, res: Response, next: NextFunction) { + saveInvoiceRune = async (req: Request, res: Response, next: NextFunction) => { try { logger.info('Saving Invoice Rune'); - const showRunes: ShowRunes = await CLNService.call('showrunes', []); + const showRunes: ShowRunes = await this.clnService.call('showrunes', []); const invoiceRune = showRunes.runes.find( rune => rune.restrictions.some(restriction => @@ -109,9 +118,9 @@ class SharedController { restriction.alternatives.some(alternative => alternative.value === 'listinvoices'), ), ); - if (invoiceRune && fs.existsSync(APP_CONSTANTS.COMMANDO_CONFIG)) { + if (invoiceRune && fs.existsSync(APP_CONSTANTS.LIGHTNING_VARS_FILE)) { const invoiceRuneString = `INVOICE_RUNE="${invoiceRune.rune}"\n`; - fs.appendFileSync(APP_CONSTANTS.COMMANDO_CONFIG, invoiceRuneString, 'utf-8'); + fs.appendFileSync(APP_CONSTANTS.LIGHTNING_VARS_FILE, invoiceRuneString, 'utf-8'); res.status(201).send(); } else { throw new Error('Invoice rune not found or .commando-env does not exist.'); @@ -119,7 +128,5 @@ class SharedController { } catch (error: any) { handleError(error, req, res, next); } - } + }; } - -export default new SharedController(); diff --git a/apps/backend/source/routes/v1/auth.ts b/apps/backend/source/routes/v1/auth.ts index db81a786..f2336231 100644 --- a/apps/backend/source/routes/v1/auth.ts +++ b/apps/backend/source/routes/v1/auth.ts @@ -1,6 +1,6 @@ import express from 'express'; import { CommonRoutesConfig } from '../../shared/routes.config.js'; -import AuthController from '../../controllers/auth.js'; +import { AuthController } from '../../controllers/auth.js'; import { API_VERSION } from '../../shared/consts.js'; const AUTH_ROUTE = '/auth'; @@ -11,12 +11,13 @@ export class AuthRoutes extends CommonRoutesConfig { } configureRoutes() { - this.app.route(API_VERSION + AUTH_ROUTE + '/logout/').get(AuthController.userLogout); - this.app.route(API_VERSION + AUTH_ROUTE + '/login/').post(AuthController.userLogin); - this.app.route(API_VERSION + AUTH_ROUTE + '/reset/').post(AuthController.resetPassword); + const authController = new AuthController(); + this.app.route(API_VERSION + AUTH_ROUTE + '/logout/').get(authController.userLogout); + this.app.route(API_VERSION + AUTH_ROUTE + '/login/').post(authController.userLogin); + this.app.route(API_VERSION + AUTH_ROUTE + '/reset/').post(authController.resetPassword); this.app .route(API_VERSION + AUTH_ROUTE + '/isauthenticated/') - .post(AuthController.isUserAuthenticated); + .post(authController.isUserAuthenticated); return this.app; } } diff --git a/apps/backend/source/routes/v1/lightning.ts b/apps/backend/source/routes/v1/lightning.ts index 60177eb4..04a43171 100755 --- a/apps/backend/source/routes/v1/lightning.ts +++ b/apps/backend/source/routes/v1/lightning.ts @@ -1,20 +1,27 @@ import express from 'express'; import { CommonRoutesConfig } from '../../shared/routes.config.js'; -import AuthController from '../../controllers/auth.js'; -import LightningController from '../../controllers/lightning.js'; +import { AuthController } from '../../controllers/auth.js'; +import { LightningController } from '../../controllers/lightning.js'; import { API_VERSION } from '../../shared/consts.js'; +import { LightningService } from '../../service/lightning.service.js'; const LIGHTNING_ROOT_ROUTE = '/cln'; export class LightningRoutes extends CommonRoutesConfig { - constructor(app: express.Application) { + private clnService: LightningService; + + constructor(app: express.Application, clnService: LightningService) { super(app, 'Lightning Routes'); + this.clnService = clnService; } configureRoutes() { + const authController = new AuthController(); + const lightningController = new LightningController(this.clnService); this.app .route(API_VERSION + LIGHTNING_ROOT_ROUTE + '/call') - .post(AuthController.isUserAuthenticated, LightningController.callMethod); + .post(authController.isUserAuthenticated, lightningController.callMethod); + return this.app; } } diff --git a/apps/backend/source/routes/v1/shared.ts b/apps/backend/source/routes/v1/shared.ts index dcca3094..fe3ba374 100755 --- a/apps/backend/source/routes/v1/shared.ts +++ b/apps/backend/source/routes/v1/shared.ts @@ -1,17 +1,24 @@ import express from 'express'; import { CommonRoutesConfig } from '../../shared/routes.config.js'; -import AuthController from '../../controllers/auth.js'; -import SharedController from '../../controllers/shared.js'; +import { AuthController } from '../../controllers/auth.js'; +import { SharedController } from '../../controllers/shared.js'; import { API_VERSION } from '../../shared/consts.js'; +import { LightningService } from '../../service/lightning.service.js'; const SHARED_ROUTE = '/shared'; export class SharedRoutes extends CommonRoutesConfig { - constructor(app: express.Application) { + private clnService: LightningService; + + constructor(app: express.Application, clnService: LightningService) { super(app, 'Shared Routes'); + this.clnService = clnService; } configureRoutes() { + const authController = new AuthController(); + const sharedController = new SharedController(this.clnService); + // eslint-disable-next-line @typescript-eslint/no-unused-vars this.app.route(API_VERSION + SHARED_ROUTE + '/csrf/').get((req, res, next) => { res.send({ @@ -21,19 +28,19 @@ export class SharedRoutes extends CommonRoutesConfig { }); this.app .route(API_VERSION + SHARED_ROUTE + '/config/') - .get(SharedController.getApplicationSettings); + .get(sharedController.getApplicationSettings); this.app .route(API_VERSION + SHARED_ROUTE + '/config/') - .post(AuthController.isUserAuthenticated, SharedController.setApplicationSettings); + .post(authController.isUserAuthenticated, sharedController.setApplicationSettings); this.app .route(API_VERSION + SHARED_ROUTE + '/connectwallet/') - .get(AuthController.isUserAuthenticated, SharedController.getWalletConnectSettings); + .get(authController.isUserAuthenticated, sharedController.getWalletConnectSettings); this.app .route(API_VERSION + SHARED_ROUTE + '/rate/:fiatCurrency') - .get(SharedController.getFiatRate); + .get(sharedController.getFiatRate); this.app .route(API_VERSION + SHARED_ROUTE + '/saveinvoicerune/') - .post(AuthController.isUserAuthenticated, SharedController.saveInvoiceRune); + .post(authController.isUserAuthenticated, sharedController.saveInvoiceRune); return this.app; } } diff --git a/apps/backend/source/server.ts b/apps/backend/source/server.ts index 11434090..d6d39e35 100755 --- a/apps/backend/source/server.ts +++ b/apps/backend/source/server.ts @@ -16,18 +16,19 @@ import { AuthRoutes } from './routes/v1/auth.js'; import { APIError } from './models/errors.js'; import { APP_CONSTANTS, Environment, HttpStatusCode } from './shared/consts.js'; import handleError from './shared/error-handler.js'; +import { LightningService } from './service/lightning.service.js'; const directoryName = dirname(fileURLToPath(import.meta.url)); const routes: Array = []; -const app: express.Application = express(); -const server: http.Server = http.createServer(app); +export const app: express.Application = express(); +export const server: http.Server = http.createServer(app); -const LIGHTNING_PORT = normalizePort(process.env.APP_PORT || '2103'); -const APP_IP = process.env.APP_IP || 'localhost'; +const APP_PORT = normalizePort(process.env.APP_PORT || '2103'); +const APP_HOST = process.env.APP_HOST || 'localhost'; const APP_PROTOCOL = process.env.APP_PROTOCOL || 'http'; -function normalizePort(val: string) { +export function normalizePort(val: string) { const port = parseInt(val, 10); if (isNaN(port)) { return val; @@ -51,12 +52,13 @@ app.use((req, res, next) => { ); next(); }); + const corsOptions = { methods: 'GET, POST, PATCH, PUT, DELETE, OPTIONS', origin: APP_CONSTANTS.APP_MODE === Environment.PRODUCTION - ? APP_PROTOCOL + '://' + APP_IP + ':' + LIGHTNING_PORT - : APP_PROTOCOL + '://localhost:4300', + ? `${APP_PROTOCOL}://${APP_HOST}:${APP_PORT}` + : `${APP_PROTOCOL}://localhost:4300`, credentials: true, allowedHeaders: 'Content-Type, X-XSRF-TOKEN, XSRF-TOKEN', }; @@ -65,45 +67,81 @@ app.use(cors(corsOptions)); app.use(expressWinston.logger(expressLogConfiguration)); app.use(expressWinston.errorLogger(expressLogConfiguration)); -routes.push(new AuthRoutes(app)); -routes.push(new SharedRoutes(app)); -routes.push(new LightningRoutes(app)); - -// serve frontend -app.use('/', express.static(join(directoryName, '..', '..', 'frontend', 'build'))); -// eslint-disable-next-line @typescript-eslint/no-unused-vars -app.use((req: express.Request, res: express.Response, next: any) => { - res.sendFile(join(directoryName, '..', '..', 'frontend', 'build', 'index.html')); -}); - -app.use((err: any, req: express.Request, res: express.Response, next: any) => { - return handleError(throwApiError(err), req, res, next); -}); - -const throwApiError = (err: any) => { - logger.error('Server error: ' + err); +export const throwApiError = (err: any) => { switch (err.code) { case 'EACCES': return new APIError( HttpStatusCode.ACCESS_DENIED, - APP_PROTOCOL + '://' + APP_IP + ':' + LIGHTNING_PORT + ' requires elevated privileges', + `${APP_PROTOCOL}://${APP_HOST}:${APP_PORT} requires elevated privileges`, ); case 'EADDRINUSE': return new APIError( HttpStatusCode.ADDR_IN_USE, - APP_PROTOCOL + '://' + APP_IP + ':' + LIGHTNING_PORT + ' is already in use', + `${APP_PROTOCOL}://${APP_HOST}:${APP_PORT} is already in use`, ); case 'ECONNREFUSED': return new APIError(HttpStatusCode.UNAUTHORIZED, 'Server is down/locked'); case 'EBADCSRFTOKEN': return new APIError(HttpStatusCode.BAD_CSRF_TOKEN, 'Invalid CSRF token. Form tempered.'); default: - return new APIError(400, 'Default: ' + JSON.stringify(err)); + return new APIError(HttpStatusCode.BAD_REQUEST, err?.message || err); } }; -server.on('error', throwApiError); -server.on('listening', () => - logger.warn('Server running at ' + APP_PROTOCOL + '://' + APP_IP + ':' + LIGHTNING_PORT), -); -server.listen({ port: LIGHTNING_PORT, host: APP_IP }); +async function startServer() { + try { + const clnService = new LightningService(); + + const authRoutes = new AuthRoutes(app); + const sharedRoutes = new SharedRoutes(app, clnService); + const lightningRoutes = new LightningRoutes(app, clnService); + + authRoutes.configureRoutes(); + sharedRoutes.configureRoutes(); + lightningRoutes.configureRoutes(); + + routes.push(authRoutes, sharedRoutes, lightningRoutes); + + // serve frontend + app.use('/', express.static(join(directoryName, '..', '..', 'frontend', 'build'))); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + app.use((req: express.Request, res: express.Response, next: any) => { + res.sendFile(join(directoryName, '..', '..', 'frontend', 'build', 'index.html')); + }); + + // Global error handler for requests + app.use((err: any, req: express.Request, res: express.Response, next: any) => { + return handleError(throwApiError(err), req, res, next); + }); + + server.on('error', (err: any) => { + if (err.code) { + logger.error('On Server Error: ', err); + } else { + logger.error('On Server Error: ', throwApiError(err)); + } + process.exit(1); + }); + + server.on('listening', () => + logger.warn(`Server running at ${APP_PROTOCOL}://${APP_HOST}:${APP_PORT}`), + ); + + server.listen({ port: APP_PORT, host: APP_HOST }); + } catch (err: any) { + if (err.code) { + logger.error('Server Startup Error: ', err); + } else { + logger.error('Server Startup Error: ', throwApiError(err)); + } + process.exit(1); + } +} + +startServer(); + +process.on('uncaughtException', err => { + logger.error('I M HERE'); + logger.error('Uncaught Exception:', err); + process.exit(1); +}); diff --git a/apps/backend/source/service/grpc.service.ts b/apps/backend/source/service/grpc.service.ts index 9fe85b56..ce32fcc1 100644 --- a/apps/backend/source/service/grpc.service.ts +++ b/apps/backend/source/service/grpc.service.ts @@ -1,27 +1,36 @@ +import * as crypto from 'crypto'; import axios, { AxiosHeaders } from 'axios'; -import * as path from 'path'; -import fs from 'fs'; import https from 'https'; import protobuf from 'protobufjs'; -import { HttpStatusCode, GRPC_CONFIG, APP_CONSTANTS } from '../shared/consts.js'; +import { HttpStatusCode } from '../shared/consts.js'; import { GRPCError } from '../models/errors.js'; import { logger } from '../shared/logger.js'; export class GRPCService { private authPubkey: string; private authSignature: string; - private protoPath: string[]; - private clnNode: protobuf.Root; + private clnNode!: protobuf.Root; private axiosConfig: any; - constructor(grpcConfig: { pubkey: string; url: string }) { - this.authSignature = 'A'.repeat(64); + constructor(grpcConfig: { + pubkey: string; + url: string; + protoPath: string; + grpcClientKey: string; + grpcClientCert: string; + grpcCaCert: string; + }) { + this.authSignature = crypto.randomBytes(32).toString('hex'); this.authPubkey = Buffer.from(grpcConfig.pubkey, 'hex').toString('base64'); - this.protoPath = [ - path.resolve(process.cwd(), './proto/node.proto'), - path.resolve(process.cwd(), './proto/primitives.proto'), - ]; - this.clnNode = protobuf.Root.fromJSON(protobuf.loadSync(this.protoPath).toJSON()); + this.loadLightningProtos(grpcConfig.protoPath) + .then(protoRes => { + logger.info('gRPC Protos loaded successfully'); + this.clnNode = protoRes; + }) + .catch(error => { + logger.error('Failed to load gRPC Protos: ', error); + throw new GRPCError(HttpStatusCode.GRPC_UNKNOWN, 'Failed to load gRPC Protos'); + }); const headers = new AxiosHeaders(); headers.set('content-type', 'application/grpc'); headers.set('accept', 'application/grpc'); @@ -32,16 +41,47 @@ export class GRPCService { baseURL: `${grpcConfig.url}/cln.Node/`, headers, }; - if (APP_CONSTANTS.LIGHTNING_GRPC_PROTOCOL === 'https') { - const httpsAgent = new https.Agent({ - cert: fs.readFileSync(path.join(APP_CONSTANTS.LIGHTNING_CERTS_PATH || '.', 'client.pem')), - key: fs.readFileSync( - path.join(APP_CONSTANTS.LIGHTNING_CERTS_PATH || '.', 'client-key.pem'), - ), - ca: fs.readFileSync(path.join(APP_CONSTANTS.LIGHTNING_CERTS_PATH || '.', 'ca.pem')), + this.axiosConfig.httpsAgent = new https.Agent({ + key: grpcConfig.grpcClientKey, + cert: grpcConfig.grpcClientCert, + ca: grpcConfig.grpcCaCert, + }); + } + + private loadLightningProtos(protoPath: string): Promise { + return axios + .get(protoPath) + .then(response => { + const files = response.data; + const protoFiles = files.filter( + (file: any) => file.name.endsWith('.proto') && file.type === 'file', + ); + + if (protoFiles.length === 0) { + logger.error('No proto files found in the directory.'); + throw new Error('No proto files found in the directory.'); + } + + return Promise.all( + protoFiles.map((file: any) => + axios + .get(file.download_url) + .then(rawResponse => rawResponse.data) + .catch(error => { + logger.error(`Failed to fetch ${file.name}:`, error); + throw error; + }), + ), + ); + }) + .then(protoContents => { + const parsed = protobuf.parse(protoContents.join('\n')); + return protobuf.Root.fromJSON(parsed.root.toJSON()); + }) + .catch(error => { + logger.error('Failed to load proto files:', error); + throw error; }); - this.axiosConfig.httpsAgent = httpsAgent; - } } private static getGrpcStatusMessages(method: string): Record { @@ -323,19 +363,8 @@ export class GRPCService { public async callMethod(methodName: string, reqPayload: object): Promise { if (methodName?.toLowerCase() === 'bkpr-listaccountevents') { - let data = {}; - if ( - GRPC_CONFIG.pubkey === '0279da9a93e50b008a7ba6bd25355fb7132f5015b790a05ee9f41bc9fbdeb30d19' - ) { - data = JSON.parse( - await fs.readFileSync(path.join(process.cwd(), '../../data/dummy/node-1.json'), 'utf8'), - )['bkpr-listaccountevents']; - } else { - data = JSON.parse( - await fs.readFileSync(path.join(process.cwd(), '../../data/dummy/node-3.json'), 'utf8'), - )['bkpr-listaccountevents']; - } - return data; + // Bookkeeper is not available in gRPC, so we return an empty object till it is implemented + return {}; } else { const [method, capitalizedMethod] = this.convertMethodName(methodName); reqPayload = this.transformPayload(capitalizedMethod, reqPayload); diff --git a/apps/backend/source/service/lightning.service.ts b/apps/backend/source/service/lightning.service.ts index df3ec4c2..54f1c68d 100755 --- a/apps/backend/source/service/lightning.service.ts +++ b/apps/backend/source/service/lightning.service.ts @@ -1,10 +1,8 @@ -import * as fs from 'fs'; import * as crypto from 'crypto'; -import { join } from 'path'; import https from 'https'; import axios, { AxiosHeaders } from 'axios'; import Lnmessage from 'lnmessage'; -import { GRPCError, LightningError } from '../models/errors.js'; +import { GRPCError, LightningError, ValidationError } from '../models/errors.js'; import { GRPCService } from './grpc.service.js'; import { HttpStatusCode, @@ -15,33 +13,50 @@ import { GRPC_CONFIG, } from '../shared/consts.js'; import { logger } from '../shared/logger.js'; -import { refreshEnvVariables } from '../shared/utils.js'; +import { setEnvVariables, validateEnvVariables } from '../shared/utils.js'; export class LightningService { private clnService: any = null; + private axiosConfig: any = { + baseURL: '', + headers: {}, + httpsAgent: null, + }; constructor() { try { - logger.info('Getting Commando Rune'); - if (fs.existsSync(APP_CONSTANTS.COMMANDO_CONFIG)) { - refreshEnvVariables(); - switch (APP_CONSTANTS.APP_CONNECT) { - case AppConnect.REST: - logger.info('REST connecting with config: ' + JSON.stringify(REST_CONFIG)); - break; - case AppConnect.GRPC: - logger.info('GRPC connecting with config: ' + JSON.stringify(GRPC_CONFIG)); - this.clnService = new GRPCService(GRPC_CONFIG); - break; - default: - logger.info('lnMessage connecting with config: ' + JSON.stringify(LN_MESSAGE_CONFIG)); - this.clnService = new Lnmessage(LN_MESSAGE_CONFIG); - this.clnService.connect(); - break; - } + setEnvVariables(); + validateEnvVariables(); + } catch (error: any) { + throw new ValidationError(HttpStatusCode.INVALID_DATA, error); + } + try { + logger.info('Strating Lightning Service with APP_CONNECT: ' + APP_CONSTANTS.APP_CONNECT); + switch (APP_CONSTANTS.APP_CONNECT) { + case AppConnect.REST: + logger.info('REST connecting with config: ' + JSON.stringify(REST_CONFIG)); + const headers = new AxiosHeaders(); + headers.set('rune', REST_CONFIG.rune); + this.axiosConfig = { + baseURL: REST_CONFIG.url + '/v1/', + headers, + }; + if (APP_CONSTANTS.LIGHTNING_REST_PROTOCOL === 'https') { + this.axiosConfig.httpsAgent = new https.Agent({ ca: REST_CONFIG.restCaCert }); + } + break; + case AppConnect.GRPC: + logger.info('GRPC connecting with config: ' + JSON.stringify(GRPC_CONFIG)); + this.clnService = new GRPCService(GRPC_CONFIG); + break; + default: + logger.info('lnMessage connecting with config: ' + JSON.stringify(LN_MESSAGE_CONFIG)); + this.clnService = new Lnmessage(LN_MESSAGE_CONFIG); + this.clnService.connect(); + break; } } catch (error: any) { - logger.error('Failed to read rune for Commando connection: ' + JSON.stringify(error)); + logger.error('Failed to construct lightning service: ' + JSON.stringify(error)); throw error; } } @@ -53,21 +68,8 @@ export class LightningService { call = async (method: string, methodParams: any[]) => { switch (APP_CONSTANTS.APP_CONNECT) { case AppConnect.REST: - const headers = new AxiosHeaders(); - headers.set('rune', APP_CONSTANTS.COMMANDO_RUNE); - const axiosConfig: any = { - baseURL: REST_CONFIG.url + '/v1/', - headers, - }; - if (APP_CONSTANTS.LIGHTNING_REST_PROTOCOL === 'https') { - const caCert = fs.readFileSync(join(APP_CONSTANTS.LIGHTNING_CERTS_PATH || '.', 'ca.pem')); - const httpsAgent = new https.Agent({ - ca: caCert, - }); - axiosConfig.httpsAgent = httpsAgent; - } return axios - .post(method, methodParams, axiosConfig) + .post(method, methodParams, this.axiosConfig) .then((commandRes: any) => { logger.info('REST response for ' + method + ': ' + JSON.stringify(commandRes.data)); return Promise.resolve(commandRes.data); @@ -98,7 +100,7 @@ export class LightningService { .commando({ method: method, params: methodParams, - rune: APP_CONSTANTS.COMMANDO_RUNE, + rune: APP_CONSTANTS.ADMIN_RUNE, reqId: crypto.randomBytes(8).toString('hex'), reqIdPrefix: 'clnapp', }) @@ -119,5 +121,3 @@ export class LightningService { } }; } - -export const CLNService = new LightningService(); diff --git a/apps/backend/source/shared/consts.ts b/apps/backend/source/shared/consts.ts index 8cf75b69..73f109b2 100755 --- a/apps/backend/source/shared/consts.ts +++ b/apps/backend/source/shared/consts.ts @@ -1,5 +1,4 @@ import * as crypto from 'crypto'; -import { join } from 'path'; import WebSocket from 'ws'; import fs from 'fs'; @@ -15,10 +14,6 @@ export enum AppConnect { GRPC = 'GRPC', } -export enum NodeType { - CLN = 'CLN', -} - export enum HttpStatusCode { GET_OK = 200, POST_OK = 201, @@ -38,56 +33,102 @@ export enum HttpStatusCode { export const SECRET_KEY = crypto.randomBytes(64).toString('hex'); +export const DEFAULT_ENV_VALUES = { + APP_SINGLE_SIGN_ON: 'false', + BITCOIN_HOST: 'localhost', + BITCOIN_NETWORK: 'bitcoin', + APP_PROTOCOL: 'http', + APP_HOST: 'localhost', + APP_PORT: '2103', + APP_CONFIG_FILE: './config.json', + APP_LOG_FILE: './application-cln.log', + APP_MODE: Environment.PRODUCTION, + APP_CONNECT: AppConnect.COMMANDO, + LIGHTNING_DATA_DIR: '', + LIGHTNING_HOST: 'localhost', + LIGHTNING_TOR_HOST: '', + LIGHTNING_VARS_FILE: './.commando-env', + LIGHTNING_WS_PROTOCOL: 'ws', + LIGHTNING_WS_PORT: 5001, + LIGHTNING_WS_CLIENT_KEY_FILE: './client-key.pem', + LIGHTNING_WS_CLIENT_CERT_FILE: './client.pem', + LIGHTNING_WS_CA_CERT_FILE: './ca.pem', + LIGHTNING_REST_PROTOCOL: 'https', + LIGHTNING_REST_HOST: 'localhost', + LIGHTNING_REST_TOR_HOST: '', + LIGHTNING_REST_PORT: 3010, + LIGHTNING_REST_CLIENT_KEY_FILE: './client-key.pem', + LIGHTNING_REST_CLIENT_CERT_FILE: './client.pem', + LIGHTNING_REST_CA_CERT_FILE: './ca.pem', + LIGHTNING_GRPC_HOST: 'localhost', + LIGHTNING_GRPC_TOR_HOST: '', + LIGHTNING_GRPC_PORT: 9736, + LIGHTNING_GRPC_PROTO_PATH: + 'https://github.com/ElementsProject/lightning/tree/master/cln-grpc/proto', + LIGHTNING_GRPC_CLIENT_KEY_FILE: './client-key.pem', + LIGHTNING_GRPC_CLIENT_CERT_FILE: './client.pem', + LIGHTNING_GRPC_CA_CERT_FILE: './ca.pem', +}; + export const APP_CONSTANTS = { - SINGLE_SIGN_ON: process.env.SINGLE_SIGN_ON || 'false', - LOCAL_HOST: process.env.LOCAL_HOST || '', - DEVICE_DOMAIN_NAME: process.env.DEVICE_DOMAIN_NAME || '', - BITCOIN_NODE_IP: process.env.BITCOIN_NODE_IP || 'localhost', - BITCOIN_NETWORK: process.env.BITCOIN_NETWORK || 'bitcoin', - APP_CONFIG_FILE: join(process.env.APP_CONFIG_DIR || '.', 'config.json'), - APP_LOG_FILE: join(process.env.APP_CONFIG_DIR || '.', 'application-cln.log'), - APP_MODE: process.env.APP_MODE || Environment.PRODUCTION, - APP_CONNECT: process.env.APP_CONNECT || AppConnect.COMMANDO, - APP_PROTOCOL: process.env.APP_PROTOCOL || 'http', - APP_IP: process.env.APP_IP || 'localhost', - APP_PORT: process.env.APP_PORT || '2103', - LIGHTNING_IP: process.env.LIGHTNING_IP || process.env.APP_CORE_LIGHTNING_DAEMON_IP || 'localhost', - LIGHTNING_PATH: process.env.LIGHTNING_PATH || '', - HIDDEN_SERVICE_URL: process.env.HIDDEN_SERVICE_URL || '', - LIGHTNING_NODE_TYPE: process.env.LIGHTNING_NODE_TYPE || NodeType.CLN, - COMMANDO_CONFIG: process.env.COMMANDO_CONFIG || './.commando-env', - LIGHTNING_CERTS_DIR: - process.env.LIGHTNING_CERTS_DIR || process.env.APP_CORE_LIGHTNING_CERTS_DIR || './certs', - LIGHTNING_WS_PROTOCOL: process.env.LIGHTNING_WEBSOCKET_PROTOCOL || 'ws', - LIGHTNING_WS_PORT: +( - process.env.LIGHTNING_WEBSOCKET_PORT || - process.env.APP_CORE_LIGHTNING_WEBSOCKET_PORT || - 5001 - ), + APP_SINGLE_SIGN_ON: process.env.APP_SINGLE_SIGN_ON || DEFAULT_ENV_VALUES.APP_SINGLE_SIGN_ON, + BITCOIN_HOST: process.env.BITCOIN_HOST || DEFAULT_ENV_VALUES.BITCOIN_HOST, + BITCOIN_NETWORK: process.env.BITCOIN_NETWORK || DEFAULT_ENV_VALUES.BITCOIN_NETWORK, + APP_PROTOCOL: process.env.APP_PROTOCOL || DEFAULT_ENV_VALUES.APP_PROTOCOL, + APP_HOST: process.env.APP_HOST || DEFAULT_ENV_VALUES.APP_HOST, + APP_PORT: process.env.APP_PORT || DEFAULT_ENV_VALUES.APP_PORT, + APP_CONFIG_FILE: process.env.APP_CONFIG_FILE || DEFAULT_ENV_VALUES.APP_CONFIG_FILE, + APP_LOG_FILE: process.env.APP_LOG_FILE || DEFAULT_ENV_VALUES.APP_LOG_FILE, + APP_MODE: process.env.APP_MODE || DEFAULT_ENV_VALUES.APP_MODE, + APP_CONNECT: process.env.APP_CONNECT || DEFAULT_ENV_VALUES.APP_CONNECT, + LIGHTNING_DATA_DIR: process.env.LIGHTNING_DATA_DIR || DEFAULT_ENV_VALUES.LIGHTNING_DATA_DIR, + LIGHTNING_HOST: process.env.LIGHTNING_HOST || DEFAULT_ENV_VALUES.LIGHTNING_HOST, + LIGHTNING_TOR_HOST: process.env.LIGHTNING_TOR_HOST || DEFAULT_ENV_VALUES.LIGHTNING_TOR_HOST, + LIGHTNING_VARS_FILE: process.env.LIGHTNING_VARS_FILE || DEFAULT_ENV_VALUES.LIGHTNING_VARS_FILE, + LIGHTNING_WS_PROTOCOL: + process.env.LIGHTNING_WS_PROTOCOL || DEFAULT_ENV_VALUES.LIGHTNING_WS_PROTOCOL, + LIGHTNING_WS_PORT: +(process.env.LIGHTNING_WS_PORT || DEFAULT_ENV_VALUES.LIGHTNING_WS_PORT), + LIGHTNING_WS_CLIENT_KEY_FILE: + process.env.LIGHTNING_WS_CLIENT_KEY_FILE || DEFAULT_ENV_VALUES.LIGHTNING_WS_CLIENT_KEY_FILE, + LIGHTNING_WS_CLIENT_CERT_FILE: + process.env.LIGHTNING_WS_CLIENT_CERT_FILE || DEFAULT_ENV_VALUES.LIGHTNING_WS_CLIENT_CERT_FILE, + LIGHTNING_WS_CA_CERT_FILE: + process.env.LIGHTNING_WS_CA_CERT_FILE || DEFAULT_ENV_VALUES.LIGHTNING_WS_CA_CERT_FILE, LIGHTNING_REST_PROTOCOL: - process.env.LIGHTNING_REST_PROTOCOL || process.env.APP_CORE_LIGHTNING_REST_PROTOCOL || 'https', - LIGHTNING_REST_PORT: +( - process.env.LIGHTNING_REST_PORT || - process.env.APP_CORE_LIGHTNING_REST_PORT || - 3010 - ), - LIGHTNING_CERTS_PATH: process.env.LIGHTNING_CERTS_PATH || '', - LIGHTNING_GRPC_PROTOCOL: - process.env.LIGHTNING_GRPC_PROTOCOL || - process.env.APP_CORE_LIGHTNING_DAEMON_GRPC_PROTOCOL || - 'http', - LIGHTNING_GRPC_PORT: +( - process.env.LIGHTNING_GRPC_PORT || - process.env.APP_CORE_LIGHTNING_DAEMON_GRPC_PORT || - 9736 + process.env.LIGHTNING_REST_PROTOCOL || DEFAULT_ENV_VALUES.LIGHTNING_REST_PROTOCOL, + LIGHTNING_REST_HOST: process.env.LIGHTNING_REST_HOST || DEFAULT_ENV_VALUES.LIGHTNING_REST_HOST, + LIGHTNING_REST_TOR_HOST: + process.env.LIGHTNING_REST_TOR_HOST || DEFAULT_ENV_VALUES.LIGHTNING_REST_TOR_HOST, + LIGHTNING_REST_PORT: +(process.env.LIGHTNING_REST_PORT || DEFAULT_ENV_VALUES.LIGHTNING_REST_PORT), + LIGHTNING_REST_CLIENT_KEY_FILE: + process.env.LIGHTNING_REST_CLIENT_KEY_FILE || DEFAULT_ENV_VALUES.LIGHTNING_REST_CLIENT_KEY_FILE, + LIGHTNING_REST_CLIENT_CERT_FILE: + process.env.LIGHTNING_REST_CLIENT_CERT_FILE || + DEFAULT_ENV_VALUES.LIGHTNING_REST_CLIENT_CERT_FILE, + LIGHTNING_REST_CA_CERT_FILE: + process.env.LIGHTNING_REST_CA_CERT_FILE || DEFAULT_ENV_VALUES.LIGHTNING_REST_CA_CERT_FILE, + LIGHTNING_GRPC_HOST: process.env.LIGHTNING_GRPC_HOST || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_HOST, + LIGHTNING_GRPC_TOR_HOST: + process.env.LIGHTNING_GRPC_TOR_HOST || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_TOR_HOST, + LIGHTNING_GRPC_PORT: +(process.env.LIGHTNING_GRPC_PORT || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_PORT), + LIGHTNING_GRPC_PROTO_PATH: encodeURIComponent( + process.env.LIGHTNING_GRPC_PROTO_PATH || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_PROTO_PATH, ), + LIGHTNING_GRPC_CLIENT_KEY_FILE: + process.env.LIGHTNING_GRPC_CLIENT_KEY_FILE || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_CLIENT_KEY_FILE, + LIGHTNING_GRPC_CLIENT_CERT_FILE: + process.env.LIGHTNING_GRPC_CLIENT_CERT_FILE || + DEFAULT_ENV_VALUES.LIGHTNING_GRPC_CLIENT_CERT_FILE, + LIGHTNING_GRPC_CA_CERT_FILE: + process.env.LIGHTNING_GRPC_CA_CERT_FILE || DEFAULT_ENV_VALUES.LIGHTNING_GRPC_CA_CERT_FILE, + // Not added by the user APP_VERSION: '', NODE_PUBKEY: '', - COMMANDO_RUNE: '', + ADMIN_RUNE: '', INVOICE_RUNE: '', - CLIENT_KEY: '', - CLIENT_CERT: '', - CA_CERT: '', + LIGHTNING_WS_TLS_CERTS: '', + LIGHTNING_REST_TLS_CERTS: '', + LIGHTNING_GRPC_TLS_CERTS: '', }; export const DEFAULT_CONFIG = { @@ -104,8 +145,8 @@ class SecureWebSocket extends WebSocket { constructor(url: string) { const options = { rejectUnauthorized: false, - cert: fs.readFileSync(APP_CONSTANTS.LIGHTNING_CERTS_DIR + '/client.pem'), - key: fs.readFileSync(APP_CONSTANTS.LIGHTNING_CERTS_DIR + '/client-key.pem'), + cert: fs.readFileSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_CERT_FILE), + key: fs.readFileSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_KEY_FILE), }; super(url, options); } @@ -123,10 +164,10 @@ export const LN_MESSAGE_CONFIG = { wsProxy: APP_CONSTANTS.LIGHTNING_WS_PROTOCOL + '://' + - APP_CONSTANTS.LIGHTNING_IP + + APP_CONSTANTS.LIGHTNING_HOST + ':' + APP_CONSTANTS.LIGHTNING_WS_PORT, - ip: APP_CONSTANTS.LIGHTNING_IP, + ip: APP_CONSTANTS.LIGHTNING_HOST, port: APP_CONSTANTS.LIGHTNING_WS_PORT, privateKey: crypto.randomBytes(32).toString('hex'), socket: (url: string) => @@ -136,31 +177,36 @@ export const LN_MESSAGE_CONFIG = { warn: APP_CONSTANTS.APP_MODE === Environment.PRODUCTION ? () => {} : console.warn, error: console.error, }, -}; - -export const GRPC_CONFIG = { - pubkey: APP_CONSTANTS.NODE_PUBKEY, - protocol: APP_CONSTANTS.LIGHTNING_GRPC_PROTOCOL, - ip: APP_CONSTANTS.LIGHTNING_IP, - port: APP_CONSTANTS.LIGHTNING_GRPC_PORT, - url: - APP_CONSTANTS.LIGHTNING_GRPC_PROTOCOL + - '://' + - APP_CONSTANTS.LIGHTNING_IP + - ':' + - APP_CONSTANTS.LIGHTNING_GRPC_PORT, + wssClientKey: '', + wssClientCert: '', + wssCaCert: '', }; export const REST_CONFIG = { protocol: APP_CONSTANTS.LIGHTNING_REST_PROTOCOL, - ip: APP_CONSTANTS.LIGHTNING_IP, + ip: APP_CONSTANTS.LIGHTNING_REST_HOST, port: APP_CONSTANTS.LIGHTNING_REST_PORT, url: APP_CONSTANTS.LIGHTNING_REST_PROTOCOL + '://' + - APP_CONSTANTS.LIGHTNING_IP + + APP_CONSTANTS.LIGHTNING_REST_HOST + ':' + APP_CONSTANTS.LIGHTNING_REST_PORT, + rune: '', + restClientKey: '', + restClientCert: '', + restCaCert: '', +}; + +export const GRPC_CONFIG = { + pubkey: APP_CONSTANTS.NODE_PUBKEY, + ip: APP_CONSTANTS.LIGHTNING_GRPC_HOST, + port: APP_CONSTANTS.LIGHTNING_GRPC_PORT, + url: 'https://' + APP_CONSTANTS.LIGHTNING_GRPC_HOST + ':' + APP_CONSTANTS.LIGHTNING_GRPC_PORT, + protoPath: APP_CONSTANTS.LIGHTNING_GRPC_PROTO_PATH, + grpcClientKey: '', + grpcClientCert: '', + grpcCaCert: '', }; export const API_VERSION = '/v1'; diff --git a/apps/backend/source/shared/logger.ts b/apps/backend/source/shared/logger.ts index cbf732ac..194913d6 100644 --- a/apps/backend/source/shared/logger.ts +++ b/apps/backend/source/shared/logger.ts @@ -1,6 +1,14 @@ +import fs from 'fs'; +import path from 'path'; import winston from 'winston'; import { Environment, APP_CONSTANTS } from './consts.js'; +const logDir = path.dirname(APP_CONSTANTS.APP_LOG_FILE); + +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + export const enum LogLevel { ERROR = 'error', WARN = 'warn', @@ -19,6 +27,10 @@ export const logConfiguration = { ? LogLevel.DEBUG : LogLevel.INFO, format: winston.format.combine( + winston.format(info => { + if (info.stack) delete info.stack; + return info; + })(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston.format.timestamp(), winston.format.align(), @@ -27,13 +39,15 @@ export const logConfiguration = { ), }), new winston.transports.File({ - filename: APP_CONSTANTS.APP_LOG_FILE, + filename: path.basename(APP_CONSTANTS.APP_LOG_FILE), + dirname: logDir, + maxsize: 5 * 1024 * 1024, level: APP_CONSTANTS.APP_MODE === Environment.PRODUCTION - ? LogLevel.WARN + ? LogLevel.DEBUG : APP_CONSTANTS.APP_MODE === Environment.TESTING ? LogLevel.DEBUG - : LogLevel.INFO, + : LogLevel.DEBUG, format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston.format.timestamp(), diff --git a/apps/backend/source/shared/routes.config.ts b/apps/backend/source/shared/routes.config.ts index 2ed2c27f..18c15e53 100644 --- a/apps/backend/source/shared/routes.config.ts +++ b/apps/backend/source/shared/routes.config.ts @@ -7,7 +7,6 @@ export abstract class CommonRoutesConfig { constructor(app: express.Application, name: string) { this.app = app; this.name = name; - this.configureRoutes(); } getName() { diff --git a/apps/backend/source/shared/utils.ts b/apps/backend/source/shared/utils.ts index 9713ba19..34d4ffe3 100644 --- a/apps/backend/source/shared/utils.ts +++ b/apps/backend/source/shared/utils.ts @@ -1,8 +1,15 @@ import jwt from 'jsonwebtoken'; import * as fs from 'fs'; -import { sep } from 'path'; import { logger } from '../shared/logger.js'; -import { APP_CONSTANTS, GRPC_CONFIG, LN_MESSAGE_CONFIG, SECRET_KEY } from '../shared/consts.js'; +import { + APP_CONSTANTS, + GRPC_CONFIG, + REST_CONFIG, + LN_MESSAGE_CONFIG, + SECRET_KEY, + AppConnect, + DEFAULT_ENV_VALUES, +} from '../shared/consts.js'; export function addServerConfig(config: any) { config.serverConfig = { @@ -10,8 +17,7 @@ export function addServerConfig(config: any) { appPort: APP_CONSTANTS.APP_PORT, appProtocol: APP_CONSTANTS.APP_PROTOCOL, appVersion: APP_CONSTANTS.APP_VERSION, - lightningNodeType: APP_CONSTANTS.LIGHTNING_NODE_TYPE, - singleSignOn: APP_CONSTANTS.SINGLE_SIGN_ON, + singleSignOn: APP_CONSTANTS.APP_SINGLE_SIGN_ON, }; return config; } @@ -87,46 +93,174 @@ function parseEnvFile(filePath: string): { [key: string]: string } { } } -export function refreshEnvVariables() { - const envVars = parseEnvFile(APP_CONSTANTS.COMMANDO_CONFIG); - process.env.LIGHTNING_PUBKEY = envVars.LIGHTNING_PUBKEY; - process.env.COMMANDO_RUNE = envVars.LIGHTNING_RUNE; - process.env.INVOICE_RUNE = envVars.INVOICE_RUNE || ''; - APP_CONSTANTS.NODE_PUBKEY = envVars.LIGHTNING_PUBKEY; - APP_CONSTANTS.COMMANDO_RUNE = envVars.LIGHTNING_RUNE; - APP_CONSTANTS.INVOICE_RUNE = envVars.INVOICE_RUNE || ''; - LN_MESSAGE_CONFIG.remoteNodePublicKey = envVars.LIGHTNING_PUBKEY; - GRPC_CONFIG.pubkey = envVars.LIGHTNING_PUBKEY; - if (APP_CONSTANTS.LIGHTNING_CERTS_PATH === '') { - APP_CONSTANTS.LIGHTNING_CERTS_PATH = - APP_CONSTANTS.LIGHTNING_PATH + sep + APP_CONSTANTS.BITCOIN_NETWORK + sep; - } - let clientKey = ''; - let clientCert = ''; - let caCert = ''; +export function setEnvVariables() { + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_VARS_FILE)) { + const configVars = parseEnvFile(APP_CONSTANTS.LIGHTNING_VARS_FILE); + process.env.NODE_PUBKEY = configVars.LIGHTNING_PUBKEY || ''; + process.env.ADMIN_RUNE = configVars.LIGHTNING_RUNE || ''; + process.env.INVOICE_RUNE = configVars.INVOICE_RUNE || ''; + APP_CONSTANTS.NODE_PUBKEY = configVars.LIGHTNING_PUBKEY || ''; + APP_CONSTANTS.ADMIN_RUNE = configVars.LIGHTNING_RUNE || ''; + APP_CONSTANTS.INVOICE_RUNE = configVars.INVOICE_RUNE || ''; + LN_MESSAGE_CONFIG.remoteNodePublicKey = configVars.LIGHTNING_PUBKEY || ''; + GRPC_CONFIG.pubkey = configVars.LIGHTNING_PUBKEY; + REST_CONFIG.rune = configVars.LIGHTNING_RUNE || ''; + } if (fs.existsSync('package.json')) { const packageData = Buffer.from(fs.readFileSync('package.json')).toString(); APP_CONSTANTS.APP_VERSION = JSON.parse(packageData).version; } - if (fs.existsSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'client-key.pem')) { - clientKey = fs.readFileSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'client-key.pem').toString(); - APP_CONSTANTS.CLIENT_KEY = clientKey - .replace(/(\r\n|\n|\r)/gm, '') - .replace('-----BEGIN PRIVATE KEY-----', '') - .replace('-----END PRIVATE KEY-----', ''); - } - if (fs.existsSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'client.pem')) { - clientCert = fs.readFileSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'client.pem').toString(); - APP_CONSTANTS.CLIENT_CERT = clientCert - .replace(/(\r\n|\n|\r)/gm, '') - .replace('-----BEGIN CERTIFICATE-----', '') - .replace('-----END CERTIFICATE-----', ''); - } - if (fs.existsSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'ca.pem')) { - caCert = fs.readFileSync(APP_CONSTANTS.LIGHTNING_CERTS_PATH + 'ca.pem').toString(); - APP_CONSTANTS.CA_CERT = caCert - .replace(/(\r\n|\n|\r)/gm, '') - .replace('-----BEGIN CERTIFICATE-----', '') - .replace('-----END CERTIFICATE-----', ''); + try { + if (APP_CONSTANTS.LIGHTNING_WS_PROTOCOL === 'wss') { + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_KEY_FILE)) { + LN_MESSAGE_CONFIG.wssClientKey = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_KEY_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_CERT_FILE)) { + LN_MESSAGE_CONFIG.wssClientCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_WS_CLIENT_CERT_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_WS_CA_CERT_FILE)) { + LN_MESSAGE_CONFIG.wssCaCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_WS_CA_CERT_FILE) + .toString(); + } + } + APP_CONSTANTS.LIGHTNING_WS_TLS_CERTS = encodeURIComponent( + btoa( + `client_key: ${LN_MESSAGE_CONFIG.wssClientKey}\nclient_cert: ${LN_MESSAGE_CONFIG.wssClientCert}\nca_cert: ${LN_MESSAGE_CONFIG.wssCaCert}`, + ), + ); + } catch (error: any) { + logger.error('Error reading wss proxy certs: ', error); + } + try { + if (APP_CONSTANTS.LIGHTNING_REST_PROTOCOL === 'https') { + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_REST_CLIENT_KEY_FILE)) { + REST_CONFIG.restClientKey = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_REST_CLIENT_KEY_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_REST_CLIENT_CERT_FILE)) { + REST_CONFIG.restClientCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_REST_CLIENT_CERT_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_REST_CA_CERT_FILE)) { + REST_CONFIG.restCaCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_REST_CA_CERT_FILE) + .toString(); + } + APP_CONSTANTS.LIGHTNING_REST_TLS_CERTS = encodeURIComponent( + btoa( + `client_key: ${REST_CONFIG.restClientKey}\nclient_cert: ${REST_CONFIG.restClientCert}\nca_cert: ${REST_CONFIG.restCaCert}`, + ), + ); + } + } catch (error: any) { + logger.error('Error reading REST certs: ', error); + } + try { + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_KEY_FILE)) { + GRPC_CONFIG.grpcClientKey = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_KEY_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_CERT_FILE)) { + GRPC_CONFIG.grpcClientCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_CERT_FILE) + .toString(); + } + if (fs.existsSync(APP_CONSTANTS.LIGHTNING_GRPC_CA_CERT_FILE)) { + GRPC_CONFIG.grpcCaCert = fs + .readFileSync(APP_CONSTANTS.LIGHTNING_GRPC_CA_CERT_FILE) + .toString(); + } + APP_CONSTANTS.LIGHTNING_GRPC_TLS_CERTS = encodeURIComponent( + btoa( + `client_key: ${GRPC_CONFIG.grpcClientKey}\nclient_cert: ${GRPC_CONFIG.grpcClientCert}\nca_cert: ${GRPC_CONFIG.grpcCaCert}`, + ), + ); + } catch (error: any) { + logger.error('Error reading gRPC certs: ', error); + } + logger.info('Environment variables set successfully'); +} + +export function validateCommandoConfig() { + if (LN_MESSAGE_CONFIG.remoteNodePublicKey === '') { + throw `Node Public Key is not set for Commando connection. Fix LIGHTNING_PUBKEY in ${APP_CONSTANTS.LIGHTNING_VARS_FILE}.`; + } + if (APP_CONSTANTS.ADMIN_RUNE === '') { + throw `Rune is not set for Commando connection. Fix LIGHTNING_RUNE in ${APP_CONSTANTS.LIGHTNING_VARS_FILE}.`; + } + if (APP_CONSTANTS.LIGHTNING_WS_PROTOCOL === 'wss') { + if (LN_MESSAGE_CONFIG.wssClientKey === '') { + throw `Missing or Invalid WSS Client Key at ${APP_CONSTANTS.LIGHTNING_WS_CLIENT_KEY_FILE}.`; + } + if (LN_MESSAGE_CONFIG.wssClientCert === '') { + throw `Missing or Invalid WSS Client Certificate at ${APP_CONSTANTS.LIGHTNING_WS_CLIENT_CERT_FILE}.`; + } + } +} + +export function validateRestConfig() { + if (APP_CONSTANTS.ADMIN_RUNE === '') { + throw `Rune is not set for REST connection. Fix LIGHTNING_RUNE in ${APP_CONSTANTS.LIGHTNING_VARS_FILE}.`; + } + if (APP_CONSTANTS.LIGHTNING_REST_PROTOCOL === 'https' && REST_CONFIG.restCaCert === '') { + throw `Missing or Invalid REST Ca Certificate at ${APP_CONSTANTS.LIGHTNING_REST_CA_CERT_FILE}.`; + } +} + +export function validateGrpcConfig() { + if (GRPC_CONFIG.pubkey === '') { + throw `Node Public Key is not set for GRPC connection. Fix LIGHTNING_PUBKEY in ${APP_CONSTANTS.LIGHTNING_VARS_FILE}.`; + } + if (GRPC_CONFIG.grpcClientKey === '') { + throw `Missing or Invalid gRPC Client Key at ${APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_KEY_FILE}.`; + } + if (GRPC_CONFIG.grpcClientCert === '') { + throw `Missing or Invalid gRPC Client Certificate at ${APP_CONSTANTS.LIGHTNING_GRPC_CLIENT_CERT_FILE}.`; + } + if (GRPC_CONFIG.grpcCaCert === '') { + throw `Missing or Invalid gRPC Ca Certificate at ${APP_CONSTANTS.LIGHTNING_GRPC_CA_CERT_FILE}.`; + } +} + +export function logDefaultValues() { + for (const [key, value] of Object.entries(DEFAULT_ENV_VALUES)) { + const envKey = key as keyof typeof APP_CONSTANTS; + if (!process.env[envKey] && APP_CONSTANTS[envKey] === value) { + logger.warn( + `${key} is defaulting to '${value}'. Configure as environment variable to override.`, + ); + } + } +} + +export function validateEnvVariables() { + if (!fs.existsSync(APP_CONSTANTS.LIGHTNING_VARS_FILE)) { + throw `LIGHTNING_VARS_FILE ${APP_CONSTANTS.LIGHTNING_VARS_FILE} does not exist. Create a file with the required variables LIGHTNING_PUBKEY and LIGHTNING_RUNE. See https://github.com/ElementsProject/cln-application?tab=readme-ov-file#commando-authentication for more details.`; + } + if (APP_CONSTANTS.NODE_PUBKEY === '') { + throw `LIGHTNING_PUBKEY is not set in ${APP_CONSTANTS.LIGHTNING_VARS_FILE} file.`; + } + if (APP_CONSTANTS.ADMIN_RUNE === '') { + throw `LIGHTNING_RUNE is not set in ${APP_CONSTANTS.LIGHTNING_VARS_FILE} file.`; + } + switch (APP_CONSTANTS.APP_CONNECT) { + case AppConnect.REST: + validateRestConfig(); + break; + case AppConnect.GRPC: + validateGrpcConfig(); + break; + default: + validateCommandoConfig(); + break; } + logDefaultValues(); } diff --git a/apps/frontend/src/components/App/App.tsx b/apps/frontend/src/components/App/App.tsx index 1f4ff67a..978edd3d 100644 --- a/apps/frontend/src/components/App/App.tsx +++ b/apps/frontend/src/components/App/App.tsx @@ -12,6 +12,7 @@ import RouteTransition from '../ui/RouteTransition/RouteTransition'; import { useSelector } from 'react-redux'; import { selectAppMode, selectIsAuthenticated, selectIsDarkMode } from '../../store/rootSelectors'; import SQLTerminal from '../modals/SQLTerminal/SQLTerminal'; +import QRCodeLarge from '../modals/QRCodeLarge/QRCodeLarge'; export const App = () => { const currentScreenSize = useBreakpoint(); @@ -37,6 +38,7 @@ export const App = () => { + diff --git a/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.scss b/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.scss new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.test.tsx b/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.test.tsx new file mode 100644 index 00000000..d26adf0a --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.test.tsx @@ -0,0 +1,156 @@ +import React, { act } from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { CLNService } from '../../../../services/http.service'; +import { mockBKPRStoreData, mockCLNStoreData, mockConnectWallet, mockRootStoreData, mockShowModals } from '../../../../utilities/test-utilities/mockData'; +import { renderWithProviders } from '../../../../utilities/test-utilities/mockStore'; +import * as dataFormatUtils from '../../../../utilities/data-formatters'; +import ConnectWallet from '../ConnectWallet'; +import { spyOnCreateInvoiceRune } from '../../../../utilities/test-utilities/mockService'; + +describe('CommandoForm Component', () => { + const customMockStore = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + const customMockStoreWithoutInvoiceRune = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + }, + connectWallet: { ...mockConnectWallet, INVOICE_RUNE: '' } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + const customMockStoreWithoutWSS = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + }, + connectWallet: { ...mockConnectWallet, LIGHTNING_WS_PROTOCOL: 'ws' } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + + it('renders without crashing', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + expect(screen.getByTestId('ws-protocol')).toBeInTheDocument(); + }); + + it('displays correct values for Commando network', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + expect(screen.getByTestId('ws-protocol')).toHaveValue(mockConnectWallet.LIGHTNING_WS_PROTOCOL); + expect(screen.getByTestId('ws-host')).toHaveValue(mockConnectWallet.LIGHTNING_HOST); + expect(screen.getByTestId('ws-port')).toHaveValue(mockConnectWallet.LIGHTNING_WS_PORT.toString()); + expect(screen.getByTestId('node-pubkey')).toHaveValue(mockConnectWallet.NODE_PUBKEY); + expect(screen.getByTestId('rune')).toHaveValue(mockConnectWallet.ADMIN_RUNE); + expect(screen.getByTestId('invoice-rune')).toHaveValue(mockConnectWallet.INVOICE_RUNE); + expect(screen.getByTestId('wss-tls-certs')).toHaveValue(mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + }); + + it('displays Tor host when network is Commando (Tor)', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + await act(async () => { + fireEvent.click(screen.getByTestId('network-toggle')); + }); + const commandoTorSelect = screen.getAllByTestId('network-item')[1]; + await act(async () => { + fireEvent.click(commandoTorSelect); + }); + expect(screen.getByTestId('ws-host')).toHaveValue(mockConnectWallet.LIGHTNING_TOR_HOST); + }); + + it('copies field values to clipboard when clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + await renderWithProviders(, { preloadedState: customMockStore }); + const fieldsToTest = [ + { id: 'WS Protocol', testId: 'ws-protocol', expected: mockConnectWallet.LIGHTNING_WS_PROTOCOL }, + { id: 'WS Host', testId: 'ws-host', expected: mockConnectWallet.LIGHTNING_HOST }, + { id: 'WS Port', testId: 'ws-port', expected: mockConnectWallet.LIGHTNING_WS_PORT.toString() }, + { id: 'Node Public Key', testId: 'node-pubkey', expected: mockConnectWallet.NODE_PUBKEY }, + { id: 'Rune', testId: 'rune', expected: mockConnectWallet.ADMIN_RUNE }, + { id: 'Invoice Rune', testId: 'invoice-rune', expected: mockConnectWallet.INVOICE_RUNE }, + { id: 'WSS TLS Certs', testId: 'wss-tls-certs', expected: mockConnectWallet.LIGHTNING_WS_TLS_CERTS } + ]; + for (const [i, field] of fieldsToTest.entries()) { + await act(async () => { + fireEvent.click(screen.getByTestId(field.testId)); + }); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(i + 1, field.expected); + } + }); + + it('toggles between encoded and decoded certs when WSS TLS Certs is clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + const decodeCombinedCertsUtil = jest.spyOn(dataFormatUtils, 'decodeCombinedCerts'); + const decodedWSSCerts = `{\n"clientKey": "\n-----BEGIN PRIVATE KEY-----\nWSSClientKeyValueToBeBase64Encoded\n-----END PRIVATE KEY-----\n",\n"clientCert": "\n-----BEGIN CERTIFICATE-----\nWSSClientCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n",\n"caCert": "\n-----BEGIN CERTIFICATE-----\nWSSCaCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n"\n}`; + await renderWithProviders(, { preloadedState: customMockStore }); + await act(async () => { + fireEvent.click(screen.getByTestId('wss-tls-certs')); + }); + expect(copyTextToClipboard).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + + await act(async () => { + fireEvent.click(screen.getByTestId('wss-tls-certs')); + }); + expect(decodeCombinedCertsUtil).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, decodedWSSCerts); + }); + + it('shows AddSVG, calls createInvoiceRune and loading spinner', async () => { + spyOnCreateInvoiceRune(); + await renderWithProviders(, { preloadedState: customMockStoreWithoutInvoiceRune }); + + const button = screen.getByTestId('invoice-rune-button'); + expect(button.querySelector('svg')).toBeInTheDocument(); + await act(async () => { + fireEvent.click(button); + }); + expect(CLNService.createInvoiceRune).toHaveBeenCalled(); + expect(screen.getByTestId('invoice-rune-spinner')).toBeInTheDocument(); + }); + + it('shows CopySVG and calls copyHandler when invoice rune exists', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + await renderWithProviders(, { preloadedState: customMockStore }); + const button = screen.getByTestId('invoice-rune-button'); + expect(button.querySelector('svg')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(button); + }); + expect(copyTextToClipboard).toHaveBeenCalledWith(mockConnectWallet.INVOICE_RUNE); + }); + + it('does not show WSS TLS Certs when protocol is not wss', async () => { + await renderWithProviders(, { preloadedState: customMockStoreWithoutWSS }); + expect(screen.queryByTestId('wss-tls-certs')).not.toBeInTheDocument(); + }); + + it('handles error when creating invoice rune fails', async () => { + const errorMessage = 'Unknown'; + spyOnCreateInvoiceRune(errorMessage); + const { getActions } = await renderWithProviders(, { preloadedState: customMockStoreWithoutInvoiceRune }); + await act(() => { + fireEvent.click(screen.getByTestId('invoice-rune-button')); + }); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowToast' && + action.payload.message === 'Error Creating Invoice Rune: ' + errorMessage + )).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.tsx b/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.tsx new file mode 100755 index 00000000..d0faec54 --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/CommandoForm/CommandoForm.tsx @@ -0,0 +1,239 @@ +import './CommandoForm.scss'; +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Col, Row, Spinner, InputGroup, Form } from 'react-bootstrap'; + +import { CopySVG } from '../../../../svgs/Copy'; +import { copyTextToClipboard, decodeCombinedCerts } from '../../../../utilities/data-formatters'; +import logger from '../../../../services/logger.service'; +import { AddSVG } from '../../../../svgs/Add'; +import { CLNService } from '../../../../services/http.service'; +import { setShowToast, setConnectWallet } from '../../../../store/rootSlice'; +import { selectWalletConnect } from '../../../../store/rootSelectors'; + +const CommandoForm = ({ selNetwork }) => { + const dispatch = useDispatch(); + const connectWallet = useSelector(selectWalletConnect); + const [isLoadingInvoiceRune, setIsLoadingInvoiceRune] = useState(false); + const [copyEncoded, setCopyEncoded] = useState(true); + let invoiceRuneClickHandler; + let InvoiceRuneSvg; + + const copyHandler = (event) => { + let textToCopy = ''; + switch (event.target.id) { + case 'WS Protocol': + textToCopy = connectWallet.LIGHTNING_WS_PROTOCOL || ''; + break; + case 'WS Host': + textToCopy = (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_TOR_HOST : connectWallet.LIGHTNING_HOST) || ''; + break; + case 'WS Port': + textToCopy = connectWallet.LIGHTNING_WS_PORT?.toString() || ''; + break; + case 'Node Public Key': + textToCopy = connectWallet.NODE_PUBKEY || ''; + break; + case 'Rune': + textToCopy = connectWallet.ADMIN_RUNE || ''; + break; + case 'Invoice Rune': + textToCopy = connectWallet.INVOICE_RUNE || ''; + break; + case 'WSS TLS Certs': + if (!copyEncoded) { + textToCopy = decodeCombinedCerts(connectWallet.LIGHTNING_WS_TLS_CERTS || ''); + } else { + textToCopy = connectWallet.LIGHTNING_WS_TLS_CERTS || ''; + } + setCopyEncoded(prev => !prev); + break; + } + copyTextToClipboard(textToCopy).then(() => { + dispatch(setShowToast({ show: true, message: ((event.target.id !== 'WSS TLS Certs' ? event.target.id : !copyEncoded ? 'Decoded WSS Certs' : 'Encoded WSS Certs') + ' Copied Successfully!'), bg: 'success' })); + }).catch((err) => { + logger.error(err); + }); + } + + const createInvoiceRuneHandler = () => { + setIsLoadingInvoiceRune(true); + CLNService.createInvoiceRune() + .then((updatedWalletConnectData) => { + dispatch(setConnectWallet(updatedWalletConnectData)); + dispatch(setShowToast({ show: true, message: ('Created Invoice Rune Successfully!'), bg: 'success' })); + }) + .catch(err => { + logger.error(err.message || JSON.stringify(err)); + dispatch(setShowToast({ show: true, message: (`Error Creating Invoice Rune: ${err.message || ''}`), bg: 'danger' })); + }) + .finally(() => { + setIsLoadingInvoiceRune(false); + }); + }; + + + if (connectWallet.INVOICE_RUNE && connectWallet.INVOICE_RUNE !== '') { + invoiceRuneClickHandler = copyHandler; + InvoiceRuneSvg = CopySVG; + } else { + invoiceRuneClickHandler = createInvoiceRuneHandler; + InvoiceRuneSvg = AddSVG; + } + + return ( + + + + WS Protocol + + + + + + + + + WS Host + + + + + + + + + WS Port + + + + + + + + + + + Node Public Key + + + + + + + + + + + Rune + + + + + + + + + + + Invoice Rune + + + + {isLoadingInvoiceRune ? + + + + : InvoiceRuneSvg && + } + + + + + {connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' && ( + + + WSS TLS Certs (Client Key + Client Cert + Ca Cert) + + + + + + + + + )} + + ); +}; + +export default CommandoForm; diff --git a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss index 9ec5e71c..13983f60 100644 --- a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss +++ b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss @@ -1,14 +1,14 @@ @import '../../../styles/constants.scss'; -.modal-content { - & .modal-header, - & .modal-footer { - border: none; - } +.qr-container { + cursor: zoom-in; } .dropdown.dropdown-network { & .dropdown-toggle.btn.btn-secondary { + color: $dark; + background-color: $body-bg; + border-color: $border-color; box-shadow: 0px 1px 2px rgba($dark-blue, 0.05); background-image: none; padding: 0.675rem; @@ -17,25 +17,13 @@ & .dropdown-menu { font-size: 0.875rem; width: 100%; - } -} - -@include color-mode(light) { - .dropdown.dropdown-network { - & .dropdown-toggle.btn.btn-secondary { + & .dropdown-item { color: $dark; - background-color: $body-bg; - border-color: $border-color; - } - & .dropdown-menu { - & .dropdown-item { - color: $dark; - &.active, - &:active, - &:focus, - &:hover { - color: $primary; - } + &.active, + &:active, + &:focus, + &:hover { + color: $primary; } } } diff --git a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.test.tsx b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.test.tsx index 45e99a26..7962b5b4 100644 --- a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.test.tsx +++ b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.test.tsx @@ -1,19 +1,18 @@ import { act } from 'react'; -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import ConnectWallet from './ConnectWallet'; +import { mockBKPRStoreData, mockCLNStoreData, mockConnectWallet, mockRootStoreData, mockShowModals } from '../../../utilities/test-utilities/mockData'; import { renderWithProviders } from '../../../utilities/test-utilities/mockStore'; -import { mockBKPRStoreData, mockCLNStoreData, mockConnectWallet, mockInvoiceRune, mockRootStoreData, mockShowModals } from '../../../utilities/test-utilities/mockData'; -import { spyOnCreateInvoiceRune } from '../../../utilities/test-utilities/mockService'; +import * as dataFormatUtils from '../../../utilities/data-formatters'; -describe('ConnectWallet component ', () => { +describe('ConnectWallet component', () => { const customMockStore = { root: { ...mockRootStoreData, showModals: { ...mockShowModals, connectWalletModal: true, - }, - connectWallet: { ...mockConnectWallet, INVOICE_RUNE: mockInvoiceRune.rune } + } }, cln: mockCLNStoreData, bkpr: mockBKPRStoreData @@ -24,123 +23,140 @@ describe('ConnectWallet component ', () => { showModals: { ...mockShowModals, connectWalletModal: true, - } + }, + connectWallet: { ...mockConnectWallet, INVOICE_RUNE: '' } }, cln: mockCLNStoreData, bkpr: mockBKPRStoreData }; - it('renders with initial state', async () => { + it('renders the modal when showModals.connectWalletModal is true', async () => { await renderWithProviders(, { preloadedState: customMockStore }); expect(screen.getByTestId('connect-wallet')).toBeInTheDocument(); - expect(screen.getByText('LN Message')).toBeInTheDocument(); - expect(screen.getByTestId('port')).toHaveValue('5001'); - expect(screen.getByTestId('host')).toHaveValue('user.local'); - expect(screen.getByTestId('rune')).toHaveValue('mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j'); - expect(screen.getByTestId('invoice-rune')).toHaveValue('aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); - expect(screen.getByTestId('connect-url')).toHaveValue('ln-message://ws://user.local:5001?rune=mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j&invoiceRune=aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); - expect(screen.queryByTestId('invoice-rune-spinner')).not.toBeInTheDocument(); }); - it('updates selected network and input fields on network change to LN Message', async () => { + it('displays the correct initial network types based on connectWallet props', async () => { await renderWithProviders(, { preloadedState: customMockStore }); - const networkToggle = screen.getByTestId('network-toggle'); - fireEvent.click(networkToggle) - const selNetworkItem = screen.getAllByTestId('network-item')[0]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('5001'); - expect(screen.getByTestId('host')).toHaveValue('user.local'); - expect(screen.getByTestId('rune')).toHaveValue('mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j'); - expect(screen.queryByTestId('client-cert')).toBeInTheDocument(); - expect(screen.queryByTestId('ca-cert')).not.toBeInTheDocument(); - expect(screen.getByTestId('invoice-rune')).toHaveValue('aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); - expect(screen.getByTestId('connect-url')).toHaveValue('ln-message://ws://user.local:5001?rune=mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j&invoiceRune=aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); + fireEvent.click(screen.getByTestId('network-toggle')); + await waitFor(() => { + const networkItems = screen.getAllByTestId('network-item'); + expect(networkItems).toHaveLength(6); + expect(networkItems[0]).toHaveTextContent('Commando'); + expect(networkItems[1]).toHaveTextContent('Commando (Tor)'); + expect(networkItems[2]).toHaveTextContent('REST'); + expect(networkItems[3]).toHaveTextContent('REST (Tor)'); + expect(networkItems[4]).toHaveTextContent('gRPC'); + expect(networkItems[5]).toHaveTextContent('gRPC (Tor)'); + }); }); - it('updates selected network and input fields on network change to LN Message (Tor)', async () => { + it('sets Commando as the default selected network', async () => { await renderWithProviders(, { preloadedState: customMockStore }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[1]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('5001'); - expect(screen.getByTestId('host')).toHaveValue('oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion'); - expect(screen.getByTestId('rune')).toHaveValue('mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j'); - expect(screen.queryByTestId('client-cert')).toBeInTheDocument(); - expect(screen.queryByTestId('client-key')).toBeInTheDocument(); - expect(screen.getByTestId('invoice-rune')).toHaveValue('aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); - expect(screen.getByTestId('connect-url')).toHaveValue('ln-message://ws://oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion:5001?rune=mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j&invoiceRune=aHFhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb2=='); + expect(screen.getByTestId('network-toggle')).toHaveTextContent('Commando'); }); - it('updates selected network and input fields on network change to REST', async () => { + it('updates the connection URL when network is changed', async () => { await renderWithProviders(, { preloadedState: customMockStore }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[2]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('3001'); - expect(screen.getByTestId('host')).toHaveValue('user.local'); - expect(screen.queryByTestId('client-cert')).toBeInTheDocument(); - expect(screen.queryByTestId('ca-cert')).toBeInTheDocument(); - expect(screen.queryByTestId('invoice-rune')).not.toBeInTheDocument(); - expect(screen.getByTestId('connect-url')).toHaveValue('clnrest://https://user.local:3001?rune=mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j&clientKey=ClientKey&clientCert=ClientCert&caCert=CACert'); + const initialUrl = screen.getByTestId('connect-url'); + expect(initialUrl).toHaveValue('commando+wss://'+ mockConnectWallet.LIGHTNING_HOST + ':' + mockConnectWallet.LIGHTNING_WS_PORT + '?pubkey=' + mockConnectWallet.NODE_PUBKEY + '&rune=' + mockConnectWallet.ADMIN_RUNE + '&invoiceRune=' + mockConnectWallet.INVOICE_RUNE + '&certs=' + mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + await act(async () => { + fireEvent.click(screen.getByTestId('network-toggle')); + }); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + const updatedUrl = screen.getByTestId('connect-url'); + expect(updatedUrl).toHaveValue('clnrest+https://' + mockConnectWallet.LIGHTNING_REST_HOST + ':' + mockConnectWallet.LIGHTNING_REST_PORT +'?rune=' + mockConnectWallet.ADMIN_RUNE + '&certs=' + mockConnectWallet.LIGHTNING_REST_TLS_CERTS); }); - it('updates selected network and input fields on network change to REST (Tor)', async () => { + it('copies the connection URL to clipboard when copy button is clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); await renderWithProviders(, { preloadedState: customMockStore }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[3]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('3001'); - expect(screen.getByTestId('host')).toHaveValue('oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion'); - expect(screen.queryByTestId('client-cert')).toBeInTheDocument(); - expect(screen.queryByTestId('ca-cert')).not.toBeInTheDocument(); - expect(screen.queryByTestId('invoice-rune')).not.toBeInTheDocument(); - expect(screen.getByTestId('connect-url')).toHaveValue('clnrest://https://oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion:3001?rune=mRXhnFyVWrRQChA9eJ01RQT9W502daqrP0JA4BiHHw89MCZGb3IgQXBwbGljYXRpb24j&clientKey=ClientKey&clientCert=ClientCert&caCert=CACert'); + const copyClick = screen.getByTestId('connect-url-copy'); + await act(async () => { + fireEvent.click(copyClick); + }); + expect(copyTextToClipboard).toHaveBeenCalledWith('commando+wss://'+ mockConnectWallet.LIGHTNING_HOST + ':' + mockConnectWallet.LIGHTNING_WS_PORT + '?pubkey=' + mockConnectWallet.NODE_PUBKEY + '&rune=' + mockConnectWallet.ADMIN_RUNE + '&invoiceRune=' + mockConnectWallet.INVOICE_RUNE + '&certs=' + mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + }); + + it('displays the correct form based on selected network', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + expect(screen.getByTestId('commando-form')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + + expect(screen.getByTestId('rest-form')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + + expect(screen.getByTestId('grpc-form')).toBeInTheDocument(); }); - it('updates selected network and input fields on network change to gRPC', async () => { + it('displays QR code with correct value', async () => { await renderWithProviders(, { preloadedState: customMockStore }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[4]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('2106'); - expect(screen.getByTestId('host')).toHaveValue('user.local'); - expect(screen.getByTestId('client-key')).toHaveValue('ClientKey'); - expect(screen.getByTestId('client-cert')).toHaveValue('ClientCert'); - expect(screen.getByTestId('ca-cert')).toHaveValue('CACert'); - expect(screen.queryByTestId('invoice-rune')).not.toBeInTheDocument(); - expect(screen.getByTestId('connect-url')).toHaveValue('cln-grpc://https://user.local:2106?clientKey=ClientKey&clientCert=ClientCert&caCert=CACert'); + const qrCode = document.querySelector('canvas'); + expect(qrCode).toBeInTheDocument(); }); - it('updates selected network and input fields on network change to gRPC (Tor)', async () => { + it('handles dark mode styling correctly', async () => { await renderWithProviders(, { preloadedState: customMockStore }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[5]; - await act(async () => fireEvent.click(selNetworkItem)); - expect(screen.getByTestId('port')).toHaveValue('2106'); - expect(screen.getByTestId('host')).toHaveValue('oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion'); - expect(screen.getByTestId('client-key')).toHaveValue('ClientKey'); - expect(screen.getByTestId('client-cert')).toHaveValue('ClientCert'); - expect(screen.queryByTestId('ca-cert')).not.toBeInTheDocument(); - expect(screen.queryByTestId('invoice-rune')).not.toBeInTheDocument(); - expect(screen.getByTestId('connect-url')).toHaveValue('cln-grpc://https://oqaer4kd7ufryngx6dsztovs4pnlmaouwmtkofjsd2m7pkq8wd.onion:2106?clientKey=ClientKey&clientCert=ClientCert&caCert=CACert'); + const logoImg = screen.getByTestId('qr-cln-logo'); + expect(logoImg).toHaveAttribute('src', '/images/cln-logo-dark.png'); }); - it('when creating an invoice rune, display loading spinner and disable button', async () => { - jest.useFakeTimers(); - const mock = spyOnCreateInvoiceRune(); + it('updates connection URL when invoice rune changes', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + + const connectUrl = screen.getByTestId('connect-url'); + expect(connectUrl).toHaveValue('commando+wss://'+ mockConnectWallet.LIGHTNING_HOST + ':' + mockConnectWallet.LIGHTNING_WS_PORT + '?pubkey=' + mockConnectWallet.NODE_PUBKEY + '&rune=' + mockConnectWallet.ADMIN_RUNE + '&invoiceRune=' + mockConnectWallet.INVOICE_RUNE + '&certs=' + mockConnectWallet.LIGHTNING_WS_TLS_CERTS); + }); + + it('does not include invoice rune in URL when not available', async () => { await renderWithProviders(, { preloadedState: customMockStoreWithoutInvoiceRune }); - await act(async () => fireEvent.click(screen.getByTestId('network-toggle'))); - const selNetworkItem = screen.getAllByTestId('network-item')[0]; - await act(async () => fireEvent.click(selNetworkItem)); - fireEvent.click(screen.getByTestId('invoice-rune')); - expect(await screen.findByTestId('invoice-rune-spinner')).toBeInTheDocument(); - expect(screen.getByTestId('invoice-rune')).toBeDisabled(); - - await act(async () => { jest.advanceTimersByTime(10) }); - - expect(mock).toHaveBeenCalled(); - expect(screen.queryByTestId('invoice-rune-spinner')).not.toBeInTheDocument(); - expect(screen.getByTestId('invoice-rune')).not.toBeDisabled(); + const connectUrl = screen.getByTestId('connect-url'); + expect(connectUrl).not.toHaveValue(expect.stringContaining('&invoiceRune=')); }); + it('closes the modal when close button is clicked', async () => { + const { getActions } = await renderWithProviders(, { preloadedState: customMockStore }); + const modalCloseButton = screen.getByTestId('modal-close'); + fireEvent.click(modalCloseButton); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowModals' && + action.payload.connectWalletModal === false && + action.payload.qrCodeLarge === false + )).toBe(true); + }); + }); + + it('opens large QR Code on QR code click', async () => { + const { getActions } = await renderWithProviders(, { preloadedState: customMockStore }); + const QRContainer = screen.getByTestId('qr-container'); + const QRCanvas = QRContainer.querySelector('canvas'); + expect(QRCanvas).toBeInTheDocument(); + await act(async () => { + if (QRCanvas) { + fireEvent.click(QRCanvas); + } else { + fail('QR canvas not found'); + } + }); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowModals' && + action.payload.connectWalletModal === false && + action.payload.qrCodeLarge === true + )).toBe(true); + }); + }); }); diff --git a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.tsx b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.tsx index b7432d95..0988dfa2 100755 --- a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.tsx +++ b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.tsx @@ -2,198 +2,117 @@ import './ConnectWallet.scss'; import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { QRCodeCanvas } from 'qrcode.react'; -import { Modal, Col, Row, Spinner, Dropdown, InputGroup, Form } from 'react-bootstrap'; +import { Modal, Col, Row, Dropdown, InputGroup, Form } from 'react-bootstrap'; import { CopySVG } from '../../../svgs/Copy'; import { CloseSVG } from '../../../svgs/Close'; import { copyTextToClipboard } from '../../../utilities/data-formatters'; -import { ConnectWalletFields } from '../../../types/root.type'; import logger from '../../../services/logger.service'; -import { AddSVG } from '../../../svgs/Add'; -import { CLNService } from '../../../services/http.service'; -import { setShowModals, setShowToast, setConnectWallet } from '../../../store/rootSlice'; +import { setConnectionUrl, setShowModals, setShowToast } from '../../../store/rootSlice'; import { useDispatch, useSelector } from 'react-redux'; import { selectIsDarkMode, selectShowModals, selectWalletConnect } from '../../../store/rootSelectors'; +import CommandoForm from './CommandoForm/CommandoForm'; +import RESTForm from './RESTForm/RESTForm'; +import GRPCForm from './GRPCForm/GRPCForm'; const ConnectWallet = () => { const dispatch = useDispatch(); const isDarkMode = useSelector(selectIsDarkMode); const connectWallet = useSelector(selectWalletConnect); const showModals = useSelector(selectShowModals); - const [networkTypes, setNetworkTypes] = useState(['LN Message', 'LN Message (Tor)']); - const [selNetwork, setSelNetwork] = useState('LN Message'); + const [networkTypes, setNetworkTypes] = useState(['Commando']); + const [selNetwork, setSelNetwork] = useState('Commando'); const [connectUrl, setConnectUrl] = useState(''); - const initialConnectValues: ConnectWalletFields = { protocol: { title: 'WS Protocol', field: 'LIGHTNING_WS_PROTOCOL' }, port: { title: 'Websocket Port', field: 'LIGHTNING_WS_PORT' }, host: { title: 'CLN Host', field: 'DEVICE_DOMAIN_NAME' }, rune: { title: 'Rune', field: 'COMMANDO_RUNE' }, invoiceRune: { title: 'Invoice Rune', field: 'INVOICE_RUNE' }, connectUrl: { title: 'Lnmessage URL', field: '' } }; - const [connectValues, setConnectValues] = useState(initialConnectValues); - const [isLoadingInvoiceRune, setIsLoadingInvoiceRune] = useState(false); useEffect(() => { - let newNetworkTypes: string[] = ['LN Message', 'LN Message (Tor)']; + let newNetworkTypes: string[] = ['Commando']; + if (connectWallet.LIGHTNING_WS_PORT && connectWallet.LIGHTNING_TOR_HOST && connectWallet.LIGHTNING_TOR_HOST !== '') { + newNetworkTypes.push('Commando (Tor)'); + } if (connectWallet.LIGHTNING_REST_PORT) { newNetworkTypes.push('REST'); - if (connectWallet.TOR_SERVICE && connectWallet.TOR_SERVICE !== '') { + if (connectWallet.LIGHTNING_REST_TOR_HOST && connectWallet.LIGHTNING_REST_TOR_HOST !== '') { newNetworkTypes.push('REST (Tor)'); } } if (connectWallet.LIGHTNING_GRPC_PORT) { newNetworkTypes.push('gRPC'); - if (connectWallet.TOR_SERVICE && connectWallet.TOR_SERVICE !== '') { + if (connectWallet.LIGHTNING_GRPC_TOR_HOST && connectWallet.LIGHTNING_GRPC_TOR_HOST !== '') { newNetworkTypes.push('gRPC (Tor)'); } } setNetworkTypes(newNetworkTypes); - if (selNetwork === 'LN Message') { - setConnectUrl('ln-message://' + connectWallet.LIGHTNING_WS_PROTOCOL + '://' + connectWallet.DEVICE_DOMAIN_NAME + ':' + connectWallet.LIGHTNING_WS_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT : '') + (connectWallet.INVOICE_RUNE !== '' ? '&invoiceRune=' + connectWallet.INVOICE_RUNE : '')); - } + setConnectUrlOnChange(); }, [connectWallet, selNetwork]); - const copyHandler = (event) => { - let textToCopy = ''; - switch (event.target.id) { - case 'WS Protocol': - textToCopy = connectWallet.LIGHTNING_WS_PROTOCOL?.toString() || ''; - break; - case 'Websocket Port': - textToCopy = connectWallet.LIGHTNING_WS_PORT?.toString() || ''; - break; - case 'gRPC Port': - textToCopy = connectWallet.LIGHTNING_GRPC_PORT?.toString() || ''; - break; - case 'REST Port': - textToCopy = connectWallet.LIGHTNING_REST_PORT?.toString() || ''; - break; - case 'CLN Host': - textToCopy = (selNetwork.includes('(Tor)') ? connectWallet.TOR_SERVICE : connectWallet.DEVICE_DOMAIN_NAME) || ''; - break; - case 'Rune': - textToCopy = connectWallet.COMMANDO_RUNE || ''; - break; - case 'Invoice Rune': - textToCopy = connectWallet.INVOICE_RUNE || ''; - break; - case 'Client Key': - textToCopy = connectWallet.CLIENT_KEY || ''; - break; - case 'Client Cert': - textToCopy = connectWallet.CLIENT_CERT || ''; - break; - case 'CA Cert': - textToCopy = connectWallet.CA_CERT || ''; - break; - default: - textToCopy = connectUrl || ''; - break; - } - copyTextToClipboard(textToCopy).then(() => { - dispatch(setShowToast({ show: true, message: (event.target.id + ' Copied Successfully!'), bg: 'success' })); - }).catch((err) => { - logger.error(err); - }); + const closeHandler = () => { + dispatch(setShowModals({ ...showModals, connectWalletModal: false, qrCodeLarge: false })); } - const createInvoiceRuneHandler = () => { - setIsLoadingInvoiceRune(true); - CLNService.createInvoiceRune() - .then((updatedWalletConnectData) => { - dispatch(setConnectWallet(updatedWalletConnectData)); - dispatch(setShowToast({ show: true, message: ('Created Invoice Rune Successfully!'), bg: 'success' })); - }) - .catch(err => { - logger.error(err.message || JSON.stringify(err)); - dispatch(setShowToast({ show: true, message: (`Error Creating Invoice Rune: ${err.message || ''}`), bg: 'danger' })); - }) - .finally(() => { - setIsLoadingInvoiceRune(false); - }); - }; + const showQRCodeLarge = () => { + dispatch(setShowModals({ ...showModals, connectWalletModal: false, qrCodeLarge: true })); + } - const closeHandler = () => { - dispatch(setShowModals({ ...showModals, connectWalletModal: false })); + const setConnectUrlOnChange = () => { + let newConnectUrl = ''; + if (selNetwork === 'Commando' || selNetwork === 'Commando (Tor)') { + newConnectUrl = 'commando+' + connectWallet.LIGHTNING_WS_PROTOCOL + '://' + (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_TOR_HOST : connectWallet.LIGHTNING_HOST) + ':' + connectWallet.LIGHTNING_WS_PORT + '?pubkey=' + connectWallet.NODE_PUBKEY + '&rune=' + connectWallet.ADMIN_RUNE + (connectWallet.INVOICE_RUNE != '' ? '&invoiceRune=' + connectWallet.INVOICE_RUNE : '') + (connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' ? '&certs=' + connectWallet.LIGHTNING_WS_TLS_CERTS : ''); + } else if (selNetwork === 'REST' || selNetwork === 'REST (Tor)') { + newConnectUrl = 'clnrest+' + connectWallet.LIGHTNING_REST_PROTOCOL + '://' + (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_REST_TOR_HOST : connectWallet.LIGHTNING_REST_HOST) + ':' + connectWallet.LIGHTNING_REST_PORT + '?rune=' + connectWallet.ADMIN_RUNE + (connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https' ? '&certs=' + connectWallet.LIGHTNING_REST_TLS_CERTS : ''); + } else if (selNetwork === 'gRPC' || selNetwork === 'gRPC (Tor)') { + newConnectUrl = 'clngrpc://' + (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_GRPC_TOR_HOST : connectWallet.LIGHTNING_GRPC_HOST) + ':' + connectWallet.LIGHTNING_GRPC_PORT + '?pubkey=' + connectWallet.NODE_PUBKEY + '&protoPath=' + connectWallet.LIGHTNING_GRPC_PROTO_PATH + '&certs=' + connectWallet.LIGHTNING_GRPC_TLS_CERTS; + } else { + newConnectUrl = ''; + } + setConnectUrl(newConnectUrl); + dispatch(setConnectionUrl(newConnectUrl)); } const networkChangeHandler = (event) => { setSelNetwork(event.target.id); - switch (event.target.id) { - case 'LN Message': - setConnectValues({ protocol: { title: 'WS Protocol', field: 'LIGHTNING_WS_PROTOCOL' }, port: { title: 'Websocket Port', field: 'LIGHTNING_WS_PORT' }, host: { title: 'CLN Host', field: 'DEVICE_DOMAIN_NAME' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, rune: { title: 'Rune', field: 'COMMANDO_RUNE' }, invoiceRune: { title: 'Invoice Rune', field: 'INVOICE_RUNE' }, connectUrl: { title: 'Lnmessage URL', field: '' } }); - setConnectUrl('ln-message://' + connectWallet.LIGHTNING_WS_PROTOCOL + '://' + connectWallet.DEVICE_DOMAIN_NAME + ':' + connectWallet.LIGHTNING_WS_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT : '') + (connectWallet.INVOICE_RUNE !== '' ? '&invoiceRune=' + connectWallet.INVOICE_RUNE : '')); - break; - - case 'LN Message (Tor)': - setConnectValues({ protocol: { title: 'WS Protocol', field: 'LIGHTNING_WS_PROTOCOL' }, port: { title: 'Websocket Port', field: 'LIGHTNING_WS_PORT' }, host: { title: 'CLN Host', field: 'TOR_SERVICE' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, rune: { title: 'Rune', field: 'COMMANDO_RUNE' }, invoiceRune: { title: 'Invoice Rune', field: 'INVOICE_RUNE' }, connectUrl: { title: 'Lnmessage URL', field: '' } }); - setConnectUrl('ln-message://' + connectWallet.LIGHTNING_WS_PROTOCOL + '://' + connectWallet.TOR_SERVICE + ':' + connectWallet.LIGHTNING_WS_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT : '') + (connectWallet.INVOICE_RUNE !== '' ? '&invoiceRune=' + connectWallet.INVOICE_RUNE : '')); - break; - - case 'REST': - setConnectValues({ protocol: { title: 'REST Protocol', field: 'LIGHTNING_REST_PROTOCOL' }, host: { title: 'CLN Host', field: 'DEVICE_DOMAIN_NAME' }, port: { title: 'REST Port', field: 'LIGHTNING_REST_PORT' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, caCert: { title: 'CA Cert', field: 'CA_CERT' }, connectUrl: { title: 'REST URL', field: '' } }); - setConnectUrl('clnrest://' + connectWallet.LIGHTNING_REST_PROTOCOL + '://' + connectWallet.DEVICE_DOMAIN_NAME + ':' + connectWallet.LIGHTNING_REST_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT + '&caCert=' + connectWallet.CA_CERT : '')); - break; - - case 'REST (Tor)': - setConnectValues({ protocol: { title: 'REST Protocol', field: 'LIGHTNING_REST_PROTOCOL' }, host: { title: 'CLN Host', field: 'TOR_SERVICE' }, port: { title: 'REST Port', field: 'LIGHTNING_REST_PORT' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, caCert: { title: 'CA Cert', field: 'CA_CERT' }, connectUrl: { title: 'REST URL', field: '' } }); - setConnectUrl('clnrest://' + connectWallet.LIGHTNING_REST_PROTOCOL + '://' + connectWallet.TOR_SERVICE + ':' + connectWallet.LIGHTNING_REST_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT + '&caCert=' + connectWallet.CA_CERT : '')); - break; - - case 'gRPC': - setConnectValues({ protocol: { title: 'gRPC Protocol', field: 'LIGHTNING_GRPC_PROTOCOL' }, host: { title: 'CLN Host', field: 'DEVICE_DOMAIN_NAME' }, port: { title: 'GRPC Port', field: 'LIGHTNING_GRPC_PORT' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, caCert: { title: 'CA Cert', field: 'CA_CERT' }, connectUrl: { title: 'gRPC URL', field: '' } }); - setConnectUrl('cln-grpc://' + connectWallet.LIGHTNING_GRPC_PROTOCOL + '://' + connectWallet.DEVICE_DOMAIN_NAME + ':' + connectWallet.LIGHTNING_GRPC_PORT + (connectWallet.LIGHTNING_GRPC_PROTOCOL?.toLowerCase() === 'https' ? '?clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT + '&caCert=' + connectWallet.CA_CERT : '')); - break; - - case 'gRPC (Tor)': - setConnectValues({ protocol: { title: 'gRPC Protocol', field: 'LIGHTNING_GRPC_PROTOCOL' }, host: { title: 'CLN Host', field: 'TOR_SERVICE' }, port: { title: 'GRPC Port', field: 'LIGHTNING_GRPC_PORT' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, caCert: { title: 'CA Cert', field: 'CA_CERT' }, connectUrl: { title: 'gRPC URL', field: '' } }); - setConnectUrl('cln-grpc://' + connectWallet.LIGHTNING_GRPC_PROTOCOL + '://' + connectWallet.TOR_SERVICE + ':' + connectWallet.LIGHTNING_GRPC_PORT + (connectWallet.LIGHTNING_GRPC_PROTOCOL?.toLowerCase() === 'https' ? '?clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT + '&caCert=' + connectWallet.CA_CERT : '')); - break; - - default: - setConnectValues({ protocol: { title: 'WS Protocol', field: 'LIGHTNING_WS_PROTOCOL' }, port: { title: 'Websocket Port', field: 'LIGHTNING_WS_PORT' }, host: { title: 'CLN Host', field: 'DEVICE_DOMAIN_NAME' }, clientKey: { title: 'Client Key', field: 'CLIENT_KEY' }, clientCert: { title: 'Client Cert', field: 'CLIENT_CERT' }, rune: { title: 'Rune', field: 'COMMANDO_RUNE' }, invoiceRune: { title: 'Invoice Rune', field: 'INVOICE_RUNE' }, connectUrl: { title: 'Lnmessage URL', field: '' } }); - setConnectUrl('ln-message://' + connectWallet.LIGHTNING_WS_PROTOCOL + '://' + connectWallet.DEVICE_DOMAIN_NAME + ':' + connectWallet.LIGHTNING_WS_PORT + '?rune=' + connectWallet.COMMANDO_RUNE + (connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' ? '&clientKey=' + connectWallet.CLIENT_KEY + '&clientCert=' + connectWallet.CLIENT_CERT : '') + (connectWallet.INVOICE_RUNE !== '' ? '&invoiceRune=' + connectWallet.INVOICE_RUNE : '')); - break; - } + setConnectUrlOnChange(); }; - let invoiceRuneClickHandler; - let InvoiceRuneSvg; - - if (selNetwork === 'LN Message' || selNetwork === 'LN Message (Tor)') { - if (connectWallet.INVOICE_RUNE && connectWallet.INVOICE_RUNE !== '') { - invoiceRuneClickHandler = copyHandler; - InvoiceRuneSvg = CopySVG; - } else { - invoiceRuneClickHandler = createInvoiceRuneHandler; - InvoiceRuneSvg = AddSVG; - } + const copyHandler = () => { + copyTextToClipboard(connectUrl).then(() => { + dispatch(setShowToast({ show: true, message: ('Connection URL Copied Successfully!'), bg: 'success' })); + }).catch((err) => { + logger.error(err); + }); } return ( <> - - + + - + - + - -

+ +

Connect wallet to your node

- - + + Network - + {selNetwork} @@ -204,209 +123,31 @@ const ConnectWallet = () => { - {connectValues.protocol ? - - {connectValues.protocol.title} - - - - - - - : - } - - - - {connectValues.host.title} - - - - - - - - - {connectValues.port.title || ''} - - - - - - - - {(selNetwork !== 'gRPC' && selNetwork !== 'gRPC (Tor)') && connectValues.rune ? - - - {connectValues.rune.title || ''} - - - - - - - - : <> - } - {(selNetwork === 'LN Message' || selNetwork === 'LN Message (Tor)') && ( - - - {connectValues.invoiceRune?.title || ''} - - - - {isLoadingInvoiceRune ? - - - - : InvoiceRuneSvg && - } - - - - - )} - {(connectWallet.LIGHTNING_WS_PROTOCOL?.toLowerCase() === 'wss' || - connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https' || - connectWallet.LIGHTNING_GRPC_PROTOCOL?.toLowerCase() === 'https') ? - <> - - - {connectValues.clientKey?.title || ''} - - - - - - - - - - - {connectValues.clientCert?.title || ''} - - - - - - - - - - : - <> - } - {(selNetwork === 'REST' && connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https') || - (selNetwork === 'gRPC' && connectWallet.LIGHTNING_GRPC_PROTOCOL?.toLowerCase() === 'https') ? - - - {connectValues.caCert?.title || ''} - - - - - - - - - : - <> + {selNetwork === 'Commando' || selNetwork === 'Commando (Tor)' ? + + : selNetwork === 'REST' || selNetwork === 'REST (Tor)' ? + + : selNetwork === 'gRPC' || selNetwork === 'gRPC (Tor)' ? + + : null } - + - {connectValues.connectUrl?.title || ''} - + Connection URL + - - + + diff --git a/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.scss b/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.scss new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.test.tsx b/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.test.tsx new file mode 100644 index 00000000..2586b94b --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.test.tsx @@ -0,0 +1,122 @@ +import React, { act } from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../../../utilities/test-utilities/mockStore'; +import { mockBKPRStoreData, mockCLNStoreData, mockConnectWallet, mockRootStoreData, mockShowModals } from '../../../../utilities/test-utilities/mockData'; +import ConnectWallet from '../ConnectWallet'; +import * as dataFormatUtils from '../../../../utilities/data-formatters'; + +describe('GRPCForm Component', () => { + const customMockStore = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + + it('renders without crashing', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + expect(screen.getByTestId('grpc-form')).toBeInTheDocument(); + expect(screen.getByTestId('grpc-host')).toBeInTheDocument(); + }); + + it('displays correct values for gRPC network', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + expect(screen.getByTestId('grpc-host')).toHaveValue(mockConnectWallet.LIGHTNING_GRPC_HOST); + expect(screen.getByTestId('grpc-port')).toHaveValue(mockConnectWallet.LIGHTNING_GRPC_PORT.toString()); + expect(screen.getByTestId('node-pubkey')).toHaveValue(mockConnectWallet.NODE_PUBKEY); + expect(screen.getByTestId('proto-path')).toHaveValue(decodeURIComponent(mockConnectWallet.LIGHTNING_GRPC_PROTO_PATH)); + expect(screen.getByTestId('grpc-tls-certs')).toHaveValue(mockConnectWallet.LIGHTNING_GRPC_TLS_CERTS); + }); + + it('displays Tor host when network is gRPC (Tor)', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[5]; + await act(async () => { + fireEvent.click(grpcItem); + }); + expect(screen.getByTestId('grpc-host')).toHaveValue(mockConnectWallet.LIGHTNING_GRPC_TOR_HOST); + }); + + it('copies field values to clipboard when clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + + const fieldsToTest = [ + { id: 'gRPC Host', testId: 'grpc-host', expected: mockConnectWallet.LIGHTNING_GRPC_HOST }, + { id: 'gRPC Port', testId: 'grpc-port', expected: mockConnectWallet.LIGHTNING_GRPC_PORT.toString() }, + { id: 'Node Public Key', testId: 'node-pubkey', expected: mockConnectWallet.NODE_PUBKEY }, + { id: 'Proto Path', testId: 'proto-path', expected: decodeURIComponent(mockConnectWallet.LIGHTNING_GRPC_PROTO_PATH) }, + { id: 'gRPC TLS Certs', testId: 'grpc-tls-certs', expected: mockConnectWallet.LIGHTNING_GRPC_TLS_CERTS } + ]; + for (const [i, field] of fieldsToTest.entries()) { + await act(async () => { + fireEvent.click(screen.getByTestId(field.testId)); + }); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(i + 1, field.expected); + } + }); + + it('toggles between encoded and decoded certs when gRPC TLS Certs is clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + const decodeCombinedCerts = jest.spyOn(dataFormatUtils, 'decodeCombinedCerts'); + const decodedGRPCCerts = `{\n"clientKey": "\n-----BEGIN PRIVATE KEY-----\ngRPCClientKeyValueToBeBase64Encoded\n-----END PRIVATE KEY-----\n",\n"clientCert": "\n-----BEGIN CERTIFICATE-----\ngRPCClientCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n",\n"caCert": "\n-----BEGIN CERTIFICATE-----\ngRPCCaCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n"\n}`; + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('grpc-tls-certs')); + }); + expect(copyTextToClipboard).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_GRPC_TLS_CERTS); + + await act(async () => { + fireEvent.click(screen.getByTestId('grpc-tls-certs')); + }); + expect(decodeCombinedCerts).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_GRPC_TLS_CERTS); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, decodedGRPCCerts); + }); + + it('dispatches success toast when copy is successful', async () => { + const { getActions } = await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const grpcItem = screen.getAllByTestId('network-item')[4]; + await act(async () => { + fireEvent.click(grpcItem); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('grpc-host')); + }); + + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowToast' && + action.payload.message === 'gRPC Host Copied Successfully!' + )).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.tsx b/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.tsx new file mode 100755 index 00000000..45c48725 --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/GRPCForm/GRPCForm.tsx @@ -0,0 +1,156 @@ +import './GRPCForm.scss'; +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Col, Row, InputGroup, Form } from 'react-bootstrap'; + +import { CopySVG } from '../../../../svgs/Copy'; +import { copyTextToClipboard, decodeCombinedCerts } from '../../../../utilities/data-formatters'; +import logger from '../../../../services/logger.service'; +import { setShowToast } from '../../../../store/rootSlice'; +import { selectWalletConnect } from '../../../../store/rootSelectors'; + +const GRPCForm = ({ selNetwork }) => { + const dispatch = useDispatch(); + const connectWallet = useSelector(selectWalletConnect); + const [copyEncoded, setCopyEncoded] = useState(true); + + const copyHandler = (event) => { + let textToCopy = ''; + switch (event.target.id) { + case 'gRPC Host': + textToCopy = (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_GRPC_TOR_HOST : connectWallet.LIGHTNING_GRPC_HOST) || ''; + break; + case 'gRPC Port': + textToCopy = connectWallet.LIGHTNING_GRPC_PORT?.toString() || ''; + break; + case 'Node Public Key': + textToCopy = connectWallet.NODE_PUBKEY || ''; + break; + case 'Proto Path': + textToCopy = decodeURIComponent(connectWallet.LIGHTNING_GRPC_PROTO_PATH || ''); + break; + case 'gRPC TLS Certs': + if (!copyEncoded) { + textToCopy = decodeCombinedCerts(connectWallet.LIGHTNING_GRPC_TLS_CERTS || ''); + } else { + textToCopy = connectWallet.LIGHTNING_GRPC_TLS_CERTS || ''; + } + setCopyEncoded(prev => !prev); + break; + } + copyTextToClipboard(textToCopy).then(() => { + dispatch(setShowToast({ show: true, message: ((event.target.id !== 'gRPC TLS Certs' ? event.target.id : !copyEncoded ? 'Decoded gRPC Certs' : 'Encoded gRPC Certs') + ' Copied Successfully!'), bg: 'success' })); + }).catch((err) => { + logger.error(err); + }); + } + + return ( + + + + gRPC Host + + + + + + + + + gRPC Port + + + + + + + + + + + Node Public Key + + + + + + + + + + + Proto Path + + + + + + + + + + + gRPC TLS Certs (Client Key + Client Cert + Ca Cert) + + + + + + + + + + ); +}; + +export default GRPCForm; diff --git a/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.scss b/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.scss new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.test.tsx b/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.test.tsx new file mode 100644 index 00000000..9b926eb4 --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.test.tsx @@ -0,0 +1,168 @@ +import React, { act } from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../../../utilities/test-utilities/mockStore'; +import { mockBKPRStoreData, mockCLNStoreData, mockConnectWallet, mockRootStoreData, mockShowModals } from '../../../../utilities/test-utilities/mockData'; +import ConnectWallet from '../ConnectWallet'; +import * as dataFormatUtils from '../../../../utilities/data-formatters'; + +describe('QRCodeLarge Component', () => { + const customMockStore = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + const customMockStoreWithoutHTTPS = { + root: { + ...mockRootStoreData, + showModals: { + ...mockShowModals, + connectWalletModal: true, + }, + connectWallet: { ...mockConnectWallet, LIGHTNING_REST_PROTOCOL: 'http' } + }, + cln: mockCLNStoreData, + bkpr: mockBKPRStoreData + }; + + it('renders without crashing', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + expect(screen.getByTestId('rest-protocol')).toBeInTheDocument(); + }); + + it('displays correct values for REST network', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + expect(screen.getByTestId('rest-protocol')).toHaveValue(mockConnectWallet.LIGHTNING_REST_PROTOCOL); + expect(screen.getByTestId('rest-host')).toHaveValue(mockConnectWallet.LIGHTNING_REST_HOST); + expect(screen.getByTestId('rest-port')).toHaveValue(mockConnectWallet.LIGHTNING_REST_PORT.toString()); + expect(screen.getByTestId('rune')).toHaveValue(mockConnectWallet.ADMIN_RUNE); + expect(screen.getByTestId('rest-tls-certs')).toHaveValue(mockConnectWallet.LIGHTNING_REST_TLS_CERTS); + }); + + it('displays Tor host when network is REST (Tor)', async () => { + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[3]; + await act(async () => { + fireEvent.click(restItem); + }); + expect(screen.getByTestId('rest-host')).toHaveValue(mockConnectWallet.LIGHTNING_REST_TOR_HOST); + }); + + it('copies field values to clipboard when clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + + const fieldsToTest = [ + { id: 'REST Protocol', testId: 'rest-protocol', expected: mockConnectWallet.LIGHTNING_REST_PROTOCOL }, + { id: 'REST Host', testId: 'rest-host', expected: mockConnectWallet.LIGHTNING_REST_HOST }, + { id: 'REST Port', testId: 'rest-port', expected: mockConnectWallet.LIGHTNING_REST_PORT.toString() }, + { id: 'Rune', testId: 'rune', expected: mockConnectWallet.ADMIN_RUNE }, + { id: 'REST TLS Certs', testId: 'rest-tls-certs', expected: mockConnectWallet.LIGHTNING_REST_TLS_CERTS }, + ]; + for (const [i, field] of fieldsToTest.entries()) { + await act(async () => { + fireEvent.click(screen.getByTestId(field.testId)); + }); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(i + 1, field.expected); + } + }); + + it('toggles between encoded and decoded certs when REST TLS Certs is clicked', async () => { + const copyTextToClipboard = jest.spyOn(dataFormatUtils, 'copyTextToClipboard'); + const decodeCombinedCerts = jest.spyOn(dataFormatUtils, 'decodeCombinedCerts'); + const decodedRESTCerts = `{\n"clientKey": "\n-----BEGIN PRIVATE KEY-----\nRESTClientKeyValueToBeBase64Encoded\n-----END PRIVATE KEY-----\n",\n"clientCert": "\n-----BEGIN CERTIFICATE-----\nRESTClientCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n",\n"caCert": "\n-----BEGIN CERTIFICATE-----\nRESTCaCertValueToBeBase64Encoded\n-----END CERTIFICATE-----\n"\n}`; + await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('rest-tls-certs')); + }); + expect(copyTextToClipboard).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_REST_TLS_CERTS); + + await act(async () => { + fireEvent.click(screen.getByTestId('rest-tls-certs')); + }); + expect(decodeCombinedCerts).toHaveBeenCalledWith(mockConnectWallet.LIGHTNING_REST_TLS_CERTS); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, decodedRESTCerts); + }); + + it('hides REST TLS Certs when protocol is http', async () => { + await renderWithProviders(, { preloadedState: customMockStoreWithoutHTTPS }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + expect(screen.queryByTestId('rest-tls-certs')).not.toBeInTheDocument(); + }); + + it('dispatches success toast when copy is successful', async () => { + const { getActions } = await renderWithProviders(, { preloadedState: customMockStoreWithoutHTTPS }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('rest-host')); + }); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowToast' && + action.payload.message === 'REST Host Copied Successfully!' + )).toBe(true); + }); + }); + + it('shows correct tooltip text for TLS certs copy button', async () => { + const { getActions } = await renderWithProviders(, { preloadedState: customMockStore }); + fireEvent.click(screen.getByTestId('network-toggle')); + const restItem = screen.getAllByTestId('network-item')[2]; + await act(async () => { + fireEvent.click(restItem); + }); + const restTlsCerts = screen.getByTestId('rest-tls-certs'); + await act(async () => { + fireEvent.click(restTlsCerts); + }); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowToast' && + action.payload.message === 'Encoded REST Certs Copied Successfully!' + )).toBe(true); + }); + await act(async () => { + fireEvent.click(restTlsCerts); + }); + await waitFor(() => { + expect(getActions().some(action => + action.type === 'root/setShowToast' && + action.payload.message === 'Decoded REST Certs Copied Successfully!' + )).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.tsx b/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.tsx new file mode 100755 index 00000000..28af9d77 --- /dev/null +++ b/apps/frontend/src/components/modals/ConnectWallet/RESTForm/RESTForm.tsx @@ -0,0 +1,156 @@ +import './RESTForm.scss'; +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Col, Row, InputGroup, Form } from 'react-bootstrap'; + +import { CopySVG } from '../../../../svgs/Copy'; +import { copyTextToClipboard, decodeCombinedCerts } from '../../../../utilities/data-formatters'; +import logger from '../../../../services/logger.service'; +import { setShowToast } from '../../../../store/rootSlice'; +import { selectWalletConnect } from '../../../../store/rootSelectors'; + +const RESTForm = ({ selNetwork }) => { + const dispatch = useDispatch(); + const connectWallet = useSelector(selectWalletConnect); + const [copyEncoded, setCopyEncoded] = useState(true); + + const copyHandler = (event) => { + let textToCopy = ''; + switch (event.target.id) { + case 'REST Protocol': + textToCopy = connectWallet.LIGHTNING_REST_PROTOCOL || ''; + break; + case 'REST Host': + textToCopy = (selNetwork.includes('(Tor)') ? connectWallet.LIGHTNING_REST_TOR_HOST : connectWallet.LIGHTNING_REST_HOST) || ''; + break; + case 'REST Port': + textToCopy = connectWallet.LIGHTNING_REST_PORT?.toString() || ''; + break; + case 'Rune': + textToCopy = connectWallet.ADMIN_RUNE || ''; + break; + case 'REST TLS Certs': + if (!copyEncoded) { + textToCopy = decodeCombinedCerts(connectWallet.LIGHTNING_REST_TLS_CERTS || ''); + } else { + textToCopy = connectWallet.LIGHTNING_REST_TLS_CERTS || ''; + } + setCopyEncoded(prev => !prev); + break; + } + copyTextToClipboard(textToCopy).then(() => { + dispatch(setShowToast({ show: true, message: ((event.target.id !== 'REST TLS Certs' ? event.target.id : !copyEncoded ? 'Decoded REST Certs' : 'Encoded REST Certs') + ' Copied Successfully!'), bg: 'success' })); + }).catch((err) => { + logger.error(err); + }); + } + + return ( + + + + REST Protocol + + + + + + + + + REST Host + + + + + + + + + REST Port + + + + + + + + + + + Rune + + + + + + + + + {connectWallet.LIGHTNING_REST_PROTOCOL?.toLowerCase() === 'https' && ( + + + REST TLS Certs (Client Key + Client Cert + Ca Cert) + + + + + + + + + )} + + ); +}; + +export default RESTForm; diff --git a/apps/frontend/src/components/modals/Login/Login.tsx b/apps/frontend/src/components/modals/Login/Login.tsx index 1b75f6de..37d7dafe 100644 --- a/apps/frontend/src/components/modals/Login/Login.tsx +++ b/apps/frontend/src/components/modals/Login/Login.tsx @@ -86,7 +86,7 @@ const LoginComponent = () => { return (
- + @@ -127,7 +127,7 @@ const LoginComponent = () => { - +