001/*
002 * Copyright (c) 2010-2025 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  @Override
639  public String getBusinessLoginDialogUrl(String appId, String redirectUri, String configId, String state,
640                                          Parameter... parameters) {
641    verifyParameterPresence("configId", configId);
642
643    List<Parameter> parameterList = new ArrayList<>(asList(parameters));
644    parameterList.add(Parameter.with("config_id", configId));
645    parameterList.add(Parameter.with("response_type", "code"));
646    parameterList.add(Parameter.with("override_default_response_type", true));
647
648    return getGenericLoginDialogUrl(appId, redirectUri, new ScopeBuilder(true),
649            () -> getFacebookEndpointUrls().getFacebookEndpoint() + "/dialog/oauth", state, parameterList);
650  }
651
652  protected String getGenericLoginDialogUrl(String appId, String redirectUri, ScopeBuilder scope,
653      Supplier<String> endpointSupplier, String state, List<Parameter> parameters) {
654    verifyParameterPresence(APP_ID, appId);
655    verifyParameterPresence("redirectUri", redirectUri);
656    verifyParameterPresence(SCOPE, scope);
657
658    String dialogUrl = endpointSupplier.get();
659
660    List<Parameter> parameterList = new ArrayList<>();
661    parameterList.add(Parameter.with(CLIENT_ID, appId));
662    parameterList.add(Parameter.with(REDIRECT_URI, redirectUri));
663    if (!scope.toString().isEmpty()) {
664      parameterList.add(Parameter.with(SCOPE, scope.toString()));
665    }
666
667    if (StringUtils.isNotBlank(state)) {
668      parameterList.add(Parameter.with("state", state));
669    }
670
671    // add optional parameters
672    parameterList.addAll(parameters);
673    return dialogUrl + "?" + toParameterString(false, parameterList.toArray(new Parameter[0]));
674  }
675
676  /**
677   * Verifies that the signed request is really from Facebook.
678   * 
679   * @param appSecret
680   *          The secret for the app that can verify this signed request.
681   * @param algorithm
682   *          Signature algorithm specified by FB in the decoded payload.
683   * @param encodedPayload
684   *          The encoded payload used to generate a signature for comparison against the provided {@code signature}.
685   * @param signature
686   *          The decoded signature extracted from the signed request. Compared against a signature generated from
687   *          {@code encodedPayload}.
688   * @return {@code true} if the signed request is verified, {@code false} if not.
689   */
690  protected boolean verifySignedRequest(String appSecret, String algorithm, String encodedPayload, byte[] signature) {
691    verifyParameterPresence(APP_SECRET, appSecret);
692    verifyParameterPresence(ALGORITHM, algorithm);
693    verifyParameterPresence("encodedPayload", encodedPayload);
694    verifyParameterPresence("signature", signature);
695
696    // Normalize algorithm name...FB calls it differently than Java does
697    if ("HMAC-SHA256".equalsIgnoreCase(algorithm)) {
698      algorithm = "HMACSHA256";
699    }
700
701    try {
702      Mac mac = Mac.getInstance(algorithm);
703      mac.init(new SecretKeySpec(toBytes(appSecret), algorithm));
704      byte[] payloadSignature = mac.doFinal(toBytes(encodedPayload));
705      return Arrays.equals(signature, payloadSignature);
706    } catch (Exception e) {
707      throw new FacebookSignedRequestVerificationException("Unable to perform signed request verification", e);
708    }
709  }
710
711  /**
712   * @see com.restfb.FacebookClient#debugToken(java.lang.String)
713   */
714  @Override
715  public DebugTokenInfo debugToken(String inputToken) {
716    verifyParameterPresence("inputToken", inputToken);
717    String response = makeRequest("/debug_token", Parameter.with("input_token", inputToken));
718
719    try {
720      JsonObject json = Json.parse(response).asObject();
721
722      // FB sometimes returns an empty DebugTokenInfo and then the 'data' field is an array
723      if (json.contains("data") && json.get("data").isArray()) {
724        return null;
725      }
726
727      JsonObject data = json.get("data").asObject();
728      return getJsonMapper().toJavaObject(data.toString(), DebugTokenInfo.class);
729    } catch (Exception t) {
730      throw new FacebookResponseContentException("Unable to parse JSON from response.", t);
731    }
732  }
733
734  /**
735   * @see com.restfb.FacebookClient#getJsonMapper()
736   */
737  @Override
738  public JsonMapper getJsonMapper() {
739    return jsonMapper;
740  }
741
742  /**
743   * @see com.restfb.FacebookClient#getWebRequestor()
744   */
745  @Override
746  public WebRequestor getWebRequestor() {
747    return webRequestor;
748  }
749
750  /**
751   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
752   * endpoint.
753   * 
754   * @param endpoint
755   *          Facebook Graph API endpoint.
756   * @param parameters
757   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
758   * @return The JSON returned by Facebook for the API call.
759   * @throws FacebookException
760   *           If an error occurs while making the Facebook API POST or processing the response.
761   */
762  protected String makeRequest(String endpoint, Parameter... parameters) {
763    return makeRequest(endpoint, false, false, null, parameters);
764  }
765
766  protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete,
767      final List<BinaryAttachment> binaryAttachments, Parameter... parameters) {
768    return makeRequest(endpoint, executeAsPost, executeAsDelete, binaryAttachments, null, parameters);
769  }
770
771  /**
772   * Coordinates the process of executing the API request GET/POST and processing the response we receive from the
773   * endpoint.
774   * 
775   * @param endpoint
776   *          Facebook Graph API endpoint.
777   * @param executeAsPost
778   *          {@code true} to execute the web request as a {@code POST}, {@code false} to execute as a {@code GET}.
779   * @param executeAsDelete
780   *          {@code true} to add a special 'treat this request as a {@code DELETE}' parameter.
781   * @param binaryAttachments
782   *          A list of binary files to include in a {@code POST} request. Pass {@code null} if no attachment should be
783   *          sent.
784   * @param parameters
785   *          Arbitrary number of parameters to send along to Facebook as part of the API call.
786   * @return The JSON returned by Facebook for the API call.
787   * @throws FacebookException
788   *           If an error occurs while making the Facebook API POST or processing the response.
789   */
790  protected String makeRequest(String endpoint, final boolean executeAsPost, final boolean executeAsDelete,
791      final List<BinaryAttachment> binaryAttachments, Body body, Parameter... parameters) {
792    verifyParameterLegality(parameters);
793
794    if (executeAsDelete && isHttpDeleteFallback()) {
795      parameters = parametersWithAdditionalParameter(Parameter.with(METHOD_PARAM_NAME, "delete"), parameters);
796    }
797
798    if (!endpoint.startsWith("/")) {
799      endpoint = "/" + endpoint;
800    }
801
802    boolean hasAttachment = binaryAttachments != null && !binaryAttachments.isEmpty();
803    boolean hasReel = hasAttachment && binaryAttachments.get(0).isFacebookReel();
804
805    final String fullEndpoint = createEndpointForApiCall(endpoint, hasAttachment, hasReel);
806    final String parameterString = toParameterString(parameters);
807
808    String headerAccessToken = (hasReel) ? accessToken : getHeaderAccessToken();
809
810    return makeRequestAndProcessResponse(() -> {
811      WebRequestor.Request request = new WebRequestor.Request(fullEndpoint, headerAccessToken, parameterString);
812      if (executeAsDelete && !isHttpDeleteFallback()) {
813        return webRequestor.executeDelete(request);
814      }
815
816      if (executeAsPost) {
817        request.setBinaryAttachments(binaryAttachments);
818        request.setBody(body);
819        return webRequestor.executePost(request);
820      }
821
822      return webRequestor.executeGet(request);
823    });
824  }
825
826  private String getHeaderAccessToken() {
827    if (accessTokenInHeader) {
828      return this.accessToken;
829    }
830
831    return null;
832  }
833
834  /**
835   * @see com.restfb.FacebookClient#obtainAppSecretProof(java.lang.String, java.lang.String)
836   */
837  @Override
838  public String obtainAppSecretProof(String accessToken, String appSecret) {
839    verifyParameterPresence("accessToken", accessToken);
840    verifyParameterPresence(APP_SECRET, appSecret);
841    return EncodingUtils.encodeAppSecretProof(appSecret, accessToken);
842  }
843
844  /**
845   * returns if the fallback post method (<code>true</code>) is used or the http delete (<code>false</code>)
846   * 
847   * @return {@code true} if POST is used instead of HTTP DELETE (default)
848   */
849  public boolean isHttpDeleteFallback() {
850    return httpDeleteFallback;
851  }
852
853  /**
854   * Set to <code>true</code> if the facebook http delete fallback should be used. Facebook allows to use the http POST
855   * with the parameter "method=delete" to override the post and use delete instead. This feature allow http client that
856   * do not support the whole http method set, to delete objects from facebook
857   * 
858   * @param httpDeleteFallback
859   *          <code>true</code> if the http Delete Fallback is used
860   */
861  public void setHttpDeleteFallback(boolean httpDeleteFallback) {
862    this.httpDeleteFallback = httpDeleteFallback;
863  }
864
865  protected interface Requestor {
866    Response makeRequest() throws IOException;
867  }
868
869  protected String makeRequestAndProcessResponse(Requestor requestor) {
870    Response response;
871
872    // Perform a GET or POST to the API endpoint
873    try {
874      response = requestor.makeRequest();
875    } catch (Exception t) {
876      throw new FacebookNetworkException(t);
877    }
878
879    // If we get any HTTP response code other than a 200 OK or 400 Bad Request
880    // or 401 Not Authorized or 403 Forbidden or 404 Not Found or 500 Internal
881    // Server Error or 302 Not Modified
882    // throw an exception.
883    if (HTTP_OK != response.getStatusCode() && HTTP_BAD_REQUEST != response.getStatusCode()
884        && HTTP_UNAUTHORIZED != response.getStatusCode() && HTTP_NOT_FOUND != response.getStatusCode()
885        && HTTP_INTERNAL_ERROR != response.getStatusCode() && HTTP_FORBIDDEN != response.getStatusCode()
886        && HTTP_NOT_MODIFIED != response.getStatusCode()) {
887      throw new FacebookNetworkException(response.getStatusCode());
888    }
889
890    String json = response.getBody();
891
892    try {
893      // If the response contained an error code, throw an exception.
894      getFacebookExceptionGenerator().throwFacebookResponseStatusExceptionIfNecessary(json, response.getStatusCode());
895    } catch (FacebookErrorMessageException feme) {
896      Optional.ofNullable(getWebRequestor()).map(WebRequestor::getDebugHeaderInfo).ifPresent(feme::setDebugHeaderInfo);
897      throw feme;
898    }
899
900    // If there was no response error information and this was a 500 or 401
901    // error, something weird happened on Facebook's end. Bail.
902    if (HTTP_INTERNAL_ERROR == response.getStatusCode() || HTTP_UNAUTHORIZED == response.getStatusCode()) {
903      throw new FacebookNetworkException(response.getStatusCode());
904    }
905
906    return json;
907  }
908
909  /**
910   * Generate the parameter string to be included in the Facebook API request.
911   * 
912   * @param parameters
913   *          Arbitrary number of extra parameters to include in the request.
914   * @return The parameter string to include in the Facebook API request.
915   * @throws FacebookJsonMappingException
916   *           If an error occurs when building the parameter string.
917   */
918  protected String toParameterString(Parameter... parameters) {
919    return toParameterString(true, parameters);
920  }
921
922  /**
923   * Generate the parameter string to be included in the Facebook API request.
924   * 
925   * @param withJsonParameter
926   *          add additional parameter format with type json
927   * @param parameters
928   *          Arbitrary number of extra parameters to include in the request.
929   * @return The parameter string to include in the Facebook API request.
930   * @throws FacebookJsonMappingException
931   *           If an error occurs when building the parameter string.
932   */
933  protected String toParameterString(boolean withJsonParameter, Parameter... parameters) {
934    if (!isBlank(accessToken) && !accessTokenInHeader) {
935      parameters = parametersWithAdditionalParameter(Parameter.with(ACCESS_TOKEN_PARAM_NAME, accessToken), parameters);
936    }
937
938    if (!isBlank(accessToken) && !isBlank(appSecret)) {
939      parameters = parametersWithAdditionalParameter(
940        Parameter.with(APP_SECRET_PROOF_PARAM_NAME, obtainAppSecretProof(accessToken, appSecret)), parameters);
941    }
942
943    if (withJsonParameter) {
944      parameters = parametersWithAdditionalParameter(Parameter.with(FORMAT_PARAM_NAME, "json"), parameters);
945    }
946
947    return Stream.of(parameters).map(p -> urlEncode(p.name) + "=" + urlEncodedValueForParameterName(p.name, p.value))
948      .collect(Collectors.joining("&"));
949  }
950
951  /**
952   * @see com.restfb.BaseFacebookClient#createEndpointForApiCall(java.lang.String,boolean,boolean)
953   */
954  @Override
955  protected String createEndpointForApiCall(String apiCall, boolean hasAttachment, boolean hasReel) {
956    while (apiCall.startsWith("/")) {
957      apiCall = apiCall.substring(1);
958    }
959
960    String baseUrl = createBaseUrlForEndpoint(apiCall, hasAttachment, hasReel);
961
962    return format("%s/%s", baseUrl, apiCall);
963  }
964
965  protected String createBaseUrlForEndpoint(String apiCall, boolean hasAttachment, boolean hasReel) {
966    String baseUrl = getFacebookGraphEndpointUrl();
967    if (hasAttachment && hasReel) {
968      baseUrl = getFacebookReelsUploadEndpointUrl();
969    } else if (hasAttachment && (apiCall.endsWith("/videos") || apiCall.endsWith("/advideos"))) {
970      baseUrl = getFacebookGraphVideoEndpointUrl();
971    } else if (apiCall.endsWith("logout.php")) {
972      baseUrl = getFacebookEndpointUrls().getFacebookEndpoint();
973    }
974    return baseUrl;
975  }
976
977  /**
978   * Returns the base endpoint URL for the Graph API.
979   * 
980   * @return The base endpoint URL for the Graph API.
981   */
982  protected String getFacebookGraphEndpointUrl() {
983    if (apiVersion.isUrlElementRequired()) {
984      return getFacebookEndpointUrls().getGraphEndpoint() + '/' + apiVersion.getUrlElement();
985    } else {
986      return getFacebookEndpointUrls().getGraphEndpoint();
987    }
988  }
989
990  /**
991   * Returns the base endpoint URL for the Graph APIs video upload functionality.
992   * 
993   * @return The base endpoint URL for the Graph APIs video upload functionality.
994   * @since 1.6.5
995   */
996  protected String getFacebookGraphVideoEndpointUrl() {
997    if (apiVersion.isUrlElementRequired()) {
998      return getFacebookEndpointUrls().getGraphVideoEndpoint() + '/' + apiVersion.getUrlElement();
999    } else {
1000      return getFacebookEndpointUrls().getGraphVideoEndpoint();
1001    }
1002  }
1003
1004  /**
1005   * Returns the Facebook Reels Upload endpoint URL for handling the Reels Upload
1006   * 
1007   * @return the Facebook Reels Upload endpoint URL
1008   */
1009  protected String getFacebookReelsUploadEndpointUrl() {
1010    if (apiVersion.isUrlElementRequired()) {
1011      return getFacebookEndpointUrls().getReelUploadEndpoint() + "/" + apiVersion.getUrlElement();
1012    }
1013
1014    return getFacebookEndpointUrls().getReelUploadEndpoint();
1015  }
1016
1017  public FacebookEndpoints getFacebookEndpointUrls() {
1018    return facebookEndpointUrls;
1019  }
1020
1021  public void setFacebookEndpointUrls(FacebookEndpoints facebookEndpointUrls) {
1022    this.facebookEndpointUrls = facebookEndpointUrls;
1023  }
1024}