fileDecrypter: refactor using Hooks

also refactored useFetchContent to use useAsyncMemo
This commit is contained in:
Peter Cai 2020-02-20 12:41:20 +08:00
parent 17d6add3dc
commit 0f6b4e06f1
No known key found for this signature in database
GPG key ID: 71F5FB4E4F3FD54F
2 changed files with 154 additions and 128 deletions

View file

@ -1,132 +1,127 @@
import React from "react" import React, { useState, useEffect, useCallback, useMemo } from "react"
import LinkButton from "./util/linkButton" import LinkButton from "./util/linkButton"
import * as hooks from "./hooks"
import * as crypto from "../crypto" import * as crypto from "../crypto"
import * as util from "../util" import * as util from "../util"
class FileDecrypter extends React.Component export default FileDecrypter = (props) ->
constructor: (props) -> [downloading, setDownloading] = useState false
super props [decrypting, setDecrypting] = useState false
@originalUrl = "/paste/#{props.id}?original" [downloaded, setDownloaded] = useState null
# 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 # Fetch credentials from location once
# if someone tries to brute-force fetchCredentials = ->
[key, iv] = window.location.hash.replace("#", "").split '+' [key, iv] = window.location.hash.replace("#", "").split '+'
@state = return
name: null
mime: null
length: null
downloading: false
decrypting: false
progress: 0
key: key key: key
iv: iv iv: iv
downloaded: null credentials = useMemo fetchCredentials, []
componentDidMount: -> # Handle object URL revocation before unmount
# Fetch metadata to show to user # (though this will be fired every time `downloaded` changes,
# We can use fetch API here # but that only changes when we finish downloading, and
resp = await fetch @originalUrl # also the registered clean-up will be run after the final unmount)
# Fail silently as explained above # (it is necessary to bind to "downloaded" otherwise the closure
return if not resp.ok # will hold stale references)
mime = resp.headers.get 'content-type' urlRevokeHandler = ->
[_, name] = resp.headers.get 'content-disposition' return ->
.split 'filename*=' URL.revokeObjectURL downloaded if downloaded
[name, mime] = await crypto.decryptMetadata @state.key, @state.iv, name, mime useEffect urlRevokeHandler, [downloaded]
@setState
name: name
mime: mime
length: parseInt resp.headers.get 'content-length'
componentWillUnmount: -> # Fetch meta (only fetches on first mount; subsequent calls return the same state)
if @state.downloaded origMeta = hooks.useFetchContent props.id
URL.revokeObjectURL @state.downloaded
downloadFile: => # Create decrypted metadata
@setState decryptMeta = ->
downloading: true if (not origMeta) or (not credentials)
decrypting: false return null
progress: 0 else
# For progress, we have to use XHR [name, mime] = await crypto.decryptMetadata credentials.key, credentials.iv,
xhr = new XMLHttpRequest() 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.responseType = "arraybuffer"
xhr.addEventListener 'progress', (e) =>
if e.lengthComputable
@setState
progress: e.loaded / e.total
xhr.addEventListener 'readystatechange', => xhr.addEventListener 'readystatechange', =>
if xhr.readyState == XMLHttpRequest.DONE if xhr.readyState == XMLHttpRequest.DONE
if xhr.status == 200 if xhr.status == 200
await @decryptFile xhr.response await decryptFile xhr.response
@setState setDownloading false
downloading: false xhr.open 'GET', "/paste/#{props.id}?original"
xhr.open 'GET', @originalUrl
xhr.send() xhr.send()
downloadFile = useCallback downloadFile, [meta, decryptFile]
decryptFile: (file) => <div className="content-pastebin">{
@setState if not meta
decrypting: true <p>Loading...</p>
decrypted = await crypto.decryptFile @state.key, @state.iv, file else
blob = new Blob [decrypted], <div className="content-file-info">
type: @state.mime <p>{meta.name}</p>
@setState <p>{meta.mime}</p>
decrypting: false <p>{util.humanFileSize meta.length}</p>
blob: blob {
downloaded: URL.createObjectURL blob if not downloaded
<button
render: -> className="button-blue"
<div className="content-pastebin">{ disabled={downloading}
if not @state.name onClick={downloadFile}
<p>Loading...</p> >{
else if not downloading
<div className="content-file-info"> "Download"
<p>{@state.name}</p> else if decrypting
<p>{@state.mime}</p> "Decrypting"
<p>{util.humanFileSize @state.length}</p> else
{ util.progressText progress
if not @state.downloaded }</button>
<button else
className="button-blue" # Use an actual link here instead of triggering click
disabled={@state.downloading} # on a hidden link, because on some browsers it doesn't work
onClick={@downloadFile} <a
>{ className="button-blue"
if not @state.downloading href={downloaded}
"Download" download={meta.name}
else if @state.decrypting >
"Decrypting" Save File
else </a>
util.progressText @state.progress }{
}</button> # In-browser previewing for certain file types
else # we can't just use this for all because it cannot handle file name
# Use an actual link here instead of triggering click downloaded and util.shouldShowInline(meta.mime) and
# on a hidden link, because on some browsers it doesn't work <a
<a className="button-blue"
className="button-blue" href={downloaded}
href={@state.downloaded} target="_blank"
download={@state.name} >
> Preview
Save File </a>
</a> }
}{ <br/>
# In-browser previewing for certain file types <LinkButton
# we can't just use this for all because it cannot handle file name className="button-blue"
@state.downloaded and util.shouldShowInline(@state.mime) and push
<a to="/paste/text"
className="button-blue" >
href={@state.downloaded}
target="_blank"
>
Preview
</a>
}
<br/>
<LinkButton
className="button-blue"
push
to="/paste/text"
>
Home Home
</LinkButton> </LinkButton>
</div> </div>
}</div> }</div>
export default FileDecrypter

View file

@ -50,21 +50,39 @@ export useDialog = ->
useCallback(renderDialog, [dialogOpen, dialogMsg]) 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 # Handles shared file-uploading logic between text / binary pasting
export usePaste = (openDialog, callback) -> export usePaste = (openDialog, callback) ->
[pasting, setPasting] = useState false [pasting, setPasting] = useState false
[progress, setProgress] = useState 0 [progress, _, beginXHR] = useXhrProgress()
doPaste = (name, mime, content, transformUrl) -> doPaste = (name, mime, content, transformUrl) ->
# Unfortunately we have to all resort to using XHR here # Unfortunately we have to all resort to using XHR here
setProgress 0
setPasting true setPasting true
# Build the XHR # Build the XHR
xhr = new XMLHttpRequest() xhr = beginXHR()
xhr.upload.addEventListener "progress", (e) ->
if e.lengthComputable
setProgress e.loaded / e.total
xhr.addEventListener "readystatechange", -> xhr.addEventListener "readystatechange", ->
if xhr.readyState == XMLHttpRequest.DONE if xhr.readyState == XMLHttpRequest.DONE
setPasting false setPasting false
@ -87,11 +105,27 @@ export usePaste = (openDialog, callback) ->
[ [
# our paste only depends on *setting* states, no reading required # our paste only depends on *setting* states, no reading required
# but all the callback it reads from its closure may change # but all the callback it reads from its closure may change
useCallback(doPaste, [openDialog, callback]), useCallback(doPaste, [openDialog, callback, beginXHR]),
pasting, pasting,
progress 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, # An effect that fetches the original pasted content,
# and then fires a callback that handles metadata and the response body # 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 # 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 # and if callback is not present, then the response body
# would simply be thrown away # would simply be thrown away
export useFetchContent = (id, callback) -> export useFetchContent = (id, callback) ->
[meta, setMeta] = useState null
doFetch = -> doFetch = ->
resp = await fetch "/paste/#{id}?original" resp = await fetch "/paste/#{id}?original"
length = resp.headers.get 'content-length' length = resp.headers.get 'content-length'
@ -111,11 +143,10 @@ export useFetchContent = (id, callback) ->
name: name name: name
mime: mime mime: mime
length: length length: length
setMeta newMeta
# We have to pass newMeta to callback because # We have to pass newMeta to callback because
# the callback will not be aware of the meta update # the callback will not be aware of the meta update
callback newMeta, resp if callback callback newMeta, resp if callback
return newMeta
# Run the effect once on mount # Use async memo
useEffect doFetch, [] useAsyncMemo null, doFetch, []
meta