├── .codeclimate.yml ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── lint.yaml ├── .gitignore ├── .pug-lintrc ├── LICENSE ├── README.md ├── config.js ├── lib ├── api │ ├── index.js │ ├── streamlabs │ │ ├── authentication.js │ │ ├── donations.js │ │ ├── index.js │ │ └── routes.js │ ├── tipeeestream │ │ └── index.js │ ├── twitch │ │ ├── authentication.js │ │ ├── follows.js │ │ ├── hosts.js │ │ ├── index.js │ │ ├── routes.js │ │ └── subscriptions.js │ └── twitcheventtracker │ │ └── index.js ├── authentication.js ├── dashboard │ ├── feed.js │ ├── index.js │ ├── settings.js │ ├── socket.js │ └── stats.js ├── database.js ├── index.js ├── middleware │ └── static-serve.js ├── notification.js ├── routes.js ├── scheduler.js ├── server.js └── template.js ├── package-lock.json ├── package.json ├── public ├── authentication │ ├── css │ │ └── style.css │ └── img │ │ └── locked.svg ├── dashboard │ ├── css │ │ ├── color.css │ │ ├── dashboard.css │ │ ├── main.css │ │ └── settings.css │ ├── img │ │ └── back.svg │ └── js │ │ ├── charts.bundle.js │ │ ├── charts.js │ │ ├── main.bundle.js │ │ └── main.js └── template │ └── js │ ├── main.bundle.js │ └── main.js ├── templates └── default │ ├── donations.pug │ ├── example-audio.pug │ ├── example-custom.pug │ ├── follows.pug │ ├── hosts.pug │ ├── includes │ └── head.pug │ ├── index.pug │ ├── ressources │ └── css │ │ └── notification.css │ └── subscriptions.pug └── views ├── authentication ├── locked.pug └── signup.pug ├── dashboard ├── dashboard.pug ├── include │ ├── footer.pug │ ├── head.pug │ └── header.pug └── settings.pug └── includes └── template.pug /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | csslint: 4 | enabled: true 5 | checks: 6 | box-sizing: 7 | enabled: false 8 | fallback-colors: 9 | enabled: false 10 | important: 11 | enabled: false 12 | duplication: 13 | enabled: true 14 | config: 15 | languages: 16 | - javascript 17 | eslint: 18 | enabled: true 19 | ratings: 20 | paths: 21 | - "**.js" 22 | - "**.css" 23 | exclude_paths: [] 24 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.min.js 2 | **/*.bundle.js 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": ["eslint:recommended"], 7 | "parserOptions": { 8 | "ecmaVersion": 8 9 | }, 10 | "rules": { 11 | "strict": [ 12 | "error", 13 | "global" 14 | ], 15 | "eqeqeq": "error", 16 | "no-var": "error", 17 | "indent": [ 18 | "error", 19 | 2 20 | ], 21 | "linebreak-style": [ 22 | "error", 23 | "unix" 24 | ], 25 | "quotes": [ 26 | "error", 27 | "single" 28 | ], 29 | "semi": [ 30 | "error", 31 | "always" 32 | ], 33 | "semi-style": [ 34 | "error", 35 | "last" 36 | ], 37 | "no-console": 0 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: push 3 | jobs: 4 | 5 | lint: 6 | name: Lint 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up NodeJS 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '10.x' 14 | 15 | - name: Check out code into the Go module directory 16 | uses: actions/checkout@v1 17 | 18 | - name: Install packages 19 | run: npm install --loglevel=verbose 20 | 21 | - name: Run linter 22 | run: npm run lint 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Template symlink 40 | views/template 41 | 42 | # Database file 43 | db.json 44 | -------------------------------------------------------------------------------- /.pug-lintrc: -------------------------------------------------------------------------------- 1 | { 2 | "disallowIdLiterals": null 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Markus Wiegand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **The project is no longer maintained!** 2 | 3 | This was my first project, with which I entered the software development. Since the project no longer meets my standards and NodeJS is no longer one of my main languages, I have decided to stop support completely. 4 | 5 | # Broadcast Notification System 6 | The Broadcast Notification System (BNS) is an open, simple and highly customisable notification/alert system for live streams on Twitch and YouTube. 7 | 8 | ### Features 9 | - Complete design freedom *(fully HTML, CSS and JS editing)* 10 | - Trigger different notifications and variations according to your own rules *(soon with v0.11)* 11 | - Modern and lightweight dashboard with different options, activity feed and weekly statistics 12 | - Different API support *(look at notes)* 13 | - Passwordless authentication *(optional)* 14 | - Runs on your local machine and on remote hosts 15 | - Cross-platform support 16 | 17 | --- 18 | 19 | ## Notes 20 | 21 | **The project is still in development and not feature complete!** 22 | **[Roadmap](https://github.com/Morphy2k/broadcast-notification-system/projects)** 23 | 24 | #### Notification type support 25 | - Follows 26 | - Subscriptions 27 | - Donations 28 | - Hosts 29 | 30 | #### API support 31 | - [x] Twitch 32 | - [x] Streamlabs 33 | - [ ] TipeeeStream 34 | - [ ] Twitch Event Tracker *(own project)* 35 | - [ ] YouTube 36 | 37 | #### Current restrictions 38 | - Only **new** subscriptions will show up, no resubs! *(This will change with a future API implementation like TipeeeStream)* 39 | 40 | ### Requirements 41 | - HTML and CSS skills *(+ JS optional)* 42 | - Technical know-how 43 | - NodeJS runtime 44 | - Server (optional) 45 | - OBS Studio + Browser plugin *(plugin in full package included)* 46 | 47 | --- 48 | 49 | ## Getting started 50 | 51 | ### Install 52 | 53 | 1. Download and install the current [NodeJS](https://nodejs.org) version 54 | 2. [Download](https://github.com/Morphy2k/broadcast-notification-system/releases/latest) and extract or clone the repo 55 | 3. Open the bash or command prompt and switch to BNS directory *(as admin on Windows)* 56 | 57 | Install it with 58 | ```bash 59 | $ npm install --only=production 60 | ``` 61 | Configure the app via `config.js` in the root directory 62 | 63 | Start the app with 64 | ```bash 65 | $ npm start --production 66 | ``` 67 | 68 | ### Use 69 | 70 | 1. Open the dashboard via `http://localhost:8080` or what you have configured *(for best experience please use a chromium based browser)* 71 | 2. Make your settings 72 | 3. Take the default template as example and build your own 73 | 4. Put `http://localhost:8080/notification[/endpoint]` in your OBS browser source *(if authentication on, with token at the end)* 74 | 75 | If you have questions or find a bug, please [open an issue](https://github.com/Morphy2k/broadcast-notification-system/issues/new) 76 | 77 | **A better guide and wiki will follow later!** 78 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | server: { 5 | host: 'localhost', // if proxy in use set proxy host 6 | port: 8080, 7 | proxy: false, 8 | proxyPort: 80, 9 | compression: true // static file compression (a bit higher CPU/RAM usage, but smaller files to transfer) 10 | }, 11 | authentication: { // !!!IMPORTANT!!! works only with HTTPS connection! (use nginx or similar as reverse-proxy for HTTPS) 12 | enabled: false, // can be disabled if it runs on a local machine 13 | cookieExp: 2880, // cookie expiration time in minutes (lower value = more secure) 14 | tokenExp: 7, // token expiration time in days (lower value = more secure) 15 | mail: { // Nodermailer configuration (https://nodemailer.com/smtp/well-known/) 16 | service: '"Mailgun"', // no need to set host or port etc. 17 | auth: { 18 | api_key: '', 19 | domain: '' 20 | } 21 | } 22 | }, 23 | api: { 24 | twitch: { // https://www.twitch.tv/kraken/oauth2/clients/new 25 | client_id: '', 26 | client_secret: '' 27 | }, 28 | streamlabs: { // https://streamlabs.com/dashboard/#/apps/register 29 | client_id: '', 30 | client_secret: '' 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /lib/api/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // api modules 4 | exports.twitch = require('./twitch'); 5 | exports.streamlabs = require('./streamlabs'); 6 | 7 | // api routes 8 | const twitchRoutes = require('./twitch/routes'); 9 | const streamlabsRoutes = require('./streamlabs/routes'); 10 | 11 | exports.routes = app => { 12 | app.use(twitchRoutes); 13 | app.use(streamlabsRoutes); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/api/streamlabs/authentication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | 5 | const config = require('../../../config'); 6 | const db = require('../../database'); 7 | const version = require('../../../package.json').version; 8 | 9 | 10 | class Authentication { 11 | constructor() { 12 | this.options = { 13 | headers: { 14 | 'User-Agent': `BroadcastNotificationSystem/${version} (StreamlabsModule)` 15 | }, 16 | uri: 'https://streamlabs.com/api/v1.0/token', 17 | formData: { 18 | client_id: config.api.streamlabs.client_id, 19 | client_secret: config.api.streamlabs.client_secret 20 | } 21 | }; 22 | } 23 | 24 | async new(query) { 25 | 26 | const state = db.settings.get('api.state').value(); 27 | 28 | if (query.state !== state) return Promise.reject('State not valid! Maybe CSRF!'); 29 | 30 | let options = Object.assign({ 31 | method: 'POST', 32 | }, this.options); 33 | 34 | Object.assign(options.formData, { 35 | grant_type: 'authorization_code', 36 | code: query.code, 37 | redirect_uri: `${db.settings.get('uri').value()}/api/streamlabs/auth` 38 | }); 39 | 40 | try { 41 | const body = await request(options); 42 | 43 | const json = JSON.parse(body), 44 | token = json.access_token, 45 | refresh_token = json.refresh_token, 46 | expiration_date = Math.floor(Date.now() / 1000 + 50 * 60); 47 | 48 | db.settings.get('api.streamlabs').assign({ 49 | enabled: true, 50 | auth: { 51 | token, 52 | refresh_token, 53 | expiration_date 54 | } 55 | }).write(); 56 | 57 | require('./').init(); 58 | require('../../scheduler').api('streamlabs'); 59 | 60 | return; 61 | 62 | } catch (err) { 63 | return Promise.reject(err); 64 | } 65 | 66 | } 67 | 68 | async refresh() { 69 | 70 | let options = Object.assign({ 71 | method: 'POST', 72 | }, this.options); 73 | 74 | Object.assign(options.formData, { 75 | grant_type: 'refresh_token', 76 | refresh_token: db.settings.get('api.streamlabs.auth.refresh_token').value() 77 | }); 78 | 79 | try { 80 | const body = await request(options); 81 | 82 | const json = JSON.parse(body), 83 | token = json.access_token, 84 | refresh_token = json.refresh_token, 85 | expiration_date = Math.floor(Date.now() / 1000 + 50 * 60); 86 | 87 | db.settings.get('api.streamlabs.auth') 88 | .assign({ 89 | token, 90 | refresh_token, 91 | expiration_date 92 | }).write(); 93 | 94 | return; 95 | 96 | } catch (err) { 97 | return Promise.reject(err); 98 | } 99 | 100 | } 101 | } 102 | 103 | const authentication = new Authentication(); 104 | module.exports = authentication; 105 | -------------------------------------------------------------------------------- /lib/api/streamlabs/donations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | const { DateTime } = require('luxon'); 5 | 6 | const db = require('../../database'); 7 | 8 | 9 | class Donations { 10 | constructor(options) { 11 | this.options = Object.assign({}, options); 12 | this.options.uri = 'https://www.streamlabs.com/api/v1.0/donations'; 13 | } 14 | 15 | async check() { 16 | 17 | let data; 18 | try { 19 | data = await this.worker(); 20 | } catch (e) { 21 | return Promise.reject(e); 22 | } 23 | 24 | if (data) { 25 | db.queue.get('donations').set('last', data.arr[0].date).write(); 26 | 27 | data.arr.reverse(); 28 | 29 | db.stats.get('donations.count').assign(data.statsCount).write(); 30 | db.stats.get('donations.amount').assign(data.statsAmount).write(); 31 | 32 | return { 33 | type: 'donations', 34 | arr: data.arr 35 | }; 36 | } else { 37 | return null; 38 | } 39 | } 40 | 41 | worker() { 42 | 43 | const dt = DateTime.utc(); 44 | 45 | let arr = [], 46 | statsCount = db.stats.get('donations.count').value(), 47 | statsAmount = db.stats.get('donations.amount').value(), 48 | options = this.options, 49 | last = db.queue.get('donations.last').value(); 50 | 51 | if (!last) { 52 | last = dt.startOf('week').toISO(); 53 | db.queue.get('donations').set('last', last).write(); 54 | } 55 | last = new Date(last); 56 | 57 | 58 | const get = async next => { 59 | 60 | if (next) Object.assign(options.qs, { 61 | after: next 62 | }); 63 | 64 | try { 65 | return parse(await request(options)); 66 | } catch (err) { 67 | return Promise.reject(err); 68 | } 69 | 70 | }; 71 | 72 | 73 | const parse = body => { 74 | 75 | const json = JSON.parse(body), 76 | donations = json.data, 77 | length = donations.length; 78 | 79 | let i = 1; 80 | 81 | if (length) { 82 | for (let donation of donations) { 83 | 84 | const id = parseInt(donation.donation_id), 85 | name = donation.name, 86 | date = new Date(donation.created_at * 1000), 87 | amount = parseInt(donation.amount), 88 | message = donation.message, 89 | currency = donation.currency; 90 | 91 | if (date > last) { 92 | 93 | arr.push({ 94 | id, 95 | name, 96 | amount, 97 | message, 98 | date, 99 | currency 100 | }); 101 | 102 | const day = DateTime.fromISO(date.toISOString()).weekday - 1; 103 | statsCount[day] = statsCount[day] + 1; 104 | statsAmount[day] = statsAmount[day] + amount; 105 | 106 | } else if (arr.length) { 107 | return { 108 | arr, 109 | statsCount, 110 | statsAmount 111 | }; 112 | } else { 113 | return null; 114 | } 115 | 116 | if (i === length && length >= options.qs.limit) { 117 | return get(id); 118 | } else if (i === length) { 119 | return { 120 | arr, 121 | statsCount, 122 | statsAmount 123 | }; 124 | } 125 | 126 | i = i + 1; 127 | } 128 | } else { 129 | return null; 130 | } 131 | }; 132 | 133 | return get(); 134 | } 135 | 136 | } 137 | 138 | module.exports = Donations; 139 | -------------------------------------------------------------------------------- /lib/api/streamlabs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('../../database'); 4 | const authentication = require('./authentication'); 5 | const version = require('../../../package.json').version; 6 | 7 | const Donations = require('./donations'); 8 | 9 | 10 | class Streamlabs { 11 | constructor() { 12 | if (db.settings.get('api.streamlabs.auth.token').value() && !this.token) this.init(); 13 | } 14 | 15 | init() { 16 | this.token = db.settings.get('api.streamlabs.auth.token').value(); 17 | this.currency = db.settings.get('api.streamlabs.currency').value(); 18 | this.options = { 19 | headers: { 20 | 'User-Agent': `BroadcastNotificationSystem/${version}` 21 | }, 22 | qs: { 23 | access_token: this.token, 24 | limit: 15 25 | } 26 | }; 27 | 28 | this.donations = new Donations(this.options); 29 | } 30 | 31 | async check() { 32 | const expDate = db.settings.get('api.streamlabs.auth.expiration_date').value(), 33 | date = Math.floor(Date.now() / 1000); 34 | 35 | if (!this.token) { 36 | 37 | console.error(new Error('Streamlabs authentication not set!')); 38 | return; 39 | 40 | } else if (date > expDate) { 41 | 42 | try { 43 | await authentication.refresh(); 44 | } catch (e) { 45 | console.error(new Error(e)); 46 | } 47 | 48 | this.init(); 49 | 50 | return; 51 | 52 | } else { 53 | 54 | const donations = this.get('donations'); 55 | 56 | const data = await Promise.all([donations]); 57 | 58 | const push = obj => { 59 | for (let el of obj.arr) { 60 | db.queue.get(`${obj.type}.list`).push(el).write(); 61 | } 62 | }; 63 | 64 | for (let obj of data) { 65 | if (obj) push(obj); 66 | } 67 | 68 | return; 69 | 70 | } 71 | } 72 | 73 | async get(type) { 74 | let data; 75 | 76 | if (db.settings.get(`notification.types.${type}`).value()) { 77 | try { 78 | data = await this[type].check(); 79 | } catch (e) { 80 | console.error(new Error(e)); 81 | return null; 82 | } 83 | } else { 84 | return null; 85 | } 86 | 87 | return data; 88 | } 89 | 90 | } 91 | 92 | const streamlabs = new Streamlabs(); 93 | module.exports = streamlabs; 94 | -------------------------------------------------------------------------------- /lib/api/streamlabs/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const qs = require('querystring'); 4 | 5 | const config = require('../../../config'); 6 | const db = require('../../database'); 7 | const authentication = require('./authentication'); 8 | 9 | 10 | module.exports = async (ctx, next) => { 11 | 12 | if (ctx.method === 'GET') { 13 | if (ctx.url === '/api/streamlabs/auth') { 14 | 15 | const query = qs.stringify({ 16 | response_type: 'code', 17 | client_id: config.api.streamlabs.client_id, 18 | redirect_uri: `${db.settings.get('uri').value()}/api/streamlabs/auth`, 19 | scope: 'donations.read', 20 | state: db.settings.get('api.state').value() 21 | }, null, null, {encodeURIComponent: qs.unescape}), 22 | url = `https://streamlabs.com/api/v1.0/authorize?${query}`; 23 | 24 | ctx.redirect(url); 25 | 26 | } else if (ctx.url.startsWith('/api/streamlabs/auth') && ctx.request.query.code) { 27 | 28 | try { 29 | await authentication.new(ctx.request.query); 30 | } catch (e) { 31 | return ctx.throw(e); 32 | } 33 | 34 | ctx.redirect('/settings'); 35 | 36 | } 37 | } 38 | 39 | await next(); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /lib/api/tipeeestream/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // placerholder 4 | -------------------------------------------------------------------------------- /lib/api/twitch/authentication.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | 5 | const config = require('../../../config'); 6 | const db = require('../../database'); 7 | const version = require('../../../package.json').version; 8 | 9 | 10 | class Authentication { 11 | constructor() { 12 | this.options = { 13 | headers: { 14 | 'User-Agent': `BroadcastNotificationSystem/${version} (TwitchModule)`, 15 | 'Accept': 'application/vnd.twitchtv.v5+json', 16 | 'Client-ID': config.api.twitch.client_id 17 | }, 18 | uri: 'https://api.twitch.tv/kraken/oauth2/token', 19 | formData: { 20 | client_id: config.api.twitch.client_id, 21 | client_secret: config.api.twitch.client_secret 22 | } 23 | }; 24 | } 25 | 26 | async new(query) { 27 | 28 | const state = db.settings.get('api.state').value(); 29 | if (query.state !== state) return Promise.reject('State not valid! Maybe CSRF!'); 30 | 31 | const getAuth = async () => { 32 | 33 | let options = Object.assign({ 34 | method: 'POST', 35 | }, this.options); 36 | 37 | Object.assign(options.formData, { 38 | grant_type: 'authorization_code', 39 | redirect_uri: `${db.settings.get('uri').value()}/api/twitch/auth`, 40 | code: query.code 41 | }); 42 | 43 | try { 44 | const body = await request(options); 45 | 46 | const json = JSON.parse(body), 47 | token = json.access_token, 48 | refresh_token = json.refresh_token, 49 | expiration_date = Math.floor(Date.now() / 1000 + json.expires_in - 60); 50 | 51 | return { 52 | token, 53 | refresh_token, 54 | expiration_date 55 | }; 56 | 57 | } catch (err) { 58 | return Promise.reject(err); 59 | } 60 | 61 | }; 62 | 63 | const getUser = async auth => { 64 | 65 | let options = Object.assign({}, this.options); 66 | delete options.formData; 67 | 68 | Object.assign(options.headers, { 69 | 'Authorization': `OAuth ${auth.token}` 70 | }); 71 | 72 | options.uri = 'https://api.twitch.tv/kraken/channel'; 73 | 74 | console.log(options); 75 | 76 | try { 77 | const body = await request(options); 78 | 79 | const json = JSON.parse(body), 80 | id = parseInt(json._id), 81 | name = json.name, 82 | type = json.broadcaster_type; 83 | 84 | return { 85 | id, 86 | name, 87 | auth, 88 | type 89 | }; 90 | 91 | } catch (err) { 92 | return Promise.reject(err); 93 | } 94 | 95 | }; 96 | 97 | let user = {}; 98 | try { 99 | const auth = await getAuth(); 100 | user = await getUser(auth); 101 | } catch (err) { 102 | return Promise.reject(err); 103 | } 104 | 105 | db.settings.get('api.twitch').assign({ 106 | enabled: true, 107 | userid: user.id, 108 | username: user.name, 109 | auth: { 110 | token: user.auth.token, 111 | refresh_token: user.auth.refresh_token, 112 | expiration_date: user.auth.expiration_date 113 | }, 114 | type: user.type 115 | }).write(); 116 | 117 | require('./').init(); 118 | require('../../scheduler').api('twitch'); 119 | 120 | return; 121 | } 122 | 123 | async refresh() { 124 | 125 | let options = Object.assign({ 126 | method: 'POST', 127 | }, this.options); 128 | 129 | Object.assign(options.formData, { 130 | grant_type: 'refresh_token', 131 | refresh_token: db.settings.get('api.twitch.auth.refresh_token').value() 132 | }); 133 | 134 | try { 135 | const body = await request(options); 136 | 137 | const json = JSON.parse(body), 138 | token = json.access_token, 139 | refresh_token = json.refresh_token, 140 | expiration_date = Math.floor(Date.now() / 1000 + json.expires_in - 60); 141 | 142 | db.settings.get('api.twitch.auth').assign({ 143 | token, 144 | refresh_token, 145 | expiration_date 146 | }).write(); 147 | 148 | return; 149 | 150 | } catch (err) { 151 | return Promise.reject(err); 152 | } 153 | 154 | } 155 | 156 | } 157 | 158 | const authentication = new Authentication(); 159 | module.exports = authentication; 160 | -------------------------------------------------------------------------------- /lib/api/twitch/follows.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | const { DateTime } = require('luxon'); 5 | 6 | const db = require('../../database'); 7 | 8 | 9 | class Follows { 10 | constructor(userId, options) { 11 | this.options = Object.assign({}, options); 12 | this.options.uri = `https://api.twitch.tv/kraken/channels/${userId}/follows`; 13 | } 14 | 15 | async check() { 16 | 17 | let data; 18 | try { 19 | data = await this.worker(); 20 | } catch (e) { 21 | return Promise.reject(e); 22 | } 23 | 24 | if (data) { 25 | db.queue.get('follows').set('last', data.arr[0].date).write(); 26 | 27 | data.arr.reverse(); 28 | 29 | db.stats.get('follows').assign(data.week).write(); 30 | 31 | for (let el of data.bl) { 32 | db.blacklist.get('list').push(el).write(); 33 | } 34 | 35 | return { 36 | type: 'follows', 37 | arr: data.arr 38 | }; 39 | } else { 40 | return null; 41 | } 42 | } 43 | 44 | worker() { 45 | 46 | const dt = DateTime.utc(); 47 | 48 | let arr = [], 49 | bl = [], 50 | week = db.stats.get('follows').value(), 51 | options = this.options, 52 | last = db.queue.get('follows.last').value(); 53 | 54 | if (!last) { 55 | last = dt.startOf('week').toISO(); 56 | db.queue.get('follows').set('last', last).write(); 57 | } 58 | last = new Date(last); 59 | 60 | 61 | const get = async cursor => { 62 | 63 | if (cursor) Object.assign(options.qs, { 64 | cursor 65 | }); 66 | 67 | try { 68 | return parse(await request(options)); 69 | } catch (err) { 70 | return Promise.reject(err); 71 | } 72 | 73 | }; 74 | 75 | 76 | const parse = body => { 77 | 78 | const json = JSON.parse(body), 79 | follows = json.follows, 80 | length = follows.length; 81 | 82 | let i = 1; 83 | 84 | if (length) { 85 | for (let follow of follows) { 86 | 87 | const id = parseInt(follow.user._id), 88 | name = follow.user.name, 89 | display_name = follow.user.display_name, 90 | date = new Date(follow.created_at), 91 | blacklist = db.blacklist.get('list').value(), 92 | blocked = blacklist.includes(id); 93 | 94 | if (date > last) { 95 | if (!blocked) { 96 | 97 | arr.push({ 98 | id, 99 | name, 100 | display_name, 101 | date 102 | }); 103 | 104 | const day = DateTime.fromISO(date.toISOString()).weekday - 1; 105 | week[day] = week[day] + 1; 106 | 107 | bl.push(id); 108 | } 109 | } else if (arr.length) { 110 | return { 111 | arr, 112 | week, 113 | bl 114 | }; 115 | } else { 116 | return null; 117 | } 118 | 119 | if (i === length && json._cursor) { 120 | return get(json._cursor); 121 | } else if (i === length) { 122 | return { 123 | arr, 124 | week, 125 | bl 126 | }; 127 | } 128 | 129 | i = i + 1; 130 | } 131 | } else { 132 | return null; 133 | } 134 | }; 135 | 136 | return get(); 137 | } 138 | 139 | } 140 | 141 | module.exports = Follows; 142 | -------------------------------------------------------------------------------- /lib/api/twitch/hosts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | 5 | const db = require('../../database'); 6 | 7 | 8 | class Hosts { 9 | constructor(userId) { 10 | this.uri = `https://tmi.twitch.tv/hosts?include_logins=1&target=${userId}`; 11 | this.last = db.queue.get('hosts.last').value(); 12 | } 13 | 14 | async check() { 15 | 16 | let data; 17 | try { 18 | const body = await request(this.uri); 19 | if (!this.compare(body)) return null; 20 | data = await this.parse(body); 21 | } catch (err) { 22 | return Promise.reject(err); 23 | } 24 | 25 | if (data) { 26 | db.queue.get('hosts').set('last', this.last).write(); 27 | return { 28 | type: 'hosts', 29 | arr: data.arr 30 | }; 31 | } else { 32 | return null; 33 | } 34 | } 35 | 36 | async parse(body) { 37 | 38 | const json = JSON.parse(body), 39 | hosts = json.hosts, 40 | length = hosts.length; 41 | 42 | let i = 1, 43 | arr = []; 44 | 45 | if (length) { 46 | for (let host of hosts) { 47 | 48 | const id = host.host_id, 49 | name = host.host_login, 50 | display_name = host.host_display_name; 51 | 52 | if (!this.last.includes(id)) { 53 | 54 | const add = async () => { 55 | try { 56 | const body = await request(`https://tmi.twitch.tv/group/user/${name}/chatters`); 57 | 58 | const json = JSON.parse(body), 59 | viewers = json.chatter_count; 60 | 61 | if (viewers > 2) { 62 | arr.push({ 63 | name, 64 | display_name, 65 | viewers, 66 | date: new Date() 67 | }); 68 | } 69 | 70 | this.last.push(id); 71 | 72 | return; 73 | 74 | } catch (err) { 75 | console.error(err); 76 | return; 77 | } 78 | }; 79 | 80 | await add(); 81 | 82 | } 83 | 84 | if (i === length) { 85 | if (arr.length) { 86 | return { 87 | arr 88 | }; 89 | } else { 90 | return null; 91 | } 92 | } 93 | 94 | i = i + 1; 95 | } 96 | } else { 97 | return null; 98 | } 99 | } 100 | 101 | compare(body) { 102 | const json = JSON.parse(body), 103 | hosts = json.hosts; 104 | 105 | if (hosts.length) { 106 | if (this.last.length) { 107 | 108 | const getCurrent = hosts => { 109 | let arr = [], 110 | i = 1; 111 | 112 | for (const host of hosts) { 113 | arr.push(host.host_id); 114 | 115 | if (i === hosts.length) return arr; 116 | i = i + 1; 117 | } 118 | }; 119 | 120 | const getLast = current => { 121 | let i = 0, 122 | arr = []; 123 | 124 | for (const last of this.last) { 125 | if (current.includes(last)) { 126 | arr.push(last); 127 | } 128 | 129 | if (i === this.last.length - 1) return arr; 130 | i = i + 1; 131 | } 132 | }; 133 | 134 | const current = getCurrent(hosts); 135 | this.last = getLast(current); 136 | 137 | db.queue.set('hosts.last', this.last).write(); 138 | return true; 139 | 140 | } else { 141 | return true; 142 | } 143 | } else if (this.last.length) { 144 | this.last.length = 0; 145 | db.queue.get('hosts.last').remove().write(); 146 | return false; 147 | } else { 148 | return false; 149 | } 150 | } 151 | 152 | } 153 | 154 | module.exports = Hosts; 155 | -------------------------------------------------------------------------------- /lib/api/twitch/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../../config'); 4 | const db = require('../../database'); 5 | const authentication = require('./authentication'); 6 | const version = require('../../../package.json').version; 7 | 8 | const Follows = require('./follows'); 9 | const Subscriptions = require('./subscriptions'); 10 | const Hosts = require('./hosts'); 11 | 12 | 13 | class Twitch { 14 | constructor() { 15 | if (db.settings.get('api.twitch.auth.token').value() && 16 | !this.token) this.init(); 17 | } 18 | 19 | init() { 20 | this.userId = db.settings.get('api.twitch.userid').value(); 21 | this.token = db.settings.get('api.twitch.auth.token').value(); 22 | this.options = { 23 | headers: { 24 | 'User-Agent': `BroadcastNotificationSystem/${version} (TwitchModule)`, 25 | 'Accept': 'application/vnd.twitchtv.v5+json', 26 | 'Client-ID': config.api.twitch.client_id, 27 | 'Authorization': `OAuth ${this.token}` 28 | }, 29 | qs: { 30 | limit: 15, 31 | offset: 0, 32 | direction: 'desc' 33 | } 34 | }; 35 | 36 | this.follows = new Follows(this.userId, this.options); 37 | this.subscriptions = new Subscriptions(this.userId, this.options); 38 | this.hosts = new Hosts(this.userId); 39 | } 40 | 41 | async check() { 42 | const expDate = db.settings.get('api.twitch.auth.expiration_date').value(), 43 | date = Math.floor(Date.now() / 1000); 44 | 45 | if (!this.token) { 46 | 47 | console.error(new Error('Twitch authentication not set!')); 48 | return; 49 | 50 | } else if (expDate && date > expDate) { 51 | 52 | try { 53 | await authentication.refresh(); 54 | } catch (e) { 55 | console.error(new Error(e)); 56 | } 57 | 58 | this.init(); 59 | 60 | return; 61 | 62 | } else { 63 | 64 | const follows = this.get('follows'); 65 | const subscriptions = this.get('subscriptions'); 66 | const hosts = this.get('hosts'); 67 | 68 | const data = await Promise.all([follows, subscriptions, hosts]); 69 | 70 | const push = obj => { 71 | for (let el of obj.arr) { 72 | db.queue.get(`${obj.type}.list`).push(el).write(); 73 | } 74 | }; 75 | 76 | for (let obj of data) { 77 | if (obj) push(obj); 78 | } 79 | 80 | return; 81 | 82 | } 83 | } 84 | 85 | async get(type) { 86 | let data; 87 | 88 | if (db.settings.get(`notification.types.${type}`).value()) { 89 | 90 | try { 91 | data = await this[type].check(); 92 | } catch (e) { 93 | console.error(new Error(e)); 94 | return null; 95 | } 96 | } else { 97 | return null; 98 | } 99 | 100 | return data; 101 | } 102 | 103 | } 104 | 105 | const twitch = new Twitch(); 106 | module.exports = twitch; 107 | -------------------------------------------------------------------------------- /lib/api/twitch/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const qs = require('querystring'); 4 | 5 | const config = require('../../../config'); 6 | const db = require('../../database'); 7 | const authentication = require('./authentication'); 8 | 9 | 10 | module.exports = async (ctx, next) => { 11 | 12 | if (ctx.method === 'GET') { 13 | if (ctx.url === '/api/twitch/auth') { 14 | 15 | const query = qs.stringify({ 16 | response_type: 'code', 17 | client_id: config.api.twitch.client_id, 18 | redirect_uri: `${db.settings.get('uri').value()}/api/twitch/auth`, 19 | scope: 'channel_read channel_subscriptions', 20 | state: db.settings.get('api.state').value() 21 | }), 22 | url = `https://api.twitch.tv/kraken/oauth2/authorize?${query}`; 23 | 24 | ctx.redirect(url); 25 | 26 | } else if (ctx.url.startsWith('/api/twitch/auth') && ctx.request.query.code) { 27 | 28 | try { 29 | await authentication.new(ctx.request.query); 30 | } catch (e) { 31 | return ctx.throw(e); 32 | } 33 | 34 | ctx.redirect('/settings'); 35 | 36 | } 37 | } 38 | 39 | await next(); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /lib/api/twitch/subscriptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request-promise-native'); 4 | const { DateTime } = require('luxon'); 5 | 6 | const db = require('../../database'); 7 | 8 | 9 | class Subscriptions { 10 | constructor(userId, options) { 11 | this.options = Object.assign({}, options); 12 | this.options.uri = `https://api.twitch.tv/kraken/channels/${userId}/subscriptions`; 13 | } 14 | 15 | async check() { 16 | const type = db.settings.get('api.twitch.type').value(); 17 | 18 | if (type !== 'affiliate' && type !== 'partner') return null; 19 | 20 | let data; 21 | try { 22 | data = await this.worker(); 23 | } catch (e) { 24 | return Promise.reject(e); 25 | } 26 | 27 | if (data) { 28 | db.queue.get('subscriptions').set('last', data.arr[0].date).write(); 29 | 30 | data.arr.reverse(); 31 | 32 | db.stats.get('subscriptions').assign(data.week).write(); 33 | 34 | return { 35 | type: 'subscriptions', 36 | arr: data.arr 37 | }; 38 | } else { 39 | return null; 40 | } 41 | } 42 | 43 | worker() { 44 | 45 | const dt = DateTime.utc(); 46 | 47 | let arr = [], 48 | week = db.stats.get('subscriptions').value(), 49 | options = this.options, 50 | last = db.queue.get('subscriptions.last').value(); 51 | 52 | if (!last) { 53 | last = dt.startOf('week').toISO(); 54 | db.queue.get('subscriptions').set('last', last).write(); 55 | } 56 | last = new Date(last); 57 | 58 | 59 | const get = async next => { 60 | 61 | if (next) options.qs.offset = options.qs.offset + options.qs.limit; 62 | 63 | try { 64 | return parse(await request(options)); 65 | } catch (err) { 66 | return Promise.reject(err); 67 | } 68 | 69 | }; 70 | 71 | const parse = body => { 72 | 73 | const json = JSON.parse(body), 74 | subscriptions = json.subscriptions, 75 | length = subscriptions.length; 76 | 77 | let i = 1; 78 | 79 | if (length) { 80 | for (let subscription of subscriptions) { 81 | 82 | const id = parseInt(subscription.user._id), 83 | name = subscription.user.name, 84 | display_name = subscription.user.display_name, 85 | date = new Date(subscription.created_at), 86 | resubs = Math.round(dt.diff(DateTime.fromISO(date.toISOString()), 'months').months); 87 | 88 | if (date > last) { 89 | 90 | arr.push({ 91 | id, 92 | name, 93 | display_name, 94 | date, 95 | resubs 96 | }); 97 | 98 | const day = DateTime.fromISO(date.toISOString()).weekday - 1; 99 | week[day] = week[day] + 1; 100 | 101 | } else if (arr.length) { 102 | return { 103 | arr, 104 | week 105 | }; 106 | } else { 107 | return null; 108 | } 109 | 110 | if (i === length && length >= options.qs.limit) { 111 | return get(true); 112 | } else if (i === length) { 113 | return { 114 | arr, 115 | week 116 | }; 117 | } 118 | 119 | i = i + 1; 120 | } 121 | } else { 122 | return null; 123 | } 124 | }; 125 | 126 | return get(); 127 | } 128 | 129 | } 130 | 131 | module.exports = Subscriptions; 132 | -------------------------------------------------------------------------------- /lib/api/twitcheventtracker/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // placerholder 4 | -------------------------------------------------------------------------------- /lib/authentication.js: -------------------------------------------------------------------------------- 1 | /* eslint require-atomic-updates: 0 */ 2 | 'use strict'; 3 | 4 | const crypto = require('crypto'); 5 | const qs = require('querystring'); 6 | 7 | const jwt = require('jsonwebtoken'); 8 | const nodemailer = require('nodemailer'); 9 | 10 | const config = require('../config'); 11 | const db = require('./database'); 12 | 13 | 14 | class Mail { 15 | constructor() { 16 | 17 | this.email = db.settings.get('auth.email').value(); 18 | 19 | if (process.env.NODE_ENV === 'development') { 20 | this.transporter = nodemailer.createTransport({ 21 | jsonTransport: true 22 | }); 23 | } else { 24 | this.transporter = nodemailer.createTransport(config.authentication.mail); 25 | } 26 | 27 | } 28 | 29 | set address(address) { 30 | this.email = address; 31 | db.settings.get('auth').set('email', this.email).write(); 32 | } 33 | 34 | get address() { 35 | return this.email; 36 | } 37 | 38 | send(token) { 39 | 40 | const uri = db.settings.get('uri').value(); 41 | 42 | const body = 43 | `${uri}/authentication?token=${token} 44 | 45 | Add the following string to the end of URLs that you use for your streaming software. 46 | ?token=${token}`; 47 | 48 | if (process.env.NODE_ENV === 'development') { 49 | 50 | this.transporter.sendMail({ 51 | from: 'Broadcast Notification System', 52 | to: this.email, 53 | subject: 'BNS access token', 54 | text: `${uri}/authentication?token=${token}` 55 | }, (err, info) => { 56 | console.log(info.envelope); 57 | console.log(info.messageId); 58 | console.log(info.message); 59 | }); 60 | 61 | } else { 62 | 63 | this.transporter.sendMail({ 64 | from: 'Broadcast Notification System', 65 | to: this.email, 66 | subject: 'BNS access token', 67 | text: body 68 | }, (err, info) => { 69 | if (err) { 70 | console.error(err); 71 | } else { 72 | console.log(info); 73 | } 74 | }); 75 | 76 | } 77 | } 78 | } 79 | 80 | const mail = new Mail(); 81 | 82 | 83 | class JSONWebToken { 84 | constructor() { 85 | 86 | this.secret = db.settings.get('auth.secret').value(); 87 | if (!this.secret) { 88 | this.secret = crypto.randomBytes(24).toString('hex'); 89 | db.settings.get('auth').set('secret', this.secret).write(); 90 | } 91 | 92 | } 93 | 94 | async signToken() { 95 | let token; 96 | 97 | const exp_date = Math.floor(Date.now() / 1000 + config.authentication.tokenExp * 24 * 60 * 60); 98 | 99 | try { 100 | token = await jwt.sign({ 101 | iss: 'BNS', 102 | email: mail.address, 103 | iat: Math.floor(Date.now() / 1000), 104 | exp: exp_date, 105 | }, this.secret); 106 | } catch (err) { 107 | return Promise.reject(err); 108 | } 109 | 110 | db.settings.get('auth').set('expiration_date', exp_date).write(); 111 | mail.send(token); 112 | 113 | console.info(new Date(), 'Token successfully signed!'); 114 | return; 115 | } 116 | 117 | async verifyToken(token) { 118 | try { 119 | await jwt.verify(token, this.secret); 120 | } catch (err) { 121 | return Promise.reject(err); 122 | } 123 | 124 | return; 125 | } 126 | 127 | ifExpired() { 128 | 129 | const expDate = db.settings.get('auth.expiration_date').value() - 24 * 60 * 60, 130 | date = Math.floor(Date.now() / 1000); 131 | 132 | if (mail.address && (!expDate || date > expDate)) { 133 | console.info('JWT has been expired! A new token will be signed.'); 134 | this.signToken(); 135 | } 136 | 137 | } 138 | 139 | } 140 | 141 | 142 | class Authentication extends JSONWebToken { 143 | constructor() { 144 | 145 | super(); 146 | 147 | this.cookieSecure = false; 148 | 149 | // if (process.env.NODE_ENV === 'development') { 150 | // this.cookieSecure = false; 151 | // } else { 152 | // this.cookieSecure = true; 153 | // } 154 | 155 | } 156 | 157 | signUp(email) { 158 | mail.address = email; 159 | super.signToken(); 160 | } 161 | 162 | koaMiddleware() { 163 | return async (ctx, next) => { 164 | 165 | const url = ctx.url; 166 | const authPath = '/authentication'; 167 | 168 | if (!mail.address && 169 | !url.startsWith(`${authPath}/signup`) && 170 | !url.startsWith(`${authPath}/ressources`)) { 171 | return ctx.redirect(`${authPath}/signup`); 172 | } 173 | 174 | if (url.startsWith(`${authPath}`) && url.indexOf('?token') === -1) { 175 | return await next(); 176 | } 177 | 178 | let token; 179 | 180 | if (ctx.request.query.token) { 181 | token = ctx.request.query.token; 182 | } else if (ctx.cookies.get('jwt')) { 183 | token = ctx.cookies.get('jwt'); 184 | } else { 185 | ctx.status = 401; 186 | return await ctx.render('authentication/locked'); 187 | } 188 | 189 | try { 190 | await super.verifyToken(token); 191 | 192 | await ctx.cookies.set('jwt', token, { 193 | secure: this.cookieSecure, 194 | httpOnly: true, 195 | maxAge: config.authentication.cookieExp * 60 * 1000, 196 | overwrite: true 197 | }); 198 | } catch (err) { 199 | console.error(err); 200 | ctx.status = 401; 201 | return await ctx.render('authentication/locked'); 202 | } 203 | 204 | await next(); 205 | 206 | }; 207 | } 208 | 209 | socketMiddleware() { 210 | return async(socket, next) => { 211 | 212 | const cookies = socket.request.headers.cookie, 213 | token = qs.parse(cookies, ';', '=').jwt; 214 | 215 | if (token) { 216 | try { 217 | await super.verifyToken(token); 218 | } catch (err) { 219 | console.error(new Error(err)); 220 | return next(new Error(err)); 221 | } 222 | } 223 | 224 | next(); 225 | 226 | }; 227 | } 228 | 229 | } 230 | 231 | const authentication = new Authentication(); 232 | module.exports = authentication; 233 | -------------------------------------------------------------------------------- /lib/dashboard/feed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const uuidV1 = require('uuid/v1'); 4 | 5 | const db = require('../database'); 6 | const socket = require('./socket'); 7 | 8 | 9 | class Feed { 10 | constructor(length) { 11 | 12 | this.length = length; 13 | 14 | } 15 | 16 | set entry(data) { 17 | 18 | let entry = data.entry, 19 | feed = db.stats.get('feed.list').value(), 20 | first = db.stats.get('feed.list').first().value(); 21 | 22 | if (feed.length === this.length) { 23 | db.stats.get('feed.list').remove(first).write(); 24 | if (socket.connected) { 25 | 26 | const remove = { 27 | feed: { 28 | remove: first.uuid 29 | } 30 | }; 31 | 32 | socket.emit('stats', remove); 33 | } 34 | } 35 | 36 | db.stats.get('feed.list').push({ 37 | uuid: uuidV1(), 38 | uid: data.id, 39 | date: entry.date, 40 | type: entry.type, 41 | name: entry.name, 42 | resubs: entry.resubs, 43 | amount: entry.amount, 44 | message: entry.message, 45 | currency: entry.currency, 46 | viewers: entry.viewers, 47 | display_name: entry.display_name 48 | }).write(); 49 | 50 | } 51 | } 52 | 53 | const feed = new Feed(30); 54 | module.exports = feed; 55 | -------------------------------------------------------------------------------- /lib/dashboard/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./socket'); 4 | 5 | const settings = require('./settings'); 6 | const stats = require('./stats'); 7 | 8 | exports.data = async () => { 9 | return { 10 | settings: settings.data, 11 | stats: stats.data 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/dashboard/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('../database'); 4 | 5 | class Settings { 6 | constructor() {} 7 | 8 | get data() { 9 | return { 10 | notification: db.settings.get('notification').cloneDeep().value(), 11 | dashboard: db.settings.get('dashboard').value(), 12 | api: { 13 | twitch: db.settings.get('api.twitch').cloneDeep().value(), 14 | youtube: { 15 | enabled: false 16 | }, 17 | streamlabs: db.settings.get('api.streamlabs').cloneDeep().value(), 18 | tipeee: { 19 | enabled: false 20 | }, 21 | } 22 | }; 23 | } 24 | 25 | set data(data) { 26 | 27 | db.settings.set(data.prop, data.value).write(); 28 | 29 | if (data.value) { 30 | const scheduler = require('../scheduler'); 31 | 32 | switch (data.prop) { 33 | case 'api.twitch.enabled': 34 | scheduler.api('twitch'); 35 | break; 36 | case 'api.youtube.enabled': 37 | scheduler.api('youtube'); 38 | break; 39 | case 'api.streamlabs.enabled': 40 | scheduler.api('streamlabs'); 41 | break; 42 | case 'api.tipeee.enabled': 43 | scheduler.api('tipeee'); 44 | break; 45 | case 'api.twitcheventtracker.enabled': 46 | scheduler.api('twitcheventtracker'); 47 | break; 48 | } 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | const settings = new Settings(); 56 | module.exports = settings; 57 | -------------------------------------------------------------------------------- /lib/dashboard/socket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const io = require('../server').io; 4 | const settings = require('./settings'); 5 | const template = require('../template'); 6 | 7 | 8 | class Socket { 9 | constructor() { 10 | 11 | this.connected = false; 12 | 13 | io.on('connection', socket => { 14 | 15 | this.connected = true; 16 | 17 | socket.on('dashboard', async (type, data) => { 18 | 19 | const response = (err, res) => { 20 | if (err) return socket.emit('dashboard', 'response', true); 21 | socket.emit('dashboard', 'response', null, res); 22 | }; 23 | 24 | let res; 25 | 26 | try { 27 | res = await this.parse(type, data); 28 | } catch (e) { 29 | response(true); 30 | return console.error(new Error(e)); 31 | } 32 | 33 | response(null, res); 34 | 35 | }); 36 | 37 | socket.on('disconnect', () => { 38 | this.connected = false; 39 | }); 40 | 41 | }); 42 | } 43 | 44 | emit(channel, data) { 45 | io.sockets.emit(channel, data); 46 | } 47 | 48 | async parse(type, data) { 49 | if (type === 'set') { 50 | 51 | settings.data = data; 52 | data.type = type; 53 | return data; 54 | 55 | } else if (type === 'function') { 56 | 57 | if (data.prop === 'template.search') { 58 | 59 | let result = {}; 60 | 61 | try { 62 | result = await template.search(); 63 | } catch (e) { 64 | return Promise.reject(e); 65 | } 66 | 67 | return { 68 | prop: data.prop, 69 | templates: result.templates, 70 | selected: result.selected 71 | }; 72 | 73 | } else if (data.prop === 'template.set') { 74 | 75 | try { 76 | await template.set(data.value); 77 | } catch (e) { 78 | return Promise.reject(e); 79 | } 80 | 81 | return data; 82 | 83 | } else if (data.prop === 'notification.test') { 84 | 85 | const obj = { 86 | type: data.value, 87 | test: true 88 | }; 89 | 90 | this.emit('notification', obj); 91 | 92 | } else if (data.prop === 'notification.cleanupQueue') { 93 | 94 | require('../notification').cleanupQueue(); 95 | 96 | return data; 97 | } 98 | 99 | } 100 | } 101 | 102 | } 103 | 104 | const socket = new Socket(); 105 | module.exports = socket; 106 | -------------------------------------------------------------------------------- /lib/dashboard/stats.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { DateTime } = require('luxon'); 4 | 5 | const db = require('../database'); 6 | const socket = require('./socket'); 7 | 8 | 9 | class Stats { 10 | constructor() {} 11 | 12 | get data() { 13 | return { 14 | feed: { 15 | list: db.stats.get('feed.list').value() 16 | }, 17 | charts: { 18 | follows: db.stats.get('follows').value(), 19 | subscriptions: db.stats.get('subscriptions').value(), 20 | donations: db.stats.get('donations').value() 21 | } 22 | }; 23 | 24 | } 25 | 26 | push() { 27 | if (socket.connected) socket.emit('stats', this.data); 28 | } 29 | 30 | async set() { 31 | const dt = DateTime.utc(); 32 | 33 | if (db.stats.get('week').value() === dt.weekNumber) return; 34 | 35 | db.stats.set('week', dt.weekNumber).write(); 36 | 37 | db.queue.get('follows').set('last', null).write(); 38 | db.queue.get('subscriptions').set('last', null).write(); 39 | db.queue.get('donations').set('last', null).write(); 40 | 41 | const zero = [0, 0, 0, 0, 0, 0, 0]; 42 | 43 | const types = [ 44 | 'follows', 45 | 'subscriptions', 46 | 'donations.count', 47 | 'donations.amount' 48 | ]; 49 | 50 | for (let type of types) { 51 | db.stats.get(type).assign(zero).write(); 52 | } 53 | 54 | console.info('Week set & datasets cleared'); 55 | 56 | return; 57 | } 58 | 59 | } 60 | 61 | const stats = new Stats(); 62 | module.exports = stats; 63 | -------------------------------------------------------------------------------- /lib/database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const low = require('lowdb'); 4 | 5 | // init database 6 | const FileSync = require('lowdb/adapters/FileSync'); 7 | 8 | const adapter = new FileSync('./db.json'); 9 | const db = low(adapter); 10 | 11 | db.defaults({ 12 | settings: { 13 | uri: '', 14 | auth: { 15 | secret: null, 16 | email: null, 17 | expiration_date: null 18 | }, 19 | notification: { 20 | enabled: false, 21 | duration: 16000, 22 | template: { 23 | selected: 'default', 24 | list: [] 25 | }, 26 | types: { 27 | follows: true, 28 | subscriptions: false, 29 | hosts: false, 30 | donations: false 31 | } 32 | }, 33 | dashboard: { 34 | popups: true 35 | }, 36 | api: { 37 | state: '', 38 | twitch: { 39 | enabled: false, 40 | userid: null, 41 | username: '', 42 | auth: { 43 | token: '', 44 | refresh_token: '', 45 | expiration_date: null 46 | }, 47 | type: '' 48 | }, 49 | streamlabs: { 50 | enabled: false, 51 | auth: { 52 | token: '', 53 | refresh_token: '', 54 | expiration_date: null 55 | } 56 | } 57 | } 58 | }, 59 | stats: { 60 | week: null, 61 | follows: [0, 0, 0, 0, 0, 0, 0], 62 | subscriptions: [0, 0, 0, 0, 0, 0, 0], 63 | donations: { 64 | count: [0, 0, 0, 0, 0, 0, 0], 65 | amount: [0, 0, 0, 0, 0, 0, 0] 66 | }, 67 | feed: { 68 | list: [] 69 | } 70 | }, 71 | queue: { 72 | follows: { 73 | last: null, 74 | list: [] 75 | }, 76 | subscriptions: { 77 | last: null, 78 | list: [] 79 | }, 80 | hosts: { 81 | last: [], 82 | list: [] 83 | }, 84 | donations: { 85 | last: null, 86 | list: [] 87 | } 88 | }, 89 | blacklist: { 90 | cleared: null, 91 | list: [] 92 | } 93 | }).write(); 94 | 95 | // define paths 96 | exports.settings = db.get('settings'); 97 | exports.stats = db.get('stats'); 98 | exports.queue = db.get('queue'); 99 | exports.blacklist = db.get('blacklist'); 100 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | 5 | require('./scheduler'); 6 | require('./routes'); 7 | 8 | const db = require('./database'); 9 | 10 | new Promise((resolve, reject) => { 11 | let server = require('../config').server, 12 | host = server.host, 13 | uri = ''; 14 | 15 | if (!host) return reject('Host is not set!'); 16 | 17 | if (server.proxy) { 18 | uri = `https://${host}:${server.proxyPort}`; 19 | } else { 20 | uri = `http://${host}:${server.port}`; 21 | } 22 | 23 | resolve(uri); 24 | 25 | }).then(uri => { 26 | db.settings.set('uri', uri).write(); 27 | }).catch(err => { 28 | console.error(new Error(err)); 29 | }); 30 | 31 | const state = db.settings.get('api.state').value(); 32 | if (!state) db.settings.get('api').set('state', crypto.randomBytes(16).toString('hex')).write(); 33 | -------------------------------------------------------------------------------- /lib/middleware/static-serve.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const zlib = require('zlib'); 6 | const stream = require('stream'); 7 | 8 | const config = require('./../../config'); 9 | const server = require('./../server'); 10 | const app = server.app; 11 | 12 | 13 | class StaticServe { 14 | constructor(route, mount) { 15 | 16 | this.route = route; 17 | this.mount = mount; 18 | 19 | this.extensions = [ 20 | 'json', 21 | 'css', 22 | 'js', 23 | 'svg', 24 | 'png', 25 | 'jpg', 26 | 'gif', 27 | 'apng', 28 | 'ttf', 29 | 'otf', 30 | 'woff', 31 | 'woff2', 32 | 'mp3' 33 | ]; 34 | 35 | this.mimes = { 36 | 37 | // application 38 | json: 'application/json', 39 | js: 'application/javascript', 40 | 41 | // text 42 | css: 'text/css', 43 | 44 | // image 45 | svg: 'image/svg+xml', 46 | png: 'image/png', 47 | jpg: 'image/jpeg', 48 | gif: 'image/gif', 49 | apng: 'image/apng', 50 | 51 | // font 52 | ttf: 'font/ttf', 53 | otf: 'font/oft', 54 | woff: 'font/woff', 55 | woff2: 'font/woff2', 56 | 57 | // audio 58 | mp3: 'audio/mpeg', 59 | ogg: 'audio/ogg', 60 | webm: 'audio/webm' 61 | 62 | }; 63 | 64 | } 65 | 66 | serve() { 67 | return async (ctx, next) => { 68 | 69 | let url = ctx.url; 70 | 71 | if ((ctx.method === 'HEAD' || ctx.method === 'GET') && (url.startsWith(this.route))) { 72 | 73 | const parse = () => { 74 | for (let el of this.extensions) { 75 | 76 | let ext = `.${el}`, 77 | query = `.${el}?`; 78 | 79 | if (url.indexOf(ext) !== -1) { 80 | 81 | if (url.indexOf(query) !== -1) url = url.substring(0, url.indexOf(query) + query.length - 1); 82 | 83 | url = url.replace(this.route, ''); 84 | ctx.type = `${this.mimes[el]}`; 85 | 86 | return path.join(`${this.mount}${url}`); 87 | } 88 | } 89 | 90 | return; 91 | }; 92 | 93 | const file = parse(); 94 | 95 | if (file) { 96 | 97 | const data = fs.createReadStream(file); 98 | 99 | ctx.set('Cache-Control', `private, max-age=${24 * 60 * 60}`); 100 | 101 | data.on('error', err => { 102 | app.emit('error', err, this); 103 | ctx.status = 404; 104 | ctx.res.end(); 105 | }); 106 | 107 | if (config.server.compression && ctx.acceptsEncodings('gzip')) { 108 | ctx.set('Content-Encoding', 'gzip'); 109 | ctx.body = data.pipe(zlib.createGzip()).pipe(stream.PassThrough()); 110 | } else { 111 | ctx.body = data.pipe(stream.PassThrough()); 112 | } 113 | 114 | } 115 | 116 | } 117 | 118 | await next(); 119 | 120 | }; 121 | } 122 | 123 | } 124 | 125 | module.exports = StaticServe; 126 | -------------------------------------------------------------------------------- /lib/notification.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('./database'); 4 | const io = require('./server').io; 5 | const feed = require('./dashboard/feed'); 6 | 7 | 8 | class Notification { 9 | constructor() { 10 | this.queue = []; 11 | this.cleanupBlacklist(); 12 | } 13 | 14 | check() { 15 | const length = this.queue.length; 16 | 17 | if (length < 5) { 18 | 19 | this.get('follow', 'follows'); 20 | this.get('subscription', 'subscriptions'); 21 | this.get('host', 'hosts'); 22 | this.get('donation', 'donations'); 23 | 24 | // sort queue by date 25 | if (length > 1) { 26 | this.queue.sort((a, b) => new Date(a.date) - new Date(b.date)); 27 | } 28 | 29 | } 30 | 31 | let id, 32 | data; 33 | 34 | if (length) { 35 | id = this.queue[0].id; 36 | data = { 37 | date: this.queue[0].date, 38 | type: this.queue[0].type, 39 | name: this.queue[0].name, 40 | resubs: this.queue[0].resubs, 41 | amount: this.queue[0].amount, 42 | message: this.queue[0].message, 43 | currency: this.queue[0].currency, 44 | viewers: this.queue[0].viewers, 45 | display_name: this.queue[0].display_name 46 | }; 47 | 48 | // push first element to client 49 | if (data.name) { 50 | this.send(data); 51 | feed.entry = ({ 52 | id, 53 | entry: data 54 | }); 55 | } 56 | 57 | // remove first element 58 | this.queue.splice(0, 1); 59 | 60 | } 61 | } 62 | 63 | get(type, category) { 64 | const queue = db.queue.get(`${category}.list`); 65 | 66 | if (queue.size().value()) { 67 | 68 | let element = queue.first().value(), 69 | obj = Object.assign({ 70 | type 71 | }, element); 72 | 73 | this.queue.push(obj); 74 | queue.remove(element).write(); 75 | 76 | } 77 | } 78 | 79 | send(data) { 80 | const enabled = db.settings.get('notification.enabled').value(); 81 | if (enabled) io.sockets.emit('notification', data); 82 | } 83 | 84 | cleanupBlacklist() { 85 | 86 | let time = new Date(), 87 | cleared = new Date(db.blacklist.get('cleared').value()); 88 | 89 | cleared.setHours(cleared.getHours() + 1); 90 | 91 | if (time > cleared || cleared === 'Invalid Date') { 92 | db.blacklist.get('list').remove().write(); 93 | db.blacklist.assign({ 94 | cleared: new Date() 95 | }).write(); 96 | } 97 | 98 | } 99 | 100 | cleanupQueue() { 101 | db.queue.get('follows.list').remove().write(); 102 | db.queue.get('subscription.list').remove().write(); 103 | db.queue.get('hosts.list').remove().write(); 104 | db.queue.get('donations.list').remove().write(); 105 | this.queue.length = 0; 106 | } 107 | 108 | } 109 | 110 | const notification = new Notification(); 111 | module.exports = notification; 112 | -------------------------------------------------------------------------------- /lib/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../config'); 4 | const server = require('./server'); 5 | const app = server.app; 6 | const StaticServe = require('./middleware/static-serve'); 7 | const db = require('./database'); 8 | const api = require('./api'); 9 | const dashboard = require('./dashboard'); 10 | const authentication = require('./authentication'); 11 | const version = require('../package.json').version; 12 | 13 | 14 | //# dashboard 15 | app.use(async (ctx, next) => { 16 | 17 | if (ctx.method === 'GET') { 18 | 19 | if (ctx.url === '/') { 20 | ctx.redirect('/dashboard'); 21 | 22 | } else if (ctx.url === '/dashboard' || ctx.url === '/settings') { 23 | 24 | const data = await dashboard.data(); 25 | 26 | if (ctx.url === '/dashboard') { 27 | await ctx.render('dashboard/dashboard', { 28 | pageTitle: 'Dashboard', 29 | data: { 30 | stats: data.stats, 31 | settings: data.settings, 32 | version 33 | } 34 | }); 35 | } else if (ctx.url === '/settings') { 36 | await ctx.render('dashboard/settings', { 37 | pageTitle: 'Settings', 38 | data: { 39 | settings: data.settings, 40 | version 41 | } 42 | }); 43 | } 44 | 45 | } 46 | } 47 | 48 | await next(); 49 | 50 | }); 51 | 52 | const staticDashboard = new StaticServe('/ressources', './public/dashboard'); 53 | app.use(staticDashboard.serve()); 54 | 55 | 56 | //# authentication 57 | if (config.authentication.enabled) { 58 | 59 | let email = db.settings.get('auth.email').value(); 60 | 61 | app.use(async (ctx, next) => { 62 | 63 | if (ctx.method === 'GET') { 64 | 65 | if (ctx.url === '/authentication') { 66 | if (email) ctx.redirect('/'); 67 | 68 | } else if (ctx.url.startsWith('/authentication?token=')) { 69 | ctx.redirect('/dashboard'); 70 | 71 | } else if (ctx.url === '/authentication/signup') { 72 | 73 | if (!email) { 74 | 75 | await ctx.render('authentication/signup', { 76 | days: config.authentication.tokenExp 77 | }); 78 | 79 | } else { 80 | ctx.redirect('/'); 81 | } 82 | 83 | } else if (ctx.url.startsWith('/authentication/signup/send?')) { 84 | 85 | if (!email && ctx.request.get('email')) { 86 | ctx.status = 202; 87 | email = ctx.request.get('email'); 88 | authentication.signUp(email); 89 | 90 | } else { 91 | ctx.throw(403); 92 | } 93 | } 94 | } 95 | 96 | await next(); 97 | 98 | }); 99 | 100 | const staticAuthentication = new StaticServe('/authentication/ressources', './public/authentication'); 101 | app.use(staticAuthentication.serve()); 102 | } 103 | 104 | 105 | //# notifications 106 | app.use(async (ctx, next) => { 107 | 108 | if (ctx.method === 'GET') { 109 | 110 | if (ctx.url === '/notification' || ctx.url.startsWith('/notification?token=')) { 111 | 112 | await ctx.render('template/index', { 113 | pageTitle: 'Notification window', 114 | version 115 | }); 116 | 117 | } else if (ctx.url.startsWith('/notification/')) { 118 | 119 | let page = ctx.url.replace('/notification/', ''); 120 | 121 | const query = '?'; 122 | 123 | if (page.indexOf(query) !== -1) page = page.substring(0, page.indexOf(query) + query.length - 1); 124 | 125 | if (page.length > 0 && page.indexOf('/') === -1) { 126 | 127 | await ctx.render(`template/${page}`, { 128 | pageTitle: 'Notification window', 129 | version 130 | }); 131 | 132 | } 133 | } 134 | } 135 | 136 | await next(); 137 | 138 | }); 139 | 140 | const staticNotification = new StaticServe('/notification/ressources', './views/template/ressources'); 141 | app.use(staticNotification.serve()); 142 | 143 | 144 | //# api module routes 145 | api.routes(app); 146 | -------------------------------------------------------------------------------- /lib/scheduler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('./database'); 4 | const stats = require('./dashboard/stats'); 5 | const authentication = require('./authentication'); 6 | const notification = require('./notification'); 7 | const api = require('./api'); 8 | 9 | 10 | class Scheduler { 11 | constructor() { 12 | 13 | this.durations = { 14 | notification: 10000, 15 | stats: 10000, 16 | jwt: 1800000, 17 | blacklist: 3660000, 18 | 19 | // apis 20 | twitch: 10000, 21 | streamlabs: 10000 22 | }; 23 | 24 | this.system(); 25 | this.notifications(); 26 | this.api('twitch'); 27 | this.api('streamlabs'); 28 | 29 | } 30 | 31 | system() { 32 | 33 | setInterval(() => stats.push(), this.durations.stats); 34 | setInterval(() => authentication.ifExpired(), this.durations.jwt); 35 | setInterval(() => notification.cleanupBlacklist(), this.durations.blacklist); 36 | 37 | } 38 | 39 | notifications() { 40 | 41 | this.durations.notification = db.settings.get('notification.duration').value(); 42 | 43 | notification.check(); 44 | setTimeout(() => this.notifications(), this.durations.notification); 45 | 46 | } 47 | 48 | async api(name) { 49 | 50 | await stats.set(); 51 | 52 | const enabled = db.settings.get(`api.${name}.enabled`).value(); 53 | 54 | if (enabled) { 55 | await api[name].check(); 56 | setTimeout(() => this.api(name), this.durations[name]); 57 | } 58 | 59 | } 60 | 61 | } 62 | 63 | const scheduler = new Scheduler(); 64 | module.exports = scheduler; 65 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Koa = require('koa'); 4 | const app = new Koa(); 5 | const views = require('koa-views'); 6 | const SocketIO = require('socket.io'); 7 | 8 | const config = require('../config'); 9 | const authentication = require('./authentication'); 10 | 11 | // init server 12 | const PORT = process.env.PORT || config.server.port; 13 | const httpServer = app.listen(PORT, () => { 14 | console.info(`Server listening now on port ${PORT}`); 15 | console.info('Press Ctrl+C to quit.'); 16 | }); 17 | const io = new SocketIO(httpServer, { 18 | serveClient: false, 19 | origins: [`${config.server.host}:${PORT}`,`${config.server.host}:${config.server.proxyPort}`] 20 | }); 21 | 22 | if (process.env.NODE_ENV === 'development') { 23 | 24 | app.use(async (ctx, next) => { 25 | const start = new Date(); 26 | await next(); 27 | const ms = new Date() - start; 28 | console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`); 29 | }).on('error', err => { 30 | console.error(new Error(err)); 31 | }); 32 | 33 | console.info('Server logging active'); 34 | 35 | } 36 | 37 | app.use(views('./views', { 38 | extension: 'pug' 39 | })); 40 | 41 | if (config.authentication.enabled) { 42 | 43 | app.use(authentication.koaMiddleware()); 44 | io.use(authentication.socketMiddleware()); 45 | 46 | console.info('Authentication middlwares loaded'); 47 | 48 | } else { 49 | console.warn('Authentication middlwares NOT loaded!'); 50 | } 51 | 52 | // export 53 | exports.app = app; 54 | exports.io = io; 55 | 56 | // send every ** seconds the version for client-server matching 57 | const version = require('../package.json').version; 58 | 59 | io.on('connection', socket => { 60 | 61 | setInterval(() => { 62 | socket.emit('general', { 63 | version 64 | }); 65 | }, 10 * 1000); 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /lib/template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const {promisify} = require('util'); 6 | 7 | const db = require('./database'); 8 | 9 | 10 | class Template { 11 | constructor() { 12 | this.selected = db.settings.get('notification.template.selected').value(); 13 | this.targetPath = path.resolve('./views/template'); 14 | 15 | try { 16 | this.search(); 17 | this.set(this.selected); 18 | } catch (e) { 19 | console.error(new Error(e)); 20 | } 21 | 22 | } 23 | 24 | async search() { 25 | const readdir = promisify(fs.readdir); 26 | 27 | let dirs = []; 28 | 29 | try { 30 | dirs = await readdir('./templates'); 31 | } catch (e) { 32 | return Promise.reject(e); 33 | } 34 | 35 | let templates = [], 36 | length = dirs.length, 37 | i = 0; 38 | 39 | for (const dir of dirs) { 40 | 41 | templates.push(dir); 42 | 43 | i = i + 1; 44 | 45 | if (i === length) { 46 | db.settings.get('notification.template').set('list', templates).write(); 47 | return { 48 | templates, 49 | selected: this.selected 50 | }; 51 | } 52 | } 53 | } 54 | 55 | async set(name) { 56 | const selected = name || 'default'; 57 | const source = path.resolve(`./templates/${selected}`); 58 | const target = this.targetPath; 59 | 60 | const readlink = promisify(fs.readlink); 61 | const unlink = promisify(fs.unlink); 62 | const symlink = promisify(fs.symlink); 63 | 64 | const link = async () => { 65 | try { 66 | await symlink(source, target, 'dir'); 67 | } catch (err) { 68 | return Promise.reject(err); 69 | } 70 | 71 | db.settings.get('notification.template').set('selected', selected).write(); 72 | return; 73 | }; 74 | 75 | try { 76 | const src = await readlink(target); 77 | if (src === source) return; 78 | } catch (err) { 79 | if (err.errno === -2) { 80 | await link(); 81 | return; 82 | } else { 83 | return Promise.reject(err); 84 | } 85 | } 86 | 87 | try { 88 | await unlink(target); 89 | } catch (err) { 90 | return Promise.reject(err); 91 | } 92 | 93 | try { 94 | await link(); 95 | } catch (err) { 96 | return Promise.reject(err); 97 | } 98 | } 99 | } 100 | 101 | const template = new Template(); 102 | module.exports = template; 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "broadcast-notification-system", 3 | "version": "0.11.7", 4 | "private": false, 5 | "license": "MIT", 6 | "description": "An open, simple and highly customisable notification/alert system for live streams on Twitch and YouTube.", 7 | "author": { 8 | "name": "Markus Wiegand", 9 | "email": "mail@morphy2k.io", 10 | "url": "https://morphy2k.io" 11 | }, 12 | "contributors": "https://github.com/morphy2k/broadcast-notification-system/graphs/contributors", 13 | "keywords": [ 14 | "livestream", 15 | "notification", 16 | "alert", 17 | "twitch", 18 | "youtube", 19 | "overlay" 20 | ], 21 | "engines": { 22 | "node": ">=8.9.0" 23 | }, 24 | "scripts": { 25 | "start": "node lib", 26 | "build-js": "browserify public/dashboard/js/main.js > public/dashboard/js/main.bundle.js && browserify public/dashboard/js/charts.js > public/dashboard/js/charts.bundle.js && browserify public/template/js/main.js > public/template/js/main.bundle.js", 27 | "lint": "eslint .", 28 | "pretest": "npm run lint", 29 | "test": "echo \"No tests\"" 30 | }, 31 | "dependencies": { 32 | "jsonwebtoken": "^8.5.1", 33 | "koa": "^2.13.0", 34 | "koa-views": "^6.3.0", 35 | "lodash": "^4.17.14", 36 | "lowdb": "^1.0.0", 37 | "luxon": "^1.24.1", 38 | "moment": "^2.27.0", 39 | "nodemailer": "^6.4.10", 40 | "pug": "^2.0.4", 41 | "request": "^2.88.2", 42 | "request-promise-native": "^1.0.8", 43 | "socket.io": "^2.3.0", 44 | "socket.io-client": "^2.3.0", 45 | "uuid": "^3.3.3" 46 | }, 47 | "devDependencies": { 48 | "browserify": "^16.5.1", 49 | "chart.js": "^2.9.3", 50 | "eslint": "^6.8.0", 51 | "jquery": "^3.5.0" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "https://github.com/morphy2k/broadcast-notification-system.git" 56 | }, 57 | "bugs": { 58 | "url": "https://github.com/morphy2k/broadcast-notification-system/issues" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /public/authentication/css/style.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | html, body { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | body { 9 | background-color: rgb(38, 50, 56); 10 | font-family: 'Roboto', sans-serif; 11 | font-size: 1rem; 12 | color: rgb(177, 191, 198); 13 | font-weight: 400; 14 | } 15 | 16 | main { 17 | display: flex; 18 | flex-wrap: wrap; 19 | justify-content: center; 20 | align-items: center; 21 | width: auto; 22 | height: 50rem; 23 | } 24 | 25 | #s40x { 26 | background: url('../../ressources/img/locked.svg') no-repeat center; 27 | background-size: 30rem; 28 | width: 100%; 29 | height: 100%; 30 | margin-top: 5rem; 31 | opacity: .5; 32 | } 33 | 34 | #email p { 35 | font-size: .9rem; 36 | max-width: 40rem; 37 | color: rgb(230, 57, 53); 38 | margin: .6rem 0; 39 | } 40 | 41 | #email form { 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | flex-direction: column; 46 | margin: 0; 47 | padding: 0; 48 | margin-bottom: 2rem; 49 | line-height: 4rem; 50 | } 51 | 52 | #email input { 53 | color: rgb(177, 191, 198); 54 | } 55 | 56 | #email input::placeholder { 57 | color: rgb(177, 191, 198); 58 | } 59 | 60 | #email input[type="email"] { 61 | box-sizing: border-box; 62 | line-height: 3rem; 63 | height: 3rem; 64 | width: 30rem; 65 | font-size: 1.8rem; 66 | padding: .2rem; 67 | margin: 0; 68 | margin-right: .3rem; 69 | border: none; 70 | background-color: rgba(255, 255, 255, 0); 71 | border-bottom: .1rem solid rgb(177, 191, 198); 72 | text-align: center; 73 | } 74 | 75 | #email input[type="email"]:focus { 76 | outline: none; 77 | } 78 | 79 | #email input[type="email"]:-webkit-autofill { 80 | -webkit-box-shadow: 0 0 0 30px rgb(38, 50, 56) inset; 81 | -webkit-text-fill-color: rgb(177, 191, 198); 82 | } 83 | 84 | #email input[type="submit"] { 85 | display: block; 86 | box-sizing: border-box; 87 | font-size: 1.6rem; 88 | margin-top: 1.5rem; 89 | border: none; 90 | background: none; 91 | text-transform: uppercase; 92 | cursor: pointer; 93 | } 94 | -------------------------------------------------------------------------------- /public/authentication/img/locked.svg: -------------------------------------------------------------------------------- 1 | 2 | 50 | -------------------------------------------------------------------------------- /public/dashboard/css/color.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | background-color: rgb(38, 50, 56); 5 | color: rgb(177, 191, 198); 6 | } 7 | 8 | a { 9 | color: rgb(177, 191, 198); 10 | } 11 | 12 | .box { 13 | background: rgb(31, 40, 46); 14 | } 15 | 16 | select { 17 | background-color: rgb(84, 110, 122); 18 | color: rgb(207, 217, 221); 19 | } 20 | 21 | input[type="checkbox"] { 22 | border: 1px solid rgb(120, 144, 156); 23 | } 24 | 25 | input[type="checkbox"]:checked { 26 | background-color: rgb(84, 110, 122); 27 | border: 1px solid rgb(84, 110, 122); 28 | } 29 | 30 | input[type="checkbox"]:checked:after { 31 | color: rgb(207, 217, 221); 32 | } 33 | 34 | #message-overlay { 35 | color: rgb(255, 255, 255); 36 | } 37 | 38 | #popup { 39 | background: rgba(144, 164, 173, .8); 40 | color: black; 41 | } 42 | 43 | /** dashboard **/ 44 | 45 | #feed .wrapper::-webkit-scrollbar-thumb { 46 | background: rgba(177, 191, 198, .1); 47 | } 48 | 49 | #feed .wrapper::-webkit-scrollbar-thumb:hover { 50 | background: rgba(177, 191, 198, .3); 51 | } 52 | 53 | #feed .wrapper { 54 | background: rgb(31, 40, 46); 55 | } 56 | 57 | #feed .list li:hover { 58 | background: rgba(38, 50, 56, 1); 59 | } 60 | 61 | .feed-btn { 62 | background: rgb(31, 40, 46); 63 | } 64 | 65 | .button { 66 | background-color: rgb(84, 110, 122); 67 | color: rgb(207, 217, 221); 68 | } 69 | 70 | .check.disabled { 71 | background-color: rgb(120, 144, 156); 72 | } 73 | -------------------------------------------------------------------------------- /public/dashboard/css/dashboard.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /** general **/ 4 | 5 | #dashboard { 6 | box-sizing: border-box; 7 | display: grid; 8 | grid-gap: .5rem; 9 | grid-template-columns: 1.1fr 1.1fr 1fr; 10 | grid-template-rows: 28rem 28rem; 11 | padding: 0 2rem; 12 | } 13 | 14 | .stats { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | #chartBox-1 { 21 | box-sizing: border-box; 22 | grid-column: 1 / 3; 23 | grid-row: 1; 24 | height: 28rem; 25 | } 26 | 27 | #chartBox-2 { 28 | box-sizing: border-box; 29 | grid-column: 1 / 3; 30 | grid-row: 2; 31 | height: 28rem; 32 | } 33 | 34 | 35 | /** feed **/ 36 | 37 | #feed { 38 | box-sizing: border-box; 39 | display: flex; 40 | grid-column: 3; 41 | grid-row: 1 / 2; 42 | height: 56.5rem; 43 | } 44 | 45 | 46 | /* navigation */ 47 | 48 | #feed nav ul { 49 | list-style: none; 50 | padding: 0; 51 | margin: 0; 52 | } 53 | 54 | #feed nav li { 55 | box-sizing: border-box; 56 | line-height: 2.5rem; 57 | width: 2.5rem; 58 | text-align: center; 59 | } 60 | 61 | .feed-btn { 62 | z-index: 10; 63 | width: 2.5rem; 64 | transition: all .3s ease; 65 | opacity: .5; 66 | } 67 | 68 | .feed-btn:hover { 69 | width: 4rem; 70 | cursor: pointer; 71 | opacity: .8; 72 | } 73 | 74 | .feed-btn.selected { 75 | opacity: 1; 76 | } 77 | 78 | #feed-all:hover {} 79 | 80 | #feed-follows:hover {} 81 | 82 | #feed-subscriptions:hover {} 83 | 84 | #feed-donations:hover {} 85 | 86 | #feed-hosts:hover {} 87 | 88 | .unfocused { 89 | opacity: 0.4 !important; 90 | } 91 | 92 | 93 | /* list */ 94 | 95 | #feed .wrapper { 96 | overflow-y: scroll; 97 | box-sizing: border-box; 98 | width: 100%; 99 | } 100 | 101 | #feed .wrapper::-webkit-scrollbar { 102 | width: .4rem; 103 | } 104 | 105 | #feed .wrapper::-webkit-scrollbar-thumb {} 106 | 107 | #feed .wrapper::-webkit-scrollbar-thumb:hover {} 108 | 109 | #feed .wrapper .list { 110 | display: flex; 111 | flex-direction: column-reverse; 112 | list-style: none; 113 | font-size: .9rem; 114 | margin: 0; 115 | padding: 0; 116 | } 117 | 118 | #feed .wrapper .list li { 119 | box-sizing: border-box; 120 | display: block; 121 | padding: .6rem .7rem; 122 | transition: all .3s ease; 123 | cursor: default; 124 | opacity: 1; 125 | } 126 | 127 | #feed .wrapper .list li:hover {} 128 | 129 | #feed .wrapper .list .head { 130 | display: flex; 131 | } 132 | 133 | #feed .wrapper .list .type { 134 | flex: 50; 135 | font-weight: 700; 136 | text-transform: uppercase; 137 | } 138 | 139 | #feed .wrapper .list .date { 140 | display: block; 141 | flex: 50; 142 | text-align: right; 143 | font-size: .7rem; 144 | opacity: .5; 145 | } 146 | 147 | #feed .wrapper .list .body { 148 | margin-top: .2rem; 149 | opacity: .8; 150 | } 151 | 152 | #feed .wrapper .list .amount, 153 | #feed .list .viewers { 154 | font-weight: 700; 155 | } 156 | 157 | #feed .wrapper .list .donation { 158 | cursor: pointer !important; 159 | } 160 | 161 | #feed .wrapper .list .message { 162 | display: none; 163 | margin-top: .5rem; 164 | padding: 0 .3rem; 165 | font-style: italic; 166 | font-size: .85rem; 167 | } 168 | 169 | 170 | /** settings button **/ 171 | 172 | .settings-btn { 173 | text-transform: uppercase; 174 | font-weight: 700; 175 | text-decoration: underline; 176 | } 177 | 178 | .settings-btn:hover { 179 | opacity: .8; 180 | } 181 | -------------------------------------------------------------------------------- /public/dashboard/css/main.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /*** Global ***/ 4 | 5 | html, body { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | body { 11 | font-family: 'Roboto', sans-serif; 12 | font-size: 1rem; 13 | font-weight: 400; 14 | } 15 | 16 | header, 17 | main, 18 | footer { 19 | box-sizing: border-box; 20 | margin: 0 auto; 21 | max-width: 90rem; 22 | } 23 | 24 | a {} 25 | 26 | select { 27 | margin-top: .2rem; 28 | height: 2.3rem; 29 | padding: 0 .6rem; 30 | border: none; 31 | font-size: .9rem; 32 | text-transform: uppercase; 33 | border: none; 34 | } 35 | 36 | input[type="checkbox"] { 37 | -webkit-appearance: none; 38 | background: none; 39 | opacity: .6; 40 | box-shadow: none; 41 | padding: 9px; 42 | border-radius: none; 43 | display: inline-block; 44 | position: relative; 45 | transition: .2s; 46 | } 47 | 48 | input[type="checkbox"]:checked { 49 | opacity: 1; 50 | } 51 | 52 | input[type="checkbox"]:checked:after { 53 | content: '\2714'; 54 | position: absolute; 55 | top: 0; 56 | left: .2rem; 57 | font-size: 1rem; 58 | } 59 | 60 | /** general **/ 61 | 62 | .box { 63 | box-sizing: border-box; 64 | } 65 | 66 | 67 | /** header **/ 68 | 69 | header { 70 | display: flex; 71 | padding: 1rem; 72 | align-items: center; 73 | } 74 | 75 | header .col-1 { 76 | display: flex; 77 | flex: auto; 78 | } 79 | 80 | header .col-2 { 81 | display: flex; 82 | flex: auto; 83 | justify-content: flex-end; 84 | } 85 | 86 | header h1 { 87 | box-sizing: border-box; 88 | display: block; 89 | margin: 0; 90 | padding: 0; 91 | line-height: 3rem; 92 | font-size: 2.4rem; 93 | text-transform: uppercase; 94 | } 95 | 96 | header nav { 97 | box-sizing: border-box; 98 | line-height: 3rem; 99 | } 100 | 101 | 102 | /** footer **/ 103 | 104 | footer { 105 | font-size: .9rem; 106 | text-align: center; 107 | margin-top: 4rem; 108 | margin-bottom: 2rem; 109 | opacity: .8; 110 | } 111 | 112 | footer p { 113 | padding: 0; 114 | margin: .5rem; 115 | height: 1.2rem; 116 | } 117 | 118 | 119 | /** message overlay **/ 120 | 121 | @keyframes fadeOut { 122 | from { 123 | opacity: 1; 124 | } 125 | to { 126 | opacity: .1; 127 | } 128 | } 129 | 130 | @keyframes fadeIn { 131 | from { 132 | opacity: 0; 133 | } 134 | to { 135 | opacity: 1; 136 | } 137 | } 138 | 139 | .greyOut header, 140 | .greyOut main, 141 | .greyOut footer { 142 | animation-name: fadeOut; 143 | animation-timing-function: ease; 144 | animation-duration: 4s; 145 | animation-fill-mode: forwards; 146 | } 147 | 148 | #message-overlay { 149 | z-index: 100; 150 | position: fixed; 151 | display: flex; 152 | top: 0; 153 | width: 100%; 154 | height: 100%; 155 | align-items: center; 156 | justify-content: center; 157 | font-size: 1.3rem; 158 | text-align: center; 159 | animation-name: fadeIn; 160 | animation-timing-function: ease; 161 | animation-duration: 6s; 162 | animation-fill-mode: forwards; 163 | } 164 | 165 | #message-overlay .text { 166 | display: flex; 167 | flex-wrap: wrap; 168 | } 169 | 170 | #message-overlay .text div { 171 | width: 100%; 172 | } 173 | 174 | #message-overlay .text .line1 { 175 | font-weight: bold; 176 | } 177 | 178 | #message-overlay .text .line2 { 179 | font-size: 1.1rem; 180 | } 181 | 182 | 183 | /** notification popup **/ 184 | 185 | @keyframes popup { 186 | 0% { 187 | left: -20rem; 188 | opacity: 0; 189 | } 190 | 15% { 191 | left: 4rem; 192 | opacity: 1; 193 | } 194 | 70% { 195 | left: 4rem; 196 | opacity: 1; 197 | } 198 | 100% { 199 | left: 4rem; 200 | opacity: 0; 201 | } 202 | } 203 | 204 | @keyframes message { 205 | 0% { 206 | opacity: 0; 207 | max-height: 0; 208 | } 209 | 15% { 210 | opacity: 0; 211 | max-height: 0; 212 | } 213 | 60% { 214 | opacity: 1; 215 | max-height: 8rem; 216 | } 217 | 80% { 218 | opacity: 1; 219 | max-height: 8rem; 220 | } 221 | 90% { 222 | opacity: 0; 223 | max-height: 0; 224 | } 225 | 100% { 226 | opacity: 0; 227 | max-height: 0; 228 | } 229 | } 230 | 231 | #popup { 232 | position: fixed; 233 | display: none; 234 | z-index: 10; 235 | bottom: 3rem; 236 | left: -20rem; 237 | padding: 1.5rem; 238 | width: 18rem; 239 | text-align: center; 240 | animation-name: popup; 241 | animation-timing-function: ease; 242 | animation-duration: 15s; 243 | animation-fill-mode: forwards; 244 | opacity: 0; 245 | } 246 | 247 | #popup .name { 248 | font-weight: 700; 249 | font-size: 1rem; 250 | } 251 | 252 | #popup .amount { 253 | font-weight: 700; 254 | font-size: 1rem; 255 | } 256 | 257 | #popup .message { 258 | display: block; 259 | margin-top: .3rem; 260 | font-style: italic; 261 | max-height: 0; 262 | animation-name: message; 263 | animation-timing-function: ease; 264 | animation-duration: 12s; 265 | animation-fill-mode: forwards; 266 | } 267 | -------------------------------------------------------------------------------- /public/dashboard/css/settings.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /** back arrow button **/ 4 | 5 | .back-arrow { 6 | display: inline-block; 7 | text-decoration: none; 8 | background-image: url('/ressources/img/back.svg'); 9 | background-repeat: no-repeat; 10 | background-position: center; 11 | background-size: 1.7rem; 12 | width: 1.7rem; 13 | margin-right: .4rem; 14 | opacity: 1; 15 | transition: opacity 400ms ease-in-out; 16 | } 17 | 18 | .back-arrow:hover { 19 | opacity: .6; 20 | } 21 | 22 | 23 | /** general **/ 24 | 25 | #settings { 26 | 27 | display: grid; 28 | grid-gap: 1rem; 29 | grid-template-columns: 50% 50%; 30 | grid-template-rows: auto auto auto; 31 | padding: 0 2rem; 32 | } 33 | 34 | #settings .box { 35 | margin: 0 auto; 36 | padding: 1.5rem; 37 | width: 100%; 38 | } 39 | 40 | #settings .box .head { 41 | text-align: center; 42 | font-size: 1.3rem; 43 | font-weight: 700; 44 | text-transform: uppercase; 45 | margin-bottom: 1rem; 46 | } 47 | 48 | #api ul, 49 | #notifications ul { 50 | display: flex; 51 | margin: 0; 52 | padding: 0; 53 | flex-wrap: wrap; 54 | justify-content: center; 55 | list-style: none; 56 | } 57 | 58 | #api li, 59 | #notifications li { 60 | margin: 1rem; 61 | } 62 | 63 | 64 | /** buttons **/ 65 | 66 | .button { 67 | cursor: pointer; 68 | } 69 | 70 | .check { 71 | display: inline-block; 72 | text-decoration: none; 73 | text-align: center; 74 | font-weight: 700; 75 | transition: all 400ms ease-in-out; 76 | } 77 | 78 | .check .name { 79 | display: block; 80 | } 81 | 82 | .check.disabled { 83 | opacity: .5; 84 | } 85 | 86 | /** api **/ 87 | 88 | #api { 89 | grid-column: 1 / 3; 90 | grid-row: 1; 91 | } 92 | 93 | #api .check { 94 | width: 15rem; 95 | line-height: 3.8rem; 96 | font-size: 1.2rem; 97 | } 98 | 99 | 100 | /** notifications **/ 101 | 102 | #notifications { 103 | grid-column: 1; 104 | grid-row: 2; 105 | } 106 | 107 | #notifications .check { 108 | width: 12rem; 109 | line-height: 3.4rem; 110 | font-size: 1rem; 111 | } 112 | 113 | 114 | /** template **/ 115 | 116 | #template { 117 | grid-column: 2; 118 | grid-row: 2; 119 | text-align: center; 120 | } 121 | 122 | #template select { 123 | width: 13rem; 124 | } 125 | 126 | #template .button { 127 | margin: 1.5rem .3rem; 128 | display: inline-block; 129 | text-align: center; 130 | width: 10rem; 131 | line-height: 2.6rem; 132 | text-decoration: none; 133 | } 134 | 135 | 136 | /* deveoper windows */ 137 | 138 | #devWindow { 139 | z-index: 20; 140 | display: none; 141 | position: absolute; 142 | top: 0; 143 | left: 0; 144 | width: 100%; 145 | } 146 | 147 | #devWindow .inner { 148 | display: flex; 149 | flex-wrap: wrap; 150 | margin: 0 auto; 151 | margin-top: 8rem; 152 | background-color: rgb(38, 50, 56); 153 | width: 50rem; 154 | padding: 1rem; 155 | } 156 | 157 | #devWindow .head { 158 | height: 2rem; 159 | font-weight: bold; 160 | font-size: 1.5rem; 161 | padding: 1rem; 162 | width: 100%; 163 | text-align: center; 164 | } 165 | 166 | #devWindow .bottom { 167 | height: 2rem; 168 | font-weight: bold; 169 | font-size: 1rem; 170 | padding: 1rem; 171 | width: 100%; 172 | text-align: center; 173 | } 174 | 175 | #devWindow .closeButton { 176 | display: inline-block; 177 | text-align: center; 178 | width: 10rem; 179 | line-height: 2.4rem; 180 | font-weight: 700; 181 | } 182 | 183 | #devWindow .col-1 { 184 | flex: 50; 185 | height: 15rem; 186 | text-align: center; 187 | padding-top: 1rem; 188 | } 189 | 190 | #devWindow .col-2 { 191 | flex: 50; 192 | height: 15rem; 193 | text-align: center; 194 | padding-top: 1rem; 195 | } 196 | 197 | #devWindow .title { 198 | margin-top: 1rem; 199 | font-weight: bold; 200 | } 201 | 202 | #devWindow .label { 203 | font-size: 1rem; 204 | width: 100%; 205 | margin-bottom: .4rem; 206 | } 207 | 208 | #devWindow .multipages { 209 | display: flex; 210 | flex-wrap: wrap; 211 | margin: 0 auto; 212 | width: 17rem; 213 | } 214 | 215 | #devWindow input[type="number"] { 216 | width: 8rem; 217 | line-height: 1.4rem; 218 | border: none; 219 | font-size: .9rem; 220 | padding: .2rem .2rem .2rem .4rem; 221 | } 222 | 223 | #devWindow .slider1 { 224 | display: flex; 225 | margin: 0 auto; 226 | height: 1.5rem; 227 | } 228 | 229 | 230 | /** misc **/ 231 | 232 | #misc { 233 | grid-column: 1 / 3; 234 | grid-row: 3; 235 | min-height: 15rem; 236 | } 237 | 238 | #misc .title { 239 | width: 100%; 240 | font-weight: bold; 241 | margin-bottom: .6rem; 242 | } 243 | 244 | #misc .inner { 245 | display: grid; 246 | grid-template-columns: 33.33% 33.33% 33.33%; 247 | grid-template-rows: auto; 248 | } 249 | 250 | #misc .col-1 { 251 | display: flex; 252 | grid-column: auto; 253 | grid-row: 1; 254 | text-align: center; 255 | justify-content: center; 256 | align-items: center; 257 | } 258 | 259 | #notificationTest { 260 | display: flex; 261 | flex-wrap: wrap; 262 | justify-content: center; 263 | align-items: center; 264 | } 265 | 266 | #notificationTest .title { 267 | font-size: .9rem; 268 | text-transform: uppercase; 269 | } 270 | 271 | #notificationTest select { 272 | margin: 0; 273 | width: 12rem; 274 | height: 2.32rem; 275 | } 276 | 277 | #push { 278 | margin: 0 0 0 .2rem; 279 | line-height: 2.32rem; 280 | width: 4rem; 281 | } 282 | 283 | #misc .col-2 { 284 | display: flex; 285 | grid-column: auto; 286 | grid-row: 1; 287 | text-align: center; 288 | justify-content: center; 289 | align-items: center; 290 | } 291 | 292 | #misc .col-2 ul { 293 | list-style: none; 294 | margin: 0; 295 | } 296 | 297 | #misc .col-2 ul { 298 | padding: 0; 299 | margin: 0; 300 | } 301 | 302 | #misc .col-2 .button { 303 | margin: 0 .3rem .3rem .3rem; 304 | display: inline-block; 305 | text-align: center; 306 | width: 11rem; 307 | line-height: 2.5rem; 308 | text-decoration: none; 309 | } 310 | 311 | #misc .col-3 { 312 | display: flex; 313 | grid-column: auto; 314 | grid-row: 1; 315 | text-align: center; 316 | justify-content: center; 317 | align-items: center; 318 | } 319 | 320 | #misc .col-3 ul { 321 | display: block; 322 | list-style: none; 323 | margin: 0 auto; 324 | padding: 0 6rem; 325 | } 326 | 327 | #misc .col-3 li { 328 | display: flex; 329 | line-height: 2rem; 330 | align-items: center; 331 | } 332 | 333 | #misc .col-3 span { 334 | margin-left: .4rem; 335 | } 336 | -------------------------------------------------------------------------------- /public/dashboard/img/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48 | -------------------------------------------------------------------------------- /public/dashboard/js/charts.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, commonjs */ 2 | 'use strict'; 3 | 4 | const Chart = require('../../../node_modules/chart.js/dist/Chart.min.js'); 5 | 6 | class Charts { 7 | constructor() { 8 | 9 | const stats = window.stats.charts; 10 | 11 | Chart.defaults.global.defaultFontColor = 'rgba(177, 191, 198, 0.7)'; 12 | Chart.defaults.global.defaultFontFamily = 'Roboto, sans-serif'; 13 | 14 | const data1 = { 15 | labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 16 | datasets: [ 17 | { 18 | type: 'line', 19 | label: 'Follows', 20 | borderColor: 'rgb(245, 245, 245)', 21 | backgroundColor: 'rgba(245, 245, 245, 0.4)', 22 | data: stats.follows, 23 | borderWidth: 0, 24 | lineTension: 0 25 | }, 26 | { 27 | type: 'bar', 28 | label: 'Subscriptions', 29 | backgroundColor: 'rgb(198, 40, 40)', 30 | data: stats.subscriptions, 31 | borderColor: 'white', 32 | borderWidth: 0 33 | } 34 | ] 35 | }; 36 | 37 | const data2 = { 38 | labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 39 | datasets: [ 40 | { 41 | type: 'bar', 42 | label: 'Donations', 43 | backgroundColor: 'rgb(21, 101, 193)', 44 | data: stats.donations.count, 45 | borderColor: 'white', 46 | borderWidth: 0 47 | }, 48 | { 49 | type: 'bar', 50 | label: 'Amount', 51 | backgroundColor: 'rgb(30, 136, 230)', 52 | data: stats.donations.amount, 53 | borderColor: 'white', 54 | borderWidth: 0 55 | } 56 | ] 57 | }; 58 | 59 | let options = {}, 60 | ctx1, 61 | ctx2; 62 | 63 | window.onload = () => { 64 | ctx1 = document.getElementById('chart1').getContext('2d'); 65 | ctx2 = document.getElementById('chart2').getContext('2d'); 66 | 67 | options = { 68 | responsive: true, 69 | title: { 70 | display: false 71 | }, 72 | scales: { 73 | xAxes: [{ 74 | gridLines: { 75 | display: false 76 | } 77 | }], 78 | yAxes: [{ 79 | gridLines: { 80 | display: false, 81 | color: '#37474F', 82 | drawBorder: false 83 | }, 84 | ticks: { 85 | display: true, 86 | beginAtZero: true, 87 | suggestedMin: 0, 88 | suggestedMax: 10 89 | } 90 | }] 91 | }, 92 | tooltips: { 93 | mode: 'index', 94 | intersect: false, 95 | cornerRadius: 0, 96 | caretSize: 0, 97 | xPadding: 15, 98 | yPadding: 12, 99 | backgroundColor: 'rgba(144, 164, 173, .9)', 100 | titleFontColor: 'black', 101 | bodyFontColor: 'black', 102 | displayColors: false 103 | }, 104 | layout: { 105 | padding: 20 106 | } 107 | }; 108 | 109 | this.chart1 = new Chart(ctx1, { 110 | type: 'bar', 111 | data: data1, 112 | options 113 | }); 114 | this.chart2 = new Chart(ctx2, { 115 | type: 'bar', 116 | data: data2, 117 | options 118 | }); 119 | }; 120 | 121 | } 122 | 123 | compare(data) { 124 | 125 | new Promise(resolve => { 126 | 127 | let changed = false; 128 | 129 | for (let i = 0; i < data.follows.length; i++) { 130 | if (data.follows[i] !== this.chart1.data.datasets[0].data[i]) { 131 | 132 | this.chart1.data.datasets[0].data = data.follows; 133 | 134 | changed = true; 135 | break; 136 | } 137 | } 138 | 139 | for (let i = 0; i < data.subscriptions.length; i++) { 140 | if (data.subscriptions[i] !== this.chart1.data.datasets[1].data[i]) { 141 | 142 | this.chart1.data.datasets[1].data = data.subscriptions; 143 | 144 | changed = true; 145 | break; 146 | } 147 | } 148 | 149 | resolve(changed); 150 | 151 | }).then(changed => { 152 | if (changed) this.chart1.update(); 153 | }); 154 | 155 | 156 | new Promise(resolve => { 157 | 158 | let changed = false; 159 | 160 | for (let i = 0; i < data.donations.count.length; i++) { 161 | if (data.donations.count[i] !== this.chart2.data.datasets[0].data[i]) { 162 | 163 | this.chart2.data.datasets[0].data = data.donations.count; 164 | this.chart2.data.datasets[1].data = data.donations.amount; 165 | 166 | changed = true; 167 | break; 168 | } 169 | } 170 | 171 | resolve(changed); 172 | 173 | }).then(changed => { 174 | if (changed) this.chart2.update(); 175 | }); 176 | 177 | } 178 | 179 | } 180 | 181 | window.charts = new Charts(); 182 | -------------------------------------------------------------------------------- /public/dashboard/js/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, commonjs */ 2 | 'use strict'; 3 | 4 | const $ = require('../../../node_modules/jquery/dist/jquery.slim.min.js'); 5 | const IO = require('../../../node_modules/socket.io-client/dist/socket.io.slim.js'); 6 | const moment = require('../../../node_modules/moment/min/moment-with-locales.min.js'); 7 | 8 | 9 | class Socket extends IO { 10 | constructor() { 11 | 12 | super(); 13 | this.connect(); 14 | 15 | this.connected = undefined; 16 | 17 | this.on('connect', () => { 18 | 19 | this.connected = true; 20 | 21 | if ($('.greyOut').length) { 22 | $('body').removeClass('greyOut'); 23 | $('body').css('overflow', 'auto'); 24 | $('#message-overlay').remove(); 25 | } 26 | 27 | // Listeners 28 | this.on('stats', data => { 29 | 30 | if (typeof window.charts === 'object') { 31 | if (data.charts !== undefined && window.page === 'dashboard') 32 | window.charts.compare(data.charts); 33 | 34 | if (data.feed !== undefined && window.page === 'dashboard') 35 | feed.compare(data.feed); 36 | } 37 | 38 | }).on('dashboard', (type, err, data) => { 39 | if (settings) settings.response(type, err, data); 40 | }).on('notification', data => { 41 | if (window.dashboard.popups) notifications.parser(data); 42 | }).on('general', data => { 43 | 44 | // client-server version matching 45 | if (data.version !== undefined && 46 | data.version !== window.version && 47 | !$('#reloadCount').length) { 48 | 49 | let count = 10; 50 | 51 | $('body').addClass('greyOut'); 52 | $('body').css('overflow', 'hidden'); 53 | $('body').append(` 54 |