001/* 002 * Copyright 2002-2020 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.http; 018 019import java.io.ByteArrayOutputStream; 020import java.nio.charset.Charset; 021import java.nio.charset.StandardCharsets; 022import java.time.ZonedDateTime; 023import java.time.format.DateTimeParseException; 024import java.util.ArrayList; 025import java.util.List; 026 027import org.springframework.lang.Nullable; 028import org.springframework.util.Assert; 029import org.springframework.util.ObjectUtils; 030import org.springframework.util.StreamUtils; 031 032import static java.nio.charset.StandardCharsets.ISO_8859_1; 033import static java.nio.charset.StandardCharsets.UTF_8; 034import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; 035 036/** 037 * Represent the Content-Disposition type and parameters as defined in RFC 6266. 038 * 039 * @author Sebastien Deleuze 040 * @author Juergen Hoeller 041 * @author Rossen Stoyanchev 042 * @since 5.0 043 * @see <a href="https://tools.ietf.org/html/rfc6266">RFC 6266</a> 044 */ 045public final class ContentDisposition { 046 047 private static final String INVALID_HEADER_FIELD_PARAMETER_FORMAT = 048 "Invalid header field parameter format (as defined in RFC 5987)"; 049 050 051 @Nullable 052 private final String type; 053 054 @Nullable 055 private final String name; 056 057 @Nullable 058 private final String filename; 059 060 @Nullable 061 private final Charset charset; 062 063 @Nullable 064 private final Long size; 065 066 @Nullable 067 private final ZonedDateTime creationDate; 068 069 @Nullable 070 private final ZonedDateTime modificationDate; 071 072 @Nullable 073 private final ZonedDateTime readDate; 074 075 076 /** 077 * Private constructor. See static factory methods in this class. 078 */ 079 private ContentDisposition(@Nullable String type, @Nullable String name, @Nullable String filename, 080 @Nullable Charset charset, @Nullable Long size, @Nullable ZonedDateTime creationDate, 081 @Nullable ZonedDateTime modificationDate, @Nullable ZonedDateTime readDate) { 082 083 this.type = type; 084 this.name = name; 085 this.filename = filename; 086 this.charset = charset; 087 this.size = size; 088 this.creationDate = creationDate; 089 this.modificationDate = modificationDate; 090 this.readDate = readDate; 091 } 092 093 094 /** 095 * Return the disposition type, like for example {@literal inline}, {@literal attachment}, 096 * {@literal form-data}, or {@code null} if not defined. 097 */ 098 @Nullable 099 public String getType() { 100 return this.type; 101 } 102 103 /** 104 * Return the value of the {@literal name} parameter, or {@code null} if not defined. 105 */ 106 @Nullable 107 public String getName() { 108 return this.name; 109 } 110 111 /** 112 * Return the value of the {@literal filename} parameter (or the value of the 113 * {@literal filename*} one decoded as defined in the RFC 5987), or {@code null} if not defined. 114 */ 115 @Nullable 116 public String getFilename() { 117 return this.filename; 118 } 119 120 /** 121 * Return the charset defined in {@literal filename*} parameter, or {@code null} if not defined. 122 */ 123 @Nullable 124 public Charset getCharset() { 125 return this.charset; 126 } 127 128 /** 129 * Return the value of the {@literal size} parameter, or {@code null} if not defined. 130 * @deprecated since 5.2.3 as per 131 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 132 * to be removed in a future release. 133 */ 134 @Deprecated 135 @Nullable 136 public Long getSize() { 137 return this.size; 138 } 139 140 /** 141 * Return the value of the {@literal creation-date} parameter, or {@code null} if not defined. 142 * @deprecated since 5.2.3 as per 143 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 144 * to be removed in a future release. 145 */ 146 @Deprecated 147 @Nullable 148 public ZonedDateTime getCreationDate() { 149 return this.creationDate; 150 } 151 152 /** 153 * Return the value of the {@literal modification-date} parameter, or {@code null} if not defined. 154 * @deprecated since 5.2.3 as per 155 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 156 * to be removed in a future release. 157 */ 158 @Deprecated 159 @Nullable 160 public ZonedDateTime getModificationDate() { 161 return this.modificationDate; 162 } 163 164 /** 165 * Return the value of the {@literal read-date} parameter, or {@code null} if not defined. 166 * @deprecated since 5.2.3 as per 167 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 168 * to be removed in a future release. 169 */ 170 @Deprecated 171 @Nullable 172 public ZonedDateTime getReadDate() { 173 return this.readDate; 174 } 175 176 177 @Override 178 public boolean equals(@Nullable Object other) { 179 if (this == other) { 180 return true; 181 } 182 if (!(other instanceof ContentDisposition)) { 183 return false; 184 } 185 ContentDisposition otherCd = (ContentDisposition) other; 186 return (ObjectUtils.nullSafeEquals(this.type, otherCd.type) && 187 ObjectUtils.nullSafeEquals(this.name, otherCd.name) && 188 ObjectUtils.nullSafeEquals(this.filename, otherCd.filename) && 189 ObjectUtils.nullSafeEquals(this.charset, otherCd.charset) && 190 ObjectUtils.nullSafeEquals(this.size, otherCd.size) && 191 ObjectUtils.nullSafeEquals(this.creationDate, otherCd.creationDate)&& 192 ObjectUtils.nullSafeEquals(this.modificationDate, otherCd.modificationDate)&& 193 ObjectUtils.nullSafeEquals(this.readDate, otherCd.readDate)); 194 } 195 196 @Override 197 public int hashCode() { 198 int result = ObjectUtils.nullSafeHashCode(this.type); 199 result = 31 * result + ObjectUtils.nullSafeHashCode(this.name); 200 result = 31 * result + ObjectUtils.nullSafeHashCode(this.filename); 201 result = 31 * result + ObjectUtils.nullSafeHashCode(this.charset); 202 result = 31 * result + ObjectUtils.nullSafeHashCode(this.size); 203 result = 31 * result + (this.creationDate != null ? this.creationDate.hashCode() : 0); 204 result = 31 * result + (this.modificationDate != null ? this.modificationDate.hashCode() : 0); 205 result = 31 * result + (this.readDate != null ? this.readDate.hashCode() : 0); 206 return result; 207 } 208 209 /** 210 * Return the header value for this content disposition as defined in RFC 6266. 211 * @see #parse(String) 212 */ 213 @Override 214 public String toString() { 215 StringBuilder sb = new StringBuilder(); 216 if (this.type != null) { 217 sb.append(this.type); 218 } 219 if (this.name != null) { 220 sb.append("; name=\""); 221 sb.append(this.name).append('\"'); 222 } 223 if (this.filename != null) { 224 if (this.charset == null || StandardCharsets.US_ASCII.equals(this.charset)) { 225 sb.append("; filename=\""); 226 sb.append(escapeQuotationsInFilename(this.filename)).append('\"'); 227 } 228 else { 229 sb.append("; filename*="); 230 sb.append(encodeFilename(this.filename, this.charset)); 231 } 232 } 233 if (this.size != null) { 234 sb.append("; size="); 235 sb.append(this.size); 236 } 237 if (this.creationDate != null) { 238 sb.append("; creation-date=\""); 239 sb.append(RFC_1123_DATE_TIME.format(this.creationDate)); 240 sb.append('\"'); 241 } 242 if (this.modificationDate != null) { 243 sb.append("; modification-date=\""); 244 sb.append(RFC_1123_DATE_TIME.format(this.modificationDate)); 245 sb.append('\"'); 246 } 247 if (this.readDate != null) { 248 sb.append("; read-date=\""); 249 sb.append(RFC_1123_DATE_TIME.format(this.readDate)); 250 sb.append('\"'); 251 } 252 return sb.toString(); 253 } 254 255 256 /** 257 * Return a builder for a {@code ContentDisposition}. 258 * @param type the disposition type like for example {@literal inline}, 259 * {@literal attachment}, or {@literal form-data} 260 * @return the builder 261 */ 262 public static Builder builder(String type) { 263 return new BuilderImpl(type); 264 } 265 266 /** 267 * Return an empty content disposition. 268 */ 269 public static ContentDisposition empty() { 270 return new ContentDisposition("", null, null, null, null, null, null, null); 271 } 272 273 /** 274 * Parse a {@literal Content-Disposition} header value as defined in RFC 2183. 275 * @param contentDisposition the {@literal Content-Disposition} header value 276 * @return the parsed content disposition 277 * @see #toString() 278 */ 279 public static ContentDisposition parse(String contentDisposition) { 280 List<String> parts = tokenize(contentDisposition); 281 String type = parts.get(0); 282 String name = null; 283 String filename = null; 284 Charset charset = null; 285 Long size = null; 286 ZonedDateTime creationDate = null; 287 ZonedDateTime modificationDate = null; 288 ZonedDateTime readDate = null; 289 for (int i = 1; i < parts.size(); i++) { 290 String part = parts.get(i); 291 int eqIndex = part.indexOf('='); 292 if (eqIndex != -1) { 293 String attribute = part.substring(0, eqIndex); 294 String value = (part.startsWith("\"", eqIndex + 1) && part.endsWith("\"") ? 295 part.substring(eqIndex + 2, part.length() - 1) : 296 part.substring(eqIndex + 1)); 297 if (attribute.equals("name") ) { 298 name = value; 299 } 300 else if (attribute.equals("filename*") ) { 301 int idx1 = value.indexOf('\''); 302 int idx2 = value.indexOf('\'', idx1 + 1); 303 if (idx1 != -1 && idx2 != -1) { 304 charset = Charset.forName(value.substring(0, idx1).trim()); 305 Assert.isTrue(UTF_8.equals(charset) || ISO_8859_1.equals(charset), 306 "Charset should be UTF-8 or ISO-8859-1"); 307 filename = decodeFilename(value.substring(idx2 + 1), charset); 308 } 309 else { 310 // US ASCII 311 filename = decodeFilename(value, StandardCharsets.US_ASCII); 312 } 313 } 314 else if (attribute.equals("filename") && (filename == null)) { 315 filename = value; 316 } 317 else if (attribute.equals("size") ) { 318 size = Long.parseLong(value); 319 } 320 else if (attribute.equals("creation-date")) { 321 try { 322 creationDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); 323 } 324 catch (DateTimeParseException ex) { 325 // ignore 326 } 327 } 328 else if (attribute.equals("modification-date")) { 329 try { 330 modificationDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); 331 } 332 catch (DateTimeParseException ex) { 333 // ignore 334 } 335 } 336 else if (attribute.equals("read-date")) { 337 try { 338 readDate = ZonedDateTime.parse(value, RFC_1123_DATE_TIME); 339 } 340 catch (DateTimeParseException ex) { 341 // ignore 342 } 343 } 344 } 345 else { 346 throw new IllegalArgumentException("Invalid content disposition format"); 347 } 348 } 349 return new ContentDisposition(type, name, filename, charset, size, creationDate, modificationDate, readDate); 350 } 351 352 private static List<String> tokenize(String headerValue) { 353 int index = headerValue.indexOf(';'); 354 String type = (index >= 0 ? headerValue.substring(0, index) : headerValue).trim(); 355 if (type.isEmpty()) { 356 throw new IllegalArgumentException("Content-Disposition header must not be empty"); 357 } 358 List<String> parts = new ArrayList<>(); 359 parts.add(type); 360 if (index >= 0) { 361 do { 362 int nextIndex = index + 1; 363 boolean quoted = false; 364 boolean escaped = false; 365 while (nextIndex < headerValue.length()) { 366 char ch = headerValue.charAt(nextIndex); 367 if (ch == ';') { 368 if (!quoted) { 369 break; 370 } 371 } 372 else if (!escaped && ch == '"') { 373 quoted = !quoted; 374 } 375 escaped = (!escaped && ch == '\\'); 376 nextIndex++; 377 } 378 String part = headerValue.substring(index + 1, nextIndex).trim(); 379 if (!part.isEmpty()) { 380 parts.add(part); 381 } 382 index = nextIndex; 383 } 384 while (index < headerValue.length()); 385 } 386 return parts; 387 } 388 389 /** 390 * Decode the given header field param as described in RFC 5987. 391 * <p>Only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported. 392 * @param filename the filename 393 * @param charset the charset for the filename 394 * @return the encoded header field param 395 * @see <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a> 396 */ 397 private static String decodeFilename(String filename, Charset charset) { 398 Assert.notNull(filename, "'input' String` should not be null"); 399 Assert.notNull(charset, "'charset' should not be null"); 400 byte[] value = filename.getBytes(charset); 401 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 402 int index = 0; 403 while (index < value.length) { 404 byte b = value[index]; 405 if (isRFC5987AttrChar(b)) { 406 baos.write((char) b); 407 index++; 408 } 409 else if (b == '%' && index < value.length - 2) { 410 char[] array = new char[]{(char) value[index + 1], (char) value[index + 2]}; 411 try { 412 baos.write(Integer.parseInt(String.valueOf(array), 16)); 413 } 414 catch (NumberFormatException ex) { 415 throw new IllegalArgumentException(INVALID_HEADER_FIELD_PARAMETER_FORMAT, ex); 416 } 417 index+=3; 418 } 419 else { 420 throw new IllegalArgumentException(INVALID_HEADER_FIELD_PARAMETER_FORMAT); 421 } 422 } 423 return StreamUtils.copyToString(baos, charset); 424 } 425 426 private static boolean isRFC5987AttrChar(byte c) { 427 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || 428 c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || c == '-' || 429 c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; 430 } 431 432 private static String escapeQuotationsInFilename(String filename) { 433 if (filename.indexOf('"') == -1 && filename.indexOf('\\') == -1) { 434 return filename; 435 } 436 boolean escaped = false; 437 StringBuilder sb = new StringBuilder(); 438 for (char c : filename.toCharArray()) { 439 sb.append((c == '"' && !escaped) ? "\\\"" : c); 440 escaped = (!escaped && c == '\\'); 441 } 442 // Remove backslash at the end.. 443 if (escaped) { 444 sb.deleteCharAt(sb.length() - 1); 445 } 446 return sb.toString(); 447 } 448 449 /** 450 * Encode the given header field param as describe in RFC 5987. 451 * @param input the header field param 452 * @param charset the charset of the header field param string, 453 * only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported 454 * @return the encoded header field param 455 * @see <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a> 456 */ 457 private static String encodeFilename(String input, Charset charset) { 458 Assert.notNull(input, "`input` is required"); 459 Assert.notNull(charset, "`charset` is required"); 460 Assert.isTrue(!StandardCharsets.US_ASCII.equals(charset), "ASCII does not require encoding"); 461 Assert.isTrue(UTF_8.equals(charset) || ISO_8859_1.equals(charset), "Only UTF-8 and ISO-8859-1 supported."); 462 byte[] source = input.getBytes(charset); 463 int len = source.length; 464 StringBuilder sb = new StringBuilder(len << 1); 465 sb.append(charset.name()); 466 sb.append("''"); 467 for (byte b : source) { 468 if (isRFC5987AttrChar(b)) { 469 sb.append((char) b); 470 } 471 else { 472 sb.append('%'); 473 char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16)); 474 char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16)); 475 sb.append(hex1); 476 sb.append(hex2); 477 } 478 } 479 return sb.toString(); 480 } 481 482 483 /** 484 * A mutable builder for {@code ContentDisposition}. 485 */ 486 public interface Builder { 487 488 /** 489 * Set the value of the {@literal name} parameter. 490 */ 491 Builder name(String name); 492 493 /** 494 * Set the value of the {@literal filename} parameter. The given 495 * filename will be formatted as quoted-string, as defined in RFC 2616, 496 * section 2.2, and any quote characters within the filename value will 497 * be escaped with a backslash, e.g. {@code "foo\"bar.txt"} becomes 498 * {@code "foo\\\"bar.txt"}. 499 */ 500 Builder filename(String filename); 501 502 /** 503 * Set the value of the {@literal filename*} that will be encoded as 504 * defined in the RFC 5987. Only the US-ASCII, UTF-8 and ISO-8859-1 505 * charsets are supported. 506 * <p><strong>Note:</strong> Do not use this for a 507 * {@code "multipart/form-data"} requests as per 508 * <a link="https://tools.ietf.org/html/rfc7578#section-4.2">RFC 7578, Section 4.2</a> 509 * and also RFC 5987 itself mentions it does not apply to multipart 510 * requests. 511 */ 512 Builder filename(String filename, Charset charset); 513 514 /** 515 * Set the value of the {@literal size} parameter. 516 * @deprecated since 5.2.3 as per 517 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 518 * to be removed in a future release. 519 */ 520 @Deprecated 521 Builder size(Long size); 522 523 /** 524 * Set the value of the {@literal creation-date} parameter. 525 * @deprecated since 5.2.3 as per 526 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 527 * to be removed in a future release. 528 */ 529 @Deprecated 530 Builder creationDate(ZonedDateTime creationDate); 531 532 /** 533 * Set the value of the {@literal modification-date} parameter. 534 * @deprecated since 5.2.3 as per 535 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 536 * to be removed in a future release. 537 */ 538 @Deprecated 539 Builder modificationDate(ZonedDateTime modificationDate); 540 541 /** 542 * Set the value of the {@literal read-date} parameter. 543 * @deprecated since 5.2.3 as per 544 * <a href="https://tools.ietf.org/html/rfc6266#appendix-B">RFC 6266, Apendix B</a>, 545 * to be removed in a future release. 546 */ 547 @Deprecated 548 Builder readDate(ZonedDateTime readDate); 549 550 /** 551 * Build the content disposition. 552 */ 553 ContentDisposition build(); 554 } 555 556 557 private static class BuilderImpl implements Builder { 558 559 private final String type; 560 561 @Nullable 562 private String name; 563 564 @Nullable 565 private String filename; 566 567 @Nullable 568 private Charset charset; 569 570 @Nullable 571 private Long size; 572 573 @Nullable 574 private ZonedDateTime creationDate; 575 576 @Nullable 577 private ZonedDateTime modificationDate; 578 579 @Nullable 580 private ZonedDateTime readDate; 581 582 public BuilderImpl(String type) { 583 Assert.hasText(type, "'type' must not be not empty"); 584 this.type = type; 585 } 586 587 @Override 588 public Builder name(String name) { 589 this.name = name; 590 return this; 591 } 592 593 @Override 594 public Builder filename(String filename) { 595 Assert.hasText(filename, "No filename"); 596 this.filename = filename; 597 return this; 598 } 599 600 @Override 601 public Builder filename(String filename, Charset charset) { 602 Assert.hasText(filename, "No filename"); 603 this.filename = filename; 604 this.charset = charset; 605 return this; 606 } 607 608 @Override 609 public Builder size(Long size) { 610 this.size = size; 611 return this; 612 } 613 614 @Override 615 public Builder creationDate(ZonedDateTime creationDate) { 616 this.creationDate = creationDate; 617 return this; 618 } 619 620 @Override 621 public Builder modificationDate(ZonedDateTime modificationDate) { 622 this.modificationDate = modificationDate; 623 return this; 624 } 625 626 @Override 627 public Builder readDate(ZonedDateTime readDate) { 628 this.readDate = readDate; 629 return this; 630 } 631 632 @Override 633 public ContentDisposition build() { 634 return new ContentDisposition(this.type, this.name, this.filename, this.charset, 635 this.size, this.creationDate, this.modificationDate, this.readDate); 636 } 637 } 638 639}