package com.sptmobile.voice import java.io.ByteArrayOutputStream import java.io.InputStream // [impl->REQ-VOICE-PIPE] /** * Minimal HTTP/1.1 + multipart/form-data reader for the Pebble Index webhook * (DESIGN.md §Pebble webhook contract). Deliberately hand-rolled and tiny: * the entire contract is one `POST` from one loopback client per recording — * `Content-Length` framing, `Connection: close`, no keep-alive, no chunked * encoding. Pure byte-in/value-out functions so the parse is JVM-unit-testable * without a socket. */ object WebhookRequest { /** Syntactically unusable request — the server answers 400. */ class Malformed(message: String) : RuntimeException(message) /** Body over the cap — the server answers 413 without reading it. */ class TooLarge(message: String) : RuntimeException(message) /** Header names lowercased; body exactly `Content-Length` bytes. */ data class Parsed( val method: String, val path: String, val headers: Map, val body: ByteArray, ) private const val MAX_HEAD_BYTES = 64 * 1024 /** Read one request off [input]; throws [Malformed] / [TooLarge]. */ fun read(input: InputStream, maxBody: Int): Parsed { val head = readHead(input) val lines = head.split("\r\n").filter { it.isNotEmpty() } if (lines.isEmpty()) throw Malformed("empty request head") val requestLine = lines[0].split(" ") if (requestLine.size < 3) throw Malformed("bad request line: ${lines[0]}") val headers = LinkedHashMap() for (line in lines.drop(1)) { val colon = line.indexOf(':') if (colon < 0) throw Malformed("bad header line") headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() } val length = headers["content-length"]?.toIntOrNull() ?: 0 if (length < 0) throw Malformed("bad content-length") if (length > maxBody) throw TooLarge("body $length > cap $maxBody") val body = ByteArray(length) var read = 0 while (read < length) { val n = input.read(body, read, length - read) if (n < 0) throw Malformed("EOF mid-body at $read/$length") read += n } return Parsed(requestLine[0], requestLine[1], headers, body) } /** * The multipart text fields, by part name. Parts carrying a `filename` * (the optional `audio` attachment) are SKIPPED — v1 payload mode is * transcription-only (DESIGN.md §Pebble webhook contract) and the * receiver must tolerate, not choke on, an audio part. */ fun fields(contentType: String?, body: ByteArray): Map { val ct = contentType ?: throw Malformed("no content-type") if (!ct.lowercase().startsWith("multipart/form-data")) { throw Malformed("not multipart/form-data: $ct") } val boundary = ct.split(';') .map { it.trim() } .firstOrNull { it.startsWith("boundary=", ignoreCase = true) } ?.substringAfter('=') ?.trim('"') ?.takeIf { it.isNotEmpty() } ?: throw Malformed("no multipart boundary") val delim = "--$boundary".toByteArray(Charsets.ISO_8859_1) val fields = LinkedHashMap() var at = indexOf(body, delim, 0) if (at < 0) throw Malformed("boundary never appears in body") while (true) { var cursor = at + delim.size // "--" after the delimiter = the terminal marker. if (cursor + 1 < body.size && body[cursor] == '-'.code.toByte() && body[cursor + 1] == '-'.code.toByte() ) break // Skip the CRLF that follows a non-terminal delimiter. if (cursor + 1 < body.size && body[cursor] == '\r'.code.toByte() && body[cursor + 1] == '\n'.code.toByte() ) cursor += 2 val next = indexOf(body, delim, cursor) if (next < 0) throw Malformed("unterminated multipart part") // Part content ends before the CRLF that precedes the next delimiter. val end = if (next >= 2 && body[next - 2] == '\r'.code.toByte() && body[next - 1] == '\n'.code.toByte() ) next - 2 else next parsePart(body, cursor, end)?.let { (name, value) -> fields[name] = value } at = next } return fields } /** One part's (name, text value); null for file parts / nameless parts. */ private fun parsePart(body: ByteArray, start: Int, end: Int): Pair? { val headerEnd = indexOf(body, HEADER_TERMINATOR, start) if (headerEnd < 0 || headerEnd > end) throw Malformed("part without header block") val headerText = String(body, start, headerEnd - start, Charsets.ISO_8859_1) val disposition = headerText.split("\r\n") .firstOrNull { it.startsWith("content-disposition:", ignoreCase = true) } ?: return null if (disposition.contains("filename=", ignoreCase = true)) return null val name = NAME_PATTERN.find(disposition)?.groupValues?.get(1) ?: return null val valueStart = headerEnd + HEADER_TERMINATOR.size if (valueStart > end) return name to "" return name to String(body, valueStart, end - valueStart, Charsets.UTF_8) } private val HEADER_TERMINATOR = "\r\n\r\n".toByteArray(Charsets.ISO_8859_1) private val NAME_PATTERN = Regex("""\bname="([^"]*)"""") private fun readHead(input: InputStream): String { val buffer = ByteArrayOutputStream() var matched = 0 while (true) { val b = input.read() if (b < 0) throw Malformed("EOF before header terminator") buffer.write(b) if (buffer.size() > MAX_HEAD_BYTES) throw Malformed("header block over cap") matched = when { b == '\r'.code && (matched == 0 || matched == 2) -> matched + 1 b == '\n'.code && (matched == 1 || matched == 3) -> matched + 1 b == '\r'.code -> 1 else -> 0 } if (matched == 4) break } val bytes = buffer.toByteArray() return String(bytes, 0, bytes.size - 4, Charsets.ISO_8859_1) } private fun indexOf(haystack: ByteArray, needle: ByteArray, from: Int): Int { if (needle.isEmpty()) return from outer@ for (i in from..haystack.size - needle.size) { for (j in needle.indices) { if (haystack[i + j] != needle[j]) continue@outer } return i } return -1 } }