Skip to content

Commit

Permalink
FileService: a way to work with file at 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 `Seq[Array[Byte]]` from
frontend to URL as simple call `FileService.createURL`, asynchronously
convert any uploaded `File` to `Array[Byte]` as
`FileService.asBytesArray`, or to `InputStream` via
`FileService.asInputStream` inside worker.

Unfortunately scalatags doesn't support `download` attribute and I need
to make it by hand. I've opened a PR[^1] 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[^2] but it might be a
while. Also, this draft API but it is supported by majority of modern
browsers[^3].

[^1]: com-lihaoyi/scalatags#212

[^2]: scala-js/scala-js-dom#424

[^3]: https://caniuse.com/?search=FileReaderSync
  • Loading branch information
catap committed Dec 2, 2020
1 parent 6aacd3f commit 5e2b8ed
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 2 deletions.
154 changes: 154 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,154 @@
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
}

final class FileBufferedInputStream(file: File, bufferSize: Int) extends InputStream {
val fileReaderSync = new FileReaderSync()

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

var buffer: Array[Byte] = Array.empty

override def available(): Int = {
val res = file.size.toInt - filePos
if (res < 0) 0 else res
}

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

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

val len = math.min(filePos + bufferSize, 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
}

override def close(): Unit = filePos = file.size.toInt

override def markSupported(): Boolean = true

var markPos: Option[Int] = None

override def mark(readlimit: Int): Unit =
markPos = Some(pos + filePos - buffer.length)

override def reset(): Unit = markPos match {
case Some(p) =>
filePos = p
pos = 0
buffer = Array.empty
markPos = None

case _ =>
// do nothing
}
}

object FileService {

final val OctetStreamType = "application/octet-stream"

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

val jsBytesArrays = js.Array[js.Any](bytesArrays.map(_.toTypedArray) :_ *)
val blob = new Blob(jsBytesArrays, BlobPropertyBag(mimeType))
URL.createObjectURL(blob)
}

/**
* Converts specified bytes arrays to string that contains URL
* that representing the array given in the parameter with `application/octet-stream` mime-type.
*
* Keep in mind that returned URL should be revoked via `org.scalajs.dom.revokeObjectURL(url)`.
*/
def createURL(bytesArrays: Seq[Array[Byte]]): String =
createURL(bytesArrays, OctetStreamType)

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

/**
* Converts specified bytes array to string that contains URL
* that representing the array given in the parameter with `application/octet-stream` mime-type.
*
* Keep in mind that returned URL should be revoked via `org.scalajs.dom.revokeObjectURL(url)`.
*/
def createURL(byteArray: Array[Byte]): String =
createURL(Seq(byteArray), OctetStreamType)

/**
* 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, bufferSize: Int = 1024): InputStream =
new FileBufferedInputStream(file, bufferSize)
}
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,26 @@ 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])

FileService.asBytesArray(file.get) foreach { bytes =>
content.set(bytes)
}

val name = file.get.name
li(showIfElse(content.transform(_.isEmpty))(
span(name).render,
{
val url = FileService.createURL(content.get)
val download = a(href := url, attr("download") := name)(name)
val revoke = a(onclick := { () =>
content.set(Array.empty[Byte])
URL.revokeObjectURL(url)
})("revoke")

Seq(download, revoke).render
}
)).render
}))
)
}.withSourceCode
Expand Down

0 comments on commit 5e2b8ed

Please sign in to comment.