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
630 changes: 35 additions & 595 deletions services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (C) 2001-2026 Food and Agriculture Organization of the
* United Nations (FAO-UN), United Nations World Food Programme (WFP)
* and United Nations Environment Programme (UNEP)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
*
* Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2,
* Rome - Italy. email: geonetwork@osgeo.org
*/

package org.fao.geonet.api.es;

import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

@Component
public class EsResponseContentTypeValidator {
private static final Set<String> VALID_CONTENT_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"application/json", "text/plain"
)));

private static final String NOT_FOUND_MESSAGE = "Not Found";

public void validateContentType(HttpURLConnection connectionWithFinalHost, HttpServletResponse response, String contentType) throws IOException {
if (contentType == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Host url has been validated by proxy but content type given by remote host is null");
return;
}

// content type has to be valid
if (!isContentTypeValid(contentType)) {
String responseMessage = connectionWithFinalHost.getResponseMessage();
if (NOT_FOUND_MESSAGE.equalsIgnoreCase(responseMessage)) {
// content type was not valid because it was a not found page (text/html)
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Remote host not found");
} else {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"The content type of the remote host's response \"" + contentType
+ "\" is not allowed by the proxy rules");
}
}
}


/**
* Check if the content type is accepted by the proxy
*
* @return true: valid; false: not valid
*/
public boolean isContentTypeValid(final String contentType) {
if (contentType == null || contentType.trim().isEmpty()) {
return false;
}

// focus only on type, not on the text encoding
String type = contentType.split(";")[0].trim().toLowerCase();
return VALID_CONTENT_TYPES.contains(type);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2001-2026 Food and Agriculture Organization of the
* United Nations (FAO-UN), United Nations World Food Programme (WFP)
* and United Nations Environment Programme (UNEP)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
*
* Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2,
* Rome - Italy. email: geonetwork@osgeo.org
*/

package org.fao.geonet.api.es;

public enum EsSearchEndpoints {
SEARCH_ENDPOINT("_search"),
MULTISEARCH_ENDPOINT("_msearch");

private final String endPoint;

EsSearchEndpoints(final String endPoint) {
this.endPoint = endPoint;
}
@Override
public String toString() {
return endPoint;
}

}
59 changes: 59 additions & 0 deletions services/src/main/java/org/fao/geonet/api/es/ObjectNodeUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2001-2026 Food and Agriculture Organization of the
* United Nations (FAO-UN), United Nations World Food Programme (WFP)
* and United Nations Environment Programme (UNEP)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
*
* Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2,
* Rome - Italy. email: geonetwork@osgeo.org
*/

package org.fao.geonet.api.es;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

public class ObjectNodeUtils {
public final static String SOURCE_NODE = "_source";

private ObjectNodeUtils() {
// Don't allow to instantiate it
}

public static String getSourceString(ObjectNode node, String name) {
if (!node.has(SOURCE_NODE)) {
return null;
}
final JsonNode sub = node.get(SOURCE_NODE).get(name);
return sub != null ? sub.asText() : null;
}

public static Integer getSourceInteger(ObjectNode node, String name) {
if (!node.has(SOURCE_NODE)) {
return null;
}
final JsonNode sub = node.get(SOURCE_NODE).get(name);
return sub != null ? sub.asInt() : null;
}

public static ObjectNode getSourceNode(ObjectNode doc) {
if (!doc.has(ObjectNodeUtils.SOURCE_NODE)) {
return null;
}

return (ObjectNode) doc.get(SOURCE_NODE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* Copyright (C) 2001-2026 Food and Agriculture Organization of the
* United Nations (FAO-UN), United Nations World Food Programme (WFP)
* and United Nations Environment Programme (UNEP)
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
*
* Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2,
* Rome - Italy. email: geonetwork@osgeo.org
*/

package org.fao.geonet.api.es.processors.query;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jeeves.server.context.ServiceContext;
import org.fao.geonet.NodeInfo;
import org.fao.geonet.kernel.search.EsFilterBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Iterator;

@Component
public class EsQueryFilterBuilder {
private static final String QUERY = "query";
private static final String BOOL = "bool";
private static final String FILTER = "filter";
private static final String MUST = "must";
private static final String MATCH_ALL = "match_all";
private static final String FUNCTION_SCORE = "function_score";
private static final String GLOBAL = "global";
private static final String AGGS = "aggs";
private static final String AGGREGATIONS = "aggregations";

/**
* Privileges filter only allows
* * op0 (ie. view operation) contains one of the ids of your groups
*/
private static final String FILTER_TEMPLATE = " {\n" +
" \t\"query_string\": {\n" +
" \t\t\"query\": \"%s\"\n" +
" \t}\n" +
"}";


@Autowired
NodeInfo node;

public void addFilterToQuery(ServiceContext context,
ObjectMapper objectMapper,
JsonNode esQuery) throws Exception {

// Build filter node
boolean isSearchingForDraft = isSearchingForDraft(esQuery);
String esFilter = buildQueryFilter(context, "", isSearchingForDraft);
JsonNode nodeFilter = objectMapper.readTree(esFilter);

JsonNode queryNode = esQuery.get(QUERY);

// Replace any "global" aggregation with a "filter" aggregation scoped to
// the ACL filter.
// Must run after nodeFilter is built, before any branch exits early via return.
for (String aggsKey : new String[]{AGGS, AGGREGATIONS}) {
JsonNode aggsNode = esQuery.get(aggsKey);
if (aggsNode != null && aggsNode.isObject()) {
replaceGlobalAggregations((ObjectNode) aggsNode, nodeFilter);
}
}

// Defensive: if no "query", create a bool { must: match_all, filter: nodeFilter }
if (queryNode == null || queryNode.isNull()
|| (queryNode.isObject() && queryNode.isEmpty())) {
ObjectNode boolNode = objectMapper.createObjectNode();
// prefer must = match_all object (same shape as existing code)
ObjectNode matchAll = objectMapper.createObjectNode();
matchAll.putObject(MATCH_ALL);
boolNode.set(MUST, matchAll);
boolNode.set(FILTER, nodeFilter);
((ObjectNode) esQuery).set(QUERY, objectMapper.createObjectNode().set(BOOL, boolNode));
return;
}

// Try to find the boolean node where to insert the filter.
// Prefer function_score.query.bool if present because function_score
// needs the filter to be applied to the inner query used for scoring.
JsonNode functionScoreNode = queryNode.get(FUNCTION_SCORE);
if (functionScoreNode != null && functionScoreNode.isObject()) {
JsonNode innerQuery = functionScoreNode.get(QUERY);
if (innerQuery == null || innerQuery.isNull()) {
// create function_score.query.bool with only the filter
ObjectNode boolNode = objectMapper.createObjectNode();
boolNode.set(FILTER, nodeFilter);
((ObjectNode) functionScoreNode).set(QUERY, objectMapper.createObjectNode().set(BOOL, boolNode));
return;
}

JsonNode innerBool = innerQuery.get(BOOL);
if (innerBool != null && innerBool.isObject()) {
insertFilter((ObjectNode) innerBool, nodeFilter);
return;
}

// innerQuery exists but isn't a bool -> wrap it into a bool { must: <old>, filter: <new> }
ObjectNode newBool = objectMapper.createObjectNode();
newBool.set(MUST, innerQuery.deepCopy());
newBool.set(FILTER, nodeFilter);
((ObjectNode) functionScoreNode).set(QUERY, objectMapper.createObjectNode().set(BOOL, newBool));
return;
}

// Top-level bool
JsonNode boolNode = queryNode.get(BOOL);
if (boolNode != null && boolNode.isObject()) {
insertFilter((ObjectNode) boolNode, nodeFilter);
return;
}

// Other query shapes: wrap existing query into a bool { must: <existing query>, filter: nodeFilter }
ObjectNode copy = queryNode.deepCopy();
ObjectNode objectNodeBool = objectMapper.createObjectNode();
objectNodeBool.set(MUST, copy);
objectNodeBool.set(FILTER, nodeFilter);

// Replace the existing "query" content with the new bool
((ObjectNode) queryNode).removeAll();
((ObjectNode) queryNode).set(BOOL, objectNodeBool);
}

private boolean isSearchingForDraft(JsonNode node) {
if (node.isObject()) {
Iterator<String> fieldNames = node.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
if (fieldName.equals("draft")) {
return true;
}
if (isSearchingForDraft(node.get(fieldName))) {
return true;
}
}
} else if (node.isArray()) {
for (JsonNode entry : node) {
if (isSearchingForDraft(entry)) {
return true;
}
}
} else if (node.isTextual()) {
String text = node.asText();
return text.contains("\"draft\":") || text.contains("+draft:") || text.contains("-draft:");
}
return false;
}

private void replaceGlobalAggregations(ObjectNode aggsNode, JsonNode aclFilter) {
aggsNode.fields().forEachRemaining(entry -> {
JsonNode aggDef = entry.getValue();
if (!aggDef.isObject()) {
return;
}
ObjectNode aggDefObj = (ObjectNode) aggDef;
if (aggDefObj.has(GLOBAL)) {
// "global" ignores the query scope; swap it for a filter-scoped bucket.
aggDefObj.remove(GLOBAL);
aggDefObj.set(FILTER, aclFilter);
}
// Recurse into nested sub-aggregations.
for (String subKey : new String[]{AGGS, AGGREGATIONS}) {
JsonNode sub = aggDefObj.get(subKey);
if (sub != null && sub.isObject()) {
replaceGlobalAggregations((ObjectNode) sub, aclFilter);
}
}
});
}

/**
* Add search privilege criteria to a query.
*/
private String buildQueryFilter(ServiceContext context, String type, boolean isSearchingForDraft) throws Exception {
return String.format(FILTER_TEMPLATE,
EsFilterBuilder.build(context, type, isSearchingForDraft, node));

}

private void insertFilter(ObjectNode objectNode, JsonNode nodeFilter) {
JsonNode filter = objectNode.get(FILTER);
if (filter == null || filter.isNull() || (filter.isObject() && filter.isEmpty())) {
objectNode.set(FILTER, nodeFilter);
} else if (filter.isArray()) {
((ArrayNode) filter).add(nodeFilter);
} else {
// existing filter is an object (or other non-array) -> convert to array preserving both
ArrayNode arr = JsonNodeFactory.instance.arrayNode();
arr.add(filter);
arr.add(nodeFilter);
objectNode.set(FILTER, arr);
}
}
}
Loading
Loading