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,118 +1,115 @@
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
# Fetch meta (only fetches on first mount; subsequent calls return the same state)
origMeta = hooks.useFetchContent props.id
# 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 name: name
mime: mime mime: mime
length: parseInt resp.headers.get 'content-length' length: origMeta.length
meta = hooks.useAsyncMemo null, decryptMeta, [origMeta, credentials]
componentWillUnmount: -> # Handle decryption
if @state.downloaded decryptFile = (file) ->
URL.revokeObjectURL @state.downloaded 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]
downloadFile: => # Handle file downloads
@setState # We don't need to share logic via hooks with CodeViewer
downloading: true # because this is a whole new fetch session that CodeViewer
decrypting: false # never shares.
progress: 0 [_, progress, beginXHR] = hooks.useXhrProgress()
# For progress, we have to use XHR downloadFile = ->
xhr = new XMLHttpRequest() 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) =>
@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: ->
<div className="content-pastebin">{ <div className="content-pastebin">{
if not @state.name if not meta
<p>Loading...</p> <p>Loading...</p>
else else
<div className="content-file-info"> <div className="content-file-info">
<p>{@state.name}</p> <p>{meta.name}</p>
<p>{@state.mime}</p> <p>{meta.mime}</p>
<p>{util.humanFileSize @state.length}</p> <p>{util.humanFileSize meta.length}</p>
{ {
if not @state.downloaded if not downloaded
<button <button
className="button-blue" className="button-blue"
disabled={@state.downloading} disabled={downloading}
onClick={@downloadFile} onClick={downloadFile}
>{ >{
if not @state.downloading if not downloading
"Download" "Download"
else if @state.decrypting else if decrypting
"Decrypting" "Decrypting"
else else
util.progressText @state.progress util.progressText progress
}</button> }</button>
else else
# Use an actual link here instead of triggering click # Use an actual link here instead of triggering click
# on a hidden link, because on some browsers it doesn't work # on a hidden link, because on some browsers it doesn't work
<a <a
className="button-blue" className="button-blue"
href={@state.downloaded} href={downloaded}
download={@state.name} download={meta.name}
> >
Save File Save File
</a> </a>
}{ }{
# In-browser previewing for certain file types # In-browser previewing for certain file types
# we can't just use this for all because it cannot handle file name # we can't just use this for all because it cannot handle file name
@state.downloaded and util.shouldShowInline(@state.mime) and downloaded and util.shouldShowInline(meta.mime) and
<a <a
className="button-blue" className="button-blue"
href={@state.downloaded} href={downloaded}
target="_blank" target="_blank"
> >
Preview Preview
@ -128,5 +125,3 @@ class FileDecrypter extends React.Component
</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