| /* |
| * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved. |
| * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. |
| * |
| * This code is free software; you can redistribute it and/or modify it |
| * under the terms of the GNU General Public License version 2 only, as |
| * published by the Free Software Foundation. Oracle designates this |
| * particular file as subject to the "Classpath" exception as provided |
| * by Oracle in the LICENSE file that accompanied this code. |
| * |
| * This code is distributed in the hope that it will be useful, but WITHOUT |
| * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License |
| * version 2 for more details (a copy is included in the LICENSE file that |
| * accompanied this code). |
| * |
| * You should have received a copy of the GNU General Public License version |
| * 2 along with this work; if not, write to the Free Software Foundation, |
| * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. |
| * |
| * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA |
| * or visit www.oracle.com if you need additional information or have any |
| * questions. |
| */ |
| |
| package jdk.internal.net.http; |
| |
| import java.io.IOException; |
| import java.io.UncheckedIOException; |
| import java.net.URI; |
| import java.nio.file.Files; |
| import java.nio.file.OpenOption; |
| import java.nio.file.Path; |
| import java.nio.file.Paths; |
| import java.util.List; |
| |
| import java.util.concurrent.CompletableFuture; |
| import java.util.concurrent.ConcurrentMap; |
| import java.util.function.Function; |
| import java.net.http.HttpRequest; |
| import java.net.http.HttpResponse; |
| import java.net.http.HttpResponse.BodyHandler; |
| import java.net.http.HttpResponse.ResponseInfo; |
| import java.net.http.HttpResponse.BodySubscriber; |
| import java.util.Set; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import jdk.internal.net.http.ResponseSubscribers.PathSubscriber; |
| import jdk.internal.util.Exceptions; |
| import static java.util.regex.Pattern.CASE_INSENSITIVE; |
| |
| public final class ResponseBodyHandlers { |
| |
| private ResponseBodyHandlers() { } |
| |
| /** |
| * A Path body handler. |
| */ |
| public static class PathBodyHandler implements BodyHandler<Path>{ |
| private final Path file; |
| private final List<OpenOption> openOptions; // immutable list |
| |
| /** |
| * Factory for creating PathBodyHandler. |
| */ |
| public static PathBodyHandler create(Path file, |
| List<OpenOption> openOptions) { |
| |
| return new PathBodyHandler(file, openOptions); |
| } |
| |
| private PathBodyHandler(Path file, |
| List<OpenOption> openOptions) { |
| this.file = file; |
| this.openOptions = openOptions; |
| } |
| |
| @Override |
| public BodySubscriber<Path> apply(ResponseInfo responseInfo) { |
| return new PathSubscriber(file, openOptions); |
| } |
| } |
| |
| /** With push promise Map implementation */ |
| public static class PushPromisesHandlerWithMap<T> |
| implements HttpResponse.PushPromiseHandler<T> |
| { |
| private final ConcurrentMap<HttpRequest,CompletableFuture<HttpResponse<T>>> pushPromisesMap; |
| private final Function<HttpRequest,BodyHandler<T>> pushPromiseHandler; |
| |
| public PushPromisesHandlerWithMap(Function<HttpRequest,BodyHandler<T>> pushPromiseHandler, |
| ConcurrentMap<HttpRequest,CompletableFuture<HttpResponse<T>>> pushPromisesMap) { |
| this.pushPromiseHandler = pushPromiseHandler; |
| this.pushPromisesMap = pushPromisesMap; |
| } |
| |
| @Override |
| public void applyPushPromise( |
| HttpRequest initiatingRequest, HttpRequest pushRequest, |
| Function<BodyHandler<T>,CompletableFuture<HttpResponse<T>>> acceptor) |
| { |
| URI initiatingURI = initiatingRequest.uri(); |
| URI pushRequestURI = pushRequest.uri(); |
| if (!initiatingURI.getHost().equalsIgnoreCase(pushRequestURI.getHost())) |
| return; |
| |
| String initiatingScheme = initiatingURI.getScheme(); |
| String pushRequestScheme = pushRequestURI.getScheme(); |
| |
| if (!initiatingScheme.equalsIgnoreCase(pushRequestScheme)) return; |
| |
| int initiatingPort = initiatingURI.getPort(); |
| if (initiatingPort == -1 ) { |
| if ("https".equalsIgnoreCase(initiatingScheme)) |
| initiatingPort = 443; |
| else |
| initiatingPort = 80; |
| } |
| int pushPort = pushRequestURI.getPort(); |
| if (pushPort == -1 ) { |
| if ("https".equalsIgnoreCase(pushRequestScheme)) |
| pushPort = 443; |
| else |
| pushPort = 80; |
| } |
| if (initiatingPort != pushPort) |
| return; |
| |
| CompletableFuture<HttpResponse<T>> cf = |
| acceptor.apply(pushPromiseHandler.apply(pushRequest)); |
| pushPromisesMap.put(pushRequest, cf); |
| } |
| } |
| |
| // Similar to Path body handler, but for file download. |
| public static class FileDownloadBodyHandler implements BodyHandler<Path> { |
| private final Path directory; |
| private final List<OpenOption> openOptions; |
| |
| /** |
| * Factory for creating FileDownloadBodyHandler. |
| */ |
| public static FileDownloadBodyHandler create(Path directory, |
| List<OpenOption> openOptions) { |
| try { |
| directory.toFile().getPath(); |
| } catch (UnsupportedOperationException uoe) { |
| // directory not associated with the default file system provider |
| throw new IllegalArgumentException("invalid path: " + directory, uoe); |
| } |
| |
| // existence, etc, checks must be after FS checks |
| if (Files.notExists(directory)) |
| throw new IllegalArgumentException("non-existent directory: " + directory); |
| if (!Files.isDirectory(directory)) |
| throw new IllegalArgumentException("not a directory: " + directory); |
| if (!Files.isWritable(directory)) |
| throw new IllegalArgumentException("non-writable directory: " + directory); |
| |
| return new FileDownloadBodyHandler(directory, openOptions); |
| } |
| |
| private FileDownloadBodyHandler(Path directory, |
| List<OpenOption> openOptions) { |
| this.directory = directory; |
| this.openOptions = openOptions; |
| } |
| |
| /** The "attachment" disposition-type and separator. */ |
| static final String DISPOSITION_TYPE = "attachment;"; |
| |
| /** The "filename" parameter. */ |
| static final Pattern FILENAME = Pattern.compile("filename\\s*=\\s*", CASE_INSENSITIVE); |
| |
| static final List<String> PROHIBITED = List.of(".", "..", "", "~" , "|"); |
| |
| // Characters disallowed in token values |
| |
| static final Set<Character> NOT_ALLOWED_IN_TOKEN = Set.of( |
| '(', ')', '<', '>', '@', |
| ',', ';', ':', '\\', '"', |
| '/', '[', ']', '?', '=', |
| '{', '}', ' ', '\t'); |
| |
| static boolean allowedInToken(char c) { |
| if (NOT_ALLOWED_IN_TOKEN.contains(c)) |
| return false; |
| // exclude CTL chars <= 31, == 127, or anything >= 128 |
| return isTokenText(c); |
| } |
| |
| static final UncheckedIOException unchecked(ResponseInfo rinfo, |
| String msg) { |
| String s; |
| if (Exceptions.enhancedNonSocketExceptions()) { |
| s = String.format("%s in response [%d, %s]", msg, rinfo.statusCode(), rinfo.headers()); |
| } else { |
| s = String.format("%s in response [%d]", msg, rinfo.statusCode()); |
| } |
| return new UncheckedIOException(new IOException(s)); |
| } |
| |
| static final UncheckedIOException unchecked(String msg) { |
| return new UncheckedIOException(new IOException(msg)); |
| } |
| |
| // Process a "filename=" parameter, which is either a "token" |
| // or a "quoted string". If a token, it is terminated by a |
| // semicolon or the end of the string. |
| // If a quoted string (surrounded by "" chars then the closing " |
| // terminates the name. |
| // quoted strings may contain quoted-pairs (eg embedded " chars) |
| |
| static String processFilename(String src) throws UncheckedIOException { |
| if ("".equals(src)) |
| return src; |
| if (src.charAt(0) == '\"') { |
| return processQuotedString(src.substring(1)); |
| } else { |
| return processToken(src); |
| } |
| } |
| |
| static boolean isTokenText(char c) throws UncheckedIOException { |
| return c > 31 && c < 127; |
| } |
| |
| static boolean isQuotedStringText(char c) throws UncheckedIOException { |
| return c > 31; |
| } |
| |
| static String processQuotedString(String src) throws UncheckedIOException { |
| boolean inqpair = false; |
| int len = src.length(); |
| StringBuilder sb = new StringBuilder(); |
| |
| for (int i=0; i<len; i++) { |
| char c = src.charAt(i); |
| if (!isQuotedStringText(c)) { |
| throw unchecked("Illegal character"); |
| } |
| if (c == '\"') { |
| if (!inqpair) { |
| return sb.toString(); |
| } else { |
| sb.append(c); |
| } |
| } else if (c == '\\') { |
| if (!inqpair) { |
| inqpair = true; |
| continue; |
| } else { |
| // the quoted char is '\' |
| sb.append(c); |
| } |
| } else { |
| sb.append(c); |
| } |
| if (inqpair) { |
| inqpair = false; |
| } |
| } |
| // not terminated by " |
| throw unchecked("Invalid quoted string"); |
| } |
| |
| static String processToken(String src) throws UncheckedIOException { |
| int end = 0; |
| int len = src.length(); |
| boolean whitespace = false; |
| |
| for (int i=0; i<len; i++) { |
| char c = src.charAt(i); |
| if (c == ';') { |
| break; |
| } |
| if (c == ' ' || c == '\t') { |
| // WS only until ; or end of string |
| whitespace = true; |
| continue; |
| } |
| end++; |
| if (whitespace || !allowedInToken(c)) { |
| String msg = whitespace ? "whitespace must be followed by a semicolon" |
| : c + " is not allowed in a token"; |
| throw unchecked(msg); |
| } |
| } |
| return src.substring(0, end); |
| } |
| |
| @Override |
| public BodySubscriber<Path> apply(ResponseInfo responseInfo) { |
| String dispoHeader = responseInfo.headers().firstValue("Content-Disposition") |
| .orElseThrow(() -> unchecked(responseInfo, "No Content-Disposition header")); |
| |
| if (!dispoHeader.regionMatches(true, // ignoreCase |
| 0, DISPOSITION_TYPE, |
| 0, DISPOSITION_TYPE.length())) { |
| throw unchecked(responseInfo, "Unknown Content-Disposition type"); |
| } |
| |
| Matcher matcher = FILENAME.matcher(dispoHeader); |
| if (!matcher.find()) { |
| throw unchecked(responseInfo, "Bad Content-Disposition filename parameter"); |
| } |
| int n = matcher.end(); |
| |
| String filenameParam = processFilename(dispoHeader.substring(n)); |
| |
| // strip all but the last path segment |
| int x = filenameParam.lastIndexOf("/"); |
| if (x != -1) { |
| filenameParam = filenameParam.substring(x+1); |
| } |
| x = filenameParam.lastIndexOf("\\"); |
| if (x != -1) { |
| filenameParam = filenameParam.substring(x+1); |
| } |
| |
| filenameParam = filenameParam.trim(); |
| |
| if (PROHIBITED.contains(filenameParam)) { |
| throw unchecked(responseInfo, |
| "Prohibited Content-Disposition filename parameter:" |
| + filenameParam); |
| } |
| |
| Path file = Paths.get(directory.toString(), filenameParam); |
| |
| if (!file.startsWith(directory)) { |
| throw unchecked(responseInfo, |
| "Resulting file, " + file.toString() + ", outside of given directory"); |
| } |
| |
| return new PathSubscriber(file, openOptions); |
| } |
| } |
| } |