Skip to content

Commit

Permalink
Introduced a simple way to work with files inside frontend
Browse files Browse the repository at this point in the history
The usecase of this code is something like this: let image that user is making a
protonmail or any another application where content inside frontend (=browser
storage) some secret user's data that shouldn't be exposed to backend. Secret
key for example or anything like that.

This code allows to developer to convert any `Array[Byte]` from frontend to URL
as simple call `FileService.asURL`, create an anchor to download it as
`FileService.asAnchor` and asynchronously convert any uploaded `File` to
`Array[Byte]` as `FileService.asBytesArray`, or to `InputStream` via
`FileService.asInputStream`.

Unfortunately scalatags doesn't support `download` attribute and I need to make
it by hand. I've opened a PR: com-lihaoyi/scalatags#212 to
introduce it, but it might be a while until it is included to release.

`FileService.asInputStream` is using `FileReaderSync` that is also missed inside
scala-js-dom. I've opened a PR:
scala-js/scala-js-dom#424 but it might be a while. Also,
this is draft API but it is supported by majority of modern browsers:
https://developer.mozilla.org/en-US/docs/Web/API/FileReaderSync#Browser_Compatibility
  • Loading branch information
catap committed Sep 30, 2020
1 parent a834c72 commit 93b95be
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 2 deletions.
115 changes: 115 additions & 0 deletions core/.js/src/main/scala/io/udash/utils/FileService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package io.udash.utils

import java.io.{IOException, InputStream}

import org.scalajs.dom._
import org.scalajs.dom.html.Anchor
import org.scalajs.dom.raw.Blob
import scalatags.JsDom

import scala.scalajs.js
import scala.concurrent.{Future, Promise}
import scala.scalajs.js.annotation.JSGlobal
import scala.scalajs.js.typedarray.ArrayBuffer
import scala.util.Try

@js.native
@JSGlobal
sealed class FileReaderSync() extends js.Object {
def readAsArrayBuffer(blob: Blob): ArrayBuffer = js.native
}

sealed class FileBufferedInputStream(val file: File) extends InputStream {
val fileReaderSync = new FileReaderSync()

var filePos: Int = 0
var pos: Int = 0

var buffer: Array[Byte] = Array.empty

override def read(): Int = {
if (pos >= buffer.length) {
import js.typedarray._

if (filePos >= file.size) {
return -1
}

val len = math.min(filePos + 1024, file.size.toInt)
val slice = file.slice(filePos, len)
buffer = new Int8Array(fileReaderSync.readAsArrayBuffer(slice)).toArray
filePos += buffer.length
pos = 0
}
val r = buffer(pos).toInt & 0xff
pos += 1
r
}
}

object FileService {

final val OctetStreamType = "application/octet-stream"

/**
* Converts specified bytes array to string that contains URL
* that representing the array given in the parameter with optionally specified mime-type.
*
* Keep in mind that returned URL should be revoked via `org.scalajs.dom.revokeObjectURL(url)`.
*/
def asURL(bytes: Array[Byte], mimeType: String = OctetStreamType): String = {
import js.typedarray._

val jsBytes = js.Array[js.Any](bytes.toTypedArray)
val blob = new Blob(jsBytes, BlobPropertyBag(mimeType))
URL.createObjectURL(blob)
}

/**
* Create an anchor element that on click downloads byte array as a file with specified name.
*
* Keep in mind that anchor's href URL should be revoked via `org.scalajs.dom.revokeObjectURL(url)`.
*/
def asAnchor(filename: String, bytes: Array[Byte], mimeType: String = OctetStreamType): JsDom.TypedTag[Anchor] = {
import JsDom.all._

val download = attr("download")
a(href := asURL(bytes, mimeType), download := filename)
}

/**
* Asynchronously convert specified file to bytes array.
*/
def asBytesArray(file: File): Future[Array[Byte]] = {
import js.typedarray._

val fileReader = new FileReader()
val promise = Promise[Array[Byte]]()

fileReader.onerror = (e: Event) =>
promise.failure(new IOException(e.toString))

fileReader.onabort = (e: Event) =>
promise.failure(new IOException(e.toString))

fileReader.onload = (_: UIEvent) =>
promise.complete(Try(
new Int8Array(fileReader.result.asInstanceOf[ArrayBuffer]).toArray
))

fileReader.readAsArrayBuffer(file)

promise.future
}

/**
* Convert specified file to InputStream with blocking I/O
*
* Because it is using synchronous I/O that could potentially this API can be used only inside worker.
*
* This method is using FileReaderSync that is part of Working Draft File API.
* Anyway it is supported for majority of modern browsers
*/
def asInputStream(file: File): InputStream =
new FileBufferedInputStream(file)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class FrontendFilesView extends View {
),
p("You can find a working demo application in the ", a(href := References.UdashFilesDemoRepo, target := "_blank")("Udash Demos"), " repositiory."),
h3("Frontend forms"),
p(i("FileService"), " is an object that allows to convert ", i("Array[Byte]")," to URL, save it as file from frontend ",
" and asynchronously convert ", i("File"), " to ", i("Array[Byte]"), "."),
p(i("FileInput"), " is the file HTML input wrapper providing a property containing selected files. "),
fileInputSnippet,
p("Take a look at the following live demo:"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.udash.web.guide.views.frontend.demos

import io.udash.css.CssView
import io.udash.utils.FileService
import io.udash.web.guide.demos.AutoDemo
import io.udash.web.guide.styles.partials.GuideStyles
import scalatags.JsDom.all._
Expand All @@ -9,8 +10,11 @@ object FileInputDemo extends AutoDemo with CssView {

private val (rendered, source) = {
import io.udash._
import org.scalajs.dom.File
import org.scalajs.dom.{File, URL}
import scalatags.JsDom.all._
import org.scalajs.dom.window

import scala.concurrent.ExecutionContext.Implicits.global

val acceptMultipleFiles = Property(true)
val selectedFiles = SeqProperty.blank[File]
Expand All @@ -19,7 +23,27 @@ object FileInputDemo extends AutoDemo with CssView {
FileInput(selectedFiles, acceptMultipleFiles)("files"),
h4("Selected files"),
ul(repeat(selectedFiles)(file => {
li(file.get.name).render
val content = Property(Array.empty[Byte])

window.setTimeout(() =>
FileService.asBytesArray(file.get) foreach { bytes =>
content.set(bytes)
}, 3000)

val name = file.get.name
li(showIfElse(content.transform(_.isEmpty))(
span(name).render,
{
val anchor = FileService.asAnchor(name, content.get)(name).render

window.setTimeout(() => {
content.set(Array.empty[Byte])
URL.revokeObjectURL(anchor.href)
}, 10000)

anchor
}
)).render
}))
)
}.withSourceCode
Expand Down

0 comments on commit 93b95be

Please sign in to comment.