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 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
|
|
|
@ -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
|
|
Loading…
Reference in a new issue