/* * This file is part of Player Analytics (Plan). * * Plan is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License v3 as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Plan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with Plan. If not, see . */ package com.djrapitops.plan.delivery.webserver.resolver.json; import com.djrapitops.plan.delivery.domain.DateMap; import com.djrapitops.plan.delivery.domain.auth.WebPermission; import com.djrapitops.plan.delivery.domain.datatransfer.InputFilterDto; import com.djrapitops.plan.delivery.domain.datatransfer.InputQueryDto; import com.djrapitops.plan.delivery.domain.datatransfer.PlayerListDto; import com.djrapitops.plan.delivery.domain.datatransfer.ViewDto; import com.djrapitops.plan.delivery.domain.mutators.SessionsMutator; import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.formatting.Formatters; import com.djrapitops.plan.delivery.rendering.json.PlayersTableJSONCreator; import com.djrapitops.plan.delivery.rendering.json.graphs.GraphJSONCreator; import com.djrapitops.plan.delivery.rendering.json.graphs.Graphs; import com.djrapitops.plan.delivery.web.resolver.MimeType; import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; import com.djrapitops.plan.delivery.webserver.RequestBodyConverter; import com.djrapitops.plan.delivery.webserver.cache.JSONStorage; import com.djrapitops.plan.extension.implementation.storage.queries.ExtensionQueryResultTableDataQuery; import com.djrapitops.plan.gathering.domain.FinishedSession; import com.djrapitops.plan.identification.ServerInfo; import com.djrapitops.plan.identification.ServerUUID; import com.djrapitops.plan.settings.config.PlanConfig; import com.djrapitops.plan.settings.config.paths.DisplaySettings; import com.djrapitops.plan.settings.config.paths.TimeSettings; import com.djrapitops.plan.settings.locale.Locale; import com.djrapitops.plan.storage.database.DBSystem; import com.djrapitops.plan.storage.database.Database; import com.djrapitops.plan.storage.database.queries.analysis.NetworkActivityIndexQueries; import com.djrapitops.plan.storage.database.queries.filter.Filter; import com.djrapitops.plan.storage.database.queries.filter.QueryFilters; import com.djrapitops.plan.storage.database.queries.objects.GeoInfoQueries; import com.djrapitops.plan.storage.database.queries.objects.SessionQueries; import com.djrapitops.plan.storage.database.queries.objects.playertable.QueryTablePlayersQuery; import com.djrapitops.plan.utilities.dev.Untrusted; import com.djrapitops.plan.utilities.java.Maps; import com.google.gson.Gson; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import net.playeranalytics.plugin.scheduling.TimeAmount; import javax.inject.Inject; import javax.inject.Singleton; import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.util.*; @Singleton @Path("/v1/query") public class QueryJSONResolver implements Resolver { private final QueryFilters filters; private final PlanConfig config; private final DBSystem dbSystem; private final ServerInfo serverInfo; private final JSONStorage jsonStorage; private final Graphs graphs; private final GraphJSONCreator graphJSONCreator; private final Locale locale; private final Formatters formatters; private final Gson gson; @Inject public QueryJSONResolver( QueryFilters filters, PlanConfig config, DBSystem dbSystem, ServerInfo serverInfo, JSONStorage jsonStorage, Graphs graphs, GraphJSONCreator graphJSONCreator, Locale locale, Formatters formatters, Gson gson ) { this.filters = filters; this.config = config; this.dbSystem = dbSystem; this.serverInfo = serverInfo; this.jsonStorage = jsonStorage; this.graphs = graphs; this.graphJSONCreator = graphJSONCreator; this.locale = locale; this.formatters = formatters; this.gson = gson; } @Override public boolean canAccess(Request request) { WebUser user = request.getUser().orElse(new WebUser("")); return user.hasPermission(WebPermission.ACCESS_QUERY) || user.hasPermission(WebPermission.PAGE_NETWORK_OVERVIEW_GRAPHS_CALENDAR) || user.hasPermission(WebPermission.PAGE_SERVER_ONLINE_ACTIVITY_GRAPHS_CALENDAR); } @GET @Operation( description = "Perform a query or get cached results. Use q to do new query, timestamp to see cached query.", responses = { @ApiResponse(responseCode = "200", content = @Content(mediaType = MimeType.JSON)), @ApiResponse(responseCode = "400 (invalid view)", description = "If 'view' date formats does not match afterDate dd/mm/yyyy, afterTime hh:mm, beforeDate dd/mm/yyyy, beforeTime hh:mm"), @ApiResponse(responseCode = "400 (no query)", description = "If request body is empty and 'q' request parameter is not given"), @ApiResponse(responseCode = "400 (invalid query)", description = "If request body is empty and 'q' json request parameter doesn't contain 'view' property"), }, parameters = { @Parameter(in = ParameterIn.QUERY, name = "timestamp", description = "Epoch millisecond for cached query"), @Parameter(in = ParameterIn.QUERY, name = "q", description = "URI encoded json, alternative is to POST in request body", schema = @Schema(implementation = InputQueryDto.class)) }, requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = InputQueryDto.class))) ) @Override public Optional resolve(Request request) { return Optional.of(getResponse(request)); } private Response getResponse(@Untrusted Request request) { Optional cachedResult = checkForCachedResult(request); if (cachedResult.isPresent()) return cachedResult.get(); InputQueryDto inputQuery = parseInputQuery(request); @Untrusted List queries = inputQuery.getFilters(); Filter.Result result = filters.apply(queries); List resultPath = result.getInverseResultPath(); Collections.reverse(resultPath); return buildAndStoreResponse(inputQuery, result, resultPath); } private InputQueryDto parseInputQuery(@Untrusted Request request) { if (request.getRequestBody().length == 0) { return parseInputQueryFromQueryParams(request); } else { return RequestBodyConverter.bodyJson(request, gson, InputQueryDto.class); } } private InputQueryDto parseInputQueryFromQueryParams(@Untrusted Request request) { @Untrusted String q = request.getQuery().get("q").orElseThrow(() -> new BadRequestException("'q' parameter not set (expecting json array)")); try { @Untrusted String query = URLDecoder.decode(q, StandardCharsets.UTF_8); @Untrusted List queryFilters = InputFilterDto.parse(query, gson); ViewDto view = request.getQuery().get("view") .map(viewJson -> gson.fromJson(viewJson, ViewDto.class)) .orElseThrow(() -> new BadRequestException("'view' parameter not set (expecting json object {afterDate, afterTime, beforeDate, beforeTime})")); return new InputQueryDto(view, queryFilters); } catch (IOException e) { throw new BadRequestException("Failed to decode json"); } } private Optional checkForCachedResult(@Untrusted Request request) { try { return request.getQuery().get("timestamp") .map(Long::parseLong) .flatMap(queryTimestamp -> jsonStorage.fetchExactJson("query", queryTimestamp)) .map(results -> Response.builder() .setMimeType(MimeType.JSON) .setJSONContent(results.json) .build()); } catch (@Untrusted NumberFormatException e) { throw new BadRequestException("Could not parse 'timestamp' into a number. Remove parameter or fix it."); } } private Response buildAndStoreResponse(InputQueryDto input, Filter.Result result, List resultPath) { try { long timestamp = System.currentTimeMillis(); @Untrusted Map json = Maps.builder(String.class, Object.class) .put("path", resultPath) .put("view", input.getView()) .put("filters", input.getFilters()) // filters json may contain untrusted data .put("timestamp", timestamp) .build(); if (!result.isEmpty()) { json.put("data", getDataFor(result.getResultUserIds(), input.getView())); } JSONStorage.StoredJSON stored = jsonStorage.storeJson("query", json, timestamp); return Response.builder() .setMimeType(MimeType.JSON) .setJSONContent(stored.json) .build(); } catch (ParseException e) { throw new BadRequestException("'view' date format was incorrect (expecting afterDate dd/mm/yyyy, afterTime hh:mm, beforeDate dd/mm/yyyy, beforeTime hh:mm})"); } } private Map getDataFor(Set userIds, ViewDto view) throws ParseException { long after = view.getAfterEpochMs(); long before = view.getBeforeEpochMs(); List serverUUIDs = view.getServerUUIDs(); Maps.Builder data = Maps.builder(String.class, Object.class); if (view.isWanted("players")) data.put("players", getPlayersTableData(userIds, serverUUIDs, after, before)); if (view.isWanted("activity")) data.put("activity", getActivityGraphData(userIds, serverUUIDs, after, before)); if (view.isWanted("geolocation")) data.put("geolocation", getGeolocationData(userIds)); if (view.isWanted("sessions")) data.put("sessions", getSessionSummaryData(userIds, serverUUIDs, after, before)); if (view.isWanted("sessionList")) data.put("sessionList", getSessionList(userIds, serverUUIDs, after, before)); return data.build(); } private List> getSessionList(Set userIds, List serverUUIDs, long after, long before) { Database database = dbSystem.getDatabase(); List sessions = database.query(SessionQueries.fetchQuerySessions(userIds, serverUUIDs, after, before)); return new SessionsMutator(sessions).toPlayerNameJSONMaps(graphs, config.getWorldAliasSettings(), formatters); } private Map getSessionSummaryData(Set userIds, List serverUUIDs, long after, long before) { Database database = dbSystem.getDatabase(); Map summary = database.query(SessionQueries.summaryOfPlayers(userIds, serverUUIDs, after, before)); Map formattedSummary = new HashMap<>(); Formatter timeAmount = formatters.timeAmount(); for (Map.Entry entry : summary.entrySet()) { formattedSummary.put(entry.getKey(), timeAmount.apply(entry.getValue())); } formattedSummary.put("total_sessions", Long.toString(summary.get("total_sessions"))); formattedSummary.put("average_sessions", Long.toString(summary.get("average_sessions"))); return formattedSummary; } private Map getGeolocationData(Set userIds) { Database database = dbSystem.getDatabase(); return graphJSONCreator.createGeolocationJSON( database.query(GeoInfoQueries.networkGeolocationCounts(userIds)) ); } private Map getActivityGraphData(Set userIds, List serverUUIDs, long after, long before) { Database database = dbSystem.getDatabase(); Long threshold = config.get(TimeSettings.ACTIVE_PLAY_THRESHOLD); long twoMonthsBeforeLastDate = before - TimeAmount.MONTH.toMillis(2L); long stopDate = Math.max(twoMonthsBeforeLastDate, after); DateMap> activityData = new DateMap<>(); for (long time = before; time >= stopDate; time -= TimeAmount.WEEK.toMillis(1L)) { activityData.put(time, database.query(NetworkActivityIndexQueries.fetchActivityIndexGroupingsOn(time, threshold, userIds, serverUUIDs))); } return graphJSONCreator.createActivityGraphJSON(activityData); } private PlayerListDto getPlayersTableData(Set userIds, List serverUUIDs, long after, long before) { Database database = dbSystem.getDatabase(); return new PlayersTableJSONCreator( database.query(new QueryTablePlayersQuery(userIds, serverUUIDs, after, before, config.get(TimeSettings.ACTIVE_PLAY_THRESHOLD))), database.query(new ExtensionQueryResultTableDataQuery(serverInfo.getServerUUID(), userIds)), config.get(DisplaySettings.OPEN_PLAYER_LINKS_IN_NEW_TAB), formatters, locale ).toPlayerList(); } }