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}