// Copyright 2017 The Nomulus Authors. All Rights Reserved. // // 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 // // http://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 google.registry.rdap; import static com.google.common.collect.ImmutableList.toImmutableList; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.rdap.RdapUtils.getRegistrarByIanaIdentifier; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Streams; import com.google.common.primitives.Booleans; import com.google.common.primitives.Longs; import com.googlecode.objectify.cmd.Query; import google.registry.model.contact.ContactResource; import google.registry.model.registrar.Registrar; import google.registry.rdap.RdapJsonFormatter.BoilerplateType; import google.registry.rdap.RdapJsonFormatter.OutputDataType; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.SearchType; import google.registry.rdap.RdapSearchResults.IncompletenessWarningType; import google.registry.request.Action; import google.registry.request.HttpException.BadRequestException; import google.registry.request.HttpException.NotFoundException; import google.registry.request.HttpException.UnprocessableEntityException; import google.registry.request.Parameter; import google.registry.request.auth.Auth; import google.registry.util.Clock; import java.util.ArrayList; import java.util.List; import java.util.Optional; import javax.inject.Inject; import org.joda.time.DateTime; /** * RDAP (new WHOIS) action for entity (contact and registrar) search requests. * *

All commands and responses conform to the RDAP spec as defined in RFCs 7480 through 7485. * * @see RFC 7482: Registration Data Access Protocol * (RDAP) Query Format * @see RFC 7483: JSON Responses for the Registration * Data Access Protocol (RDAP) */ @Action( path = RdapEntitySearchAction.PATH, method = {GET, HEAD}, auth = Auth.AUTH_PUBLIC ) public class RdapEntitySearchAction extends RdapActionBase { public static final String PATH = "/rdap/entities"; @Inject Clock clock; @Inject @Parameter("fn") Optional fnParam; @Inject @Parameter("handle") Optional handleParam; @Inject RdapEntitySearchAction() {} @Override public String getHumanReadableObjectTypeName() { return "entity search"; } @Override public EndpointType getEndpointType() { return EndpointType.ENTITIES; } @Override public String getActionPath() { return PATH; } /** Parses the parameters and calls the appropriate search function. */ @Override public ImmutableMap getJsonObjectForResource( String pathSearchString, boolean isHeadRequest) { DateTime now = clock.nowUtc(); // RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*. // The pathSearchString is not used by search commands. if (pathSearchString.length() > 0) { throw new BadRequestException("Unexpected path"); } if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) { throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY"); } RdapSearchResults results; if (fnParam.isPresent()) { metricInformationBuilder.setSearchType(SearchType.BY_FULL_NAME); // syntax: /rdap/entities?fn=Bobby%20Joe* // The name is the contact name or registrar name (not registrar contact name). results = searchByName(recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)), now); } else { metricInformationBuilder.setSearchType(SearchType.BY_HANDLE); // syntax: /rdap/entities?handle=12345-* // The handle is either the contact roid or the registrar clientId. results = searchByHandle( recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)), now); } if (results.jsonList().isEmpty()) { throw new NotFoundException("No entities found"); } ImmutableMap.Builder jsonBuilder = new ImmutableMap.Builder<>(); jsonBuilder.put("entitySearchResults", results.jsonList()); rdapJsonFormatter.addTopLevelEntries( jsonBuilder, BoilerplateType.ENTITY, results.getIncompletenessWarnings(), ImmutableList.of(), fullServletPath); return jsonBuilder.build(); } /** * Searches for entities by name, returning a JSON array of entity info maps. * *

As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not * by registrar contact name: * *

The search is by registrar name only. The profile is supporting the functionality defined * in the Base Registry Agreement. * *

According to RFC 7482 section 6.1, punycode is only used for domain name labels, so we can * assume that entity names are regular unicode. * *

The includeDeleted flag is ignored when searching for contacts, because contact names are * set to null when the contact is deleted, so a deleted contact can never have a name. * *

Since we are restricting access to contact names, we don't want name searches to return * contacts whose names are not visible. That would allow unscrupulous users to query by name * and infer that all returned contacts contain that name string. So we check the authorization * level to determine what to do. * * @see 1.6 * of Section 4 of the Base Registry Agreement */ private RdapSearchResults searchByName(final RdapSearchPattern partialStringQuery, DateTime now) { // For wildcard searches, make sure the initial string is long enough, and don't allow suffixes. if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) { throw new UnprocessableEntityException( partialStringQuery.getHasWildcard() ? "Suffixes not allowed in wildcard entity name searches" : "Suffixes not allowed when searching for deleted entities"); } if (partialStringQuery.getHasWildcard() && (partialStringQuery.getInitialString().length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)) { throw new UnprocessableEntityException( partialStringQuery.getHasWildcard() ? "Initial search string required in wildcard entity name searches" : "Initial search string required when searching for deleted entities"); } // Get the registrar matches. ImmutableList registrars = Streams.stream(Registrar.loadAllCached()) .filter( registrar -> partialStringQuery.matches(registrar.getRegistrarName()) && shouldBeVisible(registrar)) .limit(rdapResultSetMaxSize + 1) .collect(toImmutableList()); // Get the contact matches and return the results, fetching an additional contact to detect // truncation. Don't bother searching for contacts by name if the request would not be able to // see any names anyway. RdapResultSet resultSet; RdapAuthorization authorization = getAuthorization(); if (authorization.role() == RdapAuthorization.Role.PUBLIC) { resultSet = RdapResultSet.create(ImmutableList.of()); } else { Query query = queryItems( ContactResource.class, "searchName", partialStringQuery, false, rdapResultSetMaxSize + 1); if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { query = query.filter("currentSponsorClientId in", authorization.clientIds()); } resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1); } return makeSearchResults(resultSet, registrars, now); } /** * Searches for entities by handle, returning a JSON array of entity info maps. * *

Searches for deleted entities are treated like wildcard searches. * *

We don't allow suffixes after a wildcard in entity searches. Suffixes are used in domain * searches to specify a TLD, and in nameserver searches to specify a locally managed domain name. * In both cases, the suffix can be turned into an additional query filter field. For contacts, * there is no equivalent string suffix that can be used as a query filter, so we disallow use. */ private RdapSearchResults searchByHandle( final RdapSearchPattern partialStringQuery, DateTime now) { if (partialStringQuery.getSuffix() != null) { throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches"); } // Handle queries without a wildcard (and not including deleted) -- load by ID. if (!partialStringQuery.getHasWildcard() && !shouldIncludeDeleted()) { ContactResource contactResource = ofy().load() .type(ContactResource.class) .id(partialStringQuery.getInitialString()) .now(); ImmutableList contactResourceList = ((contactResource != null) && shouldBeVisible(contactResource, now)) ? ImmutableList.of(contactResource) : ImmutableList.of(); return makeSearchResults( contactResourceList, IncompletenessWarningType.COMPLETE, contactResourceList.size(), getMatchingRegistrars(partialStringQuery.getInitialString()), now); // Handle queries with a wildcard (or including deleted), but no suffix. Because the handle // for registrars is the IANA identifier number, don't allow wildcard searches for registrars, // by simply not searching for registrars if a wildcard is present. Fetch an extra contact to // detect result set truncation. } else { ImmutableList registrars = partialStringQuery.getHasWildcard() ? ImmutableList.of() : getMatchingRegistrars(partialStringQuery.getInitialString()); // Get the contact matches and return the results, fetching an additional contact to detect // truncation. If we are including deleted entries, we must fetch more entries, in case some // get excluded due to permissioning. int querySizeLimit = getStandardQuerySizeLimit(); Query query = queryItemsByKey( ContactResource.class, partialStringQuery, shouldIncludeDeleted(), querySizeLimit); return makeSearchResults( getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), registrars, now); } } /** Looks up registrars by handle (i.e. IANA identifier). */ private ImmutableList getMatchingRegistrars(final String ianaIdentifierString) { Long ianaIdentifier = Longs.tryParse(ianaIdentifierString); if (ianaIdentifier == null) { return ImmutableList.of(); } Optional registrar = getRegistrarByIanaIdentifier(ianaIdentifier); return (registrar.isPresent() && shouldBeVisible(registrar.get())) ? ImmutableList.of(registrar.get()) : ImmutableList.of(); } /** * Builds a JSON array of entity info maps based on the specified contacts and registrars. * *

This is a convenience wrapper for the four-argument makeSearchResults; it unpacks the * properties of the {@link RdapResultSet} structure and passes them as separate arguments. */ private RdapSearchResults makeSearchResults( RdapResultSet resultSet, List registrars, DateTime now) { return makeSearchResults( resultSet.resources(), resultSet.incompletenessWarningType(), resultSet.numResourcesRetrieved(), registrars, now); } /** * Builds a JSON array of entity info maps based on the specified contacts and registrars. * *

The number of contacts retrieved is recorded for use by the metrics. * * @param contacts the list of contacts which can be returned * @param incompletenessWarningType MIGHT_BE_INCOMPLETE if the list of contacts might be * incomplete; this only matters if the total count of contacts and registrars combined is * less than a full result set's worth * @param numContactsRetrieved the number of contacts retrieved in the process of generating the * results * @param registrars the list of registrars which can be returned * @param now the current date and time * @return an {@link RdapSearchResults} object */ private RdapSearchResults makeSearchResults( List contacts, IncompletenessWarningType incompletenessWarningType, int numContactsRetrieved, List registrars, DateTime now) { metricInformationBuilder.setNumContactsRetrieved(numContactsRetrieved); // Determine what output data type to use, depending on whether more than one entity will be // returned. OutputDataType outputDataType = (contacts.size() + registrars.size() > 1) ? OutputDataType.SUMMARY : OutputDataType.FULL; // There can be more results than our max size, partially because we have two pools to draw from // (contacts and registrars), and partially because we try to fetch one more than the max size, // so we can tell whether to display the truncation notification. RdapAuthorization authorization = getAuthorization(); List> jsonOutputList = new ArrayList<>(); for (ContactResource contact : contacts) { if (jsonOutputList.size() >= rdapResultSetMaxSize) { return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED); } // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since // they are global, and might have different roles for different domains. jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForContact( contact, false, Optional.empty(), fullServletPath, rdapWhoisServer, now, outputDataType, authorization)); } for (Registrar registrar : registrars) { if (jsonOutputList.size() >= rdapResultSetMaxSize) { return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED); } jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar( registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType)); } return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), (jsonOutputList.size() < rdapResultSetMaxSize) ? incompletenessWarningType : IncompletenessWarningType.COMPLETE); } }