OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / lib / multipart.js

88 lines · javascript ref:
// multipart/form-data — enough of it to carry one small file from a no-JS form.
//
// ⛔ Byte-exact throughout. An image is binary, so the body is never turned into a string and back;
// the boundary search, the header split and the part slicing all work on bytes. Decoding to UTF-8
// anywhere in that path silently corrupts every non-text upload, and the corruption looks like a
// broken image rather than an error.
//
// This is deliberately the smallest thing that works: no streaming, no nested multiparts, no
// transfer-encodings. It exists because a browser cannot post a file any other way without
// JavaScript, and the surface's whole point is that reading and writing need none.

import b4a from 'b4a'

const CRLF = b4a.from('\r\n')
const DASH = 0x2d

/**
 * parse(body, contentType) -> { fields, files } | null
 *
 *   fields  name -> string        (utf-8 decoded, because a form field IS text)
 *   files   name -> { filename, contentType, bytes }
 *
 * Returns null when the content type is not multipart or carries no boundary — a caller should
 * treat that as a bad request rather than as an empty form.
 */
export function parse (body, contentType) {
  const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(String(contentType || ''))
  if (!m) return null
  const boundary = (m[1] || m[2]).trim()
  if (!boundary) return null

  // Every delimiter in the body is `\r\n--boundary`, except the first, which has no leading CRLF.
  // Prepending one makes the scan uniform instead of special-casing the head.
  const buf = b4a.concat([CRLF, body])
  const delim = b4a.concat([CRLF, b4a.from('--' + boundary)])

  const out = { fields: {}, files: {} }
  let pos = b4a.indexOf(buf, delim)
  if (pos !== 0) return out // not our boundary; nothing to read rather than a guess

  while (pos !== -1) {
    let start = pos + delim.byteLength
    // `--` after the boundary is the terminator.
    if (buf[start] === DASH && buf[start + 1] === DASH) break
    // Otherwise a CRLF, then the part.
    if (buf[start] !== 0x0d || buf[start + 1] !== 0x0a) break
    start += 2

    const next = b4a.indexOf(buf, delim, start)
    if (next === -1) break
    readPart(buf.subarray(start, next), out)
    pos = next
  }

  return out
}

function readPart (part, out) {
  const sep = b4a.indexOf(part, b4a.concat([CRLF, CRLF]))
  if (sep === -1) return
  // Headers are ASCII by definition; the BODY is what must stay bytes.
  const head = b4a.toString(part.subarray(0, sep), 'utf8')
  const bytes = part.subarray(sep + 4)

  const name = /name="([^"]*)"/i.exec(head)
  if (!name) return
  const filename = /filename="([^"]*)"/i.exec(head)

  if (!filename) {
    out.fields[name[1]] = b4a.toString(bytes, 'utf8')
    return
  }
  // ⚠ An empty filename means "no file chosen" — every browser sends the part anyway. Treating it
  // as a zero-byte upload would clear an avatar every time the form was saved.
  if (!filename[1]) return

  const type = /content-type:\s*([^\r\n]+)/i.exec(head)
  out.files[name[1]] = {
    filename: filename[1],
    // ⛔ Recorded, never trusted. The daemon types the file from its magic bytes; this is only here
    // so a caller can report what the browser claimed.
    contentType: type ? type[1].trim() : null,
    bytes
  }
}

export default { parse }