node.jsdropbox

Upload stream to Dropbox session upload


I have a form that accepts a file to pass on to Dropbox using the session upload. I've wrapped the code in a Fastify server but always end up with an error in append:

{"error_summary": "closed/..", "error": {".tag": "closed"}}.

Here's the Fastify server:

require("dotenv").config()
const https = require("https")
const JSONbig = require("json-bigint")

const TOKEN = process.env.DROPBOX_ACCESS_TOKEN

const fastify = require("fastify")({
  logger: {
    level: "info"
  }
})

fastify.addContentTypeParser("*", function (req, done) {
  done()
})

// Promisify https request
const httpRequest = (url, options, data) => {
  return new Promise((resolve, reject) => {
    const req = https.request(url, options, (res) => {
      let body = ""
      res.on("data", (chunk) => {
        console.log("SDSDSDSDASDASDSAD")
        body += chunk.toString()
      })
      res.on("error", reject)
      res.on("end", () => {
        if (res.statusCode >= 200 && res.statusCode <= 299) {
          resolve({ statusCode: res.statusCode, headers: res.headers, body: JSON.parse(body) })
        } else {
          reject("Request failed. status: " + res.statusCode + ", body: " + body)
        }
      })
    })
    req.on("error", reject)
    if (data) req.write(data)
    req.end()
  })
}

fastify.register(require("fastify-cors"))

fastify.post("/", async (req, reply) => {
  const stream = req.raw

  const initResponse = await httpRequest(
    "https://content.dropboxapi.com/2/files/upload_session/start",
    {
      method: "post",

      headers: {
        Authorization: `Bearer ${TOKEN}`,
        "Dropbox-API-Arg": JSON.stringify({
          close: false
        }),
        "Content-Type": "application/octet-stream"
      }
    }
  )

  const sessionId = initResponse.body.session_id

  req.log.info(`Initiating session ${sessionId}`)

  let offset = 0

  stream.on("data", async (chunk) => {
    req.log.info("Sending chunk")
    stream.pause()

    const appendResponse = await httpRequest(
      `https://content.dropboxapi.com/2/files/upload_session/append_v2`,

      {
        method: "post",
        headers: {
          Authorization: `Bearer ${TOKEN}`,
          "Dropbox-API-Arg": JSONbig.stringify({
            cursor: {
              session_id: sessionId,
              offset
            },
            close: false
          }),
          "Content-Type": "application/octet-stream"
        }
      },
      chunk
    )

    stream.resume()
    offset += chunk.length
  })
  stream.on("end", async () => {
    const terminateResponse = await httpRequest(
      `https://content.dropboxapi.com/2/files/upload_session/finish`,

      {
        method: "post",
        headers: {
          Authorization: `Bearer ${TOKEN}`,
          "Dropbox-API-Arg": JSONbig.stringify({
            cursor: {
              session_id: sessionId,
              offset: offset
            },
            commit: {
              path: "/Upload/test-large.txt",
              mode: "add",
              autorename: true,
              mute: false,
              strict_conflict: false
            }
          }),
          "Content-Type": "application/octet-stream"
        }
      }
    )

    req.log.info("upload session finished")

    reply.code(204).send()
  })
})

fastify.listen(3000, (err) => {
  if (err) {
    fastify.log.fatal(err)
    process.exit(1)
  }
  console.log(`server listening on ${fastify.server.address().port}`)
})

And here's the form:

<!DOCTYPE html>
<!-- Coding By CodingNepal - youtube.com/codingnepal -->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>File Upload with Progress Bar | CodingNepal</title>
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
    />
    <style>
      /* Import Google font - Poppins */
      @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap");
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
        font-family: "Poppins", sans-serif;
      }
      body {
        display: flex;
        align-items: center;
        justify-content: center;
        min-height: 100vh;
        background: #6990f2;
      }

      ::selection {
        color: #fff;
        background: #6990f2;
      }
      .wrapper {
        width: 430px;
        background: #fff;
        border-radius: 5px;
        padding: 30px;
        box-shadow: 7px 7px 12px rgba(0, 0, 0, 0.05);
      }
      .wrapper header {
        color: #6990f2;
        font-size: 27px;
        font-weight: 600;
        text-align: center;
      }
      .wrapper form {
        height: 167px;
        display: flex;
        cursor: pointer;
        margin: 30px 0;
        align-items: center;
        justify-content: center;
        flex-direction: column;
        border-radius: 5px;
        border: 2px dashed #6990f2;
      }
      form :where(i, p) {
        color: #6990f2;
      }
      form i {
        font-size: 50px;
      }
      form p {
        margin-top: 15px;
        font-size: 16px;
      }

      section .row {
        margin-bottom: 10px;
        background: #e9f0ff;
        list-style: none;
        padding: 15px 20px;
        border-radius: 5px;
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
      section .row i {
        color: #6990f2;
        font-size: 30px;
      }
      section .details span {
        font-size: 14px;
      }
      .progress-area .row .content {
        width: 100%;
        margin-left: 15px;
      }
      .progress-area .details {
        display: flex;
        align-items: center;
        margin-bottom: 7px;
        justify-content: space-between;
      }
      .progress-area .content .progress-bar {
        height: 6px;
        width: 100%;
        margin-bottom: 4px;
        background: #fff;
        border-radius: 30px;
      }
      .content .progress-bar .progress {
        height: 100%;
        width: 0%;
        background: #6990f2;
        border-radius: inherit;
      }
      .uploaded-area {
        max-height: 232px;
        overflow-y: scroll;
      }
      .uploaded-area.onprogress {
        max-height: 150px;
      }
      .uploaded-area::-webkit-scrollbar {
        width: 0px;
      }
      .uploaded-area .row .content {
        display: flex;
        align-items: center;
      }
      .uploaded-area .row .details {
        display: flex;
        margin-left: 15px;
        flex-direction: column;
      }
      .uploaded-area .row .details .size {
        color: #404040;
        font-size: 11px;
      }
      .uploaded-area i.fa-check {
        font-size: 16px;
      }
    </style>
  </head>
  <body>
    <div class="wrapper">
      <header>File Uploader JavaScript</header>
      <form action="#">
        <input class="file-input" type="file" name="file" hidden />
        <i class="fas fa-cloud-upload-alt"></i>
        <p>Browse File to Upload</p>
      </form>
      <section class="progress-area"></section>
      <section class="uploaded-area"></section>
    </div>
    <script>
      const form = document.querySelector("form"),
        fileInput = document.querySelector(".file-input"),
        progressArea = document.querySelector(".progress-area"),
        uploadedArea = document.querySelector(".uploaded-area")

      // form click event
      form.addEventListener("click", () => {
        fileInput.click()
      })

      fileInput.onchange = ({ target }) => {
        let file = target.files[0] //getting file [0] this means if user has selected multiple files then get first one only
        if (file) {
          let fileName = file.name //getting file name
          if (fileName.length >= 12) {
            //if file name length is greater than 12 then split it and add ...
            let splitName = fileName.split(".")
            fileName = splitName[0].substring(0, 13) + "... ." + splitName[1]
          }
          uploadFile(fileName) //calling uploadFile with passing file name as an argument
        }
      }

      // file upload function
      function uploadFile(name) {
        let xhr = new XMLHttpRequest() //creating new xhr object (AJAX)
        xhr.open("POST", "http://localhost:3000") //sending post request to the specified URL
        xhr.upload.addEventListener("progress", ({ loaded, total }) => {
          //file uploading progress event
          let fileLoaded = Math.floor((loaded / total) * 100) //getting percentage of loaded file size
          let fileTotal = Math.floor(total / 1000) // getting total file size in KB from bytes
          let fileSize
          // if file size is less than 1024 then add only KB else convert this KB into MB
          fileTotal < 1024
            ? (fileSize = fileTotal + " KB")
            : (fileSize = (loaded / (1024 * 1024)).toFixed(2) + " MB")
          let progressHTML = `<li class="row">
                            <i class="fas fa-file-alt"></i>
                            <div class="content">
                              <div class="details">
                                <span class="name">${name} • Uploading</span>
                                <span class="percent">${fileLoaded}%</span>
                              </div>
                              <div class="progress-bar">
                                <div class="progress" style="width: ${fileLoaded}%"></div>
                              </div>
                            </div>
                          </li>`
          // uploadedArea.innerHTML = ""; //uncomment this line if you don't want to show upload history
          uploadedArea.classList.add("onprogress")
          progressArea.innerHTML = progressHTML
          if (loaded == total) {
            progressArea.innerHTML = ""
            let uploadedHTML = `<li class="row">
                              <div class="content upload">
                                <i class="fas fa-file-alt"></i>
                                <div class="details">
                                  <span class="name">${name} • Uploaded</span>
                                  <span class="size">${fileSize}</span>
                                </div>
                              </div>
                              <i class="fas fa-check"></i>
                            </li>`
            uploadedArea.classList.remove("onprogress")
            // uploadedArea.innerHTML = uploadedHTML; //uncomment this line if you don't want to show upload history
            uploadedArea.insertAdjacentHTML("afterbegin", uploadedHTML) //remove this line if you don't want to show upload history
          }
        })
        let data = new FormData(form) //FormData is an object to easily send form data
        xhr.send(data) //sending form data
      }
    </script>
  </body>
</html>

Does need an app created on DB to get oauth token to run example.

Anyone see the issue here? I've worked from a number of examples online but all of them assume I am using some dummy static file rather than incoming uploaded file.


Solution

  • Ok figured it out. Basically in my example I am terminating the session on end but it turns out the end event is fired at the same time as the last data event so I am effectively closing the session in the midst of the last chunk append. I refactored things to have a flag isEnd that gets set in the end event handler and moved the terminate session and resolve code inside of the data handler to be fired from there once isEnd is set.