fileDecrypter: refactor using Hooks
also refactored useFetchContent to use useAsyncMemo
This commit is contained in:
parent
17d6add3dc
commit
0f6b4e06f1
|
@ -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>
|
|
@ -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, []
|
Loading…
Reference in New Issue