You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

233 lines
6.5 KiB

  1. import dotenv from 'dotenv'
  2. import express from 'express'
  3. import http from 'http'
  4. import redis from 'redis'
  5. import pg from 'pg'
  6. import log from 'npmlog'
  7. import url from 'url'
  8. import WebSocket from 'ws'
  9. const env = process.env.NODE_ENV || 'development'
  10. dotenv.config({
  11. path: env === 'production' ? '.env.production' : '.env'
  12. })
  13. const pgConfigs = {
  14. development: {
  15. database: 'mastodon_development',
  16. host: '/var/run/postgresql',
  17. max: 10
  18. },
  19. production: {
  20. user: process.env.DB_USER || 'mastodon',
  21. password: process.env.DB_PASS || '',
  22. database: process.env.DB_NAME || 'mastodon_production',
  23. host: process.env.DB_HOST || 'localhost',
  24. port: process.env.DB_PORT || 5432,
  25. max: 10
  26. }
  27. }
  28. const app = express()
  29. const pgPool = new pg.Pool(pgConfigs[env])
  30. const server = http.createServer(app)
  31. const wss = new WebSocket.Server({ server })
  32. const allowCrossDomain = (req, res, next) => {
  33. res.header('Access-Control-Allow-Origin', '*')
  34. res.header('Access-Control-Allow-Headers', 'Authorization, Accept, Cache-Control')
  35. res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
  36. next()
  37. }
  38. const accountFromToken = (token, req, next) => {
  39. pgPool.connect((err, client, done) => {
  40. if (err) {
  41. return next(err)
  42. }
  43. client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 LIMIT 1', [token], (err, result) => {
  44. done()
  45. if (err) {
  46. return next(err)
  47. }
  48. if (result.rows.length === 0) {
  49. err = new Error('Invalid access token')
  50. err.statusCode = 401
  51. return next(err)
  52. }
  53. req.accountId = result.rows[0].account_id
  54. next()
  55. })
  56. })
  57. }
  58. const authenticationMiddleware = (req, res, next) => {
  59. if (req.method === 'OPTIONS') {
  60. return next()
  61. }
  62. const authorization = req.get('Authorization')
  63. if (!authorization) {
  64. const err = new Error('Missing access token')
  65. err.statusCode = 401
  66. return next(err)
  67. }
  68. const token = authorization.replace(/^Bearer /, '')
  69. accountFromToken(token, req, next)
  70. }
  71. const errorMiddleware = (err, req, res, next) => {
  72. log.error(err)
  73. res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' })
  74. res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occurred' }))
  75. }
  76. const placeholders = (arr, shift = 0) => arr.map((_, i) => `$${i + 1 + shift}`).join(', ');
  77. const streamFrom = (redisClient, id, req, output, needsFiltering = false) => {
  78. log.verbose(`Starting stream from ${id} for ${req.accountId}`)
  79. redisClient.on('message', (channel, message) => {
  80. const { event, payload } = JSON.parse(message)
  81. // Only messages that may require filtering are statuses, since notifications
  82. // are already personalized and deletes do not matter
  83. if (needsFiltering && event === 'update') {
  84. pgPool.connect((err, client, done) => {
  85. if (err) {
  86. log.error(err)
  87. return
  88. }
  89. const unpackedPayload = JSON.parse(payload)
  90. const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : [])
  91. client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
  92. done()
  93. if (err) {
  94. log.error(err)
  95. return
  96. }
  97. if (result.rows.length > 0) {
  98. return
  99. }
  100. log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
  101. output(event, payload)
  102. })
  103. })
  104. } else {
  105. log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
  106. output(event, payload)
  107. }
  108. })
  109. redisClient.subscribe(id)
  110. }
  111. // Setup stream output to HTTP
  112. const streamToHttp = (req, res, redisClient) => {
  113. res.setHeader('Content-Type', 'text/event-stream')
  114. res.setHeader('Transfer-Encoding', 'chunked')
  115. const heartbeat = setInterval(() => res.write(':thump\n'), 15000)
  116. req.on('close', () => {
  117. log.verbose(`Ending stream for ${req.accountId}`)
  118. clearInterval(heartbeat)
  119. redisClient.quit()
  120. })
  121. return (event, payload) => {
  122. res.write(`event: ${event}\n`)
  123. res.write(`data: ${payload}\n\n`)
  124. }
  125. }
  126. // Setup stream output to WebSockets
  127. const streamToWs = (req, ws, redisClient) => {
  128. ws.on('close', () => {
  129. log.verbose(`Ending stream for ${req.accountId}`)
  130. redisClient.quit()
  131. })
  132. return (event, payload) => {
  133. ws.send(JSON.stringify({ event, payload }))
  134. }
  135. }
  136. // Get new redis connection
  137. const getRedisClient = () => redis.createClient({
  138. host: process.env.REDIS_HOST || '127.0.0.1',
  139. port: process.env.REDIS_PORT || 6379,
  140. password: process.env.REDIS_PASSWORD
  141. })
  142. app.use(allowCrossDomain)
  143. app.use(authenticationMiddleware)
  144. app.use(errorMiddleware)
  145. app.get('/api/v1/streaming/user', (req, res) => {
  146. const redisClient = getRedisClient()
  147. streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToHttp(req, res, redisClient))
  148. })
  149. app.get('/api/v1/streaming/public', (req, res) => {
  150. const redisClient = getRedisClient()
  151. streamFrom(redisClient, 'timeline:public', req, streamToHttp(req, res, redisClient), true)
  152. })
  153. app.get('/api/v1/streaming/hashtag', (req, res) => {
  154. const redisClient = getRedisClient()
  155. streamFrom(redisClient, `timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res, redisClient), true)
  156. })
  157. wss.on('connection', ws => {
  158. const location = url.parse(ws.upgradeReq.url, true)
  159. const token = location.query.access_token
  160. const req = {}
  161. accountFromToken(token, req, err => {
  162. if (err) {
  163. log.error(err)
  164. ws.close()
  165. return
  166. }
  167. const redisClient = getRedisClient()
  168. switch(location.query.stream) {
  169. case 'user':
  170. streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToWs(req, ws, redisClient))
  171. break;
  172. case 'public':
  173. streamFrom(redisClient, 'timeline:public', req, streamToWs(req, ws, redisClient), true)
  174. break;
  175. case 'hashtag':
  176. streamFrom(redisClient, `timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws, redisClient), true)
  177. break;
  178. default:
  179. ws.close()
  180. }
  181. })
  182. })
  183. server.listen(process.env.PORT || 4000, () => {
  184. log.level = process.env.LOG_LEVEL || 'verbose'
  185. log.info(`Starting streaming API server on port ${server.address().port}`)
  186. })