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.api.engine;
17  
18  import java.io.IOException;
19  import java.io.InputStream;
20  import java.io.OutputStream;
21  import java.nio.file.Files;
22  import java.nio.file.Path;
23  import java.util.Locale;
24  import java.util.UUID;
25  
26  import org.apache.catalina.connector.ClientAbortException;
27  import org.apache.logging.log4j.LogManager;
28  import org.apache.logging.log4j.Logger;
29  import org.codelibs.core.io.CopyUtil;
30  import org.codelibs.core.lang.StringUtil;
31  import org.codelibs.curl.Curl.Method;
32  import org.codelibs.curl.CurlRequest;
33  import org.codelibs.curl.CurlResponse;
34  import org.codelibs.fess.Constants;
35  import org.codelibs.fess.api.BaseApiManager;
36  import org.codelibs.fess.exception.FessSystemException;
37  import org.codelibs.fess.exception.WebApiException;
38  import org.codelibs.fess.mylasta.action.FessUserBean;
39  import org.codelibs.fess.util.ComponentUtil;
40  import org.codelibs.fess.util.ResourceUtil;
41  import org.lastaflute.web.servlet.request.RequestManager;
42  import org.lastaflute.web.servlet.session.SessionManager;
43  
44  import jakarta.annotation.PostConstruct;
45  import jakarta.servlet.FilterChain;
46  import jakarta.servlet.ServletException;
47  import jakarta.servlet.ServletInputStream;
48  import jakarta.servlet.ServletOutputStream;
49  import jakarta.servlet.http.HttpServletRequest;
50  import jakarta.servlet.http.HttpServletResponse;
51  
52  /**
53   * API manager for search engine administrative operations.
54   * Provides secure access to search engine APIs through authentication and token-based authorization.
55   */
56  public class SearchEngineApiManager extends BaseApiManager {
57      private static final String ADMIN_SERVER = "/admin/server_";
58  
59      private static final Logger logger = LogManager.getLogger(SearchEngineApiManager.class);
60  
61      /** Roles that are allowed to access the search engine API */
62      protected String[] acceptedRoles = { "admin" };
63  
64      /**
65       * Default constructor.
66       * Initializes the API manager with the admin server path prefix.
67       */
68      public SearchEngineApiManager() {
69          super();
70          setPathPrefix(ADMIN_SERVER);
71      }
72  
73      /**
74       * Registers this API manager with the web API manager factory.
75       * Called automatically after construction via @PostConstruct.
76       */
77      @PostConstruct
78      public void register() {
79          if (logger.isInfoEnabled()) {
80              logger.info("Loaded {}", this.getClass().getSimpleName());
81          }
82          ComponentUtil.getWebApiManagerFactory().add(this);
83      }
84  
85      @Override
86      public boolean matches(final HttpServletRequest request) {
87          final String servletPath = request.getServletPath();
88          return servletPath.startsWith(pathPrefix);
89      }
90  
91      @Override
92      public void process(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)
93              throws IOException, ServletException {
94          final RequestManager requestManager = ComponentUtil.getRequestManager();
95          if (!requestManager.findUserBean(FessUserBean.class).map(user -> user.hasRoles(acceptedRoles)).orElse(Boolean.FALSE)) {
96              response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized access: " + request.getServletPath());
97              return;
98          }
99  
100         try {
101             getSessionManager().getAttribute(Constants.SEARCH_ENGINE_API_ACCESS_TOKEN, String.class).ifPresent(token -> {
102                 final String servletPath = request.getServletPath();
103                 final String pathPrefix = ADMIN_SERVER + token;
104                 if (!servletPath.startsWith(pathPrefix)) {
105                     throw new WebApiException(HttpServletResponse.SC_FORBIDDEN, "Invalid access token.");
106                 }
107                 final String path;
108                 final String value = servletPath.substring(pathPrefix.length());
109                 if (!value.startsWith("/")) {
110                     path = "/" + value;
111                 } else {
112                     path = value;
113                 }
114                 processRequest(request, response, path);
115             }).orElse(() -> {
116                 throw new WebApiException(HttpServletResponse.SC_FORBIDDEN, "Invalid session.");
117             });
118         } catch (final WebApiException e) {
119             final int statusCode = e.getStatusCode();
120             String message;
121             if (Constants.TRUE.equalsIgnoreCase(ComponentUtil.getFessConfig().getApiJsonResponseExceptionIncluded())) {
122                 if (statusCode >= 400 && statusCode < 500) {
123                     if (logger.isDebugEnabled()) {
124                         logger.debug("Failed to access Web API.", e);
125                     }
126                 } else {
127                     logger.warn("Failed to access Web API.", e);
128                 }
129                 message = e.getMessage();
130             } else {
131                 final String errorCode = UUID.randomUUID().toString();
132                 message = "[" + errorCode + "] Failed to access to Web API.";
133                 if (statusCode >= 400 && statusCode < 500) {
134                     if (logger.isDebugEnabled()) {
135                         logger.debug(message, e);
136                     }
137                 } else {
138                     logger.warn(message, e);
139                 }
140             }
141             response.sendError(statusCode, message);
142         }
143     }
144 
145     /**
146      * Processes API requests to the search engine.
147      * Handles both regular API calls and plugin requests.
148      *
149      * @param request  the HTTP servlet request
150      * @param response the HTTP servlet response
151      * @param path     the request path after removing the prefix
152      */
153     protected void processRequest(final HttpServletRequest request, final HttpServletResponse response, final String path) {
154         if ("/_plugin".equals(path) || path.startsWith("/_plugin/")) {
155             processPluginRequest(request, response, path.replaceFirst("^/_plugin", StringUtil.EMPTY));
156             return;
157         }
158 
159         final Method httpMethod = Method.valueOf(request.getMethod().toUpperCase(Locale.ROOT));
160         final CurlRequest curlRequest = ComponentUtil.getCurlHelper().request(httpMethod, path);
161 
162         final String contentType = request.getHeader("Content-Type");
163         if (StringUtil.isNotEmpty(contentType)) {
164             curlRequest.header("Content-Type", contentType);
165         }
166 
167         request.getParameterMap().entrySet().stream().forEach(entry -> {
168             if (entry.getValue().length > 1) {
169                 curlRequest.param(entry.getKey(), String.join(",", entry.getValue()));
170             } else if (entry.getValue().length == 1) {
171                 curlRequest.param(entry.getKey(), entry.getValue()[0]);
172             }
173         });
174         try (final CurlResponse curlResponse = curlRequest.onConnect((req, con) -> {
175             con.setDoOutput(true);
176             if (httpMethod != Method.GET && request.getContentLength() > 2) {
177                 try (ServletInputStream in = request.getInputStream(); OutputStream out = con.getOutputStream()) {
178                     CopyUtil.copy(in, out);
179                 } catch (final IOException e) {
180                     throw new WebApiException(HttpServletResponse.SC_BAD_REQUEST, e);
181                 }
182             }
183         }).execute()) {
184 
185             try (ServletOutputStream out = response.getOutputStream(); InputStream in = curlResponse.getContentAsStream()) {
186                 response.setStatus(curlResponse.getHttpStatusCode());
187                 writeHeaders(response);
188                 final String responseContentType = curlResponse.getHeaderValue("Content-Type");
189                 if (StringUtil.isBlank(responseContentType)) {
190                     response.setHeader("Content-Type", "application/json");
191                 } else {
192                     response.setHeader("Content-Type", responseContentType);
193                 }
194                 CopyUtil.copy(in, out);
195             } catch (final ClientAbortException e) {
196                 logger.debug("Client aborts this request.", e);
197             }
198         } catch (final Exception e) {
199             if (!(e.getCause() instanceof ClientAbortException)) {
200                 throw new WebApiException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e);
201             }
202             logger.debug("Client aborts this request.", e);
203         }
204     }
205 
206     /**
207      * Processes requests for plugin resources (static files).
208      * Sets appropriate content types and serves files from the resource path.
209      *
210      * @param request  the HTTP servlet request
211      * @param response the HTTP servlet response
212      * @param path     the plugin resource path
213      */
214     protected void processPluginRequest(final HttpServletRequest request, final HttpServletResponse response, final String path) {
215         if (StringUtil.isNotBlank(path)) {
216             final String lowerPath = path.toLowerCase(Locale.ROOT);
217             if (lowerPath.endsWith(".html")) {
218                 response.setContentType("text/html;charset=utf-8");
219             } else if (lowerPath.endsWith(".css")) {
220                 response.setContentType("text/css");
221             } else if (lowerPath.endsWith(".eot")) {
222                 response.setContentType("application/vnd.ms-fontobject");
223             } else if (lowerPath.endsWith(".ico")) {
224                 response.setContentType("image/vnd.microsoft.icon");
225             } else if (lowerPath.endsWith(".js")) {
226                 response.setContentType("text/javascript");
227             } else if (lowerPath.endsWith(".json")) {
228                 response.setContentType("application/json");
229             } else if (lowerPath.endsWith(".otf")) {
230                 response.setContentType("font/otf");
231             } else if (lowerPath.endsWith(".svg")) {
232                 response.setContentType("image/svg+xml");
233             } else if (lowerPath.endsWith(".ttf")) {
234                 response.setContentType("font/ttf");
235             } else if (lowerPath.endsWith(".txt")) {
236                 response.setContentType("text/plain");
237             } else if (lowerPath.endsWith(".woff")) {
238                 response.setContentType("font/woff");
239             } else if (lowerPath.endsWith(".woff2")) {
240                 response.setContentType("font/woff2");
241             } else if (lowerPath.endsWith("/")) {
242                 response.setContentType("text/html;charset=utf-8");
243             }
244         }
245 
246         Path filePath = ResourceUtil.getSitePath(path.replaceAll("\\.\\.+", StringUtil.EMPTY).replaceAll("/+", "/").split("/"));
247         if (Files.isDirectory(filePath)) {
248             filePath = filePath.resolve("index.html");
249         }
250         if (Files.exists(filePath)) {
251             try (InputStream in = Files.newInputStream(filePath); ServletOutputStream out = response.getOutputStream()) {
252                 response.setStatus(HttpServletResponse.SC_OK);
253                 writeHeaders(response);
254                 CopyUtil.copy(in, out);
255             } catch (final ClientAbortException e) {
256                 logger.debug("Client aborts this request.", e);
257             } catch (final IOException e) {
258                 logger.error("Failed to read file: path={}, filePath={}", path, filePath);
259                 throw new WebApiException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e);
260             }
261         } else {
262             try {
263                 writeHeaders(response);
264                 response.sendError(HttpServletResponse.SC_NOT_FOUND, path + " is not found.");
265             } catch (final ClientAbortException e) {
266                 logger.debug("Client aborts this request.", e);
267             } catch (final IOException e) {
268                 logger.error("Failed to read file: path={}, filePath={}", path, filePath);
269                 throw new WebApiException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e);
270             }
271         }
272     }
273 
274     /**
275      * Sets the roles that are allowed to access the search engine API.
276      *
277      * @param acceptedRoles array of role names that can access the API
278      */
279     public void setAcceptedRoles(final String[] acceptedRoles) {
280         this.acceptedRoles = acceptedRoles;
281     }
282 
283     /**
284      * Gets the server path with access token for API requests.
285      *
286      * @return the complete server path including the access token
287      * @throws FessSystemException if no access token is available
288      */
289     public String getServerPath() {
290         return getSessionManager().getAttribute(Constants.SEARCH_ENGINE_API_ACCESS_TOKEN, String.class)
291                 .map(token -> ADMIN_SERVER + token)
292                 .orElseThrow(() -> new FessSystemException("Cannot create an access token."));
293     }
294 
295     /**
296      * Generates and saves a new access token for the current session.
297      * The token is used to authenticate API requests.
298      */
299     public void saveToken() {
300         getSessionManager().setAttribute(Constants.SEARCH_ENGINE_API_ACCESS_TOKEN, UUID.randomUUID().toString().replace("-", ""));
301     }
302 
303     private SessionManager getSessionManager() {
304         return ComponentUtil.getComponent(SessionManager.class);
305     }
306 
307     @Override
308     protected void writeHeaders(final HttpServletResponse response) {
309         ComponentUtil.getFessConfig().getApiDashboardResponseHeaderList().forEach(e -> response.setHeader(e.getFirst(), e.getSecond()));
310     }
311 }