index.js 15 KB

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