;;;; Handling media uploading / downloading (in-package :whatsxmpp) (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))) (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 :url (pb:string-field url) :sha256 file-sha256 :enc-sha256 file-enc-sha256 :length-bytes (length encrypted-blob) :mime-type (pb:string-field mime-type))))))))))) (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) (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`.)"))))))))) (t (e) (admin-msg comp jid (format nil "Failed to upload QR code!~%Report the following error to the bridge admin: `~A`" e)))))))