StreamedFile.java
/*
* Copyright 2017-2020 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.http.server.types.files;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.MediaType;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.exceptions.MessageBodyException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.time.Instant;
/**
* A special type for streaming an {@link InputStream} representing a file or resource.
*
* @author James Kleeh
* @since 1.0
*/
public class StreamedFile implements FileCustomizableResponseType {
private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray();
private final MediaType mediaType;
private final String name;
private final long lastModified;
private final InputStream inputStream;
private final long length;
private String attachmentName;
/**
* @param inputStream The input stream
* @param mediaType The media type of the content
*/
public StreamedFile(InputStream inputStream, MediaType mediaType) {
this(inputStream, mediaType, Instant.now().toEpochMilli());
}
/**
* @param inputStream The input stream
* @param mediaType The media type of the content
* @param lastModified The last modified date
*/
public StreamedFile(InputStream inputStream, MediaType mediaType, long lastModified) {
this(inputStream, mediaType, lastModified, -1);
}
/**
* @param inputStream The input stream
* @param mediaType The media type of the content
* @param lastModified The last modified date
* @param contentLength the content length
*/
public StreamedFile(InputStream inputStream, MediaType mediaType, long lastModified, long contentLength) {
this.mediaType = mediaType;
this.name = null;
this.lastModified = lastModified;
this.inputStream = inputStream;
this.length = contentLength;
}
/**
* Immediately opens a connection to the given URL to retrieve
* data about the connection, including the input stream.
*
* @param url The URL to resource
*/
public StreamedFile(URL url) {
String path = url.getPath();
int idx = path.lastIndexOf(File.separatorChar);
this.name = idx > -1 ? path.substring(idx + 1) : path;
this.mediaType = MediaType.forFilename(name);
try {
URLConnection con = url.openConnection();
this.lastModified = con.getLastModified();
this.inputStream = con.getInputStream();
this.length = con.getContentLengthLong();
} catch (IOException e) {
throw new MessageBodyException("Could not open a connection to the URL: " + path, e);
}
}
@Override
public long getLastModified() {
return lastModified;
}
@Override
public long getLength() {
return length;
}
@Override
public MediaType getMediaType() {
return mediaType;
}
/**
* @return The stream used to retrieve data for the file
*/
public InputStream getInputStream() {
return inputStream;
}
/**
* Sets the file to be downloaded as an attachment.
* The name is set in the Content-Disposition header.
*
* @param attachmentName The attachment name.
* @return The same StreamedFile instance
*/
public StreamedFile attach(String attachmentName) {
this.attachmentName = attachmentName;
return this;
}
@Override
public void process(MutableHttpResponse<?> response) {
if (attachmentName != null) {
response.header(HttpHeaders.CONTENT_DISPOSITION, buildAttachmentHeader(attachmentName));
}
}
static String buildAttachmentHeader(String attachmentName) {
// https://httpwg.org/specs/rfc6266.html#advice.generating
// 'filename' parameter is the fallback for legacy browsers, 'filename*' is the supported approach.
return "attachment; filename=\"" + sanitizeAscii(attachmentName) + "\"; filename*=utf-8''" + encodeRfc6987(attachmentName);
}
private static String sanitizeAscii(String s) {
StringBuilder builder = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// " ends the string
if (c >= 32 && c < 127 && c != '"') {
builder.append(c);
}
}
return builder.toString();
}
// this is mostly copied from netty QueryStringEncoder
@SuppressWarnings({"java:S3776", "java:S135", "java:S127"}) // stay close to netty impl
static String encodeRfc6987(String s) {
StringBuilder uriBuilder = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x80) {
if (dontNeedEncoding(c)) {
uriBuilder.append(c);
} else {
appendEncoded(uriBuilder, c);
}
} else if (c < 0x800) {
appendEncoded(uriBuilder, 0xc0 | (c >> 6));
appendEncoded(uriBuilder, 0x80 | (c & 0x3f));
} else if (Character.isSurrogate(c)) {
if (!Character.isHighSurrogate(c)) {
appendEncoded(uriBuilder, '?');
continue;
}
// Surrogate Pair consumes 2 characters.
if (++i == s.length()) {
appendEncoded(uriBuilder, '?');
break;
}
// Extra method to allow inlining the rest of writeUtf8 which is the most likely code path.
writeUtf8Surrogate(uriBuilder, c, s.charAt(i));
} else {
appendEncoded(uriBuilder, 0xe0 | (c >> 12));
appendEncoded(uriBuilder, 0x80 | ((c >> 6) & 0x3f));
appendEncoded(uriBuilder, 0x80 | (c & 0x3f));
}
}
return uriBuilder.toString();
}
private static boolean dontNeedEncoding(char ch) {
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9'
|| ch == '-' || ch == '_' || ch == '.' || ch == '*' || ch == '~';
}
private static void appendEncoded(StringBuilder uriBuilder, int b) {
uriBuilder.append('%').append(HEX_DIGITS[(b >> 4) & 0xf]).append(HEX_DIGITS[b & 0xf]);
}
private static void writeUtf8Surrogate(StringBuilder uriBuilder, char c, char c2) {
if (!Character.isLowSurrogate(c2)) {
appendEncoded(uriBuilder, '?');
appendEncoded(uriBuilder, Character.isHighSurrogate(c2) ? '?' : c2);
return;
}
int codePoint = Character.toCodePoint(c, c2);
// See https://www.unicode.org/versions/Unicode7.0.0/ch03.pdf#G2630.
appendEncoded(uriBuilder, 0xf0 | (codePoint >> 18));
appendEncoded(uriBuilder, 0x80 | ((codePoint >> 12) & 0x3f));
appendEncoded(uriBuilder, 0x80 | ((codePoint >> 6) & 0x3f));
appendEncoded(uriBuilder, 0x80 | (codePoint & 0x3f));
}
}