|
| 1 | +// Copyright 2024 The Nomulus Authors. All Rights Reserved. |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +package google.registry.ui.server.console; |
| 16 | + |
| 17 | +import static com.google.common.collect.ImmutableMap.toImmutableMap; |
| 18 | +import static jakarta.servlet.http.HttpServletResponse.SC_OK; |
| 19 | +import static java.nio.charset.StandardCharsets.UTF_8; |
| 20 | + |
| 21 | +import com.google.common.collect.ImmutableMap; |
| 22 | +import com.google.common.escape.Escaper; |
| 23 | +import com.google.common.xml.XmlEscapers; |
| 24 | +import com.google.gson.JsonElement; |
| 25 | +import com.google.gson.annotations.Expose; |
| 26 | +import google.registry.flows.EppController; |
| 27 | +import google.registry.flows.EppRequestSource; |
| 28 | +import google.registry.flows.PasswordOnlyTransportCredentials; |
| 29 | +import google.registry.flows.StatelessRequestSessionMetadata; |
| 30 | +import google.registry.model.console.ConsolePermission; |
| 31 | +import google.registry.model.console.User; |
| 32 | +import google.registry.model.eppcommon.ProtocolDefinition; |
| 33 | +import google.registry.model.eppoutput.EppOutput; |
| 34 | +import google.registry.model.eppoutput.Result; |
| 35 | +import google.registry.request.Action; |
| 36 | +import google.registry.request.OptionalJsonPayload; |
| 37 | +import google.registry.request.Parameter; |
| 38 | +import google.registry.request.auth.Auth; |
| 39 | +import java.util.List; |
| 40 | +import java.util.Map; |
| 41 | +import java.util.Optional; |
| 42 | +import javax.inject.Inject; |
| 43 | + |
| 44 | +/** |
| 45 | + * Console endpoint to perform the same action to a list of domains. |
| 46 | + * |
| 47 | + * <p>All requests must include the {@link BulkAction} to perform as well as a {@link |
| 48 | + * BulkDomainList} of domains on which to apply the action. The remaining contents of the request |
| 49 | + * body depend on the type of action -- some requests may require more data than others. |
| 50 | + */ |
| 51 | +@Action( |
| 52 | + service = Action.GaeService.DEFAULT, |
| 53 | + gkeService = Action.GkeService.CONSOLE, |
| 54 | + path = ConsoleBulkDomainAction.PATH, |
| 55 | + auth = Auth.AUTH_PUBLIC_LOGGED_IN) |
| 56 | +public class ConsoleBulkDomainAction extends ConsoleApiAction { |
| 57 | + |
| 58 | + public static final String PATH = "/console-api/bulk-domain"; |
| 59 | + |
| 60 | + private static Escaper XML_ESCAPER = XmlEscapers.xmlContentEscaper(); |
| 61 | + |
| 62 | + public enum BulkAction { |
| 63 | + DELETE, |
| 64 | + SUSPEND |
| 65 | + } |
| 66 | + |
| 67 | + /** All requests must include at least a list of domain names on which to perform the action. */ |
| 68 | + public record BulkDomainList(@Expose List<String> domainList) {} |
| 69 | + |
| 70 | + public record BulkDomainDeleteRequest(@Expose String reason) {} |
| 71 | + |
| 72 | + public record BulkDomainSuspendRequest(@Expose String reason) {} |
| 73 | + |
| 74 | + private static final String DOMAIN_DELETE_XML = |
| 75 | + """ |
| 76 | +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 77 | +<epp xmlns="urn:ietf:params:xml:ns:epp-1.0"> |
| 78 | + <command> |
| 79 | + <delete> |
| 80 | + <domain:delete |
| 81 | + xmlns:domain="urn:ietf:params:xml:ns:domain-1.0"> |
| 82 | + <domain:name>%DOMAIN_NAME%</domain:name> |
| 83 | + </domain:delete> |
| 84 | + </delete> |
| 85 | + <extension> |
| 86 | + <metadata:metadata xmlns:metadata="urn:google:params:xml:ns:metadata-1.0"> |
| 87 | + <metadata:reason>%REASON%</metadata:reason> |
| 88 | + <metadata:requestedByRegistrar>true</metadata:requestedByRegistrar> |
| 89 | + </metadata:metadata> |
| 90 | + </extension> |
| 91 | + <clTRID>RegistryConsole</clTRID> |
| 92 | + </command> |
| 93 | +</epp>"""; |
| 94 | + |
| 95 | + private static final String DOMAIN_SUSPEND_XML = |
| 96 | + """ |
| 97 | +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 98 | +<epp |
| 99 | + xmlns="urn:ietf:params:xml:ns:epp-1.0"> |
| 100 | + <command> |
| 101 | + <update> |
| 102 | + <domain:update |
| 103 | + xmlns:domain="urn:ietf:params:xml:ns:domain-1.0"> |
| 104 | + <domain:name>%DOMAIN_NAME%</domain:name> |
| 105 | + <domain:add> |
| 106 | + <domain:status s="serverDeleteProhibited" lang="en"></domain:status> |
| 107 | + <domain:status s="serverHold" lang="en"></domain:status> |
| 108 | + <domain:status s="serverRenewProhibited" lang="en"></domain:status> |
| 109 | + <domain:status s="serverTransferProhibited" lang="en"></domain:status> |
| 110 | + <domain:status s="serverUpdateProhibited" lang="en"></domain:status> |
| 111 | + </domain:add> |
| 112 | + <domain:rem></domain:rem> |
| 113 | + </domain:update> |
| 114 | + </update> |
| 115 | + <extension> |
| 116 | + <metadata:metadata |
| 117 | + xmlns:metadata="urn:google:params:xml:ns:metadata-1.0"> |
| 118 | + <metadata:reason>Console suspension: %REASON%</metadata:reason> |
| 119 | + <metadata:requestedByRegistrar>false</metadata:requestedByRegistrar> |
| 120 | + </metadata:metadata> |
| 121 | + </extension> |
| 122 | + <clTRID>RegistryTool</clTRID> |
| 123 | + </command> |
| 124 | +</epp>"""; |
| 125 | + |
| 126 | + private final EppController eppController; |
| 127 | + private final String registrarId; |
| 128 | + private final String bulkDomainAction; |
| 129 | + private final Optional<JsonElement> optionalJsonPayload; |
| 130 | + |
| 131 | + @Inject |
| 132 | + public ConsoleBulkDomainAction( |
| 133 | + ConsoleApiParams consoleApiParams, |
| 134 | + EppController eppController, |
| 135 | + @Parameter("registrarId") String registrarId, |
| 136 | + @Parameter("bulkDomainAction") String bulkDomainAction, |
| 137 | + @OptionalJsonPayload Optional<JsonElement> optionalJsonPayload) { |
| 138 | + super(consoleApiParams); |
| 139 | + this.eppController = eppController; |
| 140 | + this.registrarId = registrarId; |
| 141 | + this.bulkDomainAction = bulkDomainAction; |
| 142 | + this.optionalJsonPayload = optionalJsonPayload; |
| 143 | + } |
| 144 | + |
| 145 | + @Override |
| 146 | + protected void postHandler(User user) { |
| 147 | + BulkAction bulkAction = BulkAction.valueOf(bulkDomainAction); |
| 148 | + JsonElement jsonPayload = |
| 149 | + optionalJsonPayload.orElseThrow( |
| 150 | + () -> new IllegalArgumentException("Bulk action payload must be present")); |
| 151 | + BulkDomainList domainList = consoleApiParams.gson().fromJson(jsonPayload, BulkDomainList.class); |
| 152 | + checkPermission(user, registrarId, ConsolePermission.EXECUTE_EPP_COMMANDS); |
| 153 | + ImmutableMap<String, ConsoleEppOutput> result = |
| 154 | + switch (bulkAction) { |
| 155 | + case DELETE -> handleBulkDelete(jsonPayload, domainList, user); |
| 156 | + case SUSPEND -> handleBulkSuspend(jsonPayload, domainList, user); |
| 157 | + }; |
| 158 | + // Front end should parse situations where only some commands worked |
| 159 | + consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(result)); |
| 160 | + consoleApiParams.response().setStatus(SC_OK); |
| 161 | + } |
| 162 | + |
| 163 | + private ImmutableMap<String, ConsoleEppOutput> handleBulkDelete( |
| 164 | + JsonElement jsonPayload, BulkDomainList domainList, User user) { |
| 165 | + String reason = |
| 166 | + consoleApiParams.gson().fromJson(jsonPayload, BulkDomainDeleteRequest.class).reason; |
| 167 | + return runCommandOverDomains( |
| 168 | + domainList, |
| 169 | + DOMAIN_DELETE_XML, |
| 170 | + new ImmutableMap.Builder<String, String>().put("REASON", reason), |
| 171 | + user); |
| 172 | + } |
| 173 | + |
| 174 | + private ImmutableMap<String, ConsoleEppOutput> handleBulkSuspend( |
| 175 | + JsonElement jsonPayload, BulkDomainList domainList, User user) { |
| 176 | + String reason = |
| 177 | + consoleApiParams.gson().fromJson(jsonPayload, BulkDomainSuspendRequest.class).reason; |
| 178 | + return runCommandOverDomains( |
| 179 | + domainList, |
| 180 | + DOMAIN_SUSPEND_XML, |
| 181 | + new ImmutableMap.Builder<String, String>().put("REASON", reason), |
| 182 | + user); |
| 183 | + } |
| 184 | + |
| 185 | + /** Runs the provided XML template and substitutions over a provided list of domains. */ |
| 186 | + private ImmutableMap<String, ConsoleEppOutput> runCommandOverDomains( |
| 187 | + BulkDomainList domainList, |
| 188 | + String xmlTemplate, |
| 189 | + ImmutableMap.Builder<String, String> replacements, |
| 190 | + User user) { |
| 191 | + return domainList.domainList.stream() |
| 192 | + .collect( |
| 193 | + toImmutableMap( |
| 194 | + d -> d, |
| 195 | + d -> |
| 196 | + executeEpp( |
| 197 | + fillSubstitutions(xmlTemplate, replacements.put("DOMAIN_NAME", d)), user))); |
| 198 | + } |
| 199 | + |
| 200 | + private ConsoleEppOutput executeEpp(String xml, User user) { |
| 201 | + return ConsoleEppOutput.fromEppOutput( |
| 202 | + eppController.handleEppCommand( |
| 203 | + new StatelessRequestSessionMetadata( |
| 204 | + registrarId, ProtocolDefinition.getVisibleServiceExtensionUris()), |
| 205 | + new PasswordOnlyTransportCredentials(), |
| 206 | + EppRequestSource.CONSOLE, |
| 207 | + false, |
| 208 | + user.getUserRoles().isAdmin(), |
| 209 | + xml.getBytes(UTF_8))); |
| 210 | + } |
| 211 | + |
| 212 | + /** Fills the provided XML template with the replacement values, including escaping the values. */ |
| 213 | + private String fillSubstitutions( |
| 214 | + String xmlTemplate, ImmutableMap.Builder<String, String> replacements) { |
| 215 | + String xml = xmlTemplate; |
| 216 | + for (Map.Entry<String, String> entry : replacements.buildKeepingLast().entrySet()) { |
| 217 | + xml = xml.replaceAll("%" + entry.getKey() + "%", XML_ESCAPER.escape(entry.getValue())); |
| 218 | + } |
| 219 | + return xml; |
| 220 | + } |
| 221 | + |
| 222 | + public record ConsoleEppOutput(@Expose String message, @Expose int responseCode) { |
| 223 | + static ConsoleEppOutput fromEppOutput(EppOutput eppOutput) { |
| 224 | + Result result = eppOutput.getResponse().getResult(); |
| 225 | + return new ConsoleEppOutput(result.getMsg(), result.getCode().code); |
| 226 | + } |
| 227 | + } |
| 228 | +} |
0 commit comments