oauth.js 10 KB

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