Kaynağa Gözat

fix #2657 tag support

Shinsuke Sugaya 3 yıl önce
ebeveyn
işleme
27e51af84e

+ 150 - 42
src/main/java/org/codelibs/fess/app/web/admin/storage/AdminStorageAction.java

@@ -21,6 +21,7 @@ import java.io.InputStream;
 import java.net.URLDecoder;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.net.URLEncoder;
 import java.util.ArrayList;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
@@ -45,12 +46,14 @@ import org.lastaflute.web.ruts.process.ActionRuntime;
 import org.lastaflute.web.servlet.request.stream.WrittenStreamOut;
 import org.lastaflute.web.servlet.request.stream.WrittenStreamOut;
 
 
 import io.minio.GetObjectArgs;
 import io.minio.GetObjectArgs;
+import io.minio.GetObjectTagsArgs;
 import io.minio.ListObjectsArgs;
 import io.minio.ListObjectsArgs;
 import io.minio.MakeBucketArgs;
 import io.minio.MakeBucketArgs;
 import io.minio.MinioClient;
 import io.minio.MinioClient;
 import io.minio.PutObjectArgs;
 import io.minio.PutObjectArgs;
 import io.minio.RemoveObjectArgs;
 import io.minio.RemoveObjectArgs;
 import io.minio.Result;
 import io.minio.Result;
+import io.minio.SetObjectTagsArgs;
 import io.minio.errors.ErrorResponseException;
 import io.minio.errors.ErrorResponseException;
 import io.minio.messages.Item;
 import io.minio.messages.Item;
 
 
@@ -85,10 +88,7 @@ public class AdminStorageAction extends FessAdminAction {
     @Secured({ ROLE, ROLE + VIEW })
     @Secured({ ROLE, ROLE + VIEW })
     public ActionResponse list(final OptionalThing<String> id) {
     public ActionResponse list(final OptionalThing<String> id) {
         saveToken();
         saveToken();
-        if (id.isPresent() && id.get() != null) {
-            return asListHtml(decodePath(id.get()));
-        }
-        return redirect(getClass());
+        return id.filter(StringUtil::isNotBlank).map(s -> asListHtml(decodePath(s))).orElse(redirect(getClass()));
     }
     }
 
 
     @Execute
     @Execute
@@ -116,12 +116,12 @@ public class AdminStorageAction extends FessAdminAction {
     @Execute
     @Execute
     @Secured({ ROLE, ROLE + VIEW })
     @Secured({ ROLE, ROLE + VIEW })
     public ActionResponse download(final String id) {
     public ActionResponse download(final String id) {
-        final String[] values = decodeId(id);
-        if (StringUtil.isEmpty(values[1])) {
-            throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(values[0])));
+        final PathInfo pi = convertToItem(id);
+        if (StringUtil.isEmpty(pi.getName())) {
+            throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(pi.getPath())));
         }
         }
         final StreamResponse response = new StreamResponse(StringUtil.EMPTY);
         final StreamResponse response = new StreamResponse(StringUtil.EMPTY);
-        final String name = values[1];
+        final String name = pi.getName();
         final String encodedName = URLEncoder.encode(name, Constants.UTF_8_CHARSET).replace("+", "%20");
         final String encodedName = URLEncoder.encode(name, Constants.UTF_8_CHARSET).replace("+", "%20");
         response.header("Content-Disposition", "attachment; filename=\"" + name + "\"; filename*=utf-8''" + encodedName);
         response.header("Content-Disposition", "attachment; filename=\"" + name + "\"; filename*=utf-8''" + encodedName);
         response.header("Pragma", "no-cache");
         response.header("Pragma", "no-cache");
@@ -130,13 +130,13 @@ public class AdminStorageAction extends FessAdminAction {
         response.contentTypeOctetStream();
         response.contentTypeOctetStream();
         return response.stream(out -> {
         return response.stream(out -> {
             try {
             try {
-                downloadObject(getObjectName(values[0], values[1]), out);
+                downloadObject(getObjectName(pi.getPath(), pi.getName()), out);
             } catch (final StorageException e) {
             } catch (final StorageException e) {
                 if (logger.isDebugEnabled()) {
                 if (logger.isDebugEnabled()) {
-                    logger.debug("Failed to download {}", values[1], e);
+                    logger.debug("Failed to download {}", pi.getName(), e);
                 }
                 }
-                throwValidationError(messages -> messages.addErrorsStorageFileDownloadFailure(GLOBAL, values[1]),
-                        () -> asListHtml(encodeId(values[0])));
+                throwValidationError(messages -> messages.addErrorsStorageFileDownloadFailure(GLOBAL, pi.getName()),
+                        () -> asListHtml(encodeId(pi.getPath())));
             }
             }
         });
         });
     }
     }
@@ -144,20 +144,20 @@ public class AdminStorageAction extends FessAdminAction {
     @Execute
     @Execute
     @Secured({ ROLE })
     @Secured({ ROLE })
     public HtmlResponse delete(final String id) {
     public HtmlResponse delete(final String id) {
-        final String[] values = decodeId(id);
-        if (StringUtil.isEmpty(values[1])) {
-            throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(values[0])));
+        final PathInfo pi = convertToItem(id);
+        if (StringUtil.isEmpty(pi.getName())) {
+            throwValidationError(messages -> messages.addErrorsStorageFileNotFound(GLOBAL), () -> asListHtml(encodeId(pi.getPath())));
         }
         }
-        final String objectName = getObjectName(values[0], values[1]);
+        final String objectName = getObjectName(pi.getPath(), pi.getName());
         try {
         try {
             deleteObject(objectName);
             deleteObject(objectName);
         } catch (final StorageException e) {
         } catch (final StorageException e) {
-            logger.debug("Failed to delete {}", values[1], e);
-            throwValidationError(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, values[1]),
-                    () -> asListHtml(encodeId(values[0])));
+            logger.debug("Failed to delete {}", pi.getName(), e);
+            throwValidationError(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, pi.getName()),
+                    () -> asListHtml(encodeId(pi.getPath())));
         }
         }
-        saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, values[1]));
-        return redirectWith(getClass(), moreUrl("list/" + encodeId(values[0])));
+        saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, pi.getName()));
+        return redirectWith(getClass(), moreUrl("list/" + encodeId(pi.getPath())));
     }
     }
 
 
     @Execute
     @Execute
@@ -170,6 +170,62 @@ public class AdminStorageAction extends FessAdminAction {
         return redirectWith(getClass(), moreUrl("list/" + encodeId(getObjectName(form.path, form.name))));
         return redirectWith(getClass(), moreUrl("list/" + encodeId(getObjectName(form.path, form.name))));
     }
     }
 
 
+    @Execute
+    @Secured({ ROLE })
+    public HtmlResponse editTags(final TagForm form) {
+        validate(form, messages -> {}, () -> asEditTagsHtml(form.path, form.name));
+        saveToken();
+        return asEditTagsHtml(form.path, form.name);
+    }
+
+    @Execute
+    @Secured({ ROLE })
+    public HtmlResponse updateTags(final TagForm form) {
+        validate(form, messages -> {}, () -> asEditTagsHtml(form.path, form.name));
+        final String objectName = getObjectName(form.path, form.name);
+        try {
+            updateObjectTags(objectName, form.tags);
+        } catch (final StorageException e) {
+            logger.debug("Failed to update tags in {}", form.path, e);
+            throwValidationError(messages -> messages.addErrorsStorageTagsUpdateFailure(GLOBAL, objectName),
+                    () -> asEditTagsHtml(form.path, form.name));
+        }
+        saveInfo(messages -> messages.addSuccessUpdateStorageTags(GLOBAL, objectName));
+        return redirectWith(getClass(), moreUrl("list/" + encodeId(form.path)));
+    }
+
+    public static void updateObjectTags(final String objectName, final Map<String, String> tagItems) {
+        final Map<String, String> tags = new HashMap<>();
+        tagItems.keySet().stream().filter(s -> s.startsWith("name")).forEach(nameKey -> {
+            final String valueKey = nameKey.replace("name", "value");
+            final String name = tagItems.get(nameKey);
+            if (StringUtil.isNotBlank(name)) {
+                tags.put(name, tagItems.get(valueKey));
+            }
+        });
+        if (logger.isDebugEnabled()) {
+            logger.debug("tags: {} -> {}", tagItems, tags);
+        }
+        try {
+            final FessConfig fessConfig = ComponentUtil.getFessConfig();
+            final SetObjectTagsArgs args =
+                    SetObjectTagsArgs.builder().bucket(fessConfig.getStorageBucket()).object(objectName).tags(tags).build();
+            createClient(fessConfig).setObjectTags(args);
+        } catch (final Exception e) {
+            throw new StorageException("Failed to update tags for " + objectName, e);
+        }
+    }
+
+    public static Map<String, String> getObjectTags(final String objectName) {
+        try {
+            final FessConfig fessConfig = ComponentUtil.getFessConfig();
+            final GetObjectTagsArgs args = GetObjectTagsArgs.builder().bucket(fessConfig.getStorageBucket()).object(objectName).build();
+            return createClient(fessConfig).getObjectTags(args).get();
+        } catch (final Exception e) {
+            throw new StorageException("Failed to get tags from " + objectName, e);
+        }
+    }
+
     public static void uploadObject(final String objectName, final MultipartFormFile uploadFile) {
     public static void uploadObject(final String objectName, final MultipartFormFile uploadFile) {
         try (final InputStream in = uploadFile.getInputStream()) {
         try (final InputStream in = uploadFile.getInputStream()) {
             final FessConfig fessConfig = ComponentUtil.getFessConfig();
             final FessConfig fessConfig = ComponentUtil.getFessConfig();
@@ -214,7 +270,8 @@ public class AdminStorageAction extends FessAdminAction {
 
 
     public static List<Map<String, Object>> getFileItems(final String prefix) {
     public static List<Map<String, Object>> getFileItems(final String prefix) {
         final FessConfig fessConfig = ComponentUtil.getFessConfig();
         final FessConfig fessConfig = ComponentUtil.getFessConfig();
-        final ArrayList<Map<String, Object>> list = new ArrayList<>();
+        final List<Map<String, Object>> list = new ArrayList<>();
+        final List<Map<String, Object>> fileList = new ArrayList<>();
         try {
         try {
             final MinioClient minioClient = createClient(fessConfig);
             final MinioClient minioClient = createClient(fessConfig);
             final ListObjectsArgs args = ListObjectsArgs.builder().bucket(fessConfig.getStorageBucket())
             final ListObjectsArgs args = ListObjectsArgs.builder().bucket(fessConfig.getStorageBucket())
@@ -225,15 +282,18 @@ public class AdminStorageAction extends FessAdminAction {
                 final Item item = result.get();
                 final Item item = result.get();
                 final String objectName = item.objectName();
                 final String objectName = item.objectName();
                 map.put("id", encodeId(objectName));
                 map.put("id", encodeId(objectName));
+                map.put("path", prefix);
                 map.put("name", getName(objectName));
                 map.put("name", getName(objectName));
                 map.put("hashCode", item.hashCode());
                 map.put("hashCode", item.hashCode());
                 map.put("size", item.size());
                 map.put("size", item.size());
                 map.put("directory", item.isDir());
                 map.put("directory", item.isDir());
                 if (!item.isDir()) {
                 if (!item.isDir()) {
                     map.put("lastModified", item.lastModified());
                     map.put("lastModified", item.lastModified());
+                    fileList.add(map);
+                } else {
+                    list.add(map);
                 }
                 }
-                list.add(map);
-                if (list.size() > fessConfig.getStorageMaxItemsInPageAsInteger()) {
+                if (list.size() + fileList.size() > fessConfig.getStorageMaxItemsInPageAsInteger()) {
                     break;
                     break;
                 }
                 }
             }
             }
@@ -256,6 +316,7 @@ public class AdminStorageAction extends FessAdminAction {
                 logger.debug("Failed to access {}", fessConfig.getStorageEndpoint(), e);
                 logger.debug("Failed to access {}", fessConfig.getStorageEndpoint(), e);
             }
             }
         }
         }
+        list.addAll(fileList);
         return list;
         return list;
     }
     }
 
 
@@ -268,25 +329,25 @@ public class AdminStorageAction extends FessAdminAction {
     }
     }
 
 
     public static String decodePath(final String id) {
     public static String decodePath(final String id) {
-        final String[] values = decodeId(id);
-        if (StringUtil.isEmpty(values[0]) && StringUtil.isEmpty(values[1])) {
+        final PathInfo pi = convertToItem(id);
+        if (StringUtil.isEmpty(pi.getPath()) && StringUtil.isEmpty(pi.getName())) {
             return StringUtil.EMPTY;
             return StringUtil.EMPTY;
         }
         }
-        if (StringUtil.isEmpty(values[0])) {
-            return values[1];
+        if (StringUtil.isEmpty(pi.getPath())) {
+            return pi.getName();
         }
         }
-        return values[0] + "/" + values[1];
+        return pi.getPath() + "/" + pi.getName();
     }
     }
 
 
-    public static String[] decodeId(final String id) {
-        final String value = urlDecode(urlDecode(id));
+    public static PathInfo convertToItem(final String id) {
+        final String value = decodeId(id);
         final String[] values = split(value, "/").get(stream -> stream.filter(StringUtil::isNotEmpty).toArray(n -> new String[n]));
         final String[] values = split(value, "/").get(stream -> stream.filter(StringUtil::isNotEmpty).toArray(n -> new String[n]));
         if (values.length == 0) {
         if (values.length == 0) {
             // invalid?
             // invalid?
-            return new String[] { StringUtil.EMPTY, StringUtil.EMPTY };
+            return new PathInfo(StringUtil.EMPTY, StringUtil.EMPTY);
         }
         }
         if (values.length == 1) {
         if (values.length == 1) {
-            return new String[] { StringUtil.EMPTY, values[0] };
+            return new PathInfo(StringUtil.EMPTY, values[0]);
         }
         }
         final StringBuilder buf = new StringBuilder();
         final StringBuilder buf = new StringBuilder();
         for (int i = 0; i < values.length - 1; i++) {
         for (int i = 0; i < values.length - 1; i++) {
@@ -295,7 +356,7 @@ public class AdminStorageAction extends FessAdminAction {
             }
             }
             buf.append(values[i]);
             buf.append(values[i]);
         }
         }
-        return new String[] { buf.toString(), values[values.length - 1] };
+        return new PathInfo(buf.toString(), values[values.length - 1]);
     }
     }
 
 
     protected static String createParentId(final String prefix) {
     protected static String createParentId(final String prefix) {
@@ -311,7 +372,7 @@ public class AdminStorageAction extends FessAdminAction {
                 }
                 }
                 buf.append(values[i]);
                 buf.append(values[i]);
             }
             }
-            return urlEncode(buf.toString());
+            return encodeId(buf.toString());
         }
         }
         return StringUtil.EMPTY;
         return StringUtil.EMPTY;
     }
     }
@@ -325,7 +386,7 @@ public class AdminStorageAction extends FessAdminAction {
             }
             }
             buf.append(s);
             buf.append(s);
             final Map<String, String> map = new HashMap<>();
             final Map<String, String> map = new HashMap<>();
-            map.put("id", urlEncode(buf.toString()));
+            map.put("id", encodeId(buf.toString()));
             map.put("name", s);
             map.put("name", s);
             list.add(map);
             list.add(map);
         }));
         }));
@@ -340,6 +401,7 @@ public class AdminStorageAction extends FessAdminAction {
         return getPathPrefix(path) + name;
         return getPathPrefix(path) + name;
     }
     }
 
 
+    @Deprecated
     protected static String urlEncode(final String str) {
     protected static String urlEncode(final String str) {
         if (str == null) {
         if (str == null) {
             return StringUtil.EMPTY;
             return StringUtil.EMPTY;
@@ -347,6 +409,7 @@ public class AdminStorageAction extends FessAdminAction {
         return URLEncoder.encode(str, Constants.UTF_8_CHARSET);
         return URLEncoder.encode(str, Constants.UTF_8_CHARSET);
     }
     }
 
 
+    @Deprecated
     protected static String urlDecode(final String str) {
     protected static String urlDecode(final String str) {
         if (str == null) {
         if (str == null) {
             return StringUtil.EMPTY;
             return StringUtil.EMPTY;
@@ -355,18 +418,63 @@ public class AdminStorageAction extends FessAdminAction {
     }
     }
 
 
     protected static String encodeId(final String objectName) {
     protected static String encodeId(final String objectName) {
-        return urlEncode(urlEncode(objectName));
+        if (objectName == null) {
+            return StringUtil.EMPTY;
+        }
+        return new String(Base64.getUrlEncoder().encode(objectName.getBytes(Constants.UTF_8_CHARSET)), Constants.UTF_8_CHARSET);
+    }
+
+    protected static String decodeId(final String id) {
+        if (id == null) {
+            return StringUtil.EMPTY;
+        }
+        return new String(Base64.getUrlDecoder().decode(id.getBytes(Constants.UTF_8_CHARSET)), Constants.UTF_8_CHARSET);
     }
     }
 
 
-    private HtmlResponse asListHtml(final String prefix) {
+    private HtmlResponse asListHtml(final String path) {
         return asHtml(path_AdminStorage_AdminStorageJsp).useForm(ItemForm.class).renderWith(data -> {
         return asHtml(path_AdminStorage_AdminStorageJsp).useForm(ItemForm.class).renderWith(data -> {
             RenderDataUtil.register(data, "endpoint", fessConfig.getStorageEndpoint());
             RenderDataUtil.register(data, "endpoint", fessConfig.getStorageEndpoint());
             RenderDataUtil.register(data, "bucket", fessConfig.getStorageBucket());
             RenderDataUtil.register(data, "bucket", fessConfig.getStorageBucket());
-            RenderDataUtil.register(data, "path", prefix);
-            RenderDataUtil.register(data, "pathItems", createPathItems(prefix));
-            RenderDataUtil.register(data, "parentId", createParentId(prefix));
-            RenderDataUtil.register(data, "fileItems", getFileItems(prefix));
+            RenderDataUtil.register(data, "path", path);
+            RenderDataUtil.register(data, "pathItems", createPathItems(path));
+            RenderDataUtil.register(data, "parentId", createParentId(path));
+            RenderDataUtil.register(data, "fileItems", getFileItems(path));
         });
         });
     }
     }
 
 
+    private HtmlResponse asEditTagsHtml(final String path, final String name) {
+        return asHtml(path_AdminStorage_AdminStorageTagEditJsp).renderWith(data -> {
+            RenderDataUtil.register(data, "endpoint", fessConfig.getStorageEndpoint());
+            RenderDataUtil.register(data, "bucket", fessConfig.getStorageBucket());
+            RenderDataUtil.register(data, "pathItems", createPathItems(path));
+            RenderDataUtil.register(data, "parentId", encodeId(path));
+            RenderDataUtil.register(data, "path", path);
+            RenderDataUtil.register(data, "name", name);
+            final Map<String, String> tags = new HashMap<>();
+            getObjectTags(getObjectName(path, name)).entrySet().forEach(e -> {
+                int index = tags.size() / 2 + 1;
+                tags.put("name" + index, e.getKey());
+                tags.put("value" + index, e.getValue());
+            });
+            RenderDataUtil.register(data, "savedTags", tags);
+        });
+    }
+
+    public static class PathInfo {
+        private final String path;
+        private final String name;
+
+        public PathInfo(final String path, final String name) {
+            this.path = path;
+            this.name = name;
+        }
+
+        public String getPath() {
+            return path;
+        }
+
+        public String getName() {
+            return name;
+        }
+    }
 }
 }

+ 36 - 0
src/main/java/org/codelibs/fess/app/web/admin/storage/TagForm.java

@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2022 CodeLibs Project and the Others.
+ *
+ * Licensed 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.codelibs.fess.app.web.admin.storage;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.validation.constraints.Size;
+
+import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required;
+
+public class TagForm {
+
+    @Required
+    public String path;
+
+    @Required
+    @Size(max = 100)
+    public String name;
+
+    public Map<String, String> tags = new HashMap<>();
+
+}

+ 12 - 11
src/main/java/org/codelibs/fess/app/web/api/admin/storage/ApiAdminStorageAction.java

@@ -15,7 +15,7 @@
  */
  */
 package org.codelibs.fess.app.web.api.admin.storage;
 package org.codelibs.fess.app.web.api.admin.storage;
 
 
-import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.decodeId;
+import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.convertToItem;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.decodePath;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.decodePath;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.deleteObject;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.deleteObject;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.downloadObject;
 import static org.codelibs.fess.app.web.admin.storage.AdminStorageAction.downloadObject;
@@ -29,6 +29,7 @@ import java.util.Map;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.logging.log4j.Logger;
 import org.codelibs.core.lang.StringUtil;
 import org.codelibs.core.lang.StringUtil;
+import org.codelibs.fess.app.web.admin.storage.AdminStorageAction.PathInfo;
 import org.codelibs.fess.app.web.api.ApiResult;
 import org.codelibs.fess.app.web.api.ApiResult;
 import org.codelibs.fess.app.web.api.admin.FessApiAdminAction;
 import org.codelibs.fess.app.web.api.admin.FessApiAdminAction;
 import org.codelibs.fess.exception.ResultOffsetExceededException;
 import org.codelibs.fess.exception.ResultOffsetExceededException;
@@ -62,18 +63,18 @@ public class ApiAdminStorageAction extends FessApiAdminAction {
     // GET /api/admin/storage/download/{id}/
     // GET /api/admin/storage/download/{id}/
     @Execute
     @Execute
     public StreamResponse get$download(final String id) {
     public StreamResponse get$download(final String id) {
-        final String[] values = decodeId(id);
-        if (StringUtil.isEmpty(values[1])) {
+        final PathInfo pi = convertToItem(id);
+        if (StringUtil.isEmpty(pi.getName())) {
             throwValidationErrorApi(messages -> messages.addErrorsStorageFileNotFound(GLOBAL));
             throwValidationErrorApi(messages -> messages.addErrorsStorageFileNotFound(GLOBAL));
         }
         }
-        return asStream(values[1]).contentTypeOctetStream().stream(out -> {
+        return asStream(pi.getName()).contentTypeOctetStream().stream(out -> {
             try {
             try {
-                downloadObject(getObjectName(values[0], values[1]), out);
+                downloadObject(getObjectName(pi.getPath(), pi.getName()), out);
             } catch (final StorageException e) {
             } catch (final StorageException e) {
                 if (logger.isDebugEnabled()) {
                 if (logger.isDebugEnabled()) {
                     logger.debug("Failed to download {}", id, e);
                     logger.debug("Failed to download {}", id, e);
                 }
                 }
-                throwValidationErrorApi(messages -> messages.addErrorsStorageFileDownloadFailure(GLOBAL, values[1]));
+                throwValidationErrorApi(messages -> messages.addErrorsStorageFileDownloadFailure(GLOBAL, pi.getName()));
             }
             }
         });
         });
     }
     }
@@ -81,20 +82,20 @@ public class ApiAdminStorageAction extends FessApiAdminAction {
     // DELETE /api/admin/storage/delete/{id}/
     // DELETE /api/admin/storage/delete/{id}/
     @Execute
     @Execute
     public JsonResponse<ApiResult> delete$delete(final String id) {
     public JsonResponse<ApiResult> delete$delete(final String id) {
-        final String[] values = decodeId(id);
-        if (StringUtil.isEmpty(values[1])) {
+        final PathInfo pi = convertToItem(id);
+        if (StringUtil.isEmpty(pi.getName())) {
             throwValidationErrorApi(messages -> messages.addErrorsStorageAccessError(GLOBAL, "id is invalid"));
             throwValidationErrorApi(messages -> messages.addErrorsStorageAccessError(GLOBAL, "id is invalid"));
         }
         }
-        final String objectName = getObjectName(values[0], values[1]);
+        final String objectName = getObjectName(pi.getPath(), pi.getName());
         try {
         try {
             deleteObject(objectName);
             deleteObject(objectName);
-            saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, values[1]));
+            saveInfo(messages -> messages.addSuccessDeleteFile(GLOBAL, pi.getName()));
             return asJson(new ApiResult.ApiResponse().status(ApiResult.Status.OK).result());
             return asJson(new ApiResult.ApiResponse().status(ApiResult.Status.OK).result());
         } catch (final StorageException e) {
         } catch (final StorageException e) {
             if (logger.isDebugEnabled()) {
             if (logger.isDebugEnabled()) {
                 logger.debug("Failed to delete {}", id, e);
                 logger.debug("Failed to delete {}", id, e);
             }
             }
-            throwValidationErrorApi(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, values[1]));
+            throwValidationErrorApi(messages -> messages.addErrorsFailedToDeleteFile(GLOBAL, pi.getName()));
         }
         }
         return null;
         return null;
     }
     }

+ 3 - 0
src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java

@@ -355,6 +355,9 @@ public interface FessHtmlPath {
     /** The path of the HTML: /admin/storage/admin_storage.jsp */
     /** The path of the HTML: /admin/storage/admin_storage.jsp */
     HtmlNext path_AdminStorage_AdminStorageJsp = new HtmlNext("/admin/storage/admin_storage.jsp");
     HtmlNext path_AdminStorage_AdminStorageJsp = new HtmlNext("/admin/storage/admin_storage.jsp");
 
 
+    /** The path of the HTML: /admin/storage/admin_storage_tag_edit.jsp */
+    HtmlNext path_AdminStorage_AdminStorageTagEditJsp = new HtmlNext("/admin/storage/admin_storage_tag_edit.jsp");
+
     /** The path of the HTML: /admin/suggest/admin_suggest.jsp */
     /** The path of the HTML: /admin/suggest/admin_suggest.jsp */
     HtmlNext path_AdminSuggest_AdminSuggestJsp = new HtmlNext("/admin/suggest/admin_suggest.jsp");
     HtmlNext path_AdminSuggest_AdminSuggestJsp = new HtmlNext("/admin/suggest/admin_suggest.jsp");
 
 

+ 12 - 0
src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java

@@ -3162,6 +3162,18 @@ public class FessLabels extends UserMessages {
     /** The key of the message: Download */
     /** The key of the message: Download */
     public static final String LABELS_storage_button_download = "{labels.storage_button_download}";
     public static final String LABELS_storage_button_download = "{labels.storage_button_download}";
 
 
+    /** The key of the message: Tags */
+    public static final String LABELS_storage_button_tags = "{labels.storage_button_tags}";
+
+    /** The key of the message: Tag: */
+    public static final String LABELS_storage_title_tag = "{labels.storage_title_tag}";
+
+    /** The key of the message: Tag Key */
+    public static final String LABELS_storage_tag_key = "{labels.storage_tag_key}";
+
+    /** The key of the message: Tag Value */
+    public static final String LABELS_storage_tag_value = "{labels.storage_tag_value}";
+
     /** The key of the message: Your password needs to be updated. */
     /** The key of the message: Your password needs to be updated. */
     public static final String LABELS_LOGIN_NEWPASSWORD = "{labels.login.newpassword}";
     public static final String LABELS_LOGIN_NEWPASSWORD = "{labels.login.newpassword}";
 
 

+ 36 - 0
src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java

@@ -434,6 +434,9 @@ public class FessMessages extends FessLabels {
     /** The key of the message: Directory name is invalid. */
     /** The key of the message: Directory name is invalid. */
     public static final String ERRORS_storage_directory_name_is_invalid = "{errors.storage_directory_name_is_invalid}";
     public static final String ERRORS_storage_directory_name_is_invalid = "{errors.storage_directory_name_is_invalid}";
 
 
+    /** The key of the message: Failed to update tags for {0} */
+    public static final String ERRORS_storage_tags_update_failure = "{errors.storage_tags_update_failure}";
+
     /** The key of the message: Updated parameters. */
     /** The key of the message: Updated parameters. */
     public static final String SUCCESS_update_crawler_params = "{success.update_crawler_params}";
     public static final String SUCCESS_update_crawler_params = "{success.update_crawler_params}";
 
 
@@ -524,6 +527,9 @@ public class FessMessages extends FessLabels {
     /** The key of the message: Logged out. */
     /** The key of the message: Logged out. */
     public static final String SUCCESS_sso_logout = "{success.sso_logout}";
     public static final String SUCCESS_sso_logout = "{success.sso_logout}";
 
 
+    /** The key of the message: Updated tags for {0}. */
+    public static final String SUCCESS_update_storage_tags = "{success.update_storage_tags}";
+
     /** The key of the message: Created data. */
     /** The key of the message: Created data. */
     public static final String SUCCESS_crud_create_crud_table = "{success.crud_create_crud_table}";
     public static final String SUCCESS_crud_create_crud_table = "{success.crud_create_crud_table}";
 
 
@@ -2506,6 +2512,21 @@ public class FessMessages extends FessLabels {
         return this;
         return this;
     }
     }
 
 
+    /**
+     * Add the created action message for the key 'errors.storage_tags_update_failure' with parameters.
+     * <pre>
+     * message: Failed to update tags for {0}
+     * </pre>
+     * @param property The property name for the message. (NotNull)
+     * @param arg0 The parameter arg0 for message. (NotNull)
+     * @return this. (NotNull)
+     */
+    public FessMessages addErrorsStorageTagsUpdateFailure(String property, String arg0) {
+        assertPropertyNotNull(property);
+        add(property, new UserMessage(ERRORS_storage_tags_update_failure, arg0));
+        return this;
+    }
+
     /**
     /**
      * Add the created action message for the key 'success.update_crawler_params' with parameters.
      * Add the created action message for the key 'success.update_crawler_params' with parameters.
      * <pre>
      * <pre>
@@ -2935,6 +2956,21 @@ public class FessMessages extends FessLabels {
         return this;
         return this;
     }
     }
 
 
+    /**
+     * Add the created action message for the key 'success.update_storage_tags' with parameters.
+     * <pre>
+     * message: Updated tags for {0}.
+     * </pre>
+     * @param property The property name for the message. (NotNull)
+     * @param arg0 The parameter arg0 for message. (NotNull)
+     * @return this. (NotNull)
+     */
+    public FessMessages addSuccessUpdateStorageTags(String property, String arg0) {
+        assertPropertyNotNull(property);
+        add(property, new UserMessage(SUCCESS_update_storage_tags, arg0));
+        return this;
+    }
+
     /**
     /**
      * Add the created action message for the key 'success.crud_create_crud_table' with parameters.
      * Add the created action message for the key 'success.crud_create_crud_table' with parameters.
      * <pre>
      * <pre>

+ 3 - 3
src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java

@@ -1076,7 +1076,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
     /** The key of the configuration. e.g. true */
     /** The key of the configuration. e.g. true */
     String SMB_ROLE_FROM_FILE = "smb.role.from.file";
     String SMB_ROLE_FROM_FILE = "smb.role.from.file";
 
 
-    /** The key of the configuration. e.g. 1,2,4:2 */
+    /** The key of the configuration. e.g. 1,2,4:2,5:1 */
     String SMB_AVAILABLE_SID_TYPES = "smb.available.sid.types";
     String SMB_AVAILABLE_SID_TYPES = "smb.available.sid.types";
 
 
     /** The key of the configuration. e.g. true */
     /** The key of the configuration. e.g. true */
@@ -5011,7 +5011,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
 
 
     /**
     /**
      * Get the value for the key 'smb.available.sid.types'. <br>
      * Get the value for the key 'smb.available.sid.types'. <br>
-     * The value is, e.g. 1,2,4:2 <br>
+     * The value is, e.g. 1,2,4:2,5:1 <br>
      * @return The value of found property. (NotNull: if not found, exception but basically no way)
      * @return The value of found property. (NotNull: if not found, exception but basically no way)
      */
      */
     String getSmbAvailableSidTypes();
     String getSmbAvailableSidTypes();
@@ -10449,7 +10449,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction
             defaultMap.put(FessConfig.QUERY_FACET_QUERIES,
             defaultMap.put(FessConfig.QUERY_FACET_QUERIES,
                     "labels.facet_timestamp_title:labels.facet_timestamp_1day=timestamp:[now/d-1d TO *]\tlabels.facet_timestamp_1week=timestamp:[now/d-7d TO *]\tlabels.facet_timestamp_1month=timestamp:[now/d-1M TO *]\tlabels.facet_timestamp_1year=timestamp:[now/d-1y TO *]\nlabels.facet_contentLength_title:labels.facet_contentLength_10k=content_length:[0 TO 9999]\tlabels.facet_contentLength_10kto100k=content_length:[10000 TO 99999]\tlabels.facet_contentLength_100kto500k=content_length:[100000 TO 499999]\tlabels.facet_contentLength_500kto1m=content_length:[500000 TO 999999]\tlabels.facet_contentLength_1m=content_length:[1000000 TO *]\nlabels.facet_filetype_title:labels.facet_filetype_html=filetype:html\tlabels.facet_filetype_word=filetype:word\tlabels.facet_filetype_excel=filetype:excel\tlabels.facet_filetype_powerpoint=filetype:powerpoint\tlabels.facet_filetype_odt=filetype:odt\tlabels.facet_filetype_ods=filetype:ods\tlabels.facet_filetype_odp=filetype:odp\tlabels.facet_filetype_pdf=filetype:pdf\tlabels.facet_filetype_txt=filetype:txt\tlabels.facet_filetype_others=filetype:others\n");
                     "labels.facet_timestamp_title:labels.facet_timestamp_1day=timestamp:[now/d-1d TO *]\tlabels.facet_timestamp_1week=timestamp:[now/d-7d TO *]\tlabels.facet_timestamp_1month=timestamp:[now/d-1M TO *]\tlabels.facet_timestamp_1year=timestamp:[now/d-1y TO *]\nlabels.facet_contentLength_title:labels.facet_contentLength_10k=content_length:[0 TO 9999]\tlabels.facet_contentLength_10kto100k=content_length:[10000 TO 99999]\tlabels.facet_contentLength_100kto500k=content_length:[100000 TO 499999]\tlabels.facet_contentLength_500kto1m=content_length:[500000 TO 999999]\tlabels.facet_contentLength_1m=content_length:[1000000 TO *]\nlabels.facet_filetype_title:labels.facet_filetype_html=filetype:html\tlabels.facet_filetype_word=filetype:word\tlabels.facet_filetype_excel=filetype:excel\tlabels.facet_filetype_powerpoint=filetype:powerpoint\tlabels.facet_filetype_odt=filetype:odt\tlabels.facet_filetype_ods=filetype:ods\tlabels.facet_filetype_odp=filetype:odp\tlabels.facet_filetype_pdf=filetype:pdf\tlabels.facet_filetype_txt=filetype:txt\tlabels.facet_filetype_others=filetype:others\n");
             defaultMap.put(FessConfig.SMB_ROLE_FROM_FILE, "true");
             defaultMap.put(FessConfig.SMB_ROLE_FROM_FILE, "true");
-            defaultMap.put(FessConfig.SMB_AVAILABLE_SID_TYPES, "1,2,4:2");
+            defaultMap.put(FessConfig.SMB_AVAILABLE_SID_TYPES, "1,2,4:2,5:1");
             defaultMap.put(FessConfig.FILE_ROLE_FROM_FILE, "true");
             defaultMap.put(FessConfig.FILE_ROLE_FROM_FILE, "true");
             defaultMap.put(FessConfig.FTP_ROLE_FROM_FILE, "true");
             defaultMap.put(FessConfig.FTP_ROLE_FROM_FILE, "true");
             defaultMap.put(FessConfig.INDEX_BACKUP_TARGETS,
             defaultMap.put(FessConfig.INDEX_BACKUP_TARGETS,

+ 4 - 0
src/main/resources/fess_label.properties

@@ -1045,6 +1045,10 @@ labels.storage_bucket_name=Bucket Name
 labels.storage_file=File
 labels.storage_file=File
 labels.storage_folder_name=Folder Name
 labels.storage_folder_name=Folder Name
 labels.storage_button_download=Download
 labels.storage_button_download=Download
+labels.storage_button_tags=Tags
+labels.storage_title_tag=Tag:
+labels.storage_tag_key=Tag Key
+labels.storage_tag_value=Tag Value
 labels.login.newpassword=Your password needs to be updated.
 labels.login.newpassword=Your password needs to be updated.
 labels.login.placeholder_new_password=New Password
 labels.login.placeholder_new_password=New Password
 labels.login.placeholder_confirm_new_password=Confirm New Password
 labels.login.placeholder_confirm_new_password=Confirm New Password

+ 4 - 0
src/main/resources/fess_label_en.properties

@@ -1045,6 +1045,10 @@ labels.storage_bucket_name=Bucket Name
 labels.storage_file=File
 labels.storage_file=File
 labels.storage_folder_name=Folder Name
 labels.storage_folder_name=Folder Name
 labels.storage_button_download=Download
 labels.storage_button_download=Download
+labels.storage_button_tags=Tags
+labels.storage_title_tag=Tag:
+labels.storage_tag_key=Tag Key
+labels.storage_tag_value=Tag Value
 labels.login.newpassword=Your password needs to be updated.
 labels.login.newpassword=Your password needs to be updated.
 labels.login.placeholder_new_password=New Password
 labels.login.placeholder_new_password=New Password
 labels.login.placeholder_confirm_new_password=Confirm New Password
 labels.login.placeholder_confirm_new_password=Confirm New Password

+ 4 - 0
src/main/resources/fess_label_ja.properties

@@ -1045,6 +1045,10 @@ labels.storage_bucket_name=バケット名
 labels.storage_file=ファイル
 labels.storage_file=ファイル
 labels.storage_folder_name=フォルダ名
 labels.storage_folder_name=フォルダ名
 labels.storage_button_download=ダウンロード
 labels.storage_button_download=ダウンロード
+labels.storage_button_tags=タグ
+labels.storage_title_tag=タグ:
+labels.storage_tag_key=タグキー
+labels.storage_tag_value=タグ値
 labels.login.newpassword=パスワードを更新する必要があります
 labels.login.newpassword=パスワードを更新する必要があります
 labels.login.placeholder_new_password=新しいパスワード
 labels.login.placeholder_new_password=新しいパスワード
 labels.login.placeholder_confirm_new_password=新しいパスワードの確認
 labels.login.placeholder_confirm_new_password=新しいパスワードの確認

+ 2 - 0
src/main/resources/fess_message.properties

@@ -170,6 +170,7 @@ errors.storage_file_download_failure=Failed to download {0}.
 errors.storage_access_error=Storage access error: {0}
 errors.storage_access_error=Storage access error: {0}
 errors.storage_no_upload_file=Upload file is required.
 errors.storage_no_upload_file=Upload file is required.
 errors.storage_directory_name_is_invalid=Directory name is invalid.
 errors.storage_directory_name_is_invalid=Directory name is invalid.
+errors.storage_tags_update_failure=Failed to update tags for {0}
 
 
 success.update_crawler_params=Updated parameters.
 success.update_crawler_params=Updated parameters.
 success.delete_doc_from_index=Started a process to delete the document from index.
 success.delete_doc_from_index=Started a process to delete the document from index.
@@ -201,6 +202,7 @@ success.install_plugin=Installing {0} plugin.
 success.delete_plugin=Deleting {0} plugin.
 success.delete_plugin=Deleting {0} plugin.
 success.upload_file_to_storage=Uploaded {0}
 success.upload_file_to_storage=Uploaded {0}
 success.sso_logout=Logged out.
 success.sso_logout=Logged out.
+success.update_storage_tags=Updated tags for {0}.
 
 
 success.crud_create_crud_table=Created data.
 success.crud_create_crud_table=Created data.
 success.crud_update_crud_table=Updated data.
 success.crud_update_crud_table=Updated data.

+ 2 - 0
src/main/resources/fess_message_en.properties

@@ -166,6 +166,7 @@ errors.storage_file_download_failure=Failed to download {0}.
 errors.storage_access_error=Storage access error: {0}
 errors.storage_access_error=Storage access error: {0}
 errors.storage_no_upload_file=Upload file is required.
 errors.storage_no_upload_file=Upload file is required.
 errors.storage_directory_name_is_invalid=Directory name is invalid.
 errors.storage_directory_name_is_invalid=Directory name is invalid.
+errors.storage_tags_update_failure=Failed to update tags for {0}
 
 
 success.update_crawler_params=Updated parameters.
 success.update_crawler_params=Updated parameters.
 success.delete_doc_from_index=Started a process to delete the document from index.
 success.delete_doc_from_index=Started a process to delete the document from index.
@@ -197,6 +198,7 @@ success.install_plugin=Installing {0} plugin.
 success.delete_plugin=Deleting {0} plugin.
 success.delete_plugin=Deleting {0} plugin.
 success.upload_file_to_storage=Uploaded {0}
 success.upload_file_to_storage=Uploaded {0}
 success.sso_logout=Logged out.
 success.sso_logout=Logged out.
+success.update_storage_tags=Updated tags for {0}.
 
 
 success.crud_create_crud_table=Created data.
 success.crud_create_crud_table=Created data.
 success.crud_update_crud_table=Updated data.
 success.crud_update_crud_table=Updated data.

+ 2 - 0
src/main/resources/fess_message_ja.properties

@@ -159,6 +159,7 @@ errors.storage_file_download_failure={0} のダウンロードに失敗しまし
 errors.storage_access_error=ストレージアクセスエラー: {0}
 errors.storage_access_error=ストレージアクセスエラー: {0}
 errors.storage_no_upload_file=アップロードするファイルを指定してください。
 errors.storage_no_upload_file=アップロードするファイルを指定してください。
 errors.storage_directory_name_is_invalid=ディレクトリ名が正しくありません。
 errors.storage_directory_name_is_invalid=ディレクトリ名が正しくありません。
+errors.storage_tags_update_failure={0}のタグの更新に失敗しました。
 
 
 success.update_crawler_params = パラメータを更新しました。
 success.update_crawler_params = パラメータを更新しました。
 success.delete_doc_from_index = インデックスから文書を削除するプロセスを開始しました。
 success.delete_doc_from_index = インデックスから文書を削除するプロセスを開始しました。
@@ -190,6 +191,7 @@ success.install_plugin=プラグイン {0} をインストールしています
 success.delete_plugin=プラグイン {0} を削除しています。
 success.delete_plugin=プラグイン {0} を削除しています。
 success.upload_file_to_storage={0} をアップロードしました。
 success.upload_file_to_storage={0} をアップロードしました。
 success.sso_logout=ログアウトしました。
 success.sso_logout=ログアウトしました。
+success.update_storage_tags={0}のタグを更新しました。
 
 
 success.crud_create_crud_table = データを作成しました。
 success.crud_create_crud_table = データを作成しました。
 success.crud_update_crud_table = データを更新しました。
 success.crud_update_crud_table = データを更新しました。

+ 9 - 2
src/main/webapp/WEB-INF/view/admin/storage/admin_storage.jsp

@@ -155,7 +155,7 @@
                                             </thead>
                                             </thead>
                                             <tbody>
                                             <tbody>
                                             <c:if test="${not empty path and not empty parentId}">
                                             <c:if test="${not empty path and not empty parentId}">
-                                                <tr data-href="${contextPath}/admin/storage/list/${f:u(data.parentId)}/">
+                                                <tr data-href="${contextPath}/admin/storage/list/${f:u(data.parentId)}/">
                                                     <td>..</td>
                                                     <td>..</td>
                                                     <td></td>
                                                     <td></td>
                                                     <td></td>
                                                     <td></td>
@@ -163,7 +163,7 @@
                                                 </tr>
                                                 </tr>
                                             </c:if>
                                             </c:if>
                                             <c:if test="${not empty path and empty parentId}">
                                             <c:if test="${not empty path and empty parentId}">
-                                                <tr data-href="${contextPath}/admin/storage/">
+                                                <tr data-href="${contextPath}/admin/storage/">
                                                     <td>..</td>
                                                     <td>..</td>
                                                     <td></td>
                                                     <td></td>
                                                     <td></td>
                                                     <td></td>
@@ -199,6 +199,13 @@
                                                             <la:message key="labels.storage_button_download"/>
                                                             <la:message key="labels.storage_button_download"/>
                                                         </a>
                                                         </a>
                                                         <c:if test="${editable}">
                                                         <c:if test="${editable}">
+	                                                        <a class="btn btn-primary btn-xs" role="button" name="editTags"
+	                                                           href="${contextPath}/admin/storage/editTags?path=${f:u(data.path)}&name=${f:u(data.name)}"
+	                                                           value="<la:message key="labels.storage_button_tags" />"
+	                                                        >
+	                                                            <em class="fa fa-tags"></em>
+	                                                            <la:message key="labels.storage_button_tags"/>
+	                                                        </a>
                                                             <button type="button" class="btn btn-danger btn-xs"
                                                             <button type="button" class="btn btn-danger btn-xs"
                                                                     name="delete" data-toggle="modal"
                                                                     name="delete" data-toggle="modal"
                                                                     data-target="#confirmToDelete-${f:h(data.hashCode)}"
                                                                     data-target="#confirmToDelete-${f:h(data.hashCode)}"

+ 111 - 0
src/main/webapp/WEB-INF/view/admin/storage/admin_storage_tag_edit.jsp

@@ -0,0 +1,111 @@
+<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%><!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title><la:message key="labels.admin_brand_title" /> | <la:message key="labels.storage_configuration" /></title>
+<jsp:include page="/WEB-INF/view/common/admin/head.jsp"></jsp:include>
+</head>
+<body class="hold-transition sidebar-mini">
+	<div class="wrapper">
+		<jsp:include page="/WEB-INF/view/common/admin/header.jsp"></jsp:include>
+		<jsp:include page="/WEB-INF/view/common/admin/sidebar.jsp">
+			<jsp:param name="menuCategoryType" value="system" />
+			<jsp:param name="menuType" value="storage" />
+		</jsp:include>
+		<div class="content-wrapper">
+			<div class="content-header">
+				<div class="container-fluid">
+					<div class="row mb-2">
+						<div class="col-sm-6">
+							<h1>
+								<la:message key="labels.storage_configuration" />
+							</h1>
+						</div>
+						<div class="col-sm-6">
+							<ol class="breadcrumb float-sm-right">
+								<li class="breadcrumb-item"><la:link href="/admin/storage/">
+										<la:message key="labels.crud_link_list" />
+									</la:link></li>
+								<li class="breadcrumb-item active"><la:message key="labels.crud_link_edit" /></li>
+							</ol>
+						</div>
+					</div>
+				</div>
+			</div>
+			<section class="content">
+				<la:form action="/admin/storage/">
+					<input type="hidden" name="path" value="${f:h(path)}">
+					<input type="hidden" name="name" value="${f:h(name)}">
+					<div class="row">
+						<div class="col-md-12">
+							<div class="card card-outline card-success">
+								<div class="card-header">
+									<h3 class="card-title">
+										<la:message key="labels.storage_title_tag" />
+										<c:if test="${path!=null}">${f:h(path)}/</c:if>${f:h(name)}
+									</h3>
+								</div>
+								<div class="card-body">
+									<div>
+										<la:info id="msg" message="true">
+											<div class="alert alert-info">${msg}</div>
+										</la:info>
+										<la:errors property="_global" />
+									</div>
+									<div class="form-group row">
+										<div class="col-sm-6">
+											<la:message key="labels.storage_tag_key" />
+										</div>
+										<div class="col-sm-6">
+											<la:message key="labels.storage_tag_value" />
+										</div>
+									</div>
+									<c:forEach var="position" begin="1" end="${savedTags.size()/2}">
+									<c:set var="nameKey">name${position}</c:set>
+									<c:set var="valueKey">value${position}</c:set>
+									<div class="form-group row">
+										<div class="col-sm-6">
+											<input type="text" id="tags.${f:h(nameKey)}" name="tags.${f:h(nameKey)}" value="${f:h(savedTags.get(nameKey))}" class="form-control" placeholder="Name" >
+										</div>
+										<div class="col-sm-6">
+											<input type="text" id="tags.${f:h(valueKey)}" name="tags.${f:h(valueKey)}" value="${f:h(savedTags.get(valueKey))}" class="form-control" placeholder="Value" >
+										</div>
+									</div>
+									</c:forEach>
+									<c:set var="nameKey">name${Double.valueOf(savedTags.size()/2).intValue()+1}</c:set>
+									<c:set var="valueKey">value${Double.valueOf(savedTags.size()/2).intValue()+1}</c:set>
+									<div class="form-group row">
+										<div class="col-sm-6">
+											<input type="text" id="tags.${f:h(nameKey)}" name="tags.${f:h(nameKey)}" value="${f:h(savedTags.get(nameKey))}" class="form-control" placeholder="Name" >
+										</div>
+										<div class="col-sm-6">
+											<input type="text" id="tags.${f:h(valueKey)}" name="tags.${f:h(valueKey)}" value="${f:h(savedTags.get(valueKey))}" class="form-control" placeholder="Value" >
+										</div>
+									</div>
+								</div>
+								<div class="card-footer">
+									<la:link styleClass="btn btn-default" href="list/${parentId}">
+										<em class="fa fa-arrow-circle-left"></em>
+										<la:message key="labels.crud_button_back" />
+									</la:link>
+									<c:if test="${editable}">
+										<button type="submit" class="btn btn-success" name="updateTags"
+											value="<la:message key="labels.crud_button_update" />"
+										>
+											<em class="fa fa-pencil-alt"></em>
+											<la:message key="labels.crud_button_update" />
+										</button>
+									</c:if>
+								</div>
+							</div>
+						</div>
+					</div>
+				</la:form>
+			</section>
+		</div>
+		<jsp:include page="/WEB-INF/view/common/admin/footer.jsp"></jsp:include>
+	</div>
+	<jsp:include page="/WEB-INF/view/common/admin/foot.jsp"></jsp:include>
+</body>
+</html>
+