oauth.js 9.5 KB

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