001/*
002 * Copyright (c) 2010-2020 Mark Allen, Norbert Bartels.
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.restfb;
023
024import static com.restfb.logging.RestFBLogger.CLIENT_LOGGER;
025import static com.restfb.util.EncodingUtils.decodeBase64;
026import static com.restfb.util.ObjectUtil.verifyParameterPresence;
027import static com.restfb.util.StringUtils.*;
028import static com.restfb.util.UrlUtils.urlEncode;
029import static java.lang.String.format;
030import static java.net.HttpURLConnection.*;
031import static java.util.Arrays.asList;
032import static java.util.Collections.emptyList;
033
034import java.io.IOException;
035import java.util.*;
036import java.util.stream.Collectors;
037import java.util.stream.Stream;
038
039import javax.crypto.Mac;
040import javax.crypto.spec.SecretKeySpec;
041
042import com.restfb.WebRequestor.Response;
043import com.restfb.batch.BatchRequest;
044import com.restfb.batch.BatchResponse;
045import com.restfb.exception.*;
046import com.restfb.exception.devicetoken.*;
047import com.restfb.exception.generator.DefaultFacebookExceptionGenerator;
048import com.restfb.exception.generator.FacebookExceptionGenerator;
049import com.restfb.json.*;
050import com.restfb.scope.ScopeBuilder;
051import com.restfb.types.DeviceCode;
052import com.restfb.util.EncodingUtils;
053import com.restfb.util.ObjectUtil;
054import com.restfb.util.StringUtils;
055
056/**
057 * Default implementation of a <a href="http://developers.facebook.com/docs/api">Facebook Graph API</a> client.
058 * 
059 * @author <a href="http://restfb.com">Mark Allen</a>
060 */
061public class DefaultFacebookClient extends BaseFacebookClient implements FacebookClient {
062  public static final String CLIENT_ID = "client_id";
063  public static final String APP_ID = "appId";
064  public static final String APP_SECRET = "appSecret";
065  public static final String SCOPE = "scope";
066  public static final String CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE = "Unable to extract access token from response.";
067  /**
068   * Graph API access token.
069   */
070  protected String accessToken;
071
072  /**
073   * Graph API app secret.
074   */
075  private String appSecret;
076
077  /**
078   * facebook exception generator to convert Facebook error json into java exceptions
079   */
080  private FacebookExceptionGenerator graphFacebookExceptionGenerator;
081
082  /**
083   * holds the Facebook endpoint urls
084   */
085  private FacebookEndpoints facebookEndpointUrls = new FacebookEndpoints() {};
086
087  /**
088   * Reserved "multiple IDs" parameter name.
089   */
090  protected static final String IDS_PARAM_NAME = "ids";
091
092  /**
093   * Version of API endpoint.
094   */
095  protected Version apiVersion = Version.UNVERSIONED;
096
097  /**
098   * By default this is <code>false</code>, so real http DELETE is used
099   */
100  protected boolean httpDeleteFallback;
101
102  protected DefaultFacebookClient() {
103    this(Version.LATEST);
104  }
105
106  /**
107   * Creates a Facebook Graph API client with the given {@code apiVersion}.
108   * 
109   * @param apiVersion
110   *          Version of the api endpoint
111   */
112  public DefaultFacebookClient(Version apiVersion) {
113    this(null, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
114  }
115
116  /**
117   * Creates a Facebook Graph API client with the given {@code accessToken}.
118   * 
119   * @param accessToken
120   *          A Facebook OAuth access token.
121   * @param apiVersion
122   *          Version of the api endpoint
123   * @since 1.6.14
124   */
125  public DefaultFacebookClient(String accessToken, Version apiVersion) {
126    this(accessToken, null, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
127  }
128
129  /**
130   * Creates a Facebook Graph API client with the given {@code accessToken}.
131   * 
132   * @param accessToken
133   *          A Facebook OAuth access token.
134   * @param appSecret
135   *          A Facebook application secret.
136   * @param apiVersion
137   *          Version of the api endpoint
138   * @since 1.6.14
139   */
140  public DefaultFacebookClient(String accessToken, String appSecret, Version apiVersion) {
141    this(accessToken, appSecret, new DefaultWebRequestor(), new DefaultJsonMapper(), apiVersion);
142  }
143
144  /**
145   * Creates a Facebook Graph API client with the given {@code accessToken}.
146   * 
147   * @param accessToken
148   *          A Facebook OAuth access token.
149   * @param webRequestor
150   *          The {@link WebRequestor} implementation to use for sending requests to the API endpoint.
151   * @param jsonMapper
152   *          The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects.
153   * @param apiVersion
154   *          Version of the api endpoint
155   * @throws NullPointerException
156   *           If {@code jsonMapper} or {@code webRequestor} is {@code null}.
157   * @since 1.6.14
158   */
159  public DefaultFacebookClient(String accessToken, WebRequestor webRequestor, JsonMapper jsonMapper,
160      Version apiVersion) {
161    this(accessToken, null, webRequestor, jsonMapper, apiVersion);
162  }
163
164  /**
165   * Creates a Facebook Graph API client with the given {@code accessToken}, {@code webRequestor}, and
166   * {@code jsonMapper}.
167   * 
168   * @param accessToken
169   *          A Facebook OAuth access token.
170   * @param appSecret
171   *          A Facebook application secret.
172   * @param webRequestor
173   *          The {@link WebRequestor} implementation to use for sending requests to the API endpoint.
174   * @param jsonMapper
175   *          The {@link JsonMapper} implementation to use for mapping API response JSON to Java objects.
176   * @param apiVersion
177   *          Version of the api endpoint
178   * @throws NullPointerException
179   *           If {@code jsonMapper} or {@code webRequestor} is {@code null}.
180   */
181  public DefaultFacebookClient(String accessToken, String appSecret, WebRequestor webRequestor, JsonMapper jsonMapper,
182      Version apiVersion) {
183    super();
184
185    verifyParameterPresence("jsonMapper", jsonMapper);
186    verifyParameterPresence("webRequestor", webRequestor);
187
188    this.accessToken = trimToNull(accessToken);
189    this.appSecret = trimToNull(appSecret);
190
191    this.webRequestor = webRequestor;
192    this.jsonMapper = jsonMapper;
193    this.jsonMapper.setFacebookClient(this);
194    this.apiVersion = Optional.ofNullable(apiVersion).orElse(Version.UNVERSIONED);
195    graphFacebookExceptionGenerator = new DefaultFacebookExceptionGenerator();
196  }
197
198  /**
199   * override the default facebook exception generator to provide a custom handling for the facebook error objects
200   * 
201   * @param exceptionGenerator
202   *          the custom exception generator implementing the {@link FacebookExceptionGenerator} interface
203   */
204  public void setFacebookExceptionGenerator(FacebookExceptionGenerator exceptionGenerator) {
205    graphFacebookExceptionGenerator = exceptionGenerator;
206  }
207
208  /**
209   * fetch the current facebook exception generator implementing the {@link FacebookExceptionGenerator} interface
210   * 
211   * @return the current facebook exception generator
212   */
213  public FacebookExceptionGenerator getFacebookExceptionGenerator() {
214    return graphFacebookExceptionGenerator;
215  }
216
217  @Override
218  public boolean deleteObject(String object, Parameter... parameters) {
219    verifyParameterPresence("object", object);
220
221    String responseString = makeRequest(object, true, true, null, parameters);
222
223    try {
224      JsonValue jObj = Json.parse(responseString);
225      boolean success = false;
226      if (jObj.isObject()) {
227        if (jObj.asObject().contains("success")) {
228          success = jObj.asObject().get("success").asBoolean();
229        }
230        if (jObj.asObject().contains("result")) {
231          success = jObj.asObject().get("result").asString().contains("Successfully deleted");
232        }
233      } else {
234        success = jObj.asBoolean();
235      }
236      return success;
237    } catch (ParseException jex) {
238      CLIENT_LOGGER.trace("no valid JSON returned while deleting a object, using returned String instead", jex);
239      return "true".equals(responseString);
240    }
241  }
242
243  /**
244   * @see com.restfb.FacebookClient#fetchConnection(java.lang.String, java.lang.Class, com.restfb.Parameter[])
245   */
246  @Override
247  public <T> Connection<T> fetchConnection(String connection, Class<T> connectionType, Parameter... parameters) {
248    verifyParameterPresence("connection", connection);
249    verifyParameterPresence("connectionType", connectionType);
250    return new Connection<>(this, makeRequest(connection, parameters), connectionType);
251  }
252
253  /**
254   * @see com.restfb.FacebookClient#fetchConnectionPage(java.lang.String, java.lang.Class)
255   */
256  @Override
257  public <T> Connection<T> fetchConnectionPage(final String connectionPageUrl, Class<T> connectionType) {
258    String connectionJson;
259    if (!isBlank(accessToken) && !isBlank(appSecret)) {
260      connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(String.format("%s&%s=%s", connectionPageUrl,
261        urlEncode(APP_SECRET_PROOF_PARAM_NAME), obtainAppSecretProof(accessToken, appSecret))));
262    } else {
263      connectionJson = makeRequestAndProcessResponse(() -> webRequestor.executeGet(connectionPageUrl));
264    }
265
266    return new Connection<>(this, connectionJson, connectionType);
267  }
268
269  /**
270   * @see com.restfb.FacebookClient#fetchObject(java.lang.String, java.lang.Class, com.restfb.Parameter[])
271   */
272  @Override
273  public <T> T fetchObject(String object, Class<T> objectType, Parameter... parameters) {
274    verifyParameterPresence("object", object);
275    verifyParameterPresence("objectType", objectType);
276    return jsonMapper.toJavaObject(makeRequest(object, parameters), objectType);
277  }
278
279  @Override
280  public FacebookClient createClientWithAccessToken(String accessToken) {
281    return new DefaultFacebookClient(accessToken, this.appSecret, this.apiVersion);
282  }
283
284  /**
285   * @see com.restfb.FacebookClient#fetchObjects(java.util.List, java.lang.Class, com.restfb.Parameter[])
286   */
287  @Override
288  @SuppressWarnings("unchecked")
289  public <T> T fetchObjects(List<String> ids, Class<T> objectType, Parameter... parameters) {
290    verifyParameterPresence("ids", ids);
291    verifyParameterPresence("connectionType", objectType);
292
293    if (ids.isEmpty()) {
294      throw new IllegalArgumentException("The list of IDs cannot be empty.");
295    }
296
297    if (Stream.of(parameters).anyMatch(p -> IDS_PARAM_NAME.equals(p.name))) {
298      throw new IllegalArgumentException("You cannot specify the '" + IDS_PARAM_NAME + "' URL parameter yourself - "
299          + "RestFB will populate this for you with the list of IDs you passed to this method.");
300    }
301
302    JsonArray idArray = new JsonArray();
303
304    // Normalize the IDs
305    for (String id : ids) {
306      if (StringUtils.isBlank(id)) {
307        throw new IllegalArgumentException("The list of IDs cannot contain blank strings.");
308      }
309      idArray.add(id.trim());
310    }
311
312    try {
313      String jsonString = makeRequest("",
314        parametersWithAdditionalParameter(Parameter.with(IDS_PARAM_NAME, idArray.toString()), parameters));
315
316      return jsonMapper.toJavaObject(jsonString, objectType);
317    } catch (ParseException e) {
318      throw new FacebookJsonMappingException("Unable to map connection JSON to Java objects", e);
319    }
320  }
321
322  /**
323   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment,
324   *      com.restfb.Parameter[])
325   */
326  @Override
327  public <T> T publish(String connection, Class<T> objectType, List<BinaryAttachment> binaryAttachments,
328      Parameter... parameters) {
329    verifyParameterPresence("connection", connection);
330
331    return jsonMapper.toJavaObject(makeRequest(connection, true, false, binaryAttachments, parameters), objectType);
332  }
333
334  /**
335   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.BinaryAttachment,
336   *      com.restfb.Parameter[])
337   */
338  @Override
339  public <T> T publish(String connection, Class<T> objectType, BinaryAttachment binaryAttachment,
340      Parameter... parameters) {
341    List<BinaryAttachment> attachments = Optional.ofNullable(binaryAttachment).map(Collections::singletonList).orElse(null);
342    return publish(connection, objectType, attachments, parameters);
343  }
344
345  /**
346   * @see com.restfb.FacebookClient#publish(java.lang.String, java.lang.Class, com.restfb.Parameter[])
347   */
348  @Override
349  public <T> T publish(String connection, Class<T> objectType, Parameter... parameters) {
350    return publish(connection, objectType, (List<BinaryAttachment>) null, parameters);
351  }
352
353  @Override
354  public String getLogoutUrl(String next) {
355    String parameterString;
356    if (next != null) {
357      Parameter p = Parameter.with("next", next);
358      parameterString = toParameterString(false, p);
359    } else {
360      parameterString = toParameterString(false);
361    }
362
363    final String fullEndPoint = createEndpointForApiCall("logout.php", false);
364    return fullEndPoint + "?" + parameterString;
365  }
366
367  /**
368   * @see com.restfb.FacebookClient#executeBatch(com.restfb.batch.BatchRequest[])
369   */
370  @Override
371  public List<BatchResponse> executeBatch(BatchRequest... batchRequests) {
372    return executeBatch(asList(batchRequests), Collections.emptyList());
373  }
374
375  /**
376   * @see com.restfb.FacebookClient#executeBatch(java.util.List)
377   */
378  @Override
379  public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests) {
380    return executeBatch(batchRequests, Collections.emptyList());
381  }
382
383  /**
384   * @see com.restfb.FacebookClient#executeBatch(java.util.List, java.util.List)
385   */
386  @Override
387  public List<BatchResponse> executeBatch(List<BatchRequest> batchRequests, List<BinaryAttachment> binaryAttachments) {
388    verifyParameterPresence("binaryAttachments", binaryAttachments);
389
390    if (batchRequests == null || batchRequests.isEmpty()) {
391      throw new IllegalArgumentException("You must specify at least one batch request.");
392    }
393
394    return jsonMapper.toJavaList(
395      makeRequest("", true, false, binaryAttachments, Parameter.with("batch", jsonMapper.toJson(batchRequests, true))),
396      BatchResponse.class);
397  }
398
399  /**
400   * @see com.restfb.FacebookClient#convertSessionKeysToAccessTokens(java.lang.String, java.lang.String,
401   *      java.lang.String[])
402   */
403  @Override
404  public List<AccessToken> convertSessionKeysToAccessTokens(String appId, String secretKey, String... sessionKeys) {
405    verifyParameterPresence(APP_ID, appId);
406    verifyParameterPresence("secretKey", secretKey);
407
408    if (sessionKeys == null || sessionKeys.length == 0) {
409      return emptyList();
410    }
411
412    String json = makeRequest("/oauth/exchange_sessions", true, false, null, Parameter.with(CLIENT_ID, appId),
413      Parameter.with("client_secret", secretKey), Parameter.with("sessions", String.join(",", sessionKeys)));
414
415    return jsonMapper.toJavaList(json, AccessToken.class);
416  }
417
418  /**
419   * @see com.restfb.FacebookClient#obtainAppAccessToken(java.lang.String, java.lang.String)
420   */
421  @Override
422  public AccessToken obtainAppAccessToken(String appId, String appSecret) {
423    verifyParameterPresence(APP_ID, appId);
424    verifyParameterPresence(APP_SECRET, appSecret);
425
426    String response = makeRequest("oauth/access_token", Parameter.with("grant_type", "client_credentials"),
427      Parameter.with(CLIENT_ID, appId), Parameter.with("client_secret", appSecret));
428
429    try {
430      return getAccessTokenFromResponse(response);
431    } catch (Exception t) {
432      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
433    }
434  }
435
436  @Override
437  public DeviceCode fetchDeviceCode(ScopeBuilder scope) {
438    verifyParameterPresence(SCOPE, scope);
439    ObjectUtil.requireNotNull(accessToken, () -> new IllegalStateException("access token is required to fetch a device access token"));
440
441    String response = makeRequest("device/login", true, false, null, Parameter.with("type", "device_code"),
442      Parameter.with(SCOPE, scope.toString()));
443    return jsonMapper.toJavaObject(response, DeviceCode.class);
444  }
445
446  @Override
447  public AccessToken obtainDeviceAccessToken(String code) throws FacebookDeviceTokenCodeExpiredException,
448      FacebookDeviceTokenPendingException, FacebookDeviceTokenDeclinedException, FacebookDeviceTokenSlowdownException {
449    verifyParameterPresence("code", code);
450
451    ObjectUtil.requireNotNull(accessToken, () -> new IllegalStateException("access token is required to fetch a device access token"));
452
453    try {
454      String response = makeRequest("device/login_status", true, false, null, Parameter.with("type", "device_token"),
455        Parameter.with("code", code));
456      return getAccessTokenFromResponse(response);
457    } catch (FacebookOAuthException foae) {
458      DeviceTokenExceptionFactory.createFrom(foae);
459      return null;
460    }
461  }
462
463  /**
464   * @see com.restfb.FacebookClient#obtainUserAccessToken(java.lang.String, java.lang.String, java.lang.String,
465   *      java.lang.String)
466   */
467  @Override
468  public AccessToken obtainUserAccessToken(String appId, String appSecret, String redirectUri,
469      String verificationCode) {
470    verifyParameterPresence(APP_ID, appId);
471    verifyParameterPresence(APP_SECRET, appSecret);
472    verifyParameterPresence("verificationCode", verificationCode);
473
474    String response = makeRequest("oauth/access_token", Parameter.with(CLIENT_ID, appId),
475      Parameter.with("client_secret", appSecret), Parameter.with("code", verificationCode),
476      Parameter.with("redirect_uri", redirectUri));
477
478    try {
479      return getAccessTokenFromResponse(response);
480    } catch (Exception t) {
481      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
482    }
483  }
484
485  /**
486   * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String)
487   */
488  @Override
489  public AccessToken obtainExtendedAccessToken(String appId, String appSecret) {
490    ObjectUtil.requireNotNull(accessToken,
491      () -> new IllegalStateException(
492        format("You cannot call this method because you did not construct this instance of %s with an access token.",
493          getClass().getSimpleName())));
494
495    return obtainExtendedAccessToken(appId, appSecret, accessToken);
496  }
497
498  /**
499   * @see com.restfb.FacebookClient#obtainExtendedAccessToken(java.lang.String, java.lang.String, java.lang.String)
500   */
501  @Override
502  public AccessToken obtainExtendedAccessToken(String appId, String appSecret, String accessToken) {
503    verifyParameterPresence(APP_ID, appId);
504    verifyParameterPresence(APP_SECRET, appSecret);
505    verifyParameterPresence("accessToken", accessToken);
506
507    String response = makeRequest("/oauth/access_token", false, false, null, Parameter.with(CLIENT_ID, appId),
508      Parameter.with("client_secret", appSecret), Parameter.with("grant_type", "fb_exchange_token"),
509      Parameter.with("fb_exchange_token", accessToken));
510
511    try {
512      return getAccessTokenFromResponse(response);
513    } catch (Exception t) {
514      throw new FacebookResponseContentException(CANNOT_EXTRACT_ACCESS_TOKEN_MESSAGE, t);
515    }
516  }
517
518  private AccessToken getAccessTokenFromResponse(String response) {
519    AccessToken token;
520    try {
521      token = getJsonMapper().toJavaObject(response, AccessToken.class);
522    } catch (FacebookJsonMappingException fjme) {
523      CLIENT_LOGGER.trace("could not map response to access token class try to fetch directly from String", fjme);
524      token = AccessToken.fromQueryString(response);
525    }
526    token.setClient(createClientWithAccessToken(token.getAccessToken()));
527    return token;
528  }
529
530  @Override
531  @SuppressWarnings("unchecked")
532  public <T> T parseSignedRequest(String signedRequest, String appSecret, Class<T> objectType) {
533    verifyParameterPresence("signedRequest", signedRequest);
534    verifyParameterPresence(APP_SECRET, appSecret);
535    verifyParameterPresence("objectType", objectType);
536
537    String[] signedRequestTokens = signedRequest.split("[.]");
538
539    if (signedRequestTokens.length != 2) {
540      throw new FacebookSignedRequestParsingException(format(
541        "Signed request '%s' is expected to be signature and payload strings separated by a '.'", signedRequest));
542    }
543
544    String encodedSignature = signedRequestTokens[0];
545    String urlDecodedSignature = urlDecodeSignedRequestToken(encodedSignature);
546    byte[] signature = decodeBase64(urlDecodedSignature);
547
548    String encodedPayload = signedRequestTokens[1];
549    String urlDecodedPayload = urlDecodeSignedRequestToken(encodedPayload);
550    String payload = StringUtils.toString(decodeBase64(urlDecodedPayload));
551
552    // Convert payload to a JsonObject so we can pull algorithm data out of it
553    JsonObject payloadObject = getJsonMapper().toJavaObject(payload, JsonObject.class);
554
555    if (!payloadObject.contains("algorithm")) {
556      throw new FacebookSignedRequestParsingException("Unable to detect algorithm used for signed request");
557    }
558
559    String algorithm = payloadObject.getString("algorithm", null);
560
561    if (!verifySignedRequest(appSecret, algorithm, encodedPayload, signature)) {
562      throw new FacebookSignedRequestVerificationException(
563        "Signed request verification failed. Are you sure the request was made for the app identified by the app secret you provided?");
564    }
565
566    // Marshal to the user's preferred type.
567    // If the user asked for a JsonObject, send back the one we already parsed.
568    return objectType.equals(JsonObject.class) ? (T) payloadObject : getJsonMapper().toJavaObject(payload, objectType);
569  }
570
571  /**
572   * Decodes a component of a signed request received from Facebook using FB's special URL-encoding strategy.
573   * 
574   * @param signedRequestToken
575   *          Token to decode.
576   * @return The decoded token.
577   */
578  protected String urlDecodeSignedRequestToken(String signedRequestToken) {
579    verifyParameterPresence("signedRequestToken", signedRequestToken);
580    return signedRequestToken.replace("-", "+").replace("_", "/").trim();
581  }
582
583  @Override
584  public String getLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope, Parameter... parameters) {
585    verifyParameterPresence(APP_ID, appId);
586    verifyParameterPresence("redirectUri", redirectUri);
587    verifyParameterPresence(SCOPE, scope);
588
589    String dialogUrl = getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth";
590
591    List<Parameter> parameterList = new ArrayList<>();
592    parameterList.add(Parameter.with(CLIENT_ID, appId));
593    parameterList.add(Parameter.with("redirect_uri", redirectUri));
594    parameterList.add(Parameter.with(SCOPE, scope.toString()));
595
596    // add optional parameters
597    Collections.addAll(parameterList, parameters);
598
599    return dialogUrl + "?" + toParameterString(false, parameterList.stream().toArray(Parameter[]::new));
600  }
601
602  /**
603   * Verifies that the signed request is really from Facebook.
604   * 
605   * @param appSecret
606   *          The secret for the app that can verify this signed request.
607   * @param algorithm
608   *          Signature algorithm specified by FB in the decoded payload.
609   * @param encodedPayload
610   *          The encoded payload used to generate a signature for comparison against the provided {@code signature}.
611   * @param signature
612   *          The decoded signature extracted from the signed request. Compared against a signature generated from
613   *          {@code encodedPayload}.
614   * @return {@code true} if the signed request is verified, {@code false} if not.
615   */
616  protected boolean verifySignedRequest(String appSecret, String algorithm, String encodedPayload, byte[] signature) {
617    verifyParameterPresence(APP_SECRET, appSecret);
618    verifyParameterPresence("algorithm", algorithm);
619    verifyParameterPresence("encodedPayload", encodedPayload);
620    verifyParameterPresence("signature", signature);
621
622    // Normalize algorithm name...FB calls it differently than Java does
623    if ("HMAC-SHA256".equalsIgnoreCase(algorithm)) {
624      algorithm = "HMACSHA256";
625    }
626
627    try {
628      Mac mac = Mac.getInstance(algorithm);
629      mac.init(new SecretKeySpec(toBytes(appSecret), algorithm));
630      byte[] payloadSignature = mac.doFinal(toBytes(encodedPayload));
631      return Arrays.equals(signature, payloadSignature);
632    } catch (Exception e) {
633      throw new FacebookSignedRequestVerificationException("Unable to perform signed request verification", e);
634    }
635  }
636
637  /**
638   * @see com.restfb.FacebookClient#debugToken(java.lang.String)
639   */
640  @Override
641  public DebugTokenInfo debugToken(String inputToken) {
642    verifyParameterPresence("inputToken", inputToken);
643    String response = makeRequest("/debug_token", Parameter.with("input_token", inputToken));
644
645    try {
646      JsonObject json = Json.parse(response).asObject();
647      JsonObject data = json.get("data").asObject();
648      return getJsonMapper().toJavaObject(data.toString(), DebugTokenInfo.class);
649    } catch (Exception t) {
650      throw new FacebookResponseContentException("Unable to parse JSON from response.", t);
651    }
652  }
653
654  /**
655   * @see com.restfb.FacebookClient#getJsonMapper()
656   */
657  @Override
658  public JsonMapper getJsonMapper() {
659    return jsonMapper;
660  }
661
662  /**
663   * @see com.restfb.FacebookClient#getWebRequestor()
664   */
665  @Override
666  public WebRequestor getWebRequestor() {
667    return webRequestor;
668  }
669
670  /**
671   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
672   * endpoint.
673   * 
674   * @param endpoint
675   *          Facebook Graph API endpoint.
676   * @param parameters
677   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
678   * @return The JSON returned by Facebook for the API call.
679   * @throws FacebookException
680   *           If an error occurs while making the Facebook API POST or processing the response.
681   */
682  protected String makeRequest(String endpoint, Parameter... parameters) {
683    return makeRequest(endpoint, false, false, null, parameters);
684  }
685
686  /**
687   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
688   * endpoint.
689   * 
690   * @param endpoint
691   *          Facebook Graph API endpoint.
692   * @param executeAsPost
693   *          {@code true} to execute the web request as a {@code POST}, {@code false} to execute as a {@code GET}.
694   * @param executeAsDelete
695   *          {@code true} to add a special 'treat this request as a {@code DELETE}' parameter.
696   * @param binaryAttachments
697   *          A list of binary files to include in a {@code POST} request. Pass {@code null} if no attachment should be
698   *          sent.
699   * @param parameters
700   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
701   * @return The JSON returned by Facebook for the API call.
702   * @throws FacebookException
703   *           If an error occurs while making the Facebook API POST or processing the response.
704   */
705  protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete,
706      final List<BinaryAttachment> binaryAttachments, Parameter... parameters) {
707    verifyParameterLegality(parameters);
708
709    if (executeAsDelete && isHttpDeleteFallback()) {
710      parameters = parametersWithAdditionalParameter(Parameter.with(METHOD_PARAM_NAME, "delete"), parameters);
711    }
712
713    if (!endpoint.startsWith("/")) {
714      endpoint = "/" + endpoint;
715    }
716
717    final String fullEndpoint =
718        createEndpointForApiCall(endpoint, binaryAttachments != null && !binaryAttachments.isEmpty());
719    final String parameterString = toParameterString(parameters);
720
721    return makeRequestAndProcessResponse(() -> {
722        if (executeAsDelete && !isHttpDeleteFallback()) {
723          return webRequestor.executeDelete(fullEndpoint + "?" + parameterString);
724        }
725
726        if (executeAsPost) {
727          return webRequestor.executePost(fullEndpoint, parameterString, binaryAttachments);
728        }
729
730        return webRequestor.executeGet(fullEndpoint + "?" + parameterString);
731    });
732  }
733
734  /**
735   * @see com.restfb.FacebookClient#obtainAppSecretProof(java.lang.String, java.lang.String)
736   */
737  @Override
738  public String obtainAppSecretProof(String accessToken, String appSecret) {
739    verifyParameterPresence("accessToken", accessToken);
740    verifyParameterPresence(APP_SECRET, appSecret);
741    return EncodingUtils.encodeAppSecretProof(appSecret, accessToken);
742  }
743
744  /**
745   * returns if the fallback post method (<code>true</code>) is used or the http delete (<code>false</code>)
746   * 
747   * @return
748   */
749  public boolean isHttpDeleteFallback() {
750    return httpDeleteFallback;
751  }
752
753  /**
754   * Set to <code>true</code> if the facebook http delete fallback should be used. Facebook allows to use the http POST
755   * with the parameter "method=delete" to override the post and use delete instead. This feature allow http client that
756   * do not support the whole http method set, to delete objects from facebook
757   * 
758   * @param httpDeleteFallback
759   *          <code>true</code> if the the http Delete Fallback is used
760   */
761  public void setHttpDeleteFallback(boolean httpDeleteFallback) {
762    this.httpDeleteFallback = httpDeleteFallback;
763  }
764
765  protected interface Requestor {
766    Response makeRequest() throws IOException;
767  }
768
769  protected String makeRequestAndProcessResponse(Requestor requestor) {
770    Response response;
771
772    // Perform a GET or POST to the API endpoint
773    try {
774      response = requestor.makeRequest();
775    } catch (Exception t) {
776      throw new FacebookNetworkException(t);
777    }
778
779    // If we get any HTTP response code other than a 200 OK or 400 Bad Request
780    // or 401 Not Authorized or 403 Forbidden or 404 Not Found or 500 Internal
781    // Server Error or 302 Not Modified
782    // throw an exception.
783    if (HTTP_OK != response.getStatusCode() && HTTP_BAD_REQUEST != response.getStatusCode()
784        && HTTP_UNAUTHORIZED != response.getStatusCode() && HTTP_NOT_FOUND != response.getStatusCode()
785        && HTTP_INTERNAL_ERROR != response.getStatusCode() && HTTP_FORBIDDEN != response.getStatusCode()
786        && HTTP_NOT_MODIFIED != response.getStatusCode()) {
787      throw new FacebookNetworkException(response.getStatusCode());
788    }
789
790    String json = response.getBody();
791
792    try {
793      // If the response contained an error code, throw an exception.
794      getFacebookExceptionGenerator().throwFacebookResponseStatusExceptionIfNecessary(json, response.getStatusCode());
795    } catch (FacebookErrorMessageException feme) {
796      Optional.ofNullable(getWebRequestor()).map(WebRequestor::getDebugHeaderInfo).ifPresent(feme::setDebugHeaderInfo);
797      throw feme;
798    }
799
800    // If there was no response error information and this was a 500 or 401
801    // error, something weird happened on Facebook's end. Bail.
802    if (HTTP_INTERNAL_ERROR == response.getStatusCode() || HTTP_UNAUTHORIZED == response.getStatusCode()) {
803      throw new FacebookNetworkException(response.getStatusCode());
804    }
805
806    return json;
807  }
808
809  /**
810   * Generate the parameter string to be included in the Facebook API request.
811   * 
812   * @param parameters
813   *          Arbitrary number of extra parameters to include in the request.
814   * @return The parameter string to include in the Facebook API request.
815   * @throws FacebookJsonMappingException
816   *           If an error occurs when building the parameter string.
817   */
818  protected String toParameterString(Parameter... parameters) {
819    return toParameterString(true, parameters);
820  }
821
822  /**
823   * Generate the parameter string to be included in the Facebook API request.
824   * 
825   * @param withJsonParameter
826   *          add additional parameter format with type json
827   * @param parameters
828   *          Arbitrary number of extra parameters to include in the request.
829   * @return The parameter string to include in the Facebook API request.
830   * @throws FacebookJsonMappingException
831   *           If an error occurs when building the parameter string.
832   */
833  protected String toParameterString(boolean withJsonParameter, Parameter... parameters) {
834    if (!isBlank(accessToken)) {
835      parameters = parametersWithAdditionalParameter(Parameter.with(ACCESS_TOKEN_PARAM_NAME, accessToken), parameters);
836    }
837
838    if (!isBlank(accessToken) && !isBlank(appSecret)) {
839      parameters = parametersWithAdditionalParameter(
840        Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken, appSecret)), parameters);
841    }
842
843    if (withJsonParameter) {
844      parameters = parametersWithAdditionalParameter(Parameter.with(FORMAT_PARAM_NAME, "json"), parameters);
845    }
846
847    return Stream.of(parameters).map(p -> urlEncode(p.name) + "=" + urlEncodedValueForParameterName(p.name, p.value))
848      .collect(Collectors.joining("&"));
849  }
850
851  /**
852   * @see com.restfb.BaseFacebookClient#createEndpointForApiCall(java.lang.String,boolean)
853   */
854  @Override
855  protected String createEndpointForApiCall(String apiCall, boolean hasAttachment) {
856    while (apiCall.startsWith("/"))
857      apiCall = apiCall.substring(1);
858
859    String baseUrl = getFacebookGraphEndpointUrl();
860
861    if (hasAttachment && (apiCall.endsWith("/videos") || apiCall.endsWith("/advideos"))) {
862      baseUrl = getFacebookGraphVideoEndpointUrl();
863    } else if (apiCall.endsWith("logout.php")) {
864      baseUrl = getFacebookEndpointUrls().getFacebookEndpoint();
865    }
866
867    return format("%s/%s", baseUrl, apiCall);
868  }
869
870  /**
871   * Returns the base endpoint URL for the Graph API.
872   * 
873   * @return The base endpoint URL for the Graph API.
874   */
875  protected String getFacebookGraphEndpointUrl() {
876    if (apiVersion.isUrlElementRequired()) {
877      return getFacebookEndpointUrls().getGraphEndpoint() + '/' + apiVersion.getUrlElement();
878    } else {
879      return getFacebookEndpointUrls().getGraphEndpoint();
880    }
881  }
882
883  /**
884   * Returns the base endpoint URL for the Graph API's video upload functionality.
885   * 
886   * @return The base endpoint URL for the Graph API's video upload functionality.
887   * @since 1.6.5
888   */
889  protected String getFacebookGraphVideoEndpointUrl() {
890    if (apiVersion.isUrlElementRequired()) {
891      return getFacebookEndpointUrls().getGraphVideoEndpoint() + '/' + apiVersion.getUrlElement();
892    } else {
893      return getFacebookEndpointUrls().getGraphVideoEndpoint();
894    }
895  }
896
897  public FacebookEndpoints getFacebookEndpointUrls() {
898    return facebookEndpointUrls;
899  }
900
901  public void setFacebookEndpointUrls(FacebookEndpoints facebookEndpointUrls) {
902    this.facebookEndpointUrls = facebookEndpointUrls;
903  }
904}