From 0f6b4e06f1f79e741e09867acd0beee9a18e5ba3 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Thu, 20 Feb 2020 12:41:20 +0800 Subject: [PATCH] fileDecrypter: refactor using Hooks also refactored useFetchContent to use useAsyncMemo --- src/web/fileDecrypter.coffee | 225 +++++++++++++++++------------------ src/web/hooks.coffee | 57 +++++++-- 2 files changed, 154 insertions(+), 128 deletions(-) diff --git a/src/web/fileDecrypter.coffee b/src/web/fileDecrypter.coffee index 5f816be..305842b 100644 --- a/src/web/fileDecrypter.coffee +++ b/src/web/fileDecrypter.coffee @@ -1,132 +1,127 @@ -import React from "react" +import React, { useState, useEffect, useCallback, useMemo } from "react" import LinkButton from "./util/linkButton" +import * as hooks from "./hooks" import * as crypto from "../crypto" import * as util from "../util" -class FileDecrypter extends React.Component - constructor: (props) -> - super props - @originalUrl = "/paste/#{props.id}?original" - # We simply let it fail if there's no key / iv provided in window.location.hash - # also we don't care about decryption failure. Just let it look like a broken page - # if someone tries to brute-force +export default FileDecrypter = (props) -> + [downloading, setDownloading] = useState false + [decrypting, setDecrypting] = useState false + [downloaded, setDownloaded] = useState null + + # Fetch credentials from location once + fetchCredentials = -> [key, iv] = window.location.hash.replace("#", "").split '+' - @state = - name: null - mime: null - length: null - downloading: false - decrypting: false - progress: 0 + return key: key iv: iv - downloaded: null + credentials = useMemo fetchCredentials, [] - componentDidMount: -> - # Fetch metadata to show to user - # We can use fetch API here - resp = await fetch @originalUrl - # Fail silently as explained above - return if not resp.ok - mime = resp.headers.get 'content-type' - [_, name] = resp.headers.get 'content-disposition' - .split 'filename*=' - [name, mime] = await crypto.decryptMetadata @state.key, @state.iv, name, mime - @setState - name: name - mime: mime - length: parseInt resp.headers.get 'content-length' + # Handle object URL revocation before unmount + # (though this will be fired every time `downloaded` changes, + # but that only changes when we finish downloading, and + # also the registered clean-up will be run after the final unmount) + # (it is necessary to bind to "downloaded" otherwise the closure + # will hold stale references) + urlRevokeHandler = -> + return -> + URL.revokeObjectURL downloaded if downloaded + useEffect urlRevokeHandler, [downloaded] - componentWillUnmount: -> - if @state.downloaded - URL.revokeObjectURL @state.downloaded + # Fetch meta (only fetches on first mount; subsequent calls return the same state) + origMeta = hooks.useFetchContent props.id - downloadFile: => - @setState - downloading: true - decrypting: false - progress: 0 - # For progress, we have to use XHR - xhr = new XMLHttpRequest() + # Create decrypted metadata + decryptMeta = -> + if (not origMeta) or (not credentials) + return null + else + [name, mime] = await crypto.decryptMetadata credentials.key, credentials.iv, + origMeta.name, origMeta.mime + return + name: name + mime: mime + length: origMeta.length + meta = hooks.useAsyncMemo null, decryptMeta, [origMeta, credentials] + + # Handle decryption + decryptFile = (file) -> + setDecrypting true + decrypted = await crypto.decryptFile credentials.key, credentials.iv, file + blob = new Blob [decrypted], + type: meta.mime + setDownloaded URL.createObjectURL blob + decryptFile = useCallback decryptFile, [credentials, meta] + + # Handle file downloads + # We don't need to share logic via hooks with CodeViewer + # because this is a whole new fetch session that CodeViewer + # never shares. + [_, progress, beginXHR] = hooks.useXhrProgress() + downloadFile = -> + setDownloading true + xhr = beginXHR() xhr.responseType = "arraybuffer" - xhr.addEventListener 'progress', (e) => - if e.lengthComputable - @setState - progress: e.loaded / e.total xhr.addEventListener 'readystatechange', => if xhr.readyState == XMLHttpRequest.DONE if xhr.status == 200 - await @decryptFile xhr.response - @setState - downloading: false - xhr.open 'GET', @originalUrl + await decryptFile xhr.response + setDownloading false + xhr.open 'GET', "/paste/#{props.id}?original" xhr.send() + downloadFile = useCallback downloadFile, [meta, decryptFile] - decryptFile: (file) => - @setState - decrypting: true - decrypted = await crypto.decryptFile @state.key, @state.iv, file - blob = new Blob [decrypted], - type: @state.mime - @setState - decrypting: false - blob: blob - downloaded: URL.createObjectURL blob - - render: -> -
{ - if not @state.name -

Loading...

- else -
-

{@state.name}

-

{@state.mime}

-

{util.humanFileSize @state.length}

- { - if not @state.downloaded - - else - # Use an actual link here instead of triggering click - # on a hidden link, because on some browsers it doesn't work - - Save File - - }{ - # In-browser previewing for certain file types - # we can't just use this for all because it cannot handle file name - @state.downloaded and util.shouldShowInline(@state.mime) and - - Preview - - } -
- +
{ + if not meta +

Loading...

+ else +
+

{meta.name}

+

{meta.mime}

+

{util.humanFileSize meta.length}

+ { + if not downloaded + + else + # Use an actual link here instead of triggering click + # on a hidden link, because on some browsers it doesn't work + + Save File + + }{ + # In-browser previewing for certain file types + # we can't just use this for all because it cannot handle file name + downloaded and util.shouldShowInline(meta.mime) and + + Preview + + } +
+ Home - -
- }
- -export default FileDecrypter \ No newline at end of file +
+
+ }
\ No newline at end of file diff --git a/src/web/hooks.coffee b/src/web/hooks.coffee index 25b484c..60d723f 100644 --- a/src/web/hooks.coffee +++ b/src/web/hooks.coffee @@ -50,21 +50,39 @@ export useDialog = -> useCallback(renderDialog, [dialogOpen, dialogMsg]) ] +# A hook to automatically store XHR progress in states +export useXhrProgress = -> + [progressUp, setProgressUp] = useState 0 + [progressDown, setProgressDown] = useState 0 + + progressHandler = (update) -> (e) -> + update e.loaded / e.total if e.lengthComputable + + beginXHR = -> + setProgressUp 0 + setProgressDown 0 + xhr = new XMLHttpRequest() + xhr.addEventListener "progress", progressHandler setProgressDown + xhr.upload.addEventListener "progress", progressHandler setProgressUp + xhr + + [ + progressUp, + progressDown, + useCallback beginXHR, [] + ] + # Handles shared file-uploading logic between text / binary pasting export usePaste = (openDialog, callback) -> [pasting, setPasting] = useState false - [progress, setProgress] = useState 0 + [progress, _, beginXHR] = useXhrProgress() doPaste = (name, mime, content, transformUrl) -> # Unfortunately we have to all resort to using XHR here - setProgress 0 setPasting true # Build the XHR - xhr = new XMLHttpRequest() - xhr.upload.addEventListener "progress", (e) -> - if e.lengthComputable - setProgress e.loaded / e.total + xhr = beginXHR() xhr.addEventListener "readystatechange", -> if xhr.readyState == XMLHttpRequest.DONE setPasting false @@ -87,11 +105,27 @@ export usePaste = (openDialog, callback) -> [ # our paste only depends on *setting* states, no reading required # but all the callback it reads from its closure may change - useCallback(doPaste, [openDialog, callback]), + useCallback(doPaste, [openDialog, callback, beginXHR]), pasting, progress ] +# Asynchronous useMemo +# defVal is the value before the factory function completes +# returns the current value, and if factory has not completed +# then return defVal. +# Factory is only executed once +export useAsyncMemo = (defVal, factory, deps) -> + [state, setState] = useState defVal + + exec = -> + do -> + setState await factory() + return + + useEffect exec, deps + state + # An effect that fetches the original pasted content, # and then fires a callback that handles metadata and the response body # it also stores the meta into a state and returns it every time @@ -99,8 +133,6 @@ export usePaste = (openDialog, callback) -> # and if callback is not present, then the response body # would simply be thrown away export useFetchContent = (id, callback) -> - [meta, setMeta] = useState null - doFetch = -> resp = await fetch "/paste/#{id}?original" length = resp.headers.get 'content-length' @@ -111,11 +143,10 @@ export useFetchContent = (id, callback) -> name: name mime: mime length: length - setMeta newMeta # We have to pass newMeta to callback because # the callback will not be aware of the meta update callback newMeta, resp if callback + return newMeta - # Run the effect once on mount - useEffect doFetch, [] - meta \ No newline at end of file + # Use async memo + useAsyncMemo null, doFetch, [] \ No newline at end of file