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