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 3771b24bc1d9..7644ddb3ebfb 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 cc876e8c66fe..9aba3c0f44b0 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 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 a462dd5799d9..134b801dafc6 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; + } + }