3838
3939import java .io .ByteArrayOutputStream ;
4040import java .io .UnsupportedEncodingException ;
41+ import java .net .URLDecoder ;
4142import java .nio .charset .Charset ;
4243import java .nio .charset .StandardCharsets ;
44+ import java .util .Base64 ;
45+ import java .util .BitSet ;
4346import java .util .List ;
44- import java .util .Map ;
47+ import java .util .Locale ;
4548import java .util .Objects ;
49+ import java .util .regex .Matcher ;
50+ import java .util .regex .Pattern ;
51+
52+ import org .slf4j .Logger ;
53+ import org .slf4j .LoggerFactory ;
4654
4755import com .google .common .base .Ascii ;
4856import com .google .common .collect .ImmutableList ;
49- import com .google .common .collect .ImmutableMap ;
5057
5158import com .linecorp .armeria .common .annotation .Nullable ;
59+ import com .linecorp .armeria .common .multipart .MultipartFilenameDecodingMode ;
5260import com .linecorp .armeria .internal .common .util .TemporaryThreadLocals ;
5361
5462/**
6270 */
6371public final class ContentDisposition {
6472
65- // Forked from https://github.com/spring-projects/spring-framework/blob/d9ccd618ea9cbf339eb5639d24d5a5fabe8157b5/spring-web/src/main/java/org/springframework/http/ContentDisposition.java
73+ // Forked from https://github.com/spring-projects/spring-framework/blob/e5fccd1fbbf09f1e253b10ebfc12ad339d0196b5/spring-web/src/main/java/org/springframework/http/ContentDisposition.java
74+
75+ private static final Logger logger = LoggerFactory .getLogger (ContentDisposition .class );
6676
6777 private static final ContentDisposition EMPTY = new ContentDisposition ("" , null , null , null );
6878
69- private static final Map <String , Charset > supportedCharsets =
70- ImmutableMap .of ("utf-8" , UTF_8 , "iso-8859-1" , ISO_8859_1 );
79+ private static final Pattern BASE64_ENCODED_PATTERN =
80+ Pattern .compile ("=\\ ?([0-9a-zA-Z-_]+)\\ ?B\\ ?([+/0-9a-zA-Z]+=*)\\ ?=" );
81+
82+ // Printable ASCII other than "?" or SPACE
83+ private static final Pattern QUOTED_PRINTABLE_ENCODED_PATTERN =
84+ Pattern .compile ("=\\ ?([0-9a-zA-Z-_]+)\\ ?Q\\ ?([!->@-~]+)\\ ?=" );
85+
86+ private static final MultipartFilenameDecodingMode MULTIPART_FILENAME_DECODING_MODE =
87+ Flags .defaultMultipartFilenameDecodingMode ();
88+
89+ private static final BitSet PRINTABLE = new BitSet (256 );
90+
91+ static {
92+ // RFC 2045, Section 6.7, and RFC 2047, Section 4.2
93+ for (int i = 33 ; i <= 126 ; i ++) {
94+ PRINTABLE .set (i );
95+ }
96+ PRINTABLE .set (34 , false ); // "
97+ PRINTABLE .set (61 , false ); // =
98+ PRINTABLE .set (63 , false ); // ?
99+ PRINTABLE .set (95 , false ); // _
100+ }
71101
72102 /**
73103 * Returns a new {@link ContentDispositionBuilder} with the specified {@code type}.
@@ -146,7 +176,7 @@ public static ContentDisposition parse(String contentDisposition) {
146176 final String part = parts .get (i );
147177 final int eqIndex = part .indexOf ('=' );
148178 if (eqIndex != -1 ) {
149- final String attribute = part .substring (0 , eqIndex );
179+ final String attribute = part .substring (0 , eqIndex ). toLowerCase ( Locale . ROOT ) ;
150180 final String value ;
151181 if (part .startsWith ("\" " , eqIndex + 1 ) && part .endsWith ("\" " )) {
152182 value = part .substring (eqIndex + 2 , part .length () - 1 );
@@ -161,14 +191,61 @@ public static ContentDisposition parse(String contentDisposition) {
161191 final int idx2 = value .indexOf ('\'' , idx1 + 1 );
162192 if (idx1 != -1 && idx2 != -1 ) {
163193 final String charsetString = value .substring (0 , idx1 ).trim ();
164- charset = supportedCharsets .getOrDefault (Ascii .toLowerCase (charsetString ), ISO_8859_1 );
194+ charset = Charset .forName (charsetString );
195+ if (UTF_8 != charset && ISO_8859_1 != charset ) {
196+ throw new IllegalArgumentException ("Charset must be UTF-8 or ISO-8859-1" +
197+ " for filename*: " + charsetString );
198+ }
199+
165200 filename = decodeFilename (value .substring (idx2 + 1 ), charset );
166201 } else {
167202 // US ASCII
168203 filename = decodeFilename (value , StandardCharsets .US_ASCII );
169204 }
170205 } else if ("filename" .equals (attribute ) && (filename == null )) {
171- filename = value ;
206+ if (value .startsWith ("=?" )) {
207+ Matcher matcher = BASE64_ENCODED_PATTERN .matcher (value );
208+ if (matcher .find ()) {
209+ final Base64 .Decoder decoder = Base64 .getDecoder ();
210+ final StringBuilder builder = new StringBuilder ();
211+ do {
212+ charset = Charset .forName (matcher .group (1 ));
213+ final byte [] decoded = decoder .decode (matcher .group (2 ));
214+ builder .append (new String (decoded , charset ));
215+ }
216+ while (matcher .find ());
217+
218+ filename = builder .toString ();
219+ } else {
220+ matcher = QUOTED_PRINTABLE_ENCODED_PATTERN .matcher (value );
221+ if (matcher .find ()) {
222+ final StringBuilder builder = new StringBuilder ();
223+ do {
224+ charset = Charset .forName (matcher .group (1 ));
225+ final String decoded =
226+ decodeQuotedPrintableFilename (matcher .group (2 ), charset );
227+ builder .append (decoded );
228+ }
229+ while (matcher .find ());
230+
231+ filename = builder .toString ();
232+ } else {
233+ filename = value ;
234+ }
235+ }
236+ } else if (value .indexOf ('\\' ) != -1 ) {
237+ filename = decodeQuotedPairs (value );
238+ } else if (MULTIPART_FILENAME_DECODING_MODE == MultipartFilenameDecodingMode .URL_DECODING ) {
239+ try {
240+ filename = URLDecoder .decode (value , "UTF-8" );
241+ } catch (Exception e ) {
242+ logger .debug ("Failed to URL decode filename: {}, contentDisposition: {}" ,
243+ value , contentDisposition , e );
244+ filename = value ;
245+ }
246+ } else {
247+ filename = value ;
248+ }
172249 }
173250 } else {
174251 throw new IllegalArgumentException ("Invalid content disposition format: " + contentDisposition );
@@ -303,6 +380,10 @@ private static String decodeFilename(String filename, Charset charset) {
303380 filename + " (charset: " + charset + ')' );
304381 }
305382 }
383+ return copyToString (baos , charset );
384+ }
385+
386+ private static String copyToString (ByteArrayOutputStream baos , Charset charset ) {
306387 try {
307388 return baos .toString (charset .name ());
308389 } catch (UnsupportedEncodingException e ) {
@@ -364,6 +445,46 @@ private static void encodeFilename(StringBuilder sb, String input, Charset chars
364445 }
365446 }
366447
448+ private static String decodeQuotedPrintableFilename (String filename , Charset charset ) {
449+ final byte [] value = filename .getBytes (StandardCharsets .US_ASCII );
450+ final ByteArrayOutputStream baos = new ByteArrayOutputStream ();
451+ int index = 0 ;
452+ while (index < value .length ) {
453+ final byte b = value [index ];
454+ if (b == '_' ) { // RFC 2047, section 4.2, rule (2)
455+ baos .write (' ' );
456+ index ++;
457+ } else if (b == '=' && index < value .length - 2 ) {
458+ final char [] array = {(char ) value [index + 1 ], (char ) value [index + 2 ]};
459+ baos .write (Integer .parseInt (String .valueOf (array ), 16 ));
460+ index += 3 ;
461+ } else {
462+ baos .write (b );
463+ index ++;
464+ }
465+ }
466+ return copyToString (baos , charset );
467+ }
468+
469+ private static String decodeQuotedPairs (String filename ) {
470+ final StringBuilder sb = new StringBuilder ();
471+ final int length = filename .length ();
472+ for (int i = 0 ; i < length ; i ++) {
473+ final char c = filename .charAt (i );
474+ if (filename .charAt (i ) == '\\' && i + 1 < length ) {
475+ i ++;
476+ final char next = filename .charAt (i );
477+ if (next != '"' && next != '\\' ) {
478+ sb .append (c );
479+ }
480+ sb .append (next );
481+ } else {
482+ sb .append (c );
483+ }
484+ }
485+ return sb .toString ();
486+ }
487+
367488 @ Override
368489 public boolean equals (@ Nullable Object other ) {
369490 if (this == other ) {
@@ -405,17 +526,87 @@ public String asHeaderValue() {
405526 if (filename != null ) {
406527 if (charset == null || StandardCharsets .US_ASCII .equals (charset )) {
407528 sb .append ("; filename=\" " );
408- escapeQuotationsInFilename (sb , filename );
409- sb .append ('\"' );
529+ sb .append (encodeQuotedPairs (this .filename )).append ('\"' );
410530 } else {
531+ sb .append ("; filename=\" " );
532+ sb .append (encodeQuotedPrintableFilename (filename , charset )).append ('\"' );
411533 sb .append ("; filename*=" );
412- encodeFilename ( sb , filename , charset );
534+ sb . append ( encodeRfc5987Filename ( filename , charset ) );
413535 }
414536 }
415537 return strVal = sb .toString ();
416538 }
417539 }
418540
541+ private static String encodeQuotedPairs (String filename ) {
542+ if (filename .indexOf ('"' ) == -1 && filename .indexOf ('\\' ) == -1 ) {
543+ return filename ;
544+ }
545+ final StringBuilder sb = new StringBuilder ();
546+ for (int i = 0 ; i < filename .length (); i ++) {
547+ final char c = filename .charAt (i );
548+ if (c == '"' || c == '\\' ) {
549+ sb .append ('\\' );
550+ }
551+ sb .append (c );
552+ }
553+ return sb .toString ();
554+ }
555+
556+ /**
557+ * Encode the given header field param as described in RFC 2047.
558+ *
559+ * @see <a href="https://datatracker.ietf.org/doc/html/rfc2047">RFC 2047</a>
560+ */
561+ private static String encodeQuotedPrintableFilename (String filename , Charset charset ) {
562+ final byte [] source = filename .getBytes (charset );
563+ final StringBuilder sb = new StringBuilder (source .length << 1 );
564+ sb .append ("=?" );
565+ sb .append (charset .name ());
566+ sb .append ("?Q?" );
567+ for (byte b : source ) {
568+ if (b == 32 ) { // RFC 2047, section 4.2, rule (2)
569+ sb .append ('_' );
570+ } else if (isPrintable (b )) {
571+ sb .append ((char ) b );
572+ } else {
573+ sb .append ('=' );
574+ sb .append (String .format ("%02X" , b & 0xFF ));
575+ }
576+ }
577+ sb .append ("?=" );
578+ return sb .toString ();
579+ }
580+
581+ private static boolean isPrintable (byte c ) {
582+ int b = c ;
583+ if (b < 0 ) {
584+ b = 256 + b ;
585+ }
586+ return PRINTABLE .get (b );
587+ }
588+
589+ /**
590+ * Encode the given header field param as describe in RFC 5987.
591+ *
592+ * @see <a href="https://datatracker.ietf.org/doc/html/rfc5987">RFC 5987</a>
593+ */
594+ private static String encodeRfc5987Filename (String input , Charset charset ) {
595+ final byte [] source = input .getBytes (charset );
596+ final StringBuilder sb = new StringBuilder (source .length << 1 );
597+ sb .append (charset .name ());
598+ sb .append ("''" );
599+ for (byte b : source ) {
600+ if (isRFC5987AttrChar (b )) {
601+ sb .append ((char ) b );
602+ } else {
603+ sb .append ('%' );
604+ sb .append (String .format ("%02X" , b & 0xFF ));
605+ }
606+ }
607+ return sb .toString ();
608+ }
609+
419610 /**
420611 * Returns the header value for this content disposition as defined in RFC 6266.
421612 */
0 commit comments