├── public ├── favicon.ico ├── img │ ├── 4_login.png │ ├── 1_welcome.png │ ├── 2_connect.png │ └── 3_connected.png ├── placeholder.png ├── robots.txt ├── manifest.json └── index.html ├── .prettierrc ├── config.example.json ├── src ├── setupTests.ts ├── index.tsx ├── reportWebVitals.ts ├── App.tsx ├── utils │ ├── Base64Utils.test.ts │ ├── RequestUtils.ts │ └── Base64Utils.ts ├── hooks │ └── useLNC.ts ├── react-app-env.d.ts ├── logo.svg ├── pages │ ├── Connect.tsx │ ├── Login.tsx │ └── Home.tsx ├── zeus-logo.svg └── components │ └── Page.tsx ├── config ├── webpack │ └── persistentCache │ │ └── createEnvironmentHash.js ├── jest │ ├── cssTransform.js │ ├── babelTransform.js │ └── fileTransform.js ├── getHttpsConfig.js ├── paths.js ├── modules.js ├── env.js ├── webpackDevServer.config.js └── webpack.config.js ├── .gitignore ├── .prettierrc.json ├── README.md ├── tsconfig.json ├── scripts ├── test.js ├── start.js └── build.js ├── package.json └── LICENSE /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/img/4_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/img/4_login.png -------------------------------------------------------------------------------- /public/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/placeholder.png -------------------------------------------------------------------------------- /public/img/1_welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/img/1_welcome.png -------------------------------------------------------------------------------- /public/img/2_connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/img/2_connect.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/img/3_connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeusLN/echo/HEAD/public/img/3_connected.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "semi": true, 5 | "trailingComma": "none", 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "podcastIndexAPIKey": "XXXXXXXXXXXXXXXXXXXX", 3 | "podcastIndexSecretKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 4 | } 5 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /config/webpack/persistentCache/createEnvironmentHash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { createHash } = require('crypto'); 3 | 4 | module.exports = env => { 5 | const hash = createHash('md5'); 6 | hash.update(JSON.stringify(env)); 7 | 8 | return hash.digest('hex'); 9 | }; 10 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | build.zip 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # config 27 | config.json 28 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [".prettierrc", ".babelrc", ".eslintrc", ".stylelintrc"], 5 | "options": { 6 | "parser": "json" 7 | } 8 | } 9 | ], 10 | "printWidth": 90, 11 | "proseWrap": "always", 12 | "singleQuote": true, 13 | "useTabs": false, 14 | "semi": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "bracketSpacing": true, 18 | "jsxBracketSameLine": false, 19 | "arrowParens": "avoid" 20 | } 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import 'bootstrap/dist/css/bootstrap.min.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render(); 11 | 12 | // If you want to start measuring performance in your app, pass a function 13 | // to log results (for example: reportWebVitals(console.log)) 14 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 15 | reportWebVitals(); 16 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then( 6 | ({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 7 | getCLS(onPerfEntry); 8 | getFID(onPerfEntry); 9 | getFCP(onPerfEntry); 10 | getLCP(onPerfEntry); 11 | getTTFB(onPerfEntry); 12 | } 13 | ); 14 | } 15 | }; 16 | 17 | export default reportWebVitals; 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Echo 2 | 3 | A Podcasting 2.0 web player that connects to your own node using Lightning Node Connect 4 | 5 | echo-screenshot 6 | 7 | ## Getting started 8 | 9 | 1. `npm i` 10 | 2. `cp config.example.json config.json` 11 | 3. Populate `config.json` with credentials from https://api.podcastindex.org/developer_home 12 | 4. `npm start` 13 | 14 | Lightning Node Connect documentation: https://docs.lightning.engineering/lightning-network-tools/lightning-terminal/lightning-node-connect 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 3 | import Connect from './pages/Connect'; 4 | import Home from './pages/Home'; 5 | import Login from './pages/Login'; 6 | 7 | function App() { 8 | return ( 9 | <> 10 | 11 | 12 | } /> 13 | } /> 14 | } /> 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /config/jest/babelTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const babelJest = require('babel-jest').default; 4 | 5 | const hasJsxRuntime = (() => { 6 | if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') { 7 | return false; 8 | } 9 | 10 | try { 11 | require.resolve('react/jsx-runtime'); 12 | return true; 13 | } catch (e) { 14 | return false; 15 | } 16 | })(); 17 | 18 | module.exports = babelJest.createTransformer({ 19 | presets: [ 20 | [ 21 | require.resolve('babel-preset-react-app'), 22 | { 23 | runtime: hasJsxRuntime ? 'automatic' : 'classic', 24 | }, 25 | ], 26 | ], 27 | babelrc: false, 28 | configFile: false, 29 | }); 30 | -------------------------------------------------------------------------------- /src/utils/Base64Utils.test.ts: -------------------------------------------------------------------------------- 1 | import { utf8ToHexString } from './Base64Utils'; 2 | 3 | describe('Base64Utils', () => { 4 | describe('utf8ToHexString', () => { 5 | it('Converts utf8 string to hexidecimal string', () => { 6 | expect(utf8ToHexString('Test string with punctuation.')).toEqual( 7 | '5465737420737472696e6720776974682070756e6374756174696f6e2e' 8 | ); 9 | expect(utf8ToHexString('1234567890')).toEqual( 10 | '31323334353637383930' 11 | ); 12 | expect(utf8ToHexString('!@#$%^&*')).toEqual('21402324255e262a'); 13 | expect(utf8ToHexString('áéíÓÚ àèìÒÙ âêîÔÛ')).toEqual( 14 | 'c3a1c3a9c3adc393c39a20c3a0c3a8c3acc392c39920c3a2c3aac3aec394c39b' 15 | ); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/hooks/useLNC.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import LNC from '@lightninglabs/lnc-web'; 3 | 4 | // create a singleton instance of LNC that will live for the lifetime of the app 5 | const lnc = new LNC({}); 6 | 7 | /** 8 | * A hook that exposes a single LNC instance of LNC to all component that need it. 9 | * It also returns a couple helper functions to simplify the usage of LNC 10 | */ 11 | const useLNC = () => { 12 | /** Connects to LNC using the provided pairing phrase and password */ 13 | const connect = useCallback( 14 | async (pairingPhrase: string, password: string) => { 15 | lnc.credentials.pairingPhrase = pairingPhrase; 16 | await lnc.connect(); 17 | // verify we can fetch data 18 | await lnc.lnd.lightning.listChannels(); 19 | // set the password after confirming the connection works 20 | lnc.credentials.password = password; 21 | }, 22 | [] 23 | ); 24 | 25 | /** Connects to LNC using the password to decrypt the stored keys */ 26 | const login = useCallback(async (password: string) => { 27 | lnc.credentials.password = password; 28 | await lnc.connect(); 29 | }, []); 30 | 31 | return { lnc, connect, login }; 32 | }; 33 | 34 | export default useLNC; 35 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const camelcase = require('camelcase'); 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)); 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }); 19 | const componentName = `Svg${pascalCaseFilename}`; 20 | return `const React = require('react'); 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | }; 34 | }), 35 | };`; 36 | } 37 | 38 | return `module.exports = ${assetFilename};`; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = 'test'; 5 | process.env.NODE_ENV = 'test'; 6 | process.env.PUBLIC_URL = ''; 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on('unhandledRejection', err => { 12 | throw err; 13 | }); 14 | 15 | // Ensure environment variables are read. 16 | require('../config/env'); 17 | 18 | const jest = require('jest'); 19 | const execSync = require('child_process').execSync; 20 | let argv = process.argv.slice(2); 21 | 22 | function isInGitRepository() { 23 | try { 24 | execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); 25 | return true; 26 | } catch (e) { 27 | return false; 28 | } 29 | } 30 | 31 | function isInMercurialRepository() { 32 | try { 33 | execSync('hg --cwd . root', { stdio: 'ignore' }); 34 | return true; 35 | } catch (e) { 36 | return false; 37 | } 38 | } 39 | 40 | // Watch unless on CI or explicitly running all tests 41 | if ( 42 | !process.env.CI && 43 | argv.indexOf('--watchAll') === -1 && 44 | argv.indexOf('--watchAll=false') === -1 45 | ) { 46 | // https://github.com/facebook/create-react-app/issues/5210 47 | const hasSourceControl = isInGitRepository() || isInMercurialRepository(); 48 | argv.push(hasSourceControl ? '--watch' : '--watchAll'); 49 | } 50 | 51 | 52 | jest.run(argv); 53 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare namespace NodeJS { 6 | interface ProcessEnv { 7 | readonly NODE_ENV: 'development' | 'production' | 'test'; 8 | readonly PUBLIC_URL: string; 9 | } 10 | } 11 | 12 | declare module '*.avif' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.bmp' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.gif' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.jpg' { 28 | const src: string; 29 | export default src; 30 | } 31 | 32 | declare module '*.jpeg' { 33 | const src: string; 34 | export default src; 35 | } 36 | 37 | declare module '*.png' { 38 | const src: string; 39 | export default src; 40 | } 41 | 42 | declare module '*.webp' { 43 | const src: string; 44 | export default src; 45 | } 46 | 47 | declare module '*.svg' { 48 | import * as React from 'react'; 49 | 50 | export const ReactComponent: React.FunctionComponent< 51 | React.SVGProps & { title?: string } 52 | >; 53 | 54 | const src: string; 55 | export default src; 56 | } 57 | 58 | declare module '*.module.css' { 59 | const classes: { readonly [key: string]: string }; 60 | export default classes; 61 | } 62 | 63 | declare module '*.module.scss' { 64 | const classes: { readonly [key: string]: string }; 65 | export default classes; 66 | } 67 | 68 | declare module '*.module.sass' { 69 | const classes: { readonly [key: string]: string }; 70 | export default classes; 71 | } 72 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Echo 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/utils/RequestUtils.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import config from './../../config.json'; 3 | import packageInfo from './../../package.json'; 4 | 5 | const sha1 = require('js-sha1'); 6 | 7 | const BASE_URL = 'https://api.podcastindex.org/api/1.0'; 8 | 9 | const reqInstance = () => { 10 | const unixTime = new Date().getTime().toString().slice(0, -3); 11 | const { podcastIndexAPIKey, podcastIndexSecretKey } = config; 12 | const hash = sha1( 13 | `${podcastIndexAPIKey}${podcastIndexSecretKey}${unixTime}` 14 | ); 15 | 16 | return axios.create({ 17 | headers: { 18 | Authorization: hash, 19 | 'X-Auth-Key': podcastIndexAPIKey, 20 | 'X-Auth-Date': unixTime, 21 | 'User-Agent': `Echo v${packageInfo.version}` 22 | } 23 | }); 24 | }; 25 | 26 | const searchPodcasts = (searchString = '') => { 27 | return reqInstance() 28 | .get(`${BASE_URL}/search/byterm?q=${searchString}`) 29 | .then((res: any) => { 30 | const shows = res.data.feeds; 31 | return shows; 32 | }) 33 | .catch((err: Error) => { 34 | console.log('Error: ', err.message); 35 | }); 36 | }; 37 | 38 | const podcastByFeedId = (searchString = '') => { 39 | return reqInstance() 40 | .get(`${BASE_URL}/podcasts/byfeedid?id=${searchString}`) 41 | .then((res: any) => { 42 | const show = res.data.feed; 43 | return show; 44 | }) 45 | .catch((err: Error) => { 46 | console.log('Error: ', err.message); 47 | }); 48 | }; 49 | 50 | const episodesByFeedId = (searchString = '') => { 51 | return reqInstance() 52 | .get(`${BASE_URL}/episodes/byfeedid?id=${searchString}`) 53 | .then((res: any) => { 54 | const episodes = res.data.items; 55 | return episodes; 56 | }) 57 | .catch((err: Error) => { 58 | console.log('Error: ', err.message); 59 | }); 60 | }; 61 | 62 | export { searchPodcasts, podcastByFeedId, episodesByFeedId }; 63 | -------------------------------------------------------------------------------- /config/getHttpsConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const crypto = require('crypto'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const paths = require('./paths'); 8 | 9 | // Ensure the certificate and key provided are valid and if not 10 | // throw an easy to debug error 11 | function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { 12 | let encrypted; 13 | try { 14 | // publicEncrypt will throw an error with an invalid cert 15 | encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); 16 | } catch (err) { 17 | throw new Error( 18 | `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` 19 | ); 20 | } 21 | 22 | try { 23 | // privateDecrypt will throw an error with an invalid key 24 | crypto.privateDecrypt(key, encrypted); 25 | } catch (err) { 26 | throw new Error( 27 | `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ 28 | err.message 29 | }` 30 | ); 31 | } 32 | } 33 | 34 | // Read file and throw an error if it doesn't exist 35 | function readEnvFile(file, type) { 36 | if (!fs.existsSync(file)) { 37 | throw new Error( 38 | `You specified ${chalk.cyan( 39 | type 40 | )} in your env, but the file "${chalk.yellow(file)}" can't be found.` 41 | ); 42 | } 43 | return fs.readFileSync(file); 44 | } 45 | 46 | // Get the https config 47 | // Return cert files if provided in env, otherwise just true or false 48 | function getHttpsConfig() { 49 | const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; 50 | const isHttps = HTTPS === 'true'; 51 | 52 | if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { 53 | const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); 54 | const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); 55 | const config = { 56 | cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), 57 | key: readEnvFile(keyFile, 'SSL_KEY_FILE'), 58 | }; 59 | 60 | validateKeyAndCerts({ ...config, keyFile, crtFile }); 61 | return config; 62 | } 63 | return isHttps; 64 | } 65 | 66 | module.exports = getHttpsConfig; 67 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 13 | // "public path" at which the app is served. 14 | // webpack needs to know it to put the right