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