Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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.
* <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} (default {@code .*\.log$|.*\.out$}, matching the regex used
* by {@link #getLogs}).
* <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
* the {@code GET /api/v1/maintenance/_downloadLog/{fileName}} download endpoints.
* <p>
* <strong>Auth note:</strong> 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<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);
}

@GET
@Path("/{fileName:.+}/_tail")
@JSONP
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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -137,4 +165,83 @@ void validateKeepAliveEvent(final String name, final Map<String,?> 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
*
* <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("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<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);
}

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;
}

}
Loading