Skip to content
Merged
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
@@ -0,0 +1,33 @@
package com.dotcms.rest.api.v1.maintenance;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.immutables.value.Value;

/**
* Immutable view representing the result of dropping old asset versions.
*
* @author hassandotcms
*/
@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*")
@Value.Immutable
@JsonSerialize(as = DropOldVersionsResultView.class)
@JsonDeserialize(as = DropOldVersionsResultView.class)
@Schema(description = "Result of dropping old asset versions older than a specified date")
public interface AbstractDropOldVersionsResultView {

@Schema(
description = "Number of asset versions deleted",
example = "1523",
requiredMode = Schema.RequiredMode.REQUIRED
)
int deletedCount();

@Schema(
description = "Whether the operation completed successfully",
example = "true",
requiredMode = Schema.RequiredMode.REQUIRED
)
boolean success();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.dotcms.rest.api.v1.maintenance;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.immutables.value.Value;

/**
* Immutable view representing the result of a database-wide search and replace operation.
*
* @author hassandotcms
*/
@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*")
@Value.Immutable
@JsonSerialize(as = SearchAndReplaceResultView.class)
@JsonDeserialize(as = SearchAndReplaceResultView.class)
@Schema(description = "Result of a database-wide search and replace operation")
public interface AbstractSearchAndReplaceResultView {

@Schema(
description = "Whether the operation completed without errors",
example = "true",
requiredMode = Schema.RequiredMode.REQUIRED
)
boolean success();

@Schema(
description = "Whether any table updates encountered errors. "
+ "When true, some tables may not have been updated.",
example = "false",
requiredMode = Schema.RequiredMode.REQUIRED
)
boolean hasErrors();
}
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.ResponseEntityStringView;
import com.dotcms.rest.ResponseEntityView;
import com.dotcms.rest.WebResource;
import com.dotcms.rest.annotation.NoCache;
Expand All @@ -19,27 +20,37 @@
import com.dotmarketing.business.Role;
import com.dotmarketing.exception.DoesNotExistException;
import com.dotmarketing.exception.DotRuntimeException;
import com.dotmarketing.portlets.cmsmaintenance.factories.CMSMaintenanceFactory;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.DateUtil;
import com.dotmarketing.util.FileUtil;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.MaintenanceUtil;
import com.dotmarketing.util.SecurityLogger;
import com.dotmarketing.util.StringUtils;
import com.dotmarketing.util.UtilMethods;
import com.dotmarketing.util.starter.ExportStarterUtil;
import com.liferay.portal.model.Portlet;
import com.liferay.portal.model.User;
import io.vavr.Lazy;
import io.vavr.control.Try;
import org.apache.commons.io.IOUtils;
import org.glassfish.jersey.server.JSONP;
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.glassfish.jersey.server.JSONP;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
Expand Down Expand Up @@ -500,6 +511,213 @@ private Response downloadStarter(final HttpServletRequest request, final HttpSer
return this.buildFileResponse(response, stream, zipName);
}

// -------------------------------------------------------------------------
// Maintenance Tools endpoints
// -------------------------------------------------------------------------

/**
* Performs a database-wide find/replace across text content in working and live versions of
* contentlets, containers, templates, fields, and links. This is a dangerous, irreversible
* operation that should only be used by CMS Administrators.
*
* @param request The current {@link HttpServletRequest}
* @param response The current {@link HttpServletResponse}
* @param form The search and replace parameters
* @return The operation result indicating success and whether errors occurred
*/
@Operation(
summary = "Database-wide search and replace",
description = "Performs a find/replace across text content in contentlets, containers, "
+ "templates, fields, and links. Only affects working/live versions. "
+ "This is a dangerous, irreversible operation. "
+ "Returns 200 with hasErrors=true if some tables failed — check the response body."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Search and replace completed",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntitySearchAndReplaceResultView.class))),
@ApiResponse(responseCode = "400",
description = "Bad request - searchString is empty or missing",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json"))
})
@POST
@Path("/_searchAndReplace")
@NoCache
@Consumes(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON})
public final ResponseEntitySearchAndReplaceResultView searchAndReplace(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Search and replace parameters",
required = true,
content = @Content(schema = @Schema(implementation = SearchAndReplaceForm.class))
)
final SearchAndReplaceForm form) {

final User user = assertBackendUser(request, response).getUser();

if (form == null) {
throw new BadRequestException("Request body is required");
}

SecurityLogger.logInfo(this.getClass(),
String.format("User '%s' executing search and replace from ip: %s",
user.getUserId(), request.getRemoteAddr()));

Logger.info(this, String.format("User '%s' starting database search and replace",
user.getUserId()));

final boolean hasErrors = MaintenanceUtil.DBSearchAndReplace(
form.getSearchString(), form.getReplaceString());

MaintenanceUtil.flushCache();

return new ResponseEntitySearchAndReplaceResultView(
SearchAndReplaceResultView.builder()
.success(!hasErrors)
.hasErrors(hasErrors)
.build());
}

/**
* Deletes all versions of versionable objects older than the specified date. Affects
* contentlets, containers, templates, links, and workflow history. Iterates in 30-day
* chunks and flushes all caches when done. Can take minutes on large datasets.
*
* @param request The current {@link HttpServletRequest}
* @param response The current {@link HttpServletResponse}
* @param dateStr Date in yyyy-MM-dd ISO format. All versions older than this date are deleted.
* @return The operation result with the count of deleted versions
*/
@Operation(
summary = "Drop old asset versions",
description = "Deletes all versions of versionable objects (contentlets, containers, "
+ "templates, links, workflow history) older than the specified date. "
+ "Can take minutes on large datasets."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Old versions deleted",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntityDropOldVersionsResultView.class))),
@ApiResponse(responseCode = "400",
description = "Bad request - missing or invalid date format",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json"))
})
@DELETE
@Path("/_oldVersions")
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public final ResponseEntityDropOldVersionsResultView dropOldVersions(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response,
@Parameter(description = "Cutoff date in yyyy-MM-dd format. Versions older than this are deleted.",
required = true, example = "2025-06-15")
@QueryParam("date") final String dateStr) {

final User user = assertBackendUser(request, response).getUser();

if (!UtilMethods.isSet(dateStr)) {
throw new BadRequestException("date query parameter is required (format: yyyy-MM-dd)");
}

final Date assetsOlderThan;
try {
final java.time.LocalDate localDate = java.time.LocalDate.parse(dateStr);
assetsOlderThan = Date.from(
localDate.atStartOfDay(java.time.ZoneOffset.UTC).toInstant());
} catch (final java.time.format.DateTimeParseException e) {
throw new BadRequestException(
"Invalid date format. Expected yyyy-MM-dd, got: " + dateStr);
}

SecurityLogger.logInfo(this.getClass(),
String.format("User '%s' dropping old asset versions before %s from ip: %s",
user.getUserId(), dateStr, request.getRemoteAddr()));

Logger.info(this, String.format("User '%s' dropping asset versions older than %s",
user.getUserId(), dateStr));

final int deleted = CMSMaintenanceFactory.deleteOldAssetVersions(assetsOlderThan);

if (deleted < 0) {
throw new DotRuntimeException(
"Failed to delete old asset versions before " + dateStr
+ " — check server logs for details");
}

return new ResponseEntityDropOldVersionsResultView(
DropOldVersionsResultView.builder()
.deletedCount(deleted)
.success(true)
.build());
}

/**
* Deletes all records from the pushed assets tracking table. Clears push publishing history,
* making all assets appear as never pushed to any endpoint. Used when resetting push
* publishing state.
*
* @param request The current {@link HttpServletRequest}
* @param response The current {@link HttpServletResponse}
* @return A success message
*/
@Operation(
summary = "Delete all pushed assets records",
description = "Deletes ALL records from the pushed assets tracking table. "
+ "Clears push publishing history, making all assets appear as "
+ "\"never pushed\" to all endpoints."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Pushed assets deleted",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntityStringView.class))),
@ApiResponse(responseCode = "401",
description = "Unauthorized - authentication required",
content = @Content(mediaType = "application/json")),
@ApiResponse(responseCode = "403",
description = "Forbidden - CMS Administrator role required",
content = @Content(mediaType = "application/json"))
})
@DELETE
@Path("/_pushedAssets")
@NoCache
@Produces({MediaType.APPLICATION_JSON})
public final ResponseEntityStringView deletePushedAssets(
@Parameter(hidden = true) @Context final HttpServletRequest request,
@Parameter(hidden = true) @Context final HttpServletResponse response) {

final User user = assertBackendUser(request, response).getUser();

SecurityLogger.logInfo(this.getClass(),
String.format("User '%s' deleting all pushed assets from ip: %s",
user.getUserId(), request.getRemoteAddr()));

Logger.info(this, String.format("User '%s' deleting all pushed assets records",
user.getUserId()));

Try.run(() -> APILocator.getPushedAssetsAPI().deleteAllPushedAssets())
.getOrElseThrow(e -> new DotRuntimeException(
"Failed to delete pushed assets: " + e.getMessage(), e));

return new ResponseEntityStringView("success");
}

/**
* Verifies that calling user is a backend user required to access the Maintenance portlet.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dotcms.rest.api.v1.maintenance;

import com.dotcms.rest.ResponseEntityView;

/**
* Response wrapper for the drop old versions endpoint.
*
* @author hassandotcms
*/
public class ResponseEntityDropOldVersionsResultView extends ResponseEntityView<DropOldVersionsResultView> {

public ResponseEntityDropOldVersionsResultView(final DropOldVersionsResultView entity) {
super(entity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dotcms.rest.api.v1.maintenance;

import com.dotcms.rest.ResponseEntityView;

/**
* Response wrapper for the search and replace endpoint.
*
* @author hassandotcms
*/
public class ResponseEntitySearchAndReplaceResultView extends ResponseEntityView<SearchAndReplaceResultView> {

public ResponseEntitySearchAndReplaceResultView(final SearchAndReplaceResultView entity) {
super(entity);
}
}
Loading
Loading