SearchService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import com.rometools.rome.feed.synd.SyndFeed;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.solr.client.solrj.request.SolrQuery;
import org.apache.solr.common.SolrException;
import org.entrystore.AuthorizationException;
import org.entrystore.Entry;
import org.entrystore.EntryType;
import org.entrystore.GraphType;
import org.entrystore.Group;
import org.entrystore.Resource;
import org.entrystore.User;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.util.QueryResult;
import org.entrystore.repository.util.SolrSearchIndex;
import org.entrystore.rest.springboot.configuration.SyndicationProperties;
import org.entrystore.rest.springboot.model.api.FacetSettingsRequestParams;
import org.entrystore.rest.springboot.model.dto.FacetValueDto;
import org.entrystore.rest.springboot.model.dto.FacetValuesDto;
import org.entrystore.rest.springboot.model.dto.QueryResultsDto;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.util.HttpQueryRedactor;
import org.entrystore.rest.springboot.util.Syndication;
import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

import java.util.regex.Pattern;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedList;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class SearchService {

	/** Page size applied when the caller omits {@code limit} or passes a negative value. */
	public static final int DEFAULT_LIMIT = 50;

	/** Facet page size when {@code facetLimit} is absent or below 1. */
	private static final int DEFAULT_FACET_LIMIT = 100;

	private final RepositoryManagerImpl repositoryManager;
	private final SyndicationProperties syndicationProperties;
	private final ResourceSerializationService resourceSerializationService;

	@Value("${entrystore.solr.max-limit:100}")
	private int solrMaxLimit;

	@Value("${entrystore.solr.facet-max-limit:1000}")
	private int solrMaxFacetLimit;

	/**
	 * Valid SPARQL predicate: a full IRI ({@code <http://...>}), a prefixed name ({@code dc:title}),
	 * or the keyword {@code a} (shorthand for rdf:type).
	 */
	private static final Pattern VALID_SPARQL_PREDICATE = Pattern.compile(
			"^(<[^<>\"\\s{}|^`\\\\]+>|[a-zA-Z][\\w.-]*:[a-zA-Z_][\\w.-]*[\\w]|a)$"
	);

	/**
	 * Clamps a requested page size to {@code entrystore.solr.max-limit}. Zero is allowed on purpose so a
	 * client can ask for the result count only; a negative value falls back to {@link #DEFAULT_LIMIT}.
	 */
	public int clampLimit(int limit) {
		if (limit > solrMaxLimit) {
			return solrMaxLimit;
		}
		if (limit < 0) {
			return DEFAULT_LIMIT;
		}
		return limit;
	}

	/**
	 * Converts the bound facet request parameters into Solr facet settings, capping the facet limit at
	 * {@code entrystore.solr.facet-max-limit} and substituting {@link #DEFAULT_FACET_LIMIT} when it is absent or
	 * below 1; {@code facetMinCount} defaults to 1 and is otherwise passed through.
	 */
	public SolrSearchIndex.FacetSettings toFacetSettings(FacetSettingsRequestParams request) {
		return request.toSolrFacetSettings(solrMaxFacetLimit, DEFAULT_FACET_LIMIT);
	}

	public List<Entry> findEntriesSparql(String queryValue) {

		if (queryValue == null || !VALID_SPARQL_PREDICATE.matcher(queryValue).matches()) {
			log.info("Rejected invalid SPARQL predicate input: '{}'", queryValue);
			throw new BadRequestException("Invalid SPARQL predicate. Expected a full IRI (<http://...>) or prefixed name (prefix:name).");
		}

		try {
			String query = "PREFIX dc:<http://purl.org/dc/terms/> " +
					"SELECT ?x " +
					"WHERE { " +
					"  ?x " + queryValue + " ?y }";
			return repositoryManager.getContextManager().search(query, null, null);

		} catch (AuthorizationException e) {
			throw e;
		} catch (Exception e) {
			throw new BadRequestException("Exception processing SPARQL query", e);
		}
	}

	/**
	 * Runs a Solr query. The row count is clamped with {@link #clampLimit(int)} here as well, so the
	 * {@code entrystore.solr.max-limit} cap holds for every caller.
	 */
	public QueryResultsDto findEntriesSolr(
			String queryValue,
			String sorting,
			int offset,
			int limit,
			List<String> filterQueries,
			SolrSearchIndex.FacetSettings facetSettings) {

		try {

			List<Entry> entries;
			long results;
			List<FacetValuesDto> responseFacetFields;

			if (repositoryManager.getIndex() == null) {
				throw new CustomResponseException("Solr search is deactivated", HttpStatus.SERVICE_UNAVAILABLE);
			}

			SolrQuery q = new SolrQuery(queryValue);
			q.setStart(offset);
			q.setRows(clampLimit(limit));

			if (sorting != null) {
				for (String string : sorting.split(",")) {
					String[] fieldAndOrder = string.split(" ");
					if (fieldAndOrder.length == 2) {
						String field = fieldAndOrder[0];
						if (field.startsWith("title.")) {
							field = field.replace("title.", "title_sort.");
						}
						SolrQuery.ORDER order = SolrQuery.ORDER.asc;
						try {
							order = SolrQuery.ORDER.valueOf(fieldAndOrder[1].toLowerCase());
						} catch (IllegalArgumentException iae) {
							log.warn("Unable to parse sorting value, using ascending by default");
						}
						q.addSort(field, order);
					}
				}
			} else {
				q.addSort("score", SolrQuery.ORDER.desc);
				q.addSort("modified", SolrQuery.ORDER.desc);
			}

			if (facetSettings.fields != null) {
				LanguageAwareFacets.configure(q, facetSettings, solrMaxFacetLimit);
			}

			for (String fq : filterQueries) {
				q.addFilterQuery(fq);
			}

			try {
				SolrSearchIndex index = (SolrSearchIndex) repositoryManager.getIndex();
				QueryResult qResult = index.sendQuery(q);
				entries = new LinkedList<>(qResult.getEntries());
				results = qResult.getHits();
				responseFacetFields = LanguageAwareFacets.merge(qResult.getFacetFields(), facetSettings, q, index);
			} catch (SolrException se) {
				// AppExceptionHandler logs both at a lower level; a Solr failure should be visible in the logs
				log.warn("SolrException ({}): {}", se.code(), se.getMessage());
				if (se.code() >= HttpStatus.INTERNAL_SERVER_ERROR.value()) {
					throw new CustomResponseException("Search is temporarily unavailable", HttpStatus.SERVICE_UNAVAILABLE, se);
				}
				throw new BadRequestException("Search failed due to wrong parameters");
			}
			return new QueryResultsDto(entries, results, responseFacetFields);
		} catch (JSONException e) {
			throw new InternalServerErrorException("Error during Solr search", e);
		}

	}

	public String generateSyndication(HttpServletRequest request, List<Entry> entries, String feedType, String language,
									  int limit, String urlTemplate, String feedTitle) {

		SyndFeed feed = Syndication.createFeedFromEntries(repositoryManager.getPrincipalManager(),
				syndicationProperties.template(urlTemplate), entries, language, limit);
		feed.setTitle(Syndication.sanitizeFeedTitle(feedTitle));
		feed.setLink(buildRequestUri(request));
		feed.setFeedType(feedType);

		return Syndication.convertSyndFeedToXml(feed);
	}

	public String generateJson(int offset, int limit, QueryResultsDto queryResults, String rdfFormat) {
		Instant startTime = Instant.now();
		JSONArray children = new JSONArray();
		if (queryResults.entries() != null) {
			for (Entry e : queryResults.entries()) {
				if (e != null) {
					JSONObject childJSON = new JSONObject();
					childJSON.put("entryId", e.getId());
					childJSON.put("contextId", e.getContext().getEntry().getId());
					GraphType btChild = e.getGraphType();
					EntryType locChild = e.getEntryType();
					if (btChild == GraphType.Context || btChild == GraphType.SystemContext) {
						childJSON.put("alias", repositoryManager.getContextManager().getName(e.getResourceURI()));
					} else if (btChild == GraphType.User && locChild == EntryType.Local) {
						User u = (User) e.getResource();
						childJSON.put("name", u.getName());
						try {
							if (u.isDisabled()) {
								childJSON.put("disabled", true);
							}
						} catch (AuthorizationException ae) {
							log.debug("Not allowed to read disabled status of " + e.getEntryURI());
						}
					} else if (btChild == GraphType.Group && locChild == EntryType.Local) {
						Resource groupResource = e.getResource();
						if (groupResource != null) {
							childJSON.put("name", ((Group) groupResource).getName());
						}
					}
					JSONArray rights = resourceSerializationService.serializeRights(e);
					if (!rights.isEmpty()) {
						// unlike list serialization, search children only carry a "rights" key when
						// at least one right exists
						childJSON.put("rights", rights);
					}

					resourceSerializationService.appendMetadataInfoAndRelations(e, childJSON, rdfFormat, true);

					children.put(childJSON);
				}
			}
		}

		JSONObject result = new JSONObject();
		JSONObject resource = new JSONObject();
		resource.put("children", children);
		result.put("resource", resource);
		result.put("results", queryResults.resultsCount());
		result.put("limit", limit);
		result.put("offset", offset);
		result.put("facetFields", getFacetFieldsArr(queryResults));

		log.debug("Graph fetching and serialization took {} ms ", Duration.between(startTime, Instant.now()).toMillis());

		return result.toString(2);
	}

	/**
	 * Serialises the client-facing facet fields. A bucket carries {@code name} and {@code count}; the
	 * {@code facet.missing} bucket has a {@code null} name, which {@code JSONObject.put} drops, so it carries
	 * {@code count} only.
	 */
	private static @NotNull JSONArray getFacetFieldsArr(QueryResultsDto queryResults) {
		JSONArray facetFieldsArr = new JSONArray();
		for (FacetValuesDto facet : queryResults.responseFacetFields()) {
			JSONObject ffObj = new JSONObject();
			ffObj.put("name", facet.name());
			ffObj.put("valueCount", facet.values().size());
			JSONArray ffValArr = new JSONArray();
			for (FacetValueDto value : facet.values()) {
				JSONObject valueObj = new JSONObject();
				valueObj.put("name", value.name());
				valueObj.put("count", value.count());
				ffValArr.put(valueObj);
			}
			ffObj.put("values", ffValArr);
			facetFieldsArr.put(ffObj);
		}
		return facetFieldsArr;
	}

	/**
	 * Builds the request URI using the configured base URL from {@code entrystore.baseurl.folder}.
	 * This ensures the URI always uses the canonical public base URL regardless of how the request
	 * arrived (e.g., via reverse proxy with or without X-Forwarded-* headers).
	 *
	 * @return Request URI with scheme, host, and port from the configured base URL.
	 */
	// Package-private for direct unit testing of the redactor-integration contract.
	String buildRequestUri(HttpServletRequest request) {
		var repositoryURL = repositoryManager.getRepositoryURL();
		if (repositoryURL == null) {
			throw new InternalServerErrorException(
					"Repository base URL is not configured; check 'entrystore.baseurl.folder'");
		}
		String baseUrl = repositoryURL.toExternalForm();
		if (baseUrl.endsWith("/")) {
			baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
		}

		// Redact known sensitive parameters (confirm, token, ticket, RelayState, …) before the
		// query string lands in a public Atom/RSS self-link. No current search endpoint accepts
		// any of these names; this is defensive against a future addition.
		String query = HttpQueryRedactor.redact(request.getQueryString());
		return baseUrl + request.getServletPath() + (query != null ? "?" + query : "");
	}
}