oauth.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. const crypto = require('crypto');
  2. const cheerio = require('cheerio');
  3. const {defaultPermissions} = require('../util/default.json');
  4. const Wiki = require('../util/wiki.js');
  5. const allLangs = require('./i18n.js').allLangs().names;
  6. const {got, sessionData, settingsData, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
  7. const DiscordOauth2 = require('discord-oauth2');
  8. const oauth = new DiscordOauth2( {
  9. clientId: process.env.bot,
  10. clientSecret: process.env.secret,
  11. redirectUri: process.env.dashboard
  12. } );
  13. const file = require('fs').readFileSync('./dashboard/login.html');
  14. /**
  15. * Let a user login
  16. * @param {import('http').ServerResponse} res - The server response
  17. * @param {import('./i18n.js')} dashboardLang - The user language.
  18. * @param {String} theme - The display theme
  19. * @param {String} [state] - The user state
  20. * @param {String} [action] - The action the user made
  21. */
  22. function dashboard_login(res, dashboardLang, theme, state, action) {
  23. if ( state && sessionData.has(state) ) {
  24. if ( !action ) {
  25. res.writeHead(302, {Location: '/'});
  26. return res.end();
  27. }
  28. sessionData.delete(state);
  29. }
  30. var $ = cheerio.load(file);
  31. $('html').attr('lang', dashboardLang.lang);
  32. if ( theme === 'light' ) $('html').addClass('theme-light');
  33. $('<script>').text(`
  34. const selectLanguage = '${dashboardLang.get('general.language').replace( /'/g, '\\$&' )}';
  35. const allLangs = ${JSON.stringify(allLangs)};
  36. `).insertBefore('script#langjs');
  37. $('head title').text(dashboardLang.get('general.login') + ' – ' + dashboardLang.get('general.title'));
  38. $('#login-button span, .channel#login div').text(dashboardLang.get('general.login'));
  39. $('.channel#invite-wikibot div').text(dashboardLang.get('general.invite'));
  40. $('.guild#invite a').attr('alt', dashboardLang.get('general.invite'));
  41. $('.guild#theme-dark a').attr('alt', dashboardLang.get('general.theme-dark'));
  42. $('.guild#theme-light a').attr('alt', dashboardLang.get('general.theme-light'));
  43. $('#support span').text(dashboardLang.get('general.support'));
  44. $('#text .description #welcome').html(dashboardLang.get('general.welcome'));
  45. let responseCode = 200;
  46. let prompt = 'none';
  47. if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);
  48. if ( action ) createNotice($, action, dashboardLang);
  49. if ( action === 'unauthorized' ) $('head').append(
  50. $('<script>').text('history.replaceState(null, null, "/login");')
  51. );
  52. if ( action === 'logout' ) prompt = 'consent';
  53. if ( action === 'loginfail' ) responseCode = 400;
  54. state = Date.now().toString(16) + crypto.randomBytes(16).toString("hex");
  55. while ( sessionData.has(state) ) {
  56. state = Date.now().toString(16) + crypto.randomBytes(16).toString("hex");
  57. }
  58. let invite = oauth.generateAuthUrl( {
  59. scope: ['identify', 'guilds', 'bot', 'applications.commands'],
  60. permissions: defaultPermissions, state
  61. } );
  62. $('.guild#invite a, .channel#invite-wikibot').attr('href', invite);
  63. let url = oauth.generateAuthUrl( {
  64. scope: ['identify', 'guilds'],
  65. prompt, state
  66. } );
  67. $('.channel#login, #login-button').attr('href', url);
  68. addWidgets($, dashboardLang);
  69. let body = $.html();
  70. res.writeHead(responseCode, {
  71. 'Set-Cookie': [
  72. ...( res.getHeader('Set-Cookie') || [] ),
  73. `wikibot="${state}"; HttpOnly; Path=/`
  74. ],
  75. 'Content-Length': Buffer.byteLength(body)
  76. });
  77. res.write( body );
  78. return res.end();
  79. }
  80. /**
  81. * Load oauth data of a user
  82. * @param {import('http').ServerResponse} res - The server response
  83. * @param {String} state - The user state
  84. * @param {URLSearchParams} searchParams - The url parameters
  85. * @param {String} [lastGuild] - The guild to return to
  86. */
  87. function dashboard_oauth(res, state, searchParams, lastGuild) {
  88. if ( searchParams.get('error') === 'access_denied' && state === searchParams.get('state') && sessionData.has(state) ) {
  89. res.writeHead(302, {Location: '/'});
  90. return res.end();
  91. }
  92. if ( state !== searchParams.get('state') || !searchParams.get('code') ) {
  93. res.writeHead(302, {Location: '/login?action=failed'});
  94. return res.end();
  95. }
  96. sessionData.delete(state);
  97. return oauth.tokenRequest( {
  98. scope: ['identify', 'guilds'],
  99. code: searchParams.get('code'),
  100. grantType: 'authorization_code'
  101. } ).then( ({access_token}) => {
  102. return Promise.all([
  103. oauth.getUser(access_token),
  104. oauth.getUserGuilds(access_token)
  105. ]).then( ([user, guilds]) => {
  106. guilds = guilds.filter( guild => {
  107. return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
  108. } ).map( guild => {
  109. return {
  110. id: guild.id,
  111. name: guild.name,
  112. acronym: guild.name.replace( /'s /g, ' ' ).replace( /\w+/g, e => e[0] ).replace( /\s/g, '' ),
  113. icon: ( guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.`
  114. + ( guild.icon.startsWith( 'a_' ) ? 'gif' : 'png' ) : null ),
  115. userPermissions: guild.permissions
  116. };
  117. } );
  118. sendMsg( {
  119. type: 'getGuilds',
  120. member: user.id,
  121. guilds: guilds.map( guild => guild.id )
  122. } ).then( response => {
  123. var userSession = {
  124. state: `${state}-${user.id}`,
  125. access_token,
  126. user_id: user.id
  127. };
  128. sessionData.set(userSession.state, userSession);
  129. /** @type {import('./util.js').Settings} */
  130. var settings = ( settingsData.has(user.id) ? settingsData.get(user.id) : {
  131. user: {},
  132. guilds: {}
  133. } );
  134. settings.user.id = user.id;
  135. settings.user.username = user.username;
  136. settings.user.discriminator = user.discriminator;
  137. settings.user.avatar = 'https://cdn.discordapp.com/' + ( user.avatar ? `avatars/${user.id}/${user.avatar}.` + ( user.avatar.startsWith( 'a_' ) ? 'gif' : 'png' ) : `embed/avatars/${user.discriminator % 5}.png` ) + '?size=64';
  138. settings.user.locale = user.locale;
  139. settings.guilds.count = guilds.length;
  140. settings.guilds.isMember = new Map();
  141. settings.guilds.notMember = new Map();
  142. response.forEach( (guild, i) => {
  143. if ( guild ) {
  144. if ( guild === 'noMember' ) return;
  145. settings.guilds.isMember.set(guilds[i].id, Object.assign(guilds[i], guild));
  146. }
  147. else settings.guilds.notMember.set(guilds[i].id, guilds[i]);
  148. } );
  149. settingsData.set(user.id, settings);
  150. if ( searchParams.has('guild_id') ) {
  151. lastGuild = searchParams.get('guild_id') + '/settings';
  152. }
  153. res.writeHead(302, {
  154. Location: ( lastGuild && /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new))?$/.test(lastGuild) ? `/guild/${lastGuild}` : '/' ),
  155. 'Set-Cookie': [`wikibot="${userSession.state}"; HttpOnly; Path=/`]
  156. });
  157. return res.end();
  158. }, error => {
  159. console.log( '- Dashboard: Error while getting the guilds:', error );
  160. res.writeHead(302, {Location: '/login?action=failed'});
  161. return res.end();
  162. } );
  163. }, error => {
  164. console.log( '- Dashboard: Error while getting user and guilds: ' + error );
  165. res.writeHead(302, {Location: '/login?action=failed'});
  166. return res.end();
  167. } );
  168. }, error => {
  169. console.log( '- Dashboard: Error while getting the token: ' + error );
  170. res.writeHead(302, {Location: '/login?action=failed'});
  171. return res.end();
  172. } );
  173. }
  174. /**
  175. * Reload the guild of a user
  176. * @param {import('http').ServerResponse} res - The server response
  177. * @param {import('./util.js').UserSession} userSession - The user session
  178. * @param {String} [returnLocation] - The return location
  179. */
  180. function dashboard_refresh(res, userSession, returnLocation = '/') {
  181. return oauth.getUserGuilds(userSession.access_token).then( guilds => {
  182. guilds = guilds.filter( guild => {
  183. return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
  184. } ).map( guild => {
  185. return {
  186. id: guild.id,
  187. name: guild.name,
  188. acronym: guild.name.replace( /'s /g, ' ' ).replace( /\w+/g, e => e[0] ).replace( /\s/g, '' ),
  189. icon: ( guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.`
  190. + ( guild.icon.startsWith( 'a_' ) ? 'gif' : 'png' ) : null ),
  191. userPermissions: guild.permissions
  192. };
  193. } );
  194. var settings = settingsData.get(userSession.user_id);
  195. sendMsg( {
  196. type: 'getGuilds',
  197. member: settings.user.id,
  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 ) {
  204. if ( guild === 'noMember' ) return;
  205. isMember.set(guilds[i].id, Object.assign(guilds[i], guild));
  206. }
  207. else notMember.set(guilds[i].id, guilds[i]);
  208. } );
  209. settings.guilds = {count: guilds.length, isMember, notMember};
  210. res.writeHead(302, {Location: returnLocation + '?refresh=success'});
  211. return res.end();
  212. }, error => {
  213. console.log( '- Dashboard: Error while getting the refreshed guilds:', error );
  214. res.writeHead(302, {Location: returnLocation + '?refresh=failed'});
  215. return res.end();
  216. } );
  217. }, error => {
  218. console.log( '- Dashboard: Error while refreshing guilds: ' + error );
  219. res.writeHead(302, {Location: returnLocation + '?refresh=failed'});
  220. return res.end();
  221. } );
  222. }
  223. /**
  224. * Check if a wiki is availabe
  225. * @param {import('http').ServerResponse} res - The server response
  226. * @param {String} input - The wiki to check
  227. */
  228. function dashboard_api(res, input) {
  229. var wiki = Wiki.fromInput('https://' + input + '/');
  230. var result = {
  231. api: true,
  232. error: false,
  233. error_code: '',
  234. wiki: wiki.href,
  235. MediaWiki: false,
  236. TextExtracts: false,
  237. PageImages: false,
  238. RcGcDw: '',
  239. customRcGcDw: wiki.toLink('MediaWiki:Custom-RcGcDw', 'action=edit')
  240. };
  241. return got.get( wiki + 'api.php?&action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions&format=json', {
  242. responseType: 'text'
  243. } ).then( response => {
  244. try {
  245. response.body = JSON.parse(response.body);
  246. }
  247. catch (error) {
  248. if ( response.statusCode === 404 && typeof response.body === 'string' ) {
  249. let api = cheerio.load(response.body)('head link[rel="EditURI"]').prop('href');
  250. if ( api ) {
  251. wiki = new Wiki(api.split('api.php?')[0], wiki);
  252. return got.get( wiki + 'api.php?action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions&format=json' );
  253. }
  254. }
  255. }
  256. return response;
  257. } ).then( response => {
  258. var body = response.body;
  259. if ( response.statusCode !== 200 || body?.batchcomplete === undefined || !body?.query?.allmessages || !body?.query?.general || !body?.query?.extensions ) {
  260. console.log( '- Dashboard: ' + response.statusCode + ': Error while checking the wiki: ' + body?.error?.info );
  261. if ( body?.error?.info === 'You need read permission to use this module.' ) {
  262. result.error_code = 'private';
  263. }
  264. result.error = true;
  265. return;
  266. }
  267. wiki.updateWiki(body.query.general);
  268. result.wiki = wiki.href;
  269. if ( body.query.general.generator.replace( /^MediaWiki 1\.(\d\d).*$/, '$1' ) >= 30 ) {
  270. result.MediaWiki = true;
  271. }
  272. if ( body.query.extensions.some( extension => extension.name === 'TextExtracts' ) ) {
  273. result.TextExtracts = true;
  274. }
  275. if ( body.query.extensions.some( extension => extension.name === 'PageImages' ) ) {
  276. result.PageImages = true;
  277. }
  278. if ( body.query.allmessages[0]['*'] ) {
  279. result.RcGcDw = body.query.allmessages[0]['*'];
  280. }
  281. result.customRcGcDw = wiki.toLink('MediaWiki:Custom-RcGcDw', 'action=edit');
  282. if ( wiki.isFandom() ) return;
  283. }, error => {
  284. if ( error.message?.startsWith( 'connect ECONNREFUSED ' ) || error.message?.startsWith( 'Hostname/IP does not match certificate\'s altnames: ' ) || error.message === 'certificate has expired' || error.message === 'self signed certificate' ) {
  285. console.log( '- Dashboard: Error while testing the wiki: No HTTPS' );
  286. result.error_code = 'http';
  287. result.error = true;
  288. return;
  289. }
  290. console.log( '- Dashboard: Error while checking the wiki: ' + error );
  291. if ( error.message === `Timeout awaiting 'request' for ${got.defaults.options.timeout.request}ms` ) {
  292. result.error_code = 'timeout';
  293. }
  294. result.error = true;
  295. } ).finally( () => {
  296. let body = JSON.stringify(result);
  297. res.writeHead(200, {
  298. 'Content-Length': Buffer.byteLength(body),
  299. 'Content-Type': 'application/json'
  300. });
  301. res.write( body );
  302. return res.end();
  303. } );
  304. }
  305. module.exports = {
  306. login: dashboard_login,
  307. oauth: dashboard_oauth,
  308. refresh: dashboard_refresh,
  309. api: dashboard_api
  310. };