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.app.web.admin.design;
17  
18  import java.io.File;
19  import java.io.FileInputStream;
20  import java.io.IOException;
21  import java.io.UnsupportedEncodingException;
22  import java.net.URLDecoder;
23  import java.util.ArrayList;
24  import java.util.List;
25  import java.util.Locale;
26  
27  import org.apache.commons.io.FileUtils;
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.codelibs.core.io.FileUtil;
31  import org.codelibs.core.io.ResourceUtil;
32  import org.codelibs.core.lang.StringUtil;
33  import org.codelibs.core.misc.Pair;
34  import org.codelibs.fess.Constants;
35  import org.codelibs.fess.annotation.Secured;
36  import org.codelibs.fess.app.web.base.FessAdminAction;
37  import org.codelibs.fess.exception.FessSystemException;
38  import org.codelibs.fess.util.ComponentUtil;
39  import org.dbflute.optional.OptionalEntity;
40  import org.lastaflute.web.Execute;
41  import org.lastaflute.web.response.HtmlResponse;
42  import org.lastaflute.web.response.StreamResponse;
43  import org.lastaflute.web.ruts.process.ActionRuntime;
44  
45  /**
46   * Admin action for Design management.
47   *
48   */
49  public class AdminDesignAction extends FessAdminAction {
50  
51      /**
52       * Default constructor.
53       */
54      public AdminDesignAction() {
55          super();
56      }
57  
58      private static final String CACHE_AND_SESSION_INVALIDATE_STATEMENT = "<!--CACHE_AND_SESSION_INVALIDATE-->";
59  
60      private static final String TRY_STATEMENT = "<!--TRY-->";
61  
62      /** The role for this action. */
63      public static final String ROLE = "admin-design";
64  
65      private static final Logger logger = LogManager.getLogger(AdminDesignAction.class);
66  
67      // ===================================================================================
68      //                                                                           Attribute
69      //                                                                           =========
70  
71      // ===================================================================================
72      //                                                                               Hook
73      //                                                                              ======
74  
75      @Override
76      protected void setupHtmlData(final ActionRuntime runtime) {
77          super.setupHtmlData(runtime);
78          runtime.registerData("fileNameItems", loadFileNameItems());
79          runtime.registerData("jspFileNameItems", loadJspFileNameItems());
80          runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameDesign()));
81      }
82  
83      @Override
84      protected String getActionRole() {
85          return ROLE;
86      }
87  
88      private List<Pair<String, String>> loadJspFileNameItems() {
89          final List<Pair<String, String>> jspItems = new ArrayList<>();
90          for (final Pair<String, String> p : systemHelper.getDesignJspFileNames()) {
91              jspItems.add(new Pair<>(":" + p.getFirst(), "/" + p.getSecond()));
92          }
93          for (String key : ComponentUtil.getVirtualHostHelper().getVirtualHostPaths()) {
94              if (StringUtil.isBlank(key)) {
95                  key = "/";
96              }
97              for (final Pair<String, String> p : systemHelper.getDesignJspFileNames()) {
98                  jspItems.add(new Pair<>(key + ":" + p.getFirst(), key + "/" + p.getSecond()));
99              }
100         }
101         return jspItems;
102     }
103 
104     private List<String> loadFileNameItems() {
105         final File baseDir = new File(getServletContext().getRealPath("/"));
106         final List<String> fileNameItems = new ArrayList<>();
107         final List<File> fileList = getAccessibleFileList(baseDir);
108         final int length = baseDir.getAbsolutePath().length();
109         for (final File file : fileList) {
110             fileNameItems.add(file.getAbsolutePath().substring(length));
111         }
112         return fileNameItems;
113     }
114 
115     // ===================================================================================
116     //                                                                             Execute
117     //                                                                             =======
118     /**
119      * Show the index page.
120      * @return The HTML response.
121      */
122     @Execute
123     @Secured({ ROLE, ROLE + VIEW })
124     public HtmlResponse index() {
125         saveToken();
126         return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
127     }
128 
129     /**
130      * Go back to the index page.
131      * @return The HTML response.
132      */
133     @Execute
134     @Secured({ ROLE, ROLE + VIEW })
135     public HtmlResponse back() {
136         saveToken();
137         return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
138     }
139 
140     /**
141      * Upload a design file.
142      * @param form The upload form.
143      * @return The HTML response.
144      */
145     @Execute
146     @Secured({ ROLE })
147     public HtmlResponse upload(final UploadForm form) {
148         validate(form, messages -> {}, () -> asListHtml(form));
149         verifyToken(this::asListHtml);
150         final String uploadedFileName = form.designFile.getFileName();
151         String fileName = form.designFileName;
152         if (StringUtil.isBlank(fileName)) {
153             fileName = uploadedFileName;
154             try {
155                 int pos = fileName.indexOf('/');
156                 if (pos >= 0) {
157                     fileName = fileName.substring(pos + 1);
158                 }
159                 pos = fileName.indexOf('\\');
160                 if (pos >= 0) {
161                     fileName = fileName.substring(pos + 1);
162                 }
163             } catch (final Exception e) {
164                 logger.warn("Failed to process a request.", e);
165                 throwValidationError(messages -> messages.addErrorsDesignFileNameIsInvalid("designFile"), this::asListHtml);
166             }
167         }
168         if (StringUtil.isBlank(fileName)) {
169             throwValidationError(messages -> messages.addErrorsDesignFileNameIsNotFound("designFile"), this::asListHtml);
170         }
171 
172         final File baseDir = new File(getServletContext().getRealPath("/"));
173         File uploadFile;
174         File expectedBaseDir;
175         // normalize filename
176         if (checkFileType(fileName, fessConfig.getSupportedUploadedMediaExtentionsAsArray())
177                 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedMediaExtentionsAsArray())) {
178             expectedBaseDir = new File(baseDir, "images");
179             uploadFile = new File(getServletContext().getRealPath("/images/" + fileName));
180         } else if (checkFileType(fileName, fessConfig.getSupportedUploadedCssExtentionsAsArray())
181                 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedCssExtentionsAsArray())) {
182             expectedBaseDir = new File(baseDir, "css");
183             uploadFile = new File(getServletContext().getRealPath("/css/" + fileName));
184         } else if (checkFileType(fileName, fessConfig.getSupportedUploadedJsExtentionsAsArray())
185                 && checkFileType(uploadedFileName, fessConfig.getSupportedUploadedJsExtentionsAsArray())) {
186             expectedBaseDir = new File(baseDir, "js");
187             uploadFile = new File(getServletContext().getRealPath("/js/" + fileName));
188         } else if (fessConfig.isSupportedUploadedFile(fileName) || fessConfig.isSupportedUploadedFile(uploadedFileName)) {
189             uploadFile = ResourceUtil.getResourceAsFileNoException(fileName);
190             if (uploadFile == null) {
191                 throwValidationError(messages -> messages.addErrorsDesignFileNameIsNotFound("designFileName"), this::asListHtml);
192                 return null;
193             }
194             expectedBaseDir = null; // Skip path traversal check for resource files
195         } else {
196             throwValidationError(messages -> messages.addErrorsDesignFileIsUnsupportedType("designFileName"), this::asListHtml);
197             return null;
198         }
199 
200         // Validate path to prevent path traversal attacks
201         if (expectedBaseDir != null && !isValidUploadPath(uploadFile, expectedBaseDir)) {
202             logger.warn("Path traversal attempt detected: fileName={}", fileName);
203             throwValidationError(messages -> messages.addErrorsDesignFileNameIsInvalid("designFileName"), this::asListHtml);
204             return null;
205         }
206 
207         final File parentFile = uploadFile.getParentFile();
208         if (!parentFile.exists() && !parentFile.mkdirs()) {
209             logger.warn("Could not create directory: {}", parentFile.getAbsolutePath());
210         }
211 
212         try {
213             write(uploadFile.getAbsolutePath(), form.designFile.getFileData());
214             final String currentFileName = fileName;
215             saveInfo(messages -> messages.addSuccessUploadDesignFile(GLOBAL, currentFileName));
216         } catch (final Exception e) {
217             logger.error("Failed to write an image file: {}", fileName, e);
218             throwValidationError(messages -> messages.addErrorsFailedToWriteDesignImageFile(GLOBAL), this::asListHtml);
219         }
220         return redirect(getClass());
221     }
222 
223     private boolean checkFileType(final String fileName, final String[] exts) {
224         if (fileName == null) {
225             return false;
226         }
227         final String lFileName = fileName.toLowerCase(Locale.ENGLISH);
228         for (final String ext : exts) {
229             if (lFileName.endsWith("." + ext)) {
230                 return true;
231             }
232         }
233         return false;
234     }
235 
236     /**
237      * Download a design file.
238      * @param form The file access form.
239      * @return The stream response.
240      */
241     @Execute
242     @Secured({ ROLE, ROLE + VIEW })
243     public StreamResponse download(final FileAccessForm form) {
244         final File file = getTargetFile(form.fileName).get();
245         if (file == null) {
246             throwValidationError(messages -> messages.addErrorsTargetFileDoesNotExist(GLOBAL, form.fileName), this::asListHtml);
247             return null;
248         }
249         validate(form, messages -> {}, this::asListHtml);
250         verifyTokenKeep(this::asListHtml);
251         return asStream(file.getName()).contentTypeOctetStream().stream(out -> {
252             try (FileInputStream fis = new FileInputStream(file)) {
253                 out.write(fis);
254             }
255         });
256     }
257 
258     /**
259      * Delete a design file.
260      * @param form The file access form.
261      * @return The HTML response.
262      */
263     @Execute
264     @Secured({ ROLE })
265     public HtmlResponse delete(final FileAccessForm form) {
266         getTargetFile(form.fileName).ifPresent(file -> {
267             if (!file.delete()) {
268                 logger.error("Failed to delete design file: {}", file.getAbsolutePath());
269                 throwValidationError(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, form.fileName), this::asListHtml);
270             }
271         }).orElse(() -> {
272             throwValidationError(messages -> messages.addErrorsTargetFileDoesNotExist(GLOBAL, form.fileName), this::asListHtml);
273         });
274         saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, form.fileName));
275         validate(form, messages -> {}, this::asListHtml);
276         verifyToken(this::asListHtml);
277         return redirect(getClass());
278     }
279 
280     // -----------------------------------------------------
281     //                                                 Edit
282     //                                                ------
283     /**
284      * Show the edit page.
285      * @param form The edit form.
286      * @return The HTML response.
287      */
288     @Execute
289     @Secured({ ROLE })
290     public HtmlResponse edit(final EditForm form) {
291         final String jspType = "view";
292         final File jspFile = getJspFile(form.fileName, jspType);
293         try {
294             form.content = encodeJsp(new String(FileUtil.readBytes(jspFile), Constants.UTF_8));
295         } catch (final UnsupportedEncodingException e) {
296             throw new FessSystemException("Invalid encoding: fileName=" + form.fileName, e);
297         }
298         saveToken();
299         return asEditHtml(form);
300     }
301 
302     /**
303      * Show the edit page with the default content.
304      * @param form The edit form.
305      * @return The HTML response.
306      */
307     @Execute
308     @Secured({ ROLE })
309     public HtmlResponse editAsUseDefault(final EditForm form) {
310         final String jspType = "orig/view";
311         final File jspFile = getJspFile(form.fileName, jspType);
312         try {
313             form.content = encodeJsp(new String(FileUtil.readBytes(jspFile), Constants.UTF_8));
314         } catch (final UnsupportedEncodingException e) {
315             throw new FessSystemException("Invalid encoding: fileName=" + form.fileName, e);
316         }
317         saveToken();
318         return asEditHtml(form);
319     }
320 
321     /**
322      * Update a design file.
323      * @param form The edit form.
324      * @return The HTML response.
325      */
326     @Execute
327     @Secured({ ROLE })
328     public HtmlResponse update(final EditForm form) {
329         final String jspType = "view";
330         final File jspFile = getJspFile(form.fileName, jspType);
331 
332         if (form.content == null) {
333             form.content = StringUtil.EMPTY;
334         }
335 
336         validate(form, messages -> {}, () -> asEditHtml(form));
337         verifyToken(() -> asEditHtml(form));
338         try {
339             write(jspFile.getAbsolutePath(), decodeJsp(form.content).getBytes(Constants.UTF_8));
340             saveInfo(messages -> messages.addSuccessUpdateDesignJspFile(GLOBAL, jspFile.getAbsolutePath()));
341         } catch (final Exception e) {
342             logger.warn("Failed to update {}", form.fileName, e);
343             throwValidationError(messages -> messages.addErrorsFailedToUpdateJspFile(GLOBAL), this::asListHtml);
344         }
345         return redirect(getClass());
346     }
347 
348     // ===================================================================================
349     //                                                                        Assist Logic
350     //                                                                        ============
351     private OptionalEntity<File> getTargetFile(final String fileName) {
352         final File baseDir = new File(getServletContext().getRealPath("/"));
353         final File targetFile = new File(getServletContext().getRealPath(fileName));
354         final List<File> fileList = getAccessibleFileList(baseDir);
355         for (final File file : fileList) {
356             if (targetFile.equals(file)) {
357                 return OptionalEntity.of(targetFile);
358             }
359         }
360         return OptionalEntity.empty();
361     }
362 
363     private List<File> getAccessibleFileList(final File baseDir) {
364         final List<File> fileList = new ArrayList<>(
365                 FileUtils.listFiles(new File(baseDir, "images"), fessConfig.getSupportedUploadedMediaExtentionsAsArray(), true));
366         fileList.addAll(FileUtils.listFiles(new File(baseDir, "css"), fessConfig.getSupportedUploadedCssExtentionsAsArray(), true));
367         fileList.addAll(FileUtils.listFiles(new File(baseDir, "js"), fessConfig.getSupportedUploadedJsExtentionsAsArray(), true));
368         return fileList;
369     }
370 
371     private boolean isValidUploadPath(final File file, final File baseDir) {
372         try {
373             final String canonicalFilePath = file.getCanonicalPath();
374             final String canonicalBasePath = baseDir.getCanonicalPath() + File.separator;
375             return canonicalFilePath.startsWith(canonicalBasePath);
376         } catch (final IOException e) {
377             logger.warn("Failed to validate upload path: file={}", file.getAbsolutePath(), e);
378             return false;
379         }
380     }
381 
382     private File getJspFile(final String fileName, final String jspType) {
383         try {
384             final String[] values = URLDecoder.decode(fileName, Constants.UTF_8).split(":");
385             if (values.length != 2) {
386                 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
387             }
388 
389             // Validate virtual host path to prevent path traversal
390             final String virtualHostPath = values[0];
391             if (!isValidVirtualHostPath(virtualHostPath)) {
392                 logger.warn("Invalid virtual host path detected: path={}", virtualHostPath);
393                 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
394             }
395 
396             final String jspFileName = systemHelper.getDesignJspFileName(values[1]);
397             if (jspFileName == null) {
398                 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
399             }
400             String path;
401             if ("view".equals(jspType)) {
402                 path = "/WEB-INF/" + jspType + virtualHostPath + "/" + jspFileName;
403             } else {
404                 path = "/WEB-INF/" + jspType + "/" + jspFileName;
405             }
406             final File jspFile = new File(getServletContext().getRealPath(path));
407 
408             // Validate canonical path to prevent path traversal
409             final File webInfDir = new File(getServletContext().getRealPath("/WEB-INF"));
410             if (!isValidUploadPath(jspFile, webInfDir)) {
411                 logger.warn("Path traversal attempt detected in JSP file path: path={}", path);
412                 throwValidationError(messages -> messages.addErrorsInvalidDesignJspFileName(GLOBAL), this::asListHtml);
413             }
414 
415             if (!jspFile.exists()) {
416                 throwValidationError(messages -> messages.addErrorsDesignJspFileDoesNotExist(GLOBAL), this::asListHtml);
417             }
418             return jspFile;
419         } catch (final UnsupportedEncodingException e) {
420             throw new FessSystemException("Failed to decode " + fileName, e);
421         }
422     }
423 
424     private boolean isValidVirtualHostPath(final String path) {
425         // Empty path is valid (default host)
426         if (StringUtil.isBlank(path)) {
427             return true;
428         }
429         // Path must match one of the configured virtual host paths
430         for (final String validPath : ComponentUtil.getVirtualHostHelper().getVirtualHostPaths()) {
431             if (path.equals(validPath)) {
432                 return true;
433             }
434         }
435         // Also allow "/" as a valid path (used in loadJspFileNameItems for blank keys)
436         return "/".equals(path);
437     }
438 
439     // ===================================================================================
440     //                                                                        Small Helper
441     //                                                                        ============
442 
443     private HtmlResponse asListHtml() {
444         return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class);
445     }
446 
447     private HtmlResponse asListHtml(final UploadForm uploadForm) {
448         return asHtml(path_AdminDesign_AdminDesignJsp).useForm(DesignForm.class, setup -> {
449             setup.setup(form -> {
450                 copyBeanToBean(uploadForm, form, op -> op.include("designFile", "designFileName"));
451             });
452         });
453     }
454 
455     private HtmlResponse asEditHtml(final EditForm form) {
456         return asHtml(path_AdminDesign_AdminDesignEditJsp).renderWith(data -> {
457             data.register("displayFileName", getJspFile(form.fileName, "view").getAbsolutePath());
458         });
459     }
460 
461     /**
462      * Decode the JSP content.
463      * @param value The value.
464      * @return The decoded value.
465      */
466     public static String decodeJsp(final String value) {
467         return value.replaceAll("<%(?![@-])([\\s\\S]*?)%>", "&lt;%$1%&gt;")
468                 .replaceAll("<%=([\\s\\S]*?)%>", "&lt;%=$1%&gt;")
469                 .replace(TRY_STATEMENT, "<% try{ %>")
470                 .replace(CACHE_AND_SESSION_INVALIDATE_STATEMENT, "<% }catch(Exception e){session.invalidate();} %>");
471     }
472 
473     /**
474      * Encode the JSP content.
475      * @param value The value.
476      * @return The encoded value.
477      */
478     public static String encodeJsp(final String value) {
479         return value.replace("<% try{ %>", TRY_STATEMENT)
480                 .replace("<% }catch(Exception e){session.invalidate();} %>", CACHE_AND_SESSION_INVALIDATE_STATEMENT);
481     }
482 }