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