2019-07-02 08:10:25 +01:00
// Gab Social Stream API
// Copyright (C) 2019 Gab AI, Inc.
// License: AGPL-3.0
'use strict' ;
const os = require ( 'os' ) ;
const throng = require ( 'throng' ) ;
const dotenv = require ( 'dotenv' ) ;
const express = require ( 'express' ) ;
const http = require ( 'http' ) ;
const redis = require ( 'redis' ) ;
const pg = require ( 'pg' ) ;
const log = require ( 'npmlog' ) ;
const url = require ( 'url' ) ;
const { WebSocketServer } = require ( '@clusterws/cws' ) ;
const uuid = require ( 'uuid' ) ;
const fs = require ( 'fs' ) ;
const env = process . env . NODE _ENV || 'development' ;
dotenv . config ( {
path : env === 'production' ? '.env.production' : '.env' ,
} ) ;
log . level = process . env . LOG _LEVEL || 'verbose' ;
const dbUrlToConfig = ( dbUrl ) => {
if ( ! dbUrl ) {
return { } ;
}
const params = url . parse ( dbUrl , true ) ;
const config = { } ;
if ( params . auth ) {
[ config . user , config . password ] = params . auth . split ( ':' ) ;
}
if ( params . hostname ) {
config . host = params . hostname ;
}
if ( params . port ) {
config . port = params . port ;
}
if ( params . pathname ) {
config . database = params . pathname . split ( '/' ) [ 1 ] ;
}
const ssl = params . query && params . query . ssl ;
if ( ssl && ssl === 'true' || ssl === '1' ) {
config . ssl = true ;
}
return config ;
} ;
const redisUrlToClient = ( defaultConfig , redisUrl ) => {
const config = defaultConfig ;
if ( ! redisUrl ) {
return redis . createClient ( config ) ;
}
if ( redisUrl . startsWith ( 'unix://' ) ) {
return redis . createClient ( redisUrl . slice ( 7 ) , config ) ;
}
return redis . createClient ( Object . assign ( config , {
url : redisUrl ,
} ) ) ;
} ;
const numWorkers = + process . env . STREAMING _CLUSTER _NUM || ( env === 'development' ? 1 : Math . max ( os . cpus ( ) . length - 1 , 1 ) ) ;
const startMaster = ( ) => {
if ( ! process . env . SOCKET && process . env . PORT && isNaN ( + process . env . PORT ) ) {
log . warn ( 'UNIX domain socket is now supported by using SOCKET. Please migrate from PORT hack.' ) ;
}
log . info ( ` Starting streaming API server master with ${ numWorkers } workers ` ) ;
} ;
const startWorker = ( workerId ) => {
log . info ( ` Starting worker ${ workerId } ` ) ;
const pgConfigs = {
development : {
2020-03-04 22:26:01 +00:00
user : process . env . DB _USER || pg . defaults . user ,
2019-07-02 08:10:25 +01:00
password : process . env . DB _PASS || pg . defaults . password ,
database : process . env . DB _NAME || 'gabsocial_development' ,
2020-03-04 22:26:01 +00:00
host : process . env . DB _HOST || pg . defaults . host ,
port : process . env . DB _PORT || pg . defaults . port ,
max : 10 ,
2019-07-02 08:10:25 +01:00
} ,
production : {
2020-03-04 22:26:01 +00:00
user : process . env . DB _USER || 'gabsocial' ,
2019-07-02 08:10:25 +01:00
password : process . env . DB _PASS || '' ,
database : process . env . DB _NAME || 'gabsocial_production' ,
2020-03-04 22:26:01 +00:00
host : process . env . DB _HOST || 'localhost' ,
port : process . env . DB _PORT || 5432 ,
max : 10 ,
2019-07-02 08:10:25 +01:00
} ,
} ;
if ( ! ! process . env . DB _SSLMODE && process . env . DB _SSLMODE !== 'disable' ) {
pgConfigs . development . ssl = true ;
2020-03-04 22:26:01 +00:00
pgConfigs . production . ssl = true ;
2019-07-02 08:10:25 +01:00
}
const app = express ( ) ;
app . set ( 'trusted proxy' , process . env . TRUSTED _PROXY _IP || 'loopback,uniquelocal' ) ;
const pgPool = new pg . Pool ( Object . assign ( pgConfigs [ env ] , dbUrlToConfig ( process . env . DATABASE _URL ) ) ) ;
const server = http . createServer ( app ) ;
const redisNamespace = process . env . REDIS _NAMESPACE || null ;
const redisParams = {
2020-03-04 22:26:01 +00:00
host : process . env . REDIS _HOST || '127.0.0.1' ,
port : process . env . REDIS _PORT || 6379 ,
db : process . env . REDIS _DB || 0 ,
2019-07-02 08:10:25 +01:00
password : process . env . REDIS _PASSWORD ,
} ;
if ( redisNamespace ) {
redisParams . namespace = redisNamespace ;
}
const redisPrefix = redisNamespace ? ` ${ redisNamespace } : ` : '' ;
const redisSubscribeClient = redisUrlToClient ( redisParams , process . env . REDIS _URL ) ;
const redisClient = redisUrlToClient ( redisParams , process . env . REDIS _URL ) ;
const subs = { } ;
redisSubscribeClient . on ( 'message' , ( channel , message ) => {
const callbacks = subs [ channel ] ;
log . silly ( ` New message on channel ${ channel } ` ) ;
if ( ! callbacks ) {
return ;
}
callbacks . forEach ( callback => callback ( message ) ) ;
} ) ;
const subscriptionHeartbeat = ( channel ) => {
2020-03-04 22:26:01 +00:00
const interval = 6 * 60 ;
2019-07-02 08:10:25 +01:00
const tellSubscribed = ( ) => {
2020-03-04 22:26:01 +00:00
redisClient . set ( ` ${ redisPrefix } subscribed: ${ channel } ` , '1' , 'EX' , interval * 3 ) ;
2019-07-02 08:10:25 +01:00
} ;
tellSubscribed ( ) ;
2020-03-04 22:26:01 +00:00
const heartbeat = setInterval ( tellSubscribed , interval * 1000 ) ;
2019-07-02 08:10:25 +01:00
return ( ) => {
clearInterval ( heartbeat ) ;
} ;
} ;
const subscribe = ( channel , callback ) => {
log . silly ( ` Adding listener for ${ channel } ` ) ;
subs [ channel ] = subs [ channel ] || [ ] ;
if ( subs [ channel ] . length === 0 ) {
log . verbose ( ` Subscribe ${ channel } ` ) ;
redisSubscribeClient . subscribe ( channel ) ;
}
subs [ channel ] . push ( callback ) ;
} ;
const unsubscribe = ( channel , callback ) => {
log . silly ( ` Removing listener for ${ channel } ` ) ;
subs [ channel ] = subs [ channel ] . filter ( item => item !== callback ) ;
if ( subs [ channel ] . length === 0 ) {
log . verbose ( ` Unsubscribe ${ channel } ` ) ;
redisSubscribeClient . unsubscribe ( channel ) ;
}
} ;
const allowCrossDomain = ( req , res , next ) => {
res . header ( 'Access-Control-Allow-Origin' , '*' ) ;
res . header ( 'Access-Control-Allow-Headers' , 'Authorization, Accept, Cache-Control' ) ;
res . header ( 'Access-Control-Allow-Methods' , 'GET, OPTIONS' ) ;
next ( ) ;
} ;
const setRequestId = ( req , res , next ) => {
req . requestId = uuid . v4 ( ) ;
res . header ( 'X-Request-Id' , req . requestId ) ;
next ( ) ;
} ;
const setRemoteAddress = ( req , res , next ) => {
req . remoteAddress = req . connection . remoteAddress ;
next ( ) ;
} ;
const accountFromToken = ( token , allowedScopes , req , next ) => {
pgPool . connect ( ( err , client , done ) => {
if ( err ) {
next ( err ) ;
return ;
}
client . query ( 'SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1' , [ token ] , ( err , result ) => {
done ( ) ;
if ( err ) {
next ( err ) ;
return ;
}
if ( result . rows . length === 0 ) {
err = new Error ( 'Invalid access token' ) ;
err . statusCode = 401 ;
next ( err ) ;
return ;
}
const scopes = result . rows [ 0 ] . scopes . split ( ' ' ) ;
if ( allowedScopes . size > 0 && ! scopes . some ( scope => allowedScopes . includes ( scope ) ) ) {
err = new Error ( 'Access token does not cover required scopes' ) ;
err . statusCode = 401 ;
next ( err ) ;
return ;
}
req . accountId = result . rows [ 0 ] . account _id ;
req . chosenLanguages = result . rows [ 0 ] . chosen _languages ;
req . allowNotifications = scopes . some ( scope => [ 'read' , 'read:notifications' ] . includes ( scope ) ) ;
next ( ) ;
} ) ;
} ) ;
} ;
const accountFromRequest = ( req , next , required = true , allowedScopes = [ 'read' ] ) => {
const authorization = req . headers . authorization ;
const location = url . parse ( req . url , true ) ;
const accessToken = location . query . access _token || req . headers [ 'sec-websocket-protocol' ] ;
if ( ! authorization && ! accessToken ) {
if ( required ) {
const err = new Error ( 'Missing access token' ) ;
err . statusCode = 401 ;
next ( err ) ;
return ;
} else {
next ( ) ;
return ;
}
}
const token = authorization ? authorization . replace ( /^Bearer / , '' ) : accessToken ;
accountFromToken ( token , allowedScopes , req , next ) ;
} ;
2020-11-05 03:09:27 +00:00
const PUBLIC _STREAMS = [ ] ;
2019-07-02 08:10:25 +01:00
const wsVerifyClient = ( info , cb ) => {
const location = url . parse ( info . req . url , true ) ;
const authRequired = ! PUBLIC _STREAMS . some ( stream => stream === location . query . stream ) ;
const allowedScopes = [ ] ;
if ( authRequired ) {
allowedScopes . push ( 'read' ) ;
if ( location . query . stream === 'user:notification' ) {
allowedScopes . push ( 'read:notifications' ) ;
} else {
allowedScopes . push ( 'read:statuses' ) ;
}
}
accountFromRequest ( info . req , err => {
if ( ! err ) {
cb ( true , undefined , undefined ) ;
} else {
log . error ( info . req . requestId , err . toString ( ) ) ;
cb ( false , 401 , 'Unauthorized' ) ;
}
} , authRequired , allowedScopes ) ;
} ;
2020-11-05 03:09:27 +00:00
const PUBLIC _ENDPOINTS = [ ] ;
2019-07-02 08:10:25 +01:00
const authenticationMiddleware = ( req , res , next ) => {
if ( req . method === 'OPTIONS' ) {
next ( ) ;
return ;
}
const authRequired = ! PUBLIC _ENDPOINTS . some ( endpoint => endpoint === req . path ) ;
const allowedScopes = [ ] ;
if ( authRequired ) {
allowedScopes . push ( 'read' ) ;
if ( req . path === '/api/v1/streaming/user/notification' ) {
allowedScopes . push ( 'read:notifications' ) ;
} else {
allowedScopes . push ( 'read:statuses' ) ;
}
}
accountFromRequest ( req , next , authRequired , allowedScopes ) ;
} ;
2020-03-04 22:26:01 +00:00
const errorMiddleware = ( err , req , res , { } ) => {
2019-07-02 08:10:25 +01:00
log . error ( req . requestId , err . toString ( ) ) ;
res . writeHead ( err . statusCode || 500 , { 'Content-Type' : 'application/json' } ) ;
res . end ( JSON . stringify ( { error : err . statusCode ? err . toString ( ) : 'An unexpected error occurred' } ) ) ;
} ;
const placeholders = ( arr , shift = 0 ) => arr . map ( ( _ , i ) => ` $ ${ i + 1 + shift } ` ) . join ( ', ' ) ;
const authorizeListAccess = ( id , req , next ) => {
pgPool . connect ( ( err , client , done ) => {
if ( err ) {
next ( false ) ;
return ;
}
client . query ( 'SELECT id, account_id FROM lists WHERE id = $1 LIMIT 1' , [ id ] , ( err , result ) => {
done ( ) ;
if ( err || result . rows . length === 0 || result . rows [ 0 ] . account _id !== req . accountId ) {
next ( false ) ;
return ;
}
next ( true ) ;
} ) ;
} ) ;
} ;
const authorizeGroupAccess = ( id , req , next ) => {
pgPool . connect ( ( err , client , done ) => {
if ( err ) {
next ( false ) ;
return ;
}
client . query ( 'SELECT id FROM groups WHERE id = $1 LIMIT 1' , [ id ] , ( err , result ) => {
done ( ) ;
if ( err || result . rows . length === 0 ) {
next ( false ) ;
return ;
}
next ( true ) ;
} ) ;
} ) ;
} ;
const streamFrom = ( id , req , output , attachCloseHandler , needsFiltering = false , notificationOnly = false ) => {
const accountId = req . accountId || req . remoteAddress ;
const streamType = notificationOnly ? ' (notification)' : '' ;
log . verbose ( req . requestId , ` Starting stream from ${ id } for ${ accountId } ${ streamType } ` ) ;
const listener = message => {
const { event , payload , queued _at } = JSON . parse ( message ) ;
const transmit = ( ) => {
2020-03-04 22:26:01 +00:00
const now = new Date ( ) . getTime ( ) ;
const delta = now - queued _at ;
2019-07-02 08:10:25 +01:00
const encodedPayload = typeof payload === 'object' ? JSON . stringify ( payload ) : payload ;
log . silly ( req . requestId , ` Transmitting for ${ accountId } : ${ event } ${ encodedPayload } Delay: ${ delta } ms ` ) ;
output ( event , encodedPayload ) ;
} ;
if ( notificationOnly && event !== 'notification' ) {
return ;
}
if ( event === 'notification' && ! req . allowNotifications ) {
return ;
}
// Only messages that may require filtering are statuses, since notifications
// are already personalized and deletes do not matter
if ( ! needsFiltering || event !== 'update' ) {
transmit ( ) ;
return ;
}
2020-03-04 22:26:01 +00:00
const unpackedPayload = payload ;
2019-07-02 08:10:25 +01:00
const targetAccountIds = [ unpackedPayload . account . id ] . concat ( unpackedPayload . mentions . map ( item => item . id ) ) ;
2020-03-04 22:26:01 +00:00
const accountDomain = unpackedPayload . account . acct . split ( '@' ) [ 1 ] ;
2019-07-02 08:10:25 +01:00
if ( Array . isArray ( req . chosenLanguages ) && unpackedPayload . language !== null && req . chosenLanguages . indexOf ( unpackedPayload . language ) === - 1 ) {
log . silly ( req . requestId , ` Message ${ unpackedPayload . id } filtered by language ( ${ unpackedPayload . language } ) ` ) ;
return ;
}
// When the account is not logged in, it is not necessary to confirm the block or mute
if ( ! req . accountId ) {
transmit ( ) ;
return ;
}
pgPool . connect ( ( err , client , done ) => {
if ( err ) {
log . error ( err ) ;
return ;
}
const queries = [
client . query ( ` SELECT 1 FROM blocks WHERE (account_id = $ 1 AND target_account_id IN ( ${ placeholders ( targetAccountIds , 2 ) } )) OR (account_id = $ 2 AND target_account_id = $ 1) UNION SELECT 1 FROM mutes WHERE account_id = $ 1 AND target_account_id IN ( ${ placeholders ( targetAccountIds , 2 ) } ) ` , [ req . accountId , unpackedPayload . account . id ] . concat ( targetAccountIds ) ) ,
] ;
if ( accountDomain ) {
2020-11-15 18:48:32 +00:00
// queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]));
2019-07-02 08:10:25 +01:00
}
Promise . all ( queries ) . then ( values => {
done ( ) ;
if ( values [ 0 ] . rows . length > 0 || ( values . length > 1 && values [ 1 ] . rows . length > 0 ) ) {
return ;
}
transmit ( ) ;
} ) . catch ( err => {
done ( ) ;
log . error ( err ) ;
} ) ;
} ) ;
} ;
subscribe ( ` ${ redisPrefix } ${ id } ` , listener ) ;
attachCloseHandler ( ` ${ redisPrefix } ${ id } ` , listener ) ;
} ;
// Setup stream output to HTTP
const streamToHttp = ( req , res ) => {
const accountId = req . accountId || req . remoteAddress ;
res . setHeader ( 'Content-Type' , 'text/event-stream' ) ;
res . setHeader ( 'Transfer-Encoding' , 'chunked' ) ;
const heartbeat = setInterval ( ( ) => res . write ( ':thump\n' ) , 15000 ) ;
req . on ( 'close' , ( ) => {
log . verbose ( req . requestId , ` Ending stream for ${ accountId } ` ) ;
clearInterval ( heartbeat ) ;
} ) ;
return ( event , payload ) => {
res . write ( ` event: ${ event } \n ` ) ;
res . write ( ` data: ${ payload } \n \n ` ) ;
} ;
} ;
// Setup stream end for HTTP
const streamHttpEnd = ( req , closeHandler = false ) => ( id , listener ) => {
req . on ( 'close' , ( ) => {
unsubscribe ( id , listener ) ;
if ( closeHandler ) {
closeHandler ( ) ;
}
} ) ;
} ;
// Setup stream output to WebSockets
const streamToWs = ( req , ws ) => ( event , payload ) => {
if ( ws . readyState !== ws . OPEN ) {
log . error ( req . requestId , 'Tried writing to closed socket' ) ;
return ;
}
ws . send ( JSON . stringify ( { event , payload } ) ) ;
} ;
// Setup stream end for WebSockets
const streamWsEnd = ( req , ws , closeHandler = false ) => ( id , listener ) => {
const accountId = req . accountId || req . remoteAddress ;
ws . on ( 'close' , ( ) => {
log . verbose ( req . requestId , ` Ending stream for ${ accountId } ` ) ;
unsubscribe ( id , listener ) ;
if ( closeHandler ) {
closeHandler ( ) ;
}
} ) ;
ws . on ( 'error' , ( ) => {
log . verbose ( req . requestId , ` Ending stream for ${ accountId } ` ) ;
unsubscribe ( id , listener ) ;
if ( closeHandler ) {
closeHandler ( ) ;
}
} ) ;
} ;
const httpNotFound = res => {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' } ) ;
res . end ( JSON . stringify ( { error : 'Not found' } ) ) ;
} ;
app . use ( setRequestId ) ;
app . use ( setRemoteAddress ) ;
app . use ( allowCrossDomain ) ;
app . get ( '/api/v1/streaming/health' , ( req , res ) => {
res . writeHead ( 200 , { 'Content-Type' : 'text/plain' } ) ;
res . end ( 'OK' ) ;
} ) ;
app . use ( authenticationMiddleware ) ;
app . use ( errorMiddleware ) ;
2019-08-28 06:26:34 +01:00
app . get ( '/api/v1/streaming/statuscard' , ( req , res ) => {
2019-08-28 08:00:57 +01:00
const channel = ` statuscard: ${ req . accountId } ` ;
2019-08-28 06:26:34 +01:00
streamFrom ( channel , req , streamToHttp ( req , res ) , streamHttpEnd ( req , subscriptionHeartbeat ( channel ) ) ) ;
} ) ;
2019-07-02 08:10:25 +01:00
app . get ( '/api/v1/streaming/user' , ( req , res ) => {
const channel = ` timeline: ${ req . accountId } ` ;
streamFrom ( channel , req , streamToHttp ( req , res ) , streamHttpEnd ( req , subscriptionHeartbeat ( channel ) ) ) ;
} ) ;
app . get ( '/api/v1/streaming/user/notification' , ( req , res ) => {
streamFrom ( ` timeline: ${ req . accountId } ` , req , streamToHttp ( req , res ) , streamHttpEnd ( req ) , false , true ) ;
} ) ;
const wss = new WebSocketServer ( { server , verifyClient : wsVerifyClient } ) ;
wss . on ( 'connection' , ( ws , req ) => {
const location = url . parse ( req . url , true ) ;
2020-03-04 22:26:01 +00:00
req . requestId = uuid . v4 ( ) ;
2019-07-02 08:10:25 +01:00
req . remoteAddress = ws . _socket . remoteAddress ;
let channel ;
2020-03-04 22:26:01 +00:00
switch ( location . query . stream ) {
case 'statuscard' :
channel = ` statuscard: ${ req . accountId } ` ;
streamFrom ( channel , req , streamToWs ( req , ws ) , streamWsEnd ( req , ws , subscriptionHeartbeat ( channel ) ) ) ;
break ;
case 'user' :
channel = ` timeline: ${ req . accountId } ` ;
streamFrom ( channel , req , streamToWs ( req , ws ) , streamWsEnd ( req , ws , subscriptionHeartbeat ( channel ) ) ) ;
break ;
case 'user:notification' :
streamFrom ( ` timeline: ${ req . accountId } ` , req , streamToWs ( req , ws ) , streamWsEnd ( req , ws ) , false , true ) ;
break ;
default :
ws . close ( ) ;
2019-07-02 08:10:25 +01:00
}
} ) ;
wss . startAutoPing ( 30000 ) ;
attachServerWithConfig ( server , address => {
log . info ( ` Worker ${ workerId } now listening on ${ address } ` ) ;
} ) ;
const onExit = ( ) => {
log . info ( ` Worker ${ workerId } exiting, bye bye ` ) ;
server . close ( ) ;
process . exit ( 0 ) ;
} ;
const onError = ( err ) => {
log . error ( err ) ;
server . close ( ) ;
process . exit ( 0 ) ;
} ;
process . on ( 'SIGINT' , onExit ) ;
process . on ( 'SIGTERM' , onExit ) ;
process . on ( 'exit' , onExit ) ;
process . on ( 'uncaughtException' , onError ) ;
} ;
const attachServerWithConfig = ( server , onSuccess ) => {
if ( process . env . SOCKET || process . env . PORT && isNaN ( + process . env . PORT ) ) {
server . listen ( process . env . SOCKET || process . env . PORT , ( ) => {
if ( onSuccess ) {
fs . chmodSync ( server . address ( ) , 0o666 ) ;
onSuccess ( server . address ( ) ) ;
}
} ) ;
} else {
server . listen ( + process . env . PORT || 4000 , process . env . BIND || '0.0.0.0' , ( ) => {
if ( onSuccess ) {
onSuccess ( ` ${ server . address ( ) . address } : ${ server . address ( ) . port } ` ) ;
}
} ) ;
}
} ;
const onPortAvailable = onSuccess => {
const testServer = http . createServer ( ) ;
testServer . once ( 'error' , err => {
onSuccess ( err ) ;
} ) ;
testServer . once ( 'listening' , ( ) => {
testServer . once ( 'close' , ( ) => onSuccess ( ) ) ;
testServer . close ( ) ;
} ) ;
attachServerWithConfig ( testServer ) ;
} ;
onPortAvailable ( err => {
if ( err ) {
log . error ( 'Could not start server, the port or socket is in use' ) ;
return ;
}
throng ( {
workers : numWorkers ,
lifetime : Infinity ,
start : startWorker ,
master : startMaster ,
} ) ;
} ) ;