View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  
20  import java.io.ByteArrayInputStream;
21  import java.io.File;
22  import java.io.FileInputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.net.Proxy;
26  import java.nio.file.Files;
27  import java.nio.file.Path;
28  import java.nio.file.Paths;
29  import java.util.ArrayList;
30  import java.util.Collections;
31  import java.util.Comparator;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.concurrent.TimeUnit;
35  import java.util.regex.Matcher;
36  import java.util.regex.Pattern;
37  import java.util.stream.Collectors;
38  
39  import javax.xml.XMLConstants;
40  import javax.xml.parsers.DocumentBuilder;
41  import javax.xml.parsers.DocumentBuilderFactory;
42  
43  import org.apache.commons.lang3.StringUtils;
44  import org.apache.logging.log4j.LogManager;
45  import org.apache.logging.log4j.Logger;
46  import org.codelibs.core.io.CopyUtil;
47  import org.codelibs.core.lang.StringUtil;
48  import org.codelibs.curl.Curl;
49  import org.codelibs.curl.CurlRequest;
50  import org.codelibs.curl.CurlResponse;
51  import org.codelibs.fess.crawler.Constants;
52  import org.codelibs.fess.exception.PluginException;
53  import org.codelibs.fess.util.ComponentUtil;
54  import org.codelibs.fess.util.ResourceUtil;
55  import org.lastaflute.di.exception.IORuntimeException;
56  import org.w3c.dom.Document;
57  import org.w3c.dom.Node;
58  import org.w3c.dom.NodeList;
59  import org.xml.sax.SAXException;
60  
61  import com.fasterxml.jackson.databind.ObjectMapper;
62  import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
63  import com.google.common.cache.CacheBuilder;
64  import com.google.common.cache.CacheLoader;
65  import com.google.common.cache.LoadingCache;
66  
67  /**
68   * Helper class for managing Fess plugins and artifacts.
69   * This class provides functionality to discover, install, and manage various types of plugins
70   * including data stores, themes, ingest processors, scripts, web applications, thumbnails, and crawlers.
71   */
72  public class PluginHelper {
73      /** Logger instance for this class */
74      private static final Logger logger = LogManager.getLogger(PluginHelper.class);
75  
76      /**
77       * Cache for storing available artifacts by type.
78       * The cache expires after 5 minutes and has a maximum size of 10 entries.
79       */
80      protected LoadingCache<ArtifactType, Artifact[]> availableArtifacts = CacheBuilder.newBuilder()
81              .maximumSize(10)
82              .expireAfterWrite(5, TimeUnit.MINUTES)
83              .build(new CacheLoader<ArtifactType, Artifact[]>() {
84                  @Override
85                  public Artifact[] load(final ArtifactType key) {
86                      final List<Artifact> list = new ArrayList<>();
87                      for (final String url : getRepositories()) {
88                          if (url.endsWith(".yaml")) {
89                              if (key == ArtifactType.UNKNOWN) {
90                                  list.addAll(loadArtifactsFromRepository(url));
91                              }
92                          } else {
93                              list.addAll(processRepository(key, url));
94                          }
95                      }
96                      return list.toArray(new Artifact[list.size()]);
97                  }
98              });
99  
100     /**
101      * Default constructor for PluginHelper.
102      * Initializes the plugin helper with default settings.
103      */
104     public PluginHelper() {
105         // Default constructor
106     }
107 
108     /**
109      * Retrieves available artifacts of the specified type from configured repositories.
110      *
111      * @param artifactType the type of artifacts to retrieve
112      * @return an array of available artifacts
113      * @throws PluginException if failed to access the artifact repository
114      */
115     public Artifact[] getAvailableArtifacts(final ArtifactType artifactType) {
116         try {
117             return availableArtifacts.get(artifactType);
118         } catch (final Exception e) {
119             throw new PluginException("Failed to access " + artifactType, e);
120         }
121     }
122 
123     /**
124      * Gets the list of configured plugin repositories.
125      *
126      * @return an array of repository URLs
127      */
128     protected String[] getRepositories() {
129         return split(ComponentUtil.getFessConfig().getPluginRepositories(), ",")
130                 .get(stream -> stream.map(String::trim).toArray(n -> new String[n]));
131     }
132 
133     /**
134      * Loads artifacts from a YAML-based repository.
135      *
136      * @param url the URL of the YAML repository
137      * @return a list of artifacts loaded from the repository
138      * @throws PluginException if failed to parse the repository content
139      */
140     protected List<Artifact> loadArtifactsFromRepository(final String url) {
141         final String content = getRepositoryContent(url);
142         final ObjectMapper objectMapper = new YAMLMapper();
143         try {
144             @SuppressWarnings("unchecked")
145             final List<Map<?, ?>> result = objectMapper.readValue(content, List.class);
146             if (result != null) {
147                 return result.stream()
148                         .map(o -> new Artifact((String) o.get("name"), (String) o.get("version"), (String) o.get("url")))
149                         .collect(Collectors.toList());
150             }
151             return Collections.emptyList();
152         } catch (final Exception e) {
153             throw new PluginException("Failed to access " + url, e);
154         }
155     }
156 
157     /**
158      * Processes a Maven-style repository to extract artifacts of the specified type.
159      *
160      * @param artifactType the type of artifacts to process
161      * @param url the URL of the repository
162      * @return a list of artifacts found in the repository
163      */
164     protected List<Artifact> processRepository(final ArtifactType artifactType, final String url) {
165         final List<Artifact> list = new ArrayList<>();
166         final String repoContent = getRepositoryContent(url);
167         final Matcher matcher = Pattern.compile("href=\"[^\"]*(" + artifactType.getId() + "[a-zA-Z0-9\\-]+)/?\"").matcher(repoContent);
168         while (matcher.find()) {
169             final String name = matcher.group(1);
170             if (isExcludedName(artifactType, name)) {
171                 continue;
172             }
173             final String pluginUrl = url + (url.endsWith("/") ? name + "/" : "/" + name + "/");
174             try {
175                 final String pluginMetaContent = getRepositoryContent(pluginUrl + "maven-metadata.xml");
176                 try (final InputStream is = new ByteArrayInputStream(pluginMetaContent.getBytes(Constants.UTF_8_CHARSET))) {
177                     final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
178                     factory.setFeature(Constants.FEATURE_SECURE_PROCESSING, true);
179                     factory.setFeature(Constants.FEATURE_EXTERNAL_GENERAL_ENTITIES, false);
180                     factory.setFeature(Constants.FEATURE_EXTERNAL_PARAMETER_ENTITIES, false);
181                     factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, StringUtil.EMPTY);
182                     factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, StringUtil.EMPTY);
183                     final DocumentBuilder builder = factory.newDocumentBuilder();
184                     final Document document = builder.parse(is);
185                     final NodeList nodeList = document.getElementsByTagName("version");
186                     for (int i = 0; i < nodeList.getLength(); i++) {
187                         final String version = nodeList.item(i).getTextContent();
188                         if (isTargetPluginVersion(version)) {
189                             if (version.endsWith("SNAPSHOT")) {
190                                 final String snapshotVersion = getSnapshotActualVersion(builder, pluginUrl, version);
191                                 if (StringUtil.isNotBlank(snapshotVersion)) {
192                                     final String actualVersion = version.replace("SNAPSHOT", snapshotVersion);
193                                     list.add(new Artifact(name, actualVersion,
194                                             pluginUrl + version + "/" + name + "-" + actualVersion + ".jar"));
195                                 } else if (logger.isDebugEnabled()) {
196                                     logger.debug("Snapshot name not found: name={}, version={}", name, version);
197                                 }
198                             } else {
199                                 list.add(new Artifact(name, version, pluginUrl + version + "/" + name + "-" + version + ".jar"));
200                             }
201                         } else if (logger.isDebugEnabled()) {
202                             logger.debug("Artifact ignored: name={}, version={}", name, version);
203                         }
204                     }
205                 }
206             } catch (final Exception e) {
207                 logger.warn("Failed to parse maven-metadata.xml: url={}", pluginUrl, e);
208             }
209         }
210         return list;
211     }
212 
213     /**
214      * Checks if an artifact name should be excluded from the results.
215      *
216      * @param artifactType the type of the artifact
217      * @param name the name of the artifact
218      * @return true if the artifact should be excluded, false otherwise
219      */
220     protected boolean isExcludedName(final ArtifactType artifactType, final String name) {
221         if (artifactType != ArtifactType.CRAWLER) {
222             return false;
223         }
224 
225         if ("fess-crawler".equals(name)//
226                 || "fess-crawler-db".equals(name)//
227                 || "fess-crawler-db-h2".equals(name)//
228                 || "fess-crawler-db-mysql".equals(name)//
229                 || "fess-crawler-es".equals(name)//
230                 || "fess-crawler-opensearch".equals(name)//
231                 || "fess-crawler-lasta".equals(name)//
232                 || "fess-crawler-parent".equals(name)//
233                 || "fess-crawler-playwright".equals(name)//
234                 || "fess-crawler-webdriver".equals(name)) {
235             return true;
236         }
237 
238         return false;
239     }
240 
241     /**
242      * Checks if a plugin version is a target version for the current Fess installation.
243      *
244      * @param version the version to check
245      * @return true if the version is a target version, false otherwise
246      */
247     protected boolean isTargetPluginVersion(final String version) {
248         return ComponentUtil.getFessConfig().isTargetPluginVersion(version);
249     }
250 
251     /**
252      * Gets the actual version string for a SNAPSHOT artifact by parsing the snapshot metadata.
253      *
254      * @param builder the document builder to use for parsing XML
255      * @param pluginUrl the URL of the plugin
256      * @param version the snapshot version
257      * @return the actual version string with timestamp and build number, or null if not found
258      * @throws SAXException if XML parsing fails
259      * @throws IOException if I/O error occurs
260      */
261     protected String getSnapshotActualVersion(final DocumentBuilder builder, final String pluginUrl, final String version)
262             throws SAXException, IOException {
263         String timestamp = null;
264         String buildNumber = null;
265         final String versionMetaContent = getRepositoryContent(pluginUrl + version + "/maven-metadata.xml");
266         try (final InputStream is = new ByteArrayInputStream(versionMetaContent.getBytes(Constants.UTF_8_CHARSET))) {
267             final Document doc = builder.parse(is);
268             final NodeList snapshotNodeList = doc.getElementsByTagName("snapshot");
269             if (snapshotNodeList.getLength() > 0) {
270                 final NodeList nodeList = snapshotNodeList.item(0).getChildNodes();
271                 for (int i = 0; i < nodeList.getLength(); i++) {
272                     final Node node = nodeList.item(i);
273                     if ("timestamp".equalsIgnoreCase(node.getNodeName())) {
274                         timestamp = node.getTextContent();
275                     } else if ("buildNumber".equalsIgnoreCase(node.getNodeName())) {
276                         buildNumber = node.getTextContent();
277                     }
278                 }
279             }
280         }
281         if (StringUtil.isNotBlank(timestamp) && StringUtil.isNotBlank(buildNumber)) {
282             return timestamp + "-" + buildNumber;
283         }
284         return null;
285     }
286 
287     /**
288      * Retrieves the content of a repository URL.
289      *
290      * @param url the URL to retrieve content from
291      * @return the content as a string
292      * @throws IORuntimeException if I/O error occurs
293      */
294     protected String getRepositoryContent(final String url) {
295         if (logger.isDebugEnabled()) {
296             logger.debug("Loading: url={}", url);
297         }
298         try (final CurlResponse response = createCurlRequest(url).execute()) {
299             return response.getContentAsString();
300         } catch (final IOException e) {
301             throw new IORuntimeException(e);
302         }
303     }
304 
305     /**
306      * Gets the list of installed artifacts of the specified type.
307      *
308      * @param artifactType the type of artifacts to retrieve
309      * @return an array of installed artifacts
310      */
311     public Artifact[] getInstalledArtifacts(final ArtifactType artifactType) {
312         if (artifactType == ArtifactType.UNKNOWN) {
313             final File[] jarFiles = ResourceUtil.getPluginJarFiles((d, n) -> {
314                 for (final ArtifactType type : ArtifactType.values()) {
315                     if (n.startsWith(type.getId())) {
316                         return false;
317                     }
318                 }
319                 return n.endsWith(".jar");
320             });
321             final List<Artifact> list = new ArrayList<>(jarFiles.length);
322             for (final File file : jarFiles) {
323                 list.add(getArtifactFromFileName(artifactType, file.getName()));
324             }
325             list.sort(Comparator.comparing(Artifact::getName));
326             return list.toArray(new Artifact[list.size()]);
327         }
328 
329         final File[] jarFiles = ResourceUtil.getPluginJarFiles(artifactType.getId());
330         final List<Artifact> list = new ArrayList<>(jarFiles.length);
331         for (final File file : jarFiles) {
332             list.add(getArtifactFromFileName(artifactType, file.getName()));
333         }
334         list.sort(Comparator.comparing(Artifact::getName));
335         return list.toArray(new Artifact[list.size()]);
336     }
337 
338     /**
339      * Creates an artifact instance from a filename.
340      *
341      * @param artifactType the type of the artifact
342      * @param filename the filename to parse
343      * @return an artifact instance
344      */
345     protected Artifact getArtifactFromFileName(final ArtifactType artifactType, final String filename) {
346         return getArtifactFromFileName(artifactType, filename, null);
347     }
348 
349     /**
350      * Creates an artifact instance from a filename with a specified URL.
351      *
352      * @param artifactType the type of the artifact
353      * @param filename the filename to parse
354      * @param url the URL of the artifact
355      * @return an artifact instance
356      */
357     public Artifact getArtifactFromFileName(final ArtifactType artifactType, final String filename, final String url) {
358         final String baseName = StringUtils.removeEndIgnoreCase(filename, ".jar");
359         final List<String> nameList = new ArrayList<>();
360         final List<String> versionList = new ArrayList<>();
361         boolean isName = true;
362         for (final String value : baseName.split("-")) {
363             if (isName && value.length() > 0 && value.charAt(0) >= '0' && value.charAt(0) <= '9') {
364                 isName = false;
365             }
366             if (isName) {
367                 nameList.add(value);
368             } else {
369                 versionList.add(value);
370             }
371         }
372         return new Artifact(nameList.stream().collect(Collectors.joining("-")), versionList.stream().collect(Collectors.joining("-")), url);
373     }
374 
375     /**
376      * Installs an artifact based on its type.
377      *
378      * @param artifact the artifact to install
379      */
380     public void installArtifact(final Artifact artifact) {
381         switch (artifact.getType()) {
382         case THEME:
383             install(artifact);
384             ComponentUtil.getThemeHelper().install(artifact);
385             break;
386         default:
387             install(artifact);
388             break;
389         }
390     }
391 
392     /**
393      * Installs an artifact by downloading it from its URL.
394      *
395      * @param artifact the artifact to install
396      * @throws PluginException if installation fails
397      */
398     protected void install(final Artifact artifact) {
399         final String fileName = artifact.getFileName();
400         final String url = artifact.getUrl();
401         if (StringUtil.isBlank(url)) {
402             throw new PluginException("url is blank: " + artifact.getName());
403         }
404         if (url.startsWith("http:") || url.startsWith("https:")) {
405             try (final CurlResponse response = createCurlRequest(url).execute()) {
406                 if (response.getHttpStatusCode() != 200) {
407                     throw new PluginException("HTTP Status " + response.getHttpStatusCode() + " : failed to get the artifact from " + url);
408                 }
409                 try (final InputStream in = response.getContentAsStream()) {
410                     CopyUtil.copy(in, ResourceUtil.getPluginPath(fileName).toFile());
411                 }
412             } catch (final Exception e) {
413                 throw new PluginException("Failed to install the artifact " + artifact.getName(), e);
414             }
415         } else {
416             try (final InputStream in = new FileInputStream(url)) {
417                 CopyUtil.copy(in, ResourceUtil.getPluginPath(fileName).toFile());
418             } catch (final Exception e) {
419                 throw new PluginException("Failed to install the artifact " + artifact.getName(), e);
420             }
421         }
422     }
423 
424     /**
425      * Creates a CURL request for the specified URL with proxy configuration if available.
426      *
427      * @param url the URL to create a request for
428      * @return a configured CURL request
429      */
430     protected CurlRequest createCurlRequest(final String url) {
431         final CurlRequest request = Curl.get(url);
432         final Proxy proxy = ComponentUtil.getFessConfig().getHttpProxy();
433         if (proxy != null && !Proxy.NO_PROXY.equals(proxy)) {
434             request.proxy(proxy);
435         }
436         return request;
437     }
438 
439     /**
440      * Deletes an installed artifact.
441      *
442      * @param artifact the artifact to delete
443      * @throws PluginException if the artifact does not exist or deletion fails
444      */
445     public void deleteInstalledArtifact(final Artifact artifact) {
446         final String fileName = artifact.getFileName();
447         final Path jarPath = Paths.get(ResourceUtil.getPluginPath().toString(), fileName);
448         if (!Files.exists(jarPath)) {
449             throw new PluginException(fileName + " does not exist.");
450         }
451 
452         switch (artifact.getType()) {
453         case THEME:
454             ComponentUtil.getThemeHelper().uninstall(artifact);
455             uninstall(fileName, jarPath);
456             break;
457         default:
458             uninstall(fileName, jarPath);
459             break;
460         }
461 
462     }
463 
464     /**
465      * Uninstalls an artifact by deleting its JAR file.
466      *
467      * @param fileName the name of the file to delete
468      * @param jarPath the path to the JAR file
469      * @throws PluginException if deletion fails
470      */
471     protected void uninstall(final String fileName, final Path jarPath) {
472         try {
473             Files.delete(jarPath);
474         } catch (final IOException e) {
475             throw new PluginException("Failed to delete the artifact " + fileName, e);
476         }
477     }
478 
479     /**
480      * Gets an artifact by name and version from available artifacts.
481      *
482      * @param name the name of the artifact
483      * @param version the version of the artifact
484      * @return the artifact if found, null otherwise
485      */
486     public Artifact getArtifact(final String name, final String version) {
487         if (StringUtil.isBlank(name) || StringUtil.isBlank(version)) {
488             return null;
489         }
490         for (final Artifact artifact : getAvailableArtifacts(ArtifactType.getType(name))) {
491             if (name.equals(artifact.getName()) && version.equals(artifact.getVersion())) {
492                 return artifact;
493             }
494         }
495         return null;
496     }
497 
498     /**
499      * Represents a plugin artifact with name, version, and URL information.
500      */
501     public static class Artifact {
502         /** The name of the artifact */
503         protected final String name;
504         /** The version of the artifact */
505         protected final String version;
506         /** The URL where the artifact can be downloaded */
507         protected final String url;
508 
509         /**
510          * Creates a new artifact with name, version, and URL.
511          *
512          * @param name the name of the artifact
513          * @param version the version of the artifact
514          * @param url the URL where the artifact can be downloaded
515          */
516         public Artifact(final String name, final String version, final String url) {
517             this.name = name;
518             this.version = version;
519             this.url = url;
520         }
521 
522         /**
523          * Creates a new artifact with name and version, but no URL.
524          *
525          * @param name the name of the artifact
526          * @param version the version of the artifact
527          */
528         public Artifact(final String name, final String version) {
529             this(name, version, null);
530         }
531 
532         /**
533          * Gets the name of the artifact.
534          *
535          * @return the artifact name
536          */
537         public String getName() {
538             return name;
539         }
540 
541         /**
542          * Gets the version of the artifact.
543          *
544          * @return the artifact version
545          */
546         public String getVersion() {
547             return version;
548         }
549 
550         /**
551          * Gets the filename of the artifact JAR file.
552          *
553          * @return the filename in the format "name-version.jar"
554          */
555         public String getFileName() {
556             return name + "-" + version + ".jar";
557         }
558 
559         /**
560          * Gets the URL where the artifact can be downloaded.
561          *
562          * @return the artifact URL
563          */
564         public String getUrl() {
565             return url;
566         }
567 
568         /**
569          * Gets the type of the artifact based on its name.
570          *
571          * @return the artifact type
572          */
573         public ArtifactType getType() {
574             return ArtifactType.getType(name);
575         }
576 
577         /**
578          * Returns a string representation of the artifact.
579          *
580          * @return a string in the format "name:version"
581          */
582         @Override
583         public String toString() {
584             return name + ":" + version;
585         }
586     }
587 
588     /**
589      * Enumeration of different artifact types supported by Fess.
590      * Each type has a specific ID prefix used to identify artifacts of that type.
591      */
592     public enum ArtifactType {
593         /** Data store plugins */
594         DATA_STORE("fess-ds"), //
595         /** Theme plugins */
596         THEME("fess-theme"), //
597         /** Ingest processor plugins */
598         INGEST("fess-ingest"), //
599         /** Script plugins */
600         SCRIPT("fess-script"), //
601         /** Web application plugins */
602         WEBAPP("fess-webapp"), //
603         /** Thumbnail generator plugins */
604         THUMBNAIL("fess-thumbnail"), //
605         /** Crawler plugins */
606         CRAWLER("fess-crawler"), //
607         /** LLM plugins */
608         LLM("fess-llm"), //
609         /** Unknown/generic JAR files */
610         UNKNOWN("jar");
611 
612         /** The ID prefix for this artifact type */
613         private final String id;
614 
615         /**
616          * Creates a new artifact type with the specified ID.
617          *
618          * @param id the ID prefix for this artifact type
619          */
620         ArtifactType(final String id) {
621             this.id = id;
622         }
623 
624         /**
625          * Gets the ID prefix for this artifact type.
626          *
627          * @return the ID prefix
628          */
629         public String getId() {
630             return id;
631         }
632 
633         /**
634          * Determines the artifact type based on the artifact name.
635          *
636          * @param name the name of the artifact
637          * @return the corresponding artifact type, or UNKNOWN if no match is found
638          */
639         public static ArtifactType getType(final String name) {
640             if (name.startsWith(DATA_STORE.getId())) {
641                 return DATA_STORE;
642             }
643             if (name.startsWith(THEME.getId())) {
644                 return THEME;
645             }
646             if (name.startsWith(INGEST.getId())) {
647                 return INGEST;
648             }
649             if (name.startsWith(SCRIPT.getId())) {
650                 return SCRIPT;
651             }
652             if (name.startsWith(WEBAPP.getId())) {
653                 return WEBAPP;
654             }
655             if (name.startsWith(THUMBNAIL.getId())) {
656                 return THUMBNAIL;
657             }
658             if (name.startsWith(CRAWLER.getId())) {
659                 return CRAWLER;
660             }
661             if (name.startsWith(LLM.getId())) {
662                 return LLM;
663             }
664             return UNKNOWN;
665         }
666     }
667 
668 }