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