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}