Browse Source

Multilple configs export

wxq 4 năm trước cách đây
mục cha
commit
f8f37066e6

+ 57 - 79
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ConfigsExportController.java

@@ -1,33 +1,29 @@
 package com.ctrip.framework.apollo.portal.controller;
 package com.ctrip.framework.apollo.portal.controller;
 
 
-import com.ctrip.framework.apollo.common.dto.NamespaceDTO;
-import com.ctrip.framework.apollo.common.exception.BadRequestException;
 import com.ctrip.framework.apollo.common.exception.ServiceException;
 import com.ctrip.framework.apollo.common.exception.ServiceException;
-import com.ctrip.framework.apollo.core.ConfigConsts;
 import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
 import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
-import com.ctrip.framework.apollo.portal.environment.Env;
 import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
 import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
-import com.ctrip.framework.apollo.portal.entity.model.NamespaceTextModel;
-import com.ctrip.framework.apollo.portal.service.ItemService;
+import com.ctrip.framework.apollo.portal.environment.Env;
+import com.ctrip.framework.apollo.portal.service.ConfigsExportService;
 import com.ctrip.framework.apollo.portal.service.NamespaceService;
 import com.ctrip.framework.apollo.portal.service.NamespaceService;
-import com.ctrip.framework.apollo.portal.util.ConfigToFileUtils;
+import com.ctrip.framework.apollo.portal.util.NamespaceBOUtils;
 import com.google.common.base.Joiner;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Splitter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Date;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.lang.time.DateFormatUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.http.HttpHeaders;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.multipart.MultipartFile;
-
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
 
 
 /**
 /**
  * jian.tan
  * jian.tan
@@ -35,57 +31,31 @@ import java.util.stream.Collectors;
 @RestController
 @RestController
 public class ConfigsExportController {
 public class ConfigsExportController {
 
 
-  private final ItemService configService;
+  private static final Logger logger = LoggerFactory.getLogger(ConfigsExportController.class);
+
+  private final ConfigsExportService configsExportService;
 
 
   private final NamespaceService namespaceService;
   private final NamespaceService namespaceService;
 
 
   public ConfigsExportController(
   public ConfigsExportController(
-      final ItemService configService,
-      final @Lazy NamespaceService namespaceService) {
-    this.configService = configService;
+      final ConfigsExportService configsExportService,
+      final @Lazy NamespaceService namespaceService
+  ) {
+    this.configsExportService = configsExportService;
     this.namespaceService = namespaceService;
     this.namespaceService = namespaceService;
   }
   }
 
 
-  @PreAuthorize(value = "@permissionValidator.hasModifyNamespacePermission(#appId, #namespaceName, #env)")
-  @PostMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/items/import")
-  public void importConfigFile(@PathVariable String appId, @PathVariable String env,
-      @PathVariable String clusterName, @PathVariable String namespaceName,
-      @RequestParam("file") MultipartFile file) {
-    if (file.isEmpty()) {
-      throw new BadRequestException("The file is empty.");
-    }
-
-    NamespaceDTO namespaceDTO = namespaceService
-        .loadNamespaceBaseInfo(appId, Env.fromString(env), clusterName, namespaceName);
-
-    if (Objects.isNull(namespaceDTO)) {
-      throw new BadRequestException(String.format("Namespace: %s not exist.", namespaceName));
-    }
-
-    NamespaceTextModel model = new NamespaceTextModel();
-    List<String> fileNameSplit = Splitter.on(".").splitToList(file.getOriginalFilename());
-    if (fileNameSplit.size() <= 1) {
-      throw new BadRequestException("The file format is invalid.");
-    }
-
-    String format = fileNameSplit.get(fileNameSplit.size() - 1);
-    model.setFormat(format);
-    model.setAppId(appId);
-    model.setEnv(env);
-    model.setClusterName(clusterName);
-    model.setNamespaceName(namespaceName);
-    model.setNamespaceId(namespaceDTO.getId());
-    String configText;
-    try(InputStream in = file.getInputStream()){
-      configText = ConfigToFileUtils.fileToString(in);
-    }catch (IOException e) {
-      throw new ServiceException("Read config file errors:{}", e);
-    }
-    model.setConfigText(configText);
-
-    configService.updateConfigItemByText(model);
-  }
-
+  /**
+   * export one config as file.
+   * keep compatibility.
+   * file name examples:
+   * <pre>
+   *   application.properties
+   *   application.yml
+   *   application.json
+   * </pre>
+   */
+  @PreAuthorize(value = "!@permissionValidator.shouldHideConfigToCurrentUser(#appId, #env, #namespaceName)")
   @GetMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/items/export")
   @GetMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/items/export")
   public void exportItems(@PathVariable String appId, @PathVariable String env,
   public void exportItems(@PathVariable String appId, @PathVariable String env,
       @PathVariable String clusterName, @PathVariable String namespaceName,
       @PathVariable String clusterName, @PathVariable String namespaceName,
@@ -94,30 +64,38 @@ public class ConfigsExportController {
 
 
     String fileName = fileNameSplit.size() <= 1 ? Joiner.on(".")
     String fileName = fileNameSplit.size() <= 1 ? Joiner.on(".")
         .join(namespaceName, ConfigFileFormat.Properties.getValue()) : namespaceName;
         .join(namespaceName, ConfigFileFormat.Properties.getValue()) : namespaceName;
-    NamespaceBO namespaceBO = namespaceService.loadNamespaceBO(appId, Env.fromString
+    NamespaceBO namespaceBO = namespaceService.loadNamespaceBO(appId, Env.valueOf
         (env), clusterName, namespaceName);
         (env), clusterName, namespaceName);
 
 
     //generate a file.
     //generate a file.
-    res.setHeader("Content-Disposition", "attachment;filename=" + fileName);
-
-    List<String> fileItems = namespaceBO.getItems().stream().map(itemBO -> {
-      String key = itemBO.getItem().getKey();
-      String value = itemBO.getItem().getValue();
-      if (ConfigConsts.CONFIG_FILE_CONTENT_KEY.equals(key)) {
-        return value;
-      }
-
-      if ("".equals(key)) {
-        return Joiner.on("").join(itemBO.getItem().getKey(), itemBO.getItem().getValue());
-      }
-
-      return Joiner.on(" = ").join(itemBO.getItem().getKey(), itemBO.getItem().getValue());
-    }).collect(Collectors.toList());
-
+    res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName);
+    // file content
+    final String configFileContent = NamespaceBOUtils.convert2configFileContent(namespaceBO);
     try {
     try {
-      ConfigToFileUtils.itemsToFile(res.getOutputStream(), fileItems);
+      // write content to net
+      res.getOutputStream().write(configFileContent.getBytes());
     } catch (Exception e) {
     } catch (Exception e) {
       throw new ServiceException("export items failed:{}", e);
       throw new ServiceException("export items failed:{}", e);
     }
     }
   }
   }
+
+  /**
+   * Export all configs in a compressed file.
+   * Just export namespace which current exists read permission.
+   * The permission check in service.
+   */
+  @GetMapping("/export")
+  public void exportAll(HttpServletRequest request, HttpServletResponse response) throws IOException {
+    // filename must contain the information of time
+    final String filename = "apollo_config_export_" + DateFormatUtils.format(new Date(), "yyyy_MMdd_HH_mm_ss") + ".zip";
+    // log who download the configs
+    logger.info("Download configs, remote addr [{}], remote host [{}]. Filename is [{}]", request.getRemoteAddr(), request.getRemoteHost(), filename);
+    // set downloaded filename
+    response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + filename);
+
+    try (OutputStream outputStream = response.getOutputStream()) {
+      configsExportService.exportAllTo(outputStream);
+    }
+  }
+
 }
 }

+ 48 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/ConfigsImportController.java

@@ -0,0 +1,48 @@
+package com.ctrip.framework.apollo.portal.controller;
+
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import com.ctrip.framework.apollo.portal.service.ConfigsImportService;
+import com.ctrip.framework.apollo.portal.util.ConfigFileUtils;
+import java.io.IOException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * Import the configs from file.
+ * First version: move code from {@link ConfigsExportController}
+ * @author wxq
+ */
+@RestController
+public class ConfigsImportController {
+
+  private final ConfigsImportService configsImportService;
+
+  public ConfigsImportController(
+      final ConfigsImportService configsImportService
+  ) {
+    this.configsImportService = configsImportService;
+  }
+
+  /**
+   * copy from old {@link ConfigsExportController}.
+   * @param file Yml file's name must ends with {@code .yml}.
+   *             Properties file's name must ends with {@code .properties}.
+   *             etc.
+   * @throws IOException
+   */
+  @PreAuthorize(value = "@permissionValidator.hasModifyNamespacePermission(#appId, #namespaceName, #env)")
+  @PostMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/items/import")
+  public void importConfigFile(@PathVariable String appId, @PathVariable String env,
+      @PathVariable String clusterName, @PathVariable String namespaceName,
+      @RequestParam("file") MultipartFile file) throws IOException {
+    // check file
+    ConfigFileUtils.check(file);
+    final String format = ConfigFileUtils.getFormat(file.getOriginalFilename());
+    final String standardFilename = ConfigFileUtils.toFilename(appId, clusterName, namespaceName, ConfigFileFormat.fromString(format));
+    configsImportService.importOneConfigFromFile(env, standardFilename, file.getInputStream());
+  }
+}

+ 86 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/entity/bo/ConfigBO.java

@@ -0,0 +1,86 @@
+package com.ctrip.framework.apollo.portal.entity.bo;
+
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import com.ctrip.framework.apollo.portal.environment.Env;
+import com.ctrip.framework.apollo.portal.util.NamespaceBOUtils;
+
+/**
+ * a namespace represent.
+ * @author wxq
+ */
+public class ConfigBO {
+
+  private final Env env;
+
+  private final String ownerName;
+
+  private final String appId;
+
+  private final String clusterName;
+
+  private final String namespace;
+
+  private final String configFileContent;
+
+  private final ConfigFileFormat format;
+
+  public ConfigBO(Env env, String ownerName, String appId, String clusterName,
+      String namespace, String configFileContent, ConfigFileFormat format) {
+    this.env = env;
+    this.ownerName = ownerName;
+    this.appId = appId;
+    this.clusterName = clusterName;
+    this.namespace = namespace;
+    this.configFileContent = configFileContent;
+    this.format = format;
+  }
+
+  public ConfigBO(Env env, String ownerName, String appId, String clusterName, NamespaceBO namespaceBO) {
+    this(env, ownerName, appId, clusterName,
+        namespaceBO.getBaseInfo().getNamespaceName(),
+        NamespaceBOUtils.convert2configFileContent(namespaceBO),
+        ConfigFileFormat.fromString(namespaceBO.getFormat())
+    );
+  }
+
+  @Override
+  public String toString() {
+    return "ConfigBO{" +
+        "env=" + env +
+        ", ownerName='" + ownerName + '\'' +
+        ", appId='" + appId + '\'' +
+        ", clusterName='" + clusterName + '\'' +
+        ", namespace='" + namespace + '\'' +
+        ", configFileContent='" + configFileContent + '\'' +
+        ", format=" + format +
+        '}';
+  }
+
+  public Env getEnv() {
+    return env;
+  }
+
+  public String getOwnerName() {
+    return ownerName;
+  }
+
+  public String getAppId() {
+    return appId;
+  }
+
+  public String getClusterName() {
+    return clusterName;
+  }
+
+  public String getNamespace() {
+    return namespace;
+  }
+
+  public String getConfigFileContent() {
+    return configFileContent;
+  }
+
+  public ConfigFileFormat getFormat() {
+    return format;
+  }
+}

+ 178 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/ConfigsExportService.java

@@ -0,0 +1,178 @@
+package com.ctrip.framework.apollo.portal.service;
+
+import com.ctrip.framework.apollo.common.dto.ClusterDTO;
+import com.ctrip.framework.apollo.common.entity.App;
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import com.ctrip.framework.apollo.portal.component.PermissionValidator;
+import com.ctrip.framework.apollo.portal.component.PortalSettings;
+import com.ctrip.framework.apollo.portal.entity.bo.ConfigBO;
+import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
+import com.ctrip.framework.apollo.portal.environment.Env;
+import com.ctrip.framework.apollo.portal.util.ConfigFileUtils;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.BinaryOperator;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ConfigsExportService {
+
+  private static final Logger logger = LoggerFactory.getLogger(ConfigsExportService.class);
+
+  private final AppService appService;
+
+  private final ClusterService clusterService;
+
+  private final NamespaceService namespaceService;
+
+  private final PortalSettings portalSettings;
+
+  private final PermissionValidator permissionValidator;
+
+  public ConfigsExportService(
+      AppService appService,
+      ClusterService clusterService,
+      final @Lazy NamespaceService namespaceService,
+      PortalSettings portalSettings,
+      PermissionValidator permissionValidator) {
+    this.appService = appService;
+    this.clusterService = clusterService;
+    this.namespaceService = namespaceService;
+    this.portalSettings = portalSettings;
+    this.permissionValidator = permissionValidator;
+  }
+
+  /**
+   * write multiple namespace to a zip. use {@link Stream#reduce(Object, BiFunction,
+   * BinaryOperator)} to forbid concurrent write.
+   *
+   * @param configBOStream namespace's stream
+   * @param outputStream receive zip file output stream
+   * @throws IOException if happen write problem
+   */
+  private static void writeAsZipOutputStream(
+      Stream<ConfigBO> configBOStream, OutputStream outputStream) throws IOException {
+    try (final ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
+      final Consumer<ConfigBO> configBOConsumer =
+          configBO -> {
+            try {
+              // TODO, Stream.reduce will cause some problems. Is There other way to speed up the
+              // downloading?
+              synchronized (zipOutputStream) {
+                write2ZipOutputStream(zipOutputStream, configBO);
+              }
+            } catch (IOException e) {
+              logger.error("Write error. {}", configBO);
+              throw new IllegalStateException(e);
+            }
+          };
+      configBOStream.forEach(configBOConsumer);
+    }
+  }
+
+  /**
+   * write {@link ConfigBO} as file to {@link ZipOutputStream}. Watch out the concurrent problem!
+   * zip output stream is same like cannot write concurrently! the name of file is determined by
+   * {@link ConfigFileUtils#toFilename(String, String, String, ConfigFileFormat)}. the path of file
+   * is determined by {@link ConfigFileUtils#toFilePath(String, String, Env, String)}.
+   *
+   * @param zipOutputStream zip file output stream
+   * @param configBO a namespace represent
+   * @return zip file output stream same as parameter zipOutputStream
+   */
+  private static ZipOutputStream write2ZipOutputStream(
+      final ZipOutputStream zipOutputStream, final ConfigBO configBO) throws IOException {
+    final Env env = configBO.getEnv();
+    final String ownerName = configBO.getOwnerName();
+    final String appId = configBO.getAppId();
+    final String clusterName = configBO.getClusterName();
+    final String namespace = configBO.getNamespace();
+    final String configFileContent = configBO.getConfigFileContent();
+    final ConfigFileFormat configFileFormat = configBO.getFormat();
+    final String configFilename =
+        ConfigFileUtils.toFilename(appId, clusterName, namespace, configFileFormat);
+    final String filePath = ConfigFileUtils.toFilePath(ownerName, appId, env, configFilename);
+    final ZipEntry zipEntry = new ZipEntry(filePath);
+    try {
+      zipOutputStream.putNextEntry(zipEntry);
+      zipOutputStream.write(configFileContent.getBytes());
+      zipOutputStream.closeEntry();
+    } catch (IOException e) {
+      logger.error("config export failed. {}", configBO);
+      throw new IOException("config export failed", e);
+    }
+    return zipOutputStream;
+  }
+
+  /** @return the namespaces current user exists */
+  private Stream<ConfigBO> makeStreamBy(
+      final Env env, final String ownerName, final String appId, final String clusterName) {
+    final List<NamespaceBO> namespaceBOS =
+        namespaceService.findNamespaceBOs(appId, env, clusterName);
+    final Function<NamespaceBO, ConfigBO> function =
+        namespaceBO -> new ConfigBO(env, ownerName, appId, clusterName, namespaceBO);
+    return namespaceBOS.parallelStream().map(function);
+  }
+
+  private Stream<ConfigBO> makeStreamBy(final Env env, final String ownerName, final String appId) {
+    final List<ClusterDTO> clusterDTOS = clusterService.findClusters(env, appId);
+    final Function<ClusterDTO, Stream<ConfigBO>> function =
+        clusterDTO -> this.makeStreamBy(env, ownerName, appId, clusterDTO.getName());
+    return clusterDTOS.parallelStream().flatMap(function);
+  }
+
+  private Stream<ConfigBO> makeStreamBy(final Env env, final List<App> apps) {
+    final Function<App, Stream<ConfigBO>> function =
+        app -> this.makeStreamBy(env, app.getOwnerName(), app.getAppId());
+
+    return apps.parallelStream().flatMap(function);
+  }
+
+  private Stream<ConfigBO> makeStreamBy(final Collection<Env> envs) {
+    // get all apps
+    final List<App> apps = appService.findAll();
+
+    // permission check
+    final Predicate<App> isAppAdmin =
+        app -> {
+          try {
+            return permissionValidator.isAppAdmin(app.getAppId());
+          } catch (Exception e) {
+            logger.error("app = {}", app);
+            logger.error(app.getAppId());
+          }
+          return false;
+        };
+
+    // app admin permission filter
+    final List<App> appsExistPermission =
+        apps.stream().filter(isAppAdmin).collect(Collectors.toList());
+    return envs.parallelStream().flatMap(env -> this.makeStreamBy(env, appsExistPermission));
+  }
+
+  /**
+   * Export all projects which current user own them. Permission check by {@link
+   * PermissionValidator#isAppAdmin(java.lang.String)}
+   *
+   * @param outputStream network file download stream to user
+   * @throws IOException if happen write problem
+   */
+  public void exportAllTo(OutputStream outputStream) throws IOException {
+    final List<Env> activeEnvs = portalSettings.getActiveEnvs();
+    final Stream<ConfigBO> configBOStream = this.makeStreamBy(activeEnvs);
+    writeAsZipOutputStream(configBOStream, outputStream);
+  }
+}

+ 121 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/ConfigsImportService.java

@@ -0,0 +1,121 @@
+package com.ctrip.framework.apollo.portal.service;
+
+import com.ctrip.framework.apollo.common.dto.NamespaceDTO;
+import com.ctrip.framework.apollo.common.exception.ServiceException;
+import com.ctrip.framework.apollo.portal.component.PermissionValidator;
+import com.ctrip.framework.apollo.portal.entity.model.NamespaceTextModel;
+import com.ctrip.framework.apollo.portal.environment.Env;
+import com.ctrip.framework.apollo.portal.util.ConfigFileUtils;
+import com.ctrip.framework.apollo.portal.util.ConfigToFileUtils;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.AccessControlException;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author wxq
+ */
+@Service
+public class ConfigsImportService {
+
+  private final ItemService itemService;
+
+  private final NamespaceService namespaceService;
+
+  private final PermissionValidator permissionValidator;
+
+  public ConfigsImportService(
+      final ItemService itemService,
+      final @Lazy NamespaceService namespaceService,
+      PermissionValidator permissionValidator) {
+    this.itemService = itemService;
+    this.namespaceService = namespaceService;
+    this.permissionValidator = permissionValidator;
+  }
+
+  /**
+   * move from {@link com.ctrip.framework.apollo.portal.controller.ConfigsImportController}
+   */
+  private void importConfig(
+      final String appId,
+      final String env,
+      final String clusterName,
+      final String namespaceName,
+      final long namespaceId,
+      final String format,
+      final String configText
+  ) {
+    final NamespaceTextModel model = new NamespaceTextModel();
+
+    model.setAppId(appId);
+    model.setEnv(env);
+    model.setClusterName(clusterName);
+    model.setNamespaceName(namespaceName);
+    model.setNamespaceId(namespaceId);
+    model.setFormat(format);
+    model.setConfigText(configText);
+
+    itemService.updateConfigItemByText(model);
+  }
+
+  /**
+   * import one config from file
+   */
+  private void importOneConfigFromFile(
+      final String appId,
+      final String env,
+      final String clusterName,
+      final String namespaceName,
+      final String configText,
+      final String format
+  ) {
+    final NamespaceDTO namespaceDTO = namespaceService
+        .loadNamespaceBaseInfo(appId, Env.valueOf(env), clusterName, namespaceName);
+    this.importConfig(appId, env, clusterName, namespaceName, namespaceDTO.getId(), format, configText);
+  }
+
+  /**
+   * import a config file.
+   * the name of config file must be special like
+   * appId+cluster+namespace.format
+   * Example:
+   * <pre>
+   *   123456+default+application.properties (appId is 123456, cluster is default, namespace is application, format is properties)
+   *   654321+north+password.yml (appId is 654321, cluster is north, namespace is password, format is yml)
+   * </pre>
+   * so we can get the information of appId, cluster, namespace, format from the file name.
+   * @param env environment
+   * @param standardFilename appId+cluster+namespace.format
+   * @param configText config content
+   */
+  private void importOneConfigFromText(
+      final String env,
+      final String standardFilename,
+      final String configText
+  ) {
+    final String appId = ConfigFileUtils.getAppId(standardFilename);
+    final String clusterName = ConfigFileUtils.getClusterName(standardFilename);
+    final String namespace = ConfigFileUtils.getNamespace(standardFilename);
+    final String format = ConfigFileUtils.getFormat(standardFilename);
+    this.importOneConfigFromFile(appId, env, clusterName, namespace, configText, format);
+  }
+
+  /**
+   * @see ConfigsImportService#importOneConfigFromText(java.lang.String, java.lang.String, java.lang.String)
+   * @throws AccessControlException if has no modify namespace permission
+   */
+  public void importOneConfigFromFile(
+      final String env,
+      final String standardFilename,
+      final InputStream inputStream
+  ) {
+    final String configText;
+    try(InputStream in = inputStream) {
+      configText = ConfigToFileUtils.fileToString(in);
+    } catch (IOException e) {
+      throw new ServiceException("Read config file errors:{}", e);
+    }
+    this.importOneConfigFromText(env, standardFilename, configText);
+  }
+}

+ 10 - 6
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/NamespaceService.java

@@ -122,7 +122,7 @@ public class NamespaceService {
       String namespaceName) {
       String namespaceName) {
     NamespaceDTO namespace = namespaceAPI.loadNamespace(appId, env, clusterName, namespaceName);
     NamespaceDTO namespace = namespaceAPI.loadNamespace(appId, env, clusterName, namespaceName);
     if (namespace == null) {
     if (namespace == null) {
-      throw new BadRequestException("namespaces not exist");
+      throw new BadRequestException(String.format("Namespace: %s not exist.", namespaceName));
     }
     }
     return namespace;
     return namespace;
   }
   }
@@ -256,20 +256,24 @@ public class NamespaceService {
 
 
   private void fillAppNamespaceProperties(NamespaceBO namespace) {
   private void fillAppNamespaceProperties(NamespaceBO namespace) {
 
 
-    NamespaceDTO namespaceDTO = namespace.getBaseInfo();
+    final NamespaceDTO namespaceDTO = namespace.getBaseInfo();
+    final String appId = namespaceDTO.getAppId();
+    final String clusterName = namespaceDTO.getClusterName();
+    final String namespaceName = namespaceDTO.getNamespaceName();
     //先从当前appId下面找,包含私有的和公共的
     //先从当前appId下面找,包含私有的和公共的
     AppNamespace appNamespace =
     AppNamespace appNamespace =
         appNamespaceService
         appNamespaceService
-            .findByAppIdAndName(namespaceDTO.getAppId(), namespaceDTO.getNamespaceName());
+            .findByAppIdAndName(appId, namespaceName);
     //再从公共的app namespace里面找
     //再从公共的app namespace里面找
     if (appNamespace == null) {
     if (appNamespace == null) {
-      appNamespace = appNamespaceService.findPublicAppNamespace(namespaceDTO.getNamespaceName());
+      appNamespace = appNamespaceService.findPublicAppNamespace(namespaceName);
     }
     }
 
 
-    String format;
-    boolean isPublic;
+    final String format;
+    final boolean isPublic;
     if (appNamespace == null) {
     if (appNamespace == null) {
       //dirty data
       //dirty data
+      logger.warn("Dirty data, cannot find appNamespace by namespaceName [{}], appId = {}, cluster = {}, set it format to {}, make public", namespaceName, appId, clusterName, ConfigFileFormat.Properties.getValue());
       format = ConfigFileFormat.Properties.getValue();
       format = ConfigFileFormat.Properties.getValue();
       isPublic = true; // set to true, because public namespace allowed to delete by user
       isPublic = true; // set to true, because public namespace allowed to delete by user
     } else {
     } else {

+ 163 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/util/ConfigFileUtils.java

@@ -0,0 +1,163 @@
+package com.ctrip.framework.apollo.portal.util;
+
+import com.ctrip.framework.apollo.common.exception.BadRequestException;
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import com.ctrip.framework.apollo.core.utils.StringUtils;
+import com.ctrip.framework.apollo.portal.controller.ConfigsImportController;
+import com.ctrip.framework.apollo.portal.environment.Env;
+import com.google.common.base.Splitter;
+import java.io.File;
+import java.util.List;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * First version: move from {@link ConfigsImportController#importConfigFile(java.lang.String, java.lang.String, java.lang.String, java.lang.String, org.springframework.web.multipart.MultipartFile)}
+ * @author wxq
+ */
+public class ConfigFileUtils {
+
+  public static void check(MultipartFile file) {
+    checkEmpty(file);
+    final String originalFilename = file.getOriginalFilename();
+    checkFormat(originalFilename);
+  }
+
+  /**
+   * @throws BadRequestException if file is empty
+   */
+  static void checkEmpty(MultipartFile file) {
+    if (file.isEmpty()) {
+      throw new BadRequestException("The file is empty. " + file.getOriginalFilename());
+    }
+  }
+
+  /**
+   * @throws BadRequestException if file's format is invalid
+   */
+  static void checkFormat(final String originalFilename) {
+    final List<String> fileNameSplit = Splitter.on(".").splitToList(originalFilename);
+    if (fileNameSplit.size() <= 1) {
+      throw new BadRequestException("The file format is invalid.");
+    }
+
+    for (String s : fileNameSplit) {
+      if (StringUtils.isEmpty(s)) {
+        throw new BadRequestException("The file format is invalid.");
+      }
+    }
+  }
+
+  static String[] getThreePart(final String originalFilename) {
+    return originalFilename.split("[+]");
+  }
+
+  /**
+   * @throws BadRequestException if file's name cannot divide to 3 parts by "+" symbol
+   */
+  static void checkThreePart(final String originalFilename) {
+    String[] parts = getThreePart(originalFilename);
+    if (3 != parts.length) {
+      throw new BadRequestException("file name [" + originalFilename + "] not valid");
+    }
+  }
+
+  /**
+   * <pre>
+   *  "application+default+application.properties" -> "properties"
+   *  "application+default+application.yml" -> "yml"
+   * </pre>
+   * @throws BadRequestException if file's format is invalid
+   */
+  public static String getFormat(final String originalFilename) {
+    final List<String> fileNameSplit = Splitter.on(".").splitToList(originalFilename);
+    if (fileNameSplit.size() <= 1) {
+      throw new BadRequestException("The file format is invalid.");
+    }
+    return fileNameSplit.get(fileNameSplit.size() - 1);
+  }
+
+  /**
+   * <pre>
+   *  "123+default+application.properties" -> "123"
+   *  "abc+default+application.yml" -> "abc"
+   *  "666+default+application.json" -> "666"
+   * </pre>
+   * @throws BadRequestException if file's name is invalid
+   */
+  public static String getAppId(final String originalFilename) {
+    checkThreePart(originalFilename);
+    return getThreePart(originalFilename)[0];
+  }
+
+  public static String getClusterName(final String originalFilename) {
+    checkThreePart(originalFilename);
+    return getThreePart(originalFilename)[1];
+  }
+
+  /**
+   * <pre>
+   *  "application+default+application.properties" -> "application"
+   *  "application+default+application.yml" -> "application.yml"
+   *  "application+default+application.json" -> "application.json"
+   *  "application+default+application.333.yml" -> "application.333.yml"
+   * </pre>
+   * @throws BadRequestException if file's name is invalid
+   */
+  public static String getNamespace(final String originalFilename) {
+    checkThreePart(originalFilename);
+    final String[] threeParts = getThreePart(originalFilename);
+    final String suffix = threeParts[2];
+    if (!suffix.contains(".")) {
+      throw new BadRequestException(originalFilename + " namespace and format is invalid!");
+    }
+    final int lastDotIndex = suffix.lastIndexOf(".");
+    final String namespace = suffix.substring(0, lastDotIndex);
+    // format after last character '.'
+    final String format = suffix.substring(lastDotIndex + 1);
+    if (!ConfigFileFormat.isValidFormat(format)) {
+      throw new BadRequestException(originalFilename + " format is invalid!");
+    }
+    ConfigFileFormat configFileFormat = ConfigFileFormat.fromString(format);
+    if (configFileFormat.equals(ConfigFileFormat.Properties)) {
+      return namespace;
+    } else {
+      // compatibility of other format
+      return namespace + "." + format;
+    }
+  }
+
+  /**
+   * <pre>
+   *   appId    cluster   namespace       return
+   *   666      default   application     666+default+application.properties
+   *   123      none      action.yml      123+none+action.yml
+   * </pre>
+   */
+  public static String toFilename(
+      final String appId,
+      final String clusterName,
+      final String namespace,
+      final ConfigFileFormat configFileFormat
+  ) {
+    final String suffix;
+    if (ConfigFileFormat.Properties.equals(configFileFormat)) {
+      suffix = "." + ConfigFileFormat.Properties.getValue();
+    } else {
+      suffix = "";
+    }
+    return appId + "+" + clusterName + "+" + namespace + suffix;
+  }
+
+  /**
+   * file path = ownerName/appId/env/configFilename
+   * @return file path in compressed file
+   */
+  public static String toFilePath(
+      final String ownerName,
+      final String appId,
+      final Env env,
+      final String configFilename
+  ) {
+    return String.join(File.separator, ownerName, appId, env.getName(), configFilename);
+  }
+}

+ 1 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/util/ConfigToFileUtils.java

@@ -13,6 +13,7 @@ import java.util.stream.Collectors;
  */
  */
 public class ConfigToFileUtils {
 public class ConfigToFileUtils {
 
 
+  @Deprecated
   public static void itemsToFile(OutputStream os, List<String> items) {
   public static void itemsToFile(OutputStream os, List<String> items) {
     try {
     try {
       PrintWriter printWriter = new PrintWriter(os);
       PrintWriter printWriter = new PrintWriter(os);

+ 72 - 0
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/util/NamespaceBOUtils.java

@@ -0,0 +1,72 @@
+package com.ctrip.framework.apollo.portal.util;
+
+import com.ctrip.framework.apollo.core.ConfigConsts;
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import com.ctrip.framework.apollo.core.utils.PropertiesUtil;
+import com.ctrip.framework.apollo.portal.controller.ConfigsExportController;
+import com.ctrip.framework.apollo.portal.entity.bo.ItemBO;
+import com.ctrip.framework.apollo.portal.entity.bo.NamespaceBO;
+import java.io.IOException;
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * @author wxq
+ */
+public class NamespaceBOUtils {
+
+  /**
+   * namespace must not be {@link ConfigFileFormat#Properties}.
+   * the content of namespace in item's value which item's key is {@link ConfigConsts#CONFIG_FILE_CONTENT_KEY}.
+   * @param namespaceBO namespace
+   * @return content of non-properties's namespace
+   */
+  static String convertNonProperties2configFileContent(NamespaceBO namespaceBO) {
+    List<ItemBO> itemBOS = namespaceBO.getItems();
+    for (ItemBO itemBO : itemBOS) {
+      String key = itemBO.getItem().getKey();
+      // special namespace format(not properties)
+      if (ConfigConsts.CONFIG_FILE_CONTENT_KEY.equals(key)) {
+        return itemBO.getItem().getValue();
+      }
+    }
+    // If there is no items?
+    // return empty string ""
+    return "";
+  }
+
+  /**
+   * copy from old {@link ConfigsExportController}.
+   * convert {@link NamespaceBO} to a file content.
+   * @return content of config file
+   * @throws IllegalStateException if convert properties to string fail
+   */
+  public static String convert2configFileContent(NamespaceBO namespaceBO) {
+    // early return if it is not a properties format namespace
+    if (!ConfigFileFormat.Properties.equals(ConfigFileFormat.fromString(namespaceBO.getFormat()))) {
+      // it is not a properties namespace
+      return convertNonProperties2configFileContent(namespaceBO);
+    }
+
+    // it must be a properties format namespace
+    List<ItemBO> itemBOS = namespaceBO.getItems();
+    // save the kev value pair
+    Properties properties = new Properties();
+    for (ItemBO itemBO : itemBOS) {
+      String key = itemBO.getItem().getKey();
+      String value = itemBO.getItem().getValue();
+      // ignore comment, so the comment will lack
+      properties.put(key, value);
+    }
+
+    // use a special method convert properties to string
+    final String configFileContent;
+    try {
+      configFileContent = PropertiesUtil.toString(properties);
+    } catch (IOException e) {
+      throw new IllegalStateException("convert properties to string fail.", e);
+    }
+    return configFileContent;
+  }
+
+}

+ 89 - 0
apollo-portal/src/main/resources/static/config_export.html

@@ -0,0 +1,89 @@
+<!doctype html>
+<html ng-app="config_export">
+
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+    <link rel="icon" href="img/config.png">
+    <!-- styles -->
+    <link rel="stylesheet" type="text/css" href="vendor/bootstrap/css/bootstrap.min.css">
+    <link rel="stylesheet" type="text/css" href="vendor/angular/angular-toastr-1.4.1.min.css">
+    <link rel="stylesheet" type="text/css" href="vendor/select2/select2.min.css">
+    <link rel="stylesheet" type="text/css" media='all' href="vendor/angular/loading-bar.min.css">
+    <link rel="stylesheet" type="text/css" href="styles/common-style.css">
+    <title>{{'ConfigExport.Title' | translate }}</title>
+</head>
+
+<body>
+    <apollonav></apollonav>
+    <div class="container-fluid">
+        <div class="col-md-8 col-md-offset-2 panel">
+            <section class="panel-body">
+                <div class="row">
+                    <header class="panel-heading">
+                        {{'ConfigExport.Title' | translate }}
+                        <small>
+                            {{'ConfigExport.TitleTips' | translate}}
+                        </small>
+                    </header>
+                    <div class="col-sm-offset-2 col-sm-9">
+                        <a href="export" target="_blank">
+                            <button class="btn btn-block btn-lg btn-primary">
+                                {{'ConfigExport.Download' | translate }}
+                            </button>
+                        </a>
+                    </div>
+                </div>
+            </section>
+        </div>
+    </div>
+
+    <div ng-include="'views/common/footer.html'"></div>
+
+    <!-- jquery.js -->
+    <script src="vendor/jquery.min.js" type="text/javascript"></script>
+
+    <!--angular-->
+    <script src="vendor/angular/angular.min.js"></script>
+    <script src="vendor/angular/angular-route.min.js"></script>
+    <script src="vendor/angular/angular-resource.min.js"></script>
+    <script src="vendor/angular/angular-toastr-1.4.1.tpls.min.js"></script>
+    <script src="vendor/angular/loading-bar.min.js"></script>
+    <script src="vendor/angular/angular-cookies.min.js"></script>
+
+    <script src="vendor/angular/angular-translate.2.18.1/angular-translate.min.js"></script>
+    <script src="vendor/angular/angular-translate.2.18.1/angular-translate-loader-static-files.min.js"></script>
+    <script src="vendor/angular/angular-translate.2.18.1/angular-translate-storage-cookie.min.js"></script>
+
+    <!--valdr-->
+    <script src="vendor/valdr/valdr.min.js" type="text/javascript"></script>
+    <script src="vendor/valdr/valdr-message.min.js" type="text/javascript"></script>
+
+    <!-- bootstrap.js -->
+    <script src="vendor/bootstrap/js/bootstrap.min.js" type="text/javascript"></script>
+
+    <script src="vendor/lodash.min.js"></script>
+
+    <script src="vendor/select2/select2.min.js" type="text/javascript"></script>
+    <!--biz-->
+    <!--must import-->
+    <script type="application/javascript" src="scripts/app.js"></script>
+    <script type="application/javascript" src="scripts/services/AppService.js"></script>
+    <script type="application/javascript" src="scripts/services/EnvService.js"></script>
+    <script type="application/javascript" src="scripts/services/UserService.js"></script>
+    <script type="application/javascript" src="scripts/services/CommonService.js"></script>
+    <script type="application/javascript" src="scripts/services/PermissionService.js"></script>
+    <script type="application/javascript" src="scripts/services/ClusterService.js"></script>
+    <script type="application/javascript" src="scripts/services/NamespaceService.js"></script>
+    <script type="application/javascript" src="scripts/services/SystemInfoService.js"></script>
+
+    <script type="application/javascript" src="scripts/AppUtils.js"></script>
+
+    <script type="application/javascript" src="scripts/PageCommon.js"></script>
+    <script type="application/javascript" src="scripts/directive/directive.js"></script>
+    <script type="application/javascript" src="scripts/valdr.js"></script>
+
+    <script type="application/javascript" src="scripts/AppUtils.js"></script>
+    <script type="application/javascript" src="scripts/services/OrganizationService.js"></script>
+</body>
+
+</html>

+ 5 - 0
apollo-portal/src/main/resources/static/i18n/en.json

@@ -6,12 +6,14 @@
   "Common.Nav.HideNavBar": "Hide navigation bar",
   "Common.Nav.HideNavBar": "Hide navigation bar",
   "Common.Nav.Help": "Help",
   "Common.Nav.Help": "Help",
   "Common.Nav.AdminTools": "Admin Tools",
   "Common.Nav.AdminTools": "Admin Tools",
+  "Common.Nav.NonAdminTools": "Tools",
   "Common.Nav.UserManage": "User Management",
   "Common.Nav.UserManage": "User Management",
   "Common.Nav.SystemRoleManage": "System Permission Management",
   "Common.Nav.SystemRoleManage": "System Permission Management",
   "Common.Nav.OpenMange": "Open Platform Authorization Management",
   "Common.Nav.OpenMange": "Open Platform Authorization Management",
   "Common.Nav.SystemConfig": "System Configuration",
   "Common.Nav.SystemConfig": "System Configuration",
   "Common.Nav.DeleteApp-Cluster-Namespace": "Delete Apps, Clusters, AppNamespace",
   "Common.Nav.DeleteApp-Cluster-Namespace": "Delete Apps, Clusters, AppNamespace",
   "Common.Nav.SystemInfo": "System Information",
   "Common.Nav.SystemInfo": "System Information",
+  "Common.Nav.ConfigExport": "Config Export",
   "Common.Nav.Logout": "Logout",
   "Common.Nav.Logout": "Logout",
   "Common.Department": "Department",
   "Common.Department": "Department",
   "Common.Cluster": "Cluster",
   "Common.Cluster": "Cluster",
@@ -681,6 +683,9 @@
   "Config.Diff.DiffCluster": "Clusters to be compared",
   "Config.Diff.DiffCluster": "Clusters to be compared",
   "Config.Diff.HasDiffComment": "Whether to compare comments or not",
   "Config.Diff.HasDiffComment": "Whether to compare comments or not",
   "Config.Diff.PleaseChooseTwoCluster": "Please select at least two clusters",
   "Config.Diff.PleaseChooseTwoCluster": "Please select at least two clusters",
+  "ConfigExport.Title": "Config Export",
+  "ConfigExport.TitleTips" : "Super administrators will download the configuration of all projects, normal users will only download the configuration of their own projects",
+  "ConfigExport.Download": "Download",
   "App.CreateProject": "Create Project",
   "App.CreateProject": "Create Project",
   "App.AppIdTips": "(Application's unique identifiers)",
   "App.AppIdTips": "(Application's unique identifiers)",
   "App.AppNameTips": "(Suggested format xx-yy-zz e.g. apollo-server)",
   "App.AppNameTips": "(Suggested format xx-yy-zz e.g. apollo-server)",

+ 5 - 0
apollo-portal/src/main/resources/static/i18n/zh-CN.json

@@ -6,12 +6,14 @@
   "Common.Nav.HideNavBar": "隐藏导航栏",
   "Common.Nav.HideNavBar": "隐藏导航栏",
   "Common.Nav.Help": "帮助",
   "Common.Nav.Help": "帮助",
   "Common.Nav.AdminTools": "管理员工具",
   "Common.Nav.AdminTools": "管理员工具",
+  "Common.Nav.NonAdminTools": "工具",
   "Common.Nav.UserManage": "用户管理",
   "Common.Nav.UserManage": "用户管理",
   "Common.Nav.SystemRoleManage": "系统权限管理",
   "Common.Nav.SystemRoleManage": "系统权限管理",
   "Common.Nav.OpenMange": "开放平台授权管理",
   "Common.Nav.OpenMange": "开放平台授权管理",
   "Common.Nav.SystemConfig": "系统参数",
   "Common.Nav.SystemConfig": "系统参数",
   "Common.Nav.DeleteApp-Cluster-Namespace": "删除应用、集群、AppNamespace",
   "Common.Nav.DeleteApp-Cluster-Namespace": "删除应用、集群、AppNamespace",
   "Common.Nav.SystemInfo": "系统信息",
   "Common.Nav.SystemInfo": "系统信息",
+  "Common.Nav.ConfigExport": "配置导出",
   "Common.Nav.Logout": "退出",
   "Common.Nav.Logout": "退出",
   "Common.Department": "部门",
   "Common.Department": "部门",
   "Common.Cluster": "集群",
   "Common.Cluster": "集群",
@@ -681,6 +683,9 @@
   "Config.Diff.DiffCluster": "要比较的集群",
   "Config.Diff.DiffCluster": "要比较的集群",
   "Config.Diff.HasDiffComment": "是否比较注释",
   "Config.Diff.HasDiffComment": "是否比较注释",
   "Config.Diff.PleaseChooseTwoCluster": "请至少选择两个集群",
   "Config.Diff.PleaseChooseTwoCluster": "请至少选择两个集群",
+  "ConfigExport.Title": "配置导出",
+  "ConfigExport.TitleTips" : "超级管理员会下载所有项目的配置,普通用户只会下载自己项目的配置",
+  "ConfigExport.Download": "下载",
   "App.CreateProject": "创建项目",
   "App.CreateProject": "创建项目",
   "App.AppIdTips": "(应用唯一标识)",
   "App.AppIdTips": "(应用唯一标识)",
   "App.AppNameTips": "(建议格式 xx-yy-zz 例:apollo-server)",
   "App.AppNameTips": "(建议格式 xx-yy-zz 例:apollo-server)",

+ 2 - 0
apollo-portal/src/main/resources/static/scripts/app.js

@@ -68,3 +68,5 @@ var delete_app_cluster_namespace_module = angular.module('delete_app_cluster_nam
 var system_info_module = angular.module('system_info', ['app.service', 'apollo.directive', 'app.util', 'toastr', 'angular-loading-bar']);
 var system_info_module = angular.module('system_info', ['app.service', 'apollo.directive', 'app.util', 'toastr', 'angular-loading-bar']);
 //access secretKey
 //access secretKey
 var access_key_module = angular.module('access_key', ['app.service', 'apollo.directive', 'app.util', 'toastr', 'angular-loading-bar']);
 var access_key_module = angular.module('access_key', ['app.service', 'apollo.directive', 'app.util', 'toastr', 'angular-loading-bar']);
+//config export
+var config_export_module = angular.module('config_export', ['app.service', 'apollo.directive', 'app.util', 'toastr', 'angular-loading-bar']);

+ 16 - 2
apollo-portal/src/main/resources/static/views/common/nav.html

@@ -34,19 +34,33 @@
                         <li value="zh-CN"><a href="javascript:void(0)" ng-click="changeLanguage('zh-CN')">简体中文</a></li>
                         <li value="zh-CN"><a href="javascript:void(0)" ng-click="changeLanguage('zh-CN')">简体中文</a></li>
                     </ul>
                     </ul>
                 </li>
                 </li>
-                <li class="dropdown" ng-if="hasRootPermission">
+
+                <!-- admin tool -->
+                <li class="dropdown" ng-if="hasRootPermission == true">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                         <span class="glyphicon glyphicon-cog"></span>&nbsp;{{'Common.Nav.AdminTools' | translate }}
                         <span class="glyphicon glyphicon-cog"></span>&nbsp;{{'Common.Nav.AdminTools' | translate }}
                         <span class="caret"></span></a>
                         <span class="caret"></span></a>
-                    <ul class="dropdown-menu">
+                    <ul class="dropdown-menu" >
                         <li><a ng-href="{{ '/user-manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.UserManage' | translate }}</a></li>
                         <li><a ng-href="{{ '/user-manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.UserManage' | translate }}</a></li>
                         <li><a href="{{ '/system-role-manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemRoleManage' | translate }}</a></li>
                         <li><a href="{{ '/system-role-manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemRoleManage' | translate }}</a></li>
                         <li><a href="{{ '/open/manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.OpenMange' | translate }}</a></li>
                         <li><a href="{{ '/open/manage.html' | prefixPath }}" target="_blank">{{'Common.Nav.OpenMange' | translate }}</a></li>
                         <li><a href="{{ '/server_config.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemConfig' | translate }}</a></li>
                         <li><a href="{{ '/server_config.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemConfig' | translate }}</a></li>
                         <li><a href="{{ '/delete_app_cluster_namespace.html' | prefixPath }}" target="_blank">{{'Common.Nav.DeleteApp-Cluster-Namespace' | translate }}</a></li>
                         <li><a href="{{ '/delete_app_cluster_namespace.html' | prefixPath }}" target="_blank">{{'Common.Nav.DeleteApp-Cluster-Namespace' | translate }}</a></li>
                         <li><a href="{{ '/system_info.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemInfo' | translate }}</a></li>
                         <li><a href="{{ '/system_info.html' | prefixPath }}" target="_blank">{{'Common.Nav.SystemInfo' | translate }}</a></li>
+                        <li><a href="{{ '/config_export.html' | prefixPath }}" target="_blank">{{'Common.Nav.ConfigExport' | translate }}</a></li>
+                    </ul>
+                </li>
+
+                <!-- normal user tool (not admin)-->
+                <li class="dropdown" ng-if="hasRootPermission == false">
+                    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
+                        <span class="glyphicon glyphicon-cog"></span>&nbsp;{{'Common.Nav.NonAdminTools' | translate }}
+                        <span class="caret"></span></a>
+                    <ul class="dropdown-menu" >
+                        <li><a href="{{ '/config_export.html' | prefixPath }}" target="_blank">{{'Common.Nav.ConfigExport' | translate }}</a></li>
                     </ul>
                     </ul>
                 </li>
                 </li>
+
                 <li class="dropdown">
                 <li class="dropdown">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
                         <span class="glyphicon glyphicon-user"></span>&nbsp;{{userName}}
                         <span class="glyphicon glyphicon-user"></span>&nbsp;{{userName}}

+ 84 - 0
apollo-portal/src/test/java/com/ctrip/framework/apollo/portal/util/ConfigFileUtilsTest.java

@@ -0,0 +1,84 @@
+package com.ctrip.framework.apollo.portal.util;
+
+import static org.junit.Assert.*;
+
+import com.ctrip.framework.apollo.common.exception.BadRequestException;
+import com.ctrip.framework.apollo.core.enums.ConfigFileFormat;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ConfigFileUtilsTest {
+
+  private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+  @Test
+  public void checkFormat() {
+    ConfigFileUtils.checkFormat("1234+default+app.properties");
+    ConfigFileUtils.checkFormat("1234+default+app.yml");
+    ConfigFileUtils.checkFormat("1234+default+app.json");
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void checkFormatWithException0() {
+    ConfigFileUtils.checkFormat("1234+defaultes");
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void checkFormatWithException1() {
+    ConfigFileUtils.checkFormat(".json");
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void checkFormatWithException2() {
+    ConfigFileUtils.checkFormat("application.");
+  }
+
+  @Test
+  public void getFormat() {
+    final String properties = ConfigFileUtils.getFormat("application+default+application.properties");
+    assertEquals("properties", properties);
+
+    final String yml = ConfigFileUtils.getFormat("application+default+application.yml");
+    assertEquals("yml", yml);
+  }
+
+  @Test
+  public void getAppId() {
+    final String application = ConfigFileUtils.getAppId("application+default+application.properties");
+    assertEquals("application", application);
+
+    final String abc = ConfigFileUtils.getAppId("abc+default+application.yml");
+    assertEquals("abc", abc);
+  }
+
+  @Test
+  public void getClusterName() {
+    final String cluster = ConfigFileUtils.getClusterName("application+default+application.properties");
+    assertEquals("default", cluster);
+
+    final String Beijing = ConfigFileUtils.getClusterName("abc+Beijing+application.yml");
+    assertEquals("Beijing", Beijing);
+  }
+
+  @Test
+  public void getNamespace() {
+    final String application = ConfigFileUtils.getNamespace("234+default+application.properties");
+    assertEquals("application", application);
+
+    final String applicationYml = ConfigFileUtils.getNamespace("abc+default+application.yml");
+    assertEquals("application.yml", applicationYml);
+  }
+
+  @Test
+  public void toFilename() {
+    final String propertiesFilename0 = ConfigFileUtils.toFilename("123", "default", "application", ConfigFileFormat.Properties);
+    logger.info("propertiesFilename0 {}", propertiesFilename0);
+    assertEquals("123+default+application.properties", propertiesFilename0);
+
+    final String ymlFilename0 = ConfigFileUtils.toFilename("666", "none", "cc.yml", ConfigFileFormat.YML);
+    logger.info("ymlFilename0 {}", ymlFilename0);
+    assertEquals("666+none+cc.yml", ymlFilename0);
+  }
+
+}