import express from 'express' import RateLimit from 'express-rate-limit' import multiparty from 'multiparty' import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' import { Common, Facebook, Twitter, Discord, Google } from '../api/external' import * as Image from '../api/image' import * as News from '../api/news' import { User, Payment, OAuth2 } from '../api' const router = express.Router() const dev = process.env.NODE_ENV !== 'production' // Restrict API usage const apiLimiter = new RateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 100, delayMs: 0 }) // Restrict image uploads const uploadLimiter = new RateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, delayMs: 0 }) router.use(apiLimiter) // Turn things like 'key1[key2]': 'value' into key1: {key2: 'value'} because facebook function objectAssembler (insane) { const object = {} for (const key in insane) { const value = insane[key] if (key.indexOf('[') !== -1) { const subKey = key.match(/^([\w]+)\[(\w+)\]$/) if (subKey[1] && subKey[2]) { if (!object[subKey[1]]) { object[subKey[1]] = {} } object[subKey[1]][subKey[2]] = value } } else { object[key] = value } } return object } // Create a session and return a redirect uri if provided function createSession (req, user) { req.session.user = { id: user.id, username: user.username, display_name: user.display_name, email: user.email, avatar_file: user.avatar_file, session_refresh: Date.now() + 1800000 // 30 minutes } } // Get either `uuid` or `id` from `:id` parameter function idParam (req) { const id = req.params.id if (id.length === 36 && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) { return id } if (!isNaN(parseInt(id))) { return parseInt(id) } return null } // Either give JSON or make a redirect function JsonData (req, res, error, redirect = '/') { res.jsonp({ error: error, redirect: redirect }) } // Common middleware for all external account unlinks function removeAuthMiddleware (identifier) { return wrap(async (req, res) => { if (!req.session.user) return res.redirect('/login') const done = await Common.remove(req.session.user, identifier) if (!done) { req.flash('message', { error: true, text: 'Unable to unlink social media account' }) } res.redirect('/user/manage') }) } /** FACEBOOK LOGIN * Ajax POST only * No tokens saved in configs, everything works out-of-the-box */ router.post('/external/facebook/callback', wrap(async (req, res, next) => { if (!config.external || !config.external.facebook || !config.external.facebook.client) return next() // Fix up the retarded object Facebook sends us const sane = objectAssembler(req.body) if (!sane || !sane.authResponse) { return next() } const response = await Facebook.callback(req.session.user, sane.authResponse, req.realIP) if (response.banned) { return JsonData(req, res, 'You are banned.') } if (response.error) { return JsonData(req, res, response.error) } // Create session if (!req.session.user) { const user = response.user createSession(req, user) } JsonData(req, res, null, '/login') })) router.get('/external/facebook/remove', removeAuthMiddleware('facebook')) /** TWITTER LOGIN * OAuth1.0a flows * Tokens in configs */ router.get('/external/twitter/login', wrap(async (req, res) => { if (!config.external || !config.external.twitter || !config.external.twitter.api) return res.redirect('/') const tokens = await Twitter.getRequestToken() if (tokens.error) { return res.jsonp({ error: tokens.error }) } req.session.twitter_auth = tokens res.redirect('https://twitter.com/oauth/authenticate?oauth_token=' + tokens.token) })) router.get('/external/twitter/callback', wrap(async (req, res) => { if (!config.external || !config.external.twitter || !config.external.twitter.api) return res.redirect('/login') if (!req.session.twitter_auth) return res.redirect('/login') const ta = req.session.twitter_auth const uri = '/login' if (!req.query.oauth_verifier) { req.flash('message', { error: true, text: 'Couldn\'t get a verifier' }) return res.redirect(uri) } const accessTokens = await Twitter.getAccessTokens(ta.token, ta.token_secret, req.query.oauth_verifier) delete req.session.twitter_auth if (accessTokens.error) { req.flash('message', { error: true, text: 'Couldn\'t get an access token' }) return res.redirect(uri) } const response = await Twitter.callback(req.session.user, accessTokens, req.realIP) if (response.banned) { return res.render('user/banned', { bans: response.banned, ipban: response.ip }) } if (response.error) { req.flash('message', { error: true, text: response.error }) return res.redirect(uri) } if (!req.session.user) { const user = response.user createSession(req, user) } res.render('redirect', { url: uri }) })) router.get('/external/twitter/remove', removeAuthMiddleware('twitter')) /** DISCORD LOGIN * OAuth2 flows * Tokens in configs */ router.get('/external/discord/login', wrap(async (req, res) => { if (!config.external || !config.external.discord || !config.external.discord.api) return res.redirect('/') const infos = Discord.getAuthorizeURL(req) res.redirect(infos.url) })) router.get('/external/discord/callback', wrap(async (req, res) => { if (!config.external || !config.external.discord || !config.external.discord.api) return res.redirect('/login') const code = req.query.code const state = req.query.state const uri = '/login' if (!code) { req.flash('message', { error: true, text: 'No authorization.' }) return res.redirect(uri) } if (!state || state !== Common.stateGenerator(req)) { req.flash('message', { error: true, text: 'Request got intercepted, try again.' }) return res.redirect(uri) } delete req.session.discord_auth const accessToken = await Discord.getAccessToken(code) if (accessToken.error) { req.flash('message', { error: true, text: accessToken.error }) return res.redirect(uri) } const response = await Discord.callback(req.session.user, accessToken.accessToken, req.realIP) if (response.banned) { return res.render('user/banned', { bans: response.banned, ipban: response.ip }) } if (response.error) { req.flash('message', { error: true, text: response.error }) return res.redirect(uri) } if (!req.session.user) { const user = response.user createSession(req, user) } res.render('redirect', { url: uri }) })) router.get('/external/discord/remove', removeAuthMiddleware('discord')) /** GOOGLE LOGIN * Google Token Verification * Tokens in configs */ router.get('/external/google/login', wrap(async (req, res) => { if (!config.external || !config.external.google || !config.external.google.api) return res.redirect('/') res.redirect('/login') })) router.post('/external/google/callback', wrap(async (req, res) => { if (!config.external || !config.external.google || !config.external.google.api) return res.redirect('/login') if (!req.body.id_token) { return JsonData(req, res, 'Invalid or missing ID token!', '/login') } const response = await Google.callback(req.session.user, req.body, req.realIP) if (response.banned) { return JsonData(req, res, 'You are banned.', '/login') } if (response.error) { return JsonData(req, res, response.error, '/login') } if (!req.session.user) { const user = response.user createSession(req, user) } JsonData(req, res, null, '/login') })) router.get('/external/google/remove', removeAuthMiddleware('google')) /* ======== * NEWS * ======== */ // Cache news for one day router.get('/news', (req, res, next) => { if (!dev) res.header('Cache-Control', 'max-age=' + 24 * 60 * 60 * 1000) // 1 day next() }) // Get a page of articles router.get('/news/all/:page', wrap(async (req, res) => { if (!req.params.page || isNaN(parseInt(req.params.page))) { return res.status(400).jsonp({ error: 'Invalid page number.' }) } const page = parseInt(req.params.page) const articles = await News.listNews(page) res.jsonp(articles) })) // Redirect to page one router.get('/news/all/', (req, res) => { res.redirect('/api/news/all/1') }) router.post('/news/edit/:id', wrap(async (req, res, next) => { const id = parseInt(req.params.id) if (!req.session.user || req.session.user.privilege < 1) return next() if (!id || isNaN(id)) { return res.status(400).jsonp({ error: 'Invalid ID number.' }) } if (!req.body.content) { return res.status(400).jsonp({ error: 'Content is required.' }) } try { await News.edit(id, req.body) } catch (e) { return res.status(400).jsonp({ error: e.message }) } res.status(204).end() })) // Fetch article router.get('/news/:id', wrap(async (req, res) => { if (!req.params.id || isNaN(parseInt(req.params.id))) { return res.status(400).jsonp({ error: 'Invalid ID number.' }) } const id = parseInt(req.params.id) const article = await News.article(id) res.jsonp(article) })) // Preview endpoint router.get('/news', wrap(async (req, res) => { const articles = await News.preview() res.jsonp(articles) })) /* ========== * AVATAR * ========== */ // Promisify multiparty form parser async function promiseForm (req) { const form = new multiparty.Form() return new Promise(function (resolve, reject) { form.parse(req, async (err, fields, files) => { if (err) return reject(err) resolve({ fields: fields, files: files }) }) }) } // Upload avatar image router.post('/avatar', uploadLimiter, wrap(async (req, res, next) => { if (!req.session.user) return next() const data = await promiseForm(req) let avatarFile try { const result = await Image.uploadImage(req.session.user.username, data.fields, data.files) avatarFile = await User.changeAvatar(req.session.user, result.file) } catch (e) { return res.status(400).jsonp({ error: e.message }) } if (avatarFile) { req.session.user.avatar_file = avatarFile } req.flash('message', { error: false, text: 'Success!' }) res.status(200).jsonp({}) })) // Remove avatar image router.post('/avatar/remove', wrap(async (req, res, next) => { if (!req.session.user) return next() await User.removeAvatar(req.session.user) req.session.user.avatar_file = null res.status(200).jsonp({ done: true }) })) // Get latest avatar of logged in user router.get('/avatar', wrap(async (req, res, next) => { if (!req.session.user) return next() const user = req.session.user if (!user.avatar_file) return next() res.header('Cache-Control', 'max-age=' + 7 * 24 * 60 * 60 * 1000) // 1 week res.redirect('/usercontent/images/' + user.avatar_file) })) // Get latest avatar of user by id router.get('/avatar/:id', wrap(async (req, res, next) => { const id = idParam(req) if (!id) return next() const user = await User.get(id) if (!user || !user.avatar_file) return next() res.header('Cache-Control', 'max-age=' + 7 * 24 * 60 * 60 * 1000) // 1 week res.redirect('/usercontent/images/' + user.avatar_file) })) router.get('/avatar/gravatar', (req, res, next) => { if (!req.session.user) return next() const email = req.session.user.email res.set('Content-Type', 'text/plain') res.end(Image.gravatarURL(email)) }) router.post('/avatar/gravatar', wrap(async (req, res, next) => { if (!req.session.user) return next() const user = req.session.user try { const gravURL = await Image.downloadImage(Image.gravatarURL(user.email), 'GRAV-' + user.username) const file = await User.changeAvatar(user, gravURL) req.session.user.avatar_file = file req.flash('message', { error: false, text: 'Success!' }) } catch (e) { console.error(e) req.flash('message', { error: true, text: 'Failed to use gravatar avatar.' }) } res.jsonp({}) })) // Redirect to no avatar on 404 router.use('/avatar', (req, res) => { res.redirect('/static/image/avatar.png') }) /* ===================== * OAuth2 Management * ===================== */ // List authorizations router.get('/oauth2/authorized-clients', wrap(async (req, res, next) => { if (!req.session.user) return next() const list = await OAuth2.getUserAuthorizations(req.session.user) if (!list) return next() res.jsonp(list) })) // Revoke an authorization router.post('/oauth2/authorized-clients/revoke', wrap(async (req, res, next) => { if (!req.session.user) return next() const clientId = parseInt(req.body.client_id) if (isNaN(clientId)) return res.status(400).jsonp({ error: 'Missing Client ID parameter' }) const done = await OAuth2.removeUserAuthorization(req.session.user, clientId) if (!done) return res.status(400).jsonp({ error: 'Failed to remove client authorization' }) res.status(204).end() })) /* ================== * Donation Store * ================== */ router.post('/paypal/ipn', wrap(async (req, res) => { const content = req.body if (content && content.payment_status && content.payment_status === 'Completed') { await Payment.handleIPN(content) } res.status(204).end() })) router.get('/donations/user', wrap(async (req, res, next) => { if (!req.session.user) return next() const contribs = await Payment.userContributions(req.session.user) res.jsonp(contribs) })) router.get('/donations', wrap(async (req, res) => { let count = parseInt(req.query.count) if (isNaN(count)) { count = 10 } if (count > 10) { count = 10 } const mcu = req.query.mcu === '1' || req.query.mcu === 'true' let timeFrame = parseInt(req.query.timeFrame) if (isNaN(timeFrame)) timeFrame = 0 const contribs = await Payment.allContributions(count, mcu, timeFrame) res.jsonp(contribs) })) // 404 router.use((req, res) => { res.status(404).jsonp({ error: 'Not found' }) }) router.use((err, req, res) => { console.error(err) res.jsonp({ error: 'Internal server error.' }) }) export default router