001package com.pusher.rest; 002 003import com.google.gson.FieldNamingPolicy; 004import com.google.gson.Gson; 005import com.google.gson.GsonBuilder; 006import com.pusher.rest.crypto.CryptoUtil; 007import com.pusher.rest.data.*; 008import com.pusher.rest.marshaller.DataMarshaller; 009import com.pusher.rest.marshaller.DefaultDataMarshaller; 010import com.pusher.rest.util.Prerequisites; 011 012import java.net.URI; 013import java.nio.charset.StandardCharsets; 014import java.util.ArrayList; 015import java.util.Collections; 016import java.util.List; 017import java.util.Map; 018import java.util.regex.Matcher; 019import java.util.regex.Pattern; 020 021/** 022 * Parent class for Pusher clients, deals with anything that isn't IO related. 023 * 024 * @param <T> The return type of the IO calls. 025 * 026 * See {@link Pusher} for the synchronous implementation, {@link PusherAsync} for the asynchronous implementation. 027 */ 028public abstract class PusherAbstract<T> { 029 protected static final Gson BODY_SERIALISER = new GsonBuilder() 030 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 031 .disableHtmlEscaping() 032 .create(); 033 034 private static final Pattern HEROKU_URL = Pattern.compile("(https?)://(.+):(.+)@(.+:?.*)/apps/(.+)"); 035 private static final String ENCRYPTED_CHANNEL_PREFIX = "private-encrypted-"; 036 037 protected final String appId; 038 protected final String key; 039 protected final String secret; 040 041 protected String host = "api.pusherapp.com"; 042 protected String scheme = "http"; 043 044 private DataMarshaller dataMarshaller; 045 private CryptoUtil crypto; 046 private final boolean hasValidEncryptionMasterKey; 047 048 /** 049 * Construct an instance of the Pusher object through which you may interact with the Pusher API. 050 * <p> 051 * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 052 * <p> 053 * 054 * @param appId The ID of the App you will to interact with. 055 * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 056 * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 057 */ 058 public PusherAbstract(final String appId, final String key, final String secret) { 059 Prerequisites.nonEmpty("appId", appId); 060 Prerequisites.nonEmpty("key", key); 061 Prerequisites.nonEmpty("secret", secret); 062 Prerequisites.isValidSha256Key("secret", secret); 063 064 this.appId = appId; 065 this.key = key; 066 this.secret = secret; 067 this.hasValidEncryptionMasterKey = false; 068 069 configureDataMarshaller(); 070 } 071 072 /** 073 * Construct an instance of the Pusher object through which you may interact with the Pusher API. 074 * <p> 075 * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 076 * <p> 077 * 078 * @param appId The ID of the App you will to interact with. 079 * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 080 * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 081 * @param encryptionMasterKeyBase64 32 byte key, base64 encoded. This key, along with the channel name, are used to derive per-channel encryption keys. 082 */ 083 public PusherAbstract(final String appId, final String key, final String secret, final String encryptionMasterKeyBase64) { 084 Prerequisites.nonEmpty("appId", appId); 085 Prerequisites.nonEmpty("key", key); 086 Prerequisites.nonEmpty("secret", secret); 087 Prerequisites.isValidSha256Key("secret", secret); 088 Prerequisites.nonEmpty("encryptionMasterKeyBase64", encryptionMasterKeyBase64); 089 090 this.appId = appId; 091 this.key = key; 092 this.secret = secret; 093 094 this.crypto = new CryptoUtil(encryptionMasterKeyBase64); 095 this.hasValidEncryptionMasterKey = true; 096 097 configureDataMarshaller(); 098 } 099 100 public PusherAbstract(final String url) { 101 Prerequisites.nonNull("url", url); 102 103 final Matcher m = HEROKU_URL.matcher(url); 104 if (m.matches()) { 105 this.scheme = m.group(1); 106 this.key = m.group(2); 107 this.secret = m.group(3); 108 this.host = m.group(4); 109 this.appId = m.group(5); 110 this.hasValidEncryptionMasterKey = false; 111 } else { 112 throw new IllegalArgumentException("URL '" + url + "' does not match pattern '<scheme>://<key>:<secret>@<host>[:<port>]/apps/<appId>'"); 113 } 114 115 Prerequisites.isValidSha256Key("secret", secret); 116 configureDataMarshaller(); 117 } 118 119 private void configureDataMarshaller() { 120 this.dataMarshaller = new DefaultDataMarshaller(); 121 } 122 123 protected void setCryptoUtil(CryptoUtil crypto) { 124 this.crypto = crypto; 125 } 126 127 /* 128 * CONFIG 129 */ 130 131 /** 132 * For testing or specifying an alternative cluster. See also {@link #setCluster(String)} for the latter. 133 * <p> 134 * Default: api.pusherapp.com 135 * 136 * @param host the API endpoint host 137 */ 138 public void setHost(final String host) { 139 Prerequisites.nonNull("host", host); 140 141 this.host = host; 142 } 143 144 /** 145 * For Specifying an alternative cluster. 146 * <p> 147 * See also {@link #setHost(String)} for targetting an arbitrary endpoint. 148 * 149 * @param cluster the Pusher cluster to target 150 */ 151 public void setCluster(final String cluster) { 152 Prerequisites.nonNull("cluster", cluster); 153 154 this.host = "api-" + cluster + ".pusher.com"; 155 } 156 157 /** 158 * Set whether to use a secure connection to the API (SSL). 159 * <p> 160 * Authentication is secure even without this option, requests cannot be faked or replayed with access 161 * to their plain text, a secure connection is only required if the requests or responses contain 162 * sensitive information. 163 * <p> 164 * Default: false 165 * 166 * @param encrypted whether to use SSL to contact the API 167 */ 168 public void setEncrypted(final boolean encrypted) { 169 this.scheme = encrypted ? "https" : "http"; 170 } 171 172 /** 173 * Set the Gson instance used to marshal Objects passed to {@link #trigger(List, String, Object)} 174 * Set the marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 175 * and friends. 176 * By default, the library marshals the objects provided to JSON using the Gson library 177 * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 178 * here, you may exert control over the marshalling, for example choosing how Java property 179 * names are mapped on to the field names in the JSON representation, allowing you to match 180 * the expected scheme on the client side. 181 * We added the {@link #setDataMarshaller(DataMarshaller)} method to allow specification 182 * of other marshalling libraries. This method was kept around to maintain backwards 183 * compatibility. 184 * @param gson a GSON instance configured to your liking 185 */ 186 public void setGsonSerialiser(final Gson gson) { 187 setDataMarshaller(new DefaultDataMarshaller(gson)); 188 } 189 190 /** 191 * Set a custom marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 192 * and friends. 193 * <p> 194 * By default, the library marshals the objects provided to JSON using the Gson library 195 * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 196 * here, you may exert control over the marshalling, for example choosing how Java property 197 * names are mapped on to the field names in the JSON representation, allowing you to match 198 * the expected scheme on the client side. 199 * 200 * @param marshaller a DataMarshaller instance configured to your liking 201 */ 202 public void setDataMarshaller(final DataMarshaller marshaller) { 203 this.dataMarshaller = marshaller; 204 } 205 206 /** 207 * This method provides an override point if the default Gson based serialisation is absolutely 208 * unsuitable for your use case, even with customisation of the Gson instance doing the serialisation. 209 * <p> 210 * For example, in the simplest case, you might already have your data pre-serialised and simply want 211 * to elide the default serialisation: 212 * <pre> 213 * Pusher pusher = new Pusher(appId, key, secret) { 214 * protected String serialise(final Object data) { 215 * return (String)data; 216 * } 217 * }; 218 * 219 * pusher.trigger("my-channel", "my-event", "{\"my-data\":\"my-value\"}"); 220 * </pre> 221 * 222 * @param data an unserialised event payload 223 * @return a serialised event payload 224 */ 225 protected String serialise(final Object data) { 226 return dataMarshaller.marshal(data); 227 } 228 229 /* 230 * REST 231 */ 232 233 /** 234 * Publish a message to a single channel. 235 * <p> 236 * The message data should be a POJO, which will be serialised to JSON for submission. 237 * Use {@link #setDataMarshaller(DataMarshaller)} to control the serialisation 238 * <p> 239 * Note that if you do not wish to create classes specifically for the purpose of specifying 240 * the message payload, use Map<String, Object>. These maps will nest just fine. 241 * 242 * @param channel the channel name on which to trigger the event 243 * @param eventName the name given to the event 244 * @param data an object which will be serialised to create the event body 245 * @return a {@link Result} object encapsulating the success state and response to the request 246 */ 247 public T trigger(final String channel, final String eventName, final Object data) { 248 return trigger(channel, eventName, data, null); 249 } 250 251 /** 252 * Publish identical messages to multiple channels. 253 * 254 * @param channels the channel names on which to trigger the event 255 * @param eventName the name given to the event 256 * @param data an object which will be serialised to create the event body 257 * @return a {@link Result} object encapsulating the success state and response to the request 258 */ 259 public T trigger(final List<String> channels, final String eventName, final Object data) { 260 return trigger(channels, eventName, data, null); 261 } 262 263 /** 264 * Publish a message to a single channel, excluding the specified socketId from receiving the message. 265 * 266 * @param channel the channel name on which to trigger the event 267 * @param eventName the name given to the event 268 * @param data an object which will be serialised to create the event body 269 * @param socketId a socket id which should be excluded from receiving the event 270 * @return a {@link Result} object encapsulating the success state and response to the request 271 */ 272 public T trigger(final String channel, final String eventName, final Object data, final String socketId) { 273 return trigger(Collections.singletonList(channel), eventName, data, socketId); 274 } 275 276 /** 277 * Publish identical messages to multiple channels, excluding the specified socketId from receiving the message. 278 * 279 * @param channels the channel names on which to trigger the event 280 * @param eventName the name given to the event 281 * @param data an object which will be serialised to create the event body 282 * @param socketId a socket id which should be excluded from receiving the event 283 * @return a {@link Result} object encapsulating the success state and response to the request 284 */ 285 public T trigger(final List<String> channels, final String eventName, final Object data, final String socketId) { 286 Prerequisites.nonNull("channels", channels); 287 Prerequisites.nonNull("eventName", eventName); 288 Prerequisites.nonNull("data", data); 289 Prerequisites.maxLength("channels", 100, channels); 290 Prerequisites.noNullMembers("channels", channels); 291 Prerequisites.areValidChannels(channels); 292 Prerequisites.isValidSocketId(socketId); 293 294 final String eventBody; 295 final String encryptedChannel = channels.stream() 296 .filter(this::isEncryptedChannel) 297 .findFirst() 298 .orElse(""); 299 300 if (encryptedChannel.isEmpty()) { 301 eventBody = serialise(data); 302 } else { 303 requireEncryptionMasterKey(); 304 305 if (channels.size() > 1) { 306 throw PusherException.cannotTriggerMultipleChannelsWithEncryption(); 307 } 308 309 eventBody = encryptPayload(encryptedChannel, serialise(data)); 310 } 311 312 final String body = BODY_SERIALISER.toJson(new TriggerData(channels, eventName, eventBody, socketId)); 313 314 return post("/events", body); 315 } 316 317 318 /** 319 * Publish a batch of different events with a single API call. 320 * <p> 321 * The batch is limited to 10 events on our multi-tenant clusters. 322 * 323 * @param batch a list of events to publish 324 * @return a {@link Result} object encapsulating the success state and response to the request 325 */ 326 public T trigger(final List<Event> batch) { 327 final List<Event> eventsWithSerialisedBodies = new ArrayList<Event>(batch.size()); 328 329 for (final Event e : batch) { 330 final String eventData; 331 332 if (isEncryptedChannel(e.getChannel())) { 333 requireEncryptionMasterKey(); 334 335 eventData = encryptPayload(e.getChannel(), serialise(e.getData())); 336 } else { 337 eventData = serialise(e.getData()); 338 } 339 340 eventsWithSerialisedBodies.add( 341 new Event( 342 e.getChannel(), 343 e.getName(), 344 eventData, 345 e.getSocketId() 346 ) 347 ); 348 } 349 350 final String body = BODY_SERIALISER.toJson(new EventBatch(eventsWithSerialisedBodies)); 351 352 return post("/batch_events", body); 353 } 354 355 /** 356 * Make a generic HTTP call to the Pusher API. 357 * <p> 358 * See: http://pusher.com/docs/rest_api 359 * <p> 360 * NOTE: the path specified here is relative to that of your app. For example, to access 361 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 362 * at the beginning of the path. 363 * 364 * @param path the path (e.g. /channels) to query 365 * @return a {@link Result} object encapsulating the success state and response to the request 366 */ 367 public T get(final String path) { 368 return get(path, Collections.<String, String>emptyMap()); 369 } 370 371 /** 372 * Make a generic HTTP call to the Pusher API. 373 * <p> 374 * See: http://pusher.com/docs/rest_api 375 * <p> 376 * Parameters should be a map of query parameters for the HTTP call, and may be null 377 * if none are required. 378 * <p> 379 * NOTE: the path specified here is relative to that of your app. For example, to access 380 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 381 * at the beginning of the path. 382 * 383 * @param path the path (e.g. /channels) to query 384 * @param parameters query parameters to submit with the request 385 * @return a {@link Result} object encapsulating the success state and response to the request 386 */ 387 public T get(final String path, final Map<String, String> parameters) { 388 final String fullPath = "/apps/" + appId + path; 389 final URI uri = SignatureUtil.uri("GET", scheme, host, fullPath, null, key, secret, parameters); 390 391 return doGet(uri); 392 } 393 394 protected abstract T doGet(final URI uri); 395 396 /** 397 * Make a generic HTTP call to the Pusher API. 398 * <p> 399 * The body should be a UTF-8 encoded String 400 * <p> 401 * See: http://pusher.com/docs/rest_api 402 * <p> 403 * NOTE: the path specified here is relative to that of your app. For example, to access 404 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 405 * at the beginning of the path. 406 * 407 * @param path the path (e.g. /channels) to submit 408 * @param body the body to submit 409 * @return a {@link Result} object encapsulating the success state and response to the request 410 */ 411 public T post(final String path, final String body) { 412 final String fullPath = "/apps/" + appId + path; 413 final URI uri = SignatureUtil.uri("POST", scheme, host, fullPath, body, key, secret, Collections.<String, String>emptyMap()); 414 415 return doPost(uri, body); 416 } 417 418 protected abstract T doPost(final URI uri, final String body); 419 420 /** 421 * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 422 * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 423 * 424 * @param method the HTTP method, e.g. GET, POST 425 * @param path the HTTP path, e.g. /channels 426 * @param body the HTTP request body, if there is one (otherwise pass null) 427 * @return a URI object which includes the necessary query params for request authentication 428 */ 429 public URI signedUri(final String method, final String path, final String body) { 430 return signedUri(method, path, body, Collections.<String, String>emptyMap()); 431 } 432 433 /** 434 * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 435 * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 436 * <p> 437 * Note that any further query parameters you wish to be add must be specified here, as they form part of the signature. 438 * 439 * @param method the HTTP method, e.g. GET, POST 440 * @param path the HTTP path, e.g. /channels 441 * @param body the HTTP request body, if there is one (otherwise pass null) 442 * @param parameters HTTP query parameters to be included in the request 443 * @return a URI object which includes the necessary query params for request authentication 444 */ 445 public URI signedUri(final String method, final String path, final String body, final Map<String, String> parameters) { 446 return SignatureUtil.uri(method, scheme, host, path, body, key, secret, parameters); 447 } 448 449 /* 450 * CHANNEL AUTHENTICATION 451 */ 452 453 /** 454 * Generate authentication response to authorise a user on a private channel 455 * <p> 456 * The return value is the complete body which should be returned to a client requesting authorisation. 457 * 458 * @param socketId the socket id of the connection to authenticate 459 * @param channel the name of the channel which the socket id should be authorised to join 460 * @return an authentication string, suitable for return to the requesting client 461 */ 462 public String authenticate(final String socketId, final String channel) { 463 Prerequisites.nonNull("socketId", socketId); 464 Prerequisites.nonNull("channel", channel); 465 Prerequisites.isValidChannel(channel); 466 Prerequisites.isValidSocketId(socketId); 467 468 if (channel.startsWith("presence-")) { 469 throw new IllegalArgumentException("This method is for private channels, use authenticate(String, String, PresenceUser) to authenticate for a presence channel."); 470 } 471 if (!channel.startsWith("private-")) { 472 throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 473 } 474 475 final String signature = SignatureUtil.sign(socketId + ":" + channel, secret); 476 477 final AuthData authData = new AuthData(key, signature); 478 479 if (isEncryptedChannel(channel)) { 480 requireEncryptionMasterKey(); 481 482 authData.setSharedSecret(crypto.generateBase64EncodedSharedSecret(channel)); 483 } 484 485 return BODY_SERIALISER.toJson(authData); 486 } 487 488 /** 489 * Generate authentication response to authorise a user on a presence channel 490 * <p> 491 * The return value is the complete body which should be returned to a client requesting authorisation. 492 * 493 * @param socketId the socket id of the connection to authenticate 494 * @param channel the name of the channel which the socket id should be authorised to join 495 * @param user a {@link PresenceUser} object which represents the channel data to be associated with the user 496 * @return an authentication string, suitable for return to the requesting client 497 */ 498 public String authenticate(final String socketId, final String channel, final PresenceUser user) { 499 Prerequisites.nonNull("socketId", socketId); 500 Prerequisites.nonNull("channel", channel); 501 Prerequisites.nonNull("user", user); 502 Prerequisites.isValidChannel(channel); 503 Prerequisites.isValidSocketId(socketId); 504 505 if (channel.startsWith("private-")) { 506 throw new IllegalArgumentException("This method is for presence channels, use authenticate(String, String) to authenticate for a private channel."); 507 } 508 if (!channel.startsWith("presence-")) { 509 throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 510 } 511 512 final String channelData = BODY_SERIALISER.toJson(user); 513 final String signature = SignatureUtil.sign(socketId + ":" + channel + ":" + channelData, secret); 514 return BODY_SERIALISER.toJson(new AuthData(key, signature, channelData)); 515 } 516 517 /* 518 * WEBHOOK VALIDATION 519 */ 520 521 /** 522 * Check the signature on a webhook received from Pusher 523 * 524 * @param xPusherKeyHeader the X-Pusher-Key header as received in the webhook request 525 * @param xPusherSignatureHeader the X-Pusher-Signature header as received in the webhook request 526 * @param body the webhook body 527 * @return enum representing the possible validities of the webhook request 528 */ 529 public Validity validateWebhookSignature(final String xPusherKeyHeader, final String xPusherSignatureHeader, final String body) { 530 if (!xPusherKeyHeader.trim().equals(key)) { 531 // We can't validate the signature, because it was signed with a different key to the one we were initialised with. 532 return Validity.SIGNED_WITH_WRONG_KEY; 533 } 534 535 final String recalculatedSignature = SignatureUtil.sign(body, secret); 536 return xPusherSignatureHeader.trim().equals(recalculatedSignature) ? Validity.VALID : Validity.INVALID; 537 } 538 539 private boolean isEncryptedChannel(final String channel) { 540 return channel.startsWith(ENCRYPTED_CHANNEL_PREFIX); 541 } 542 543 private void requireEncryptionMasterKey() 544 { 545 if (hasValidEncryptionMasterKey) { 546 return; 547 } 548 549 throw PusherException.encryptionMasterKeyRequired(); 550 } 551 552 private String encryptPayload(final String encryptedChannel, final String payload) { 553 final EncryptedMessage encryptedMsg = crypto.encrypt( 554 encryptedChannel, 555 payload.getBytes(StandardCharsets.UTF_8) 556 ); 557 558 return BODY_SERIALISER.toJson(encryptedMsg); 559 } 560}