// 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.base.Predicates.not; import static com.google.common.base.Strings.nullToEmpty; import static com.google.common.collect.ImmutableList.toImmutableList; import static google.registry.model.EppResourceUtils.isLinked; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.util.CollectionUtils.union; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; import com.google.common.collect.Ordering; import com.google.common.collect.Streams; import com.google.common.net.InetAddresses; import com.google.gson.JsonArray; import com.googlecode.objectify.Key; import google.registry.config.RegistryConfig.Config; import google.registry.model.EppResource; import google.registry.model.contact.ContactPhoneNumber; import google.registry.model.contact.ContactResource; import google.registry.model.contact.PostalInfo; import google.registry.model.domain.DesignatedContact; import google.registry.model.domain.DesignatedContact.Type; import google.registry.model.domain.DomainBase; import google.registry.model.eppcommon.Address; import google.registry.model.eppcommon.StatusValue; import google.registry.model.host.HostResource; import google.registry.model.registrar.Registrar; import google.registry.model.registrar.RegistrarAddress; import google.registry.model.registrar.RegistrarContact; import google.registry.model.reporting.HistoryEntry; import google.registry.rdap.RdapDataStructures.Event; import google.registry.rdap.RdapDataStructures.EventAction; import google.registry.rdap.RdapDataStructures.Link; import google.registry.rdap.RdapDataStructures.Notice; import google.registry.rdap.RdapDataStructures.Port43WhoisServer; import google.registry.rdap.RdapDataStructures.PublicId; import google.registry.rdap.RdapDataStructures.RdapStatus; import google.registry.rdap.RdapObjectClasses.RdapDomain; import google.registry.rdap.RdapObjectClasses.RdapEntity; import google.registry.rdap.RdapObjectClasses.RdapNameserver; import google.registry.rdap.RdapObjectClasses.Vcard; import google.registry.rdap.RdapObjectClasses.VcardArray; import google.registry.request.FullServletPath; import google.registry.request.HttpException.InternalServerErrorException; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; import java.nio.file.Paths; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Stream; import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.time.DateTime; import org.joda.time.DateTimeComparator; /** * Helper class to create RDAP JSON objects for various registry entities and objects. * *

The JSON format specifies that entities should be supplied with links indicating how to fetch * them via RDAP, which requires the URL to the RDAP server. The linkBase parameter, passed to many * of the methods, is used as the first part of the link URL. For instance, if linkBase is * "http://rdap.org/dir/", the link URLs will look like "http://rdap.org/dir/domain/XXXX", etc. * * @see * RFC 7483: JSON Responses for the Registration Data Access Protocol (RDAP) */ public class RdapJsonFormatter { @Inject @Config("rdapTos") ImmutableList rdapTos; @Inject @Config("rdapTosStaticUrl") @Nullable String rdapTosStaticUrl; @Inject @FullServletPath String fullServletPath; @Inject RdapJsonFormatter() {} /** * What type of data to generate. * *

Summary data includes only information about the object itself, while full data includes * associated items (e.g. for domains, full data includes the hosts, contacts and history entries * connected with the domain). * *

Summary data is appropriate for search queries which return many results, to avoid load on * the system. According to the ICANN operational profile, a remark must be attached to the * returned object indicating that it includes only summary data. */ public enum OutputDataType { FULL, SUMMARY } /** Map of EPP status values to the RDAP equivalents. */ private static final ImmutableMap STATUS_TO_RDAP_STATUS_MAP = new ImmutableMap.Builder() // RdapStatus.ADD_PERIOD not defined in our system // RdapStatus.AUTO_RENEW_PERIOD not defined in our system .put(StatusValue.CLIENT_DELETE_PROHIBITED, RdapStatus.CLIENT_DELETE_PROHIBITED) .put(StatusValue.CLIENT_HOLD, RdapStatus.CLIENT_HOLD) .put(StatusValue.CLIENT_RENEW_PROHIBITED, RdapStatus.CLIENT_RENEW_PROHIBITED) .put(StatusValue.CLIENT_TRANSFER_PROHIBITED, RdapStatus.CLIENT_TRANSFER_PROHIBITED) .put(StatusValue.CLIENT_UPDATE_PROHIBITED, RdapStatus.CLIENT_UPDATE_PROHIBITED) .put(StatusValue.INACTIVE, RdapStatus.INACTIVE) .put(StatusValue.LINKED, RdapStatus.ASSOCIATED) .put(StatusValue.OK, RdapStatus.ACTIVE) .put(StatusValue.PENDING_CREATE, RdapStatus.PENDING_CREATE) .put(StatusValue.PENDING_DELETE, RdapStatus.PENDING_DELETE) // RdapStatus.PENDING_RENEW not defined in our system // RdapStatus.PENDING_RESTORE not defined in our system .put(StatusValue.PENDING_TRANSFER, RdapStatus.PENDING_TRANSFER) .put(StatusValue.PENDING_UPDATE, RdapStatus.PENDING_UPDATE) // RdapStatus.REDEMPTION_PERIOD not defined in our system // RdapStatus.RENEW_PERIOD not defined in our system .put(StatusValue.SERVER_DELETE_PROHIBITED, RdapStatus.SERVER_DELETE_PROHIBITED) .put(StatusValue.SERVER_HOLD, RdapStatus.SERVER_HOLD) .put(StatusValue.SERVER_RENEW_PROHIBITED, RdapStatus.SERVER_RENEW_PROHIBITED) .put(StatusValue.SERVER_TRANSFER_PROHIBITED, RdapStatus.SERVER_TRANSFER_PROHIBITED) .put(StatusValue.SERVER_UPDATE_PROHIBITED, RdapStatus.SERVER_UPDATE_PROHIBITED) // RdapStatus.TRANSFER_PERIOD not defined in our system .build(); /** * Map of EPP event values to the RDAP equivalents. * *

Only has entries for the events we care about, according to the RDAP Response Profile * 15feb19. * * There are additional events that don't have HistoryEntry equivalent and are created * differently. They will be in different locations in the code. These values are: EXPIRATION, * LAST_CHANGED, LAST_UPDATE_OF_RDAP_DATABASE. */ private static final ImmutableMap HISTORY_ENTRY_TYPE_TO_RDAP_EVENT_ACTION_MAP = new ImmutableMap.Builder() .put(HistoryEntry.Type.CONTACT_CREATE, EventAction.REGISTRATION) .put(HistoryEntry.Type.CONTACT_DELETE, EventAction.DELETION) .put(HistoryEntry.Type.CONTACT_TRANSFER_APPROVE, EventAction.TRANSFER) /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_AUTORENEW, EventAction.REREGISTRATION) /** Section 2.3.1.1, obligatory. */ .put(HistoryEntry.Type.DOMAIN_CREATE, EventAction.REGISTRATION) /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_DELETE, EventAction.DELETION) /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_RENEW, EventAction.REREGISTRATION) /** Not in the Response Profile. */ .put(HistoryEntry.Type.DOMAIN_RESTORE, EventAction.REINSTANTIATION) /** Section 2.3.2.3, optional. */ .put(HistoryEntry.Type.DOMAIN_TRANSFER_APPROVE, EventAction.TRANSFER) .put(HistoryEntry.Type.HOST_CREATE, EventAction.REGISTRATION) .put(HistoryEntry.Type.HOST_DELETE, EventAction.DELETION) .build(); private static final ImmutableList STATUS_LIST_ACTIVE = ImmutableList.of(RdapStatus.ACTIVE); private static final ImmutableList STATUS_LIST_INACTIVE = ImmutableList.of(RdapStatus.INACTIVE); private static final ImmutableMap> PHONE_TYPE_VOICE = ImmutableMap.of("type", ImmutableList.of("voice")); private static final ImmutableMap> PHONE_TYPE_FAX = ImmutableMap.of("type", ImmutableList.of("fax")); /** Sets the ordering for hosts; just use the fully qualified host name. */ private static final Ordering HOST_RESOURCE_ORDERING = Ordering.natural().onResultOf(HostResource::getFullyQualifiedHostName); /** Sets the ordering for designated contacts; order them in a fixed order by contact type. */ private static final Ordering DESIGNATED_CONTACT_ORDERING = Ordering.natural().onResultOf(DesignatedContact::getType); /** Creates the TOS notice that is added to every reply. */ Notice createTosNotice() { String linkValue = makeRdapServletRelativeUrl("help", RdapHelpAction.TOS_PATH); Link.Builder linkBuilder = Link.builder() .setValue(linkValue); if (rdapTosStaticUrl == null) { linkBuilder.setRel("self").setHref(linkValue).setType("application/rdap+json"); } else { URI htmlBaseURI = URI.create(fullServletPath); URI htmlUri = htmlBaseURI.resolve(rdapTosStaticUrl); linkBuilder.setRel("alternate").setHref(htmlUri.toString()).setType("text/html"); } return Notice.builder() .setTitle("RDAP Terms of Service") .setDescription(rdapTos) .addLink(linkBuilder.build()) .build(); } /** * Creates a JSON object for a {@link DomainBase}. * * @param domainBase the domain resource object from which the JSON object should be created * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object * @param now the as-date * @param outputDataType whether to generate full or summary data * @param authorization the authorization level of the request; if not authorized for the * registrar owning the domain, no contact information is included */ RdapDomain makeRdapJsonForDomain( DomainBase domainBase, @Nullable String whoisServer, DateTime now, OutputDataType outputDataType, RdapAuthorization authorization) { RdapDomain.Builder builder = RdapDomain.builder(); // RDAP Response Profile 15feb19 section 2.2: // The domain handle MUST be the ROID builder.setHandle(domainBase.getRepoId()); builder.setLdhName(domainBase.getFullyQualifiedDomainName()); builder.statusBuilder().addAll( makeStatusValueList( domainBase.getStatusValues(), false, // isRedacted domainBase.getDeletionTime().isBefore(now))); builder.linksBuilder().add( makeSelfLink("domain", domainBase.getFullyQualifiedDomainName())); boolean displayContacts = authorization.isAuthorizedForClientId(domainBase.getCurrentSponsorClientId()); // If we are outputting all data (not just summary data), also add information about hosts, // contacts and events (history entries). If we are outputting summary data, instead add a // remark indicating that fact. if (outputDataType == OutputDataType.SUMMARY) { builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); } else { ImmutableList events = makeEvents(domainBase, now); builder.eventsBuilder().addAll(events); // Kick off the database loads of the nameservers that we will need, so it can load // asynchronously while we load and process the contacts. Map, HostResource> loadedHosts = ofy().load().keys(domainBase.getNameservers()); // Load the registrant and other contacts and add them to the data. if (!displayContacts) { builder .remarksBuilder() .add(RdapIcannStandardInformation.DOMAIN_CONTACTS_HIDDEN_DATA_REMARK); } else { Map, ContactResource> loadedContacts = ofy().load().keys(domainBase.getReferencedContacts()); Streams.concat( domainBase.getContacts().stream(), Stream.of( DesignatedContact.create(Type.REGISTRANT, domainBase.getRegistrant()))) .sorted(DESIGNATED_CONTACT_ORDERING) .map( designatedContact -> makeRdapJsonForContact( loadedContacts.get(designatedContact.getContactKey()), Optional.of(designatedContact.getType()), null, now, outputDataType, authorization)) .forEach(builder.entitiesBuilder()::add); } builder .entitiesBuilder() .add( createInternalRegistrarEntity( domainBase.getCurrentSponsorClientId(), whoisServer, now)); // Add the nameservers to the data; the load was kicked off above for efficiency. for (HostResource hostResource : HOST_RESOURCE_ORDERING.immutableSortedCopy(loadedHosts.values())) { builder.nameserversBuilder().add(makeRdapJsonForHost( hostResource, null, now, outputDataType)); } } if (whoisServer != null) { builder.setPort43(Port43WhoisServer.create(whoisServer)); } return builder.build(); } /** * Creates a JSON object for the desired registrar to an existing list of JSON objects. * * @param clientId the registrar client ID * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object * @param now the as-date */ RdapEntity createInternalRegistrarEntity( String clientId, @Nullable String whoisServer, DateTime now) { Optional registrar = Registrar.loadByClientIdCached(clientId); if (!registrar.isPresent()) { throw new InternalServerErrorException( String.format("Coudn't find registrar '%s'", clientId)); } // TODO(b/130150723): we need to display the ABUSE contact for registrar object inside of Domain // responses. Currently, we use summary for any "internal" registrar. return makeRdapJsonForRegistrar( registrar.get(), whoisServer, now, OutputDataType.SUMMARY); } /** * Creates a JSON object for a {@link HostResource}. * * @param hostResource the host resource object from which the JSON object should be created * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object * @param now the as-date * @param outputDataType whether to generate full or summary data */ RdapNameserver makeRdapJsonForHost( HostResource hostResource, @Nullable String whoisServer, DateTime now, OutputDataType outputDataType) { RdapNameserver.Builder builder = RdapNameserver.builder() .setHandle(hostResource.getRepoId()) .setLdhName(hostResource.getFullyQualifiedHostName()); ImmutableSet.Builder statuses = new ImmutableSet.Builder<>(); statuses.addAll(hostResource.getStatusValues()); if (isLinked(Key.create(hostResource), now)) { statuses.add(StatusValue.LINKED); } if (hostResource.isSubordinate() && ofy().load().key(hostResource.getSuperordinateDomain()).now().cloneProjectedAtTime(now) .getStatusValues() .contains(StatusValue.PENDING_TRANSFER)) { statuses.add(StatusValue.PENDING_TRANSFER); } builder .statusBuilder() .addAll( makeStatusValueList( statuses.build(), false, // isRedacted hostResource.getDeletionTime().isBefore(now))); builder .linksBuilder() .add(makeSelfLink("nameserver", hostResource.getFullyQualifiedHostName())); // If we are outputting all data (not just summary data), also add events taken from the history // entries. If we are outputting summary data, instead add a remark indicating that fact. if (outputDataType == OutputDataType.SUMMARY) { builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); } else { builder.eventsBuilder().addAll(makeEvents(hostResource, now)); } // We MUST have the ip addresses: RDAP Response Profile 4.2. for (InetAddress inetAddress : hostResource.getInetAddresses()) { if (inetAddress instanceof Inet4Address) { builder.ipv4Builder().add(InetAddresses.toAddrString(inetAddress)); } else if (inetAddress instanceof Inet6Address) { builder.ipv6Builder().add(InetAddresses.toAddrString(inetAddress)); } } builder.entitiesBuilder().add(createInternalRegistrarEntity( hostResource.getPersistedCurrentSponsorClientId(), whoisServer, now)); if (whoisServer != null) { builder.setPort43(Port43WhoisServer.create(whoisServer)); } return builder.build(); } /** * Creates a JSON object for a {@link ContactResource} and associated contact type. * * @param contactResource the contact resource object from which the JSON object should be created * @param contactType the contact type to map to an RDAP role; if absent, no role is listed * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object * @param now the as-date * @param outputDataType whether to generate full or summary data * @param authorization the authorization level of the request; personal contact data is only * shown if the contact is owned by a registrar for which the request is authorized */ RdapEntity makeRdapJsonForContact( ContactResource contactResource, Optional contactType, @Nullable String whoisServer, DateTime now, OutputDataType outputDataType, RdapAuthorization authorization) { boolean isAuthorized = authorization.isAuthorizedForClientId(contactResource.getCurrentSponsorClientId()); RdapEntity.Builder entityBuilder = RdapEntity.builder() .setHandle(contactResource.getRepoId()); entityBuilder .statusBuilder() .addAll( makeStatusValueList( isLinked(Key.create(contactResource), now) ? union(contactResource.getStatusValues(), StatusValue.LINKED) : contactResource.getStatusValues(), !isAuthorized, contactResource.getDeletionTime().isBefore(now))); contactType.ifPresent( type -> entityBuilder.rolesBuilder().add(convertContactTypeToRdapRole(type))); entityBuilder.linksBuilder().add(makeSelfLink("entity", contactResource.getRepoId())); // If we are logged in as the owner of this contact, create the vCard. if (isAuthorized) { VcardArray.Builder vcardBuilder = VcardArray.builder(); PostalInfo postalInfo = contactResource.getInternationalizedPostalInfo(); if (postalInfo == null) { postalInfo = contactResource.getLocalizedPostalInfo(); } if (postalInfo != null) { if (postalInfo.getName() != null) { vcardBuilder.add(Vcard.create("fn", "text", postalInfo.getName())); } if (postalInfo.getOrg() != null) { vcardBuilder.add(Vcard.create("org", "text", postalInfo.getOrg())); } addVCardAddressEntry(vcardBuilder, postalInfo.getAddress()); } ContactPhoneNumber voicePhoneNumber = contactResource.getVoiceNumber(); if (voicePhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, makePhoneString(voicePhoneNumber))); } ContactPhoneNumber faxPhoneNumber = contactResource.getFaxNumber(); if (faxPhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, makePhoneString(faxPhoneNumber))); } String emailAddress = contactResource.getEmailAddress(); if (emailAddress != null) { vcardBuilder.add(Vcard.create("email", "text", emailAddress)); } entityBuilder.setVcardArray(vcardBuilder.build()); } else { entityBuilder .remarksBuilder() .add(RdapIcannStandardInformation.CONTACT_PERSONAL_DATA_HIDDEN_DATA_REMARK); } // If we are outputting all data (not just summary data), also add events taken from the history // entries. If we are outputting summary data, instead add a remark indicating that fact. if (outputDataType == OutputDataType.SUMMARY) { entityBuilder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); } else { entityBuilder.eventsBuilder().addAll(makeEvents(contactResource, now)); } if (whoisServer != null) { entityBuilder.setPort43(Port43WhoisServer.create(whoisServer)); } return entityBuilder.build(); } /** * Creates a JSON object for a {@link Registrar}. * * @param registrar the registrar object from which the JSON object should be created * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object * @param now the as-date * @param outputDataType whether to generate full or summary data */ RdapEntity makeRdapJsonForRegistrar( Registrar registrar, @Nullable String whoisServer, DateTime now, OutputDataType outputDataType) { RdapEntity.Builder builder = RdapEntity.builder(); Long ianaIdentifier = registrar.getIanaIdentifier(); // the handle MUST be the ianaIdentifier, RDAP Response Profile 2.4.2. builder.setHandle((ianaIdentifier == null) ? "(none)" : ianaIdentifier.toString()); builder.statusBuilder().addAll(registrar.isLive() ? STATUS_LIST_ACTIVE : STATUS_LIST_INACTIVE); builder.rolesBuilder().add(RdapEntity.Role.REGISTRAR); if (ianaIdentifier != null) { builder.linksBuilder().add(makeSelfLink("entity", ianaIdentifier.toString())); // We MUST have a publicId with the ianaIdentifier, RDAP Response Profile 2.4.3, 4.3 builder .publicIdsBuilder() .add(PublicId.create(PublicId.Type.IANA_REGISTRAR_ID, ianaIdentifier.toString())); } // Create the vCard. VcardArray.Builder vcardBuilder = VcardArray.builder(); String registrarName = registrar.getRegistrarName(); if (registrarName != null) { // A valid fn member MUST be present: RDAP Response Profile 2.4.1. vcardBuilder.add(Vcard.create("fn", "text", registrarName)); } RegistrarAddress address = registrar.getInternationalizedAddress(); if (address == null) { address = registrar.getLocalizedAddress(); } addVCardAddressEntry(vcardBuilder, address); String voicePhoneNumber = registrar.getPhoneNumber(); if (voicePhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, "tel:" + voicePhoneNumber)); } String faxPhoneNumber = registrar.getFaxNumber(); if (faxPhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, "tel:" + faxPhoneNumber)); } String emailAddress = registrar.getEmailAddress(); if (emailAddress != null) { vcardBuilder.add(Vcard.create("email", "text", emailAddress)); } builder.setVcardArray(vcardBuilder.build()); // If we are outputting all data (not just summary data), also add registrar contacts. If we are // outputting summary data, instead add a remark indicating that fact. if (outputDataType == OutputDataType.SUMMARY) { builder.remarksBuilder().add(RdapIcannStandardInformation.SUMMARY_DATA_REMARK); } else { builder.eventsBuilder().addAll(makeEvents(registrar, now)); // include the registrar contacts as subentities ImmutableList registrarContacts = registrar.getContacts().stream() .map(registrarContact -> makeRdapJsonForRegistrarContact(registrarContact, null)) .filter(optional -> optional.isPresent()) .map(optional -> optional.get()) .collect(toImmutableList()); // TODO(b/117242274): add a warning (severe?) log if registrar has no ABUSE contact, as having // one is required by the RDAP response profile builder.entitiesBuilder().addAll(registrarContacts); } if (whoisServer != null) { builder.setPort43(Port43WhoisServer.create(whoisServer)); } return builder.build(); } /** * Creates a JSON object for a {@link RegistrarContact}. * *

Returns empty if this contact shouldn't be visible (doesn't have a role). * * @param registrarContact the registrar contact for which the JSON object should be created * @param whoisServer the fully-qualified domain name of the WHOIS server to be listed in the * port43 field; if null, port43 is not added to the object */ static Optional makeRdapJsonForRegistrarContact( RegistrarContact registrarContact, @Nullable String whoisServer) { ImmutableList roles = makeRdapRoleList(registrarContact); if (roles.isEmpty()) { return Optional.empty(); } RdapEntity.Builder builder = RdapEntity.builder(); builder.statusBuilder().addAll(STATUS_LIST_ACTIVE); builder.rolesBuilder().addAll(roles); // Create the vCard. VcardArray.Builder vcardBuilder = VcardArray.builder(); String name = registrarContact.getName(); if (name != null) { vcardBuilder.add(Vcard.create("fn", "text", name)); } String voicePhoneNumber = registrarContact.getPhoneNumber(); if (voicePhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_VOICE, "tel:" + voicePhoneNumber)); } String faxPhoneNumber = registrarContact.getFaxNumber(); if (faxPhoneNumber != null) { vcardBuilder.add(makePhoneEntry(PHONE_TYPE_FAX, "tel:" + faxPhoneNumber)); } String emailAddress = registrarContact.getEmailAddress(); if (emailAddress != null) { vcardBuilder.add(Vcard.create("email", "text", emailAddress)); } builder.setVcardArray(vcardBuilder.build()); if (whoisServer != null) { builder.setPort43(Port43WhoisServer.create(whoisServer)); } return Optional.of(builder.build()); } /** Converts a domain registry contact type into a role as defined by RFC 7483. */ private static RdapEntity.Role convertContactTypeToRdapRole(DesignatedContact.Type contactType) { switch (contactType) { case REGISTRANT: return RdapEntity.Role.REGISTRANT; case TECH: return RdapEntity.Role.TECH; case BILLING: return RdapEntity.Role.BILLING; case ADMIN: return RdapEntity.Role.ADMIN; default: throw new AssertionError(); } } /** * Creates the list of RDAP roles for a registrar contact, using the visibleInWhoisAs* flags. * *

Only contacts with a non-empty role list should be visible. * *

The RDAP response profile only mandates the "abuse" entity: * *

2.4.5. Abuse Contact (email, phone) - an RDAP server MUST include an *entity* with the * *abuse* role within the registrar *entity* which MUST include *tel* and *email*, and MAY * include other members */ private static ImmutableList makeRdapRoleList( RegistrarContact registrarContact) { ImmutableList.Builder rolesBuilder = new ImmutableList.Builder<>(); if (registrarContact.getVisibleInWhoisAsAdmin()) { rolesBuilder.add(RdapEntity.Role.ADMIN); } if (registrarContact.getVisibleInWhoisAsTech()) { rolesBuilder.add(RdapEntity.Role.TECH); } if (registrarContact.getVisibleInDomainWhoisAsAbuse()) { rolesBuilder.add(RdapEntity.Role.ABUSE); } return rolesBuilder.build(); } /** * Creates an event list for a domain, host or contact resource. */ private static ImmutableList makeEvents(EppResource resource, DateTime now) { HashMap lastEntryOfType = Maps.newHashMap(); // Events (such as transfer, but also create) can appear multiple times. We only want the last // time they appeared. // // We can have multiple create historyEntries if a domain was deleted, and then someone new // bought it. // // From RDAP response profile // 2.3.2 The domain object in the RDAP response MAY contain the following events: // 2.3.2.3 An event of *eventAction* type *transfer*, with the last date and time that the // domain was transferred. The event of *eventAction* type *transfer* MUST be omitted if the // domain name has not been transferred since it was created. for (HistoryEntry historyEntry : ofy().load().type(HistoryEntry.class).ancestor(resource).order("modificationTime")) { EventAction rdapEventAction = HISTORY_ENTRY_TYPE_TO_RDAP_EVENT_ACTION_MAP.get(historyEntry.getType()); // Only save the historyEntries if this is a type we care about. if (rdapEventAction == null) { continue; } lastEntryOfType.put(rdapEventAction, historyEntry); } ImmutableList.Builder eventsBuilder = new ImmutableList.Builder<>(); // There are 2 possibly conflicting values for the creation time - either the // resource.getCreationTime, or the REGISTRATION event created from a HistoryEntry // // We favor the HistoryEntry if it exists, since we show that value as REGISTRATION time in the // reply, so the reply will be self-consistent. // // This is mostly an issue in the tests as in "reality" these two values should be the same. // DateTime creationTime = Optional.ofNullable(lastEntryOfType.get(EventAction.REGISTRATION)) .map(historyEntry -> historyEntry.getModificationTime()) .orElse(resource.getCreationTime()); // TODO(b/129849684) remove this and use the events List defined above once we have Event // objects ImmutableList.Builder changeTimesBuilder = new ImmutableList.Builder<>(); // The order of the elements is stable - it's the order in which the enum elements are defined // in EventAction for (EventAction rdapEventAction : EventAction.values()) { HistoryEntry historyEntry = lastEntryOfType.get(rdapEventAction); // Check if there was any entry of this type if (historyEntry == null) { continue; } DateTime modificationTime = historyEntry.getModificationTime(); // We will ignore all events that happened before the "creation time", since these events are // from a "previous incarnation of the domain" (for a domain that was owned by someone, // deleted, and then bought by someone else) if (modificationTime.isBefore(creationTime)) { continue; } eventsBuilder.add( Event.builder() .setEventAction(rdapEventAction) .setEventActor(historyEntry.getClientId()) .setEventDate(modificationTime) .build()); changeTimesBuilder.add(modificationTime); } if (resource instanceof DomainBase) { DateTime expirationTime = ((DomainBase) resource).getRegistrationExpirationTime(); if (expirationTime != null) { eventsBuilder.add( Event.builder() .setEventAction(EventAction.EXPIRATION) .setEventDate(expirationTime) .build()); changeTimesBuilder.add(expirationTime); } } if (resource.getLastEppUpdateTime() != null) { changeTimesBuilder.add(resource.getLastEppUpdateTime()); } // The last change time might not be the lastEppUpdateTime, since some changes happen without // any EPP update (for example, by the passage of time). DateTime lastChangeTime = changeTimesBuilder.build().stream() .filter(changeTime -> changeTime.isBefore(now)) .max(DateTimeComparator.getInstance()) .orElse(null); if (lastChangeTime != null && lastChangeTime.isAfter(creationTime)) { eventsBuilder.add(makeEvent(EventAction.LAST_CHANGED, null, lastChangeTime)); } eventsBuilder.add(makeEvent(EventAction.LAST_UPDATE_OF_RDAP_DATABASE, null, now)); // TODO(b/129849684): sort events by their time once we return a list of Events instead of JSON // objects. return eventsBuilder.build(); } /** * Creates an event list for a {@link Registrar}. */ private static ImmutableList makeEvents(Registrar registrar, DateTime now) { ImmutableList.Builder eventsBuilder = new ImmutableList.Builder<>(); Long ianaIdentifier = registrar.getIanaIdentifier(); eventsBuilder.add(makeEvent( EventAction.REGISTRATION, (ianaIdentifier == null) ? "(none)" : ianaIdentifier.toString(), registrar.getCreationTime())); if ((registrar.getLastUpdateTime() != null) && registrar.getLastUpdateTime().isAfter(registrar.getCreationTime())) { eventsBuilder.add(makeEvent( EventAction.LAST_CHANGED, null, registrar.getLastUpdateTime())); } eventsBuilder.add(makeEvent(EventAction.LAST_UPDATE_OF_RDAP_DATABASE, null, now)); return eventsBuilder.build(); } /** * Creates an RDAP event object as defined by RFC 7483. */ private static Event makeEvent( EventAction eventAction, @Nullable String eventActor, DateTime eventDate) { Event.Builder builder = Event.builder() .setEventAction(eventAction) .setEventDate(eventDate); if (eventActor != null) { builder.setEventActor(eventActor); } return builder.build(); } /** * Creates a vCard address entry: array of strings specifying the components of the address. * * @see * RFC 7095: jCard: The JSON Format for vCard */ private static void addVCardAddressEntry(VcardArray.Builder vcardArrayBuilder, Address address) { if (address == null) { return; } JsonArray addressArray = new JsonArray(); addressArray.add(""); // PO box addressArray.add(""); // extended address // The vCard spec allows several different ways to handle multiline street addresses. Per // Gustavo Lozano of ICANN, the one we should use is an embedded array of street address lines // if there is more than one line: // // RFC7095 provides two examples of structured addresses, and one of the examples shows a // street JSON element that contains several data elements. The example showing (see below) // several data elements is the expected output when two or more elements // exists in the contact object. // // ["adr", {}, "text", // [ // "", "", // ["My Street", "Left Side", "Second Shack"], // "Hometown", "PA", "18252", "U.S.A." // ] // ] // // Gustavo further clarified that the embedded array should only be used if there is more than // one line: // // My reading of RFC 7095 is that if only one element is known, it must be a string. If // multiple elements are known (e.g. two or three street elements were provided in the case of // the EPP contact data model), an array must be used. // // I don’t think that one street address line nested in a single-element array is valid // according to RFC 7095. ImmutableList street = address.getStreet(); if (street.isEmpty()) { addressArray.add(""); } else if (street.size() == 1) { addressArray.add(street.get(0)); } else { JsonArray streetArray = new JsonArray(); street.forEach(streetArray::add); addressArray.add(streetArray); } addressArray.add(nullToEmpty(address.getCity())); addressArray.add(nullToEmpty(address.getState())); addressArray.add(nullToEmpty(address.getZip())); addressArray.add( new Locale("en", address.getCountryCode()).getDisplayCountry(new Locale("en"))); vcardArrayBuilder.add(Vcard.create( "adr", "text", addressArray)); } /** Creates a vCard phone number entry. */ private static Vcard makePhoneEntry( ImmutableMap> type, String phoneNumber) { return Vcard.create("tel", type, "uri", phoneNumber); } /** Creates a phone string in URI format, as per the vCard spec. */ private static String makePhoneString(ContactPhoneNumber phoneNumber) { String phoneString = String.format("tel:%s", phoneNumber.getPhoneNumber()); if (phoneNumber.getExtension() != null) { phoneString = phoneString + ";ext=" + phoneNumber.getExtension(); } return phoneString; } /** * Creates a string array of status values. * *

The spec indicates that OK should be listed as "active". We use the "inactive" status to * indicate deleted objects, and as directed by the profile, the "removed" status to indicate * redacted objects. */ private static ImmutableList makeStatusValueList( ImmutableSet statusValues, boolean isRedacted, boolean isDeleted) { Stream stream = statusValues .stream() .map(status -> STATUS_TO_RDAP_STATUS_MAP.getOrDefault(status, RdapStatus.OBSCURED)); if (isRedacted) { stream = Streams.concat(stream, Stream.of(RdapStatus.REMOVED)); } if (isDeleted) { stream = Streams.concat( stream.filter(not(RdapStatus.ACTIVE::equals)), Stream.of(RdapStatus.INACTIVE)); } return stream .sorted(Ordering.natural().onResultOf(RdapStatus::getDisplayName)) .collect(toImmutableList()); } /** * Create a link relative to the RDAP server endpoint. */ String makeRdapServletRelativeUrl(String part, String... moreParts) { String relativePath = Paths.get(part, moreParts).toString(); if (fullServletPath.endsWith("/")) { return fullServletPath + relativePath; } return fullServletPath + "/" + relativePath; } /** * Creates a self link as directed by the spec. * * @see RFC 7483: JSON Responses for the * Registration Data Access Protocol (RDAP) */ private Link makeSelfLink(String type, String name) { String url = makeRdapServletRelativeUrl(type, name); return Link.builder() .setValue(url) .setRel("self") .setHref(url) .setType("application/rdap+json") .build(); } }