diff --git a/src/main/java/run/halo/app/exception/UnsupportedMediaTypeException.java b/src/main/java/run/halo/app/exception/UnsupportedMediaTypeException.java new file mode 100644 index 000000000..60da74068 --- /dev/null +++ b/src/main/java/run/halo/app/exception/UnsupportedMediaTypeException.java @@ -0,0 +1,18 @@ +package run.halo.app.exception; + +/** + * Unsupported media type exception. + * + * @author johnniang + * @date 19-4-19 + */ +public class UnsupportedMediaTypeException extends BadRequestException { + + public UnsupportedMediaTypeException(String message) { + super(message); + } + + public UnsupportedMediaTypeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/run/halo/app/model/support/HaloConst.java b/src/main/java/run/halo/app/model/support/HaloConst.java index 820064065..5271d0eea 100644 --- a/src/main/java/run/halo/app/model/support/HaloConst.java +++ b/src/main/java/run/halo/app/model/support/HaloConst.java @@ -1,7 +1,6 @@ package run.halo.app.model.support; import java.util.Collections; -import java.util.List; import java.util.Map; /** diff --git a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java index 4246dfdc1..192ea008f 100644 --- a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java @@ -5,7 +5,6 @@ import cn.hutool.core.io.file.FileReader; import cn.hutool.core.io.file.FileWriter; import cn.hutool.core.text.StrBuilder; import cn.hutool.core.util.StrUtil; -import cn.hutool.core.util.ZipUtil; import freemarker.template.Configuration; import freemarker.template.TemplateModelException; import lombok.extern.slf4j.Slf4j; @@ -28,6 +27,7 @@ import run.halo.app.model.support.HaloConst; import run.halo.app.model.support.ThemeFile; import run.halo.app.service.OptionService; import run.halo.app.service.ThemeService; +import run.halo.app.service.support.HaloMediaType; import run.halo.app.utils.FileUtils; import run.halo.app.utils.FilenameUtils; import run.halo.app.utils.JsonUtils; @@ -39,6 +39,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; +import java.util.zip.ZipInputStream; import static run.halo.app.model.support.HaloConst.DEFAULT_THEME_ID; @@ -222,7 +223,7 @@ public class ThemeServiceImpl implements ThemeService { FileUtil.del(Paths.get(themeProperty.getThemePath())); // Delete theme cache - cacheStore.delete(THEMES_CACHE_KEY); + clearThemeCache(); } catch (Exception e) { throw new ServiceException("Failed to delete theme folder", e).setErrorData(themeId); } @@ -296,7 +297,7 @@ public class ThemeServiceImpl implements ThemeService { setActivatedThemeId(themeId); // Clear the cache - cacheStore.delete(THEMES_CACHE_KEY); + clearThemeCache(); try { // TODO Refactor here in the future @@ -313,31 +314,39 @@ public class ThemeServiceImpl implements ThemeService { public ThemeProperty upload(MultipartFile file) { Assert.notNull(file, "Multipart file must not be null"); - // Get upload path - Path uploadPath = Paths.get(workDir.toString(), file.getOriginalFilename()); + if (!HaloMediaType.isZipType(file.getContentType())) { + throw new UnsupportedMediaTypeException("Unsupported theme media type: " + file.getContentType()).setErrorData(file.getOriginalFilename()); + } - final String originalBasename = FilenameUtils.getBasename(file.getOriginalFilename()); - - log.info("Uploading theme to directory: [{}]", uploadPath.toString()); + ZipInputStream zis = null; + Path tempPath = null; try { - // Create directory - Files.createDirectories(uploadPath.getParent()); - Files.createFile(uploadPath); - file.transferTo(uploadPath); + // Create temp directory + tempPath = Files.createTempDirectory("halo"); + String basename = FilenameUtils.getBasename(file.getOriginalFilename()); + Path themeTempPath = tempPath.resolve(basename); - // Unzip theme package - ZipUtil.unzip(uploadPath.toFile(), uploadPath.getParent().toFile()); + // Check directory traversal + FileUtils.checkDirectoryTraversal(tempPath, themeTempPath); - // Delete theme package - FileUtil.del(uploadPath.toFile()); + // New zip input stream + zis = new ZipInputStream(file.getInputStream()); - cacheStore.delete(THEMES_CACHE_KEY); + // Unzip to temp path + FileUtils.unzip(zis, themeTempPath); - return getProperty(Paths.get(workDir.toString(), originalBasename)); + // Go to the base folder + + // Add the theme to system + return add(FileUtils.skipZipParentFolder(themeTempPath)); } catch (IOException e) { - log.error("Failed to upload theme to local: " + uploadPath, e); - throw new ServiceException("Failed to upload theme to local").setErrorData(uploadPath); + throw new ServiceException("Failed to upload theme file: " + file.getOriginalFilename(), e); + } finally { + // Close zip input stream + FileUtils.closeQuietly(zis); + // Delete folder after testing + FileUtils.deleteFolderQuietly(tempPath); } } @@ -347,19 +356,35 @@ public class ThemeServiceImpl implements ThemeService { Assert.isTrue(Files.isDirectory(themeTmpPath), "Theme temporary path must be a directory"); // Check property config - Path configPath = getThemePropertyPathOfNullable(themeTmpPath).orElseThrow(() -> new ThemePropertyMissingException("Theme property file is dismiss").setErrorData(themeTmpPath)); + ThemeProperty tmpThemeProperty = getProperty(themeTmpPath); - ThemeProperty tmpThemeProperty = getProperty(configPath); + // Check theme existence + boolean isExist = getThemes().stream() + .anyMatch(themeProperty -> themeProperty.getId().equalsIgnoreCase(tmpThemeProperty.getId())); + + if (isExist) { + throw new AlreadyExistsException("The theme with id " + tmpThemeProperty.getId() + " has already existed"); + } // Copy the temporary path to current theme folder Path targetThemePath = workDir.resolve(tmpThemeProperty.getId()); FileUtils.copyFolder(themeTmpPath, targetThemePath); - // Delete temp theme folder - FileUtils.deleteFolder(themeTmpPath); - // Get property again - return getProperty(targetThemePath); + ThemeProperty property = getProperty(targetThemePath); + + // Clear theme cache + clearThemeCache(); + + // Delete cache + return property; + } + + /** + * Clears theme cache. + */ + private void clearThemeCache() { + cacheStore.delete(THEMES_CACHE_KEY); } /** @@ -446,15 +471,8 @@ public class ThemeServiceImpl implements ThemeService { * @throws ForbiddenException throws when the given absolute directory name is invalid */ private void checkDirectory(@NonNull String absoluteName) { - Assert.hasText(absoluteName, "Absolute name must not be blank"); - ThemeProperty activeThemeProperty = getThemeOfNonNullBy(getActivatedThemeId()); - - boolean valid = Paths.get(absoluteName).startsWith(activeThemeProperty.getThemePath()); - - if (!valid) { - throw new ForbiddenException("You cannot access " + absoluteName).setErrorData(absoluteName); - } + FileUtils.checkDirectoryTraversal(activeThemeProperty.getThemePath(), absoluteName); } /** diff --git a/src/main/java/run/halo/app/service/support/HaloMediaType.java b/src/main/java/run/halo/app/service/support/HaloMediaType.java new file mode 100644 index 000000000..4c74a1889 --- /dev/null +++ b/src/main/java/run/halo/app/service/support/HaloMediaType.java @@ -0,0 +1,87 @@ +package run.halo.app.service.support; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.MediaType; + +import java.nio.charset.Charset; +import java.util.Map; + +/** + * Halo Media type. + * + * @author johnniang + * @date 19-4-18 + */ +public class HaloMediaType extends MediaType { + + /** + * Public constant media type of {@code application/zip} + */ + public static final MediaType APPLICATION_ZIP; + + /** + * A String equivalent of {@link HaloMediaType#APPLICATION_ZIP} + */ + public static final String APPLICATION_ZIP_VALUE = "application/zip"; + + + static { + APPLICATION_ZIP = valueOf(APPLICATION_ZIP_VALUE); + } + + public HaloMediaType(String type) { + super(type); + } + + public HaloMediaType(String type, String subtype) { + super(type, subtype); + } + + public HaloMediaType(String type, String subtype, Charset charset) { + super(type, subtype, charset); + } + + public HaloMediaType(String type, String subtype, double qualityValue) { + super(type, subtype, qualityValue); + } + + public HaloMediaType(MediaType other, Charset charset) { + super(other, charset); + } + + public HaloMediaType(MediaType other, Map parameters) { + super(other, parameters); + } + + public HaloMediaType(String type, String subtype, Map parameters) { + super(type, subtype, parameters); + } + + /** + * Checks whether the media type is zip type or not . + * + * @param mediaType media type + * @return true if the given media type is zip type; false otherwise + */ + public static boolean isZipType(MediaType mediaType) { + if (mediaType == null) { + return false; + } + + return mediaType.includes(APPLICATION_ZIP); + } + + /** + * Checks whether the media type is zip type or not . + * + * @param contentType content type + * @return true if the given content type is zip type; false otherwise + */ + public static boolean isZipType(String contentType) { + if (StringUtils.isBlank(contentType)) { + return false; + } + + return isZipType(valueOf(contentType)); + } +} diff --git a/src/main/java/run/halo/app/utils/FileUtils.java b/src/main/java/run/halo/app/utils/FileUtils.java index adae39b92..9baaf5bb7 100644 --- a/src/main/java/run/halo/app/utils/FileUtils.java +++ b/src/main/java/run/halo/app/utils/FileUtils.java @@ -12,6 +12,8 @@ import java.io.InputStream; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -108,6 +110,25 @@ public class FileUtils { } } + /** + * Skips zip parent folder. (Go into base folder) + * + * @param unzippedPath unzipped path must not be null + * @return path containing base files + * @throws IOException + */ + public static Path skipZipParentFolder(@NonNull Path unzippedPath) throws IOException { + Assert.notNull(unzippedPath, "Unzipped folder must not be null"); + + List childrenPath = Files.list(unzippedPath).collect(Collectors.toList()); + + if (childrenPath.size() == 1 && Files.isDirectory(childrenPath.get(0))) { + return childrenPath.get(0); + } + + return unzippedPath; + } + /** * Creates directories if absent. * diff --git a/src/test/java/run/halo/app/utils/FileUtilsTest.java b/src/test/java/run/halo/app/utils/FileUtilsTest.java new file mode 100644 index 000000000..ee8baebb8 --- /dev/null +++ b/src/test/java/run/halo/app/utils/FileUtilsTest.java @@ -0,0 +1,56 @@ +package run.halo.app.utils; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.equalTo; + +/** + * @author johnniang + * @date 19-4-19 + */ +public class FileUtilsTest { + + @Test + public void deleteFolder() throws IOException { + // Create a temp folder + Path tempDirectory = Files.createTempDirectory("halo-test"); + + Path testPath = tempDirectory.resolve("test/test/test"); + + // Create test folders + Files.createDirectories(testPath); + + System.out.println("Walk path list"); + List walkList = Files.walk(tempDirectory).collect(Collectors.toList()); + walkList.forEach(System.out::println); + Assert.assertThat(walkList.size(), equalTo(4)); + + + System.out.println("Walk 1 deep path list"); + List walk1DeepList = Files.walk(tempDirectory, 1).collect(Collectors.toList()); + walk1DeepList.forEach(System.out::println); + Assert.assertThat(walk1DeepList.size(), equalTo(2)); + + System.out.println("List path list"); + List listList = Files.list(tempDirectory).collect(Collectors.toList()); + listList.forEach(System.out::println); + Assert.assertThat(listList.size(), equalTo(1)); + + System.out.println("List test path list"); + List testPathList = Files.list(testPath).collect(Collectors.toList()); + testPathList.forEach(System.out::println); + Assert.assertThat(testPathList.size(), equalTo(0)); + + // Delete it + FileUtils.deleteFolder(tempDirectory); + + Assert.assertTrue(Files.notExists(tempDirectory)); + } +} \ No newline at end of file