Skip to content
35 changes: 18 additions & 17 deletions dotCMS/src/main/java/com/dotcms/browser/BrowserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -250,31 +250,32 @@ public default Map<String, Object> getFolderContent(final User user, final Strin


/**
* Retrieves a paginated collection of contentlets that reside under the specified parent
* Retrieves a paginated collection of the assets that reside under the specified parent
* ({@code browserQuery.directParent}).
* <p>
* Key differences compared to other retrieval methods:
* Three sources may contribute to a page — folders, menu links and contentlets — each gated
* by its own flag ({@code showFolders}, {@code showLinks}, {@code showContent}) and each
* paged by its own independent cursor:
* <ul>
* <li>This method applies pagination at the database level using
* {@code browserQuery.offset} and {@code browserQuery.maxResults}
* to fetch only the requested slice of contentlets.</li>
* <li>Folders are <b>not</b> paginated by this method. They are returned in full
* when {@code browserQuery.showFolders} is enabled.</li>
* <li>Other retrieval methods may combine multiple asset types (contentlets, folders, links)
* into a single list and then apply pagination at the aggregate level.
* In contrast, this method paginates only contentlets from the database.</li>
* <li>Folders and links are read in full from the database and sliced in memory by
* {@code folderCursor} / {@code linkCursor}. Both are strictly the direct children of
* the parent.</li>
* <li>Contentlets are paged at the database level from {@code contentCursor}, and may be
* gathered recursively when {@code skipFolder} is enabled.</li>
* <li>The three sources consume {@code maxResults} in that order, so a page is filled with
* folders first, then links, then contentlets.</li>
* </ul>
* <p>
* When implementing pagination:
* <ul>
* <li>Enable {@code browserQuery.showFolders} to render folders before paginated contentlets.</li>
* <li>Pagination will then continue over contentlets only.</li>
* </ul>
* When implementing pagination, feed each {@code next*Cursor} from the response back as the
* matching {@code *Cursor} on the following request and keep {@code offset} at 0. Once a
* source reports {@code hasMore* == false} its flag can be switched off to skip that query
* entirely. See {@link com.dotcms.browser.BrowserAPIImpl.PaginatedContents} for the full
* contract.
*
* @param browserQuery the query parameters defining parent, pagination, and flags for which
* elements (content, folders, links) to include
* @return a {@code Map<String, Object>} containing the retrieved contentlets and, if requested,
* quested, folders and/or links
* @return a {@link com.dotcms.browser.BrowserAPIImpl.PaginatedContents} holding this page's
* merged, sorted list plus the per-source counts and cursors
* @throws DotSecurityException if the user does not have permission to access the parent or
* contents
* @throws DotDataException if a data retrieval error occurs at the database level
Expand Down
244 changes: 227 additions & 17 deletions dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java

Large diffs are not rendered by default.

41 changes: 38 additions & 3 deletions dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class BrowserQuery {
final boolean respectFrontEndRoles;
final int contentCursor;
final int folderCursor;
final int linkCursor;
final User user;
final String filter;
final String fileName;
Expand Down Expand Up @@ -116,6 +117,7 @@ public List<FieldSearchCriteria> getFieldCriteria() {
public String toString() {
return "BrowserQuery {user:" + user + ", respectFronEndRoles:" + respectFrontEndRoles +
", contentCursor=" + contentCursor + ", folderCursor=" + folderCursor +
", linkCursor=" + linkCursor +
" ,site:" + site + ", folder:" + folder + ", filter:"
+ filter + ", sortBy:" + sortBy + ", forceSystemHost:" + forceSystemHost
+ ", skipFolder:" + skipFolder + ", ignoreSiteForFolders:" + ignoreSiteForFolders
Expand All @@ -126,6 +128,7 @@ public String toString() {
+ showLinks + ", showContent:" + showContent + ", showShorties:" + showShorties
+ ", luceneQuery:" + luceneQuery
+ ", languageIds:" + StringUtils.join(languageIds)
+ ", mimeTypes:" + StringUtils.join(mimeTypes)
+ ", baseTypes:" + StringUtils.join(baseTypes)
+ ", contentTypes:" + StringUtils.join(contentTypeIds)
+ ", fieldCriteria:" + StringUtils.join(fieldCriteria)
Expand All @@ -136,6 +139,7 @@ private BrowserQuery(final Builder builder) {
this.respectFrontEndRoles = builder.respectFrontEndRoles;
this.contentCursor = builder.contentCursor;
this.folderCursor = builder.folderCursor;
this.linkCursor = builder.linkCursor;
this.user = builder.user == null ? APILocator.systemUser() : builder.user;
final Tuple2<Host, Folder> siteAndFolder = getParents(builder.hostFolderId,this.user, builder.hostIdSystemFolder);
this.filter = builder.filter;
Expand Down Expand Up @@ -259,6 +263,7 @@ public static final class Builder {
private boolean respectFrontEndRoles = true;
private int contentCursor = 0;
private int folderCursor = 0;
private int linkCursor = 0;
private User user;
private boolean useElasticsearchFiltering = false;
private boolean filterFolderNames = false;
Expand Down Expand Up @@ -297,6 +302,7 @@ private Builder() {
private Builder(BrowserQuery browserQuery) {
this.contentCursor = browserQuery.contentCursor;
this.folderCursor = browserQuery.folderCursor;
this.linkCursor = browserQuery.linkCursor;
this.user = browserQuery.user;
this.hostFolderId = browserQuery.folder.isSystemFolder()
? browserQuery.site.getIdentifier()
Expand Down Expand Up @@ -337,13 +343,37 @@ public Builder respectFrontEndRoles(boolean respectFrontEndRoles) {
return this;
}

/**
* Cursors are designed to be echoed back verbatim from a previous response, so a client
* replaying a corrupted value is plausible. All three are clamped at zero: the folder and
* link slices index a list directly, so a negative cursor would reach
* {@code List.subList} and surface as a 500 rather than an empty page.
*
* @param contentCursor DB row to resume the content scan from; negatives are treated as 0
* @return this builder
*/
public Builder contentCursor(int contentCursor) {
this.contentCursor = contentCursor;
this.contentCursor = Math.max(0, contentCursor);
return this;
}

/**
* @param folderCursor index into the folder list to start from; negatives are treated as 0
* @return this builder
* @see #contentCursor(int)
*/
public Builder folderCursor(int folderCursor) {
this.folderCursor = folderCursor;
this.folderCursor = Math.max(0, folderCursor);
return this;
}

/**
* @param linkCursor index into the link list to start from; negatives are treated as 0
* @return this builder
* @see #contentCursor(int)
*/
public Builder linkCursor(int linkCursor) {
this.linkCursor = Math.max(0, linkCursor);
return this;
}

Expand Down Expand Up @@ -452,8 +482,13 @@ public Builder sortBy(@Nonnull String sortBy) {
return this;
}

/**
* @param offset row offset for the content query; negatives are treated as 0
* @return this builder
* @see #contentCursor(int)
*/
public Builder offset(@Nonnull int offset) {
this.offset = offset;
this.offset = Math.max(0, offset);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,54 @@ public interface AbstractDriveRequestForm {
@Value.Default
default boolean showFolders(){return true; }

/**
* Whether to include menu Links in results.
* <p>
* When false (default), menu Links are never returned, so existing callers are unaffected.
* When true, the Links directly under the resolved {@code assetPath} are returned alongside
* folders and contentlets, filtered by the requesting user's READ permission.
* </p>
* <p>
* Links are not a {@code BaseContentType}, so this flag is <b>orthogonal</b> to
* {@code baseTypes} rather than a value within it:
* </p>
* <ul>
* <li>{@code showLinks: true} with {@code baseTypes} omitted returns links <i>plus</i>
* content of every base type.</li>
* <li>{@code showLinks: true, baseTypes: ["HTMLPAGE"]} returns links plus pages.</li>
* <li>{@code showLinks: true, baseTypes: [], showFolders: false} returns links
* <i>only</i> — an empty {@code baseTypes} array disables the content query.</li>
* </ul>
* <p>
* Links are ignored when {@code mimeTypes}, {@code workflow} or {@code userSearchable}
* filters are present: a Link carries no file MIME type, no workflow state and no fields, so
* it could never satisfy any of them.
* Links are also always the <i>direct</i> children of the resolved path — they are never
* gathered recursively across subfolders, matching the legacy {@code /api/v1/browser}
* endpoint.
* </p>
* <p>
* Two ordering and filtering details are worth knowing:
* </p>
* <ul>
* <li><b>Links page in title order, independent of {@code sortBy}.</b> The page slice is
* taken in title-ascending order (identifier as tiebreaker) so that an index-based
* {@code linkCursor} stays stable; {@code sortBy} then reorders the items already
* selected. This matches the other two sources — folders slice in name-ascending order
* and contentlets in {@code mod_date} order, likewise regardless of {@code sortBy}.</li>
* <li><b>{@code filters.filterFolders} does not gate link titles.</b> That flag only
* controls whether folder <i>names</i> are narrowed by {@code filters.text}; link titles
* are always narrowed when {@code filters.text} is set, because a link is a selectable
* leaf rather than something to navigate into. There is no {@code filterLinks}
* equivalent.</li>
* </ul>
*
* @return true to include menu Links, false to exclude (default)
*/
@JsonProperty("showLinks")
@Value.Default
default boolean showLinks(){ return false; }

/**
* Content cursor: the DB row to start scanning content from (returned as
* {@code nextContentCursor} in the previous page response).
Expand Down Expand Up @@ -397,6 +445,23 @@ public interface AbstractDriveRequestForm {
@Value.Default
default int folderCursor() { return 0; }

/**
* Link cursor: the index into the link list to start from (returned as
* {@code nextLinkCursor} in the previous page response).
* <p>
* On the first page leave this at 0. On subsequent pages pass the
* {@code nextLinkCursor} value from the previous response. When the
* previous response returned {@code hasMoreLinks: false} you should
* also set {@code showLinks: false} to skip the link query entirely
* on pages where all links have already been shown.
* </p>
*
* @return link list index to start from, defaults to 0
*/
@JsonProperty("linkCursor")
@Value.Default
default int linkCursor() { return 0; }

/**
* Per-field value filters, keyed by field variable name.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U
final boolean showFiles = isShowFile(types);
final boolean showDotAssets = isShowDotAsset(types);
final boolean showFolders = requestForm.showFolders();
// A Link carries no file MIME type, and the paginated path does not run the mimeType
// filter, so links would otherwise leak through a mimeType-narrowed search unfiltered.
final boolean showLinks = requestForm.showLinks()
&& !UtilMethods.isSet(requestForm.mimeTypes());
if (null != requestForm.mimeTypes()){
builder.showMimeTypes(requestForm.mimeTypes());
}
Expand All @@ -129,6 +133,7 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U
.respectFrontEndRoles(false)
.contentCursor(requestForm.contentCursor())
.folderCursor(requestForm.folderCursor())
.linkCursor(requestForm.linkCursor())
//These are not always present
.withContentTypes(
contentTypes.stream().map(ContentType::id).collect(Collectors.toSet())
Expand All @@ -144,7 +149,7 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U
.showArchived(showArchived)
.showWorking(!live)
.showFolders(showFolders)
.showLinks(false)
.showLinks(showLinks)
Comment thread
nicobytes marked this conversation as resolved.
.showContent(!baseContentTypes.isEmpty())
.withLanguageIds(langIds)
.offset(requestForm.offset())
Expand Down Expand Up @@ -184,7 +189,9 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U
}
final List<FieldSearchCriteria> fieldCriteria =
fieldFilterResolver.parse(requestForm.userSearchable(), contentTypes.get(0));
builder.withFieldCriteria(fieldCriteria);
// Field filters are resolved against a single content type; links have no fields, so
// they could never satisfy one — drop them as folders already are elsewhere.
builder.withFieldCriteria(fieldCriteria).showLinks(false);
final boolean hasIndexCriteria = fieldCriteria.stream()
.anyMatch(criteria ->
criteria.getBucket() == FieldSearchCriteria.RoutingBucket.INDEX);
Expand All @@ -209,17 +216,23 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U
if (!workflowSchemeIds.isEmpty() || !workflowStepIds.isEmpty()) {
builder.withWorkflowSchemeIds(workflowSchemeIds)
.withWorkflowStepIds(workflowStepIds)
// Folders carry no workflow state — drop them when filtering by workflow.
.showFolders(false);
// Folders and links carry no workflow state — drop them when filtering
// by workflow.
.showFolders(false)
.showLinks(false);
}
}

Logger.debug(this, String.format(
"Content drive search - User: %s, Path: %s, Languages: %s, ContentTypes: %s, Filter: %s, MIME Types: %s",
user.getUserId(), assetPath, requestForm.language(), requestForm.contentTypes(), requestForm.filters(),
requestForm.mimeTypes()));
// Build once and log the query itself: flags such as showLinks and showFolders can be
// overridden by the workflow and userSearchable branches above, so logging the locals
// would misreport what actually ran. BrowserQuery.toString() carries the effective flags,
// all three cursors and the filters.
final BrowserQuery browserQuery = builder.build();

return browserAPI.getPaginatedContents(builder.build());
Logger.debug(this, () -> String.format("Content drive search - User: %s, Path: %s, %s",
user.getUserId(), assetPath, browserQuery));

return browserAPI.getPaginatedContents(browserQuery);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,23 @@ boolean move (final String folderId, final String newFolderId,
*/
List<Link> getLiveLinks(Folder parent,User user, boolean respectFrontEndPermissions) throws DotDataException, DotSecurityException;

/**
* Gets the 'live' Links directly under the given host, filtered by the user's READ permission.
* <p>
* The Host counterpart of {@link #getLiveLinks(Folder, User, boolean)}. Prefer either of them
* over {@code getLinks(parent, false, deleted, ...)} to ask for published links: the
* {@code working=false} form does not express "live", and the version-table predicate it emits
* does not correlate on the link, so it returns duplicates.
*
* @param host the host whose direct child links are wanted
* @param user the user the READ filter is applied for
* @param respectFrontEndPermissions whether front-end roles count towards READ
* @return the live links under the host, never null
* @throws DotDataException if the links cannot be read
* @throws DotSecurityException if the user cannot read the host
*/
List<Link> getLiveLinks(Host host, User user, boolean respectFrontEndPermissions) throws DotDataException, DotSecurityException;

/**
*
* @param parent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,24 @@ public List<Link> getLiveLinks(final Folder parent, final User user,
return permissionAPI.filterCollection(list, PermissionAPI.PERMISSION_READ, respectFrontEndPermissions, user);
}

@CloseDBIfOpened
@Override
public List<Link> getLiveLinks(final Host host, final User user,
final boolean respectFrontEndPermissions)
throws DotDataException, DotSecurityException {

if (!permissionAPI.doesUserHavePermission(host, PermissionAPI.PERMISSION_READ, user, respectFrontEndPermissions)) {
throw new DotSecurityException("User " + (user.getUserId() != null?user.getUserId():BLANK) + " does not have permission to read Host " + host.getHostname());
}

final ChildrenCondition cond = new ChildrenCondition();
cond.live=true;
cond.deleted=false;
final List list = folderFactory.getChildrenClass(host, Link.class, cond);

return permissionAPI.filterCollection(list, PermissionAPI.PERMISSION_READ, respectFrontEndPermissions, user);
}

@CloseDBIfOpened
public List<Contentlet> getWorkingContent(Folder parent, User user,boolean respectFrontEndPermissions) throws DotDataException, DotSecurityException {
if (!permissionAPI.doesUserHavePermission(parent, PermissionAPI.PERMISSION_READ, user,respectFrontEndPermissions)) {
Expand Down
2 changes: 2 additions & 0 deletions dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.dotcms.rest.api.v1.drive.ContentDriveFieldFilterTest;
import com.dotcms.rest.api.v1.drive.ContentDriveHelperContentletAPIComparisonTest;
import com.dotcms.rest.api.v1.drive.ContentDriveKeywordSearchTest;
import com.dotcms.rest.api.v1.drive.ContentDriveLinksTest;
import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowArchiveStepTest;
import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowFilterTest;
import com.dotcms.rest.api.v1.system.cache.CacheResourceIntegrationTest;
Expand Down Expand Up @@ -79,6 +80,7 @@
ContentDriveFieldFilterTest.class,
ContentDriveHelperContentletAPIComparisonTest.class,
ContentDriveKeywordSearchTest.class,
ContentDriveLinksTest.class,
ContentDriveWorkflowArchiveStepTest.class,
ContentDriveWorkflowFilterTest.class,
AppsAPIImplTest.class,
Expand Down
Loading
Loading