diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js index 9e50b82f5..df86086ed 100644 --- a/keyserver/src/keyserver.js +++ b/keyserver/src/keyserver.js @@ -1,340 +1,347 @@ // @flow import olm from '@commapp/olm'; import cluster from 'cluster'; import compression from 'compression'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import crypto from 'crypto'; import express from 'express'; import type { $Request, $Response } from 'express'; import expressWs from 'express-ws'; import os from 'os'; import qrcode from 'qrcode'; import stoppable from 'stoppable'; import './cron/cron.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; import { identityDeviceTypes } from 'lib/types/identity-service-types.js'; import { isDev } from 'lib/utils/dev-utils.js'; import sleep from 'lib/utils/sleep.js'; import { fetchDBVersion } from './database/db-version.js'; import { latestWrapInTransactionAndBlockRequestsVersion } from './database/migration-config.js'; import { migrate } from './database/migrations.js'; import { jsonEndpoints } from './endpoints.js'; import { logEndpointMetrics } from './middleware/endpoint-profiling.js'; import { emailSubscriptionResponder } from './responders/comm-landing-responders.js'; +import { taggedCommFarcasterResponder } from './responders/farcaster-webhook-responders.js'; import { jsonHandler, downloadHandler, htmlHandler, uploadHandler, + webhookPayloadHandler, } from './responders/handlers.js'; import landingHandler from './responders/landing-handler.js'; import { errorReportDownloadResponder } from './responders/report-responders.js'; import { inviteResponder, websiteResponder, } from './responders/website-responders.js'; import { webWorkerResponder } from './responders/webworker-responders.js'; import { onConnection } from './socket/socket.js'; import { createAndMaintainTunnelbrokerWebsocket } from './socket/tunnelbroker.js'; import { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, } from './uploads/uploads.js'; import { fetchIdentityInfo } from './user/identity.js'; import { initENSCache } from './utils/ens-cache.js'; import { initFCCache } from './utils/fc-cache.js'; import { setUpKeyserverWithServices } from './utils/keyserver-services-setup.js'; import { getContentSigningKey } from './utils/olm-utils.js'; import { isPrimaryNode, isSecondaryNode, } from './utils/primary-secondary-utils.js'; import { getRunServerConfig } from './utils/server-utils.js'; import { prefetchAllURLFacts, getKeyserverURLFacts, getLandingURLFacts, getWebAppURLFacts, getWebAppCorsConfig, } from './utils/urls.js'; const shouldDisplayQRCodeInTerminal = false; void (async () => { const [webAppCorsConfig] = await Promise.all([ getWebAppCorsConfig(), olm.init(), prefetchAllURLFacts(), initENSCache(), initFCCache(), ]); const keyserverURLFacts = getKeyserverURLFacts(); const keyserverBaseRoutePath = keyserverURLFacts?.baseRoutePath; const landingBaseRoutePath = getLandingURLFacts()?.baseRoutePath; const webAppURLFacts = getWebAppURLFacts(); const webAppBaseRoutePath = webAppURLFacts?.baseRoutePath; const compiledFolderOptions = process.env.NODE_ENV === 'development' ? undefined : { maxAge: '1y', immutable: true }; let keyserverCorsOptions = null; if (webAppCorsConfig) { keyserverCorsOptions = { origin: webAppCorsConfig.domain, methods: ['GET', 'POST'], }; } const isCPUProfilingEnabled = process.env.KEYSERVER_CPU_PROFILING_ENABLED; const areEndpointMetricsEnabled = process.env.KEYSERVER_ENDPOINT_METRICS_ENABLED; const listenAddress = (() => { if (process.env.COMM_LISTEN_ADDR) { return process.env.COMM_LISTEN_ADDR; } else if (process.env.NODE_ENV === 'development') { return undefined; } else { return 'localhost'; } })(); if (cluster.isMaster) { if (isPrimaryNode) { const healthCheckApp = express(); healthCheckApp.use(express.json({ limit: '250mb' })); healthCheckApp.get('/health', (req: $Request, res: $Response) => { res.send('OK'); }); // We use stoppable to allow forcibly stopping the health check server // on the master process so that non-master processes can successfully // initialize their express servers on the same port without conflict const healthCheckServer = stoppable( healthCheckApp.listen( parseInt(process.env.PORT, 10) || 3000, listenAddress, ), 0, ); const didMigrationsSucceed: boolean = await migrate(); if (!didMigrationsSucceed) { // The following line uses exit code 2 to ensure nodemon exits // in a dev environment, instead of restarting. Context provided // in https://github.com/remy/nodemon/issues/751 process.exit(2); } if (healthCheckServer) { await new Promise((resolve, reject) => { healthCheckServer.stop(err => { if (err) { reject(err); } else { resolve(); } }); }); } } if (shouldDisplayQRCodeInTerminal && isPrimaryNode) { try { const aes256Key = crypto.randomBytes(32).toString('hex'); const ed25519Key = await getContentSigningKey(); const [identityInfo] = await Promise.all([ fetchIdentityInfo(), createAndMaintainTunnelbrokerWebsocket(aes256Key), ]); if (!identityInfo) { console.log( '\nOpen the Comm app on your phone and scan the QR code below, or copy and paste this URL:\n', ); const url = qrCodeLinkURL( aes256Key, ed25519Key, identityDeviceTypes.KEYSERVER, ); console.log(url, '\n'); console.log('How to find the scanner:\n'); console.log('Go to \x1b[1mProfile\x1b[0m'); console.log('Select \x1b[1mLinked devices\x1b[0m'); console.log('Click \x1b[1mAdd\x1b[0m on the top right'); qrcode.toString(url, (error, encodedURL) => console.log(encodedURL)); } } catch (e) { console.log('Error generating QR code', e); } } else { await setUpKeyserverWithServices(); } if (!isCPUProfilingEnabled) { const cpuCount = os.cpus().length; for (let i = 0; i < cpuCount; i++) { cluster.fork(); } cluster.on('exit', () => cluster.fork()); } } if (!cluster.isMaster || isCPUProfilingEnabled) { const server = express(); server.use(compression()); expressWs(server); server.use(express.json({ limit: '250mb' })); server.use(cookieParser()); server.get('/health', (req: $Request, res: $Response) => { res.send('OK'); }); server.listen(parseInt(process.env.PORT, 10) || 3000, listenAddress); if (isSecondaryNode) { let dbVersion = await fetchDBVersion(); while (dbVersion < latestWrapInTransactionAndBlockRequestsVersion) { await sleep(5000); dbVersion = await fetchDBVersion(); } } // Note - the order of router declarations matters. On prod we have // keyserverBaseRoutePath configured to '/', which means it's a catch-all. // If we call server.use on keyserverRouter first, it will catch all // requests and prevent webAppRouter and landingRouter from working // correctly. So we make sure that keyserverRouter goes last const runServerConfig = await getRunServerConfig(); if (landingBaseRoutePath && runServerConfig.runLanding) { const landingRouter = express.Router<$Request, $Response>(); landingRouter.get('/invite/:secret', inviteResponder); landingRouter.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); landingRouter.use('/images', express.static('images')); landingRouter.use('/fonts', express.static('fonts')); landingRouter.use( '/compiled', express.static('landing_compiled', compiledFolderOptions), ); landingRouter.use('/', express.static('landing_icons')); landingRouter.post('/subscribe_email', emailSubscriptionResponder); landingRouter.get('*', landingHandler); server.use(landingBaseRoutePath, landingRouter); } if (webAppBaseRoutePath && runServerConfig.runWebApp) { const webAppRouter = express.Router<$Request, $Response>(); webAppRouter.use('/images', express.static('images')); webAppRouter.use('/fonts', express.static('fonts')); webAppRouter.use('/misc', express.static('misc')); webAppRouter.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); webAppRouter.use( '/compiled', express.static('app_compiled', compiledFolderOptions), ); webAppRouter.use('/', express.static('icons')); webAppRouter.get('/invite/:secret', inviteResponder); webAppRouter.get('/worker/:worker', webWorkerResponder); if (keyserverURLFacts) { webAppRouter.get( '/upload/:uploadID/:secret', (req: $Request, res: $Response) => { const { uploadID, secret } = req.params; const url = `${keyserverURLFacts.baseDomain}${keyserverURLFacts.basePath}upload/${uploadID}/${secret}`; res.redirect(url); }, ); } webAppRouter.get('*', htmlHandler(websiteResponder)); server.use(webAppBaseRoutePath, webAppRouter); } if (keyserverBaseRoutePath && runServerConfig.runKeyserver) { const keyserverRouter = express.Router<$Request, $Response>(); if (areEndpointMetricsEnabled) { keyserverRouter.use(logEndpointMetrics); } if (keyserverCorsOptions) { keyserverRouter.use(cors(keyserverCorsOptions)); } + keyserverRouter.post( + '/fc_comm_tagged', + webhookPayloadHandler(taggedCommFarcasterResponder), + ); + for (const endpoint in jsonEndpoints) { // $FlowFixMe Flow thinks endpoint is string const responder = jsonEndpoints[endpoint]; const expectCookieInvalidation = endpoint === 'log_out'; keyserverRouter.post( `/${endpoint}`, jsonHandler(responder, expectCookieInvalidation), ); } keyserverRouter.get( '/download_error_report/:reportID', downloadHandler(errorReportDownloadResponder), ); keyserverRouter.get( '/upload/:uploadID/:secret', downloadHandler(uploadDownloadResponder), ); // $FlowFixMe express-ws has side effects that can't be typed keyserverRouter.ws('/ws', onConnection); keyserverRouter.post( '/upload_multimedia', multerProcessor, uploadHandler(multimediaUploadResponder), ); server.use(keyserverBaseRoutePath, keyserverRouter); } if (isDev && webAppURLFacts) { const oldPath = '/comm/'; server.all(`${oldPath}*`, (req: $Request, res: $Response) => { const endpoint = req.url.slice(oldPath.length); const newURL = `${webAppURLFacts.baseDomain}${webAppURLFacts.basePath}${endpoint}`; res.redirect(newURL); }); } } })(); diff --git a/keyserver/src/responders/farcaster-webhook-responders.js b/keyserver/src/responders/farcaster-webhook-responders.js new file mode 100644 index 000000000..9040e7a28 --- /dev/null +++ b/keyserver/src/responders/farcaster-webhook-responders.js @@ -0,0 +1,20 @@ +// @flow + +import type { $Request } from 'express'; + +import { neynarWebhookCastCreatedEventValidator } from 'lib/types/validators/farcaster-webhook-validators.js'; +import { assertWithValidator } from 'lib/utils/validation-utils.js'; + +const taggedCommFarcasterInputValidator = + neynarWebhookCastCreatedEventValidator; + +async function taggedCommFarcasterResponder(request: $Request): Promise { + const event = assertWithValidator( + request.body, + taggedCommFarcasterInputValidator, + ); + + console.log(event); +} + +export { taggedCommFarcasterResponder, taggedCommFarcasterInputValidator }; diff --git a/keyserver/src/responders/handlers.js b/keyserver/src/responders/handlers.js index 6b8886f7d..384ec0e83 100644 --- a/keyserver/src/responders/handlers.js +++ b/keyserver/src/responders/handlers.js @@ -1,248 +1,271 @@ // @flow import type { $Response, $Request } from 'express'; import type { TType } from 'tcomb'; import type { Endpoint } from 'lib/types/endpoints.js'; import { ServerError } from 'lib/utils/errors.js'; import { assertWithValidator, tPlatformDetails, } from 'lib/utils/validation-utils.js'; import { getMessageForException } from './utils.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import type { PolicyType } from '../lib/facts/policies.js'; import { fetchViewerForJSONRequest, addCookieToJSONResponse, addCookieToHomeResponse, createNewAnonymousCookie, setCookiePlatformDetails, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { getAppURLFactsFromRequestURL } from '../utils/urls.js'; import { policiesValidator, validateInput, validateOutput, } from '../utils/validation-utils.js'; type InnerJSONResponder = { responder: (viewer: Viewer, input: any) => Promise<*>, requiredPolicies: $ReadOnlyArray, }; export opaque type JSONResponder: InnerJSONResponder = InnerJSONResponder; function createJSONResponder( responder: (Viewer, input: I) => Promise, inputValidator: TType, outputValidator: TType, requiredPolicies: $ReadOnlyArray, endpoint: Endpoint, ): JSONResponder { return { responder: async (viewer, input) => { const request = await validateInput( viewer, inputValidator, input, endpoint, ); const result = await responder(viewer, request); return await validateOutput( viewer.platformDetails, outputValidator, result, ); }, requiredPolicies, }; } export type DownloadResponder = ( viewer: Viewer, req: $Request, res: $Response, ) => Promise; export type HTTPGetResponder = DownloadResponder; export type HTMLResponder = (req: $Request, res: $Response) => Promise; function jsonHandler( responder: JSONResponder, expectCookieInvalidation: boolean, ): (req: $Request, res: $Response) => Promise { return async (req: $Request, res: $Response) => { let viewer; try { if (!req.body || typeof req.body !== 'object') { throw new ServerError('invalid_parameters'); } const { input, platformDetails } = req.body; viewer = await fetchViewerForJSONRequest(req); const promises = [policiesValidator(viewer, responder.requiredPolicies)]; if (platformDetails) { if (!tPlatformDetails.is(platformDetails)) { throw new ServerError('invalid_platform_details'); } promises.push( setCookiePlatformDetails( viewer, assertWithValidator(platformDetails, tPlatformDetails), ), ); } await Promise.all(promises); const responderResult = await responder.responder(viewer, input); if (res.headersSent) { return; } const result = { ...responderResult }; addCookieToJSONResponse(viewer, res, result, expectCookieInvalidation); res.json({ success: true, ...result }); } catch (e) { await handleException(e, res, viewer, expectCookieInvalidation); } }; } +type WebhookPayloadResponder = (request: $Request) => Promise; +function webhookPayloadHandler( + responder: WebhookPayloadResponder, +): (req: $Request, res: $Response) => Promise { + return async (req: $Request, res: $Response) => { + try { + if (!req.body || typeof req.body !== 'object') { + throw new ServerError('invalid_parameters'); + } + + const responderResult = await responder(req); + + if (res.headersSent) { + return; + } + res.json({ success: true, ...responderResult }); + } catch (e) { + await handleException(e, res); + } + }; +} + function httpGetHandler( responder: HTTPGetResponder, ): (req: $Request, res: $Response) => Promise { return async (req: $Request, res: $Response) => { let viewer; try { viewer = await fetchViewerForJSONRequest(req); await responder(viewer, req, res); } catch (e) { await handleException(e, res, viewer); } }; } function downloadHandler( responder: DownloadResponder, ): (req: $Request, res: $Response) => Promise { return async (req: $Request, res: $Response) => { try { const viewer = await fetchViewerForJSONRequest(req); await responder(viewer, req, res); } catch (e) { // Passing viewer in only makes sense if we want to handle failures as // JSON. We don't, and presume all download handlers avoid ServerError. await handleException(e, res); } }; } async function handleException( error: Error, res: $Response, viewer?: ?Viewer, expectCookieInvalidation?: boolean, ) { console.warn(error); if (res.headersSent) { return; } if (!(error instanceof ServerError)) { res.status(500).send(getMessageForException(error)); return; } const result: Object = error.payload ? { error: error.message, payload: error.payload } : { error: error.message }; if (viewer) { if (error.message === 'client_version_unsupported' && viewer.loggedIn) { // If the client version is unsupported, log the user out const { platformDetails } = error; const [data] = await Promise.all([ createNewAnonymousCookie({ platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(data); viewer.cookieInvalidated = true; } // This can mutate the result object addCookieToJSONResponse(viewer, res, result, !!expectCookieInvalidation); } res.json(result); } function htmlHandler( responder: HTMLResponder, ): (req: $Request, res: $Response) => Promise { return async (req: $Request, res: $Response) => { try { addCookieToHomeResponse( req, res, getAppURLFactsFromRequestURL(req.originalUrl), ); res.type('html'); await responder(req, res); } catch (e) { console.warn(e); if (!res.headersSent) { res.status(500).send(getMessageForException(e)); } } }; } type MulterFile = { fieldname: string, originalname: string, encoding: string, mimetype: string, buffer: Buffer, size: number, }; export type MulterRequest = $Request & { files?: $ReadOnlyArray, ... }; type UploadResponder = (viewer: Viewer, req: MulterRequest) => Promise; function uploadHandler( responder: UploadResponder, ): (req: $Request, res: $Response) => Promise { return async (req: $Request, res: $Response) => { let viewer; try { if (!req.body || typeof req.body !== 'object') { throw new ServerError('invalid_parameters'); } viewer = await fetchViewerForJSONRequest(req); const responderResult = await responder( viewer, ((req: any): MulterRequest), ); if (res.headersSent) { return; } const result = { ...responderResult }; addCookieToJSONResponse(viewer, res, result, false); res.json({ success: true, ...result }); } catch (e) { await handleException(e, res, viewer); } }; } export { createJSONResponder, jsonHandler, httpGetHandler, downloadHandler, htmlHandler, uploadHandler, + webhookPayloadHandler, }; diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js index 92505e579..81f416d8f 100644 --- a/lib/types/farcaster-types.js +++ b/lib/types/farcaster-types.js @@ -1,43 +1,78 @@ // @flow // This is a message that the rendered webpage // (landing/connect-farcaster.react.js) uses to communicate back // to the React Native WebView that is rendering it // (native/components/farcaster-web-view.react.js) export type FarcasterWebViewMessage = | { +type: 'farcaster_url', +url: string, } | { +type: 'farcaster_data', +fid: string, }; export type NeynarUser = { +fid: number, +username: string, +pfp_url: string, ... }; export type NeynarUserWithViewerContext = $ReadOnly<{ ...NeynarUser, +viewerContext: { +following: boolean, }, ... }>; export type NeynarChannel = { +id: string, +name: string, +follower_count: number, +lead: { +fid: number, ... }, +image_url: string, +description: string, ... }; + +export type NeynarWebhookCastAuthor = { + +object: 'user', + +fid: number, + +custody_address: string, + +username: string, + +display_name: string, + +pfp_url: string, + ... +}; + +export type NeynarWebhookCastCreatedData = { + +object: 'cast', + +hash: string, + +thread_hash: string, + +text: string, + +channel?: ?NeynarWebhookChannel, + +parent_hash?: ?string, + +author: NeynarWebhookCastAuthor, + ... +}; + +export type NeynarWebhookChannel = { + +id: string, + +name: string, + +image_url: string, + ... +}; + +export type NeynarWebhookCastCreatedEvent = { + +created_at: number, + +type: 'cast.created', + +data: NeynarWebhookCastCreatedData, + ... +}; diff --git a/lib/types/validators/farcaster-webhook-validators.js b/lib/types/validators/farcaster-webhook-validators.js new file mode 100644 index 000000000..9e95d403b --- /dev/null +++ b/lib/types/validators/farcaster-webhook-validators.js @@ -0,0 +1,45 @@ +// @flow + +import t, { type TInterface } from 'tcomb'; + +import type { + NeynarWebhookCastCreatedData, + NeynarWebhookChannel, + NeynarWebhookCastAuthor, + NeynarWebhookCastCreatedEvent, +} from '../farcaster-types.js'; + +export const neynarWebhookCastAuthorValidator: TInterface = + t.interface({ + object: t.enums.of(['user']), + fid: t.Number, + custody_address: t.String, + username: t.String, + display_name: t.String, + pfp_url: t.String, + }); + +export const neynarWebhookChannelValidator: TInterface = + t.interface({ + id: t.String, + name: t.String, + image_url: t.String, + }); + +export const neynarWebhookCastCreatedDataValidator: TInterface = + t.interface({ + object: t.enums.of(['cast']), + hash: t.String, + thread_hash: t.String, + text: t.String, + channel: t.maybe(neynarWebhookChannelValidator), + parent_hash: t.maybe(t.String), + author: neynarWebhookCastAuthorValidator, + }); + +export const neynarWebhookCastCreatedEventValidator: TInterface = + t.interface({ + created_at: t.Number, + type: t.enums.of(['cast.created']), + data: neynarWebhookCastCreatedDataValidator, + });