From 9fb102f7b62ae1643c03e3c8433c39061111e3c8 Mon Sep 17 00:00:00 2001 From: "AAVN\\pvquan" Date: Thu, 12 Dec 2024 15:05:13 +0700 Subject: [PATCH] MARP-1548 Suspicious installation counts --- marketplace-service/pom.xml | 6 +- .../market/constants/CommonConstants.java | 1 + .../ProductMarketplaceDataController.java | 16 ++-- .../com/axonivy/market/logging/Loggable.java | 11 +++ .../market/logging/LoggableAspect.java | 94 +++++++++++++++++++ .../com/axonivy/market/util/FileUtils.java | 27 ++++++ .../com/axonivy/market/util/LoggingUtils.java | 46 +++++++++ .../src/main/resources/application.properties | 3 +- 8 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java diff --git a/marketplace-service/pom.xml b/marketplace-service/pom.xml index 8bdb6e625..ec7ccc0ed 100644 --- a/marketplace-service/pom.xml +++ b/marketplace-service/pom.xml @@ -73,7 +73,11 @@ jaxb-api 2.3.1 - + + org.springframework.boot + spring-boot-starter-aop + + org.springframework.boot spring-boot-starter-validation diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java index 2966383f4..e80c2b57d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java @@ -6,6 +6,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CommonConstants { public static final String REQUESTED_BY = "X-Requested-By"; + public static final String USER_AGENT = "user-agent"; public static final String SLASH = "/"; public static final String DOT_SEPARATOR = "."; public static final String PLUS = "+"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java index 728251b33..cd4abe584 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java @@ -3,13 +3,12 @@ import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.logging.Loggable; import com.axonivy.market.model.Message; import com.axonivy.market.model.ProductCustomSortRequest; import com.axonivy.market.service.ProductMarketplaceDataService; import com.axonivy.market.util.AuthorizationUtils; 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.tags.Tag; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -36,7 +35,7 @@ public class ProductMarketplaceDataController { private final GitHubService gitHubService; private final ProductMarketplaceDataService productMarketplaceDataService; - + @PostMapping(CUSTOM_SORT) @Operation(hidden = true) public ResponseEntity createCustomSortProducts( @@ -51,15 +50,14 @@ public ResponseEntity createCustomSortProducts( return new ResponseEntity<>(message, HttpStatus.OK); } + @Loggable + @Operation(hidden = true) @PutMapping(INSTALLATION_COUNT_BY_ID) - @Operation(summary = "Update installation count of product", - description = "By default, increase installation count when click download product files by users") public ResponseEntity syncInstallationCount( - @PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "approval-decision-utils", - in = ParameterIn.PATH) String productId, - @RequestParam(name = DESIGNER_VERSION, required = false) @Parameter(in = ParameterIn.QUERY, - example = "v10.0.20") String designerVersion) { + @PathVariable(ID) String productId, + @RequestParam(name = DESIGNER_VERSION, required = false) String designerVersion) { int result = productMarketplaceDataService.updateInstallationCountForProduct(productId, designerVersion); return new ResponseEntity<>(result, HttpStatus.OK); } + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java b/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java new file mode 100644 index 000000000..3194ee816 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java @@ -0,0 +1,11 @@ +package com.axonivy.market.logging; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Loggable { +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java b/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java new file mode 100644 index 000000000..f799f59b9 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java @@ -0,0 +1,94 @@ +package com.axonivy.market.logging; + +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.exceptions.model.MissingHeaderException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.log4j.Log4j2; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +import static com.axonivy.market.util.FileUtils.createFile; +import static com.axonivy.market.util.FileUtils.writeToFile; +import static com.axonivy.market.util.LoggingUtils.*; + +@Log4j2 +@Aspect +@Component +public class LoggableAspect { + + @Value("${loggable.log-path:marketplace-service/logs}") + private String logFilePath; + + private static final String REQUESTED_BY = "marketplace-website"; + + @Before("@annotation(com.axonivy.market.logging.Loggable)") + public void logMethodCall(JoinPoint joinPoint) throws MissingHeaderException { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + Map headersMap = extractHeaders(request, signature, joinPoint); + saveLogToDailyFile(headersMap); + + // block execution if request isn't from Market or Ivy Designer + if (!Objects.equals(headersMap.get(CommonConstants.REQUESTED_BY), REQUESTED_BY)) { + throw new MissingHeaderException(); + } + } + } + + private Map extractHeaders(HttpServletRequest request, MethodSignature signature, + JoinPoint joinPoint) { + return Map.of( + "method", escapeXml(String.valueOf(signature.getMethod())), + "timestamp", escapeXml(getCurrentTimestamp()), + "user-agent", escapeXml(request.getHeader(CommonConstants.USER_AGENT)), + "arguments", escapeXml(getArgumentsString(signature.getParameterNames(), joinPoint.getArgs())), + "x-requested-by", escapeXml(request.getHeader(CommonConstants.REQUESTED_BY)) + ); + } + + // Use synchronized to prevent race condition + private synchronized void saveLogToDailyFile(Map headersMap) { + try { + File logFile = createFile(generateFileName()); + + StringBuilder content = new StringBuilder(); + if (logFile.exists()) { + content.append(new String(Files.readAllBytes(logFile.toPath()))); + } + if (content.isEmpty()) { + content.append("\n"); + } + int lastLogIndex = content.lastIndexOf(""); + if (lastLogIndex != -1) { + content.delete(lastLogIndex, content.length()); + } + content.append(buildLogEntry(headersMap)); + content.append("\n"); + + writeToFile(logFile, content.toString()); + } catch (IOException e) { + log.error("Error writing log to file: {}", e.getMessage()); + } + } + + private String generateFileName() { + return Path.of(logFilePath, "log-" + getCurrentDate() + ".xml").toString(); + } + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java new file mode 100644 index 000000000..76ef66756 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java @@ -0,0 +1,27 @@ +package com.axonivy.market.util; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +public class FileUtils { + + public static File createFile(String fileName) throws IOException { + File file = new File(fileName); + File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create directory: " + parentDir.getAbsolutePath()); + } + if (!file.exists() && !file.createNewFile()) { + throw new IOException("Failed to create file: " + file.getAbsolutePath()); + } + return file; + } + + public static void writeToFile(File file, String content) throws IOException { + try (FileWriter writer = new FileWriter(file, false)) { + writer.write(content); + } + } + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java new file mode 100644 index 000000000..4af0ac304 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java @@ -0,0 +1,46 @@ +package com.axonivy.market.util; + +import java.text.SimpleDateFormat; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class LoggingUtils { + + public static String getCurrentDate() { + return new SimpleDateFormat("yyyy-MM-dd").format(System.currentTimeMillis()); + } + + public static String getCurrentTimestamp() { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(System.currentTimeMillis()); + } + + public static String escapeXml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static String getArgumentsString(String[] paramNames, Object[] args) { + if (paramNames == null || paramNames.length == 0 || args == null || args.length == 0) { + return "No arguments"; + } + return IntStream.range(0, paramNames.length) + .mapToObj(i -> paramNames[i] + ": " + args[i]) + .collect(Collectors.joining(", ")); + } + + public static String buildLogEntry(Map headersMap) { + StringBuilder logEntry = new StringBuilder(); + logEntry.append(" \n"); + headersMap.forEach((key, value) -> logEntry.append(String.format(" <%s>%s\n", key, value, key))); + logEntry.append(" \n"); + return logEntry.toString(); + } + +} diff --git a/marketplace-service/src/main/resources/application.properties b/marketplace-service/src/main/resources/application.properties index 3e81b354f..a0ab0a261 100644 --- a/marketplace-service/src/main/resources/application.properties +++ b/marketplace-service/src/main/resources/application.properties @@ -16,4 +16,5 @@ market.github.oauth2-clientSecret=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} jwt.secret=${MARKET_JWT_SECRET_KEY} jwt.expiration=365 logging.level.org.springframework.data.mongodb.core.MongoTemplate=${MARKET_MONGO_LOG_LEVEL} -spring.jackson.serialization.indent_output=true \ No newline at end of file +spring.jackson.serialization.indent_output=true +loggable.log-path=${MARKET_LOG_PATH} \ No newline at end of file