001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.http.entity; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.nio.charset.*; 023import java.util.*; 024 025import org.apache.juneau.http.*; 026 027/** 028 * An {@link HttpBody} that serializes a {@code multipart/form-data} message (RFC 7578). 029 * 030 * <p> 031 * Each part is a {@link MultipartPart} that carries a field name, an optional filename, an optional 032 * per-part {@code Content-Type}, and a body writer. When {@link #writeTo(OutputStream)} is called the 033 * parts are streamed directly — no full-body buffering occurs. 034 * 035 * <h5 class='section'>Example:</h5> 036 * <p class='bjava'> 037 * <jv>body</jv> = MultipartBody.<jsm>builder</jsm>() 038 * .field(<js>"title"</js>, <js>"My Report"</js>) 039 * .file(<js>"attachment"</js>, <jv>reportFile</jv>, <js>"application/pdf"</js>) 040 * .build(); 041 * </p> 042 * 043 * <p> 044 * {@link #isRepeatable()} returns {@code false} when any part wraps a one-shot stream, and {@code true} 045 * when all parts are repeatable (e.g. strings, files, byte arrays). 046 * 047 * <h5 class='section'>See Also:</h5><ul> 048 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a> 049 * <li class='extlink'><a class="doclink" href="https://www.rfc-editor.org/rfc/rfc7578">RFC 7578 — Returning Values from Forms: multipart/form-data</a> 050 * </ul> 051 * 052 * @since 9.2.1 053 */ 054public final class MultipartBody implements HttpBody { 055 056 private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.US_ASCII); 057 private static final byte[] DASHDASH = "--".getBytes(StandardCharsets.US_ASCII); 058 059 private final String boundary; 060 private final List<MultipartPart> parts; 061 062 /** 063 * Rejects a part-metadata value (name, filename, or content-type) that could inject an extra MIME header line 064 * or otherwise corrupt the multipart framing at the receiving service. 065 * 066 * @param fieldName The name of the field being validated, for the exception message. 067 * @param value The value to validate. Can be <jk>null</jk> (no-op). 068 * @throws IllegalArgumentException If {@code value} contains CR, LF, another C0 control, NEL, or a bidi control. 069 */ 070 private static void assertNoInjectionChars(String fieldName, String value) { 071 if (value == null) 072 return; 073 for (var i = 0; i < value.length(); i++) 074 if (isInjectionChar(value.charAt(i))) 075 throw iaex("MultipartPart '%s' must not contain CR, LF, or other control characters.", fieldName); 076 } 077 078 private static boolean isInjectionChar(char c) { 079 if (c == '\t') 080 return false; 081 if (c <= '\u001F') // C0 controls (CR/LF included). 082 return true; 083 if (c >= '\u007F' && c <= '\u009F') // DEL + C1 controls (includes NEL \u0085). 084 return true; 085 if (c == '\u2028' || c == '\u2029') // Line / paragraph separators. 086 return true; 087 if (c >= '\u202A' && c <= '\u202E') // Bidi embedding/override controls. 088 return true; 089 return c >= '\u2066' && c <= '\u2069'; // Bidi isolate controls. 090 } 091 092 /** 093 * Escapes {@code \} and {@code "} in a {@code Content-Disposition} quoted-string parameter value so it cannot 094 * break out of the surrounding quotes. 095 * 096 * @param value The raw value to escape. Must not be <jk>null</jk>. 097 * @return The escaped value, unquoted (caller adds the surrounding {@code "..."}). 098 */ 099 private static String escapeQuoted(String value) { 100 var sb = new StringBuilder(value.length() + 4); 101 for (var i = 0; i < value.length(); i++) { 102 var c = value.charAt(i); 103 if (c == '\\' || c == '"') 104 sb.append('\\'); 105 sb.append(c); 106 } 107 return sb.toString(); 108 } 109 110 private MultipartBody(String boundary, List<MultipartPart> parts) { 111 this.boundary = boundary; 112 this.parts = List.copyOf(parts); 113 } 114 115 /** Returns a new {@link Builder}. */ 116 public static Builder builder() { 117 return new Builder(); 118 } 119 120 @Override /* HttpBody */ 121 public String getContentType() { 122 return "multipart/form-data; boundary=" + boundary; 123 } 124 125 /** 126 * Returns {@code -1} — multipart bodies are streamed without pre-computing the total length. 127 */ 128 @Override /* HttpBody */ 129 public long getContentLength() { 130 return -1; 131 } 132 133 @Override /* HttpBody */ 134 public void writeTo(OutputStream out) throws IOException { 135 var bnd = boundary.getBytes(StandardCharsets.US_ASCII); 136 for (var part : parts) { 137 // Belt-and-suspenders: MultipartPart's compact constructor already rejects these, but re-validate at 138 // write time in case a part was ever reachable without going through that constructor. 139 assertNoInjectionChars("name", part.name()); 140 assertNoInjectionChars("filename", part.filename()); 141 assertNoInjectionChars("contentType", part.contentType()); 142 out.write(DASHDASH); 143 out.write(bnd); 144 out.write(CRLF); 145 // Content-Disposition header 146 var cd = new StringBuilder("Content-Disposition: form-data; name=\"").append(escapeQuoted(part.name())).append('"'); 147 if (part.filename() != null) 148 cd.append("; filename=\"").append(escapeQuoted(part.filename())).append('"'); 149 out.write(cd.toString().getBytes(StandardCharsets.UTF_8)); 150 out.write(CRLF); 151 // Content-Type header (if present) 152 if (part.contentType() != null) { 153 out.write(("Content-Type: " + part.contentType()).getBytes(StandardCharsets.US_ASCII)); 154 out.write(CRLF); 155 } 156 out.write(CRLF); 157 part.body().writeTo(out); 158 out.write(CRLF); 159 } 160 // Final boundary 161 out.write(DASHDASH); 162 out.write(bnd); 163 out.write(DASHDASH); 164 out.write(CRLF); 165 } 166 167 @Override /* HttpBody */ 168 public boolean isRepeatable() { 169 for (var part : parts) 170 if (!part.body().isRepeatable()) 171 return false; 172 return true; 173 } 174 175 /** 176 * Returns the multipart boundary string. 177 * 178 * @return The boundary. Never <jk>null</jk>. 179 */ 180 public String getBoundary() { 181 return boundary; 182 } 183 184 /** 185 * Returns an unmodifiable view of the parts in this multipart body. 186 * 187 * @return The parts. Never <jk>null</jk>. 188 */ 189 public List<MultipartPart> getParts() { 190 return parts; 191 } 192 193 // ----------------------------------------------------------------------------------------------------------------- 194 // Inner types 195 // ----------------------------------------------------------------------------------------------------------------- 196 197 /** 198 * A single part within a {@link MultipartBody}. 199 * 200 * @since 9.2.1 201 */ 202 public record MultipartPart(String name, String filename, String contentType, HttpBody body) { 203 204 /** 205 * Compact constructor enforcing the documented non-null contract on {@code name} and {@code body}, and 206 * rejecting CR/LF and other control characters in {@code name}, {@code filename}, and {@code contentType} 207 * that could otherwise inject an extra MIME header line into the multipart wire format. 208 */ 209 public MultipartPart { 210 reqnn("name", name); 211 reqnn("body", body); 212 assertNoInjectionChars("name", name); 213 assertNoInjectionChars("filename", filename); 214 assertNoInjectionChars("contentType", contentType); 215 } 216 217 /** 218 * Creates a text field part with no filename and {@code text/plain; charset=UTF-8} content type. 219 * 220 * @param name The field name. Must not be <jk>null</jk>. 221 * @param value The string value. Must not be <jk>null</jk>. 222 * @return A new instance. Never <jk>null</jk>. 223 */ 224 public static MultipartPart field(String name, String value) { 225 return new MultipartPart(name, null, "text/plain; charset=UTF-8", StringBody.of(value, "text/plain; charset=UTF-8")); 226 } 227 228 /** 229 * Creates a file upload part. 230 * 231 * @param name The field name. Must not be <jk>null</jk>. 232 * @param file The file to upload. Must not be <jk>null</jk>. Its {@link File#getName()} is validated like any 233 * other {@code filename} — a hostile name containing CR/LF or other control characters is rejected. 234 * @param contentType The MIME content type for the file (e.g. {@code "application/pdf"}). May be <jk>null</jk>. 235 * @return A new instance. Never <jk>null</jk>. 236 */ 237 public static MultipartPart file(String name, File file, String contentType) { 238 reqnn("file", file); 239 return new MultipartPart(name, file.getName(), contentType, FileBody.of(file, contentType)); 240 } 241 242 /** 243 * Creates a part from an arbitrary {@link HttpBody}. 244 * 245 * @param name The field name. Must not be <jk>null</jk>. 246 * @param filename The filename to advertise in {@code Content-Disposition}. May be <jk>null</jk>. 247 * @param contentType The MIME content type. May be <jk>null</jk>. 248 * @param body The body content. Must not be <jk>null</jk>. 249 * @return A new instance. Never <jk>null</jk>. 250 */ 251 public static MultipartPart of(String name, String filename, String contentType, HttpBody body) { 252 return new MultipartPart(name, filename, contentType, body); 253 } 254 } 255 256 /** 257 * Fluent builder for {@link MultipartBody}. 258 * 259 * @since 9.2.1 260 */ 261 public static final class Builder { 262 263 private String boundary = UUID.randomUUID().toString().replace("-", ""); 264 private final List<MultipartPart> parts = l(); 265 266 private Builder() {} 267 268 /** 269 * Sets a custom multipart boundary string (defaults to a random UUID-derived value). 270 * 271 * @param value The boundary. Must not be <jk>null</jk> or blank. 272 * @return This object. 273 */ 274 public Builder boundary(String value) { 275 boundary = reqnb("value", value); 276 return this; 277 } 278 279 /** 280 * Adds a plain-text form field. 281 * 282 * @param name The field name. Must not be <jk>null</jk>. 283 * @param value The field value. Must not be <jk>null</jk>. 284 * @return This object. 285 */ 286 public Builder field(String name, String value) { 287 parts.add(MultipartPart.field(name, value)); 288 return this; 289 } 290 291 /** 292 * Adds a file upload part. 293 * 294 * @param name The field name. Must not be <jk>null</jk>. 295 * @param file The file to upload. Must not be <jk>null</jk>. 296 * @param contentType The MIME content type for the file. May be <jk>null</jk>. 297 * @return This object. 298 */ 299 public Builder file(String name, File file, String contentType) { 300 parts.add(MultipartPart.file(name, file, contentType)); 301 return this; 302 } 303 304 /** 305 * Adds a custom part. 306 * 307 * @param part The part to add. Must not be <jk>null</jk>. 308 * @return This object. 309 */ 310 public Builder part(MultipartPart part) { 311 parts.add(reqnn("part", part)); 312 return this; 313 } 314 315 /** 316 * Builds and returns the {@link MultipartBody}. 317 * 318 * @return A new instance. Never <jk>null</jk>. 319 */ 320 public MultipartBody build() { 321 return new MultipartBody(boundary, parts); 322 } 323 } 324}