From f4c76fac404df73960990bb7eef19acdd30b11c9 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 19 May 2026 03:05:39 +0500 Subject: [PATCH 1/2] feat(maintenance): add REST endpoint for log file listing #35208 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/v1/maintenance/_logFiles which returns a sorted, regex-filtered list of names from the configured TAIL_LOG_LOG_FOLDER. The names are used as the {fileName} path parameter for the existing tail and download endpoints, replacing the JSP scriptlet that populated the legacy log-files dropdown. - Reuses assertBackendUser() (requiredBackendUser + requireAdmin + Maintenance portlet) for auth, matching the rest of the resource. - Uses the TailLogResource default regex (.*\.log$|.*\.out$) rather than the JSP's ".*", so the listing matches what /api/v1/logs/{fileName}/_tail will actually accept. - Returns ResponseEntityListStringView so Swagger gets a concrete List schema. - Returns 200 with an empty list when the configured folder is missing, so the UI dropdown stays functional under misconfiguration. - Integration tests sandbox the scan via a temp directory and override TAIL_LOG_LOG_FOLDER, restoring it in finally — never touches the real dotsecure/logs path. --- .../v1/maintenance/MaintenanceResource.java | 80 +++++++++++++++++++ .../main/webapp/WEB-INF/openapi/openapi.yaml | 25 ++++++ .../MaintenanceResourceIntegrationTest.java | 74 +++++++++++++++++ 3 files changed, 179 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java index d64483d7785..16d8709b193 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java @@ -7,6 +7,7 @@ import com.dotcms.concurrent.DotConcurrentFactory; import com.google.common.annotations.VisibleForTesting; import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; @@ -78,6 +79,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import java.util.stream.Collectors; import com.dotcms.cdi.CDIUtils; @@ -197,6 +199,84 @@ public final Response shutdownCluster(@Context final HttpServletRequest request, return Response.ok(new ResponseEntityView<>("Shutdown")).build(); } + /** + * Lists the names of log files available in the configured log folder, sorted alphabetically. + *

+ * The folder is resolved from the {@code TAIL_LOG_LOG_FOLDER} property (default + * {@code ./dotsecure/logs/}) and files are filtered by the regex configured in + * {@code TAIL_LOG_FILE_REGEX}. The default matches the {@code TailLogResource} default + * ({@code .*\\.log$|.*\\.out$}) for consistency with the tail endpoint. + *

+ * The returned names are paths relative to the log folder and are intended to be used as + * the {@code fileName} path parameter for + * {@code GET /api/v1/logs/{fileName}/_tail} and + * {@code GET /api/v1/maintenance/_downloadLog/{fileName}}. + * + * @param request http request + * @param response http response + * @return a {@link ResponseEntityListStringView} wrapping the sorted list of log file names + */ + @Operation( + summary = "List available log files", + description = "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER), " + + "filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are used as " + + "the {fileName} path parameter for the tail and download endpoints." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Sorted list of log file names", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityListStringView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - CMS Administrator role and Maintenance portlet access required", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/_logFiles") + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public final ResponseEntityListStringView listLogFiles( + @Parameter(hidden = true) @Context final HttpServletRequest request, + @Parameter(hidden = true) @Context final HttpServletResponse response) { + + assertBackendUser(request, response); + + final String regex = Config.getStringProperty( + "TAIL_LOG_FILE_REGEX", ".*\\.log$|.*\\.out$"); + + String logPath = Config.getStringProperty( + "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); + logPath = FileUtil.getAbsolutlePath(logPath); + if (!logPath.endsWith(File.separator)) { + logPath = logPath + File.separator; + } + + final File logFolder = new File(logPath); + if (!logFolder.isDirectory()) { + Logger.debug(this, "Log folder does not exist or is not a directory: " + logPath); + return new ResponseEntityListStringView(new ArrayList<>()); + } + + final Pattern pattern = Pattern.compile(regex); + final String basePath = logPath; + + final List fileNames = com.liferay.util.FileUtil + .getFilesByPattern(logFolder, "*.*").stream() + .filter(f -> pattern.matcher(f.getName()).matches()) + .sorted(File::compareTo) + .map(f -> f.getPath().substring(basePath.length())) + .collect(Collectors.toList()); + + Logger.debug(this, () -> String.format( + "Listing %d log file(s) from '%s' matching regex '%s'", + fileNames.size(), basePath, regex)); + + return new ResponseEntityListStringView(fileNames); + } + /** * This method attempts to send resolved log file using an octet stream http response. * diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index cc876e8c66f..e1731548d71 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -11539,6 +11539,31 @@ paths: description: default response tags: - Maintenance + /v1/maintenance/_logFiles: + get: + description: "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER),\ + \ filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are\ + \ used as the {fileName} path parameter for the tail and download endpoints." + operationId: listLogFiles + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityListStringView" + description: Sorted list of log file names + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - CMS Administrator role and Maintenance portlet + access required + summary: List available log files + tags: + - Maintenance /v1/maintenance/_oldVersions: delete: description: "Deletes all versions of versionable objects (contentlets, containers,\ diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java index bbcf543c752..c18a349a10c 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java @@ -19,6 +19,7 @@ import com.dotcms.mock.request.MockHttpRequestIntegrationTest; import com.dotcms.mock.response.MockHttpResponse; import com.dotcms.rest.ResponseEntityJobStatusView; +import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.api.v1.job.JobStatusResponse; @@ -30,10 +31,12 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.Config; import com.dotmarketing.util.UUIDGenerator; import com.liferay.portal.model.User; import com.liferay.portal.util.WebKeys; import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.util.Arrays; import java.util.Collections; @@ -197,6 +200,70 @@ public void test_deletePushedAssets_asNonAdmin_throwsSecurity() { resource.deletePushedAssets(request, mockResponse); } + // ==================== GET /_logFiles ==================== + + /** + * Given scenario: redirect {@code TAIL_LOG_LOG_FOLDER} to an isolated temp directory and + * plant two matching {@code .log} files and one non-matching {@code .txt} + * file inside it + * Expected result: both {@code .log} files appear in the returned list (sorted alphabetically + * and stripped of the base path), and the {@code .txt} file is filtered out + * + *

The override is fully restored in {@code finally} so the test never pollutes the real + * dotsecure/logs directory or leaks state across tests. + */ + @Test + public void test_listLogFiles_filtersAndSortsAndStripsBasePath() throws Exception { + final String originalLogFolder = Config.getStringProperty( + "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); + + final File tempLogFolder = Files.createTempDirectory("maint-loglist-").toFile(); + tempLogFolder.deleteOnExit(); + + final File firstMatch = plantLogFile(tempLogFolder, "aa-itlist.log"); + final File secondMatch = plantLogFile(tempLogFolder, "zz-itlist.log"); + final File notMatching = plantLogFile(tempLogFolder, "aa-itlist.txt"); + + try { + Config.setProperty("TAIL_LOG_LOG_FOLDER", tempLogFolder.getAbsolutePath()); + + final ResponseEntityListStringView result = + resource.listLogFiles(createAdminRequest(), mockResponse); + + assertNotNull(result); + final List names = result.getEntity(); + assertNotNull(names); + + assertTrue("listing should contain the first matching .log file: " + names, + names.contains(firstMatch.getName())); + assertTrue("listing should contain the second matching .log file: " + names, + names.contains(secondMatch.getName())); + assertFalse("non-matching .txt file must be filtered out by TAIL_LOG_FILE_REGEX: " + names, + names.contains(notMatching.getName())); + + // base path is stripped — no absolute path leaks through, no leading separator + final String basePath = tempLogFolder.getAbsolutePath(); + for (final String name : names) { + assertFalse("listed name must not contain the absolute log folder path: " + name, + name.contains(basePath)); + assertFalse("listed name must not start with a separator: " + name, + name.startsWith(File.separator)); + } + + // sort order: aa- before zz- among our planted files + assertTrue("aa-* entry must sort before zz-* entry: " + names, + names.indexOf(firstMatch.getName()) < names.indexOf(secondMatch.getName())); + } finally { + Config.setProperty("TAIL_LOG_LOG_FOLDER", originalLogFolder); + FileUtils.deleteQuietly(tempLogFolder); + } + } + + @Test(expected = SecurityException.class) + public void test_listLogFiles_asNonAdmin_throwsSecurity() { + resource.listLogFiles(createRequestForUser(nonAdminUser), mockResponse); + } + // ==================== POST /assets/_fix ==================== /** @@ -575,6 +642,13 @@ private HttpServletRequest createAdminRequest() { return createRequestForUser(adminUser); } + private static File plantLogFile(final File folder, final String name) throws IOException { + final File file = new File(folder, name); + Files.writeString(file.toPath(), "test content"); + file.deleteOnExit(); + return file; + } + private static HttpServletRequest createRequestForUser(final User user) { final HttpServletRequest request = new MockAttributeRequest( new MockHttpRequestIntegrationTest("localhost", "/").request() From cab892f41a3dc21db4be9bc912a40142beef2083 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 19 May 2026 05:07:26 +0500 Subject: [PATCH 2/2] refactor(taillog): move log file listing endpoint to TailLogResource #35208 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: co-locate log file listing next to the existing tail endpoint so /v1/logs becomes a coherent collection resource. GET /v1/logs list available log files (moved) GET /v1/logs/{fileName}/_tail tail one log file (existing) The endpoint keeps its admin + Maintenance portlet auth — same security perimeter as the JSP it replaces. This is stricter than the existing _tail method on the same resource (back-end user only); the asymmetry is intentional and noted in javadoc. _downloadLog and _downloadClusterLog remain in MaintenanceResource, untouched by this change. --- .../v1/maintenance/MaintenanceResource.java | 80 ------------- .../rest/api/v1/taillog/TailLogResource.java | 101 +++++++++++++++++ .../main/webapp/WEB-INF/openapi/openapi.yaml | 50 ++++---- .../MaintenanceResourceIntegrationTest.java | 74 ------------ .../api/v1/taillog/TailLogResourceTest.java | 107 ++++++++++++++++++ 5 files changed, 233 insertions(+), 179 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java index 16d8709b193..d64483d7785 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java @@ -7,7 +7,6 @@ import com.dotcms.concurrent.DotConcurrentFactory; import com.google.common.annotations.VisibleForTesting; import com.dotcms.rest.InitDataObject; -import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; @@ -79,7 +78,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; import java.util.stream.Collectors; import com.dotcms.cdi.CDIUtils; @@ -199,84 +197,6 @@ public final Response shutdownCluster(@Context final HttpServletRequest request, return Response.ok(new ResponseEntityView<>("Shutdown")).build(); } - /** - * Lists the names of log files available in the configured log folder, sorted alphabetically. - *

- * The folder is resolved from the {@code TAIL_LOG_LOG_FOLDER} property (default - * {@code ./dotsecure/logs/}) and files are filtered by the regex configured in - * {@code TAIL_LOG_FILE_REGEX}. The default matches the {@code TailLogResource} default - * ({@code .*\\.log$|.*\\.out$}) for consistency with the tail endpoint. - *

- * The returned names are paths relative to the log folder and are intended to be used as - * the {@code fileName} path parameter for - * {@code GET /api/v1/logs/{fileName}/_tail} and - * {@code GET /api/v1/maintenance/_downloadLog/{fileName}}. - * - * @param request http request - * @param response http response - * @return a {@link ResponseEntityListStringView} wrapping the sorted list of log file names - */ - @Operation( - summary = "List available log files", - description = "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER), " - + "filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are used as " - + "the {fileName} path parameter for the tail and download endpoints." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", - description = "Sorted list of log file names", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityListStringView.class))), - @ApiResponse(responseCode = "401", - description = "Unauthorized - authentication required", - content = @Content(mediaType = "application/json")), - @ApiResponse(responseCode = "403", - description = "Forbidden - CMS Administrator role and Maintenance portlet access required", - content = @Content(mediaType = "application/json")) - }) - @GET - @Path("/_logFiles") - @NoCache - @Produces({MediaType.APPLICATION_JSON}) - public final ResponseEntityListStringView listLogFiles( - @Parameter(hidden = true) @Context final HttpServletRequest request, - @Parameter(hidden = true) @Context final HttpServletResponse response) { - - assertBackendUser(request, response); - - final String regex = Config.getStringProperty( - "TAIL_LOG_FILE_REGEX", ".*\\.log$|.*\\.out$"); - - String logPath = Config.getStringProperty( - "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); - logPath = FileUtil.getAbsolutlePath(logPath); - if (!logPath.endsWith(File.separator)) { - logPath = logPath + File.separator; - } - - final File logFolder = new File(logPath); - if (!logFolder.isDirectory()) { - Logger.debug(this, "Log folder does not exist or is not a directory: " + logPath); - return new ResponseEntityListStringView(new ArrayList<>()); - } - - final Pattern pattern = Pattern.compile(regex); - final String basePath = logPath; - - final List fileNames = com.liferay.util.FileUtil - .getFilesByPattern(logFolder, "*.*").stream() - .filter(f -> pattern.matcher(f.getName()).matches()) - .sorted(File::compareTo) - .map(f -> f.getPath().substring(basePath.length())) - .collect(Collectors.toList()); - - Logger.debug(this, () -> String.format( - "Listing %d log file(s) from '%s' matching regex '%s'", - fileNames.size(), basePath, regex)); - - return new ResponseEntityListStringView(fileNames); - } - /** * This method attempts to send resolved log file using an octet stream http response. * diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/taillog/TailLogResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/taillog/TailLogResource.java index 3771b24bc1d..7644ddb3ebf 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/taillog/TailLogResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/taillog/TailLogResource.java @@ -5,9 +5,11 @@ import com.dotcms.rest.EmptyHttpResponse; import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; import com.dotcms.util.CloseUtils; +import com.dotmarketing.business.Role; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.servlets.taillog.Tailer; import com.dotmarketing.util.Config; @@ -16,6 +18,13 @@ import com.dotmarketing.util.SecurityLogger; import com.dotmarketing.util.ThreadUtils; import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.Portlet; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import org.apache.commons.io.input.TailerListenerAdapter; import org.glassfish.jersey.media.sse.EventOutput; @@ -24,6 +33,7 @@ import org.glassfish.jersey.server.JSONP; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -34,9 +44,12 @@ import java.io.File; import java.io.IOException; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * This resource provides the endpoint used by the LogViewer functionality to display backend server logs @@ -51,6 +64,94 @@ public class TailLogResource { //This is in seconds public static final int KEEP_ALIVE_EVENT_INTERVAL = Config.getIntProperty("KEEP_ALIVE_EVENT_INTERVAL",20); + /** + * Lists the names of log files available in the configured log folder, sorted alphabetically. + *

+ * The folder is resolved from the {@code TAIL_LOG_LOG_FOLDER} property (default + * {@code ./dotsecure/logs/}) and files are filtered by the regex configured in + * {@code TAIL_LOG_FILE_REGEX} (default {@code .*\.log$|.*\.out$}, matching the regex used + * by {@link #getLogs}). + *

+ * The returned names are paths relative to the log folder and are intended to be used as + * the {@code fileName} path parameter for {@code GET /api/v1/logs/{fileName}/_tail} and + * the {@code GET /api/v1/maintenance/_downloadLog/{fileName}} download endpoints. + *

+ * Auth note: this listing requires admin + Maintenance portlet access, which + * is stricter than {@link #getLogs} (back-end user only). The stricter check preserves the + * security perimeter of the legacy JSP scriptlet in {@code tail_log.jsp} that this endpoint + * replaces. + * + * @param request http request + * @param response http response + * @return a {@link ResponseEntityListStringView} wrapping the sorted list of log file names + */ + @Operation( + summary = "List available log files", + description = "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER), " + + "filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are used as " + + "the {fileName} path parameter for the tail and download endpoints." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Sorted list of log file names", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityListStringView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - CMS Administrator role and Maintenance portlet access required", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/") + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public final ResponseEntityListStringView listLogFiles( + @Parameter(hidden = true) @Context final HttpServletRequest request, + @Parameter(hidden = true) @Context final HttpServletResponse response) { + + new WebResource.InitBuilder(new WebResource()) + .requiredBackendUser(true) + .requireAdmin(true) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .requiredPortlet(Portlet.MAINTENANCE) + .init(); + + final String regex = Config.getStringProperty( + "TAIL_LOG_FILE_REGEX", ".*\\.log$|.*\\.out$"); + + String logPath = Config.getStringProperty( + "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); + logPath = FileUtil.getAbsolutlePath(logPath); + if (!logPath.endsWith(File.separator)) { + logPath = logPath + File.separator; + } + + final File logFolder = new File(logPath); + if (!logFolder.isDirectory()) { + Logger.debug(this, "Log folder does not exist or is not a directory: " + logPath); + return new ResponseEntityListStringView(new ArrayList<>()); + } + + final Pattern pattern = Pattern.compile(regex); + final String basePath = logPath; + + final List fileNames = com.liferay.util.FileUtil + .getFilesByPattern(logFolder, "*.*").stream() + .filter(f -> pattern.matcher(f.getName()).matches()) + .sorted(File::compareTo) + .map(f -> f.getPath().substring(basePath.length())) + .collect(Collectors.toList()); + + Logger.debug(this, () -> String.format( + "Listing %d log file(s) from '%s' matching regex '%s'", + fileNames.size(), basePath, regex)); + + return new ResponseEntityListStringView(fileNames); + } + @GET @Path("/{fileName:.+}/_tail") @JSONP diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index e1731548d71..9aba3c0f44b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -11375,6 +11375,31 @@ paths: summary: Logout user tags: - Authentication + /v1/logs: + get: + description: "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER),\ + \ filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are\ + \ used as the {fileName} path parameter for the tail and download endpoints." + operationId: listLogFiles + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityListStringView" + description: Sorted list of log file names + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - CMS Administrator role and Maintenance portlet + access required + summary: List available log files + tags: + - TailLog /v1/logs/{fileName}/_tail: get: operationId: getLogs @@ -11539,31 +11564,6 @@ paths: description: default response tags: - Maintenance - /v1/maintenance/_logFiles: - get: - description: "Returns log file names from the configured log folder (TAIL_LOG_LOG_FOLDER),\ - \ filtered by TAIL_LOG_FILE_REGEX and sorted alphabetically. The names are\ - \ used as the {fileName} path parameter for the tail and download endpoints." - operationId: listLogFiles - responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/ResponseEntityListStringView" - description: Sorted list of log file names - "401": - content: - application/json: {} - description: Unauthorized - authentication required - "403": - content: - application/json: {} - description: Forbidden - CMS Administrator role and Maintenance portlet - access required - summary: List available log files - tags: - - Maintenance /v1/maintenance/_oldVersions: delete: description: "Deletes all versions of versionable objects (contentlets, containers,\ diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java index c18a349a10c..bbcf543c752 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java @@ -19,7 +19,6 @@ import com.dotcms.mock.request.MockHttpRequestIntegrationTest; import com.dotcms.mock.response.MockHttpResponse; import com.dotcms.rest.ResponseEntityJobStatusView; -import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.api.v1.job.JobStatusResponse; @@ -31,12 +30,10 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.portlets.contentlet.model.Contentlet; -import com.dotmarketing.util.Config; import com.dotmarketing.util.UUIDGenerator; import com.liferay.portal.model.User; import com.liferay.portal.util.WebKeys; import java.io.File; -import java.io.IOException; import java.nio.file.Files; import java.util.Arrays; import java.util.Collections; @@ -200,70 +197,6 @@ public void test_deletePushedAssets_asNonAdmin_throwsSecurity() { resource.deletePushedAssets(request, mockResponse); } - // ==================== GET /_logFiles ==================== - - /** - * Given scenario: redirect {@code TAIL_LOG_LOG_FOLDER} to an isolated temp directory and - * plant two matching {@code .log} files and one non-matching {@code .txt} - * file inside it - * Expected result: both {@code .log} files appear in the returned list (sorted alphabetically - * and stripped of the base path), and the {@code .txt} file is filtered out - * - *

The override is fully restored in {@code finally} so the test never pollutes the real - * dotsecure/logs directory or leaks state across tests. - */ - @Test - public void test_listLogFiles_filtersAndSortsAndStripsBasePath() throws Exception { - final String originalLogFolder = Config.getStringProperty( - "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); - - final File tempLogFolder = Files.createTempDirectory("maint-loglist-").toFile(); - tempLogFolder.deleteOnExit(); - - final File firstMatch = plantLogFile(tempLogFolder, "aa-itlist.log"); - final File secondMatch = plantLogFile(tempLogFolder, "zz-itlist.log"); - final File notMatching = plantLogFile(tempLogFolder, "aa-itlist.txt"); - - try { - Config.setProperty("TAIL_LOG_LOG_FOLDER", tempLogFolder.getAbsolutePath()); - - final ResponseEntityListStringView result = - resource.listLogFiles(createAdminRequest(), mockResponse); - - assertNotNull(result); - final List names = result.getEntity(); - assertNotNull(names); - - assertTrue("listing should contain the first matching .log file: " + names, - names.contains(firstMatch.getName())); - assertTrue("listing should contain the second matching .log file: " + names, - names.contains(secondMatch.getName())); - assertFalse("non-matching .txt file must be filtered out by TAIL_LOG_FILE_REGEX: " + names, - names.contains(notMatching.getName())); - - // base path is stripped — no absolute path leaks through, no leading separator - final String basePath = tempLogFolder.getAbsolutePath(); - for (final String name : names) { - assertFalse("listed name must not contain the absolute log folder path: " + name, - name.contains(basePath)); - assertFalse("listed name must not start with a separator: " + name, - name.startsWith(File.separator)); - } - - // sort order: aa- before zz- among our planted files - assertTrue("aa-* entry must sort before zz-* entry: " + names, - names.indexOf(firstMatch.getName()) < names.indexOf(secondMatch.getName())); - } finally { - Config.setProperty("TAIL_LOG_LOG_FOLDER", originalLogFolder); - FileUtils.deleteQuietly(tempLogFolder); - } - } - - @Test(expected = SecurityException.class) - public void test_listLogFiles_asNonAdmin_throwsSecurity() { - resource.listLogFiles(createRequestForUser(nonAdminUser), mockResponse); - } - // ==================== POST /assets/_fix ==================== /** @@ -642,13 +575,6 @@ private HttpServletRequest createAdminRequest() { return createRequestForUser(adminUser); } - private static File plantLogFile(final File folder, final String name) throws IOException { - final File file = new File(folder, name); - Files.writeString(file.toPath(), "test content"); - file.deleteOnExit(); - return file; - } - private static HttpServletRequest createRequestForUser(final User user) { final HttpServletRequest request = new MockAttributeRequest( new MockHttpRequestIntegrationTest("localhost", "/").request() diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/taillog/TailLogResourceTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/taillog/TailLogResourceTest.java index a462dd5799d..134b801dafc 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/taillog/TailLogResourceTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/taillog/TailLogResourceTest.java @@ -2,13 +2,25 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.dotcms.datagen.TestUserUtils; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.mock.request.MockAttributeRequest; +import com.dotcms.mock.request.MockHttpRequestIntegrationTest; +import com.dotcms.mock.response.MockHttpResponse; +import com.dotcms.rest.ResponseEntityListStringView; import com.dotcms.rest.api.v1.taillog.TailLogResource.MyTailerListener; import com.dotcms.rest.api.v1.taillog.TailLogResource.MyTailerThread; +import com.dotcms.rest.exception.SecurityException; import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import com.liferay.portal.util.WebKeys; import io.vavr.Tuple; import io.vavr.Tuple2; import java.io.BufferedWriter; @@ -25,6 +37,9 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.io.FileUtils; import org.glassfish.jersey.media.sse.EventOutput; import org.glassfish.jersey.media.sse.OutboundEvent; import org.junit.Assert; @@ -36,9 +51,22 @@ public class TailLogResourceTest { static final String TAILING = "Tailing info from file"; static final String WRITTEN = "New Line written With Number"; + private static TailLogResource resource; + private static HttpServletResponse mockResponse; + private static User adminUser; + private static User nonAdminUser; + @BeforeClass public static void prepare() throws Exception { IntegrationTestInitService.getInstance().init(); + + resource = new TailLogResource(); + mockResponse = new MockHttpResponse().response(); + adminUser = TestUserUtils.getAdminUser(); + + nonAdminUser = new UserDataGen().nextPersisted(); + APILocator.getRoleAPI().addRoleToUser( + APILocator.getRoleAPI().loadBackEndUserRole(), nonAdminUser); } synchronized void writeText(final Writer out, final int index) throws IOException { @@ -137,4 +165,83 @@ void validateKeepAliveEvent(final String name, final Map data){ assertTrue(bool); } + // ==================== GET /v1/logs (listLogFiles) ==================== + + /** + * Given scenario: redirect {@code TAIL_LOG_LOG_FOLDER} to an isolated temp directory and + * plant two matching {@code .log} files and one non-matching {@code .txt} + * file inside it + * Expected result: both {@code .log} files appear in the returned list (sorted alphabetically + * and stripped of the base path), and the {@code .txt} file is filtered out + * + *

The override is fully restored in {@code finally} so the test never pollutes the real + * dotsecure/logs directory or leaks state across tests. + */ + @Test + public void test_listLogFiles_filtersAndSortsAndStripsBasePath() throws Exception { + final String originalLogFolder = Config.getStringProperty( + "TAIL_LOG_LOG_FOLDER", "./dotsecure/logs/"); + + final File tempLogFolder = Files.createTempDirectory("taillog-list-").toFile(); + tempLogFolder.deleteOnExit(); + + final File firstMatch = plantLogFile(tempLogFolder, "aa-itlist.log"); + final File secondMatch = plantLogFile(tempLogFolder, "zz-itlist.log"); + final File notMatching = plantLogFile(tempLogFolder, "aa-itlist.txt"); + + try { + Config.setProperty("TAIL_LOG_LOG_FOLDER", tempLogFolder.getAbsolutePath()); + + final ResponseEntityListStringView result = + resource.listLogFiles(createRequestForUser(adminUser), mockResponse); + + assertNotNull(result); + final List names = result.getEntity(); + assertNotNull(names); + + assertTrue("listing should contain the first matching .log file: " + names, + names.contains(firstMatch.getName())); + assertTrue("listing should contain the second matching .log file: " + names, + names.contains(secondMatch.getName())); + assertFalse("non-matching .txt file must be filtered out by TAIL_LOG_FILE_REGEX: " + names, + names.contains(notMatching.getName())); + + // base path is stripped — no absolute path leaks through, no leading separator + final String basePath = tempLogFolder.getAbsolutePath(); + for (final String name : names) { + assertFalse("listed name must not contain the absolute log folder path: " + name, + name.contains(basePath)); + assertFalse("listed name must not start with a separator: " + name, + name.startsWith(File.separator)); + } + + // sort order: aa- before zz- among our planted files + assertTrue("aa-* entry must sort before zz-* entry: " + names, + names.indexOf(firstMatch.getName()) < names.indexOf(secondMatch.getName())); + } finally { + Config.setProperty("TAIL_LOG_LOG_FOLDER", originalLogFolder); + FileUtils.deleteQuietly(tempLogFolder); + } + } + + @Test(expected = SecurityException.class) + public void test_listLogFiles_asNonAdmin_throwsSecurity() { + resource.listLogFiles(createRequestForUser(nonAdminUser), mockResponse); + } + + private static HttpServletRequest createRequestForUser(final User user) { + final HttpServletRequest request = new MockAttributeRequest( + new MockHttpRequestIntegrationTest("localhost", "/").request() + ).request(); + request.setAttribute(WebKeys.USER, user); + return request; + } + + private static File plantLogFile(final File folder, final String name) throws IOException { + final File file = new File(folder, name); + Files.writeString(file.toPath(), "test content"); + file.deleteOnExit(); + return file; + } + }