package com.faforever.client.api; import com.faforever.client.config.CacheNames; import com.faforever.client.io.ByteCountListener; import com.faforever.client.io.CountingFileContent; import com.faforever.client.leaderboard.Ranked1v1EntryBean; import com.faforever.client.map.MapBean; import com.faforever.client.mod.ModInfoBean; import com.faforever.client.net.UriUtil; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.user.UserService; import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl; import com.google.api.client.auth.oauth2.BearerToken; import com.google.api.client.auth.oauth2.ClientParametersAuthentication; import com.google.api.client.auth.oauth2.Credential; import com.google.api.client.auth.oauth2.TokenResponse; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpMediaType; import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.MultipartContent; import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.JsonParser; import com.google.api.client.json.JsonToken; import com.google.api.client.util.store.FileDataStoreFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.escape.Escaper; import com.google.common.net.MediaType; import com.google.common.net.UrlEscapers; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.ReflectionUtils; import org.springframework.web.util.UriComponentsBuilder; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.regex.Pattern; import java.util.stream.Collectors; public class FafApiAccessorImpl implements FafApiAccessor { private static final String HTTP_LOCALHOST = "http://localhost:"; private static final String ENCODED_HTTP_LOCALHOST = HTTP_LOCALHOST.replace(":", "%3A").replace("/", "%2F"); private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final String SCOPE_READ_ACHIEVEMENTS = "read_achievements"; private static final String SCOPE_READ_EVENTS = "read_events"; private static final String UPLOAD_MAP = "upload_map"; private static final String UPLOAD_MOD = "upload_mod"; @Resource JsonFactory jsonFactory; @Resource PreferencesService preferencesService; @Resource HttpTransport httpTransport; @Resource UserService userService; @Resource ClientHttpRequestFactory clientHttpRequestFactory; @Value("${api.baseUrl}") String baseUrl; @Value("${oauth.authUri}") String oAuthUrl; @Value("${oauth.tokenUri}") String oAuthTokenServerUrl; @Value("${oauth.clientId}") String oAuthClientId; @Value("${oauth.clientSecret}") String oAuthClientSecret; @Value("${oauth.loginUri}") URI oAuthLoginUrl; @VisibleForTesting Credential credential; @VisibleForTesting HttpRequestFactory requestFactory; private FileDataStoreFactory dataStoreFactory; @PostConstruct void postConstruct() throws IOException { Path playServicesDirectory = preferencesService.getPreferencesDirectory().resolve("oauth"); dataStoreFactory = new FileDataStoreFactory(playServicesDirectory.toFile()); } @Override @SuppressWarnings("unchecked") public List<PlayerAchievement> getPlayerAchievements(int playerId) { logger.debug("Loading achievements for player: {}", playerId); return getMany("/players/" + playerId + "/achievements", PlayerAchievement.class, 1); } @Override @SuppressWarnings("unchecked") public List<PlayerEvent> getPlayerEvents(int playerId) { logger.debug("Loading events for player: {}", playerId); return getMany("/players/" + playerId + "/events", PlayerEvent.class, 1); } @Override @SuppressWarnings("unchecked") @Cacheable(CacheNames.ACHIEVEMENTS) public List<AchievementDefinition> getAchievementDefinitions() { logger.debug("Loading achievement definitions"); return getMany("/achievements?sort=order", AchievementDefinition.class, 1); } @Override @Cacheable(CacheNames.ACHIEVEMENTS) public AchievementDefinition getAchievementDefinition(String achievementId) { logger.debug("Getting definition for achievement {}", achievementId); return getSingle("/achievements/" + achievementId, AchievementDefinition.class); } @Override public void authorize(int playerId) { try { AuthorizationCodeFlow flow = new AuthorizationCodeFlow.Builder( BearerToken.authorizationHeaderAccessMethod(), httpTransport, jsonFactory, new GenericUrl(oAuthTokenServerUrl), new ClientParametersAuthentication(oAuthClientId, oAuthClientSecret), oAuthClientId, oAuthUrl) .setDataStoreFactory(dataStoreFactory) .setScopes(Arrays.asList(SCOPE_READ_ACHIEVEMENTS, SCOPE_READ_EVENTS, UPLOAD_MAP, UPLOAD_MOD)) .build(); credential = authorize(flow, String.valueOf(playerId)); requestFactory = httpTransport.createRequestFactory(credential); } catch (IOException e) { throw new RuntimeException(e); } } @Override public List<ModInfoBean> getMods() { logger.debug("Loading available mods"); return getMany("/mods", Mod.class).stream() .map(ModInfoBean::fromModInfo) .collect(Collectors.toList()); } private <T> List<T> getMany(String endpointPath, Class<T> type) { List<T> result = new LinkedList<>(); List<T> current = null; int page = 1; while (current == null || !current.isEmpty()) { current = getMany(endpointPath, type, page++); result.addAll(current); } return result; } @Override public MapBean findMapByName(String mapId) { logger.debug("Searching map: {}", mapId); return MapBean.fromMap(getSingle("/maps/" + mapId, com.faforever.client.api.Map.class)); } @Override @Cacheable(CacheNames.LEADERBOARD) public List<Ranked1v1EntryBean> getRanked1v1Entries() { return getMany("/leaderboards/1v1", LeaderboardEntry.class).stream() .map(Ranked1v1EntryBean::fromLeaderboardEntry) .collect(Collectors.toList()); } @Override public Ranked1v1Stats getRanked1v1Stats() { return getSingle("/leaderboards/1v1/stats", Ranked1v1Stats.class); } @Override public Ranked1v1EntryBean getRanked1v1EntryForPlayer(int playerId) { return Ranked1v1EntryBean.fromLeaderboardEntry(getSingle("/leaderboards/1v1/" + playerId, LeaderboardEntry.class)); } @Override public History getRatingHistory(RatingType ratingType, int playerId) { return getSingle(String.format("/players/%d/ratings/%s/history", playerId, ratingType.getString()), History.class); } @Override @Cacheable(CacheNames.MAPS) public List<MapBean> getMaps() { logger.debug("Getting all maps"); // FIXME don't page 1 return requestMaps("/maps", 1); } @Override @Cacheable(CacheNames.MAPS) public List<MapBean> getMostDownloadedMaps(int count) { logger.debug("Getting most downloaded maps"); return requestMaps(String.format("/maps?page[size]=%d&sort=-downloads", count), 1); } @Override @Cacheable(CacheNames.MAPS) public List<MapBean> getMostPlayedMaps(int count) { logger.debug("Getting most played maps"); return requestMaps(String.format("/maps?page[size]=%d&sort=-times_played", count), 1); } @Override @Cacheable(CacheNames.MAPS) public List<MapBean> getBestRatedMaps(int count) { logger.debug("Getting most liked maps"); return requestMaps(String.format("/maps?page[size]=%d&sort=-rating", count), 1); } @Override public List<MapBean> getNewestMaps(int count) { logger.debug("Getting most liked maps"); return requestMaps(String.format("/maps?page[size]=%d&sort=-create_time", count), 1); } @Override public void uploadMod(Path file, ByteCountListener listener) throws IOException { MultipartContent multipartContent = createFileMultipart(file, listener); postMultipart("/mods/upload", multipartContent); } @Override public void uploadMap(Path file, boolean isRanked, ByteCountListener listener) throws IOException { MultipartContent multipartContent = createFileMultipart(file, listener); multipartContent.addPart(new MultipartContent.Part( new HttpHeaders().set("Content-Disposition", "form-data; name=\"metadata\";"), new JsonHttpContent(jsonFactory, new GenericJson() { { set("is_ranked", isRanked); } }))); postMultipart("/maps/upload", multipartContent); } @NotNull private MultipartContent createFileMultipart(Path file, ByteCountListener listener) { HttpMediaType mediaType = new HttpMediaType("multipart/form-data").setParameter("boundary", "__END_OF_PART__"); MultipartContent multipartContent = new MultipartContent().setMediaType(mediaType); String fileName = file.getFileName().toString(); CountingFileContent fileContent = new CountingFileContent(guessMediaType(fileName).toString(), file, listener); HttpHeaders headers = new HttpHeaders().set("Content-Disposition", String.format("form-data; name=\"file\"; filename=\"%s\"", fileName)); return multipartContent.addPart(new MultipartContent.Part(headers, fileContent)); } private void postMultipart(String endpointPath, MultipartContent multipartContent) throws IOException { if (requestFactory == null) { throw new IllegalStateException("authorize() must be called first"); } String url = baseUrl + endpointPath; logger.trace("Posting to: {}", url); HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(url), multipartContent) .setThrowExceptionOnExecuteError(false) .setParser(new JsonObjectParser(jsonFactory)); credential.initialize(request); HttpResponse httpResponse = request.execute(); if (httpResponse.getStatusCode() == 400) { throw new ApiException(httpResponse.parseAs(ErrorResponse.class)); } else if (!httpResponse.isSuccessStatusCode()) { throw new HttpResponseException(httpResponse); } } @NotNull private MediaType guessMediaType(String fileName) { if (fileName.endsWith(".zip")) { return MediaType.ZIP; } return MediaType.OCTET_STREAM; } private List<MapBean> requestMaps(String query, int page) { logger.debug("Loading available maps"); return getMany(query, Map.class, page) .stream() .map(MapBean::fromMap) .collect(Collectors.toList()); } private Credential authorize(AuthorizationCodeFlow flow, String userId) throws IOException { Credential credential = flow.loadCredential(userId); if (credential != null && (credential.getRefreshToken() != null || credential.getExpiresInSeconds() > 60)) { return credential; } // The redirect URI is irrelevant to this implementation, however the server requires one String redirectUri = "http://localhost:1337"; AuthorizationCodeRequestUrl authorizationUrl = flow.newAuthorizationUrl().setRedirectUri(redirectUri); // Google's GenericUrl does not escape ":" and "/", but Flask (FAF's OAuth) requires it. URI fixedAuthorizationUri = UriUtil.fromString(authorizationUrl.build() .replaceFirst("uri=" + Pattern.quote(HTTP_LOCALHOST), "uri=" + ENCODED_HTTP_LOCALHOST)); Escaper escaper = UrlEscapers.urlFormParameterEscaper(); byte[] postData = ("username=" + escaper.escape(userService.getUsername()) + "&password=" + escaper.escape(userService.getPassword()) + "&next=" + fixedAuthorizationUri).getBytes(StandardCharsets.UTF_8); int postDataLength = postData.length; ClientHttpRequest loginRequest = clientHttpRequestFactory.createRequest(oAuthLoginUrl, HttpMethod.POST); loginRequest.getHeaders().add("Content-Length", Integer.toString(postDataLength)); try (DataOutputStream outputStream = new DataOutputStream(loginRequest.getBody())) { outputStream.write(postData); } ClientHttpResponse loginResponse = loginRequest.execute(); if (!loginResponse.getStatusCode().is3xxRedirection()) { throw new RuntimeException("Could not log in (" + loginResponse.getStatusCode() + ")"); } String cookie = Joiner.on("").join(loginResponse.getHeaders().get("set-cookie")); postData = "allow=yes".getBytes(StandardCharsets.UTF_8); postDataLength = postData.length; ClientHttpRequest authorizeRequest = clientHttpRequestFactory.createRequest(fixedAuthorizationUri, HttpMethod.POST); authorizeRequest.getHeaders().add("Content-Length", Integer.toString(postDataLength)); authorizeRequest.getHeaders().add("Cookie", cookie); try (DataOutputStream outputStream = new DataOutputStream(authorizeRequest.getBody())) { outputStream.write(postData); } ClientHttpResponse authorizeResponse = authorizeRequest.execute(); URI redirectLocation = authorizeResponse.getHeaders().getLocation(); if (!authorizeResponse.getStatusCode().is3xxRedirection() || !redirectLocation.toString().contains("code=")) { throw new RuntimeException("Could not authorize (" + authorizeResponse.getStatusCode() + ")"); } String code = UriComponentsBuilder.fromUri(redirectLocation).build().getQueryParams().get("code").get(0); TokenResponse tokenResponse = flow.newTokenRequest(code).setRedirectUri(redirectUri).execute(); return flow.createAndStoreCredential(tokenResponse, userId); } @SuppressWarnings("unchecked") private <T> T getSingle(String endpointPath, Class<T> type) { try (InputStream inputStream = executeGet(endpointPath)) { JsonParser jsonParser = jsonFactory.createJsonParser(inputStream, StandardCharsets.UTF_8); jsonParser.nextToken(); jsonParser.skipToKey("data"); return extractObject(type, jsonParser); } catch (IOException | IllegalAccessException e) { throw new RuntimeException(e); } } @SuppressWarnings("unchecked") private <T> List<T> getMany(String endpointPath, Class<T> type, int page) { String innerEndpointPath = endpointPath; if (page > 0) { innerEndpointPath += endpointPath.contains("?") ? "&" : "?"; innerEndpointPath += "page[number]=" + page; } ArrayList<T> result = new ArrayList<>(); try (InputStream inputStream = executeGet(innerEndpointPath)) { JsonParser jsonParser = jsonFactory.createJsonParser(inputStream, StandardCharsets.UTF_8); jsonParser.nextToken(); jsonParser.skipToKey("data"); while (jsonParser.nextToken() != JsonToken.END_ARRAY) { T object = extractObject(type, jsonParser); result.add(object); } return result; } catch (IOException | IllegalAccessException e) { throw new RuntimeException(e); } } private InputStream executeGet(String endpointPath) throws IOException { if (requestFactory == null) { throw new IllegalStateException("authorize() must be called first"); } String url = baseUrl + endpointPath; logger.trace("Calling: {}", url); HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url)); credential.initialize(request); return request.execute().getContent(); } @Nullable private <T> T extractObject(Class<T> type, JsonParser jsonParser) throws IOException, IllegalAccessException { T object = null; String id = null; JsonToken currentToken = jsonParser.nextToken(); while (currentToken != null && currentToken != JsonToken.END_OBJECT) { switch (jsonParser.getCurrentToken()) { case START_OBJECT: break; case FIELD_NAME: if ("attributes".equals(jsonParser.getCurrentName())) { jsonParser.nextToken(); object = jsonParser.parse(type); } else if ("id".equals(jsonParser.getCurrentName())) { jsonParser.nextToken(); id = jsonParser.getText(); } break; } currentToken = jsonParser.nextToken(); } Field idField = ReflectionUtils.findField(type, "id"); if (idField != null) { ReflectionUtils.makeAccessible(idField); idField.set(object, id); } return object; } }