index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. const http = require('http');
  2. const crypto = require('crypto');
  3. const cheerio = require('cheerio');
  4. const {defaultPermissions} = require('../util/default.json');
  5. const sqlite3 = require('sqlite3').verbose();
  6. const mode = ( process.env.READONLY ? sqlite3.OPEN_READONLY : sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE );
  7. const db = new sqlite3.Database( './wikibot.db', mode, dberror => {
  8. if ( dberror ) {
  9. console.log( '- Dashboard: Error while connecting to the database: ' + dberror );
  10. return dberror;
  11. }
  12. console.log( '- Dashboard: Connected to the database.' );
  13. } );
  14. const DiscordOauth2 = require('discord-oauth2');
  15. const oauth = new DiscordOauth2( {
  16. clientId: process.env.bot,
  17. clientSecret: process.env.secret,
  18. redirectUri: process.env.dashboard
  19. } );
  20. const fs = require('fs');
  21. const path = require('path');
  22. const files = {
  23. index: fs.readFileSync('./dashboard/index.html'),
  24. login: fs.readFileSync('./dashboard/login.html'),
  25. src: new Map(fs.readdirSync( './dashboard/src' ).map( file => {
  26. let contentType = 'text/html';
  27. switch ( path.extname(file) ) {
  28. case '.css':
  29. contentType = 'text/css';
  30. break;
  31. case '.js':
  32. contentType = 'text/javascript';
  33. break;
  34. case '.json':
  35. contentType = 'application/json';
  36. break;
  37. case '.png':
  38. contentType = 'image/png';
  39. break;
  40. case '.jpg':
  41. contentType = 'image/jpg';
  42. break;
  43. }
  44. return [`/src/${file}`, {
  45. name: file, contentType,
  46. path: `./dashboard/src/${file}`
  47. }];
  48. } ))
  49. }
  50. /**
  51. * @type {Map<Number, PromiseConstructor>}
  52. */
  53. var messages = new Map();
  54. var messageId = 1;
  55. process.on( 'message', message => {
  56. if ( message.id ) {
  57. if ( message.data.error ) messages.get(message.id).reject(message.data.error);
  58. else messages.get(message.id).resolve(message.data.response);
  59. return messages.delete(message.id);
  60. }
  61. console.log( '- [Dashboard]: Message received!', message );
  62. } );
  63. /**
  64. * Send messages to the manager.
  65. * @param {Object} [message] - The message.
  66. * @returns {Promise<Object>}
  67. */
  68. function sendMsg(message) {
  69. var id = messageId++;
  70. var promise = new Promise( (resolve, reject) => {
  71. messages.set(id, {resolve, reject});
  72. process.send( {id, data: message} );
  73. } );
  74. return promise;
  75. }
  76. /**
  77. * @typedef Settings
  78. * @property {String} state
  79. * @property {String} access_token
  80. * @property {User} user
  81. * @property {Object} guilds
  82. * @property {Map<String, Guild>} guilds.isMember
  83. * @property {Map<String, Guild>} guilds.notMember
  84. */
  85. /**
  86. * @typedef User
  87. * @property {String} id
  88. * @property {String} username
  89. * @property {String} discriminator
  90. * @property {String} avatar
  91. * @property {String} locale
  92. */
  93. /**
  94. * @typedef Guild
  95. * @property {String} id
  96. * @property {String} name
  97. * @property {String} acronym
  98. * @property {String} [icon]
  99. * @property {String} permissions
  100. */
  101. /**
  102. * @type {Map<String, Settings>}
  103. */
  104. var settingsData = new Map();
  105. const server = http.createServer((req, res) => {
  106. if ( req.method !== 'GET' ) {
  107. let body = '<img width="400" src="https://http.cat/418"><br><strong>' + http.STATUS_CODES[418] + '</strong>';
  108. res.writeHead(418, {'Content-Length': body.length});
  109. res.write( body );
  110. return res.end();
  111. }
  112. var reqURL = new URL(req.url, process.env.dashboard);
  113. if ( reqURL.pathname === '/favicon.ico' ) {
  114. res.writeHead(302, {Location: 'https://cdn.discordapp.com/avatars/461189216198590464/f69cdc197791aed829882b64f9760dbb.png?size=64'});
  115. return res.end();
  116. }
  117. if ( files.src.has(reqURL.pathname) ) {
  118. let file = files.src.get(reqURL.pathname);
  119. res.writeHead(200, {'Content-Type': file.contentType});
  120. return fs.createReadStream(file.path).pipe(res);
  121. }
  122. res.setHeader('Content-Type', 'text/html');
  123. res.setHeader('Content-Language', ['en']);
  124. var lastGuild = req.headers?.cookie?.split('; ')?.filter( cookie => {
  125. return cookie.split('=')[0] === 'guild';
  126. } )?.map( cookie => cookie.replace( /^guild="(\w+)"$/, '$1' ) )?.join();
  127. if ( lastGuild ) res.setHeader('Set-Cookie', [`guild="${lastGuild}"; Max-Age=0; HttpOnly; Path=/`]);
  128. var state = req.headers.cookie?.split('; ')?.filter( cookie => {
  129. return cookie.split('=')[0] === 'wikibot';
  130. } )?.map( cookie => cookie.replace( /^wikibot="(\w+(?:-\d+)?)"$/, '$1' ) )?.join();
  131. if ( reqURL.pathname === '/login' ) {
  132. if ( settingsData.has(state) ) {
  133. res.writeHead(302, {Location: '/'});
  134. return res.end();
  135. }
  136. if ( state ) res.setHeader('Set-Cookie', [`wikibot="${state}"; Max-Age=0; HttpOnly`]);
  137. var $ = cheerio.load(files.login);
  138. $('.guild#invite a').attr('href', oauth.generateAuthUrl( {
  139. scope: ['identify', 'guilds', 'bot'],
  140. permissions: defaultPermissions, state
  141. } ));
  142. let responseCode = 200;
  143. let notice = '';
  144. if ( reqURL.searchParams.get('action') === 'failed' ) {
  145. responseCode = 400;
  146. notice = createNotice($, {
  147. title: 'Login failed!',
  148. text: 'An error occurred while logging you in, please try again.'
  149. });
  150. }
  151. if ( reqURL.searchParams.get('action') === 'unauthorized' ) {
  152. responseCode = 401;
  153. notice = createNotice($, {
  154. title: 'Not logged in!',
  155. text: 'Please login before you can change any settings.'
  156. });
  157. }
  158. if ( reqURL.searchParams.get('action') === 'logout' ) {
  159. notice = createNotice($, {
  160. title: 'Successfully logged out!',
  161. text: 'You have been successfully logged out. To change any settings you need to login again.'
  162. });
  163. }
  164. $('replace#notice').replaceWith(notice);
  165. state = crypto.randomBytes(16).toString("hex");
  166. while ( settingsData.has(state) ) {
  167. state = crypto.randomBytes(16).toString("hex");
  168. }
  169. let url = oauth.generateAuthUrl( {
  170. scope: ['identify', 'guilds'],
  171. prompt: 'none', state
  172. } );
  173. $('a#login').attr('href', url);
  174. $('replace#text').replaceWith(`<a href="${url}">Login</a>`);
  175. let body = $.html();
  176. res.writeHead(responseCode, {
  177. 'Set-Cookie': [`wikibot="${state}"; HttpOnly`],
  178. 'Content-Length': body.length
  179. });
  180. res.write( body );
  181. return res.end();
  182. }
  183. if ( reqURL.pathname === '/logout' ) {
  184. settingsData.delete(state);
  185. res.writeHead(302, {
  186. Location: '/login?action=logout',
  187. 'Set-Cookie': [`wikibot="${state}"; Max-Age=0; HttpOnly`]
  188. });
  189. return res.end();
  190. }
  191. if ( !state ) {
  192. res.writeHead(302, {
  193. Location: ( reqURL.pathname === '/' ? '/login' : '/login?action=unauthorized' )
  194. });
  195. return res.end();
  196. }
  197. if ( reqURL.pathname === '/oauth' ) {
  198. if ( settingsData.has(state) ) {
  199. res.writeHead(302, {Location: '/'});
  200. return res.end();
  201. }
  202. if ( state !== reqURL.searchParams.get('state') || !reqURL.searchParams.get('code') ) {
  203. res.writeHead(302, {Location: '/login?action=unauthorized'});
  204. return res.end();
  205. }
  206. return oauth.tokenRequest( {
  207. scope: ['identify', 'guilds'],
  208. code: reqURL.searchParams.get('code'),
  209. grantType: 'authorization_code'
  210. } ).then( ({access_token}) => {
  211. return Promise.all([
  212. oauth.getUser(access_token),
  213. oauth.getUserGuilds(access_token)
  214. ]).then( ([user, guilds]) => {
  215. guilds = guilds.filter( guild => {
  216. return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
  217. } ).map( guild => {
  218. return {
  219. id: guild.id,
  220. name: guild.name,
  221. acronym: guild.name.replace( /'s /g, ' ' ).replace( /\w+/g, e => e[0] ).replace( /\s/g, '' ),
  222. icon: ( guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.`
  223. + ( guild.icon.startsWith( 'a_' ) ? 'gif' : 'png' ) + '?size=128' : null ),
  224. permissions: guild.permissions
  225. };
  226. } );
  227. sendMsg( {
  228. type: 'isMemberAll',
  229. guilds: guilds.map( guild => guild.id )
  230. } ).then( response => {
  231. let isMember = new Map();
  232. let notMember = new Map();
  233. response.forEach( (guild, i) => {
  234. if ( guild ) isMember.set(guilds[i].id, guilds[i]);
  235. else notMember.set(guilds[i].id, guilds[i]);
  236. } );
  237. settingsData.set(`${state}-${user.id}`, {
  238. state: `${state}-${user.id}`,
  239. access_token,
  240. user: {
  241. id: user.id,
  242. username: user.username,
  243. discriminator: user.discriminator,
  244. avatar: 'https://cdn.discordapp.com/' + ( user.avatar ?
  245. `avatars/${user.id}/${user.avatar}.` +
  246. ( user.avatar.startsWith( 'a_' ) ? 'gif' : 'png' ) :
  247. `embed/avatars/${user.discriminator % 5}.png` ) + '?size=64',
  248. locale: user.locale
  249. },
  250. guilds: {isMember, notMember}
  251. });
  252. res.writeHead(302, {
  253. Location: ( lastGuild ? '/guild/' + lastGuild : '/' ),
  254. 'Set-Cookie': [
  255. `wikibot="${state}"; Max-Age=0; HttpOnly`,
  256. `wikibot="${state}-${user.id}"; HttpOnly`
  257. ]
  258. });
  259. return res.end();
  260. }, error => {
  261. console.log( '- Dashboard: Error while checking the guilds:', error );
  262. res.writeHead(302, {Location: '/login?action=failed'});
  263. return res.end();
  264. } );
  265. }, error => {
  266. console.log( '- Dashboard: Error while getting user and guilds: ' + error );
  267. res.writeHead(302, {Location: '/login?action=failed'});
  268. return res.end();
  269. } );
  270. }, error => {
  271. console.log( '- Dashboard: Error while getting the token: ' + error );
  272. res.writeHead(302, {Location: '/login?action=failed'});
  273. return res.end();
  274. } );
  275. }
  276. if ( !settingsData.has(state) ) {
  277. res.writeHead(302, {
  278. Location: ( reqURL.pathname === '/' ? '/login' : '/login?action=unauthorized' )
  279. });
  280. return res.end();
  281. }
  282. var settings = settingsData.get(state);
  283. if ( reqURL.pathname === '/refresh' ) {
  284. return oauth.getUserGuilds(settings.access_token).then( guilds => {
  285. guilds = guilds.filter( guild => {
  286. return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
  287. } ).map( guild => {
  288. return {
  289. id: guild.id,
  290. name: guild.name,
  291. acronym: guild.name.replace( /'s /g, ' ' ).replace( /\w+/g, e => e[0] ).replace( /\s/g, '' ),
  292. icon: ( guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.`
  293. + ( guild.icon.startsWith( 'a_' ) ? 'gif' : 'png' ) + '?size=128' : null ),
  294. permissions: guild.permissions
  295. };
  296. } );
  297. sendMsg( {
  298. type: 'isMemberAll',
  299. guilds: guilds.map( guild => guild.id )
  300. } ).then( response => {
  301. let isMember = new Map();
  302. let notMember = new Map();
  303. response.forEach( (guild, i) => {
  304. if ( guild ) isMember.set(guilds[i].id, guilds[i]);
  305. else notMember.set(guilds[i].id, guilds[i]);
  306. } );
  307. settings.guilds = {isMember, notMember};
  308. res.writeHead(302, {
  309. Location: ( reqURL.searchParams.get('return') || '/' )
  310. });
  311. return res.end();
  312. }, error => {
  313. console.log( '- Dashboard: Error while checking refreshed guilds:', error );
  314. res.writeHead(302, {Location: '/login?action=failed'});
  315. return res.end();
  316. } );
  317. }, error => {
  318. console.log( '- Dashboard: Error while refreshing guilds: ' + error );
  319. res.writeHead(302, {Location: '/login?action=failed'});
  320. return res.end();
  321. } );
  322. }
  323. var $ = cheerio.load(files.index);
  324. let notice = '';
  325. if ( process.env.READONLY ) {
  326. notice = createNotice($, {
  327. title: 'Read-only database!',
  328. text: 'You can currently only view your settings but not change them.'
  329. });
  330. }
  331. $('replace#notice').replaceWith(notice);
  332. $('.navbar #logout img').attr('src', settings.user.avatar);
  333. $('.navbar #logout span').text(`${settings.user.username} #${settings.user.discriminator}`);
  334. $('.guild#invite a').attr('href', oauth.generateAuthUrl( {
  335. scope: ['identify', 'guilds', 'bot'],
  336. permissions: defaultPermissions, state
  337. } ));
  338. $('.guild#refresh a').attr('href', '/refresh?return=' + reqURL.pathname);
  339. let guilds = $('<div>');
  340. if ( settings.guilds.isMember.size ) {
  341. $('<div class="guild">').append(
  342. $('<div class="separator">')
  343. ).appendTo(guilds);
  344. settings.guilds.isMember.forEach( guild => {
  345. $('<div class="guild">').attr('id', guild.id).append(
  346. $('<div class="bar">'),
  347. $('<a>').attr('href', `/guild/${guild.id}`).attr('alt', guild.name).append(
  348. ( guild.icon ?
  349. $('<img class="avatar" width="48" height="48">').attr('src', guild.icon).attr('alt', guild.name)
  350. : $('<div class="avatar noicon">').text(guild.acronym) )
  351. )
  352. ).appendTo(guilds);
  353. } );
  354. }
  355. if ( settings.guilds.notMember.size ) {
  356. $('<div class="guild">').append(
  357. $('<div class="separator">')
  358. ).appendTo(guilds);
  359. settings.guilds.notMember.forEach( guild => {
  360. $('<div class="guild">').attr('id', guild.id).append(
  361. $('<div class="bar">'),
  362. $('<a>').attr('href', `/guild/${guild.id}`).attr('alt', guild.name).append(
  363. ( guild.icon ?
  364. $('<img class="avatar" width="48" height="48">').attr('src', guild.icon).attr('alt', guild.name)
  365. : $('<div class="avatar noicon">').text(guild.acronym) )
  366. )
  367. ).appendTo(guilds);
  368. } );
  369. }
  370. $('replace#guilds').replaceWith(guilds.children());
  371. if ( reqURL.pathname.startsWith( '/guild/' ) ) {
  372. let id = reqURL.pathname.replace( '/guild/', '' );
  373. if ( settings.guilds.isMember.has(id) ) {
  374. $(`.guild#${id}`).addClass('selected');
  375. let guild = settings.guilds.isMember.get(id);
  376. $('head title').text(`${guild.name} – ` + $('head title').text());
  377. res.setHeader('Set-Cookie', [`guild="${id}"; HttpOnly; Path=/`]);
  378. $('replace#text').replaceWith(`${guild.permissions}`);
  379. }
  380. if ( settings.guilds.notMember.has(id) ) {
  381. $(`.guild#${id}`).addClass('selected');
  382. let guild = settings.guilds.notMember.get(id);
  383. $('head title').text(`${guild.name} – ` + $('head title').text());
  384. res.setHeader('Set-Cookie', [`guild="${id}"; HttpOnly; Path=/`]);
  385. let url = oauth.generateAuthUrl( {
  386. scope: ['identify', 'guilds', 'bot'],
  387. permissions: defaultPermissions,
  388. guild_id: id, state
  389. } );
  390. $('replace#text').replaceWith($('<a>').attr('href', url).text(guild.permissions));
  391. }
  392. $('replace#text').replaceWith('You are missing the <code>MANAGE_GUILD</code> permission.');
  393. }
  394. $('replace#text').replaceWith('Keks');
  395. let body = $.html();
  396. res.writeHead(200, {'Content-Length': body.length});
  397. res.write( body );
  398. return res.end();
  399. });
  400. server.listen(8080, 'localhost', () => {
  401. console.log( '- Dashboard: Server running at http://localhost:8080/' );
  402. });
  403. /**
  404. * Create a red notice
  405. * @param {CheerioStatic} $ - The cheerio static
  406. * @param {{title: String, text: String}[]} notices - The notices to create
  407. * @returns {Cheerio}
  408. */
  409. function createNotice($, ...notices) {
  410. return notices.map( notice => {
  411. return $('<div class="notice">').append(
  412. $('<b>').text(notice.title),
  413. $('<div>').text(notice.text)
  414. );
  415. } );
  416. }
  417. const permissions = {
  418. ADMINISTRATOR: 1 << 3,
  419. MANAGE_CHANNELS: 1 << 4,
  420. MANAGE_GUILD: 1 << 5,
  421. MANAGE_MESSAGES: 1 << 13,
  422. MENTION_EVERYONE: 1 << 17,
  423. MANAGE_NICKNAMES: 1 << 27,
  424. MANAGE_ROLES: 1 << 28,
  425. MANAGE_WEBHOOKS: 1 << 29,
  426. MANAGE_EMOJIS: 1 << 30
  427. }
  428. /**
  429. * Check if a permission is included in the BitField
  430. * @param {String|Number} all - BitField of multiple permissions
  431. * @param {String} permission - Name of the permission to check for
  432. * @param {Boolean} [admin] - If administrator permission can overwrite
  433. * @returns {Boolean}
  434. */
  435. function hasPerm(all, permission, admin = true) {
  436. var bit = permissions[permission];
  437. var adminOverwrite = ( admin && (all & permissions.ADMINISTRATOR) === permissions.ADMINISTRATOR );
  438. return ( adminOverwrite || (all & bit) === bit )
  439. }
  440. /**
  441. * End the process gracefully.
  442. * @param {NodeJS.Signals} signal - The signal received.
  443. */
  444. function graceful(signal) {
  445. console.log( '- Dashboard: ' + signal + ': Closing the dashboard...' );
  446. server.close( () => {
  447. console.log( '- Dashboard: ' + signal + ': Closed the dashboard server.' );
  448. } );
  449. db.close( dberror => {
  450. if ( dberror ) {
  451. console.log( '- Dashboard: ' + signal + ': Error while closing the database connection: ' + dberror );
  452. return dberror;
  453. }
  454. console.log( '- Dashboard: ' + signal + ': Closed the database connection.' );
  455. process.exit(0);
  456. } );
  457. }
  458. process.once( 'SIGINT', graceful );
  459. process.once( 'SIGTERM', graceful );