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.storage;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  
20  import java.io.ByteArrayOutputStream;
21  import java.io.InputStream;
22  import java.net.URLEncoder;
23  import java.util.ArrayList;
24  import java.util.Base64;
25  import java.util.HashMap;
26  import java.util.List;
27  import java.util.Map;
28  
29  import org.apache.logging.log4j.LogManager;
30  import org.apache.logging.log4j.Logger;
31  import org.codelibs.core.lang.StringUtil;
32  import org.codelibs.fess.annotation.Secured;
33  import org.codelibs.fess.app.web.base.FessAdminAction;
34  import org.codelibs.fess.crawler.Constants;
35  import org.codelibs.fess.exception.StorageException;
36  import org.codelibs.fess.mylasta.direction.FessConfig;
37  import org.codelibs.fess.storage.StorageClient;
38  import org.codelibs.fess.storage.StorageClientFactory;
39  import org.codelibs.fess.storage.StorageItem;
40  import org.codelibs.fess.util.ComponentUtil;
41  import org.codelibs.fess.util.RenderDataUtil;
42  import org.dbflute.optional.OptionalThing;
43  import org.lastaflute.web.Execute;
44  import org.lastaflute.web.response.ActionResponse;
45  import org.lastaflute.web.response.HtmlResponse;
46  import org.lastaflute.web.response.StreamResponse;
47  import org.lastaflute.web.ruts.multipart.MultipartFormFile;
48  import org.lastaflute.web.ruts.process.ActionRuntime;
49  
50  /**
51   * Admin action for Storage management.
52   *
53   */
54  public class AdminStorageAction extends FessAdminAction {
55  
56      /**
57       * Default constructor.
58       */
59      public AdminStorageAction() {
60          super();
61      }
62  
63      /** Role name for admin storage operations */
64      public static final String ROLE = "admin-storage";
65  
66      private static final Logger logger = LogManager.getLogger(AdminStorageAction.class);
67  
68      @Override
69      protected void setupHtmlData(final ActionRuntime runtime) {
70          super.setupHtmlData(runtime);
71          runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameStorage()));
72      }
73  
74      @Override
75      protected String getActionRole() {
76          return ROLE;
77      }
78  
79      /**
80       * Displays the storage management index page.
81       *
82       * @return HTML response for the storage list page
83       */
84      @Execute
85      @Secured({ ROLE, ROLE + VIEW })
86      public HtmlResponse index() {
87          saveToken();
88          return asListHtml(StringUtil.EMPTY);
89      }
90  
91      /**
92       * Displays a list of files and directories in the specified path.
93       *
94       * @param id the encoded path ID to list (optional)
95       * @return action response with the storage list or redirect
96       */
97      @Execute
98      @Secured({ ROLE, ROLE + VIEW })
99      public ActionResponse list(final OptionalThing<String> id) {
100         saveToken();
101         return id.filter(StringUtil::isNotBlank).map(s -> asListHtml(decodePath(s))).orElse(redirect(getClass()));
102     }
103 
104     /**
105      * Uploads a file to the storage system.
106      *
107      * @param form the item form containing file and path information
108      * @return HTML response redirecting to the storage list after upload
109      */
110     @Execute
111     @Secured({ ROLE })
112     public HtmlResponse upload(final ItemForm form) {
113         validate(form, messages -> {}, () -> asListHtml(form.path));
114         if (form.uploadFile == null) {
115             throwValidationError(messages -> messages.addErrorsStorageNoUploadFile(GLOBAL), () -> asListHtml(form.path));
116         }
117         verifyToken(() -> asListHtml(form.path));
118         try {
119             uploadObject(getObjectName(form.path, form.uploadFile.getFileName()), form.uploadFile);
120         } catch (final StorageException e) {
121             logger.warn("Failed to upload {}", form.uploadFile.getFileName(), e);
122             throwValidationError(messages -> messages.addErrorsStorageFileUploadFailure(GLOBAL, form.uploadFile.getFileName()),
123                     () -> asListHtml(encodeId(form.path)));
124 
125         }
126         saveInfo(messages -> messages.addSuccessUploadFileToStorage(GLOBAL, form.uploadFile.getFileName()));
127         return redirectWith(getClass(), moreUrl("list/" + encodeId(form.path)));
128     }
129 
130     /**
131      * Downloads a file from the storage system.
132      *
133      * @param id the encoded ID of the file to download
134      * @return action response with the file stream for download
135      */
136     @Execute
137     @Secured({ ROLE, ROLE + VIEW })
138     public ActionResponse download(final String id) {
139         final PathInfo pi = convertToItem(id);
140         if (StringUtil.isEmpty(pi.getName())) {
141             throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(pi.getPath())));
142         }
143         final StreamResponse response = new StreamResponse(StringUtil.EMPTY);
144         final String name = pi.getName();
145         final String encodedName = URLEncoder.encode(name, Constants.UTF_8_CHARSET).replace("+", "%20");
146         response.header("Content-Disposition", "attachment; filename=\"" + name + "\"; filename*=utf-8''" + encodedName);
147         response.header("Pragma", "no-cache");
148         response.header("Cache-Control", "no-cache");
149         response.header("Expires", "Thu, 01 Dec 1994 16:00:00 GMT");
150         response.contentTypeOctetStream();
151         return response.stream(out -> {
152             try {
153                 downloadObject(getObjectName(pi.getPath(), pi.getName()), out);
154             } catch (final StorageException e) {
155                 logger.warn("Failed to download {}", pi.getName(), e);
156                 throwValidationError(messages -> messages.addErrorsStorageFileDownloadFailure(GLOBAL, pi.getName()),
157                         () -> asListHtml(encodeId(pi.getPath())));
158             }
159         });
160     }
161 
162     /**
163      * Deletes a file from the storage system.
164      *
165      * @param id the encoded ID of the file to delete
166      * @return HTML response redirecting to the storage list after deletion
167      */
168     @Execute
169     @Secured({ ROLE })
170     public HtmlResponse delete(final String id) {
171         final PathInfo pi = convertToItem(id);
172         if (StringUtil.isEmpty(pi.getName())) {
173             throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(pi.getPath())));
174         }
175         final String objectName = getObjectName(pi.getPath(), pi.getName());
176         try {
177             deleteObject(objectName);
178         } catch (final StorageException e) {
179             logger.warn("Failed to delete {}", pi.getName(), e);
180             throwValidationError(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, pi.getName()),
181                     () -> asListHtml(encodeId(pi.getPath())));
182         }
183         saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, pi.getName()));
184         return redirectWith(getClass(), moreUrl("list/" + encodeId(pi.getPath())));
185     }
186 
187     /**
188      * Creates a new directory in the storage system.
189      *
190      * @param form the item form containing directory information
191      * @return HTML response redirecting to the new directory
192      */
193     @Execute
194     @Secured({ ROLE })
195     public HtmlResponse createDir(final ItemForm form) {
196         validate(form, messages -> {}, () -> asListHtml(form.path));
197         if (StringUtil.isBlank(form.name)) {
198             throwValidationError(messages -> messages.addErrorsStorageDirectoryNameIsInvalid(GLOBAL), () -> asListHtml(form.path));
199         }
200         return redirectWith(getClass(), moreUrl("list/" + encodeId(getObjectName(form.path, form.name))));
201     }
202 
203     /**
204      * Displays the form for editing object tags.
205      *
206      * @param form the tag form containing object information
207      * @return HTML response for the tag editing form
208      */
209     @Execute
210     @Secured({ ROLE })
211     public HtmlResponse editTags(final TagForm form) {
212         validate(form, messages -> {}, () -> asEditTagsHtml(form.path, form.name));
213         saveToken();
214         return asEditTagsHtml(form.path, form.name);
215     }
216 
217     /**
218      * Updates the tags for a storage object.
219      *
220      * @param form the tag form containing updated tag information
221      * @return HTML response redirecting to the storage list after update
222      */
223     @Execute
224     @Secured({ ROLE })
225     public HtmlResponse updateTags(final TagForm form) {
226         validate(form, messages -> {}, () -> asEditTagsHtml(form.path, form.name));
227         final String objectName = getObjectName(form.path, form.name);
228         try {
229             updateObjectTags(objectName, form.tags);
230         } catch (final StorageException e) {
231             logger.warn("Failed to update tags in {}", form.path, e);
232             throwValidationError(messages -> messages.addErrorsStorageTagsUpdateFailure(GLOBAL, objectName),
233                     () -> asEditTagsHtml(form.path, form.name));
234         }
235         saveInfo(messages -> messages.addSuccessUpdateStorageTags(GLOBAL, objectName));
236         return redirectWith(getClass(), moreUrl("list/" + encodeId(form.path)));
237     }
238 
239     /**
240      * Updates the tags for a storage object in the storage system.
241      *
242      * @param objectName the name of the object to update tags for
243      * @param tagItems the map of tag items from the form
244      * @throws StorageException if the tag update fails
245      */
246     public static void updateObjectTags(final String objectName, final Map<String, String> tagItems) {
247         final Map<String, String> tags = new HashMap<>();
248         tagItems.keySet().stream().filter(s -> s.startsWith("name")).forEach(nameKey -> {
249             final String valueKey = nameKey.replace("name", "value");
250             final String name = tagItems.get(nameKey);
251             if (StringUtil.isNotBlank(name)) {
252                 tags.put(name, tagItems.get(valueKey));
253             }
254         });
255         if (logger.isDebugEnabled()) {
256             logger.debug("Tags updated: from={}, to={}", tagItems, tags);
257         }
258         try (StorageClient client = StorageClientFactory.createClient()) {
259             client.setObjectTags(objectName, tags);
260         } catch (final Exception e) {
261             throw new StorageException("Failed to update tags for " + objectName, e);
262         }
263     }
264 
265     /**
266      * Retrieves the tags for a storage object from the storage system.
267      *
268      * @param objectName the name of the object to get tags for
269      * @return map of tag key-value pairs
270      * @throws StorageException if retrieving tags fails
271      */
272     public static Map<String, String> getObjectTags(final String objectName) {
273         try (StorageClient client = StorageClientFactory.createClient()) {
274             return client.getObjectTags(objectName);
275         } catch (final Exception e) {
276             throw new StorageException("Failed to get tags from " + objectName, e);
277         }
278     }
279 
280     /**
281      * Uploads a file to the storage system.
282      *
283      * @param objectName the name for the object in storage
284      * @param uploadFile the multipart file to upload
285      * @throws StorageException if the upload fails
286      */
287     public static void uploadObject(final String objectName, final MultipartFormFile uploadFile) {
288         try (final InputStream in = uploadFile.getInputStream(); final StorageClient client = StorageClientFactory.createClient()) {
289             client.uploadObject(objectName, in, uploadFile.getFileSize(), "application/octet-stream");
290         } catch (final Exception e) {
291             throw new StorageException("Failed to upload " + objectName, e);
292         }
293     }
294 
295     /**
296      * Downloads an object from the storage system.
297      *
298      * @param objectName the name of the object to download
299      * @param out the output stream to write the object data to
300      * @throws StorageException if the download fails
301      */
302     public static void downloadObject(final String objectName, final org.lastaflute.web.servlet.request.stream.WrittenStreamOut out) {
303         try (final StorageClient client = StorageClientFactory.createClient()) {
304             final ByteArrayOutputStream baos = new ByteArrayOutputStream();
305             client.downloadObject(objectName, baos);
306             out.write(new java.io.ByteArrayInputStream(baos.toByteArray()));
307         } catch (final Exception e) {
308             throw new StorageException("Failed to download " + objectName, e);
309         }
310     }
311 
312     /**
313      * Deletes an object from the storage system.
314      *
315      * @param objectName the name of the object to delete
316      * @throws StorageException if the deletion fails
317      */
318     public static void deleteObject(final String objectName) {
319         try (final StorageClient client = StorageClientFactory.createClient()) {
320             client.deleteObject(objectName);
321         } catch (final Exception e) {
322             throw new StorageException("Failed to delete " + objectName, e);
323         }
324     }
325 
326     /**
327      * Retrieves a list of files and directories from the storage system.
328      *
329      * @param prefix the path prefix to list objects under
330      * @return list of file and directory information maps
331      */
332     public static List<Map<String, Object>> getFileItems(final String prefix) {
333         final FessConfig fessConfig = ComponentUtil.getFessConfig();
334         final List<Map<String, Object>> list = new ArrayList<>();
335         final List<Map<String, Object>> fileList = new ArrayList<>();
336 
337         try (final StorageClient client = StorageClientFactory.createClient(fessConfig)) {
338             // Ensure bucket exists on first access
339             client.ensureBucketExists();
340 
341             final List<StorageItem> items = client.listObjects(prefix, fessConfig.getStorageMaxItemsInPageAsInteger());
342 
343             for (final StorageItem item : items) {
344                 final Map<String, Object> map = new HashMap<>();
345                 map.put("id", item.getEncodedId());
346                 map.put("path", item.getPath());
347                 map.put("name", item.getName());
348                 map.put("hashCode", item.hashCode());
349                 map.put("size", item.getSize());
350                 map.put("directory", item.isDirectory());
351                 if (!item.isDirectory()) {
352                     map.put("lastModified", item.getLastModified());
353                     fileList.add(map);
354                 } else {
355                     list.add(map);
356                 }
357             }
358         } catch (final Exception e) {
359             if (logger.isDebugEnabled()) {
360                 logger.debug("Failed to access storage endpoint: {}", fessConfig.getStorageEndpoint(), e);
361             }
362         }
363 
364         list.addAll(fileList);
365         return list;
366     }
367 
368     /**
369      * Extracts the file name from a full object path.
370      *
371      * @param objectName the full object path
372      * @return the file name portion of the path
373      */
374     private static String getName(final String objectName) {
375         final String[] values = objectName.split("/");
376         if (values.length == 0) {
377             return StringUtil.EMPTY;
378         }
379         return values[values.length - 1];
380     }
381 
382     /**
383      * Decodes an encoded path ID back to the original path.
384      *
385      * @param id the encoded path ID
386      * @return the decoded path string
387      */
388     public static String decodePath(final String id) {
389         final PathInfo pi = convertToItem(id);
390         if (StringUtil.isEmpty(pi.getPath()) && StringUtil.isEmpty(pi.getName())) {
391             return StringUtil.EMPTY;
392         }
393         if (StringUtil.isEmpty(pi.getPath())) {
394             return pi.getName();
395         }
396         return pi.getPath() + "/" + pi.getName();
397     }
398 
399     /**
400      * Converts an encoded ID to a PathInfo object containing path and name.
401      *
402      * @param id the encoded ID to convert
403      * @return PathInfo object with separated path and name
404      */
405     public static PathInfo convertToItem(final String id) {
406         final String value = decodeId(id);
407         final String[] values = split(value, "/").get(stream -> stream.filter(StringUtil::isNotEmpty).toArray(n -> new String[n]));
408         if (values.length == 0) {
409             // invalid?
410             return new PathInfo(StringUtil.EMPTY, StringUtil.EMPTY);
411         }
412         if (values.length == 1) {
413             return new PathInfo(StringUtil.EMPTY, values[0]);
414         }
415         final StringBuilder buf = new StringBuilder();
416         for (int i = 0; i < values.length - 1; i++) {
417             if (buf.length() > 0) {
418                 buf.append('/');
419             }
420             buf.append(values[i]);
421         }
422         return new PathInfo(buf.toString(), values[values.length - 1]);
423     }
424 
425     /**
426      * Creates an encoded parent directory ID from a path prefix.
427      *
428      * @param prefix the current path prefix
429      * @return encoded parent directory ID, or empty string if at root
430      */
431     protected static String createParentId(final String prefix) {
432         if (prefix == null) {
433             return StringUtil.EMPTY;
434         }
435         final String[] values = prefix.split("/");
436         if (values.length > 1) {
437             final StringBuilder buf = new StringBuilder();
438             for (int i = 0; i < values.length - 1; i++) {
439                 if (buf.length() > 0) {
440                     buf.append('/');
441                 }
442                 buf.append(values[i]);
443             }
444             return encodeId(buf.toString());
445         }
446         return StringUtil.EMPTY;
447     }
448 
449     /**
450      * Creates a list of path navigation items for breadcrumb display.
451      *
452      * @param prefix the current path prefix
453      * @return list of path item maps for navigation
454      */
455     protected static List<Map<String, String>> createPathItems(final String prefix) {
456         final List<Map<String, String>> list = new ArrayList<>();
457         final StringBuilder buf = new StringBuilder();
458         split(prefix, "/").of(stream -> stream.filter(StringUtil::isNotEmpty).forEach(s -> {
459             if (buf.length() > 0) {
460                 buf.append('/');
461             }
462             buf.append(s);
463             final Map<String, String> map = new HashMap<>();
464             map.put("id", encodeId(buf.toString()));
465             map.put("name", s);
466             list.add(map);
467         }));
468         return list;
469     }
470 
471     /**
472      * Creates a path prefix with trailing slash if path is not empty.
473      *
474      * @param path the base path
475      * @return path with trailing slash or empty string
476      */
477     protected static String getPathPrefix(final String path) {
478         return StringUtil.isEmpty(path) ? StringUtil.EMPTY : path + "/";
479     }
480 
481     /**
482      * Combines path and name to create a full object name.
483      *
484      * @param path the directory path
485      * @param name the file or directory name
486      * @return the full object name
487      */
488     public static String getObjectName(final String path, final String name) {
489         return getPathPrefix(path) + name;
490     }
491 
492     /**
493      * Encodes an object name to a URL-safe base64 string.
494      *
495      * @param objectName the object name to encode
496      * @return base64 encoded string
497      */
498     protected static String encodeId(final String objectName) {
499         if (objectName == null) {
500             return StringUtil.EMPTY;
501         }
502         return new String(Base64.getUrlEncoder().encode(objectName.getBytes(Constants.UTF_8_CHARSET)), Constants.UTF_8_CHARSET);
503     }
504 
505     /**
506      * Decodes a base64 encoded ID back to the original object name.
507      *
508      * @param id the encoded ID to decode
509      * @return the decoded object name
510      */
511     protected static String decodeId(final String id) {
512         if (id == null) {
513             return StringUtil.EMPTY;
514         }
515         return new String(Base64.getUrlDecoder().decode(id.getBytes(Constants.UTF_8_CHARSET)), Constants.UTF_8_CHARSET);
516     }
517 
518     private HtmlResponse asListHtml(final String path) {
519         return asHtml(path_AdminStorage_AdminStorageJsp).useForm(ItemForm.class).renderWith(data -> {
520             RenderDataUtil.register(data, "endpoint", fessConfig.getStorageEndpoint());
521             RenderDataUtil.register(data, "bucket", fessConfig.getStorageBucket());
522             RenderDataUtil.register(data, "path", path);
523             RenderDataUtil.register(data, "pathItems", createPathItems(path));
524             RenderDataUtil.register(data, "parentId", createParentId(path));
525             RenderDataUtil.register(data, "fileItems", getFileItems(path));
526         });
527     }
528 
529     private HtmlResponse asEditTagsHtml(final String path, final String name) {
530         return asHtml(path_AdminStorage_AdminStorageTagEditJsp).renderWith(data -> {
531             RenderDataUtil.register(data, "endpoint", fessConfig.getStorageEndpoint());
532             RenderDataUtil.register(data, "bucket", fessConfig.getStorageBucket());
533             RenderDataUtil.register(data, "pathItems", createPathItems(path));
534             RenderDataUtil.register(data, "parentId", encodeId(path));
535             RenderDataUtil.register(data, "path", path);
536             RenderDataUtil.register(data, "name", name);
537             final Map<String, String> tags = new HashMap<>();
538             getObjectTags(getObjectName(path, name)).entrySet().forEach(e -> {
539                 final int index = tags.size() / 2 + 1;
540                 tags.put("name" + index, e.getKey());
541                 tags.put("value" + index, e.getValue());
542             });
543             RenderDataUtil.register(data, "savedTags", tags);
544         });
545     }
546 
547     /**
548      * Container class for path information containing separate path and name components.
549      */
550     public static class PathInfo {
551         private final String path;
552         private final String name;
553 
554         /**
555          * Creates a new PathInfo instance.
556          *
557          * @param path the directory path component
558          * @param name the file or directory name component
559          */
560         public PathInfo(final String path, final String name) {
561             this.path = path;
562             this.name = name;
563         }
564 
565         /**
566          * Gets the directory path component.
567          *
568          * @return the path component
569          */
570         public String getPath() {
571             return path;
572         }
573 
574         /**
575          * Gets the file or directory name component.
576          *
577          * @return the name component
578          */
579         public String getName() {
580             return name;
581         }
582     }
583 }