Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* 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.
* <p>
* 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})
Comment thread
hassandotcms marked this conversation as resolved.
Outdated
public final ResponseEntityListStringView listLogFiles(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

assertBackendUser(request, response);

Comment thread
hassandotcms marked this conversation as resolved.
Outdated
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<String> 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.
*
Expand Down
25 changes: 25 additions & 0 deletions dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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,\
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
*
* <p>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<String> 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 ====================

/**
Expand Down Expand Up @@ -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()
Expand Down
Loading