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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import com.dotmarketing.util.UtilMethods; // UtilMethods.isSet(value)
UserAPI userAPI = APILocator.getUserAPI(); // Service access pattern
```

> **Batch permission filtering**: prefer `permissionAPI.filterCollection(Collection<P>, int, User, boolean)` over per-item `doesUserHavePermission` loops — one SQL round-trip vs N. See [Java Standards → Permission Checks](docs/backend/JAVA_STANDARDS.md#permission-checks--batch-vs-scalar).

## Critical Rules

- **Config/Logger only**: Never `System.out`, `System.getProperty`, or `System.getenv`
Expand Down
33 changes: 33 additions & 0 deletions docs/backend/JAVA_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,39 @@ LocalTransaction.wrapReturn(() -> {
});
```

### Permission Checks — Batch vs Scalar

**Always prefer the batch overload when filtering a collection.** The scalar `doesUserHavePermission(Permissionable, int, User, boolean)` runs one DB/cache lookup per item; on a list of N items that is N round-trips (the N+1 problem). The batch overload resolves the entire collection in a single SQL UNION query.

```java
// ❌ N+1 — one DB lookup per folder
for (Folder folder : folders) {
if (permissionAPI.doesUserHavePermission(folder, PermissionAPI.PERMISSION_READ, user, false)) {
visible.add(folder);
}
}

// ✅ Batch — one SQL round-trip for the whole collection
List<Folder> visible = permissionAPI.filterCollection(
folders, PermissionAPI.PERMISSION_READ, user, false);
```

**Signature** (`PermissionAPI`):
```java
// Batch overload — uses getPermittedIds() in PermissionBitFactoryImpl under the hood
<P extends Permissionable> List<P> filterCollection(
Collection<P> permissionables,
int permissionType,
User user,
boolean respectFrontendRoles) throws DotDataException, DotSecurityException;
```

**Notes:**
- Admin/system-user fast-path returns the full collection immediately (no SQL).
- `respectFrontendRoles=false` excludes anonymous and front-end roles from the role set.
- The existing `filterCollection(List, int, boolean, User)` overload is still available but does N+1 internally — prefer the batch overload for any new code that filters collections.
- Implementation: `PermissionBitAPIImpl` + `PermissionBitFactoryImpl.getPermittedIds()`.

### CDI Patterns (For New Components)
```java
@ApplicationScoped
Expand Down
151 changes: 144 additions & 7 deletions dotCMS/src/main/java/com/dotcms/rest/api/v1/folder/FolderResource.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.dotcms.rest.api.v1.folder;

import com.dotcms.exception.ExceptionUtil;
import com.dotcms.rest.ResponseEntityPaginatedDataView;
import com.dotcms.util.PaginationUtil;
import com.dotcms.util.PaginationUtilParams;
import com.dotcms.util.pagination.FolderSearchPaginator;
import com.dotcms.util.pagination.OrderDirection;
import com.google.common.annotations.VisibleForTesting;
import com.dotcms.rest.InitDataObject;
import com.dotcms.rest.ResponseEntityView;
Expand All @@ -11,6 +16,7 @@
import com.dotcms.rest.exception.mapper.ExceptionMapperUtil;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.business.PermissionAPI;
import com.dotmarketing.exception.DoesNotExistException;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.exception.DotSecurityException;
Expand Down Expand Up @@ -60,20 +66,34 @@
@Tag(name = "Folders", description = "Endpoints for managing folder structure and organization")
public class FolderResource implements Serializable {

static final String SITE_ID_PARAM = "siteId";
static final String PATH_PARAM = "path";
static final String RECURSIVE_PARAM = "recursive";

private final WebResource webResource;
private final FolderHelper folderHelper;
private final PaginationUtil folderSearchPaginationUtil;

public FolderResource() {
this(new WebResource(),
FolderHelper.getInstance());
FolderHelper.getInstance(),
new PaginationUtil(new FolderSearchPaginator()));
}

@VisibleForTesting
public FolderResource(final WebResource webResource,
final FolderHelper folderHelper) {
this(webResource, folderHelper, new PaginationUtil(new FolderSearchPaginator()));
}

@VisibleForTesting
public FolderResource(final WebResource webResource,
final FolderHelper folderHelper,
final PaginationUtil folderSearchPaginationUtil) {

this.webResource = webResource;
this.folderHelper = folderHelper;
this.folderSearchPaginationUtil = folderSearchPaginationUtil;
}

/**
Expand Down Expand Up @@ -317,6 +337,8 @@ public final Response loadFolderAndSubFoldersByPath(@Context final HttpServletRe
}

/**
* @deprecated Use {@link FolderResource#searchFolders} (GET /api/v1/folder/search) instead.
*
* This endpoint is to retrieve subfolders of a given path,
* will also filter these subfolders by the path sent. The subfolders returned will be the ones
* the user has permissions over.
Expand Down Expand Up @@ -365,16 +387,16 @@ public final Response loadFolderAndSubFoldersByPath(@Context final HttpServletRe
* @throws DotDataException
* @throws DotSecurityException
*/
@Deprecated(since = "Jun 19th, 26", forRemoval = true)
@Operation(
operationId = "findSubFoldersByPath",
summary = "Find subfolders by path",
description = "Retrieves subfolders of a given path, filtered by the path sent. The path format " +
"supports site-specific search (//siteName/path) or global search (/path). " +
"For example: '//default/folder1/' returns subfolder1, subfolder2 under folder1 in the 'default' site. " +
"'/folder1/s' returns all subfolders starting with 's' under folder1 across all sites."
summary = "Find subfolders by path (deprecated)",
description = "Retrieves subfolders of a given path, filtered by the path sent. " +
"This endpoint is deprecated — use GET /api/v1/folder/search instead.",
deprecated = true
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Subfolders retrieved successfully",
@ApiResponse(responseCode = "200", description = "Subfolders retrieved successfully (deprecated endpoint)",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ResponseEntityFolderSearchResultView.class))),
@ApiResponse(responseCode = "400", description = "Path property must be sent"),
Expand Down Expand Up @@ -492,4 +514,119 @@ public final Response findFolderById(@Context final HttpServletRequest httpServl
Response.ok(new ResponseEntityView(folder)).build(); // 200
}

/**
* Unified folder search. Searches folders within a site by optional name fil @param httpServletResponse The current instance of the {@link HttpServletResponse}.
* @param name optional case-insensitive partial match on folder name (min 3 chars if provided)
* @param path path scope; defaults to {@code /} (site root)
* @param recursive {@code true} = all descendants (default); {@code false} = direct children only
* @param siteId site identifier (required)
* @param page 1-based page number (default: 1)
* @param perPage results per page (default: 40)
* @return paginated list of matching {@link FolderSearchResultView} objects
*/
@GET
@Path("/search")
@JSONP
@NoCache
@Produces({MediaType.APPLICATION_JSON})
@Operation(operationId = "searchFolders",
summary = "Search folders",
description = "Returns folders within a site matching an optional name filter and/or " +
"path scope. Supports recursive depth control, standard pagination, and sorting. " +
"With no 'name' and default path '/' + recursive=true, all site folders are returned.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200",
description = "Paginated list of matching folders",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = ResponseEntityPaginatedDataView.class))),
@ApiResponse(responseCode = "400", description = "'siteId' is required; 'name' must be at least 3 characters if provided"),
@ApiResponse(responseCode = "401", description = "User is not authenticated"),
@ApiResponse(responseCode = "500", description = "Internal server error")
})
public final ResponseEntityPaginatedDataView searchFolders(
@Context final HttpServletRequest httpServletRequest,
@Context final HttpServletResponse httpServletResponse,
@Parameter(description = "Optional case-insensitive partial match on folder name (minimum 3 characters when provided)")
@QueryParam("name") final String name,
@Parameter(description = "Path scope for the search. Defaults to '/' (site root).")
@DefaultValue("/") @QueryParam(PATH_PARAM) final String path,
@Parameter(description = "false = direct children of 'path' only (default); true = search all descendants")
@DefaultValue("false") @QueryParam(RECURSIVE_PARAM) final boolean recursive,
@Parameter(description = "Site ID to scope the search (required)")
@QueryParam(SITE_ID_PARAM) final String siteId,
@Parameter(description = "Column to sort by.",
schema = @Schema(allowableValues = {"name", "mod_date"}, defaultValue = "name"))
@DefaultValue("name") @QueryParam(PaginationUtil.ORDER_BY) final String orderBy,
@Parameter(description = "Sort direction",
schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "ASC"))
@DefaultValue("ASC") @QueryParam(PaginationUtil.DIRECTION) final String direction,
@Parameter(description = "Page number (1-based, default 1)")
@DefaultValue("1") @QueryParam(PaginationUtil.PAGE) final int page,
@Parameter(description = "Number of results per page (default 40)")
@DefaultValue("40") @QueryParam(PaginationUtil.PER_PAGE) final int perPage) {

if (!UtilMethods.isSet(siteId)) {
throw new BadRequestException("'siteId' query parameter is required");
}
if (UtilMethods.isSet(name) && name.length() < 3) {
throw new BadRequestException("'name' must be at least 3 characters long");
}

final User user = new WebResource.InitBuilder(webResource)
.requestAndResponse(httpServletRequest, httpServletResponse)
.requiredBackendUser(true)
.requiredFrontendUser(false)
.rejectWhenNoUser(true)
.init().getUser();

validateSiteReadAccess(siteId, user);

final Map<String, Object> extraParams = Map.of(
SITE_ID_PARAM, siteId,
PATH_PARAM, path,
RECURSIVE_PARAM, recursive);

final OrderDirection orderDirection = switch (direction.toUpperCase()) {
case "DESC" -> OrderDirection.DESC;
default -> OrderDirection.ASC;
};

final PaginationUtilParams<?, ?> params = new PaginationUtilParams.Builder<>()
.withRequest(httpServletRequest)
.withResponse(httpServletResponse)
.withUser(user)
.withFilter(name) // name is the search filter — may be null
.withPage(page)
.withPerPage(perPage)
.withOrderBy(orderBy)
.withDirection(orderDirection)
.withExtraParams(extraParams)
.build();

return folderSearchPaginationUtil.getPageView(params);
}

/**
* Verifies that the given user has READ permission on the specified site.
* Throws {@link DoesNotExistException} if the site is not found,
* {@link ForbiddenException} if the user lacks READ access,
* and {@link BadRequestException} if the siteId is malformed.
*/
private void validateSiteReadAccess(final String siteId, final User user) {
try {
final Host site = APILocator.getHostAPI().find(siteId, user, false);
if (site == null || !UtilMethods.isSet(site.getIdentifier())) {
throw new DoesNotExistException("No site found with id: " + siteId);
}
if (!APILocator.getPermissionAPI()
.doesUserHavePermission(site, PermissionAPI.PERMISSION_READ, user, false)) {
throw new ForbiddenException("User does not have permission to access site: " + siteId);
}
} catch (final DotSecurityException e) {
throw new ForbiddenException(e);
} catch (final DotDataException e) {
throw new BadRequestException("Invalid siteId: " + siteId);
}
}

}
Original file line number Diff line number Diff line change
@@ -1,47 +1,16 @@
package com.dotcms.rest.api.v1.folder;

/**
* This class represents the REST View of a Folder. It's used by several Endpoints and related
* classes, such as the {@link FolderResource}, the
* {@link com.dotmarketing.portlets.workflows.actionlet.MoveContentActionlet}, and so on.
* REST view of a Folder search result. Used by {@link FolderResource},
* {@link com.dotmarketing.portlets.workflows.actionlet.MoveContentActionlet}, and related classes.
*
* @author Jonathan Sanchez
* @since Jul 19th, 2021
*/
public class FolderSearchResultView {

private final String id;
private final String inode;
private final String path;
private final String hostName;

private final boolean addChildrenAllowed;

public FolderSearchResultView(final String id, final String inode, final String path,
final String hostname, final boolean addChildrenAllowed) {
this.id = id;
this.inode = inode;
this.path = path;
this.hostName = hostname;
this.addChildrenAllowed = addChildrenAllowed;
}

public String getId() {
return id;
}

public String getInode() {
return inode;
}

public String getPath() {
return path;
}

public String getHostName() {
return hostName;
}

public boolean isAddChildrenAllowed(){ return addChildrenAllowed; }

}
public record FolderSearchResultView(
String id,
String inode,
String name,
String path,
boolean addChildrenAllowed
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.dotcms.util.pagination;

import com.dotcms.rest.api.v1.folder.FolderSearchResultView;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.portlets.folders.business.FolderAPI;
import com.dotmarketing.portlets.folders.business.FolderSearchParams;
import com.dotmarketing.util.PaginatedArrayList;
import com.dotmarketing.util.UtilMethods;
import com.google.common.annotations.VisibleForTesting;
import com.liferay.portal.model.User;
import java.util.Map;

/**
* {@link PaginatorOrdered} implementation for the unified folder search endpoint.
* Delegates to {@link FolderAPI#searchFolders} supporting optional name filtering,
* path scoping, and recursive depth control.
*
* <p>Extra params expected in the map (keyed by the query param names defined in
* the calling resource): {@code "siteId"}, {@code "path"}, {@code "recursive"}.
*/
public class FolderSearchPaginator implements PaginatorOrdered<FolderSearchResultView> {

private static final String DEFAULT_ORDER_BY_COLUMN = "folder.name";

private final FolderAPI folderAPI;

public FolderSearchPaginator() {
this(APILocator.getFolderAPI());
}

@VisibleForTesting
public FolderSearchPaginator(final FolderAPI folderAPI) {
this.folderAPI = folderAPI;
}

@Override
public PaginatedArrayList<FolderSearchResultView> getItems(final User user, final String filter,
final int limit, final int offset, final String orderBy,
final OrderDirection direction, final Map<String, Object> extraParams)
throws PaginationException {

final var ep = extraParams != null ? extraParams : Map.of();
final String siteId = (String) ep.get("siteId");
final String path = (String) ep.getOrDefault("path", "/");
final boolean recursive = Boolean.TRUE.equals(ep.get("recursive"));

final String orderByColumn = switch (orderBy) {
case "mod_date" -> "folder.mod_date";
case null, default -> DEFAULT_ORDER_BY_COLUMN;
};
final String orderDirection = direction == OrderDirection.DESC ? "DESC" : "ASC";

try {
final FolderSearchParams params = FolderSearchParams.builder()
.name(UtilMethods.isSet(filter) ? filter : null)
.path(path)
.recursive(recursive)
.siteId(siteId)
.user(user)
.limit(limit)
.offset(offset)
.orderBy(orderByColumn)
.orderDirection(orderDirection)
.build();
return folderAPI.searchFolders(params);
} catch (final Exception e) {
throw new PaginationException(e);
}
}
}
Loading
Loading