oauth.js 16 KB

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