The code powering m.abunchtell.com https://m.abunchtell.com
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

309 líneas
8.4 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. import uuid from 'uuid'
  10. const env = process.env.NODE_ENV || 'development'
  11. dotenv.config({
  12. path: env === 'production' ? '.env.production' : '.env'
  13. })
  14. const pgConfigs = {
  15. development: {
  16. database: 'mastodon_development',
  17. host: '/var/run/postgresql',
  18. max: 10
  19. },
  20. production: {
  21. user: process.env.DB_USER || 'mastodon',
  22. password: process.env.DB_PASS || '',
  23. database: process.env.DB_NAME || 'mastodon_production',
  24. host: process.env.DB_HOST || 'localhost',
  25. port: process.env.DB_PORT || 5432,
  26. max: 10
  27. }
  28. }
  29. const app = express()
  30. const pgPool = new pg.Pool(pgConfigs[env])
  31. const server = http.createServer(app)
  32. const wss = new WebSocket.Server({ server })
  33. const redisClient = redis.createClient({
  34. host: process.env.REDIS_HOST || '127.0.0.1',
  35. port: process.env.REDIS_PORT || 6379,
  36. password: process.env.REDIS_PASSWORD
  37. })
  38. const subs = {}
  39. redisClient.on('pmessage', (_, channel, message) => {
  40. const callbacks = subs[channel]
  41. log.silly(`New message on channel ${channel}`)
  42. if (!callbacks) {
  43. return
  44. }
  45. callbacks.forEach(callback => callback(message))
  46. })
  47. redisClient.psubscribe('timeline:*')
  48. const subscribe = (channel, callback) => {
  49. log.silly(`Adding listener for ${channel}`)
  50. subs[channel] = subs[channel] || []
  51. subs[channel].push(callback)
  52. }
  53. const unsubscribe = (channel, callback) => {
  54. log.silly(`Removing listener for ${channel}`)
  55. subs[channel] = subs[channel].filter(item => item !== callback)
  56. }
  57. const allowCrossDomain = (req, res, next) => {
  58. res.header('Access-Control-Allow-Origin', '*')
  59. res.header('Access-Control-Allow-Headers', 'Authorization, Accept, Cache-Control')
  60. res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
  61. next()
  62. }
  63. const setRequestId = (req, res, next) => {
  64. req.requestId = uuid.v4()
  65. res.header('X-Request-Id', req.requestId)
  66. next()
  67. }
  68. const accountFromToken = (token, req, next) => {
  69. pgPool.connect((err, client, done) => {
  70. if (err) {
  71. return next(err)
  72. }
  73. 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) => {
  74. done()
  75. if (err) {
  76. return next(err)
  77. }
  78. if (result.rows.length === 0) {
  79. err = new Error('Invalid access token')
  80. err.statusCode = 401
  81. return next(err)
  82. }
  83. req.accountId = result.rows[0].account_id
  84. next()
  85. })
  86. })
  87. }
  88. const authenticationMiddleware = (req, res, next) => {
  89. if (req.method === 'OPTIONS') {
  90. return next()
  91. }
  92. const authorization = req.get('Authorization')
  93. if (!authorization) {
  94. const err = new Error('Missing access token')
  95. err.statusCode = 401
  96. return next(err)
  97. }
  98. const token = authorization.replace(/^Bearer /, '')
  99. accountFromToken(token, req, next)
  100. }
  101. const errorMiddleware = (err, req, res, next) => {
  102. log.error(req.requestId, err)
  103. res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' })
  104. res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occurred' }))
  105. }
  106. const placeholders = (arr, shift = 0) => arr.map((_, i) => `$${i + 1 + shift}`).join(', ');
  107. const streamFrom = (id, req, output, attachCloseHandler, needsFiltering = false) => {
  108. log.verbose(req.requestId, `Starting stream from ${id} for ${req.accountId}`)
  109. const listener = message => {
  110. const { event, payload, queued_at } = JSON.parse(message)
  111. const transmit = () => {
  112. const now = new Date().getTime()
  113. const delta = now - queued_at;
  114. log.silly(req.requestId, `Transmitting for ${req.accountId}: ${event} ${payload} Delay: ${delta}ms`)
  115. output(event, payload)
  116. }
  117. // Only messages that may require filtering are statuses, since notifications
  118. // are already personalized and deletes do not matter
  119. if (needsFiltering && event === 'update') {
  120. pgPool.connect((err, client, done) => {
  121. if (err) {
  122. log.error(err)
  123. return
  124. }
  125. const unpackedPayload = JSON.parse(payload)
  126. const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : [])
  127. client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT target_account_id FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
  128. done()
  129. if (err) {
  130. log.error(err)
  131. return
  132. }
  133. if (result.rows.length > 0) {
  134. return
  135. }
  136. transmit()
  137. })
  138. })
  139. } else {
  140. transmit()
  141. }
  142. }
  143. subscribe(id, listener)
  144. attachCloseHandler(id, listener)
  145. }
  146. // Setup stream output to HTTP
  147. const streamToHttp = (req, res) => {
  148. res.setHeader('Content-Type', 'text/event-stream')
  149. res.setHeader('Transfer-Encoding', 'chunked')
  150. const heartbeat = setInterval(() => res.write(':thump\n'), 15000)
  151. req.on('close', () => {
  152. log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
  153. clearInterval(heartbeat)
  154. })
  155. return (event, payload) => {
  156. res.write(`event: ${event}\n`)
  157. res.write(`data: ${payload}\n\n`)
  158. }
  159. }
  160. // Setup stream end for HTTP
  161. const streamHttpEnd = req => (id, listener) => {
  162. req.on('close', () => {
  163. unsubscribe(id, listener)
  164. })
  165. }
  166. // Setup stream output to WebSockets
  167. const streamToWs = (req, ws) => {
  168. const heartbeat = setInterval(() => ws.ping(), 15000)
  169. ws.on('close', () => {
  170. log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
  171. clearInterval(heartbeat)
  172. })
  173. return (event, payload) => {
  174. if (ws.readyState !== ws.OPEN) {
  175. log.error(req.requestId, 'Tried writing to closed socket')
  176. return
  177. }
  178. ws.send(JSON.stringify({ event, payload }))
  179. }
  180. }
  181. // Setup stream end for WebSockets
  182. const streamWsEnd = ws => (id, listener) => {
  183. ws.on('close', () => {
  184. unsubscribe(id, listener)
  185. })
  186. ws.on('error', e => {
  187. unsubscribe(id, listener)
  188. })
  189. }
  190. app.use(setRequestId)
  191. app.use(allowCrossDomain)
  192. app.use(authenticationMiddleware)
  193. app.use(errorMiddleware)
  194. app.get('/api/v1/streaming/user', (req, res) => {
  195. streamFrom(`timeline:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req))
  196. })
  197. app.get('/api/v1/streaming/public', (req, res) => {
  198. streamFrom('timeline:public', req, streamToHttp(req, res), streamHttpEnd(req), true)
  199. })
  200. app.get('/api/v1/streaming/public/local', (req, res) => {
  201. streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true)
  202. })
  203. app.get('/api/v1/streaming/hashtag', (req, res) => {
  204. streamFrom(`timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res), streamHttpEnd(req), true)
  205. })
  206. app.get('/api/v1/streaming/hashtag/local', (req, res) => {
  207. streamFrom(`timeline:hashtag:${req.params.tag}:local`, req, streamToHttp(req, res), streamHttpEnd(req), true)
  208. })
  209. wss.on('connection', ws => {
  210. const location = url.parse(ws.upgradeReq.url, true)
  211. const token = location.query.access_token
  212. const req = { requestId: uuid.v4() }
  213. accountFromToken(token, req, err => {
  214. if (err) {
  215. log.error(req.requestId, err)
  216. ws.close()
  217. return
  218. }
  219. switch(location.query.stream) {
  220. case 'user':
  221. streamFrom(`timeline:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(ws))
  222. break;
  223. case 'public':
  224. streamFrom('timeline:public', req, streamToWs(req, ws), streamWsEnd(ws), true)
  225. break;
  226. case 'public:local':
  227. streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(ws), true)
  228. break;
  229. case 'hashtag':
  230. streamFrom(`timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws), streamWsEnd(ws), true)
  231. break;
  232. case 'hashtag:local':
  233. streamFrom(`timeline:hashtag:${location.query.tag}:local`, req, streamToWs(req, ws), streamWsEnd(ws), true)
  234. break;
  235. default:
  236. ws.close()
  237. }
  238. })
  239. })
  240. server.listen(process.env.PORT || 4000, () => {
  241. log.level = process.env.LOG_LEVEL || 'verbose'
  242. log.info(`Starting streaming API server on port ${server.address().port}`)
  243. })