From 1f79ce55cee5cfdb7bb0addac5947927552b8bee Mon Sep 17 00:00:00 2001 From: Paul Millar Date: Fri, 25 Aug 2023 00:41:51 +0200 Subject: [PATCH] webdav: add metalink support Motivation: Metalink (see RFC 5854) is a standard XML-based format for describing how to download a bunch of files. In addition to providing the URLs, it includes some file-level metadata; for example, file size and checksums. There are several programs available that support metalink (e.g., aria2). Metalink/HTTP (see RFC 6249) is a standard approach for embedding metalink metadata into HTTP response headers. Metalink/HTTP also describes how to link a resource to a corresponding metalink description. A directory is often used to group together related content and sometimes people would like to download all of that related content; i.e., download all files in a directory. Therefore, providing a metalink description of the contents of a directory would allow a client (that supports the format) to download all files from that directory. Modification: Refactor content-negotiation support to make it more modular. Update the GET response of DcacheDirectoryResource to support multiple formats; triggered by content-negotiation or by a query parameter in the URL. Add the ability to render a directory listing into an XML document that follows the metalink format. NB. This patch provides a valid, working proof-of-concept implementation. There are (deliberately) some limitations; in particular, it a. includes only the immediate children of the directory: there is no recursion, b. simply includes entries each file; in effect, asuming that all files are either public or the client is able to authenticate. The patch also updates the HTML-based directory GET and HEAD responses so they include an HTTP "Link" response header that identifies that directory's corresponding metalink description, as described by RFC 6249. Result: The WebDAV endpoint now provides a metalink description of a directory's (immediate) contents, simplifying the process of downloading files from a directory. The description is available through either HTTP content negotiation or including a query parameter in the URL. The HTML page (describing a directory) also includes a link to the metalink description. Target: master Requires-notes: yes Requires-book: no Patch: https://rb.dcache.org/r/14078/ Acked-by: Tigran Mkrtchyan --- .../webdav/AcceptAwareResponseHandler.java | 95 +---------- .../webdav/DcacheDirectoryResource.java | 81 ++++++++- .../dcache/webdav/DcacheResourceFactory.java | 135 +++++++++++++++ .../main/java/org/dcache/webdav/Requests.java | 161 ++++++++++++++++++ .../java/org/dcache/webdav/RequestsTest.java | 110 ++++++++++++ 5 files changed, 486 insertions(+), 96 deletions(-) create mode 100644 modules/dcache-webdav/src/main/java/org/dcache/webdav/Requests.java create mode 100644 modules/dcache-webdav/src/test/java/org/dcache/webdav/RequestsTest.java diff --git a/modules/dcache-webdav/src/main/java/org/dcache/webdav/AcceptAwareResponseHandler.java b/modules/dcache-webdav/src/main/java/org/dcache/webdav/AcceptAwareResponseHandler.java index 13e0f2448b2..afde38503ae 100644 --- a/modules/dcache-webdav/src/main/java/org/dcache/webdav/AcceptAwareResponseHandler.java +++ b/modules/dcache-webdav/src/main/java/org/dcache/webdav/AcceptAwareResponseHandler.java @@ -1,7 +1,7 @@ /* * dCache - http://www.dcache.org/ * - * Copyright (C) 2021 Deutsches Elektronen-Synchrotron + * Copyright (C) 2021-2023 Deutsches Elektronen-Synchrotron * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -20,11 +20,8 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; -import static java.util.Comparator.comparingDouble; import static java.util.Objects.requireNonNull; -import com.google.common.base.Splitter; -import com.google.common.collect.Multimaps; import com.google.common.net.MediaType; import io.milton.http.HrefStatus; import io.milton.http.Range; @@ -56,15 +53,6 @@ public class AcceptAwareResponseHandler implements WebDavResponseHandler, Buffer private static final Logger LOGGER = LoggerFactory.getLogger(AcceptAwareResponseHandler.class); - /** - * Describes which handler to prefer if the client's Accept request header highest q-value - * supported selects multiple handlers, of which none are the default handler. This exists - * mostly to provide consistent behaviour: the exact choice (probably) doesn't matter too much. - */ - private static final Comparator PREFERRING_SHORTER_NAMES = - Comparator.comparingInt(m -> m.toString().length()) - .thenComparing(Object::toString); - private final Map handlers = new HashMap<>(); private MediaType defaultType; private WebDavResponseHandler defaultHandler; @@ -260,84 +248,7 @@ public String generateEtag(Resource r) { private WebDavResponseHandler selectHandler(Request request) { String accept = request.getRequestHeader(Request.Header.ACCEPT); - - if (accept == null) { - LOGGER.debug("Client did not specify Accept header," - + " responding with default MIME-Type \"{}\"", defaultType); - return defaultHandler; - } - - LOGGER.debug("Client indicated response preference as \"Accept: {}\"", accept); - var acceptMimeTypes = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(accept); - Comparator preferDefaultType = (MediaType m1, MediaType m2) - -> m1.equals(defaultType) ? -1 : m2.equals(defaultType) ? 1 : 0; - - try { - var responseType = acceptMimeTypes.stream() - .map(MediaType::parse) - .sorted(comparingDouble(AcceptAwareResponseHandler::qValueOf).reversed()) - .map(AcceptAwareResponseHandler::dropQParameter) - .flatMap(acceptType -> handlers.keySet().stream() - .filter(m -> m.is(acceptType)) - .sorted(preferDefaultType.thenComparing(PREFERRING_SHORTER_NAMES))) - .findFirst(); - - responseType.ifPresent(m -> LOGGER.debug("Responding with MIME-Type \"{}\"", m)); - - return responseType.map(handlers::get).orElseGet(() -> { - LOGGER.debug("Responding with default MIME-Type \"{}\"", defaultType); - return defaultHandler; - }); - } catch (IllegalArgumentException e) { - // Client supplied an invalid media type. Oh well, let's use a default. - LOGGER.debug("Client supplied invalid Accept header \"{}\": {}", - accept, e.getMessage()); - return defaultHandler; - } - } - - /** - * Filter out the 'q' value from the MIME-Type, if one is present. This is needed because the - * MIME-Type matching requires the server supports all parameters the client supplied, which - * includes the 'q' value. As examples: {@literal "Accept: text/plain" matches - * "text/plain;charset=UTF_8" "Accept: text/plain;charset=UTF_8" matches - * "text/plain;charset=UTF_8" "Accept: text/plain;q=0.5" does NOT match - * "text/plain;charset=UTF_8" } as there is no {@literal q} parameter in the right-hand-side. - *

- * Stripping off the q value allows {@literal Accept: text/plain;q=0.5} (matched as {@literal - * text/plain}) to match {@literal text/plain;charset=UTF_8}. - */ - private static MediaType dropQParameter(MediaType acceptType) { - var params = acceptType.parameters(); - - MediaType typeWithoutQ; - if (params.get("q").isEmpty()) { - LOGGER.debug("MIME-Type \"{}\" has no q-value", acceptType); - typeWithoutQ = acceptType; - } else { - var paramsWithoutQ = Multimaps.filterKeys(params, k -> !k.equals("q")); - typeWithoutQ = acceptType.withParameters(paramsWithoutQ); - LOGGER.debug("Stripping q-value from MIME-Type \"{}\" --> \"{}\"", - acceptType, typeWithoutQ); - } - - return typeWithoutQ; - } - - private static float qValueOf(MediaType m) { - List qValues = m.parameters().get("q"); - - if (qValues.isEmpty()) { - return 1.0f; - } - - String lastQValue = qValues.get(qValues.size() - 1); - try { - return Float.parseFloat(lastQValue); - } catch (NumberFormatException e) { - LOGGER.debug("MIME-Type \"{}\" has invalid q value: {}", m, - lastQValue); - return 1.0f; - } + var type = Requests.selectResponseType(accept, handlers.keySet(), defaultType); + return handlers.get(type); } } diff --git a/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheDirectoryResource.java b/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheDirectoryResource.java index be9468e8f86..6e5db286a03 100644 --- a/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheDirectoryResource.java +++ b/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheDirectoryResource.java @@ -5,6 +5,7 @@ import static org.dcache.namespace.FileAttribute.STORAGEINFO; import com.google.common.collect.ImmutableSet; +import com.google.common.net.MediaType; import diskCacheV111.services.space.Space; import diskCacheV111.services.space.SpaceException; import diskCacheV111.util.CacheException; @@ -20,6 +21,7 @@ import io.milton.http.LockToken; import io.milton.http.Range; import io.milton.http.Request; +import io.milton.http.Response; import io.milton.http.exceptions.BadRequestException; import io.milton.http.exceptions.ConflictException; import io.milton.http.exceptions.NotAuthorizedException; @@ -39,6 +41,7 @@ import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; +import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collections; @@ -47,6 +50,7 @@ import java.util.Optional; import javax.xml.namespace.QName; import org.dcache.space.ReservationCaches; +import javax.xml.stream.XMLStreamException; import org.dcache.vehicles.FileAttributes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +64,13 @@ public class DcacheDirectoryResource MakeCollectionableResource, LockingCollectionResource, MultiNamespaceCustomPropertyResource { + /** + * An EntityWriter provides the entity (i.e., the contents) of a GET request. + */ + private interface EntityWriter { + public void writeEntity(Writer writer) throws InterruptedException, CacheException, IOException; + }; + private static final Logger LOGGER = LoggerFactory.getLogger(DcacheDirectoryResource.class); private static final String DAV_NAMESPACE_URI = "DAV:"; @@ -73,6 +84,16 @@ public class DcacheDirectoryResource private static final PropertyMetaData READONLY_LONG = new PropertyMetaData(READ_ONLY, Long.class); + private static final MediaType DEFAULT_ENTITY_TYPE = MediaType.HTML_UTF_8; + private static final MediaType METALINK_ENTITY_TYPE = MediaType.create("application", "metalink4+xml"); + + private final Map supportedMediaTypes = Map.of( + DEFAULT_ENTITY_TYPE, this::htmlEntity, + METALINK_ENTITY_TYPE, this::metalinkEntity); + + private final Map supportedResponseMediaTypes = Map.of( + "metalink", METALINK_ENTITY_TYPE); + private final boolean _allAttributes; public DcacheDirectoryResource(DcacheResourceFactory factory, @@ -157,9 +178,10 @@ public void sendContent(OutputStream out, Range range, throws IOException, NotAuthorizedException { try { Writer writer = new OutputStreamWriter(out, UTF_8); - if (!_factory.deliverClient(_path, writer)) { - _factory.list(_path, writer); - } + MediaType type = MediaType.parse(contentType); + EntityWriter entityWriter = Optional.ofNullable(supportedMediaTypes.get(type)) + .orElseThrow(); + entityWriter.writeEntity(writer); writer.flush(); } catch (PermissionDeniedCacheException e) { throw WebDavExceptions.permissionDenied(this); @@ -171,6 +193,27 @@ public void sendContent(OutputStream out, Range range, } } + private void htmlEntity(Writer writer) throws IOException, InterruptedException, + CacheException { + if (_factory.deliverClient(_path, writer)) { + return; + } + + _factory.list(_path, writer); + } + + private void metalinkEntity(Writer writer) throws IOException, + InterruptedException, CacheException { + Request request = HttpManager.request(); + // NB. Milton ensures directory URLs end with a '/' by issuing a redirection if not. + URI uri = URI.create(request.getAbsoluteUrl()); + try { + _factory.metalink(_path, writer, uri); + } catch (XMLStreamException e) { + throw new WebDavException("Failed to write metalink description: " + e, this); + } + } + @Override public Long getMaxAgeSeconds(Auth auth) { return null; @@ -178,7 +221,37 @@ public Long getMaxAgeSeconds(Auth auth) { @Override public String getContentType(String accepts) { - return "text/html; charset=utf-8"; + Request request = HttpManager.request(); + Map params = request.getParams(); + MediaType type = Optional.ofNullable(params) + .map(p -> p.get("type")) + .flatMap(Optional::ofNullable) + .map(supportedResponseMediaTypes::get) + .flatMap(Optional::ofNullable) + .orElseGet(() -> Requests.selectResponseType(accepts, + supportedMediaTypes.keySet(), DEFAULT_ENTITY_TYPE)); + + // We must set the "Link" HTTP response header here, as we want it to appear for both HEAD + // and GET requests, and (not unreasonably) Milton doesn't call sendContent for HEAD requests. + if (type.equals(DEFAULT_ENTITY_TYPE)) { + /* There is a slight subtly here. A GET request that targets a directory with a + * non-trailing-slash URL (e.g., "https://example.org/my-directory") triggers Milton to + * issue a redirection to the corresponding trailing-slash URL + * (e.g., "https://example.org/my-directory/"). This redirection does not happen for + * HEAD requets. Therefore, metalinkUrl may be a non-trailing-slash URL for HEAD + * requests, while this cannot happen for GET requests. + * + * A non-trailing-slash metalinkUrl value is not a problem as a corresponding GET + * request will trigger a similiar redirection (to the equivalent trailing-slash URL) + * while preserving the query parameter. + */ + String metalinkUrl = HttpManager.request().getAbsoluteUrl() + "?type=metalink"; + String linkValue = String.format("<%s>; rel=describedby; type=\"%s\"", metalinkUrl, + METALINK_ENTITY_TYPE); + HttpManager.response().setNonStandardHeader("Link", linkValue); + } + + return type.toString(); } @Override diff --git a/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheResourceFactory.java b/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheResourceFactory.java index 59918d4d7a3..6669a11dcfd 100644 --- a/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheResourceFactory.java +++ b/modules/dcache-webdav/src/main/java/org/dcache/webdav/DcacheResourceFactory.java @@ -40,6 +40,7 @@ import com.google.common.collect.Sets; import com.google.common.net.InetAddresses; import com.google.common.net.MediaType; +import com.google.common.net.PercentEscaper; import diskCacheV111.poolManager.PoolMonitorV5; import diskCacheV111.services.space.Space; import diskCacheV111.services.space.SpaceException; @@ -113,6 +114,9 @@ import javax.annotation.PostConstruct; import javax.security.auth.Subject; import javax.servlet.http.HttpServletRequest; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; import org.dcache.auth.Origin; import org.dcache.auth.RolePrincipal; import org.dcache.auth.RolePrincipal.Role; @@ -167,6 +171,9 @@ public class DcacheResourceFactory private static final Logger LOGGER = LoggerFactory.getLogger(DcacheResourceFactory.class); + private static final XMLOutputFactory XML_OUTPUT_FACTORY = XMLOutputFactory.newFactory(); + + public static final String TRANSACTION_ATTRIBUTE = "org.dcache.transaction"; private static final Set MINIMALLY_REQUIRED_ATTRIBUTES = @@ -203,6 +210,15 @@ enum PropfindProperties { CLIENT_COMPATIBLE }; + private static final PercentEscaper METALINK_NAME_ESCAPER = new PercentEscaper("", false); + + // See https://www.iana.org/assignments/hash-function-text-names/hash-function-text-names.xhtml + private static final Map METALINK_NAMES_FOR_CHECKSUMS = Map.of( + ChecksumType.MD5_TYPE, "md5", + ChecksumType.SHA1, "sha-1", + ChecksumType.SHA256, "sha-256", + ChecksumType.SHA512, "sha-512"); + /** * In progress transfers. The key of the map is the session id of the transfer. *

@@ -1089,6 +1105,125 @@ public void print(FsPath dir, FileAttributes dirAttr, DirectoryEntry entry) { t.write(new AutoIndentWriter(out)); } + /** + * Performs a directory listing, writing a metalink description. + */ + public void metalink(FsPath path, Writer out, URI uri) + throws InterruptedException, CacheException, IOException, XMLStreamException { + if (!_isAnonymousListingAllowed && Subjects.isNobody(getSubject())) { + throw new PermissionDeniedCacheException("Access denied"); + } + + XMLStreamWriter sw = XML_OUTPUT_FACTORY.createXMLStreamWriter(out); + + sw.writeStartDocument(); + + DirectoryListPrinter printer = + new DirectoryListPrinter() { + @Override + public Set getRequiredAttributes() { + return Set.of(MODIFICATION_TIME, TYPE, SIZE, CHECKSUM); + } + + private FileAttributes entryAttributes(FsPath dir, DirectoryEntry entry) { + FileAttributes attr = entry.getFileAttributes(); + switch (attr.getFileType()) { + case LINK: + String entryPath = dir.child(entry.getName()).toString(); + try { + return _pnfs.getFileAttributes(entryPath, + getRequiredAttributes()); + } catch (CacheException e) { + LOGGER.debug("Symlink lookup of {} failed with {}", + entryPath, e.getMessage()); + return attr; + } + + default: + return attr; + } + } + + @Override + public void print(FsPath dir, FileAttributes dirAttr, DirectoryEntry entry) { + FileAttributes attr = entryAttributes(dir, entry); + var mtime = Instant.ofEpochMilli(attr.getModificationTime()); + String safeName = METALINK_NAME_ESCAPER.escape(entry.getName()); + URI target = uri.resolve(safeName); + + if (attr.getFileType() != REGULAR && attr.getFileType() != LINK) { + return; + } + + /* FIXME: SIZE is defined if client specifies the + * file's size before uploading. + */ + if (attr.getFileType() == REGULAR && !attr.isDefined(SIZE)) { + return; + } + + try { + sw.writeStartElement("file"); + try { + sw.writeAttribute("name", entry.getName()); + + if (attr.isDefined(SIZE)) { + sw.writeStartElement("size"); + try { + sw.writeCharacters(Long.toString(attr.getSize())); + } finally { + sw.writeEndElement(); + } + } + if (attr.isDefined(CHECKSUM)) { + for (Checksum checksum : attr.getChecksums()) { + String type = METALINK_NAMES_FOR_CHECKSUMS.get(checksum.getType()); + if (type == null) { + continue; + } + sw.writeStartElement("hash"); + try { + sw.writeAttribute("type", type); + sw.writeCharacters(checksum.getValue()); + } finally { + sw.writeEndElement(); + } + } + } + sw.writeStartElement("url"); + try { + sw.writeCharacters(target.toASCIIString()); + } finally { + sw.writeEndElement(); + } + sw.writeStartElement("updated"); + try { + sw.writeCharacters(mtime.toString()); + } finally { + sw.writeEndElement(); + } + } finally { + sw.writeEndElement(); + } + } catch (XMLStreamException e) { + LOGGER.warn("Failed to process directory item {}: {}", entry.getName(), + e.toString()); + } + } + }; + + sw.writeStartElement("metalink"); + sw.writeDefaultNamespace("urn:ietf:params:xml:ns:metalink"); + try { + _list.printDirectory(getSubject(), getRestriction(), printer, path, null, + Range.all()); + } finally { + sw.writeEndElement(); + } + + sw.writeEndDocument(); + } + /** * Deletes a file. */ diff --git a/modules/dcache-webdav/src/main/java/org/dcache/webdav/Requests.java b/modules/dcache-webdav/src/main/java/org/dcache/webdav/Requests.java new file mode 100644 index 00000000000..c6ff1300e02 --- /dev/null +++ b/modules/dcache-webdav/src/main/java/org/dcache/webdav/Requests.java @@ -0,0 +1,161 @@ +/* + * dCache - http://www.dcache.org/ + * + * Copyright (C) 2023 Deutsches Elektronen-Synchrotron + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.dcache.webdav; + +import static java.util.Comparator.comparingDouble; + +import com.google.common.base.Splitter; +import com.google.common.collect.Multimaps; +import com.google.common.net.MediaType; +import java.util.Comparator; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; +import java.util.Collection; + +/** + * Utility class for handling common aspects of an HTTP request. + */ +public class Requests { + + private static final Logger LOGGER = LoggerFactory.getLogger(Requests.class); + + /** + * Describes which handler to prefer if the client's Accept request header highest q-value + * supported selects multiple handlers, of which none are the default handler. This exists + * mostly to provide consistent behaviour: the exact choice (probably) doesn't matter too much. + */ + private static final Comparator PREFERRING_SHORTER_NAMES = + Comparator.comparingInt(m -> m.toString().length()) + .thenComparing(Object::toString); + private static final Comparator PREFERRING_NON_WILDCARD_TYPES = (MediaType m1, MediaType m2) + -> m1.hasWildcard() == m2.hasWildcard() ? 0 : m1.hasWildcard() ? 1 : -1; + + private Requests() { /* Prevent initialisation */ } + + /** + * Choose a MediaType based on the client's Accept request header value. + *

+ * The supportedTypes collection is used to filter the accept request + * header terms. The q-values are honoured, if specified. For different + * values with the same q-value, the selection favours non-wildcard over + * wildcard type. If a wildcard matches then the default is preferred. If + * the default type is not selected then selection favours shorter named + * types. + *

+ * The default media type is used if the client doesn't provide any + * indication of which media type is desired or the client does not + * request any of the supported types, or if the matching term is a + * wildcard. + *

+ * It is not required that the default type is part of the + * collection of supported types; however, if the default type is missing + * from the supportedTypes then the resulting behaviour may be confusing. + * @param accept The Accept request header value. + * @param supportedTypes A collection of media types that are supported. + * @param defaultType The value to use if the client isn't selective. + * @return The desired media type for this request. + */ + public static MediaType selectResponseType(@Nullable String accept, + Collection supportedTypes, MediaType defaultType) { + if (accept == null) { + LOGGER.debug("Client did not specify Accept header," + + " responding with default MIME-Type \"{}\"", defaultType); + return defaultType; + } + + LOGGER.debug("Client indicated response preference: {}", accept); + var acceptMimeTypes = Splitter.on(',').omitEmptyStrings().trimResults() + .splitToList(accept); + + Comparator preferDefaultType = (MediaType m1, MediaType m2) + -> m1.equals(defaultType) ? -1 : m2.equals(defaultType) ? 1 : 0; + + try { + var responseType = acceptMimeTypes.stream() + .map(MediaType::parse) + .sorted(preferDefaultType) + .sorted(PREFERRING_NON_WILDCARD_TYPES) + .sorted(comparingDouble(Requests::qValueOf).reversed()) + .map(Requests::dropQParameter) + .flatMap(acceptType -> supportedTypes.stream() + .filter(m -> m.is(acceptType)) + .sorted(preferDefaultType.thenComparing(PREFERRING_SHORTER_NAMES))) + .findFirst(); + + responseType.ifPresent(m -> LOGGER.debug("Responding with MIME-Type \"{}\"", m)); + + return responseType.orElseGet(() -> { + LOGGER.debug("Responding with default MIME-Type \"{}\"", defaultType); + return defaultType; + }); + } catch (IllegalArgumentException e) { + // Client supplied an invalid media type. Oh well, let's use a default. + LOGGER.debug("Client supplied invalid Accept header \"{}\": {}", + accept, e.getMessage()); + return defaultType; + } + } + + /** + * Filter out the 'q' value from the MIME-Type, if one is present. This is needed because the + * MIME-Type matching requires the server supports all parameters the client supplied, which + * includes the 'q' value. As examples: {@literal "Accept: text/plain" matches + * "text/plain;charset=UTF_8" "Accept: text/plain;charset=UTF_8" matches + * "text/plain;charset=UTF_8" "Accept: text/plain;q=0.5" does NOT match + * "text/plain;charset=UTF_8" } as there is no {@literal q} parameter in the right-hand-side. + *

+ * Stripping off the q value allows {@literal Accept: text/plain;q=0.5} (matched as {@literal + * text/plain}) to match {@literal text/plain;charset=UTF_8}. + */ + private static MediaType dropQParameter(MediaType acceptType) { + var params = acceptType.parameters(); + + MediaType typeWithoutQ; + if (params.get("q").isEmpty()) { + LOGGER.debug("MIME-Type \"{}\" has no q-value", acceptType); + typeWithoutQ = acceptType; + } else { + var paramsWithoutQ = Multimaps.filterKeys(params, k -> !k.equals("q")); + typeWithoutQ = acceptType.withParameters(paramsWithoutQ); + LOGGER.debug("Stripping q-value from MIME-Type \"{}\" --> \"{}\"", + acceptType, typeWithoutQ); + } + + return typeWithoutQ; + } + + private static float qValueOf(MediaType m) { + List qValues = m.parameters().get("q"); + + if (qValues.isEmpty()) { + return 1.0f; + } + + String lastQValue = qValues.get(qValues.size() - 1); + try { + return Float.parseFloat(lastQValue); + } catch (NumberFormatException e) { + LOGGER.debug("MIME-Type \"{}\" has invalid q value: {}", m, + lastQValue); + return 1.0f; + } + } +} diff --git a/modules/dcache-webdav/src/test/java/org/dcache/webdav/RequestsTest.java b/modules/dcache-webdav/src/test/java/org/dcache/webdav/RequestsTest.java new file mode 100644 index 00000000000..eec8528af42 --- /dev/null +++ b/modules/dcache-webdav/src/test/java/org/dcache/webdav/RequestsTest.java @@ -0,0 +1,110 @@ +/* + * dCache - http://www.dcache.org/ + * + * Copyright (C) 2023 Deutsches Elektronen-Synchrotron + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.dcache.webdav; + +import com.google.common.net.MediaType; + +import java.util.Set; + +import org.junit.Test; + +import static com.google.common.net.MediaType.HTML_UTF_8; +import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class RequestsTest { + + private static final MediaType PLAIN_TEXT = MediaType.create("text", "plain"); + private static final MediaType HTML = MediaType.create("text", "html"); + + public RequestsTest() {} + + @Test + public void shouldSelectDefaultIfNotSupported() { + var responseType = Requests.selectResponseType("text/plain", + Set.of(HTML), HTML); + + assertThat(responseType, equalTo(HTML)); + } + + @Test + public void shouldPreferNonWildcard() { + var responseType = Requests.selectResponseType("*/*, text/plain", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(PLAIN_TEXT)); + } + + @Test + public void shouldPreferDefaultForWildcard() { + var responseType = Requests.selectResponseType("*/*", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(HTML)); + } + + @Test + public void shouldSelectNonDefault() { + var responseType = Requests.selectResponseType("text/plain", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(PLAIN_TEXT)); + } + + @Test + public void shouldPrioritiseDefault1() { + var responseType = Requests.selectResponseType("text/plain, text/html", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(HTML)); + } + + @Test + public void shouldPrioritiseDefault2() { + var responseType = Requests.selectResponseType("text/html, text/plain", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(HTML)); + } + + @Test + public void shouldAcceptQValue() { + var responseType = Requests.selectResponseType("text/plain, text/html;q=0.5", + Set.of(HTML, PLAIN_TEXT), HTML); + + assertThat(responseType, equalTo(PLAIN_TEXT)); + } + + @Test + public void shouldAcceptWithMissingParameter() { + var responseType = Requests.selectResponseType("text/plain", + Set.of(HTML_UTF_8, PLAIN_TEXT_UTF_8), HTML_UTF_8); + + assertThat(responseType, equalTo(PLAIN_TEXT_UTF_8)); + } + + @Test + public void shouldAcceptWithMatchingParameter() { + var responseType = Requests.selectResponseType("text/plain;charset=utf-8", + Set.of(HTML_UTF_8, PLAIN_TEXT_UTF_8), HTML_UTF_8); + + assertThat(responseType, equalTo(PLAIN_TEXT_UTF_8)); + } +} \ No newline at end of file