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&lt;String, Object&gt;. 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}