|
| 1 | +/* |
| 2 | + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 |
| 3 | + * |
| 4 | + * HttpMjpeg.java — MJPEG over HTTP: multipart/x-mixed-replace, one JPEG per part (#121) |
| 5 | + * |
| 6 | + */ |
| 7 | + |
| 8 | +package com.openipc.decoder; |
| 9 | + |
| 10 | +import java.io.ByteArrayOutputStream; |
| 11 | +import java.io.IOException; |
| 12 | +import java.io.InputStream; |
| 13 | +import java.nio.charset.StandardCharsets; |
| 14 | +import java.util.ArrayList; |
| 15 | +import java.util.List; |
| 16 | +import java.util.Locale; |
| 17 | + |
| 18 | +/** |
| 19 | + * The HTTP side of an http:// slot such as Majestic's {@code http://<camera>/mjpeg}: the URL, |
| 20 | + * the response head and the body, a {@code multipart/x-mixed-replace} stream whose parts are |
| 21 | + * whole JPEG frames, chunked or not. The connection itself is RtspSession's, which also |
| 22 | + * handles authentication (Basic, Digest) and reconnects. Pure Java, no Android types. |
| 23 | + */ |
| 24 | +final class HttpMjpeg { |
| 25 | + /** Larger parts are not JPEG frames of a camera: the stream is given up. */ |
| 26 | + static final int MAX_PART = 8 * 1024 * 1024; |
| 27 | + private static final int MAX_LINE = 8192; |
| 28 | + |
| 29 | + private HttpMjpeg() {} |
| 30 | + |
| 31 | + static boolean matches(String url) { |
| 32 | + return url != null && url.trim().toLowerCase(Locale.ROOT).startsWith("http://"); |
| 33 | + } |
| 34 | + |
| 35 | + static final class Url { |
| 36 | + String host; |
| 37 | + int port = 80; |
| 38 | + /** Path and query, "/" at least. */ |
| 39 | + String path = "/"; |
| 40 | + /** Decoded "user:password", or null. */ |
| 41 | + String userInfo; |
| 42 | + |
| 43 | + static Url parse(String url) throws IOException { |
| 44 | + if (!matches(url)) throw new IOException("Not an http:// URL"); |
| 45 | + String rest = url.trim().substring("http://".length()); |
| 46 | + int slash = rest.indexOf('/'); |
| 47 | + String authority = slash >= 0 ? rest.substring(0, slash) : rest; |
| 48 | + Url u = new Url(); |
| 49 | + if (slash >= 0) u.path = rest.substring(slash); |
| 50 | + int at = authority.lastIndexOf('@'); |
| 51 | + if (at >= 0) { |
| 52 | + u.userInfo = RtspSession.Url.percentDecode(authority.substring(0, at)); |
| 53 | + authority = authority.substring(at + 1); |
| 54 | + } |
| 55 | + String host = authority; |
| 56 | + int colon = authority.lastIndexOf(':'); |
| 57 | + if (authority.startsWith("[")) { |
| 58 | + int close = authority.indexOf(']'); |
| 59 | + if (close < 0) throw new IOException("Bad host in URL"); |
| 60 | + host = authority.substring(1, close); |
| 61 | + colon = authority.indexOf(':', close); |
| 62 | + } else if (colon >= 0) { |
| 63 | + host = authority.substring(0, colon); |
| 64 | + } |
| 65 | + if (colon >= 0) { |
| 66 | + try { |
| 67 | + u.port = Integer.parseInt(authority.substring(colon + 1)); |
| 68 | + } catch (NumberFormatException e) { |
| 69 | + throw new IOException("Bad port in URL"); |
| 70 | + } |
| 71 | + if (u.port <= 0 || u.port > 65535) throw new IOException("Bad port in URL"); |
| 72 | + } |
| 73 | + if (host.isEmpty()) throw new IOException("No host in URL"); |
| 74 | + u.host = host; |
| 75 | + return u; |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /** Status and the headers that matter here. */ |
| 80 | + static final class Response { |
| 81 | + int status; |
| 82 | + String contentType; |
| 83 | + boolean chunked; |
| 84 | + final List<String> challenges = new ArrayList<>(); |
| 85 | + |
| 86 | + /** The multipart boundary, without the leading "--", or null when not multipart. */ |
| 87 | + String boundary() { |
| 88 | + if (contentType == null) return null; |
| 89 | + String lower = contentType.toLowerCase(Locale.ROOT); |
| 90 | + if (!lower.startsWith("multipart/")) return null; |
| 91 | + int i = lower.indexOf("boundary="); |
| 92 | + if (i < 0) return null; |
| 93 | + String b = contentType.substring(i + "boundary=".length()).trim(); |
| 94 | + int semi = b.indexOf(';'); |
| 95 | + if (semi >= 0) b = b.substring(0, semi).trim(); |
| 96 | + if (b.length() >= 2 && b.startsWith("\"") && b.endsWith("\"")) { |
| 97 | + b = b.substring(1, b.length() - 1); |
| 98 | + } |
| 99 | + // Some servers put the dashes into the parameter too |
| 100 | + if (b.startsWith("--")) b = b.substring(2); |
| 101 | + return b.isEmpty() ? null : b; |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + /** The status line and headers, up to the empty line. */ |
| 106 | + static Response readResponse(InputStream in) throws IOException { |
| 107 | + String line = readLine(in); |
| 108 | + if (line == null) throw new IOException("No HTTP response"); |
| 109 | + String[] parts = line.split(" ", 3); |
| 110 | + if (parts.length < 2 || !parts[0].startsWith("HTTP/")) { |
| 111 | + throw new IOException("Not an HTTP response"); |
| 112 | + } |
| 113 | + Response r = new Response(); |
| 114 | + try { |
| 115 | + r.status = Integer.parseInt(parts[1]); |
| 116 | + } catch (NumberFormatException e) { |
| 117 | + throw new IOException("Bad HTTP status"); |
| 118 | + } |
| 119 | + while ((line = readLine(in)) != null && !line.isEmpty()) { |
| 120 | + int colon = line.indexOf(':'); |
| 121 | + if (colon <= 0) continue; |
| 122 | + String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); |
| 123 | + String value = line.substring(colon + 1).trim(); |
| 124 | + if (name.equals("content-type")) r.contentType = value; |
| 125 | + else if (name.equals("transfer-encoding")) { |
| 126 | + r.chunked = value.toLowerCase(Locale.ROOT).contains("chunked"); |
| 127 | + } else if (name.equals("www-authenticate")) r.challenges.add(value); |
| 128 | + } |
| 129 | + return r; |
| 130 | + } |
| 131 | + |
| 132 | + interface FrameSink { |
| 133 | + /** One JPEG frame; false to stop reading. */ |
| 134 | + boolean onFrame(byte[] buf, int offset, int length); |
| 135 | + } |
| 136 | + |
| 137 | + /** |
| 138 | + * Reads parts until the stream ends, a part is malformed or the sink says stop. Each part |
| 139 | + * goes to the sink whole: by its Content-Length, or else up to the next boundary. |
| 140 | + */ |
| 141 | + static void readParts(InputStream body, String boundary, FrameSink sink) throws IOException { |
| 142 | + String delimiter = "--" + boundary; |
| 143 | + byte[] buf = new byte[256 * 1024]; |
| 144 | + String line; |
| 145 | + // Up to the first delimiter (a preamble is allowed) |
| 146 | + while ((line = readLine(body)) != null && !line.startsWith(delimiter)) { |
| 147 | + // skip |
| 148 | + } |
| 149 | + while (line != null) { |
| 150 | + if (line.startsWith(delimiter + "--")) return; // the closing delimiter |
| 151 | + int length = -1; |
| 152 | + while ((line = readLine(body)) != null && !line.isEmpty()) { |
| 153 | + int colon = line.indexOf(':'); |
| 154 | + if (colon > 0 && line.substring(0, colon).trim() |
| 155 | + .equalsIgnoreCase("content-length")) { |
| 156 | + try { |
| 157 | + length = Integer.parseInt(line.substring(colon + 1).trim()); |
| 158 | + } catch (NumberFormatException e) { |
| 159 | + length = -1; |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + if (line == null) return; |
| 164 | + if (length > MAX_PART) throw new IOException("MJPEG part of " + length + " bytes"); |
| 165 | + int n; |
| 166 | + if (length >= 0) { |
| 167 | + if (buf.length < length) buf = new byte[length]; |
| 168 | + readFully(body, buf, length); |
| 169 | + n = length; |
| 170 | + // The rest of the line after the data, then the next delimiter |
| 171 | + while ((line = readLine(body)) != null && !line.startsWith(delimiter)) { |
| 172 | + // skip |
| 173 | + } |
| 174 | + } else { |
| 175 | + // No length: the data runs up to CRLF + delimiter |
| 176 | + ByteArrayOutputStream part = new ByteArrayOutputStream(); |
| 177 | + byte[] end = ("\r\n" + delimiter).getBytes(StandardCharsets.US_ASCII); |
| 178 | + if (!readUntil(body, end, part)) return; |
| 179 | + n = part.size(); |
| 180 | + if (n > MAX_PART) throw new IOException("MJPEG part of " + n + " bytes"); |
| 181 | + if (buf.length < n) buf = new byte[n]; |
| 182 | + System.arraycopy(part.toByteArray(), 0, buf, 0, n); |
| 183 | + // What follows the delimiter on its line: "--" closes the stream |
| 184 | + String tail = readLine(body); |
| 185 | + line = tail == null ? null : delimiter + tail; |
| 186 | + } |
| 187 | + if (n > 0 && !sink.onFrame(buf, 0, n)) return; |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + /** Copies bytes into {@code out} until {@code end} has been read (not copied). */ |
| 192 | + private static boolean readUntil(InputStream in, byte[] end, ByteArrayOutputStream out) |
| 193 | + throws IOException { |
| 194 | + int matched = 0; |
| 195 | + int b; |
| 196 | + while ((b = in.read()) >= 0) { |
| 197 | + if (b == (end[matched] & 0xFF)) { |
| 198 | + matched++; |
| 199 | + if (matched == end.length) return true; |
| 200 | + continue; |
| 201 | + } |
| 202 | + if (matched > 0) { |
| 203 | + // Not the delimiter after all: what matched so far is data |
| 204 | + out.write(end, 0, matched); |
| 205 | + matched = b == (end[0] & 0xFF) ? 1 : 0; |
| 206 | + if (matched == 0) out.write(b); |
| 207 | + } else { |
| 208 | + out.write(b); |
| 209 | + } |
| 210 | + if (out.size() > MAX_PART) throw new IOException("MJPEG part too large"); |
| 211 | + } |
| 212 | + return false; |
| 213 | + } |
| 214 | + |
| 215 | + private static void readFully(InputStream in, byte[] buf, int length) throws IOException { |
| 216 | + int off = 0; |
| 217 | + while (off < length) { |
| 218 | + int r = in.read(buf, off, length - off); |
| 219 | + if (r < 0) throw new IOException("Stream ended inside a part"); |
| 220 | + off += r; |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + /** One CRLF (or LF) terminated ASCII line without the terminator; null at the end. */ |
| 225 | + static String readLine(InputStream in) throws IOException { |
| 226 | + StringBuilder sb = new StringBuilder(); |
| 227 | + int b; |
| 228 | + while ((b = in.read()) >= 0) { |
| 229 | + if (b == '\n') { |
| 230 | + int len = sb.length(); |
| 231 | + if (len > 0 && sb.charAt(len - 1) == '\r') sb.setLength(len - 1); |
| 232 | + return sb.toString(); |
| 233 | + } |
| 234 | + if (sb.length() >= MAX_LINE) throw new IOException("HTTP line too long"); |
| 235 | + sb.append((char) b); |
| 236 | + } |
| 237 | + return sb.length() > 0 ? sb.toString() : null; |
| 238 | + } |
| 239 | + |
| 240 | + /** The body of a chunked response as a plain stream (RFC 9112 7.1). */ |
| 241 | + static final class Chunked extends InputStream { |
| 242 | + private final InputStream in; |
| 243 | + private int left; |
| 244 | + private boolean done; |
| 245 | + |
| 246 | + Chunked(InputStream in) { |
| 247 | + this.in = in; |
| 248 | + } |
| 249 | + |
| 250 | + private boolean next() throws IOException { |
| 251 | + if (done) return false; |
| 252 | + if (left > 0) return true; |
| 253 | + String line = readLine(in); |
| 254 | + // The CRLF that ends the previous chunk's data |
| 255 | + if (line != null && line.isEmpty()) line = readLine(in); |
| 256 | + if (line == null) { |
| 257 | + done = true; |
| 258 | + return false; |
| 259 | + } |
| 260 | + int semi = line.indexOf(';'); |
| 261 | + String hex = (semi >= 0 ? line.substring(0, semi) : line).trim(); |
| 262 | + try { |
| 263 | + left = Integer.parseInt(hex, 16); |
| 264 | + } catch (NumberFormatException e) { |
| 265 | + throw new IOException("Bad chunk size"); |
| 266 | + } |
| 267 | + if (left < 0) throw new IOException("Bad chunk size"); |
| 268 | + if (left == 0) { |
| 269 | + done = true; |
| 270 | + return false; |
| 271 | + } |
| 272 | + return true; |
| 273 | + } |
| 274 | + |
| 275 | + @Override |
| 276 | + public int read() throws IOException { |
| 277 | + if (!next()) return -1; |
| 278 | + int b = in.read(); |
| 279 | + if (b < 0) { |
| 280 | + done = true; |
| 281 | + return -1; |
| 282 | + } |
| 283 | + left--; |
| 284 | + return b; |
| 285 | + } |
| 286 | + |
| 287 | + @Override |
| 288 | + public int read(byte[] b, int off, int len) throws IOException { |
| 289 | + if (len == 0) return 0; |
| 290 | + if (!next()) return -1; |
| 291 | + int r = in.read(b, off, Math.min(len, left)); |
| 292 | + if (r < 0) { |
| 293 | + done = true; |
| 294 | + return -1; |
| 295 | + } |
| 296 | + left -= r; |
| 297 | + return r; |
| 298 | + } |
| 299 | + } |
| 300 | +} |
0 commit comments