2020-08-18 16:29:59 +00:00
;;;; Handling media uploading / downloading
( in-package :whatsxmpp )
2020-09-01 19:01:02 +00:00
( defun squonch-image-to-jpeg-thumbnail ( opticl-image )
"Resize the provided OPTICL-IMAGE to a small 640x480 thumbnail and return an octet vector of JPEG data for this thumbnail."
( check-type opticl-image opticl:image )
( let* ( ( image-out-stream ( flexi-streams:make-in-memory-output-stream ) )
( resized-image ( opticl:resize-image opticl-image 480 640 ) )
( useless ( opticl:write-jpeg-stream image-out-stream resized-image ) ) ; squonch
( image-thumbnail ( flexi-streams:get-output-stream-sequence image-out-stream ) ) )
( declare ( ignore useless ) )
( concatenate ' ( vector ( unsigned-byte 8 ) ) image-thumbnail ) ) )
( defun maybe-upload-whatsapp-media ( conn media-url )
" If the media at MEDIA-URL can be sent natively as a WhatsApp upload, download it and re-host it on WhatsApp.
Returns a promise that resolves with either a STRING ( if the media could not be rehosted or is ineligible ) or WHATSCL:MESSAGE-CONTENTS-IMAGE ( if the media pointed to is an image and it 's been successfully re-uploaded ) . "
( check-type media-url string )
( let ( ( opticl-function ( opticl::get-image-stream-reader ( pathname-type media-url ) ) ) )
( if opticl-function
( attach
( download-remote-media media-url )
( lambda ( media-data )
( let* ( ( image-stream ( flexi-streams:make-in-memory-input-stream media-data ) )
( image-mime ( or ( trivial-mimes:mime-lookup media-url )
( error "Couldn't guess image MIME type for ~A" media-url ) ) )
( parsed-image ( funcall opticl-function image-stream ) )
( squonched-image ( squonch-image-to-jpeg-thumbnail parsed-image ) ) )
( opticl:with-image-bounds ( image-y image-x ) parsed-image
( attach
( put-whatsapp-media-file conn media-data :image image-mime )
( lambda ( file-info )
( make-instance 'whatscl::message-contents-image
:file-info file-info
:width-px image-x
:height-px image-y
:jpeg-thumbnail squonched-image ) ) ) ) ) ) )
( promisify media-url ) ) ) )
( defun download-remote-media ( media-url )
"Returns a promise that downloads the remote MEDIA-URL and resolves with an octet vector of the downloaded data."
;; FIXME FIXME FIXME: this function is a trivial DoS vector, if you provide an infinite file like time.gif,
;; or a file that's like 1GB.
( check-type media-url string )
( with-promise-from-thread ( )
( format *debug-io* "~&downloading remote media: ~A~%" media-url )
( multiple-value-bind ( response status-code )
( drakma:http-request media-url
:force-binary t )
( unless ( eql status-code 200 )
( format *error-output* "~&downloading failed! status ~A~%" status-code )
( error "Remote media download failed with status code ~A~~%%" status-code ) )
( check-type response ( simple-array ( unsigned-byte 8 ) ) )
( format *debug-io* "~&downloaded ~A (length: ~A)~%" media-url ( length response ) )
response ) ) )
2020-08-18 16:29:59 +00:00
( defun put-whatsapp-media-file ( conn file-data media-type mime-type )
"Encrypts and uploads FILE-DATA (an octet vector), a WhatsApp media file of type MEDIA-TYPE (one of :IMAGE, :VIDEO, :AUDIO, or :DOCUMENT) to WhatsApp, returning a promise that resolves with a WHATSCL:FILE-INFO when done."
( check-type file-data ( simple-array ( unsigned-byte 8 ) ) )
( check-type media-type ( member :image :video :audio :document ) )
( attach
( with-promise ( resolve reject )
( format *debug-io* "~&requesting WhatsApp upload slot~%" )
( whatscl::start-media-upload
conn
( lambda ( conn auth-token ttl hosts )
( declare ( ignore conn ) )
( if auth-token
( resolve auth-token ttl hosts )
( reject ( make-condition 'error
"WhatsApp upload slot request rejected" ) ) ) ) ) )
( lambda ( auth-token ttl hosts )
( declare ( ignore ttl ) )
( with-promise-from-thread ( )
( multiple-value-bind ( encrypted-blob media-key file-sha256 file-enc-sha256 )
( whatscl::encrypt-media-data file-data media-type )
( let* ( ( token ( qbase64:encode-bytes file-enc-sha256 :scheme :uri ) )
( url-to-use ( format nil "https://~A/mms/~(~A~)/~A"
( first hosts ) ( symbol-name media-type ) token ) )
( headers ` ( ( "Origin" . "https://web.whatsapp.com" )
( "Referer" . "https://web.whatsapp.com" ) ) )
( qs-params ` ( ( "auth" . , auth-token ) ( "token" . , token ) ) ) )
( format *debug-io* "~&uploading encrypted media file (length ~A) to ~A"
( length encrypted-blob ) url-to-use )
( multiple-value-bind ( response status-code )
( drakma:http-request url-to-use
:method :post
:content encrypted-blob
:content-type "application/octet-stream"
:parameters qs-params
:additional-headers headers )
( let ( ( response ( babel:octets-to-string response ) ) )
( unless ( eql status-code 200 )
( format *debug-io* "~&whatsapp upload failed! status ~A / ~A" status-code response )
( error "Downloading media failed with status ~A / ~A" status-code response ) )
( let* ( ( json-response ( cl-json:decode-json-from-string response ) )
( url ( or ( whatscl::cassoc :url json-response )
( error "No :URL field in upload response ~A" json-response ) ) ) )
( format *debug-io* "~&got whatsapp uploaded media url ~A~%" url )
( make-instance 'whatscl::file-info
:media-key media-key
2020-09-01 19:01:02 +00:00
:url ( pb:string-field url )
2020-08-18 16:29:59 +00:00
:sha256 file-sha256
:enc-sha256 file-enc-sha256
:length-bytes ( length encrypted-blob )
2020-09-01 19:01:02 +00:00
:mime-type ( pb:string-field mime-type ) ) ) ) ) ) ) ) ) ) )
2020-08-18 16:29:59 +00:00
( defun upload-whatsapp-media-file ( comp file-info media-type &optional filename )
" Downloads the WhatsApp media file specified by FILE-INFO, uploads it via COMP, and returns a promise which resolves to the URL of the uploaded media.
MEDIA-TYPE is one of ( :image :video :audio :document ) . "
( declare ( type ( member :image :video :audio :document ) media-type ) )
( with-component-data-lock ( comp )
( with-accessors ( ( url whatscl::file-info-url )
( mime-type whatscl::file-info-mime-type )
( sha256 whatscl::file-info-sha256 )
( enc-sha256 whatscl::file-info-enc-sha256 )
( length-bytes whatscl::file-info-length-bytes )
( media-key whatscl::file-info-media-key ) )
file-info
( let* ( ( mime-type ( first ( uiop:split-string mime-type :separator ";" ) ) )
( extension ( or ( mimes:mime-file-type mime-type ) "what" ) )
( filename ( or filename
( concatenate 'string ( octets-to-lowercase-hex sha256 ) "." extension ) ) ) )
( format *debug-io* "~&requesting an upload slot for whatsapp media (type ~A, length ~A): ~A~%" mime-type length-bytes filename )
( attach
( request-http-upload-slot comp ( component-upload-component-name comp )
filename length-bytes mime-type )
( lambda ( slot )
( destructuring-bind ( ( put-url . headers ) get-url ) slot
( format *debug-io* "~&got put-url: ~A~% get-url: ~A~%" put-url get-url )
( with-promise-from-thread ( )
( format *debug-io* "~&fetching whatsapp media url: ~A~%" url )
( multiple-value-bind ( file-data status-code )
( drakma:http-request url )
( unless ( eql status-code 200 )
( format *debug-io* "~&couldn't fetch whatsapp media! status ~A, body ~A~%" status-code file-data )
( error "Downloading media failed with status ~A" status-code ) )
( format *debug-io* "~&got ~A bytes, decrypting~%" ( length file-data ) )
( let ( ( sha256-expected ( ironclad:digest-sequence :sha256 file-data ) )
( decrypted-file ( whatscl::decrypt-media-data media-key file-data media-type ) ) )
( unless ( equalp enc-sha256 sha256-expected )
( error "Encrypted SHA256 mismatch" ) )
( multiple-value-bind ( body status-code )
( drakma:http-request put-url
:additional-headers headers
:content-length ( length decrypted-file )
:content-type mime-type
:method :put
:content decrypted-file )
( unless ( and ( >= status-code 200 ) ( < status-code 300 ) )
( format *debug-io* "~&upload failed! status ~A, body ~A~%" status-code body )
( error "HTTP upload failed with status ~A" status-code ) )
get-url ) ) ) ) ) ) ) ) ) ) )
( defun send-qrcode ( comp jid text )
"Send a QR code containing TEXT to JID."
( with-component-data-lock ( comp )
( uiop:with-temporary-file ( :stream stream
:pathname path
:keep t ) ; Needed because async
( format *debug-io* "~&using path ~A~%" path )
( cl-qrencode:encode-png-stream text stream )
( force-output stream ) ; otherwise the QR codes get chopped off?
( catcher
( let ( ( content-length ( file-length stream ) ) )
( attach
( request-http-upload-slot comp ( component-upload-component-name comp )
"qrcode.png"
( file-length stream )
"image/png" )
( lambda ( slot )
2020-09-01 19:01:02 +00:00
( with-promise-from-thread ( )
( destructuring-bind ( ( put-url . headers ) get-url ) slot
( format *debug-io* "~&got put-url: ~A~% get-url: ~A~%" put-url get-url )
( multiple-value-bind ( body status-code )
( drakma:http-request put-url
:additional-headers headers
:content-type "image/png"
:content-length content-length
:method :put
:content path )
( unless ( and ( >= status-code 200 ) ( < status-code 300 ) )
( format *debug-io* "~&upload failed! status ~A, body ~A~%" status-code body )
( error "HTTP upload failed with status ~A" status-code ) )
( with-component-data-lock ( comp )
( let ( ( ajid ( admin-jid comp ) ) )
( admin-msg comp jid "WhatsApp Web registration: Scan the following QR code with your device! (Menu -> WhatsApp Web)" )
( with-message ( comp jid :from ajid )
( cxml:with-element "body"
( cxml:text get-url ) )
( cxml:with-element "x"
( cxml:attribute "xmlns" +oob-ns+ )
( cxml:with-element "url"
( cxml:text get-url ) ) ) )
( admin-msg comp jid "(Code expired? Be faster next time. Get a new one with `connect`.)" ) ) ) ) ) ) ) ) )
2020-08-18 16:29:59 +00:00
( t ( e )
( admin-msg comp jid ( format nil "Failed to upload QR code!~%Report the following error to the bridge admin: `~A`" e ) ) ) ) ) ) )