oauth.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. const {randomBytes} = 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, oauth, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
  7. const file = require('fs').readFileSync('./dashboard/login.html');
  8. /**
  9. * Let a user login
  10. * @param {import('http').ServerResponse} res - The server response
  11. * @param {import('./i18n.js')} dashboardLang - The user language.
  12. * @param {String} theme - The display theme
  13. * @param {String} [state] - The user state
  14. * @param {String} [action] - The action the user made
  15. */
  16. function dashboard_login(res, dashboardLang, theme, state, action) {
  17. if ( state && sessionData.has(state) ) {
  18. if ( !action ) {
  19. res.writeHead(302, {Location: '/'});
  20. return res.end();
  21. }
  22. sessionData.delete(state);
  23. }
  24. var $ = cheerio.load(file);
  25. $('html').attr('lang', dashboardLang.lang);
  26. if ( theme === 'light' ) $('html').addClass('theme-light');
  27. $('<script>').text(`
  28. const selectLanguage = '${dashboardLang.get('general.language').replace( /'/g, '\\$&' )}';
  29. const allLangs = ${JSON.stringify(allLangs)};
  30. `).insertBefore('script#langjs');
  31. $('head title').text(dashboardLang.get('general.login') + ' – ' + dashboardLang.get('general.title'));
  32. $('#login-button span, .channel#login div').text(dashboardLang.get('general.login'));
  33. $('.channel#login').attr('title', dashboardLang.get('general.login'));
  34. $('.channel#invite-wikibot div').text(dashboardLang.get('general.invite'));
  35. $('.channel#invite-wikibot').attr('title', dashboardLang.get('general.invite'));
  36. $('.guild#invite a').attr('alt', dashboardLang.get('general.invite'));
  37. $('.guild#theme-dark a').attr('alt', dashboardLang.get('general.theme-dark'));
  38. $('.guild#theme-light a').attr('alt', dashboardLang.get('general.theme-light'));
  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 = Date.now().toString(16) + randomBytes(16).toString('hex');
  51. while ( sessionData.has(state) ) {
  52. state = Date.now().toString(16) + randomBytes(16).toString('hex');
  53. }
  54. let invite = oauth.generateAuthUrl( {
  55. scope: ['identify', 'guilds', 'bot', 'applications.commands'],
  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. addWidgets($, dashboardLang);
  65. let body = $.html();
  66. res.writeHead(responseCode, {
  67. 'Set-Cookie': [
  68. ...( res.getHeader('Set-Cookie') || [] ),
  69. `wikibot="${state}"; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`
  70. ],
  71. 'Content-Length': Buffer.byteLength(body)
  72. });
  73. res.write( body );
  74. return res.end();
  75. }
  76. /**
  77. * Load oauth data of a user
  78. * @param {import('http').ServerResponse} res - The server response
  79. * @param {String} state - The user state
  80. * @param {URLSearchParams} searchParams - The url parameters
  81. * @param {String} [lastGuild] - The guild to return to
  82. */
  83. function dashboard_oauth(res, state, searchParams, lastGuild) {
  84. if ( searchParams.get('error') === 'access_denied' && state === searchParams.get('state') && sessionData.has(state) ) {
  85. res.writeHead(302, {Location: '/'});
  86. return res.end();
  87. }
  88. if ( state !== searchParams.get('state') || !searchParams.get('code') ) {
  89. res.writeHead(302, {Location: '/login?action=failed'});
  90. return res.end();
  91. }
  92. sessionData.delete(state);
  93. return oauth.tokenRequest( {
  94. scope: ['identify', 'guilds'],
  95. code: searchParams.get('code'),
  96. grantType: 'authorization_code'
  97. } ).then( ({access_token}) => {
  98. return Promise.all([
  99. oauth.getUser(access_token),
  100. oauth.getUserGuilds(access_token)
  101. ]).then( ([user, guilds]) => {
  102. guilds = guilds.filter( guild => {
  103. return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
  104. } ).map( guild => {
  105. return {
  106. id: guild.id,
  107. name: guild.name,
  108. acronym: guild.name.replace( /'s /g, ' ' ).replace( /\w+/g, e => e[0] ).replace( /\s/g, '' ),
  109. icon: ( guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.`
  110. + ( guild.icon.startsWith( 'a_' ) ? 'gif' : 'png' ) : null ),
  111. userPermissions: guild.permissions
  112. };
  113. } );
  114. sendMsg( {
  115. type: 'getGuilds',
  116. member: user.id,
  117. guilds: guilds.map( guild => guild.id )
  118. } ).then( response => {
  119. var userSession = {
  120. state: `${state}-${user.id}`,
  121. access_token,
  122. user_id: user.id
  123. };
  124. sessionData.set(userSession.state, userSession);
  125. /** @type {import('./util.js').Settings} */
  126. var settings = ( settingsData.has(user.id) ? settingsData.get(user.id) : {
  127. user: {},
  128. guilds: {}
  129. } );
  130. settings.user.id = user.id;
  131. settings.user.username = user.username;
  132. settings.user.discriminator = user.discriminator;
  133. 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';
  134. settings.user.locale = user.locale;
  135. settings.guilds.count = guilds.length;
  136. /** @type {import('./util.js').Guild[]} */
  137. var isMemberGuilds = [];
  138. settings.guilds.notMember = new Map();
  139. response.forEach( (guild, i) => {
  140. if ( guild ) {
  141. if ( guild === 'noMember' ) return;
  142. isMemberGuilds.push(Object.assign(guilds[i], guild));
  143. }
  144. else settings.guilds.notMember.set(guilds[i].id, guilds[i]);
  145. } );
  146. settings.guilds.isMember = new Map(isMemberGuilds.sort( (a, b) => {
  147. return ( b.patreon - a.patreon || b.memberCount - a.memberCount );
  148. } ).map( guild => {
  149. return [guild.id, guild];
  150. } ));
  151. settingsData.set(user.id, settings);
  152. if ( searchParams.has('guild_id') && !lastGuild.startsWith( searchParams.get('guild_id') + '/' ) ) {
  153. lastGuild = searchParams.get('guild_id') + '/settings';
  154. }
  155. res.writeHead(302, {
  156. Location: ( lastGuild && /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(lastGuild) ? `/guild/${lastGuild}` : '/' ),
  157. 'Set-Cookie': [`wikibot="${userSession.state}"; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`]
  158. });
  159. return res.end();
  160. }, error => {
  161. console.log( '- Dashboard: Error while getting the guilds:', error );
  162. res.writeHead(302, {Location: '/login?action=failed'});
  163. return res.end();
  164. } );
  165. }, error => {
  166. console.log( '- Dashboard: Error while getting user and guilds: ' + error );
  167. res.writeHead(302, {Location: '/login?action=failed'});
  168. return res.end();
  169. } );
  170. }, error => {
  171. console.log( '- Dashboard: Error while getting the token: ' + error );
  172. res.writeHead(302, {Location: '/login?action=failed'});
  173. return res.end();
  174. } );
  175. }
  176. /**
  177. * Reload the guild of a user
  178. * @param {import('http').ServerResponse} res - The server response
  179. * @param {import('./util.js').UserSession} userSession - The user session
  180. * @param {String} [returnLocation] - The return location
  181. */
  182. function dashboard_refresh(res, userSession, returnLocation = '/') {
  183. return oauth.getUserGuilds(userSession.access_token).then( 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' ) : null ),
  193. userPermissions: guild.permissions
  194. };
  195. } );
  196. var settings = settingsData.get(userSession.user_id);
  197. sendMsg( {
  198. type: 'getGuilds',
  199. member: settings.user.id,
  200. guilds: guilds.map( guild => guild.id )
  201. } ).then( response => {
  202. settings.guilds.count = guilds.length;
  203. /** @type {import('./util.js').Guild[]} */
  204. var isMemberGuilds = [];
  205. settings.guilds.notMember = new Map();
  206. response.forEach( (guild, i) => {
  207. if ( guild ) {
  208. if ( guild === 'noMember' ) return;
  209. isMemberGuilds.push(Object.assign(guilds[i], guild));
  210. }
  211. else settings.guilds.notMember.set(guilds[i].id, guilds[i]);
  212. } );
  213. settings.guilds.isMember = new Map(isMemberGuilds.sort( (a, b) => {
  214. return ( b.patreon - a.patreon || b.memberCount - a.memberCount );
  215. } ).map( guild => {
  216. return [guild.id, guild];
  217. } ));
  218. res.writeHead(302, {Location: returnLocation + '?refresh=success'});
  219. return res.end();
  220. }, error => {
  221. console.log( '- Dashboard: Error while getting the refreshed guilds:', error );
  222. res.writeHead(302, {Location: returnLocation + '?refresh=failed'});
  223. return res.end();
  224. } );
  225. }, error => {
  226. console.log( '- Dashboard: Error while refreshing guilds: ' + error );
  227. res.writeHead(302, {Location: returnLocation + '?refresh=failed'});
  228. return res.end();
  229. } );
  230. }
  231. /**
  232. * Check if a wiki is availabe
  233. * @param {import('http').ServerResponse} res - The server response
  234. * @param {String} input - The wiki to check
  235. */
  236. function dashboard_api(res, input) {
  237. var wiki = Wiki.fromInput('https://' + input + '/');
  238. var result = {
  239. api: true,
  240. error: false,
  241. error_code: '',
  242. wiki: wiki.href,
  243. base: '',
  244. sitename: '',
  245. logo: '',
  246. MediaWiki: false,
  247. RcGcDw: '',
  248. customRcGcDw: wiki.toLink('MediaWiki:Custom-RcGcDw', 'action=edit')
  249. };
  250. return got.get( wiki + 'api.php?&action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general&format=json', {
  251. responseType: 'text'
  252. } ).then( response => {
  253. try {
  254. response.body = JSON.parse(response.body);
  255. }
  256. catch (error) {
  257. if ( response.statusCode === 404 && typeof response.body === 'string' ) {
  258. let api = cheerio.load(response.body)('head link[rel="EditURI"]').prop('href');
  259. if ( api ) {
  260. wiki = new Wiki(api.split('api.php?')[0], wiki);
  261. return got.get( wiki + 'api.php?action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general&format=json' );
  262. }
  263. }
  264. }
  265. return response;
  266. } ).then( response => {
  267. var body = response.body;
  268. if ( response.statusCode !== 200 || body?.batchcomplete === undefined || !body?.query?.allmessages || !body?.query?.general ) {
  269. console.log( '- Dashboard: ' + response.statusCode + ': Error while checking the wiki: ' + body?.error?.info );
  270. if ( body?.error?.info === 'You need read permission to use this module.' ) {
  271. result.error_code = 'private';
  272. }
  273. result.error = true;
  274. return;
  275. }
  276. wiki.updateWiki(body.query.general);
  277. result.wiki = wiki.href;
  278. result.base = body.query.general.base;
  279. result.sitename = body.query.general.sitename;
  280. result.logo = body.query.general.logo;
  281. if ( body.query.general.generator.replace( /^MediaWiki 1\.(\d\d).*$/, '$1' ) >= 30 ) {
  282. result.MediaWiki = true;
  283. }
  284. if ( body.query.allmessages[0]['*'] ) {
  285. result.RcGcDw = body.query.allmessages[0]['*'];
  286. }
  287. result.customRcGcDw = wiki.toLink('MediaWiki:Custom-RcGcDw', 'action=edit');
  288. if ( wiki.isFandom() ) return;
  289. }, error => {
  290. 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' ) {
  291. console.log( '- Dashboard: Error while testing the wiki: No HTTPS' );
  292. result.error_code = 'http';
  293. result.error = true;
  294. return;
  295. }
  296. console.log( '- Dashboard: Error while checking the wiki: ' + error );
  297. if ( error.message === `Timeout awaiting 'request' for ${got.defaults.options.timeout.request}ms` ) {
  298. result.error_code = 'timeout';
  299. }
  300. result.error = true;
  301. } ).finally( () => {
  302. let body = JSON.stringify(result);
  303. res.writeHead(200, {
  304. 'Content-Length': Buffer.byteLength(body),
  305. 'Content-Type': 'application/json'
  306. });
  307. res.write( body );
  308. return res.end();
  309. } );
  310. }
  311. /**
  312. * Load oauth data of a wiki user
  313. * @param {import('http').ServerResponse} res - The server response
  314. * @param {URLSearchParams} searchParams - The url parameters
  315. */
  316. function mediawiki_oauth(res, searchParams) {
  317. if ( !searchParams.get('code') || !oauthVerify.has(searchParams.get('state')) ) {
  318. res.writeHead(302, {Location: '/login?action=failed'});
  319. return res.end();
  320. }
  321. var state = searchParams.get('state');
  322. var site = state.split(' ');
  323. got.post( 'https://' + site[0] + '/rest.php/oauth2/access_token', {
  324. form: {
  325. grant_type: 'authorization_code',
  326. code: searchParams.get('code'),
  327. redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
  328. client_id: process.env['oauth_' + ( site[2] || site[0] )],
  329. client_secret: process.env['oauth_' + ( site[2] || site[0] ) + '_secret']
  330. }
  331. } ).then( response => {
  332. var body = response.body;
  333. if ( response.statusCode !== 200 || !body?.access_token ) {
  334. console.log( '- Dashboard: ' + response.statusCode + ': Error while getting the mediawiki token: ' + ( body?.message || body?.error ) );
  335. res.writeHead(302, {Location: '/login?action=failed'});
  336. return res.end();
  337. }
  338. sendMsg( {
  339. type: 'verifyUser', state,
  340. access_token: body.access_token
  341. } ).then( () => {
  342. oauthVerify.delete(state);
  343. res.writeHead(302, {Location: 'https://' + site[1] + '/wiki/Special:MyPage'});
  344. return res.end();
  345. }, error => {
  346. console.log( '- Dashboard: Error while sending the mediawiki token: ' + error );
  347. res.writeHead(302, {Location: '/login?action=failed'});
  348. return res.end();
  349. } );
  350. }, error => {
  351. console.log( '- Dashboard: Error while getting the mediawiki token: ' + error );
  352. res.writeHead(302, {Location: '/login?action=failed'});
  353. return res.end();
  354. } );
  355. }
  356. module.exports = {
  357. login: dashboard_login,
  358. oauth: dashboard_oauth,
  359. refresh: dashboard_refresh,
  360. api: dashboard_api,
  361. verify: mediawiki_oauth
  362. };