import * as crypto from 'crypto' ; import { S3 } from 'aws-sdk' ; import { NextFunction , Request , Response } from 'express' ; const PromiseRouter = require ( 'express-promise-router' ) ; import { getConfig } from '../config-helper' ; import { AWS } from './aws' ; import Model from './model' ; import getLeaderboard from './model/leaderboard' ; import { earnBonus , hasEarnedBonus } from './model/achievements' ; import * as Basket from './basket' ; import * as Sentry from '@sentry/node' ; import Bucket from './bucket' ; import { ClientParameterError , ServerError } from './utility' ; import Awards from './model/awards' ; import { checkGoalsAfterContribution } from './model/goals' ; import { ChallengeToken , challengeTokens } from 'common' ; const Transcoder = require ( 'stream-transcoder' ) ; const { Converter } = require ( 'ffmpeg-stream' ) ; const { Readable } = require ( 'stream' ) ; enum ERRORS { MISSING_PARAM = 'MISSING_PARAM' , CLIP_NOT_FOUND = 'CLIP_NOT_FOUND' , SENTENCE_NOT_FOUND = 'SENTENCE_NOT_FOUND' , ALREADY_EXISTS = 'ALREADY_EXISTS' } /**
 * Clip - Responsibly for saving and serving clips.
 */ export default class Clip { private s3 : S3 ; private bucket : Bucket ; private model : Model ; constructor ( model ) { this . s3 = AWS . getS3 ( ) ; this . model = model ; this . bucket = new Bucket ( this . model , this . s3 ) ; } getRouter ( ) { const router = PromiseRouter ( { mergeParams : true } ) ; router . use ( ( { client_id , params } , response , next ) => { const { locale } = params ; if ( client_id && locale ) { this . model . db . saveActivity ( client_id , locale ) . catch ( ( error ) => console . error ( 'activity save error' , error ) ) ; } next ( ) ; } ) ; router . post ( '/:clipId/votes' , this . saveClipVote ) ; router . post ( '*' , this . saveClip ) ; router . get ( '/validated_hours' , this . serveValidatedHoursCount ) ; router . get ( '/daily_count' , this . serveDailyCount ) ; router . get ( '/stats' , this . serveClipsStats ) ; router . get ( '/leaderboard' , this . serveClipLeaderboard ) ; router . get ( '/votes/leaderboard' , this . serveVoteLeaderboard ) ; router . get ( '/voices' , this . serveVoicesStats ) ; router . get ( '/votes/daily_count' , this . serveDailyVotesCount ) ; router . get ( '/:clip_id' , this . serveClip ) ; router . get ( '*' , this . serveRandomClips ) ; return router ; } clipSaveError ( headers , response , status , msg , fingerprint , type ) { const compiledError = `save_${ type }_error: ${ fingerprint }: ${ msg }` ; response . status ( status ) . send ( compiledError ) ; Sentry . withScope ( scope => { scope . setFingerprint ( [ `save_${ type }_error` , fingerprint ] ) ; Sentry . captureEvent ( { request : { headers } , message : compiledError } ) ; } ) ; } serveClip = async ( { params } , response ) => { const url = await this . bucket . getClipUrl ( params . clip_id ) ; if ( url ) { response . redirect ( await this . bucket . getClipUrl ( params . clip_id ) ) ; } else { response . json ( { } ) ; } } ; saveClipVote = async ( { client_id , body , params , headers } , response ) => { const id = params . clipId as string ; const { isValid , challenge } = body ; if ( ! id || ! client_id ) { this . clipSaveError ( headers , response , 400 , `missing parameter: ${ id ? 'client_id' : 'clip_id' }` , ERRORS . MISSING_PARAM , 'vote' ) ; return ; } const clip = await this . model . db . findClip ( id ) ; if ( ! clip ) { this . clipSaveError ( headers , response , 422 , `clip not found: ${ id }` , ERRORS . CLIP_NOT_FOUND , 'vote' ) ; return ; } const glob = clip . path . replace ( '.mp3' , '' ) ; await this . model . db . saveVote ( id , client_id , isValid ) ; await Awards . checkProgress ( client_id , { id : clip . locale_id } ) ; await checkGoalsAfterContribution ( client_id , { id : clip . locale_id } ) ; Basket . sync ( client_id ) . catch ( e => console . error ( e ) ) ; const ret = challengeTokens . includes ( challenge ) ? { glob : glob , showFirstContributionToast : await earnBonus ( 'first_contribution' , [ challenge , client_id , ] ) , hasEarnedSessionToast : await hasEarnedBonus ( 'invite_contribute_same_session' , client_id , challenge ) , showFirstStreakToast : await earnBonus ( 'three_day_streak' , [ client_id , client_id , challenge , ] ) , challengeEnded : await this . model . db . hasChallengeEnded ( challenge ) , } : { glob } ; response . json ( ret ) ; } ; /**
     * Save the request body as an audio file.
     * Ensure errors from this function include the term save_clip_error
     * to be easily parsed from other errors
     */ saveClip = async ( request , response ) => { const { client_id , headers } = request ; const sentenceId = headers . sentence_id as string ; const source = headers . source || 'unidentified' ; const format = headers [ 'content-type' ] ; const size = headers [ 'content-length' ] ; if ( ! sentenceId || ! client_id ) { this . clipSaveError ( headers , response , 400 , `missing parameter: ${ sentenceId ? 'client_id' : 'sentence_id' }` , ERRORS . MISSING_PARAM , 'clip' ) ; return ; } const sentence = await this . model . db . findSentence ( sentenceId ) ; if ( ! sentence ) { this . clipSaveError ( headers , response , 422 , `sentence not found: ${ sentenceId }` , ERRORS . SENTENCE_NOT_FOUND , 'clip' ) ; return ; } const folder = client_id + '/' ; const filePrefix = sentenceId ; const clipFileName = folder + filePrefix + '.mp3' ; const metadata = `${ clipFileName } (${ size } bytes, ${ format }) from ${ source }` ; if ( await this . model . db . clipExists ( client_id , sentenceId ) ) { this . clipSaveError ( headers , response , 204 , `${ clipFileName } already exists` , ERRORS . ALREADY_EXISTS , 'clip' ) ; return ; } else { await this . s3 . putObject ( { Bucket : getConfig ( ) . CLIP_BUCKET_NAME , Key : folder } ) . promise ( ) ; let audioInput = request ; if ( getConfig ( ) . FLAG_BUFFER_STREAM_ENABLED && format . includes ( 'aac' ) ) { const converter = new Converter ( ) ; const audioStream = Readable . from ( request ) ; audioInput = converter . createBufferedInputStream ( ) ; audioStream . pipe ( audioInput ) ; } const audioOutput = new Transcoder ( audioInput ) . audioCodec ( 'mp3' ) . format ( 'mp3' ) . channels ( 1 ) . sampleRate ( 32000 ) . on ( 'error' , ( error ) => { this . clipSaveError ( headers , response , 500 , `${ error } for ${ metadata }` , `ffmpeg ${ error }` , 'clip' ) ; return ; } ) . on ( 'finish' , async ( ) => { console . log ( `clip written to s3 ${ metadata }` ) ; await this . model . saveClip ( { client_id : client_id , localeId : sentence . locale_id , original_sentence_id : sentenceId , path : clipFileName , sentence : sentence . text , } ) ; await Awards . checkProgress ( client_id , { id : sentence . locale_id } ) ; await checkGoalsAfterContribution ( client_id , { id : sentence . locale_id , } ) ; Basket . sync ( client_id ) . catch ( e => console . error ( e ) ) ; const challenge = headers . challenge as ChallengeToken ; const ret = challengeTokens . includes ( challenge ) ? { filePrefix : filePrefix , showFirstContributionToast : await earnBonus ( 'first_contribution' , [ challenge , client_id ] ) , hasEarnedSessionToast : await hasEarnedBonus ( 'invite_contribute_same_session' , client_id , challenge ) , showFirstStreakToast : await earnBonus ( 'three_day_streak' , [ client_id , client_id , challenge , ] ) , challengeEnded : await this . model . db . hasChallengeEnded ( challenge ) , } : { filePrefix } ; response . json ( ret ) ; } ) . stream ( ) ; await this . s3 . upload ( { Bucket : getConfig ( ) . CLIP_BUCKET_NAME , Key : clipFileName , Body : audioOutput , } ) . promise ( ) ; } } ; serveRandomClips = async ( { client_id , params , query } , response ) : Promise < void > => { const clips = await this . bucket . getRandomClips ( client_id , params . locale , parseInt ( query . count , 10 ) || 1 ) ; response . json ( clips ) ; } ; serveValidatedHoursCount = async ( request , response ) => { response . json ( await this . model . getValidatedHours ( ) ) ; } ; serveDailyCount = async ( request , response ) => { response . json ( await this . model . db . getDailyClipsCount ( request . params . locale ) ) ; } ; serveDailyVotesCount = async ( request , response ) => { response . json ( await this . model . db . getDailyVotesCount ( request . params . locale ) ) ; } ; serveClipsStats = async ( { params } , response ) => { response . json ( await this . model . getClipsStats ( params . locale ) ) ; } ; serveVoicesStats = async ( { params } , response ) => { response . json ( await this . model . getVoicesStats ( params . locale ) ) ; } ; serveClipLeaderboard = async ( { client_id , params , query } , response ) => { response . json ( await getLeaderboard ( { dashboard : 'stats' , type : 'clip' , client_id , cursor : query . cursor ? JSON . parse ( query . cursor ) : null , locale : params . locale , } ) ) ; } ; serveVoteLeaderboard = async ( { client_id , params , query } , response ) => { response . json ( await getLeaderboard ( { dashboard : 'stats' , type : 'vote' , client_id , cursor : query . cursor ? JSON . parse ( query . cursor ) : null , locale : params . locale , } ) ) ; } ; }