+
+
\ No newline at end of file
diff --git a/files/pom.xml b/files/pom.xml
index 7be073b..47bec73 100644
--- a/files/pom.xml
+++ b/files/pom.xml
@@ -15,46 +15,61 @@
jar
-
-
-
-
-
-
-
-
+
+ de.77zzcx7.nbs-cloud
+ web-container-registry
+
+
+ de.77zzcx7.nbs-cloud
+ web-container-config
+
+
org.springframework.bootspring-boot-starter-thymeleaf
+ providedorg.springframework.bootspring-boot-starter-web
-
-
-
-
-
-
-
-
-
-
-
- org.springframework.boot
- spring-boot-starter-tomcatprovided
-
-
-
-
-
-
-
-
-
-
+
+ org.overviewproject
+ mime-types
+
+
+ commons-io
+ commons-io
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/files/src/main/java/de/nbscloud/files/FileSystemService.java b/files/src/main/java/de/nbscloud/files/FileSystemService.java
index 912f7eb..98677b9 100644
--- a/files/src/main/java/de/nbscloud/files/FileSystemService.java
+++ b/files/src/main/java/de/nbscloud/files/FileSystemService.java
@@ -1,20 +1,82 @@
package de.nbscloud.files;
import de.nbscloud.files.config.FilesConfig;
+import de.nbscloud.files.controller.FilesController;
import de.nbscloud.files.exception.FileSystemServiceException;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.overviewproject.mime_types.GetBytesException;
+import org.overviewproject.mime_types.MimeTypeDetector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.*;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
@Service
public class FileSystemService {
- public record ContentContainer(boolean directory, String name, long size) {
+ private static final Logger logger = LoggerFactory.getLogger(FileSystemService.class);
+
+ public static record ContentContainer(boolean directory, Path path, String name, long size,
+ LocalDateTime lastModified) {
+ }
+
+ public enum SortOrder {
+ /**
+ * First by type (directories first), then by name ignoring case
+ */
+ NATURAL(Comparator.comparing(ContentContainer::directory)
+ .reversed()
+ .thenComparing(con -> con.path().getFileName().toString().toLowerCase())),
+
+ NATURAL_REVERSE(NATURAL.comparator.reversed()),
+
+ SIZE_DESC(Comparator.comparing(ContentContainer::size).reversed()),
+
+ SIZE_ASC(Comparator.comparing(ContentContainer::size)),
+
+ LAST_MOD_DESC(Comparator.comparing(ContentContainer::lastModified).reversed()),
+
+ LAST_MOD_ASC(Comparator.comparing(ContentContainer::lastModified));
+
+ private Comparator comparator;
+
+ SortOrder(Comparator comparator) {
+ this.comparator = comparator;
+ }
+
+ public static SortOrder ofQueryParam(String param) {
+ return Arrays.stream(values()).filter(so -> so.name().equalsIgnoreCase(param)).findFirst()
+ .orElse(SortOrder.NATURAL);
+ }
+ }
+
+ public enum MimeTypeFilter {
+ IMAGES("image/jpeg", "image/png"),
+ VIDEOS("video/mpeg", "video/mp4", "video/quicktime", "video/x-msvideo"),
+ AUDIO("audio/mpeg", "audio/x-wav", "audio/x-flac", "audio/flac", "audio/MPA", "audio/mpa-robust");
+
+ private List mimeTypes;
+
+ MimeTypeFilter(String... mimeTypes) {
+ this.mimeTypes = Arrays.asList(mimeTypes);
+ }
+
+ public boolean match(String mimeType) {
+ return this.mimeTypes.contains(mimeType);
+ }
}
@Autowired
@@ -23,44 +85,211 @@ public class FileSystemService {
@Autowired
private LocationTracker locationTracker;
+ private MimeTypeDetector mimeTypeDetector = new MimeTypeDetector();
+
public Path createDirectory(String name) {
try {
- return Files.createDirectory(this.locationTracker.getCurrentLocation().resolve(name));
+ return Files.createDirectories(this.locationTracker.getCurrentLocation().resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not create directory", e);
}
}
+ public void createFile(String name, byte[] content) {
+ try {
+ Files.write(this.locationTracker.getCurrentLocation()
+ .resolve(name), content, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not create file", e);
+ }
+ }
+
+ public void createFile(Path path, byte[] content) {
+ try {
+ Files.write(path, content, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not create file", e);
+ }
+ }
+
public boolean delete(String name) {
try {
+ // TODO does only delete dirs if they are empty - but maybe we want that?
return Files.deleteIfExists(this.locationTracker.getCurrentLocation().resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not delete file", e);
}
}
- // TODO soft vs hard delete - first move into /trash then delete from there
public Path move(Path originalPath, Path newPath) {
try {
- return Files.move(originalPath, newPath);
+ return Files.move(originalPath, newPath, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
throw new FileSystemServiceException("Could not move file", e);
}
}
- public List list() {
+ public byte[] get(String name) {
try {
- return Files.list(this.locationTracker.getCurrentLocation())
- .map(path -> {
- try {
- return new ContentContainer(Files.isDirectory(path),
- path.getFileName().toString(),
- Files.size(path));
- } catch (IOException e) {
- throw new FileSystemServiceException("Could not list files", e);
- }
- })
- .collect(Collectors.toList());
+ return Files.readAllBytes(this.locationTracker.getCurrentLocation().resolve(name));
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not get file", e);
+ }
+ }
+
+ public void stream(String name, OutputStream outputStream) {
+ try {
+ Files.copy(this.locationTracker.getCurrentLocation().resolve(name), outputStream);
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not get file", e);
+ }
+ }
+
+ public InputStream stream(String name) {
+ try {
+ return Files.newInputStream(this.locationTracker.getCurrentLocation().resolve(name));
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not get file", e);
+ }
+ }
+
+ public long getSize(String name) {
+ try {
+ return Files.size(this.locationTracker.getCurrentLocation().resolve(name));
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not get file", e);
+ }
+ }
+
+ public String getMimeType(String name) {
+ try {
+ final String detectedMimeType = this.mimeTypeDetector.detectMimeType(this.locationTracker.getCurrentLocation()
+ .resolve(name));
+
+ logger.debug("Detected mime type {} for file {}", detectedMimeType, name);
+
+ return detectedMimeType;
+ } catch (GetBytesException e) {
+ throw new FileSystemServiceException("Could not get mime type", e);
+ }
+ }
+
+ public List getFilesWithMimeType(MimeTypeFilter mimeTypeFilter) {
+ try {
+ final Map> fileFutureMap = new HashMap<>();
+
+ Files.list(this.locationTracker.getCurrentLocation())
+ .filter(Files::isRegularFile)
+ .forEach(path -> {
+ fileFutureMap.put(path.getFileName().toString(), this.mimeTypeDetector.detectMimeTypeAsync(path)
+ .toCompletableFuture());
+ });
+
+ CompletableFuture.allOf(fileFutureMap.values().toArray(new CompletableFuture[0])).join();
+
+ return fileFutureMap.entrySet().stream().filter(e -> {
+ try {
+ return mimeTypeFilter.match(e.getValue().get());
+ } catch (InterruptedException | ExecutionException ex) {
+ throw new FileSystemServiceException("Could not determine mime types", ex);
+ }
+ }).map(Map.Entry::getKey).collect(Collectors.toList());
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not get file", e);
+ }
+ }
+
+ public List list(SortOrder sortOrder) {
+ try {
+ List contentList = Files.list(this.locationTracker.getCurrentLocation())
+ .filter(path -> {
+ boolean ok = Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isDirectory(path);
+
+ try {
+ return ok && (this.filesConfig.isFilterHidden() ? !Files.isHidden(path) : true);
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not filter files", e);
+ }
+ })
+ .map(path -> {
+ try {
+ final boolean isDir = Files.isDirectory(path);
+
+ return new ContentContainer(isDir,
+ this.locationTracker.getRelativeToBaseDir(path),
+ path.getFileName().toString(),
+ isDir ? FileUtils.sizeOfDirectory(path.toFile()) : Files.size(path),
+ LocalDateTime.ofInstant(Files.getLastModifiedTime(path)
+ .toInstant(), ZoneId.systemDefault()));
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not list files", e);
+ }
+ })
+ .collect(Collectors.toList());
+ contentList.sort(sortOrder.comparator);
+
+ return contentList;
+ } catch (IOException e) {
+ throw new FileSystemServiceException("Could not list files", e);
+ }
+ }
+
+ public List collectDirs(String sourceFile) {
+ try {
+ final List resultList = new ArrayList<>();
+ final Path sourcePath = this.locationTracker.getCurrentLocation().resolve(sourceFile);
+ final boolean sourceIsDir = Files.isDirectory(sourcePath);
+
+ Files.walkFileTree(this.locationTracker.getBaseDirPath(), new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
+ super.visitFile(dir, attrs);
+
+ if (sourceIsDir && sourcePath.equals(dir)) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+
+ if (filesConfig.isFilterHidden() ? Files.isHidden(dir) : false) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+
+ if (dir.toString().contains(filesConfig.getTrashBinName())) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+
+ if (dir.toString().contains(filesConfig.getSharesName())) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+
+ final String relPath = locationTracker.getRelativeToBaseDir(dir).toString();
+
+ resultList.add("/" + relPath);
+
+ return FileVisitResult.CONTINUE;
+ }
+
+ public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+
+ public FileVisitResult visitFileFailed(Path file, IOException exc) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ });
+
+ Collections.sort(resultList);
+ resultList.sort((s1, s2) -> {
+ if (StringUtils.equals(s1, "/")) {
+ return 1;
+ }
+ if (StringUtils.equals(s2, "/")) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ return resultList;
} catch (IOException e) {
throw new FileSystemServiceException("Could not list files", e);
}
diff --git a/files/src/main/java/de/nbscloud/files/FilesApp.java b/files/src/main/java/de/nbscloud/files/FilesApp.java
new file mode 100644
index 0000000..f49f557
--- /dev/null
+++ b/files/src/main/java/de/nbscloud/files/FilesApp.java
@@ -0,0 +1,38 @@
+package de.nbscloud.files;
+
+import de.nbscloud.webcontainer.registry.App;
+import de.nbscloud.webcontainer.registry.AppRegistry;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class FilesApp implements App, InitializingBean {
+ @Autowired
+ private AppRegistry appRegistry;
+
+ @Override
+ public String getId() {
+ return "files";
+ }
+
+ @Override
+ public String getIcon() {
+ return null;
+ }
+
+ @Override
+ public String getStartPath() {
+ return this.getId() + "/browse";
+ }
+
+ @Override
+ public int getIndex() {
+ return 10;
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ this.appRegistry.registerApp(this);
+ }
+}
diff --git a/files/src/main/java/de/nbscloud/files/FilesFormatter.java b/files/src/main/java/de/nbscloud/files/FilesFormatter.java
new file mode 100644
index 0000000..9749044
--- /dev/null
+++ b/files/src/main/java/de/nbscloud/files/FilesFormatter.java
@@ -0,0 +1,110 @@
+package de.nbscloud.files;
+
+import de.nbscloud.files.config.FilesConfig;
+import org.apache.commons.io.FileUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.math.MathContext;
+import java.math.RoundingMode;
+
+@Component
+public class FilesFormatter {
+ enum Units {
+ P(FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB, null),
+ T(FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB, P),
+ G(FileUtils.ONE_KB * FileUtils.ONE_KB * FileUtils.ONE_KB, T),
+ M(FileUtils.ONE_KB * FileUtils.ONE_KB, G),
+ K(FileUtils.ONE_KB, M);
+
+ final long minByteSize;
+ final Units immediatlyHigherUnit;
+
+ private Units(long minByteSize, Units immediatlyHigherUnit) {
+ this.minByteSize = minByteSize;
+ this.immediatlyHigherUnit = immediatlyHigherUnit;
+ }
+ }
+
+ private static final MathContext MC_BYTE_TO_HUMAN_LESS_100 = new MathContext(2, RoundingMode.UP);
+ private static final MathContext MC_BYTE_TO_HUMAN_LESS_1000 = new MathContext(3, RoundingMode.UP);
+ private static final MathContext MC_BYTE_TO_HUMAN_MORE_1000 = new MathContext(4, RoundingMode.UP);
+
+ @Autowired
+ private FilesConfig filesConfig;
+
+ public String truncateFilename(String name) {
+ String filename = name;
+ final int lastDotIndex = filename.lastIndexOf(".");
+ final int configMaxLength = this.filesConfig.getTruncateFileNameChars() - 5; // -5 for ellipses " ... "
+ final int filenameLength = filename.length();
+
+ if (lastDotIndex <= 0) {
+ if ((configMaxLength + 5) >= filenameLength) {
+ return filename;
+ } else {
+ return filename.substring(0, configMaxLength) + "...";
+ }
+ }
+
+ final String suffix = filename.substring(lastDotIndex, filenameLength);
+
+ if ((configMaxLength + 5) >= filenameLength) {
+ return filename;
+ } else {
+ filename = filename.replace(suffix, "");
+
+ return filename.substring(0, configMaxLength - suffix.length()) + " ... " + suffix;
+ }
+ }
+
+ public boolean needsTruncate(String name) {
+ return !name.equals(truncateFilename(name));
+ }
+
+ // Taken from: https://issues.apache.org/jira/secure/attachment/12670724/byteCountToHumanReadableGnu.patch
+ public String formatSize(final long size) {
+ String humanReadableOutput = null;
+ for (Units u : Units.values()) {
+ if (size >= u.minByteSize) {
+ double numOfUnits = (double) size / (double) u.minByteSize;
+
+ // if there is no decimals and the number is less than ten
+ // then we want to format as X.0; numOfUnits.toString() does it for us
+ if (Math.ceil(numOfUnits) == numOfUnits && numOfUnits < 10d) {
+ humanReadableOutput = numOfUnits + u.toString();
+ } else {
+ // we need to do some rounding
+ BigDecimal bdNumbOfUnits = new BigDecimal(numOfUnits);
+
+ if (numOfUnits < 100d) {
+ bdNumbOfUnits = bdNumbOfUnits.round(MC_BYTE_TO_HUMAN_LESS_100);
+ } else if (numOfUnits < 1000d) {
+ bdNumbOfUnits = bdNumbOfUnits.round(MC_BYTE_TO_HUMAN_LESS_1000);
+ } else {
+ bdNumbOfUnits = bdNumbOfUnits.round(MC_BYTE_TO_HUMAN_MORE_1000);
+ // special case, if we get 1024, we should display one of the higher unit!!
+ if (bdNumbOfUnits.longValue() == FileUtils.ONE_KB && u.immediatlyHigherUnit != null) {
+ humanReadableOutput = "1.0" + u.immediatlyHigherUnit.toString();
+ break;
+ }
+ }
+
+ humanReadableOutput = bdNumbOfUnits.toPlainString() + u.toString();
+ }
+ }
+ // exit the loop
+ if (humanReadableOutput != null) {
+ break;
+ }
+ }
+
+ if (humanReadableOutput == null) {
+ humanReadableOutput = "" + size;
+ }
+ return humanReadableOutput;
+ }
+
+}
diff --git a/files/src/main/java/de/nbscloud/files/LocationTracker.java b/files/src/main/java/de/nbscloud/files/LocationTracker.java
index d527cfe..ded9ff4 100644
--- a/files/src/main/java/de/nbscloud/files/LocationTracker.java
+++ b/files/src/main/java/de/nbscloud/files/LocationTracker.java
@@ -1,6 +1,9 @@
package de.nbscloud.files;
import de.nbscloud.files.config.FilesConfig;
+import de.nbscloud.files.controller.FilesController;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -9,6 +12,8 @@ import java.nio.file.Paths;
@Component
public class LocationTracker {
+ private static final Logger logger = LoggerFactory.getLogger(LocationTracker.class);
+
@Autowired
private FilesConfig filesConfig;
@@ -17,10 +22,14 @@ public class LocationTracker {
public void init() {
this.baseDirPath = Paths.get(this.filesConfig.getBaseDir());
- this.currentLocation = baseDirPath.resolve("");
+ this.currentLocation = this.baseDirPath.resolve("");
+
+ logger.info("Initialized location to {}", this.currentLocation);
}
public void reset() {
+ logger.debug("Reset location");
+
init();
}
@@ -28,10 +37,34 @@ public class LocationTracker {
return this.currentLocation;
}
+ public Path getRelativeLocation() {
+ return this.baseDirPath.relativize(this.currentLocation);
+ }
+
+ public Path getRelativeToBaseDir(Path other) {
+ return this.baseDirPath.relativize(other);
+ }
+
+ public Path getTrashBin() {
+ return this.baseDirPath.resolve(this.filesConfig.getTrashBinName());
+ }
+
+ public boolean isTrashBin() {
+ return this.currentLocation.equals(this.getTrashBin());
+ }
+
+ public boolean isBasePath() {
+ return this.baseDirPath.equals(this.currentLocation);
+ }
+
+ public Path getParent() {
+ return this.baseDirPath.relativize(this.currentLocation.getParent());
+ }
+
public void setCurrentLocation(String navigateTo) {
validate(navigateTo);
- this.currentLocation = this.currentLocation.resolve(navigateTo);
+ this.currentLocation = this.baseDirPath.resolve(navigateTo);
}
private void validate(String navigateTo) {
@@ -47,8 +80,12 @@ public class LocationTracker {
throw new IllegalStateException("Absolute path: " + navigateTo);
}
- if(!this.currentLocation.resolve(navigateTo).startsWith(this.baseDirPath)) {
+ if(!this.baseDirPath.resolve(navigateTo).startsWith(this.baseDirPath)) {
throw new IllegalStateException("Illegal path: " + navigateTo);
}
}
+
+ public Path getBaseDirPath() {
+ return baseDirPath;
+ }
}
diff --git a/files/src/main/java/de/nbscloud/files/config/FilesConfig.java b/files/src/main/java/de/nbscloud/files/config/FilesConfig.java
index 698d33d..d59806e 100644
--- a/files/src/main/java/de/nbscloud/files/config/FilesConfig.java
+++ b/files/src/main/java/de/nbscloud/files/config/FilesConfig.java
@@ -2,11 +2,18 @@ package de.nbscloud.files.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
@Configuration
@ConfigurationProperties(prefix = "nbs-cloud.files")
+@PropertySource("classpath:/config/files-application.properties")
public class FilesConfig {
private String baseDir;
+ private boolean filterHidden;
+ private boolean useTrashBin;
+ private String trashBinName;
+ private int truncateFileNameChars;
+ private String sharesName;
public String getBaseDir() {
return baseDir;
@@ -15,4 +22,44 @@ public class FilesConfig {
public void setBaseDir(String baseDir) {
this.baseDir = baseDir;
}
+
+ public boolean isFilterHidden() {
+ return filterHidden;
+ }
+
+ public void setFilterHidden(boolean filterHidden) {
+ this.filterHidden = filterHidden;
+ }
+
+ public boolean isUseTrashBin() {
+ return useTrashBin;
+ }
+
+ public void setUseTrashBin(boolean useTrashBin) {
+ this.useTrashBin = useTrashBin;
+ }
+
+ public String getTrashBinName() {
+ return trashBinName;
+ }
+
+ public void setTrashBinName(String trashBinName) {
+ this.trashBinName = trashBinName;
+ }
+
+ public int getTruncateFileNameChars() {
+ return truncateFileNameChars;
+ }
+
+ public void setTruncateFileNameChars(int truncateFileNameChars) {
+ this.truncateFileNameChars = truncateFileNameChars;
+ }
+
+ public String getSharesName() {
+ return sharesName;
+ }
+
+ public void setSharesName(String sharesName) {
+ this.sharesName = sharesName;
+ }
}
diff --git a/files/src/main/java/de/nbscloud/files/controller/FilesController.java b/files/src/main/java/de/nbscloud/files/controller/FilesController.java
new file mode 100644
index 0000000..391b349
--- /dev/null
+++ b/files/src/main/java/de/nbscloud/files/controller/FilesController.java
@@ -0,0 +1,336 @@
+package de.nbscloud.files.controller;
+
+import de.nbscloud.files.FileSystemService;
+import de.nbscloud.files.LocationTracker;
+import de.nbscloud.files.config.FilesConfig;
+import de.nbscloud.files.exception.FileSystemServiceException;
+import de.nbscloud.files.form.RenameForm;
+import de.nbscloud.webcontainer.registry.AppRegistry;
+import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.util.UriUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Controller
+public class FilesController implements InitializingBean {
+
+ private static final Logger logger = LoggerFactory.getLogger(FilesController.class);
+
+ @Autowired
+ private FilesConfig filesConfig;
+ @Autowired
+ private LocationTracker locationTracker;
+ @Autowired
+ private WebContainerSharedConfig webContainerSharedConfig;
+ @Autowired
+ private FileSystemService fileSystemService;
+ @Autowired
+ private AppRegistry appRegistry;
+
+ // We have to temporarily store messages as we redirect: in the manipulation methods
+ // so everything we add to the model will be gone
+ private final List errors = new ArrayList<>();
+ private final List shareInfo = new ArrayList<>();
+ private final List infoMessages = new ArrayList<>();
+
+ @GetMapping("/files/browse/**")
+ public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
+ final FileSystemService.SortOrder order = FileSystemService.SortOrder.ofQueryParam(sortOrder);
+
+ updateLocation(httpServletRequest);
+
+ model.addAttribute("errors", getAndClear(this.errors));
+ model.addAttribute("infoMessages", getAndClear(this.infoMessages));
+ model.addAttribute("shareInfo", getAndClear(this.shareInfo));
+ model.addAttribute("currentLocation", getCurrentLocationPrefixed());
+ model.addAttribute("content", getContent(order));
+ model.addAttribute("sortOrder", order);
+ model.addAttribute("apps", this.appRegistry.getAll());
+ this.webContainerSharedConfig.addDefaults(model);
+
+ return "files/filesIndex";
+ }
+
+ @GetMapping("/files/delete")
+ public String deleteFile(String filename) {
+ if (this.filesConfig.isUseTrashBin() && !this.locationTracker.isTrashBin()) {
+ try {
+ // Soft delete
+ this.fileSystemService.move(
+ this.locationTracker.getCurrentLocation().resolve(Path.of(filename)),
+ this.locationTracker.getTrashBin().resolve(filename));
+
+ this.infoMessages.add("nbscloud.files.delete.success");
+ } catch (RuntimeException e) {
+ logger.error("Could not soft delete file", e);
+
+ this.errors.add(e.getMessage());
+ }
+ } else {
+ // Hard delete
+ this.fileSystemService.delete(filename);
+
+ this.infoMessages.add("nbscloud.files.delete.success");
+ }
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+
+ @GetMapping("/files/rename")
+ public String rename(Model model, String filename) {
+ model.addAttribute("currentLocation", getCurrentLocationPrefixed());
+ model.addAttribute("form", new RenameForm(filename, getCurrentLocation(), filename));
+ model.addAttribute("targetDirs", this.fileSystemService.collectDirs(filename));
+ model.addAttribute("apps", this.appRegistry.getAll());
+ this.webContainerSharedConfig.addDefaults(model);
+
+ return "files/rename";
+ }
+
+ @PostMapping("/files/doRename")
+ public String doRename(RenameForm form) {
+ final Path sourcePath = this.locationTracker.getCurrentLocation().resolve(Path.of(form.getOriginalFilename()));
+
+ // We navigate to the target dir, so we display the content of that dir after the move
+ this.locationTracker.setCurrentLocation(form.getTargetDir().substring(1));
+
+ final Path targetPath = this.locationTracker.getCurrentLocation().resolve(Path.of(form.getFilename()));
+
+ try {
+ this.fileSystemService.move(sourcePath, targetPath);
+
+ this.infoMessages.add("nbscloud.files.rename.success");
+ } catch (RuntimeException e) {
+ logger.error("Could not rename file", e);
+
+ this.errors.add(e.getMessage());
+ }
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+
+ @GetMapping("/files/download")
+ public ResponseEntity downloadFile(String filename) {
+ // TODO download of directories via .zip? Also needs to be streaming
+
+ try {
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
+ .header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename))
+ .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
+ .body(new InputStreamResource(this.fileSystemService.stream(filename)));
+ } catch (RuntimeException e) {
+ logger.error("Could not get file", e);
+
+ return ResponseEntity.internalServerError().body(null);
+ }
+ }
+
+ @PostMapping("/files/upload")
+ public String uploadFiles(@RequestParam("files") MultipartFile[] files) {
+ for (MultipartFile file : files) {
+ try {
+ this.fileSystemService.createFile(file.getOriginalFilename(), file.getBytes());
+
+ this.infoMessages.add("nbscloud.files.created.file.success");
+ } catch (IOException e) {
+ logger.error("Could not upload file", e);
+
+ this.errors.add(e.getMessage());
+ }
+ }
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+
+ @PostMapping("/files/createDir")
+ public String createDir(@RequestParam("dirName") String dirName) {
+ try {
+ this.fileSystemService.createDirectory(dirName);
+
+ this.infoMessages.add("nbscloud.files.created.dir.success");
+ } catch (RuntimeException e) {
+ logger.error("Could not create dir", e);
+
+ this.errors.add(e.getMessage());
+ }
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+
+ @GetMapping("/files/share")
+ public String share(String filename) {
+ final Path filePath = this.locationTracker.getRelativeToBaseDir(this.locationTracker.getCurrentLocation()
+ .resolve(filename));
+ final String shareUuid = UUID.randomUUID().toString();
+
+ try {
+ this.fileSystemService.createFile(this.locationTracker.getBaseDirPath()
+ .resolve(this.filesConfig.getSharesName())
+ .resolve(shareUuid), filePath.toString()
+ .getBytes(StandardCharsets.UTF_8));
+
+ this.shareInfo.add("/files/shares?shareUuid=" + shareUuid);
+ } catch (RuntimeException e) {
+ logger.error("Could not share file", e);
+
+ this.errors.add(e.getMessage());
+ }
+
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+
+ @GetMapping("files/shares")
+ public ResponseEntity shares(String shareUuid) {
+ try {
+ final String sharedFile = new String(this.fileSystemService.get(this.locationTracker.getBaseDirPath()
+ .resolve(this.filesConfig.getSharesName())
+ .resolve(shareUuid)
+ .toString()));
+ final Path sharedFilePath = this.locationTracker.getBaseDirPath().resolve(sharedFile);
+ final String filename = sharedFilePath.getFileName().toString();
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
+ .header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename))
+ .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
+ .body(new InputStreamResource(this.fileSystemService.stream(filename)));
+ } catch (RuntimeException e) {
+ logger.error("Could not get shared file", e);
+
+ return ResponseEntity.internalServerError().body(null);
+ }
+ }
+
+ @GetMapping("files/preview")
+ public void preview(HttpServletResponse response, String filename) {
+ try {
+ response.setContentType(this.fileSystemService.getMimeType(filename));
+ response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)));
+ response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "filename=\"" + filename + "\"");
+
+ this.fileSystemService.stream(filename, response.getOutputStream());
+ } catch (FileSystemServiceException | IOException e) {
+ logger.error("Could not preview file", e);
+ }
+ }
+
+ @GetMapping("files/gallery")
+ public String gallery(Model model) {
+ try {
+ final List files = this.fileSystemService.getFilesWithMimeType(FileSystemService.MimeTypeFilter.IMAGES);
+
+ model.addAttribute("files", files);
+ model.addAttribute("apps", this.appRegistry.getAll());
+ this.webContainerSharedConfig.addDefaults(model);
+
+ return "files/gallery";
+ } catch (FileSystemServiceException e) {
+ logger.error("Could not generate gallery", e);
+
+ this.errors.add(e.getMessage());
+
+ return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
+ }
+ }
+
+ private List getAndClear(List source) {
+ final List retList = new ArrayList<>(source);
+
+ source.clear();
+
+ return retList;
+ }
+
+ private List getContent(FileSystemService.SortOrder order) {
+ final List contentList = this.fileSystemService.list(order);
+
+ if (!this.locationTracker.isBasePath()) {
+ contentList.add(0,
+ new FileSystemService.ContentContainer(true,
+ this.locationTracker.getParent(),
+ "..",
+ 0L,
+ LocalDateTime.ofInstant(Instant.EPOCH, ZoneId.of("UTC"))));
+ }
+
+ return contentList;
+ }
+
+ private void updateLocation(HttpServletRequest httpServletRequest) {
+ final String requestUrl = httpServletRequest.getRequestURL().toString();
+ final String[] split = requestUrl.split("/files/browse");
+
+ if (split.length > 1) {
+ this.locationTracker.setCurrentLocation(UriUtils.decode(split[1].substring(1), StandardCharsets.UTF_8));
+ } else {
+ this.locationTracker.reset();
+ }
+ }
+
+ private String getCurrentLocation() {
+ if (this.locationTracker.getRelativeLocation().toString().isEmpty()) {
+ return "/";
+ }
+
+ return this.locationTracker.getRelativeLocation().toString();
+ }
+
+ private String getCurrentLocationPrefixed() {
+ if (this.locationTracker.getRelativeLocation().toString().isEmpty()) {
+ return "/";
+ }
+
+ return "/" + this.locationTracker.getRelativeLocation().toString();
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ this.locationTracker.init();
+
+ if (this.filesConfig.isUseTrashBin()) {
+ try {
+ this.fileSystemService.createDirectory(this.filesConfig.getTrashBinName());
+ } catch (FileSystemServiceException e) {
+ if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
+ throw e;
+ }
+ // else: do nothing
+ }
+ }
+ try {
+ this.fileSystemService.createDirectory(this.filesConfig.getSharesName());
+ } catch (FileSystemServiceException e) {
+ if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
+ throw e;
+ }
+ // else: do nothing
+ }
+ }
+}
diff --git a/files/src/main/java/de/nbscloud/files/form/RenameForm.java b/files/src/main/java/de/nbscloud/files/form/RenameForm.java
new file mode 100644
index 0000000..77c17b0
--- /dev/null
+++ b/files/src/main/java/de/nbscloud/files/form/RenameForm.java
@@ -0,0 +1,37 @@
+package de.nbscloud.files.form;
+
+public class RenameForm {
+ private String filename;
+ private String targetDir;
+ private String originalFilename;
+
+ public RenameForm(String filename, String targetDir, String originalFilename) {
+ this.filename = filename;
+ this.targetDir = targetDir;
+ this.originalFilename = originalFilename;
+ }
+
+ public String getFilename() {
+ return filename;
+ }
+
+ public void setFilename(String filename) {
+ this.filename = filename;
+ }
+
+ public String getTargetDir() {
+ return targetDir;
+ }
+
+ public void setTargetDir(String targetDir) {
+ this.targetDir = targetDir;
+ }
+
+ public String getOriginalFilename() {
+ return originalFilename;
+ }
+
+ public void setOriginalFilename(String originalFilename) {
+ this.originalFilename = originalFilename;
+ }
+}
diff --git a/files/src/main/java/de/nbscloud/files/task/OrphanedThumbnailCleanup.java b/files/src/main/java/de/nbscloud/files/task/OrphanedThumbnailCleanup.java
new file mode 100644
index 0000000..2d0dd86
--- /dev/null
+++ b/files/src/main/java/de/nbscloud/files/task/OrphanedThumbnailCleanup.java
@@ -0,0 +1,4 @@
+package de.nbscloud.files.task;
+
+public class OrphanedThumbnailCleanup {
+}
diff --git a/files/src/main/resources/config/application.properties b/files/src/main/resources/config/application.properties
deleted file mode 100644
index 9c3732e..0000000
--- a/files/src/main/resources/config/application.properties
+++ /dev/null
@@ -1,14 +0,0 @@
-### This is the main configuration file of the application.
-### Filtering of the @...@ values happens via the maven-resource-plugin. The execution of the plugin is configured in
-### the Spring Boot parent POM.// The same property exists on the server, look there for documentation
-spring.profiles.active=@activeProfiles@
-
-info.app.name=NoBullShit Cloud - Files app
-info.app.description=A simple web file admin app
-info.build.group=@project.groupId@
-info.build.artifact=@project.artifactId@
-info.build.version=@project.version@
-
-nbs-cloud.files.baseDir=/home/marius
-
-spring.flyway.enabled=false
\ No newline at end of file
diff --git a/files/src/main/resources/config/files-application.properties b/files/src/main/resources/config/files-application.properties
new file mode 100644
index 0000000..91a975b
--- /dev/null
+++ b/files/src/main/resources/config/files-application.properties
@@ -0,0 +1,29 @@
+spring.profiles.active=@activeProfiles@
+
+info.app.name=NoBullShit Cloud - Files app
+info.app.description=A simple web file admin app
+
+# Knob to configure the base dir where files stores and reads files
+# Make sure the permissions match
+#nbs-cloud.files.baseDir=/home/marius/nbstest
+nbs-cloud.files.baseDir=/home/marius
+
+# Knob to configure whether hidden files (e.g. starting with '.' on *NIX)
+# will be filtered in the file view
+# Possible values: true|false
+nbs-cloud.files.filterHidden=true
+
+# Knob to configure whether a trash bin should be used
+# or the files should be hard deleted
+# Possible values: true|false
+nbs-cloud.files.useTrashBin=true
+
+# Knob to configure the name of the trash bin directory
+nbs-cloud.files.trashBinName=nbs.internal/nbs.trashbin
+
+# Knob to configure the amount of character after which the filename
+# will be truncated
+nbs-cloud.files.truncateFileNameChars=130
+
+# Knob to configure the name of the shares directory
+nbs-cloud.files.sharesName=nbs.internal/nbs.shares
\ No newline at end of file
diff --git a/files/src/main/resources/i18n/files_messages.properties b/files/src/main/resources/i18n/files_messages.properties
new file mode 100644
index 0000000..9247cbf
--- /dev/null
+++ b/files/src/main/resources/i18n/files_messages.properties
@@ -0,0 +1,26 @@
+nbscloud.files.index.title=nbscloud - files\:\u0020
+
+nbscloud.files.files-content-table.table-header.filename=Filename
+nbscloud.files.files-content-table.table-header.size=Size
+nbscloud.files.files-content-table.table-header.lastmodified=Last modified
+nbscloud.files.files-content-table.table-header.actions=Actions
+
+nbscloud.files-content-table.table.actions.delete=Delete
+nbscloud.files-content-table.table.actions.rename=Rename/move
+nbscloud.files-content-table.table.actions.download=Download
+nbscloud.files-content-table.table.actions.share=Share
+nbscloud.files-content-table.table.actions.preview=Preview
+
+nbscloud.files.rename.title=nbscloud - files\: rename/move \u0020
+
+nbscloud.files.rename.label.filename=Filename\:
+nbscloud.files.rename.label.targetDir=Target directory\:
+
+nbscloud.files.rename.submit=Rename/move
+
+nbscloud.files.share-message=Shared file\:\u0020
+
+nbscloud.files.delete.success=File deleted
+nbscloud.files.rename.success=File renamed
+nbscloud.files.created.file.success=File created
+nbscloud.files.created.dir.success=Directory created
\ No newline at end of file
diff --git a/files/src/main/resources/i18n/files_messages_de_DE.properties b/files/src/main/resources/i18n/files_messages_de_DE.properties
new file mode 100644
index 0000000..a0f2eb6
--- /dev/null
+++ b/files/src/main/resources/i18n/files_messages_de_DE.properties
@@ -0,0 +1,26 @@
+nbscloud.files.index.title=nbscloud - Dateien\:\u0020
+
+nbscloud.files.files-content-table.table-header.filename=Dateiname
+nbscloud.files.files-content-table.table-header.size=Gr\u00F6\u00DFe
+nbscloud.files.files-content-table.table-header.lastmodified=Zuletzt ge\u00E4ndert
+nbscloud.files.files-content-table.table-header.actions=Aktionen
+
+nbscloud.files-content-table.table.actions.delete=L\u00F6schen
+nbscloud.files-content-table.table.actions.rename=Umbenennen/verschieben
+nbscloud.files-content-table.table.actions.download=Download
+nbscloud.files-content-table.table.actions.share=Teilen
+nbscloud.files-content-table.table.actions.preview=Vorschau
+
+nbscloud.files.rename.title=nbscloud - Dateien\: umbenennen/verschieben \u0020
+
+nbscloud.files.rename.label.filename=Dateiname\:
+nbscloud.files.rename.label.targetDir=Zielverzeichnis\:
+
+nbscloud.files.rename.submit=Umbenennen/verschieben
+
+nbscloud.files.share-message=Datei geteilt\:\u0020
+
+nbscloud.files.delete.success=Datei gel\u00F6scht
+nbscloud.files.rename.success=Datei verschoben/umbenannt
+nbscloud.files.created.file.success=Datei erstellt
+nbscloud.files.created.dir.success=Verzeichnis erstellt
\ No newline at end of file
diff --git a/files/src/main/resources/static/css/files_gallery.css b/files/src/main/resources/static/css/files_gallery.css
new file mode 100644
index 0000000..38d5443
--- /dev/null
+++ b/files/src/main/resources/static/css/files_gallery.css
@@ -0,0 +1,39 @@
+#main-container {
+ padding-left: unset !important;
+ padding-right: unset !important;
+ padding-top: unset !important;
+ padding-bottom: unset !important;
+}
+
+#gallery-container {
+ padding-top: 2em;
+}
+
+#header-container {
+ padding-left: 3em;
+ padding-top: 3em;
+ padding-left: 3em;
+}
+
+.galleryImage {
+ width: 15%;
+ padding-inline: 0.5em;
+ padding-bottom: 0.5em;
+ vertical-align: top;
+}
+
+@media only screen and (max-width: 450px) {
+ .galleryImage {
+ width: 45%;
+ }
+
+ #gallery-container {
+ padding-top: 1em;
+ }
+
+ #header-container {
+ padding-left: 1em;
+ padding-top: 1em;
+ padding-left: 1em;
+ }
+}
\ No newline at end of file
diff --git a/files/src/main/resources/static/css/files_main.css b/files/src/main/resources/static/css/files_main.css
new file mode 100644
index 0000000..0c01f51
--- /dev/null
+++ b/files/src/main/resources/static/css/files_main.css
@@ -0,0 +1,170 @@
+#files-content-table {
+ width: 100%;
+}
+
+#files-content-table th {
+ position: sticky;
+ background-color: var(--background-color);
+ top: 0px;
+}
+
+#type-column {
+ width: 1%;
+}
+
+#name-column {
+ width: auto;
+}
+
+#size-column {
+ width: 1%;
+}
+
+#lastmod-column {
+ width: 1%;
+}
+
+#actions-column {
+ width: 1%;
+}
+
+.files-table-padded-col {
+ padding-right: 2em;
+}
+
+.files-table-text-align-right {
+ text-align: right;
+}
+
+#files-content-table > tbody > tr > td > details > summary {
+ list-style-type: none;
+}
+
+#files-content-table > tbody > tr > td > details[open] > summary {
+ list-style-type: none;
+}
+
+#files-content-table > tbody > tr > td > details > summary:hover {
+ cursor: pointer;
+}
+
+#rename-form {
+ margin-top: 3em;
+ margin-left: 3em;
+}
+
+#rename-form * {
+ display: block;
+ margin-top: 1em;
+ box-sizing: border-box;
+}
+#rename-form > select > * {
+ margin-top: unset;
+}
+
+#rename-form > select {
+ width: 70em;
+}
+
+#rename-form > input[type=text] {
+ width: 70em;
+}
+
+#menu-container {
+ padding-top: 2em;
+ margin-left: 3em;
+}
+
+.files-menu-icon {
+ background: none !important;
+ border: none;
+ padding: 0 !important;
+ color: var(--text-color);
+ cursor: pointer;
+ font-size: 2em;
+}
+
+.files-menu-icon:hover {
+ color: var(--link-color);
+}
+
+#content-container {
+ padding-top: unset !important;
+}
+
+#create-dir-container > details > summary {
+ list-style-type: none;
+}
+
+#create-dir-container > details[open] > summary {
+ list-style-type: none;
+}
+
+#menu-container > div {
+ display: inline-block !important;
+}
+
+.create-dir-container-details-modal-content {
+ padding: 0.5em;
+ pointer-events: all;
+ display: inline;
+}
+
+.create-dir-container-details-modal {
+ background: var(--background-color);
+ filter: brightness(var(--background-brightness));
+ border-radius: 0.3em;
+ left: auto;
+ top: auto;
+ right: auto;
+ bottom: auto;
+ padding: 0;
+ pointer-events: none;
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ z-index: 100;
+}
+
+.menu-spacer {
+ width: 2em;
+}
+
+.messageContainer {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+}
+
+.message {
+ margin-top: 2em;
+ border: 1px;
+ border-style: solid;
+ border-radius: 0.3em;
+ padding: 1em;
+ display: inline-block;
+}
+
+.shareMessage {
+ border-color: var(--good-color) !important;
+}
+
+.infoMessage {
+ border-color: var(--good-color) !important;
+}
+
+.errorMessage {
+ border-color: var(--error-color) !important;
+}
+
+@media only screen and (max-width: 450px) {
+ #menu-container {
+ padding-top: 1em;
+ margin-left: 1em;
+ }
+
+ .menu-spacer {
+ width: 1em;
+ }
+}
\ No newline at end of file
diff --git a/files/src/main/resources/templates/files/filesIndex.html b/files/src/main/resources/templates/files/filesIndex.html
new file mode 100644
index 0000000..299217d
--- /dev/null
+++ b/files/src/main/resources/templates/files/filesIndex.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
d
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/files/src/main/resources/templates/files/gallery.html b/files/src/main/resources/templates/files/gallery.html
new file mode 100644
index 0000000..3a6cedd
--- /dev/null
+++ b/files/src/main/resources/templates/files/gallery.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/files/src/main/resources/templates/files/includes/menu.html b/files/src/main/resources/templates/files/includes/menu.html
new file mode 100644
index 0000000..31afd0c
--- /dev/null
+++ b/files/src/main/resources/templates/files/includes/menu.html
@@ -0,0 +1,35 @@
+