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 * 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: ->
<div className="content-pastebin">{
if not @state.name
<p>Loading...</p>
else
<div className="content-file-info">
<p>{@state.name}</p>
<p>{@state.mime}</p>
<p>{util.humanFileSize @state.length}</p>
{
if not @state.downloaded
<button
className="button-blue"
disabled={@state.downloading}
onClick={@downloadFile}
>{
if not @state.downloading
"Download"
else if @state.decrypting
"Decrypting"
else
util.progressText @state.progress
}</button>
else
# Use an actual link here instead of triggering click
# on a hidden link, because on some browsers it doesn't work
<a
className="button-blue"
href={@state.downloaded}
download={@state.name}
>
Save File
</a>
}{
# 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
<a
className="button-blue"
href={@state.downloaded}
target="_blank"
>
Preview
</a>
}
<br/>
<LinkButton
className="button-blue"
push
to="/paste/text"
>
<div className="content-pastebin">{
if not meta
<p>Loading...</p>
else
<div className="content-file-info">
<p>{meta.name}</p>
<p>{meta.mime}</p>
<p>{util.humanFileSize meta.length}</p>
{
if not downloaded
<button
className="button-blue"
disabled={downloading}
onClick={downloadFile}
>{
if not downloading
"Download"
else if decrypting
"Decrypting"
else
util.progressText progress
}</button>
else
# Use an actual link here instead of triggering click
# on a hidden link, because on some browsers it doesn't work
<a
className="button-blue"
href={downloaded}
download={meta.name}
>
Save File
</a>
}{
# 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
<a
className="button-blue"
href={downloaded}
target="_blank"
>
Preview
</a>
}
<br/>
<LinkButton
className="button-blue"
push
to="/paste/text"
>
Home
</LinkButton>
</div>
}</div>
export default FileDecrypter
</LinkButton>
</div>
}</div>

View File

@ -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
# Use async memo
useAsyncMemo null, doFetch, []