diff --git a/changelog/unreleased/PR#4853-update-org-apache-tika-tika-core.yml b/changelog/unreleased/PR#4853-update-org-apache-tika-tika-core.yml new file mode 100644 index 000000000000..bf8bad38c896 --- /dev/null +++ b/changelog/unreleased/PR#4853-update-org-apache-tika-tika-core.yml @@ -0,0 +1,7 @@ +title: Update org.apache.tika:tika-core to v4 +type: dependency_update +authors: +- name: solrbot +links: +- name: PR#4853 + url: https://github.com/apache/solr/pull/4853 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d14b00312287..77af71b1a6b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ apache-log4j = "2.26.1" apache-lucene = "10.4.0" apache-opennlp = "2.5.11" apache-rat = "0.18" -apache-tika = "3.3.2" +apache-tika = "4.0.0" apache-tomcat = "6.0.53" apache-zookeeper = "3.9.5" # @keep for version alignment diff --git a/solr/licenses/commonmark-0.30.0.jar.sha1 b/solr/licenses/commonmark-0.30.0.jar.sha1 new file mode 100644 index 000000000000..ef17e4e5f72b --- /dev/null +++ b/solr/licenses/commonmark-0.30.0.jar.sha1 @@ -0,0 +1 @@ +fc611136b3191a08d2b84c5d5a26116e3531afee diff --git a/solr/licenses/commonmark-LICENSE-BSD.txt b/solr/licenses/commonmark-LICENSE-BSD.txt new file mode 100644 index 000000000000..604b777d32c7 --- /dev/null +++ b/solr/licenses/commonmark-LICENSE-BSD.txt @@ -0,0 +1,23 @@ +Copyright (c) 2015, Robin Stocker +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/solr/licenses/commonmark-NOTICE.txt b/solr/licenses/commonmark-NOTICE.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/solr/licenses/commonmark-ext-gfm-strikethrough-0.30.0.jar.sha1 b/solr/licenses/commonmark-ext-gfm-strikethrough-0.30.0.jar.sha1 new file mode 100644 index 000000000000..79f99ad6fa50 --- /dev/null +++ b/solr/licenses/commonmark-ext-gfm-strikethrough-0.30.0.jar.sha1 @@ -0,0 +1 @@ +e9addacba704ffce26d6b8bcd1c50e235ec2c991 diff --git a/solr/licenses/commonmark-ext-gfm-tables-0.30.0.jar.sha1 b/solr/licenses/commonmark-ext-gfm-tables-0.30.0.jar.sha1 new file mode 100644 index 000000000000..8f580d0ef23c --- /dev/null +++ b/solr/licenses/commonmark-ext-gfm-tables-0.30.0.jar.sha1 @@ -0,0 +1 @@ +84560a4af5afe4808543bd9c5587a660ce148b14 diff --git a/solr/licenses/tika-core-3.3.2.jar.sha1 b/solr/licenses/tika-core-3.3.2.jar.sha1 deleted file mode 100644 index 3887c7a34110..000000000000 --- a/solr/licenses/tika-core-3.3.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -cb3323ade654469bb6f8d758d3eccbc146693f31 diff --git a/solr/licenses/tika-core-4.0.0.jar.sha1 b/solr/licenses/tika-core-4.0.0.jar.sha1 new file mode 100644 index 000000000000..bab6900e1458 --- /dev/null +++ b/solr/licenses/tika-core-4.0.0.jar.sha1 @@ -0,0 +1 @@ +f370d8619de9e19329b2cc117943c83468c2ff9f diff --git a/solr/modules/extraction/gradle.lockfile b/solr/modules/extraction/gradle.lockfile index 40fd886cdca3..401e6ca1a3a6 100644 --- a/solr/modules/extraction/gradle.lockfile +++ b/solr/modules/extraction/gradle.lockfile @@ -117,11 +117,14 @@ org.apache.lucene:lucene-spatial-extras:10.4.0=jarValidation,runtimeClasspath,ru org.apache.lucene:lucene-spatial3d:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.lucene:lucene-suggest:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspath,testRuntimeClasspath -org.apache.tika:tika-core:3.3.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath +org.apache.tika:tika-core:4.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath org.apache.zookeeper:zookeeper-jute:3.9.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.apache.zookeeper:zookeeper:3.9.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath org.codehaus.woodstox:stax2-api:4.3.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-strikethrough:0.30.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-tables:0.30.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath +org.commonmark:commonmark:0.30.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-common:12.1.12=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.12=jarValidation,testRuntimeClasspath diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingDocumentLoader.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingDocumentLoader.java index d4fd1c4cbd1b..c046125b3cef 100644 --- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingDocumentLoader.java +++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingDocumentLoader.java @@ -67,6 +67,18 @@ public class ExtractingDocumentLoader extends ContentStreamLoader { protected SolrContentHandlerFactory factory; protected ExtractionBackend backend; + /** + * Rethrows {@code e} as-is if it's already a {@link SolrException} (preserving its error code, + * e.g. a {@code BAD_REQUEST} from invalid extraction parameters), otherwise wraps it in a {@code + * SERVER_ERROR}. + */ + private static SolrException wrapExtractionException(Exception e) { + if (e instanceof SolrException se) { + return se; + } + return new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + } + public ExtractingDocumentLoader( SolrQueryRequest req, UpdateRequestProcessor processor, @@ -143,6 +155,8 @@ public void load( .tikaServerRecursive(tikaserverRecursive) .tikaServerTimeoutSeconds(tikaTimeoutSecs) .tikaServerRequestHeaders(Map.of()) + .tikaServerConfigJson(params.get(ExtractingParams.TIKASERVER_CONFIG_JSON)) + .ignoreTikaException(ignoreTikaException) .build(); boolean captureAttr = params.getBool(ExtractingParams.CAPTURE_ATTRIBUTES, false); @@ -185,7 +199,7 @@ public void load( log.warn("skip extracting text due to {}.", e.getLocalizedMessage(), e); return; } - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + throw wrapExtractionException(e); } return; } @@ -203,7 +217,7 @@ public void load( return; } } - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + throw wrapExtractionException(e); } addDoc(handler); @@ -219,7 +233,7 @@ public void load( log.warn("skip extracting text due to {}.", e.getLocalizedMessage(), e); return; } - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + throw wrapExtractionException(e); } ExtractionMetadata metadata = result.getMetadata(); diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingParams.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingParams.java index a99025127b19..4c5d625c502c 100644 --- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingParams.java +++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractingParams.java @@ -157,4 +157,14 @@ public interface ExtractingParams { /** Default or per-request timeout in seconds for TikaServer HTTP calls. */ String TIKASERVER_TIMEOUT_SECS = "tikaserver.timeoutSeconds"; + + /** + * Optional raw JSON object sent as the "config" part of a per-request TikaServer configuration + * call (e.g. {@code {"pdf-parser":{"ocr":{"strategy":"no_ocr"}}}}). The server must have {@code + * allowPerRequestConfig=true} set, or the request is rejected with 403. Rejected with {@code + * BAD_REQUEST} for recursive (tikaserver.recursive) requests, since TikaServer has no XML-output + * variant of /rmeta/config (tracked upstream as TIKA-4881). + */ + String TIKASERVER_CONFIG_JSON = "tikaserver.config"; } diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractionRequest.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractionRequest.java index 79068816271a..a68e5c66d644 100644 --- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractionRequest.java +++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/ExtractionRequest.java @@ -38,6 +38,8 @@ public class ExtractionRequest { public final boolean tikaServerRecursive; public final Integer tikaServerTimeoutSeconds; // optional per-request override public final Map tikaServerRequestHeaders = new HashMap<>(); + public final String tikaServerConfigJson; // optional raw JSON "config" part; TikaServer 4.x only + public final boolean ignoreTikaException; /** * Constructs an ExtractionRequest object containing metadata and configurations for extraction @@ -59,6 +61,11 @@ public class ExtractionRequest { * only). If null or ≤ 0, the default timeout will be used * @param tikaServerRequestHeaders optional headers to be included in requests to the extraction * service. TikaServer only + * @param tikaServerConfigJson optional raw JSON object sent as the per-request "config" part. + * TikaServer 4.x only; requires allowPerRequestConfig=true on the server + * @param ignoreTikaException if true, a backend that only partially extracted a document (e.g. a + * TikaServer 422 caused by a writeLimit truncation) may return that partial content instead + * of failing. TikaServer only */ private ExtractionRequest( String streamType, @@ -73,7 +80,9 @@ private ExtractionRequest( String extractFormat, boolean tikaServerRecursive, Integer tikaServerTimeoutSeconds, - Map tikaServerRequestHeaders) { + Map tikaServerRequestHeaders, + String tikaServerConfigJson, + boolean ignoreTikaException) { this.streamType = streamType; this.resourceName = resourceName; this.contentType = contentType; @@ -89,6 +98,8 @@ private ExtractionRequest( if (tikaServerRequestHeaders != null) { this.tikaServerRequestHeaders.putAll(tikaServerRequestHeaders); } + this.tikaServerConfigJson = tikaServerConfigJson; + this.ignoreTikaException = ignoreTikaException; } /** Creates a new Builder for constructing ExtractionRequest instances. */ @@ -111,6 +122,8 @@ public static class Builder { private boolean tikaServerRecursive = false; private Integer tikaServerTimeoutSeconds; private Map tikaServerRequestHeaders; + private String tikaServerConfigJson; + private boolean ignoreTikaException = false; private Builder() {} @@ -179,6 +192,16 @@ public Builder tikaServerRequestHeaders(Map tikaServerRequestHea return this; } + public Builder tikaServerConfigJson(String tikaServerConfigJson) { + this.tikaServerConfigJson = tikaServerConfigJson; + return this; + } + + public Builder ignoreTikaException(boolean ignoreTikaException) { + this.ignoreTikaException = ignoreTikaException; + return this; + } + public ExtractionRequest build() { return new ExtractionRequest( streamType, @@ -193,7 +216,9 @@ public ExtractionRequest build() { extractFormat, tikaServerRecursive, tikaServerTimeoutSeconds, - tikaServerRequestHeaders); + tikaServerRequestHeaders, + tikaServerConfigJson, + ignoreTikaException); } } } diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java index a6d08bb11f77..6b6d6124dcb2 100644 --- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java +++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerExtractionBackend.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.io.InputStream; +import java.io.PushbackInputStream; +import java.lang.invoke.MethodHandles; import java.net.ConnectException; import java.net.MalformedURLException; import java.net.SocketTimeoutException; @@ -33,19 +35,30 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.solr.common.SolrException; import org.apache.solr.common.util.ExecutorUtil; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SolrNamedThreadFactory; +import org.apache.solr.common.util.Utils; import org.apache.solr.util.RefCounted; import org.apache.tika.sax.BodyContentHandler; +import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.InputStreamRequestContent; import org.eclipse.jetty.client.InputStreamResponseListener; +import org.eclipse.jetty.client.MultiPartRequestContent; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.MultiPart; import org.eclipse.jetty.io.EofException; import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.xml.sax.helpers.DefaultHandler; /** @@ -53,17 +66,30 @@ * import of org.apache.tika.sax.BodyContentHandler; */ public class TikaServerExtractionBackend implements ExtractionBackend { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + /** * Default maximum response size (100MB) to prevent excessive memory usage from large documents */ public static final long DEFAULT_MAXCHARS_LIMIT = 100 * 1024 * 1024; + /** Minimum TikaServer major version this backend supports (relies on Tika 4.x-only APIs). */ + private static final int MIN_SUPPORTED_TIKASERVER_MAJOR_VERSION = 4; + + private static final Pattern TIKASERVER_VERSION_PATTERN = Pattern.compile("Apache Tika (\\d+)"); + + // Short-lived: /version is a trivial static-text endpoint, so this shouldn't use the full + // extraction defaultTimeout (which can be minutes) and block concurrent requests behind it. + private static final Duration VERSION_CHECK_TIMEOUT = Duration.ofSeconds(10); + private static final Object INIT_LOCK = new Object(); private final String baseUrl; private static final int DEFAULT_TIMEOUT_SECONDS = 3 * 60; private final Duration defaultTimeout; private final TikaServerParser tikaServerResponseParser = new TikaServerParser(); private boolean tikaMetadataCompatibility; + private volatile boolean tikaServerVersionVerified = false; + private volatile String rejectedTikaServerVersionMessage; private HashMap initArgsMap = new HashMap<>(); private final long maxCharsLimit; @@ -173,26 +199,84 @@ public void extractWithSaxHandler( * request.tikaserverRecursive */ InputStream callTikaServer(InputStream inputStream, ExtractionRequest request) throws Exception { - String url = baseUrl + (request.tikaServerRecursive ? "/rmeta" : "/tika"); + ensureSupportedTikaServerVersion(); - HttpClient client = acquiredResourcesRef.get().client; + ExtractionMetadata md = buildMetadataFromRequest(request); + String pwd = resolvePassword(request, md); + String configJson = resolveConfigJson(request, pwd); - Request req = client.newRequest(url).method("PUT"); + HttpClient client = acquiredResourcesRef.get().client; Duration effectiveTimeout = (request.tikaServerTimeoutSeconds != null && request.tikaServerTimeoutSeconds > 0) ? Duration.ofSeconds(request.tikaServerTimeoutSeconds) : defaultTimeout; - req.timeout(effectiveTimeout.toMillis(), TimeUnit.MILLISECONDS); - // Also set idle timeout in case of heavy server side work like OCR - req.idleTimeout(effectiveTimeout.toMillis(), TimeUnit.MILLISECONDS); - - // Headers - String accept = (request.tikaServerRecursive ? "application/json" : "text/xml"); - req.headers(h -> h.add("Accept", accept)); String contentType = (request.streamType != null) ? request.streamType : request.contentType; - if (contentType != null) { - req.headers(h -> h.add("Content-Type", contentType)); + + String url; + Request req; + if (configJson != null && request.tikaServerRecursive) { + // Tracked upstream: https://issues.apache.org/jira/browse/TIKA-4881 + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Per-request TikaServer config (password or " + + ExtractingParams.TIKASERVER_CONFIG_JSON + + ") is not supported together with " + + ExtractingParams.TIKASERVER_RECURSIVE + + "=true: TikaServer 4.x has no XML-output variant of /rmeta/config" + + " (see https://issues.apache.org/jira/browse/TIKA-4881)."); } + if (configJson != null) { + // TikaServer accepts per-request parser config (including passwords) as a JSON "config" + // part on a multipart request, and requires allowPerRequestConfig=true on the server. Only + // non-recursive extraction is handled here, since TikaServer has no XML content-handler + // variant of /rmeta/config (checked above). + url = baseUrl + "/tika/config/xml"; + req = client.newRequest(url).method("POST"); + req.headers(h -> h.add("Accept", "text/xml")); + + HttpFields.Mutable fileFields = HttpFields.build(); + if (contentType != null) { + fileFields.add(HttpHeader.CONTENT_TYPE, contentType); + } + try (MultiPartRequestContent multiPart = new MultiPartRequestContent()) { + multiPart.addPart( + new MultiPart.ContentSourcePart( + "file", + request.resourceName, + fileFields, + new InputStreamRequestContent(inputStream))); + multiPart.addPart( + new MultiPart.ContentSourcePart( + "config", + null, + HttpFields.build().add(HttpHeader.CONTENT_TYPE, "application/json"), + new StringRequestContent(configJson))); + req.body(multiPart); + } + } else { + // TikaServer's /tika and /rmeta endpoints return Markdown by default (TIKA-4663); Solr's + // SAX-based content handling requires XHTML/XML, hence the /xml path variants. + url = baseUrl + (request.tikaServerRecursive ? "/rmeta/xml" : "/tika/xml"); + req = client.newRequest(url).method("PUT"); + String accept = (request.tikaServerRecursive ? "application/json" : "text/xml"); + req.headers(h -> h.add("Accept", accept)); + if (contentType != null) { + req.headers(h -> h.add("Content-Type", contentType)); + } + if (request.resourceName != null) { + req.headers( + h -> + h.add( + "Content-Disposition", + "attachment; filename=\"" + request.resourceName + "\"")); + } + if (contentType != null) { + req.body(new InputStreamRequestContent(contentType, inputStream)); + } else { + req.body(new InputStreamRequestContent(inputStream)); + } + } + if (!request.tikaServerRequestHeaders.isEmpty()) { req.headers( h -> @@ -202,32 +286,9 @@ InputStream callTikaServer(InputStream inputStream, ExtractionRequest request) t })); } - ExtractionMetadata md = buildMetadataFromRequest(request); - if (request.resourcePassword != null || request.passwordsMap != null) { - RegexRulesPasswordProvider passwordProvider = new RegexRulesPasswordProvider(); - if (request.resourcePassword != null) { - passwordProvider.setExplicitPassword(request.resourcePassword); - } - if (request.passwordsMap != null) { - passwordProvider.setPasswordMap(request.passwordsMap); - } - String pwd = passwordProvider.getPassword(md); - if (pwd != null) { - req.headers(h -> h.add("Password", pwd)); // Tika Server expects this header if provided - } - } - if (request.resourceName != null) { - req.headers( - h -> - h.add( - "Content-Disposition", "attachment; filename=\"" + request.resourceName + "\"")); - } - - if (contentType != null) { - req.body(new InputStreamRequestContent(contentType, inputStream)); - } else { - req.body(new InputStreamRequestContent(inputStream)); - } + req.timeout(effectiveTimeout.toMillis(), TimeUnit.MILLISECONDS); + // Also set idle timeout in case of heavy server side work like OCR + req.idleTimeout(effectiveTimeout.toMillis(), TimeUnit.MILLISECONDS); InputStreamResponseListener listener = new InputStreamResponseListener(); req.send(listener); @@ -273,7 +334,44 @@ InputStream callTikaServer(InputStream inputStream, ExtractionRequest request) t } int code = response.getStatus(); - if (code < 200 || code >= 300) { + InputStream responseStream = listener.getInputStream(); + // Tika 4.x's raw /tika* endpoints (non-recursive) return 422 whenever a container-level + // exception occurred during parsing -- including a non-aborting one like a writeLimit + // truncation -- but the body still carries whatever content was successfully extracted + // (there's no envelope to carry the exception itself on these endpoints; use /rmeta for + // that). A request that extracted nothing at all (e.g. a wrong password) also gets 422, but + // with an empty body -- that's always a hard failure, regardless of ignoreTikaException. + // Peek the first byte to tell the two apart. + if (code == 422 && !request.tikaServerRecursive) { + PushbackInputStream peekable = new PushbackInputStream(responseStream, 1); + int firstByte = peekable.read(); + if (firstByte == -1) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "TikaServer " + + url + + " returned status 422 (Unprocessable Entity) with no content -- the document" + + " could not be parsed at all (check the password, if one was required)."); + } + peekable.unread(firstByte); + if (!request.ignoreTikaException) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "TikaServer " + + url + + " returned status 422 (Unprocessable Entity): a container-level exception" + + " occurred during parsing (e.g. a writeLimit truncation). Partial content was" + + " extracted but is being discarded because ignoreTikaException=false; set" + + " ignoreTikaException=true to index the partial content instead, or use" + + " tikaserver.recursive=true against /rmeta for the exception detail."); + } + log.warn( + "TikaServer {} returned 422 (a container-level exception occurred during parsing); " + + "using the partial content it still returned because ignoreTikaException=true. " + + "Use tikaserver.recursive=true against /rmeta for the exception detail.", + url); + responseStream = peekable; + } else if (code < 200 || code >= 300) { SolrException.ErrorCode errorCode = SolrException.ErrorCode.getErrorCode(code); String reason = response.getReason(); String msg = @@ -285,11 +383,130 @@ InputStream callTikaServer(InputStream inputStream, ExtractionRequest request) t throw new SolrException(errorCode, msg); } - InputStream responseStream = listener.getInputStream(); // Bound the amount of data we read from Tika Server to avoid excessive memory/CPU usage return new LimitingInputStream(responseStream, maxCharsLimit); } + /** + * Verifies, once per backend instance, that the configured TikaServer reports a major version of + * at least {@link #MIN_SUPPORTED_TIKASERVER_MAJOR_VERSION}. This backend relies on endpoints + * (e.g. {@code /tika/xml}, {@code /tika/config/xml}) and metadata keys (e.g. {@code tk:content}) + * that only exist on TikaServer 4.x and newer; an older server would otherwise fail with + * confusing 404s or missing-metadata errors instead of a clear diagnostic. + * + *

Connectivity/parsing failures are retried on the next call rather than cached, since + * TikaServer may simply not be up yet. A definitively too-old version, however, is a permanent + * fact, so that verdict is cached to avoid re-probing the network on every extraction request. + * + *

Deliberately not synchronized: while unverified, concurrent extraction requests may each + * probe {@code /version} independently rather than queue behind one shared lock. That's cheap and + * self-resolving once verified, and avoids turning a TikaServer outage into concurrent requests + * serialized behind a single blocking network call instead of each failing in parallel. + */ + private void ensureSupportedTikaServerVersion() throws Exception { + if (tikaServerVersionVerified) { + return; + } + if (rejectedTikaServerVersionMessage != null) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, rejectedTikaServerVersionMessage); + } + HttpClient client = acquiredResourcesRef.get().client; + String versionUrl = baseUrl + "/version"; + ContentResponse response; + try { + response = + client + .newRequest(versionUrl) + .timeout(VERSION_CHECK_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .send(); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Could not determine the TikaServer version at " + versionUrl + ": " + e.getMessage(), + e); + } + if (response.getStatus() != 200) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "TikaServer " + versionUrl + " returned status " + response.getStatus()); + } + String versionText = response.getContentAsString().trim(); + Matcher m = TIKASERVER_VERSION_PATTERN.matcher(versionText); + if (!m.find()) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Could not parse a TikaServer version from " + + versionUrl + + "'s response: '" + + versionText + + "'"); + } + int majorVersion = Integer.parseInt(m.group(1)); + if (majorVersion < MIN_SUPPORTED_TIKASERVER_MAJOR_VERSION) { + rejectedTikaServerVersionMessage = + "TikaServer at " + + baseUrl + + " reports version '" + + versionText + + "', but Solr's 'tikaserver' extraction backend requires TikaServer " + + MIN_SUPPORTED_TIKASERVER_MAJOR_VERSION + + ".x or newer (it relies on endpoints and metadata keys introduced in that" + + " version). Upgrade the TikaServer, or point tikaserver.url at a TikaServer " + + MIN_SUPPORTED_TIKASERVER_MAJOR_VERSION + + ".x+ instance."; + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, rejectedTikaServerVersionMessage); + } + tikaServerVersionVerified = true; + } + + /** Resolves the password to use for an encrypted document, or null if none applies. */ + private String resolvePassword(ExtractionRequest request, ExtractionMetadata md) { + if (request.resourcePassword == null && request.passwordsMap == null) { + return null; + } + RegexRulesPasswordProvider passwordProvider = new RegexRulesPasswordProvider(); + if (request.resourcePassword != null) { + passwordProvider.setExplicitPassword(request.resourcePassword); + } + if (request.passwordsMap != null) { + passwordProvider.setPasswordMap(request.passwordsMap); + } + return passwordProvider.getPassword(md); + } + + /** + * Builds the per-request TikaServer JSON "config" payload, merging any resolved password with any + * caller-supplied {@link ExtractingParams#TIKASERVER_CONFIG_JSON}. Returns null if neither + * applies, meaning no per-request config is needed. + */ + @SuppressWarnings("unchecked") + private String resolveConfigJson(ExtractionRequest request, String pwd) { + Map config = new LinkedHashMap<>(); + if (request.tikaServerConfigJson != null && !request.tikaServerConfigJson.isBlank()) { + Object parsed; + try { + parsed = Utils.fromJSONString(request.tikaServerConfigJson); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Invalid JSON in " + ExtractingParams.TIKASERVER_CONFIG_JSON + ": " + e.getMessage(), + e); + } + if (!(parsed instanceof Map)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + ExtractingParams.TIKASERVER_CONFIG_JSON + " must be a JSON object"); + } + config.putAll((Map) parsed); + } + if (pwd != null && !config.containsKey("simple-password-provider")) { + config.put("simple-password-provider", Map.of("password", pwd)); + } + return config.isEmpty() ? null : Utils.toJSONString(config); + } + private static class LimitingInputStream extends InputStream { private final InputStream in; private final long max; diff --git a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerParser.java b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerParser.java index e34514040b6e..96b817940149 100644 --- a/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerParser.java +++ b/solr/modules/extraction/src/java/org/apache/solr/handler/extraction/TikaServerParser.java @@ -35,20 +35,31 @@ import org.xml.sax.helpers.DefaultHandler; public class TikaServerParser { - private final SAXParser saxParser; + // TikaServer 4.x's /rmeta content key (TIKA-4816). + private static final String CONTENT_KEY = "tk:content"; + + // SAXParser isn't thread-safe, but a single TikaServerParser is shared across concurrent + // extraction requests (TikaServerExtractionBackend is held for the request handler's lifetime). + // The factory's config is fixed once at construction, so it's safe to share; each parse below + // mints its own SAXParser from it instead of reusing one. + private final SAXParserFactory saxParserFactory; public TikaServerParser() { - SAXParserFactory factory = SAXParserFactory.newInstance(); - factory.setNamespaceAware(true); + saxParserFactory = SAXParserFactory.newInstance(); + saxParserFactory.setNamespaceAware(true); try { - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + saxParserFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); + saxParserFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + saxParserFactory.setFeature( + "http://apache.org/xml/features/nonvalidating/load-external-dtd", false); } catch (Throwable ignore) { // Some parsers may not support all features; ignore } + } + + private SAXParser newSaxParser() { try { - saxParser = factory.newSAXParser(); + return saxParserFactory.newSAXParser(); } catch (Exception e) { throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); } @@ -63,7 +74,7 @@ public void parseXml(InputStream inputStream, ContentHandler handler, Extraction DefaultHandler xmlHandler = new TikaXmlResponseSaxContentHandler(handler, metadata); try (Reader reader = new XmlSanitizingReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { - saxParser.parse(new InputSource(reader), xmlHandler); + newSaxParser().parse(new InputSource(reader), xmlHandler); } } @@ -91,7 +102,7 @@ void parseRmetaJson(InputStream jsonStream, DefaultHandler handler, ExtractionMe for (Object k : map.keySet()) { String key = String.valueOf(k); Object val = map.get(k); - if ("X-TIKA:content".equalsIgnoreCase(key)) { + if (CONTENT_KEY.equalsIgnoreCase(key)) { // handled below continue; } @@ -103,7 +114,7 @@ void parseRmetaJson(InputStream jsonStream, DefaultHandler handler, ExtractionMe md.add(key, String.valueOf(val)); } } - Object content = map.get("X-TIKA:content"); + Object content = map.get(CONTENT_KEY); if (content != null) { String xhtml = String.valueOf(content); if (!xhtml.isEmpty() && handler != null) { @@ -111,7 +122,7 @@ void parseRmetaJson(InputStream jsonStream, DefaultHandler handler, ExtractionMe new ByteArrayInputStream(xhtml.getBytes(StandardCharsets.UTF_8)); try (Reader reader = new XmlSanitizingReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { - saxParser.parse(new InputSource(reader), handler); + newSaxParser().parse(new InputSource(reader), handler); } } } diff --git a/solr/modules/extraction/src/test-files/extraction/tika-server-config.json b/solr/modules/extraction/src/test-files/extraction/tika-server-config.json new file mode 100644 index 000000000000..63a1e4fcbd77 --- /dev/null +++ b/solr/modules/extraction/src/test-files/extraction/tika-server-config.json @@ -0,0 +1,5 @@ +{ + "server": { + "allowPerRequestConfig": true + } +} diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTestAbstract.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTestAbstract.java index c9c872bc99de..b6553cf9f4bb 100644 --- a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTestAbstract.java +++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTestAbstract.java @@ -440,17 +440,20 @@ public void testLiterals() throws Exception { "one", "literal.extractionLiteral", "two", - "fmap.X-Parsed-By", + // TikaServer 4.x uses a single lowercase tk: prefix for its metadata keys (TIKA-4816) + "fmap.tk:parsed-by", "ignored_parser", - "fmap.X-TIKA:Parsed-By", + "fmap.tk:parsed-by-full-set", "ignored_parser", - "fmap.X-TIKA:Parsed-By-Full-Set", + "fmap.tk:content-handler-type", "ignored_parser", - "fmap.X-TIKA:content_handler", + "fmap.tk:parse-time-millis", "ignored_parser", - "fmap.X-TIKA:parse_time_millis", + "fmap.tk:embedded-depth", "ignored_parser", - "fmap.X-TIKA:embedded_depth", + "fmap.tk:content-type-parser-override", + "ignored_parser", + "fmap.tk:content-type-magic-detected", "ignored_parser", "fmap.Last-Modified", "extractedDate"); @@ -480,17 +483,20 @@ public void testLiterals() throws Exception { "extractedLanguage", "literal.extractionLiteral", "one", - "fmap.X-Parsed-By", + // TikaServer 4.x uses a single lowercase tk: prefix for its metadata keys (TIKA-4816) + "fmap.tk:parsed-by", + "ignored_parser", + "fmap.tk:parsed-by-full-set", "ignored_parser", - "fmap.X-TIKA:Parsed-By", + "fmap.tk:content-handler-type", "ignored_parser", - "fmap.X-TIKA:Parsed-By-Full-Set", + "fmap.tk:parse-time-millis", "ignored_parser", - "fmap.X-TIKA:content_handler", + "fmap.tk:embedded-depth", "ignored_parser", - "fmap.X-TIKA:parse_time_millis", + "fmap.tk:content-type-parser-override", "ignored_parser", - "fmap.X-TIKA:embedded_depth", + "fmap.tk:content-type-magic-detected", "ignored_parser", "fmap.Last-Modified", "extractedDate"); @@ -596,21 +602,26 @@ public void testPlainTextSpecifyingMimeType() throws Exception { "one", "fmap.language", "extractedLanguage", - "fmap.X-Parsed-By", + // TikaServer 4.x uses a single lowercase tk: prefix for its metadata keys (TIKA-4816) + "fmap.tk:parsed-by", + "ignored_parser", + "fmap.tk:detected-encoding", + "ignored_parser", + "fmap.tk:encoding-detector", "ignored_parser", - "fmap.X-TIKA:Parsed-By", + "fmap.tk:encoding-detection-trace", "ignored_parser", - "fmap.X-TIKA:detectedEncoding", + "fmap.tk:parsed-by-full-set", "ignored_parser", - "fmap.X-TIKA:encodingDetector", + "fmap.tk:content-handler-type", "ignored_parser", - "fmap.X-TIKA:Parsed-By-Full-Set", + "fmap.tk:parse-time-millis", "ignored_parser", - "fmap.X-TIKA:content_handler", + "fmap.tk:embedded-depth", "ignored_parser", - "fmap.X-TIKA:parse_time_millis", + "fmap.tk:content-type-parser-override", "ignored_parser", - "fmap.X-TIKA:embedded_depth", + "fmap.tk:content-type-magic-detected", "ignored_parser", "fmap.content", "extractedContent", @@ -644,21 +655,28 @@ public void testPlainTextSpecifyingResourceName() throws Exception { "one", "fmap.language", "extractedLanguage", - "fmap.X-Parsed-By", + // TikaServer 4.x uses a single lowercase tk: prefix for its metadata keys (TIKA-4816) + "fmap.tk:parsed-by", + "ignored_parser", + "fmap.tk:detected-encoding", + "ignored_parser", + "fmap.tk:encoding-detector", + "ignored_parser", + "fmap.tk:encoding-detection-trace", "ignored_parser", - "fmap.X-TIKA:Parsed-By", + "fmap.tk:parsed-by-full-set", "ignored_parser", - "fmap.X-TIKA:detectedEncoding", + "fmap.tk:content-handler-type", "ignored_parser", - "fmap.X-TIKA:encodingDetector", + "fmap.tk:parse-time-millis", "ignored_parser", - "fmap.X-TIKA:Parsed-By-Full-Set", + "fmap.tk:embedded-depth", "ignored_parser", - "fmap.X-TIKA:content_handler", + "fmap.tk:content-type-parser-override", "ignored_parser", - "fmap.X-TIKA:parse_time_millis", + "fmap.tk:content-type-magic-detected", "ignored_parser", - "fmap.X-TIKA:embedded_depth", + "fmap.tk:resource-name", "ignored_parser", "fmap.content", "extractedContent", diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTikaServerTest.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTikaServerTest.java index f32dbb5289c8..7f31e8fcd6d6 100644 --- a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTikaServerTest.java +++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/ExtractingRequestHandlerTikaServerTest.java @@ -31,7 +31,8 @@ public class ExtractingRequestHandlerTikaServerTest extends ExtractingRequestHan private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @ClassRule - public static final TikaServerContainerRule tikaContainer = new TikaServerContainerRule(); + public static final TikaServerContainerRule tikaContainer = + new TikaServerContainerRule(getFile("extraction/tika-server-config.json")); @BeforeClass public static void beforeClassTika() throws Exception { diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerContainerRule.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerContainerRule.java index 7a6ae3937998..368bdb0c75e1 100644 --- a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerContainerRule.java +++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerContainerRule.java @@ -17,6 +17,7 @@ package org.apache.solr.handler.extraction; import java.lang.invoke.MethodHandles; +import java.nio.file.Path; import org.junit.Assume; import org.junit.rules.ExternalResource; import org.slf4j.Logger; @@ -24,6 +25,7 @@ import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.MountableFile; /** * JUnit rule that manages a single Apache Tika Server Testcontainer. Declare as a @@ -38,11 +40,21 @@ public class TikaServerContainerRule extends ExternalResource { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); - public static final String TIKA_DOCKER_IMAGE = "apache/tika:3.2.3.0-full"; + public static final String TIKA_DOCKER_IMAGE = "apache/tika:4.0.0-full"; + private final Path serverConfigFile; private GenericContainer tika; private String baseUrl; + /** + * @param serverConfigFile optional Tika Server JSON config file to mount and start the container + * with (via {@code -c}), e.g. to set {@code allowPerRequestConfig: true}. Null for the + * container's default configuration. + */ + public TikaServerContainerRule(Path serverConfigFile) { + this.serverConfigFile = serverConfigFile; + } + @Override @SuppressWarnings("resource") protected void before() { @@ -56,6 +68,10 @@ protected void before() { new GenericContainer<>(TIKA_DOCKER_IMAGE) .withExposedPorts(9998) .waitingFor(Wait.forListeningPort()); + if (serverConfigFile != null) { + tika.withCopyFileToContainer(MountableFile.forHostPath(serverConfigFile), "/tika-config.json") + .withCommand("-c", "/tika-config.json"); + } tika.start(); baseUrl = "http://" + tika.getHost() + ":" + tika.getMappedPort(9998); } diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java index 326ab818596c..fbab3ba48697 100644 --- a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java +++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerExtractionBackendTest.java @@ -21,9 +21,14 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.apache.lucene.tests.util.QuickPatchThreadsFilter; import org.apache.solr.SolrIgnoredThreadsFilter; import org.apache.solr.SolrTestCaseJ4; @@ -58,7 +63,8 @@ public boolean reject(Thread t) { } @ClassRule - public static final TikaServerContainerRule tikaContainer = new TikaServerContainerRule(); + public static final TikaServerContainerRule tikaContainer = + new TikaServerContainerRule(getFile("extraction/tika-server-config.json")); private static ExtractionRequest newRequest( String resourceName, @@ -124,14 +130,13 @@ public void testPdfWithImageRecursive() throws Exception { try (TikaServerExtractionBackend backend = new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { byte[] data = Files.readAllBytes(getFile("extraction/pdf-with-image.pdf")); - // Enable recursive extraction and set header to extract images from PDF + // Explicit inline-image extraction options can't be requested here: TikaServer's per-request + // config only supports the non-recursive /tika/config/xml endpoint (see resolveConfigJson's + // javadoc), since there is no XML-output variant of /rmeta/config for recursive requests. The + // PDF's embedded image is still OCR'd into the main document's content by default, just not + // exposed as a separate embedded resource entry. ExtractionRequest request = - newRequest( - "pdf-with-image.pdf", - "application/pdf", - "xml", - true, - Map.of("X-Tika-PDFextractInlineImages", "true")); + newRequest("pdf-with-image.pdf", "application/pdf", "xml", true, Map.of()); try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { ToXMLContentHandler xmlHandler = new ToXMLContentHandler(); ExtractionMetadata md = backend.buildMetadataFromRequest(request); @@ -139,9 +144,8 @@ public void testPdfWithImageRecursive() throws Exception { String c = xmlHandler.toString(); assertNotNull(c); assertTrue(c.contains("Puppet Apply")); - assertTrue(c.contains("embedded:image0.jpg")); - assertEquals( - "org.apache.tika.parser.DefaultParser", md.getFirst("X-TIKA:Parsed-By-Full-Set")); + // TikaServer 4.x uses a single lowercase tk: prefix for its metadata keys (TIKA-4816) + assertEquals("org.apache.tika.parser.DefaultParser", md.getFirst("tk:parsed-by-full-set")); } } } @@ -195,4 +199,140 @@ public void testMaxCharsLimitEnforcedWithSaxHandler() throws Exception { } } } + + private static ExtractionRequest newRequestWithConfig( + String resourceName, String contentType, String extractFormat, String configJson) { + return ExtractionRequest.builder() + .streamType(contentType) + .resourceName(resourceName) + .contentType(contentType) + .streamName(resourceName) + .extractFormat(extractFormat) + .tikaServerConfigJson(configJson) + .build(); + } + + @Test + public void testConfigJsonDisablesOcr() throws Exception { + try (TikaServerExtractionBackend backend = + new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { + byte[] data = Files.readAllBytes(getFile("extraction/pdf-with-image.pdf")); + // With no config, the PDF's embedded image gets OCR'd and "Puppet Apply" (from the image) + // appears in the extracted content. Disabling OCR via tikaserver.config should suppress it. + ExtractionRequest request = + newRequestWithConfig( + "pdf-with-image.pdf", + "application/pdf", + "xml", + "{\"pdf-parser\":{\"ocr\":{\"strategy\":\"NO_OCR\"}}}"); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + ExtractionResult res = backend.extract(in, request); + assertNotNull(res.getContent()); + assertFalse( + "Expected tikaserver.config's NO_OCR strategy to suppress the OCR'd image text", + res.getContent().contains("Puppet Apply")); + } + } + } + + @Test + public void testConfigJsonMergesWithPassword() throws Exception { + try (TikaServerExtractionBackend backend = + new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { + byte[] data = Files.readAllBytes(getFile("extraction/encrypted-password-is-solrRules.pdf")); + ExtractionRequest request = + ExtractionRequest.builder() + .streamType("application/pdf") + .resourceName("encrypted-password-is-solrRules.pdf") + .contentType("application/pdf") + .streamName("encrypted-password-is-solrRules.pdf") + .extractFormat("xml") + .resourcePassword("solrRules") + .tikaServerConfigJson("{\"pdf-parser\":{\"ocr\":{\"strategy\":\"NO_OCR\"}}}") + .build(); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + ExtractionResult res = backend.extract(in, request); + assertNotNull(res); + assertTrue( + "Expected the password-unlocked content to still be present alongside the merged" + + " tikaserver.config", + res.getContent().contains("This is a test of PDF and Word extraction")); + } + } + } + + @Test + public void testInvalidConfigJsonRejected() throws Exception { + try (TikaServerExtractionBackend backend = + new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { + byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + ExtractionRequest request = + newRequestWithConfig("test.txt", "text/plain", "xml", "not valid json"); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + SolrException e = expectThrows(SolrException.class, () -> backend.extract(in, request)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code()); + assertTrue(e.getMessage().contains(ExtractingParams.TIKASERVER_CONFIG_JSON)); + } + } + } + + @Test + public void testConfigJsonRejectedForRecursive() throws Exception { + try (TikaServerExtractionBackend backend = + new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { + byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + ExtractionRequest request = + ExtractionRequest.builder() + .streamType("text/plain") + .resourceName("test.txt") + .contentType("text/plain") + .streamName("test.txt") + .extractFormat("xml") + .tikaServerRecursive(true) + .tikaServerConfigJson("{\"parse-context\":{}}") + .build(); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + SolrException e = expectThrows(SolrException.class, () -> backend.extract(in, request)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code()); + assertTrue(e.getMessage().contains(ExtractingParams.TIKASERVER_RECURSIVE)); + } + } + } + + /** + * A single {@code TikaServerExtractionBackend} is constructed once by {@code + * ExtractingRequestHandler.inform()} and reused for every request it handles, including + * concurrently. {@code javax.xml.parsers.SAXParser} is not thread-safe, so parsing the response + * must not share one {@code SAXParser} instance across concurrent {@code extract()} calls. + */ + @Test + public void testConcurrentExtractDoesNotShareSaxParser() throws Exception { + int numThreads = 8; + try (TikaServerExtractionBackend backend = + new TikaServerExtractionBackend(tikaContainer.getBaseUrl())) { + ExecutorService pool = Executors.newFixedThreadPool(numThreads); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < numThreads; i++) { + futures.add( + pool.submit( + () -> { + byte[] data = "Hello TestContainers".getBytes(StandardCharsets.UTF_8); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + return backend.extract(in, newRequest("test.txt", "text/plain", "text")); + } + })); + } + for (Future future : futures) { + ExtractionResult res = future.get(60, TimeUnit.SECONDS); + assertNotNull(res); + assertNotNull(res.getContent()); + assertTrue(res.getContent().contains("Hello TestContainers")); + } + } finally { + pool.shutdown(); + pool.awaitTermination(10, TimeUnit.SECONDS); + } + } + } } diff --git a/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerVersionCheckTest.java b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerVersionCheckTest.java new file mode 100644 index 000000000000..950e910249d0 --- /dev/null +++ b/solr/modules/extraction/src/test/org/apache/solr/handler/extraction/TikaServerVersionCheckTest.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.solr.handler.extraction; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.After; +import org.junit.Test; + +/** + * Verifies that {@link TikaServerExtractionBackend} rejects a TikaServer older than {@code 4.x} + * with a clear diagnostic, rather than failing later with confusing 404s or missing metadata. + * + *

Uses a tiny in-process {@link HttpServer} stub for the {@code /version} endpoint instead of a + * real Tika Server, since that's all this check depends on. + */ +public class TikaServerVersionCheckTest extends SolrTestCaseJ4 { + + private HttpServer server; + + @After + public void stopServer() { + if (server != null) { + server.stop(0); + server = null; + } + } + + private String startServerWithVersion(String versionResponseBody) throws Exception { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/version", exchange -> serveText(exchange, versionResponseBody)); + server.createContext( + "/tika/xml", + exchange -> + serveText( + exchange, + "" + + "hello world")); + server.start(); + return "http://localhost:" + server.getAddress().getPort(); + } + + private static void serveText(HttpExchange exchange, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (var os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private static ExtractionRequest newRequest() { + return ExtractionRequest.builder() + .streamType("text/plain") + .resourceName("test.txt") + .contentType("text/plain") + .streamName("test.txt") + .extractFormat("xml") + .build(); + } + + @Test + public void testRejectsPre4xTikaServer() throws Exception { + String baseUrl = startServerWithVersion("Apache Tika 3.2.3"); + try (TikaServerExtractionBackend backend = new TikaServerExtractionBackend(baseUrl)) { + ExtractionRequest request = newRequest(); + try (ByteArrayInputStream in = + new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))) { + SolrException e = expectThrows(SolrException.class, () -> backend.extract(in, request)); + assertEquals(SolrException.ErrorCode.SERVER_ERROR.code, e.code()); + assertTrue( + "Expected message to name the offending version and the minimum required, but was: " + + e.getMessage(), + e.getMessage().contains("Apache Tika 3.") && e.getMessage().contains("requires")); + } + } + } + + @Test + public void testAcceptsSupportedTikaServerVersion() throws Exception { + String baseUrl = startServerWithVersion("Apache Tika 4.0.0"); + try (TikaServerExtractionBackend backend = new TikaServerExtractionBackend(baseUrl)) { + ExtractionRequest request = newRequest(); + try (ByteArrayInputStream in = + new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))) { + ExtractionResult result = backend.extract(in, request); + assertNotNull(result); + assertTrue(result.getContent().contains("hello world")); + } + } + } +} diff --git a/solr/packaging/test/test_extraction.bats b/solr/packaging/test/test_extraction.bats index 4b8e62ae59cc..0823377452c6 100644 --- a/solr/packaging/test/test_extraction.bats +++ b/solr/packaging/test/test_extraction.bats @@ -40,7 +40,7 @@ apply_extract_handler() { setup_file() { if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then export TIKA_PORT=$((SOLR_PORT+5)) - docker run --rm -p ${TIKA_PORT}:9998 --name bats_tika -d apache/tika:3.2.3.0-full >/dev/null 2>&1 || true + docker run --rm -p ${TIKA_PORT}:9998 --name bats_tika -d apache/tika:4.0.0-full >/dev/null 2>&1 || true echo "Waiting for Tika Server to be ready on port ${TIKA_PORT}" >&3 if ! wait_for 120 3 curl -s -f "http://localhost:${TIKA_PORT}/tika" -o /dev/null; then export DOCKER_UNAVAILABLE=1 diff --git a/solr/solr-ref-guide/modules/getting-started/pages/tutorial-diy.adoc b/solr/solr-ref-guide/modules/getting-started/pages/tutorial-diy.adoc index db34f483b69f..d58038d509e3 100644 --- a/solr/solr-ref-guide/modules/getting-started/pages/tutorial-diy.adoc +++ b/solr/solr-ref-guide/modules/getting-started/pages/tutorial-diy.adoc @@ -39,7 +39,7 @@ Indexing binary files (PDF, DOCX, PPTX, etc.) with the Post Tool requires the So .Start a Tika Server quickly using Docker (exposes port 9998 on localhost) [,bash] ---- -docker run --rm -p 9998:9998 --name tika -d apache/tika:3.2.3.0-full +docker run --rm -p 9998:9998 --name tika -d apache/tika:4.0.0-full ---- === Start Solr with the extraction module enabled diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc index bc9e1905ec61..b415f259cb1e 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/indexing-with-tika.adoc @@ -78,7 +78,7 @@ The quickest way to run Tika Server for development is using Docker. The example [,bash] ---- -docker run --rm -p 9998:9998 --name tika -d apache/tika:3.2.3.0-full +docker run --rm -p 9998:9998 --name tika -d apache/tika:4.0.0-full ---- NOTE: If Solr runs in Docker too, ensure both containers share a network and use the Tika container name as the host in `tikaserver.url`. @@ -92,7 +92,7 @@ First we start a tika server on port 9998, using Docker. [source,bash] ---- # Start Tika Server in the background -docker run --rm -p 9998:9998 --name tika -d apache/tika:3.2.3.0-full +docker run --rm -p 9998:9998 --name tika -d apache/tika:4.0.0-full # To stop the server when done, run `docker stop tika` ---- @@ -384,6 +384,18 @@ Example: `passwordsFile=/path/to/passwords.txt` // + // Only applicable for `tikaserver` backend. Can only be set in `solrconfig.xml`, not per request. +`tikaserver.config`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: none +|=== ++ +A raw JSON object sent as the per-request parser configuration for Tika Server (e.g., `{"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}`). +See <> below for details and an important security note: this requires `allowPerRequestConfig=true` on the Tika Server, which is off by default. ++ +Example: `tikaserver.config={"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}` + `tikaserver.maxChars`:: + [%autowidth,frame=none] @@ -540,9 +552,33 @@ So you can use the other URPs without worrying about unexpected field additions. === Parser-Specific Properties -Parser-specific properties for Tika must be configured directly on your Tika Server instance. Consult the https://tika.apache.org/[Apache Tika documentation] for details. +Server-wide parser properties (things that should apply to every request) must be configured directly on your Tika Server instance, via its own JSON configuration file. Consult the https://tika.apache.org/[Apache Tika documentation] for details. + +For a single request, you can instead pass parser-specific options through Solr using the `tikaserver.config` parameter, whose value is a raw JSON object matching Tika Server's per-request configuration format. +For example, to disable OCR for one request: + +[,console] +---- +$ bin/solr post -c gettingstarted example/exampledocs/solr-word.pdf --params 'literal.id=doc1&tikaserver.config={"pdf-parser":{"ocr":{"strategy":"NO_OCR"}}}' +---- + +[IMPORTANT] +==== +`tikaserver.config` requires your Tika Server to be started with `allowPerRequestConfig: true` in its own JSON configuration (under the `server` section). +This is off by default, and Tika Server logs a warning when it is enabled, because it lets any client that can reach `/update/extract` inject arbitrary parser configuration, including options that spawn external processes such as OCR. +Only enable it if you need per-request configuration, and treat access to your Solr instance's extraction endpoint accordingly. + +[source,json] +---- +{ + "server": { + "allowPerRequestConfig": true + } +} +---- +==== -NOTE: In earlier versions of Solr Cell you could supply Tika configuration directly to Solr. This is no longer possible. +`tikaserver.config` is combined with any password resolved from `resource.password` or `passwordsFile` (see <>) into a single request to Tika Server, and is only supported for non-recursive extraction (`tikaserver.recursive=false`, the default); Tika Server has no way to accept per-request configuration for recursive extraction while also returning the XHTML content Solr Cell needs (tracked upstream as https://issues.apache.org/jira/browse/TIKA-4881[TIKA-4881]). === Indexing Encrypted Documents