2020-08-11 16:24:59 +00:00
// @ts-check
2017-06-26 02:49:39 +00:00
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 uuid = require ( 'uuid' ) ;
2018-08-24 16:16:53 +00:00
const fs = require ( 'fs' ) ;
2021-03-24 08:37:41 +00:00
const WebSocket = require ( 'ws' ) ;
2017-05-20 15:31:47 +00:00
const env = process . env . NODE _ENV || 'development' ;
2020-08-11 16:24:59 +00:00
const alwaysRequireAuth = process . env . LIMITED _FEDERATION _MODE === 'true' || process . env . WHITELIST _MODE === 'true' || process . env . AUTHORIZED _FETCH === 'true' ;
2017-02-02 15:11:36 +00:00
dotenv . config ( {
2017-05-20 15:31:47 +00:00
path : env === 'production' ? '.env.production' : '.env' ,
} ) ;
2017-02-02 00:31:09 +00:00
2017-05-28 14:25:26 +00:00
log . level = process . env . LOG _LEVEL || 'verbose' ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string } dbUrl
* @ return { Object . < string , any > }
* /
2017-05-03 21:18:13 +00:00
const dbUrlToConfig = ( dbUrl ) => {
if ( ! dbUrl ) {
2017-05-20 15:31:47 +00:00
return { } ;
2017-05-03 21:18:13 +00:00
}
2019-03-10 15:00:54 +00:00
const params = url . parse ( dbUrl , true ) ;
2017-05-20 15:31:47 +00:00
const config = { } ;
2017-05-04 13:53:44 +00:00
if ( params . auth ) {
2017-05-20 15:31:47 +00:00
[ config . user , config . password ] = params . auth . split ( ':' ) ;
2017-05-04 13:53:44 +00:00
}
if ( params . hostname ) {
2017-05-20 15:31:47 +00:00
config . host = params . hostname ;
2017-05-04 13:53:44 +00:00
}
if ( params . port ) {
2017-05-20 15:31:47 +00:00
config . port = params . port ;
2017-05-03 21:18:13 +00:00
}
2017-05-04 13:53:44 +00:00
if ( params . pathname ) {
2017-05-20 15:31:47 +00:00
config . database = params . pathname . split ( '/' ) [ 1 ] ;
2017-05-04 13:53:44 +00:00
}
2017-05-20 15:31:47 +00:00
const ssl = params . query && params . query . ssl ;
2017-05-20 19:06:09 +00:00
2019-03-10 15:00:54 +00:00
if ( ssl && ssl === 'true' || ssl === '1' ) {
config . ssl = true ;
2017-05-04 13:53:44 +00:00
}
2017-05-20 15:31:47 +00:00
return config ;
} ;
2017-05-03 21:18:13 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { Object . < string , any > } defaultConfig
* @ param { string } redisUrl
* /
2017-05-20 19:06:09 +00:00
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 ,
} ) ) ;
} ;
2017-05-28 14:25:26 +00:00
const numWorkers = + process . env . STREAMING _CLUSTER _NUM || ( env === 'development' ? 1 : Math . max ( os . cpus ( ) . length - 1 , 1 ) ) ;
2017-05-03 21:18:13 +00:00
2020-09-22 13:30:41 +00:00
/ * *
* @ param { string } json
* @ return { Object . < string , any > | null }
* /
const parseJSON = ( json ) => {
try {
return JSON . parse ( json ) ;
} catch ( err ) {
log . error ( err ) ;
return null ;
}
} ;
2017-05-28 14:25:26 +00:00
const startMaster = ( ) => {
2018-08-24 16:16:53 +00:00
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.' ) ;
}
2018-10-20 00:25:25 +00:00
2021-05-01 21:19:18 +00:00
log . warn ( ` Starting streaming API server master with ${ numWorkers } workers ` ) ;
2017-05-28 14:25:26 +00:00
} ;
2017-05-03 21:18:13 +00:00
2017-05-28 14:25:26 +00:00
const startWorker = ( workerId ) => {
2021-05-01 21:19:18 +00:00
log . warn ( ` Starting worker ${ workerId } ` ) ;
2017-04-17 02:32:30 +00:00
const pgConfigs = {
development : {
2017-06-25 16:13:31 +00:00
user : process . env . DB _USER || pg . defaults . user ,
password : process . env . DB _PASS || pg . defaults . password ,
2017-10-17 09:45:37 +00:00
database : process . env . DB _NAME || 'mastodon_development' ,
2017-06-25 16:13:31 +00:00
host : process . env . DB _HOST || pg . defaults . host ,
port : process . env . DB _PORT || pg . defaults . port ,
2017-05-20 15:31:47 +00:00
max : 10 ,
2017-04-17 02:32:30 +00:00
} ,
production : {
user : process . env . DB _USER || 'mastodon' ,
password : process . env . DB _PASS || '' ,
database : process . env . DB _NAME || 'mastodon_production' ,
host : process . env . DB _HOST || 'localhost' ,
port : process . env . DB _PORT || 5432 ,
2017-05-20 15:31:47 +00:00
max : 10 ,
} ,
} ;
2017-02-02 00:31:09 +00:00
2019-03-10 23:51:23 +00:00
if ( ! ! process . env . DB _SSLMODE && process . env . DB _SSLMODE !== 'disable' ) {
pgConfigs . development . ssl = true ;
pgConfigs . production . ssl = true ;
}
const app = express ( ) ;
2020-08-11 16:24:59 +00:00
2017-12-12 14:13:24 +00:00
app . set ( 'trusted proxy' , process . env . TRUSTED _PROXY _IP || 'loopback,uniquelocal' ) ;
2017-05-20 15:31:47 +00:00
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 ;
2017-02-07 13:37:12 +00:00
2017-05-07 17:42:32 +00:00
const redisParams = {
2017-04-17 02:32:30 +00:00
host : process . env . REDIS _HOST || '127.0.0.1' ,
port : process . env . REDIS _PORT || 6379 ,
2017-05-17 13:36:34 +00:00
db : process . env . REDIS _DB || 0 ,
2020-06-24 20:25:23 +00:00
password : process . env . REDIS _PASSWORD || undefined ,
2017-05-20 15:31:47 +00:00
} ;
2017-05-07 17:42:32 +00:00
if ( redisNamespace ) {
2017-05-20 15:31:47 +00:00
redisParams . namespace = redisNamespace ;
2017-05-07 17:42:32 +00:00
}
2017-05-20 19:06:09 +00:00
2017-05-20 15:31:47 +00:00
const redisPrefix = redisNamespace ? ` ${ redisNamespace } : ` : '' ;
2017-05-07 17:42:32 +00:00
2017-06-03 18:50:53 +00:00
const redisSubscribeClient = redisUrlToClient ( redisParams , process . env . REDIS _URL ) ;
2017-05-20 19:06:09 +00:00
const redisClient = redisUrlToClient ( redisParams , process . env . REDIS _URL ) ;
2017-02-07 13:37:12 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ type { Object . < string , Array . < function ( string ) : void >> }
* /
2017-05-20 15:31:47 +00:00
const subs = { } ;
2017-02-07 13:37:12 +00:00
2023-02-09 04:41:12 +00:00
let stats = { } ;
2017-06-20 18:41:41 +00:00
redisSubscribeClient . on ( 'message' , ( channel , message ) => {
2017-05-20 15:31:47 +00:00
const callbacks = subs [ channel ] ;
2017-02-07 13:37:12 +00:00
2017-05-20 15:31:47 +00:00
log . silly ( ` New message on channel ${ channel } ` ) ;
2017-02-07 13:37:12 +00:00
2017-04-17 02:32:30 +00:00
if ( ! callbacks ) {
2017-05-20 15:31:47 +00:00
return ;
2017-04-17 02:32:30 +00:00
}
2017-05-28 14:25:26 +00:00
2017-05-20 15:31:47 +00:00
callbacks . forEach ( callback => callback ( message ) ) ;
} ) ;
2017-02-07 13:37:12 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string [ ] } channels
* @ return { function ( ) : void }
* /
2020-06-02 17:24:53 +00:00
const subscriptionHeartbeat = channels => {
const interval = 6 * 60 ;
2017-06-03 18:50:53 +00:00
const tellSubscribed = ( ) => {
2020-06-02 17:24:53 +00:00
channels . forEach ( channel => redisClient . set ( ` ${ redisPrefix } subscribed: ${ channel } ` , '1' , 'EX' , interval * 3 ) ) ;
2017-06-03 18:50:53 +00:00
} ;
2020-06-02 17:24:53 +00:00
2017-06-03 18:50:53 +00:00
tellSubscribed ( ) ;
2020-06-02 17:24:53 +00:00
const heartbeat = setInterval ( tellSubscribed , interval * 1000 ) ;
2017-06-03 18:50:53 +00:00
return ( ) => {
clearInterval ( heartbeat ) ;
} ;
} ;
2017-02-07 13:37:12 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string } channel
* @ param { function ( string ) : void } callback
* /
2017-04-17 02:32:30 +00:00
const subscribe = ( channel , callback ) => {
2017-05-20 15:31:47 +00:00
log . silly ( ` Adding listener for ${ channel } ` ) ;
subs [ channel ] = subs [ channel ] || [ ] ;
2020-08-11 16:24:59 +00:00
2017-06-20 18:41:41 +00:00
if ( subs [ channel ] . length === 0 ) {
log . verbose ( ` Subscribe ${ channel } ` ) ;
redisSubscribeClient . subscribe ( channel ) ;
}
2020-08-11 16:24:59 +00:00
2017-05-20 15:31:47 +00:00
subs [ channel ] . push ( callback ) ;
} ;
2017-02-03 17:27:42 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string } channel
* @ param { function ( string ) : void } callback
* /
2017-04-17 02:32:30 +00:00
const unsubscribe = ( channel , callback ) => {
2017-05-20 15:31:47 +00:00
log . silly ( ` Removing listener for ${ channel } ` ) ;
2020-08-11 16:24:59 +00:00
if ( ! subs [ channel ] ) {
return ;
}
2017-05-20 15:31:47 +00:00
subs [ channel ] = subs [ channel ] . filter ( item => item !== callback ) ;
2020-08-11 16:24:59 +00:00
2017-06-20 18:41:41 +00:00
if ( subs [ channel ] . length === 0 ) {
log . verbose ( ` Unsubscribe ${ channel } ` ) ;
redisSubscribeClient . unsubscribe ( channel ) ;
2020-08-12 13:36:07 +00:00
delete subs [ channel ] ;
2017-06-20 18:41:41 +00:00
}
2017-05-20 15:31:47 +00:00
} ;
2017-02-03 17:27:42 +00:00
2020-08-11 16:24:59 +00:00
const FALSE _VALUES = [
false ,
0 ,
2020-11-23 16:35:14 +00:00
'0' ,
'f' ,
'F' ,
'false' ,
'FALSE' ,
'off' ,
'OFF' ,
2020-08-11 16:24:59 +00:00
] ;
/ * *
* @ param { any } value
* @ return { boolean }
* /
const isTruthy = value =>
value && ! FALSE _VALUES . includes ( value ) ;
/ * *
* @ param { any } req
* @ param { any } res
* @ param { function ( Error = ) : void }
* /
2017-04-17 02:32:30 +00:00
const allowCrossDomain = ( req , res , next ) => {
2017-05-20 15:31:47 +00:00
res . header ( 'Access-Control-Allow-Origin' , '*' ) ;
res . header ( 'Access-Control-Allow-Headers' , 'Authorization, Accept, Cache-Control' ) ;
res . header ( 'Access-Control-Allow-Methods' , 'GET, OPTIONS' ) ;
2017-02-05 22:37:25 +00:00
2017-05-20 15:31:47 +00:00
next ( ) ;
} ;
2017-02-05 22:37:25 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { any } res
* @ param { function ( Error = ) : void }
* /
2017-04-17 02:32:30 +00:00
const setRequestId = ( req , res , next ) => {
2017-05-20 15:31:47 +00:00
req . requestId = uuid . v4 ( ) ;
res . header ( 'X-Request-Id' , req . requestId ) ;
2017-02-02 00:31:09 +00:00
2017-05-20 15:31:47 +00:00
next ( ) ;
} ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { any } res
* @ param { function ( Error = ) : void }
* /
2017-12-12 14:13:24 +00:00
const setRemoteAddress = ( req , res , next ) => {
req . remoteAddress = req . connection . remoteAddress ;
next ( ) ;
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string } token
* @ param { any } req
* @ return { Promise . < void > }
* /
const accountFromToken = ( token , req ) => new Promise ( ( resolve , reject ) => {
2017-04-17 02:32:30 +00:00
pgPool . connect ( ( err , client , done ) => {
2017-02-02 00:31:09 +00:00
if ( err ) {
2020-08-11 16:24:59 +00:00
reject ( err ) ;
2017-05-20 15:31:47 +00:00
return ;
2017-02-02 00:31:09 +00:00
}
2022-12-25 10:32:02 +00:00
client . query ( 'SELECT oauth_access_tokens.id, oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes, devices.device_id, oauth_applications.name, oauth_applications.website, (select settings.value from settings where thing_type = \'User\' and thing_id=users.id and var = \'hide_bot_on_public_timeline\') as bot, (select exists (select settings.value from settings where thing_type = \'User\' and thing_id=users.id and var = \'enable_federated_timeline\' and value ilike \'%false%\')) as disable_federated_timeline FROM oauth_access_tokens INNER JOIN oauth_applications ON oauth_access_tokens.application_id = oauth_applications.id INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id LEFT OUTER JOIN devices ON oauth_access_tokens.id = devices.access_token_id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1' , [ token ] , ( err , result ) => {
2017-05-20 15:31:47 +00:00
done ( ) ;
2017-02-02 00:31:09 +00:00
2017-04-17 02:32:30 +00:00
if ( err ) {
2020-08-11 16:24:59 +00:00
reject ( err ) ;
2017-05-20 15:31:47 +00:00
return ;
2017-04-17 02:32:30 +00:00
}
2017-02-02 00:31:09 +00:00
2017-04-17 02:32:30 +00:00
if ( result . rows . length === 0 ) {
2017-05-20 15:31:47 +00:00
err = new Error ( 'Invalid access token' ) ;
2020-08-11 16:24:59 +00:00
err . status = 401 ;
2019-05-24 13:21:42 +00:00
2020-08-11 16:24:59 +00:00
reject ( err ) ;
2019-05-24 13:21:42 +00:00
return ;
}
2020-11-12 22:05:24 +00:00
req . accessTokenId = result . rows [ 0 ] . id ;
2020-08-11 16:24:59 +00:00
req . scopes = result . rows [ 0 ] . scopes . split ( ' ' ) ;
2017-05-20 15:31:47 +00:00
req . accountId = result . rows [ 0 ] . account _id ;
2018-07-14 01:59:31 +00:00
req . chosenLanguages = result . rows [ 0 ] . chosen _languages ;
2022-07-03 05:54:03 +00:00
req . bot = result . rows [ 0 ] . bot ;
2022-12-25 10:32:02 +00:00
req . enableFederatedTimeline = ! result . rows [ 0 ] . disable _federated _timeline ;
2020-08-11 16:24:59 +00:00
req . allowNotifications = req . scopes . some ( scope => [ 'read' , 'read:notifications' ] . includes ( scope ) ) ;
2020-06-02 17:24:53 +00:00
req . deviceId = result . rows [ 0 ] . device _id ;
2020-05-26 21:41:47 +00:00
req . applicationName = result . rows [ 0 ] . name ;
2020-06-08 16:34:51 +00:00
req . website = result . rows [ 0 ] . website ;
2017-02-03 23:34:31 +00:00
2020-08-11 16:24:59 +00:00
resolve ( ) ;
2017-05-20 15:31:47 +00:00
} ) ;
} ) ;
2020-08-11 16:24:59 +00:00
} ) ;
2017-02-03 23:34:31 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { boolean = } required
* @ return { Promise . < void > }
* /
const accountFromRequest = ( req , required = true ) => new Promise ( ( resolve , reject ) => {
2017-05-29 16:20:53 +00:00
const authorization = req . headers . authorization ;
2020-08-11 16:24:59 +00:00
const location = url . parse ( req . url , true ) ;
const accessToken = location . query . access _token || req . headers [ 'sec-websocket-protocol' ] ;
2017-02-03 23:34:31 +00:00
2017-05-21 19:13:11 +00:00
if ( ! authorization && ! accessToken ) {
2017-12-12 14:13:24 +00:00
if ( required ) {
const err = new Error ( 'Missing access token' ) ;
2020-08-11 16:24:59 +00:00
err . status = 401 ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
reject ( err ) ;
2017-12-12 14:13:24 +00:00
return ;
} else {
2020-08-11 16:24:59 +00:00
resolve ( ) ;
2017-12-12 14:13:24 +00:00
return ;
}
2017-04-17 02:32:30 +00:00
}
2017-02-02 16:10:59 +00:00
2017-05-21 19:13:11 +00:00
const token = authorization ? authorization . replace ( /^Bearer / , '' ) : accessToken ;
2017-02-02 12:56:14 +00:00
2020-08-11 16:24:59 +00:00
resolve ( accountFromToken ( token , req ) ) ;
} ) ;
/ * *
* @ param { any } req
* @ return { string }
* /
const channelNameFromPath = req => {
const { path , query } = req ;
const onlyMedia = isTruthy ( query . only _media ) ;
2022-07-03 05:54:03 +00:00
const withoutMedia = isTruthy ( query . without _media ) ;
const names = [ onlyMedia ? 'media' : null , withoutMedia ? 'nomedia' : null ] . filter ( x => ! ! x ) ;
2020-08-11 16:24:59 +00:00
switch ( path ) {
case '/api/v1/streaming/user' :
return 'user' ;
case '/api/v1/streaming/user/notification' :
return 'user:notification' ;
case '/api/v1/streaming/public' :
2022-07-03 05:54:03 +00:00
return [ 'public' , ... names ] . join ( ':' ) ;
2020-06-08 16:34:51 +00:00
case '/api/v1/streaming/public/local' :
2022-07-03 05:54:03 +00:00
return [ 'public:local' , ... names ] . join ( ':' ) ;
2020-08-11 16:24:59 +00:00
case '/api/v1/streaming/public/remote' :
2022-07-03 05:54:03 +00:00
return [ 'public:remote' , ... names ] . join ( ':' ) ;
2019-11-13 22:42:56 +00:00
case '/api/v1/streaming/public/domain' :
2022-07-03 05:54:03 +00:00
return [ 'public:domain' , ... names ] . join ( ':' ) ;
2020-08-11 16:24:59 +00:00
case '/api/v1/streaming/hashtag' :
return 'hashtag' ;
case '/api/v1/streaming/direct' :
return 'direct' ;
case '/api/v1/streaming/list' :
return 'list' ;
2020-11-23 16:35:14 +00:00
default :
return undefined ;
2020-08-11 16:24:59 +00:00
}
2017-05-20 15:31:47 +00:00
} ;
2017-02-05 02:19:04 +00:00
2020-08-11 16:24:59 +00:00
const PUBLIC _CHANNELS = [
2017-12-12 14:13:24 +00:00
'public' ,
2020-07-12 13:00:23 +00:00
'group' ,
2017-12-12 14:13:24 +00:00
'hashtag' ,
] ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { string } channelName
* @ return { Promise . < void > }
* /
const checkScopes = ( req , channelName ) => new Promise ( ( resolve , reject ) => {
log . silly ( req . requestId , ` Checking OAuth scopes for ${ channelName } ` ) ;
// When accessing public channels, no scopes are needed
2022-07-03 05:54:03 +00:00
if ( PUBLIC _CHANNELS . includes ( channelName . split ( ':' ) [ 0 ] ) ) {
2020-08-11 16:24:59 +00:00
resolve ( ) ;
return ;
}
2019-05-24 13:21:42 +00:00
2020-08-11 16:24:59 +00:00
// The `read` scope has the highest priority, if the token has it
// then it can access all streams
const requiredScopes = [ 'read' ] ;
// When accessing specifically the notifications stream,
// we need a read:notifications, while in all other cases,
// we can allow access with read:statuses. Mind that the
// user stream will not contain notifications unless
// the token has either read or read:notifications scope
// as well, this is handled separately.
if ( channelName === 'user:notification' ) {
requiredScopes . push ( 'read:notifications' ) ;
} else {
requiredScopes . push ( 'read:statuses' ) ;
2019-05-24 13:21:42 +00:00
}
2017-12-12 14:13:24 +00:00
2020-08-11 16:24:59 +00:00
if ( requiredScopes . some ( requiredScope => req . scopes . includes ( requiredScope ) ) ) {
resolve ( ) ;
return ;
}
2017-05-29 16:20:53 +00:00
2020-08-11 16:24:59 +00:00
const err = new Error ( 'Access token does not cover required scopes' ) ;
err . status = 401 ;
reject ( err ) ;
} ) ;
2017-12-12 14:13:24 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } info
* @ param { function ( boolean , number , string ) : void } callback
* /
const wsVerifyClient = ( info , callback ) => {
// When verifying the websockets connection, we no longer pre-emptively
// check OAuth scopes and drop the connection if they're missing. We only
// drop the connection if access without token is not allowed by environment
// variables. OAuth scope checks are moved to the point of subscription
// to a specific stream.
accountFromRequest ( info . req , alwaysRequireAuth ) . then ( ( ) => {
callback ( true , undefined , undefined ) ;
} ) . catch ( err => {
log . error ( info . req . requestId , err . toString ( ) ) ;
callback ( false , 401 , 'Unauthorized' ) ;
} ) ;
} ;
2020-11-12 22:05:24 +00:00
/ * *
* @ typedef SystemMessageHandlers
* @ property { function ( ) : void } onKill
* /
/ * *
* @ param { any } req
* @ param { SystemMessageHandlers } eventHandlers
* @ return { function ( string ) : void }
* /
const createSystemMessageListener = ( req , eventHandlers ) => {
return message => {
const json = parseJSON ( message ) ;
if ( ! json ) return ;
const { event } = json ;
log . silly ( req . requestId , ` System message for ${ req . accountId } : ${ event } ` ) ;
if ( event === 'kill' ) {
log . verbose ( req . requestId , ` Closing connection for ${ req . accountId } due to expired access token ` ) ;
eventHandlers . onKill ( ) ;
}
2020-11-23 16:35:14 +00:00
} ;
2020-11-12 22:05:24 +00:00
} ;
/ * *
* @ param { any } req
* @ param { any } res
* /
const subscribeHttpToSystemChannel = ( req , res ) => {
const systemChannelId = ` timeline:access_token: ${ req . accessTokenId } ` ;
const listener = createSystemMessageListener ( req , {
onKill ( ) {
res . end ( ) ;
} ,
} ) ;
res . on ( 'close' , ( ) => {
unsubscribe ( ` ${ redisPrefix } ${ systemChannelId } ` , listener ) ;
} ) ;
subscribe ( ` ${ redisPrefix } ${ systemChannelId } ` , listener ) ;
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { any } res
* @ param { function ( Error = ) : void } next
* /
2017-05-29 16:20:53 +00:00
const authenticationMiddleware = ( req , res , next ) => {
if ( req . method === 'OPTIONS' ) {
next ( ) ;
return ;
}
2020-08-11 16:24:59 +00:00
accountFromRequest ( req , alwaysRequireAuth ) . then ( ( ) => checkScopes ( req , channelNameFromPath ( req ) ) ) . then ( ( ) => {
2020-11-12 22:05:24 +00:00
subscribeHttpToSystemChannel ( req , res ) ;
} ) . then ( ( ) => {
2020-08-11 16:24:59 +00:00
next ( ) ;
} ) . catch ( err => {
next ( err ) ;
} ) ;
2017-05-29 16:20:53 +00:00
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { Error } err
* @ param { any } req
* @ param { any } res
* @ param { function ( Error = ) : void } next
* /
const errorMiddleware = ( err , req , res , next ) => {
2017-05-28 14:25:26 +00:00
log . error ( req . requestId , err . toString ( ) ) ;
2020-08-11 16:24:59 +00:00
if ( res . headersSent ) {
2020-11-23 16:35:14 +00:00
next ( err ) ;
return ;
2020-08-11 16:24:59 +00:00
}
res . writeHead ( err . status || 500 , { 'Content-Type' : 'application/json' } ) ;
res . end ( JSON . stringify ( { error : err . status ? err . toString ( ) : 'An unexpected error occurred' } ) ) ;
2017-05-20 15:31:47 +00:00
} ;
2017-02-05 02:19:04 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { array }
* @ param { number = } shift
* @ return { string }
* /
2017-04-17 02:32:30 +00:00
const placeholders = ( arr , shift = 0 ) => arr . map ( ( _ , i ) => ` $ ${ i + 1 + shift } ` ) . join ( ', ' ) ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string } listId
* @ param { any } req
* @ return { Promise . < void > }
* /
const authorizeListAccess = ( listId , req ) => new Promise ( ( resolve , reject ) => {
const { accountId } = req ;
2017-11-17 23:16:48 +00:00
pgPool . connect ( ( err , client , done ) => {
if ( err ) {
2020-08-11 16:24:59 +00:00
reject ( ) ;
2017-11-17 23:16:48 +00:00
return ;
}
2020-08-11 16:24:59 +00:00
client . query ( 'SELECT id, account_id FROM lists WHERE id = $1 LIMIT 1' , [ listId ] , ( err , result ) => {
2017-11-17 23:16:48 +00:00
done ( ) ;
2020-08-11 16:24:59 +00:00
if ( err || result . rows . length === 0 || result . rows [ 0 ] . account _id !== accountId ) {
reject ( ) ;
2017-11-17 23:16:48 +00:00
return ;
}
2020-08-11 16:24:59 +00:00
resolve ( ) ;
2017-11-17 23:16:48 +00:00
} ) ;
} ) ;
2020-08-11 16:24:59 +00:00
} ) ;
2017-11-17 23:16:48 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string [ ] } ids
* @ param { any } req
* @ param { function ( string , string ) : void } output
* @ param { function ( string [ ] , function ( string ) : void ) : void } attachCloseHandler
* @ param { boolean = } needsFiltering
* @ param { boolean = } notificationOnly
* @ return { function ( string ) : void }
* /
2020-06-02 17:24:53 +00:00
const streamFrom = ( ids , req , output , attachCloseHandler , needsFiltering = false , notificationOnly = false ) => {
const accountId = req . accountId || req . remoteAddress ;
2017-06-03 18:50:53 +00:00
const streamType = notificationOnly ? ' (notification)' : '' ;
2020-06-02 17:24:53 +00:00
log . verbose ( req . requestId , ` Starting stream from ${ ids . join ( ', ' ) } for ${ accountId } ${ streamType } ` ) ;
2017-04-17 02:32:30 +00:00
const listener = message => {
2020-09-22 13:30:41 +00:00
const json = parseJSON ( message ) ;
2020-11-12 22:05:24 +00:00
2020-09-22 13:30:41 +00:00
if ( ! json ) return ;
2020-11-12 22:05:24 +00:00
2020-09-22 13:30:41 +00:00
const { event , payload , queued _at } = json ;
2017-02-02 12:56:14 +00:00
2017-04-17 02:32:30 +00:00
const transmit = ( ) => {
2017-07-07 14:56:52 +00:00
const now = new Date ( ) . getTime ( ) ;
const delta = now - queued _at ;
2017-09-24 13:31:03 +00:00
const encodedPayload = typeof payload === 'object' ? JSON . stringify ( payload ) : payload ;
2017-02-02 12:56:14 +00:00
2017-12-12 14:13:24 +00:00
log . silly ( req . requestId , ` Transmitting for ${ accountId } : ${ event } ${ encodedPayload } Delay: ${ delta } ms ` ) ;
2017-07-07 14:56:52 +00:00
output ( event , encodedPayload ) ;
2017-05-20 15:31:47 +00:00
} ;
2017-02-02 12:56:14 +00:00
2017-06-03 18:50:53 +00:00
if ( notificationOnly && event !== 'notification' ) {
return ;
}
2019-05-24 13:21:42 +00:00
if ( event === 'notification' && ! req . allowNotifications ) {
return ;
}
2017-04-17 02:32:30 +00:00
// Only messages that may require filtering are statuses, since notifications
// are already personalized and deletes do not matter
2018-04-17 11:49:09 +00:00
if ( ! needsFiltering || event !== 'update' ) {
transmit ( ) ;
return ;
}
2017-02-02 12:56:14 +00:00
2018-04-17 11:49:09 +00:00
const unpackedPayload = payload ;
const targetAccountIds = [ unpackedPayload . account . id ] . concat ( unpackedPayload . mentions . map ( item => item . id ) ) ;
const accountDomain = unpackedPayload . account . acct . split ( '@' ) [ 1 ] ;
2017-04-17 02:32:30 +00:00
2018-07-14 01:59:31 +00:00
if ( Array . isArray ( req . chosenLanguages ) && unpackedPayload . language !== null && req . chosenLanguages . indexOf ( unpackedPayload . language ) === - 1 ) {
2018-04-17 11:49:09 +00:00
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 ) {
queries . push ( client . query ( 'SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2' , [ req . accountId , accountDomain ] ) ) ;
}
Promise . all ( queries ) . then ( values => {
done ( ) ;
if ( values [ 0 ] . rows . length > 0 || ( values . length > 1 && values [ 1 ] . rows . length > 0 ) ) {
2017-05-26 22:53:48 +00:00
return ;
}
2018-04-17 11:49:09 +00:00
transmit ( ) ;
} ) . catch ( err => {
done ( ) ;
log . error ( err ) ;
2017-05-20 15:31:47 +00:00
} ) ;
2018-04-17 11:49:09 +00:00
} ) ;
2017-05-20 15:31:47 +00:00
} ;
2017-04-17 02:32:30 +00:00
2020-06-02 17:24:53 +00:00
ids . forEach ( id => {
subscribe ( ` ${ redisPrefix } ${ id } ` , listener ) ;
} ) ;
2020-08-11 16:24:59 +00:00
if ( attachCloseHandler ) {
attachCloseHandler ( ids . map ( id => ` ${ redisPrefix } ${ id } ` ) , listener ) ;
}
return listener ;
2017-05-20 15:31:47 +00:00
} ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { any } res
* @ return { function ( string , string ) : void }
* /
2017-04-17 02:32:30 +00:00
const streamToHttp = ( req , res ) => {
2017-12-12 14:13:24 +00:00
const accountId = req . accountId || req . remoteAddress ;
2017-05-20 15:31:47 +00:00
res . setHeader ( 'Content-Type' , 'text/event-stream' ) ;
2020-01-24 19:51:33 +00:00
res . setHeader ( 'Cache-Control' , 'no-store' ) ;
2017-05-20 15:31:47 +00:00
res . setHeader ( 'Transfer-Encoding' , 'chunked' ) ;
2017-02-03 23:34:31 +00:00
2020-01-24 19:51:33 +00:00
res . write ( ':)\n' ) ;
2017-05-20 15:31:47 +00:00
const heartbeat = setInterval ( ( ) => res . write ( ':thump\n' ) , 15000 ) ;
2017-02-03 23:34:31 +00:00
2017-04-17 02:32:30 +00:00
req . on ( 'close' , ( ) => {
2017-12-12 14:13:24 +00:00
log . verbose ( req . requestId , ` Ending stream for ${ accountId } ` ) ;
2017-05-20 15:31:47 +00:00
clearInterval ( heartbeat ) ;
} ) ;
2017-02-02 14:20:31 +00:00
2017-04-17 02:32:30 +00:00
return ( event , payload ) => {
2017-05-20 15:31:47 +00:00
res . write ( ` event: ${ event } \n ` ) ;
res . write ( ` data: ${ payload } \n \n ` ) ;
} ;
} ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { function ( ) : void } [ closeHandler ]
* @ return { function ( string [ ] , function ( string ) : void ) }
* /
const streamHttpEnd = ( req , closeHandler = undefined ) => ( ids , listener ) => {
2017-04-17 02:32:30 +00:00
req . on ( 'close' , ( ) => {
2020-06-02 17:24:53 +00:00
ids . forEach ( id => {
unsubscribe ( id , listener ) ;
} ) ;
2017-06-03 18:50:53 +00:00
if ( closeHandler ) {
closeHandler ( ) ;
}
2017-05-20 15:31:47 +00:00
} ) ;
} ;
2017-02-03 23:34:31 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } req
* @ param { any } ws
* @ param { string [ ] } streamName
* @ return { function ( string , string ) : void }
* /
const streamToWs = ( req , ws , streamName ) => ( event , payload ) => {
2017-05-28 14:25:26 +00:00
if ( ws . readyState !== ws . OPEN ) {
log . error ( req . requestId , 'Tried writing to closed socket' ) ;
return ;
}
2017-02-03 23:34:31 +00:00
2020-08-11 16:24:59 +00:00
ws . send ( JSON . stringify ( { stream : streamName , event , payload } ) ) ;
2017-05-20 15:31:47 +00:00
} ;
2017-02-03 23:34:31 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } res
* /
2018-10-11 17:24:43 +00:00
const httpNotFound = res => {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' } ) ;
res . end ( JSON . stringify ( { error : 'Not found' } ) ) ;
} ;
2017-05-20 15:31:47 +00:00
app . use ( setRequestId ) ;
2017-12-12 14:13:24 +00:00
app . use ( setRemoteAddress ) ;
2017-05-20 15:31:47 +00:00
app . use ( allowCrossDomain ) ;
2018-08-26 09:54:25 +00:00
app . get ( '/api/v1/streaming/health' , ( req , res ) => {
res . writeHead ( 200 , { 'Content-Type' : 'text/plain' } ) ;
res . end ( 'OK' ) ;
} ) ;
2023-02-09 04:41:12 +00:00
app . get ( '/api/v1/streaming/stats' , ( req , res ) => {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
res . end ( JSON . stringify ( stats ) ) ;
} ) ;
2017-05-20 15:31:47 +00:00
app . use ( authenticationMiddleware ) ;
app . use ( errorMiddleware ) ;
2017-02-02 00:31:09 +00:00
2020-08-11 16:24:59 +00:00
app . get ( '/api/v1/streaming/*' , ( req , res ) => {
channelNameToIds ( req , channelNameFromPath ( req ) , req . query ) . then ( ( { channelIds , options } ) => {
const onSend = streamToHttp ( req , res ) ;
const onEnd = streamHttpEnd ( req , subscriptionHeartbeat ( channelIds ) ) ;
2018-05-21 10:43:38 +00:00
2020-08-11 16:24:59 +00:00
streamFrom ( channelIds , req , onSend , onEnd , options . needsFiltering , options . notificationOnly ) ;
} ) . catch ( err => {
log . verbose ( req . requestId , 'Subscription error:' , err . toString ( ) ) ;
2018-10-11 17:24:43 +00:00
httpNotFound ( res ) ;
2017-11-17 23:16:48 +00:00
} ) ;
} ) ;
2021-03-24 08:37:41 +00:00
const wss = new WebSocket . Server ( { server , verifyClient : wsVerifyClient } ) ;
2017-05-29 16:20:53 +00:00
2020-08-11 16:24:59 +00:00
/ * *
* @ typedef StreamParams
* @ property { string } [ tag ]
* @ property { string } [ list ]
2019-11-13 22:42:56 +00:00
* @ property { string } [ domain ]
2020-08-11 16:24:59 +00:00
* @ property { string } [ only _media ]
2022-07-03 05:54:03 +00:00
* @ property { string } [ without _media ]
* @ property { string } [ without _bot ]
2020-07-12 13:00:23 +00:00
* @ property { string } [ id ]
* @ property { string } [ tagged ]
2020-08-11 16:24:59 +00:00
* /
/ * *
* @ param { any } req
* @ param { string } name
* @ param { StreamParams } params
* @ return { Promise . < { channelIds : string [ ] , options : { needsFiltering : boolean , notificationOnly : boolean } } > }
* /
const channelNameToIds = ( req , name , params ) => new Promise ( ( resolve , reject ) => {
2022-07-03 05:54:03 +00:00
const convertedName = ( ( ) => {
const parts = name . split ( ':' ) ;
if ( parts [ 0 ] === 'public' && ! parts . includes ( 'bot' ) && ! parts . includes ( 'nobot' ) ) {
if ( ( params . without _bot === undefined && req . bot === '--- true\n' ) ? true : isTruthy ( params . without _bot ) ) {
if ( parts [ parts . length - 1 ] === 'media' ) {
parts . pop ( ) ;
return [ ... parts , 'nobot' , 'media' ] . join ( ':' ) ;
} else if ( parts [ parts . length - 1 ] === 'nomedia' ) {
parts . pop ( ) ;
return [ ... parts , 'nobot' , 'nomedia' ] . join ( ':' ) ;
} else {
return [ ... parts , 'nobot' ] . join ( ':' ) ;
}
}
} else if ( parts [ 0 ] === 'public' && parts . includes ( 'bot' ) ) {
return parts . filter ( x => x !== 'bot' ) . join ( ':' ) ;
}
return name ;
} ) ( ) ;
switch ( convertedName ) {
2017-05-29 16:20:53 +00:00
case 'user' :
2020-08-11 16:24:59 +00:00
resolve ( {
channelIds : req . deviceId ? [ ` timeline: ${ req . accountId } ` , ` timeline: ${ req . accountId } : ${ req . deviceId } ` ] : [ ` timeline: ${ req . accountId } ` ] ,
options : { needsFiltering : false , notificationOnly : false } ,
} ) ;
2020-06-02 17:24:53 +00:00
2017-06-03 18:50:53 +00:00
break ;
case 'user:notification' :
2020-08-11 16:24:59 +00:00
resolve ( {
channelIds : [ ` timeline: ${ req . accountId } ` ] ,
options : { needsFiltering : false , notificationOnly : true } ,
} ) ;
2017-05-29 16:20:53 +00:00
break ;
case 'public' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2020-08-11 16:24:59 +00:00
resolve ( {
2020-05-26 21:41:47 +00:00
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote' ] : [ 'timeline:public' ] ,
2020-08-11 16:24:59 +00:00
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2022-07-03 05:54:03 +00:00
break ;
case 'public:nobot' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote:nobot' ] : [ 'timeline:public:nobot' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2020-06-08 16:34:51 +00:00
break ;
case 'public:local' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
2020-06-08 16:34:51 +00:00
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
break ;
case 'public:local:nobot' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public:nobot' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
2022-07-03 05:54:03 +00:00
reject ( 'No local stream provided' ) ;
}
2017-05-29 16:20:53 +00:00
break ;
2020-05-10 08:36:18 +00:00
case 'public:remote' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2020-08-11 16:24:59 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2022-07-03 05:54:03 +00:00
break ;
case 'public:remote:nobot' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote:nobot' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2019-11-13 22:42:56 +00:00
break ;
case 'public:domain' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2022-07-03 05:54:03 +00:00
break ;
case 'public:domain:nobot' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain:nobot: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2020-07-12 13:00:23 +00:00
break ;
case 'group' :
if ( ! params . id || params . id . length === 0 ) {
reject ( 'No group id for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:group: ${ params . id } ${ ! ! params . tagged && params . tagged . length !== 0 ? ` : ${ params . tagged . toLowerCase ( ) } ` : '' } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2020-05-10 08:36:18 +00:00
break ;
2018-05-21 10:43:38 +00:00
case 'public:media' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2020-08-11 16:24:59 +00:00
resolve ( {
2020-05-26 21:41:47 +00:00
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote:media' ] : [ 'timeline:public:media' ] ,
2020-08-11 16:24:59 +00:00
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2022-07-03 05:54:03 +00:00
break ;
case 'public:nobot:media' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote:nobot:media' ] : [ 'timeline:public:nobot:media' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2020-06-08 16:34:51 +00:00
break ;
case 'public:local:media' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public:media' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
reject ( 'No local stream provided' ) ;
2020-06-08 16:34:51 +00:00
}
2022-07-03 05:54:03 +00:00
break ;
case 'public:local:nobot:media' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public:nobot:media' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
reject ( 'No local stream provided' ) ;
2022-07-03 05:54:03 +00:00
}
2018-05-21 10:43:38 +00:00
break ;
2020-05-10 08:36:18 +00:00
case 'public:remote:media' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2020-08-11 16:24:59 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote:media' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2022-07-03 05:54:03 +00:00
break ;
case 'public:remote:nobot:media' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote:nobot:media' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2019-11-13 22:42:56 +00:00
break ;
case 'public:domain:media' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain:media: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2022-07-03 05:54:03 +00:00
break ;
case 'public:domain:nobot:media' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain:nobot:media: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
break ;
case 'public:nomedia' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote:nomedia' ] : [ 'timeline:public:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
break ;
case 'public:nobot:nomedia' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : req . applicationName === '◆ Tootdon ◆' ? [ 'timeline:public:remote:nobot:nomedia' ] : [ 'timeline:public:nobot:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
break ;
case 'public:local:nomedia' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
reject ( 'No local stream provided' ) ;
2022-07-03 05:54:03 +00:00
}
break ;
case 'public:local:nobot:nomedia' :
2022-08-23 05:14:23 +00:00
if ( ! req . accountId ) {
resolve ( {
channelIds : [ 'timeline:index' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else if ( isImast ( req ) || isMastodonForiOS ( req ) || isMastodonForAndroid ( req ) ) {
resolve ( {
channelIds : [ 'timeline:public:nobot:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
} else {
reject ( 'No local stream provided' ) ;
2022-07-03 05:54:03 +00:00
}
break ;
case 'public:remote:nomedia' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
break ;
case 'public:remote:nobot:nomedia' :
2022-12-25 10:32:02 +00:00
if ( ! isEnableFederatedTimeline ( req ) ) {
reject ( 'No local stream provided' ) ;
}
2022-07-03 05:54:03 +00:00
resolve ( {
channelIds : [ 'timeline:public:remote:nobot:nomedia' ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
break ;
case 'public:domain:nomedia' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain:nomedia: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
break ;
case 'public:domain:nobot:nomedia' :
if ( ! params . domain || params . domain . length === 0 ) {
reject ( 'No domain for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:public:domain:nobot:nomedia: ${ params . domain . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2020-07-12 13:00:23 +00:00
break ;
case 'group:media' :
if ( ! params . id || params . id . length === 0 ) {
reject ( 'No group id for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:group:media: ${ params . id } ${ ! ! params . tagged && params . tagged . length !== 0 ? ` : ${ params . tagged . toLowerCase ( ) } ` : '' } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2022-07-03 05:54:03 +00:00
break ;
case 'group:nomedia' :
if ( ! params . id || params . id . length === 0 ) {
reject ( 'No group id for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:group:nomedia: ${ params . id } ${ ! ! params . tagged && params . tagged . length !== 0 ? ` : ${ params . tagged . toLowerCase ( ) } ` : '' } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
}
2020-05-10 08:36:18 +00:00
break ;
2018-04-18 11:09:06 +00:00
case 'direct' :
2020-08-11 16:24:59 +00:00
resolve ( {
channelIds : [ ` timeline:direct: ${ req . accountId } ` ] ,
options : { needsFiltering : false , notificationOnly : false } ,
} ) ;
2018-04-18 11:09:06 +00:00
break ;
2017-05-29 16:20:53 +00:00
case 'hashtag' :
2020-08-11 16:24:59 +00:00
if ( ! params . tag || params . tag . length === 0 ) {
reject ( 'No tag for stream provided' ) ;
} else {
resolve ( {
channelIds : [ ` timeline:hashtag: ${ params . tag . toLowerCase ( ) } ` ] ,
options : { needsFiltering : true , notificationOnly : false } ,
} ) ;
2018-10-11 17:24:43 +00:00
}
2017-05-29 16:20:53 +00:00
break ;
2017-11-17 23:16:48 +00:00
case 'list' :
2020-08-11 16:24:59 +00:00
authorizeListAccess ( params . list , req ) . then ( ( ) => {
resolve ( {
channelIds : [ ` timeline:list: ${ params . list } ` ] ,
options : { needsFiltering : false , notificationOnly : false } ,
} ) ;
} ) . catch ( ( ) => {
reject ( 'Not authorized to stream this list' ) ;
2017-11-17 23:16:48 +00:00
} ) ;
2020-08-11 16:24:59 +00:00
2017-11-17 23:16:48 +00:00
break ;
2017-05-29 16:20:53 +00:00
default :
2020-08-11 16:24:59 +00:00
reject ( 'Unknown stream type' ) ;
}
} ) ;
/ * *
* @ param { string } channelName
* @ param { StreamParams } params
* @ return { string [ ] }
* /
const streamNameFromChannelName = ( channelName , params ) => {
if ( channelName === 'list' ) {
return [ channelName , params . list ] ;
2019-08-15 14:45:25 +00:00
} else if ( channelName === 'hashtag' ) {
2020-08-11 16:24:59 +00:00
return [ channelName , params . tag ] ;
2022-07-03 05:54:03 +00:00
} else if ( channelName . startsWith ( 'public:domain' ) ) {
2019-11-13 22:42:56 +00:00
return [ channelName , params . domain ] ;
2022-07-03 05:54:03 +00:00
} else if ( channelName . startsWith ( 'group' ) ) {
2020-07-12 13:00:23 +00:00
return [ channelName , params . id , params . tagged ] ;
2020-08-11 16:24:59 +00:00
} else {
return [ channelName ] ;
}
} ;
/ * *
* @ typedef WebSocketSession
* @ property { any } socket
* @ property { any } request
* @ property { Object . < string , { listener : function ( string ) : void , stopHeartbeat : function ( ) : void } > } subscriptions
* /
/ * *
* @ param { WebSocketSession } session
* @ param { string } channelName
* @ param { StreamParams } params
* /
const subscribeWebsocketToChannel = ( { socket , request , subscriptions } , channelName , params ) =>
checkScopes ( request , channelName ) . then ( ( ) => channelNameToIds ( request , channelName , params ) ) . then ( ( { channelIds , options } ) => {
if ( subscriptions [ channelIds . join ( ';' ) ] ) {
return ;
}
const onSend = streamToWs ( request , socket , streamNameFromChannelName ( channelName , params ) ) ;
const stopHeartbeat = subscriptionHeartbeat ( channelIds ) ;
const listener = streamFrom ( channelIds , request , onSend , undefined , options . needsFiltering , options . notificationOnly ) ;
subscriptions [ channelIds . join ( ';' ) ] = {
listener ,
stopHeartbeat ,
} ;
} ) . catch ( err => {
log . verbose ( request . requestId , 'Subscription error:' , err . toString ( ) ) ;
socket . send ( JSON . stringify ( { error : err . toString ( ) } ) ) ;
} ) ;
/ * *
* @ param { WebSocketSession } session
* @ param { string } channelName
* @ param { StreamParams } params
* /
const unsubscribeWebsocketFromChannel = ( { socket , request , subscriptions } , channelName , params ) =>
channelNameToIds ( request , channelName , params ) . then ( ( { channelIds } ) => {
log . verbose ( request . requestId , ` Ending stream from ${ channelIds . join ( ', ' ) } for ${ request . accountId } ` ) ;
2020-08-12 13:36:07 +00:00
const subscription = subscriptions [ channelIds . join ( ';' ) ] ;
2020-08-11 16:24:59 +00:00
2020-08-12 13:36:07 +00:00
if ( ! subscription ) {
2020-08-11 16:24:59 +00:00
return ;
}
2020-08-12 13:36:07 +00:00
const { listener , stopHeartbeat } = subscription ;
2020-08-11 16:24:59 +00:00
channelIds . forEach ( channelId => {
unsubscribe ( ` ${ redisPrefix } ${ channelId } ` , listener ) ;
} ) ;
stopHeartbeat ( ) ;
2020-08-12 13:36:07 +00:00
delete subscriptions [ channelIds . join ( ';' ) ] ;
2020-08-11 16:24:59 +00:00
} ) . catch ( err => {
log . verbose ( request . requestId , 'Unsubscription error:' , err ) ;
socket . send ( JSON . stringify ( { error : err . toString ( ) } ) ) ;
} ) ;
2020-11-12 22:05:24 +00:00
/ * *
* @ param { WebSocketSession } session
* /
const subscribeWebsocketToSystemChannel = ( { socket , request , subscriptions } ) => {
const systemChannelId = ` timeline:access_token: ${ request . accessTokenId } ` ;
const listener = createSystemMessageListener ( request , {
onKill ( ) {
socket . close ( ) ;
} ,
} ) ;
subscribe ( ` ${ redisPrefix } ${ systemChannelId } ` , listener ) ;
subscriptions [ systemChannelId ] = {
listener ,
stopHeartbeat : ( ) => { } ,
} ;
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { string | string [ ] } arrayOrString
* @ return { string }
* /
const firstParam = arrayOrString => {
if ( Array . isArray ( arrayOrString ) ) {
return arrayOrString [ 0 ] ;
} else {
return arrayOrString ;
}
} ;
wss . on ( 'connection' , ( ws , req ) => {
const location = url . parse ( req . url , true ) ;
req . requestId = uuid . v4 ( ) ;
req . remoteAddress = ws . _socket . remoteAddress ;
2021-03-24 08:37:41 +00:00
ws . isAlive = true ;
ws . on ( 'pong' , ( ) => {
ws . isAlive = true ;
} ) ;
2020-08-11 16:24:59 +00:00
/ * *
* @ type { WebSocketSession }
* /
const session = {
socket : ws ,
request : req ,
subscriptions : { } ,
} ;
const onEnd = ( ) => {
const keys = Object . keys ( session . subscriptions ) ;
keys . forEach ( channelIds => {
const { listener , stopHeartbeat } = session . subscriptions [ channelIds ] ;
channelIds . split ( ';' ) . forEach ( channelId => {
unsubscribe ( ` ${ redisPrefix } ${ channelId } ` , listener ) ;
} ) ;
stopHeartbeat ( ) ;
} ) ;
} ;
ws . on ( 'close' , onEnd ) ;
ws . on ( 'error' , onEnd ) ;
ws . on ( 'message' , data => {
2020-09-22 13:30:41 +00:00
const json = parseJSON ( data ) ;
2020-11-12 22:05:24 +00:00
2020-09-22 13:30:41 +00:00
if ( ! json ) return ;
2020-11-12 22:05:24 +00:00
2020-09-22 13:30:41 +00:00
const { type , stream , ... params } = json ;
2020-08-11 16:24:59 +00:00
if ( type === 'subscribe' ) {
subscribeWebsocketToChannel ( session , firstParam ( stream ) , params ) ;
} else if ( type === 'unsubscribe' ) {
2020-11-23 16:35:14 +00:00
unsubscribeWebsocketFromChannel ( session , firstParam ( stream ) , params ) ;
2020-08-11 16:24:59 +00:00
} else {
// Unknown action type
}
} ) ;
2020-11-12 22:05:24 +00:00
subscribeWebsocketToSystemChannel ( session ) ;
2020-08-11 16:24:59 +00:00
if ( location . query . stream ) {
subscribeWebsocketToChannel ( session , firstParam ( location . query . stream ) , location . query ) ;
2017-05-29 16:20:53 +00:00
}
2017-05-20 15:31:47 +00:00
} ) ;
2017-02-03 23:34:31 +00:00
2021-03-24 08:37:41 +00:00
setInterval ( ( ) => {
2023-02-09 04:41:12 +00:00
let count = 0 ;
2021-03-24 08:37:41 +00:00
wss . clients . forEach ( ws => {
if ( ws . isAlive === false ) {
ws . terminate ( ) ;
return ;
}
2023-02-09 04:41:12 +00:00
count ++ ;
2021-03-24 08:37:41 +00:00
ws . isAlive = false ;
2021-05-02 12:30:26 +00:00
ws . ping ( '' , false ) ;
2021-03-24 08:37:41 +00:00
} ) ;
2023-02-09 04:41:12 +00:00
stats = { ... stats , connectionCounts : count } ;
2021-03-24 08:37:41 +00:00
} , 30000 ) ;
2017-05-28 14:25:26 +00:00
2018-10-20 00:25:25 +00:00
attachServerWithConfig ( server , address => {
2021-05-01 21:19:18 +00:00
log . warn ( ` Worker ${ workerId } now listening on ${ address } ` ) ;
2018-10-20 00:25:25 +00:00
} ) ;
2017-04-21 17:24:31 +00:00
2017-05-28 14:25:26 +00:00
const onExit = ( ) => {
2021-05-01 21:19:18 +00:00
log . warn ( ` Worker ${ workerId } exiting ` ) ;
2017-05-20 15:31:47 +00:00
server . close ( ) ;
2017-07-07 18:01:00 +00:00
process . exit ( 0 ) ;
2017-05-28 14:25:26 +00:00
} ;
const onError = ( err ) => {
log . error ( err ) ;
2017-12-12 19:19:33 +00:00
server . close ( ) ;
process . exit ( 0 ) ;
2017-05-28 14:25:26 +00:00
} ;
process . on ( 'SIGINT' , onExit ) ;
process . on ( 'SIGTERM' , onExit ) ;
process . on ( 'exit' , onExit ) ;
2017-12-12 19:19:33 +00:00
process . on ( 'uncaughtException' , onError ) ;
2017-05-28 14:25:26 +00:00
} ;
2020-06-08 16:34:51 +00:00
/ * *
* @ param { any } req
* @ return { boolean }
* /
2022-12-25 10:32:02 +00:00
const isEnableFederatedTimeline = ( req ) => {
return req . enableFederatedTimeline ;
} ;
/ * *
* @ param { any } req
* @ return { boolean }
* /
const isImast = ( req ) => {
2020-06-08 16:34:51 +00:00
return req . website == 'https://cinderella-project.github.io/iMast/' ;
} ;
/ * *
* @ param { any } req
* @ return { boolean }
* /
const isMastodonForiOS = ( req ) => {
return req . applicationName == 'Mastodon for iOS' ;
} ;
/ * *
* @ param { any } req
* @ return { boolean }
* /
const isMastodonForAndroid = ( req ) => {
return req . applicationName == 'Mastodon for Android' ;
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { any } server
* @ param { function ( string ) : void } [ onSuccess ]
* /
2018-10-20 00:25:25 +00:00
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 ) {
2018-10-21 14:41:33 +00:00
fs . chmodSync ( server . address ( ) , 0o666 ) ;
2018-10-20 00:25:25 +00:00
onSuccess ( server . address ( ) ) ;
}
} ) ;
} else {
2019-07-15 03:56:35 +00:00
server . listen ( + process . env . PORT || 4000 , process . env . BIND || '127.0.0.1' , ( ) => {
2018-10-20 00:25:25 +00:00
if ( onSuccess ) {
onSuccess ( ` ${ server . address ( ) . address } : ${ server . address ( ) . port } ` ) ;
}
} ) ;
}
} ;
2020-08-11 16:24:59 +00:00
/ * *
* @ param { function ( Error = ) : void } onSuccess
* /
2018-10-20 00:25:25 +00:00
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 ,
} ) ;
2017-05-28 14:25:26 +00:00
} ) ;