Basic implementation for files app
This commit is contained in:
@@ -15,46 +15,61 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-data-jpa</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<dependency>
|
||||
<groupId>de.77zzcx7.nbs-cloud</groupId>
|
||||
<artifactId>web-container-registry</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.77zzcx7.nbs-cloud</groupId>
|
||||
<artifactId>web-container-config</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.flywaydb</groupId>-->
|
||||
<!-- <artifactId>flyway-core</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.thymeleaf.extras</groupId>-->
|
||||
<!-- <artifactId>thymeleaf-extras-springsecurity5</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-tomcat</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.security</groupId>-->
|
||||
<!-- <artifactId>spring-security-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<dependency>
|
||||
<groupId>org.overviewproject</groupId>
|
||||
<artifactId>mime-types</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.flywaydb</groupId>-->
|
||||
<!-- <artifactId>flyway-core</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.thymeleaf.extras</groupId>-->
|
||||
<!-- <artifactId>thymeleaf-extras-springsecurity5</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-tomcat</artifactId>-->
|
||||
<!-- <scope>provided</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.security</groupId>-->
|
||||
<!-- <artifactId>spring-security-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -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<ContentContainer> comparator;
|
||||
|
||||
SortOrder(Comparator<ContentContainer> 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<String> 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<ContentContainer> 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<String> getFilesWithMimeType(MimeTypeFilter mimeTypeFilter) {
|
||||
try {
|
||||
final Map<String, CompletableFuture<String>> 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<ContentContainer> list(SortOrder sortOrder) {
|
||||
try {
|
||||
List<ContentContainer> 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<String> collectDirs(String sourceFile) {
|
||||
try {
|
||||
final List<String> resultList = new ArrayList<>();
|
||||
final Path sourcePath = this.locationTracker.getCurrentLocation().resolve(sourceFile);
|
||||
final boolean sourceIsDir = Files.isDirectory(sourcePath);
|
||||
|
||||
Files.walkFileTree(this.locationTracker.getBaseDirPath(), new SimpleFileVisitor<Path>() {
|
||||
@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);
|
||||
}
|
||||
|
||||
38
files/src/main/java/de/nbscloud/files/FilesApp.java
Normal file
38
files/src/main/java/de/nbscloud/files/FilesApp.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
110
files/src/main/java/de/nbscloud/files/FilesFormatter.java
Normal file
110
files/src/main/java/de/nbscloud/files/FilesFormatter.java
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> errors = new ArrayList<>();
|
||||
private final List<String> shareInfo = new ArrayList<>();
|
||||
private final List<String> 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<Resource> 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<String> 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<String> getAndClear(List<String> source) {
|
||||
final List<String> retList = new ArrayList<>(source);
|
||||
|
||||
source.clear();
|
||||
|
||||
return retList;
|
||||
}
|
||||
|
||||
private List<FileSystemService.ContentContainer> getContent(FileSystemService.SortOrder order) {
|
||||
final List<FileSystemService.ContentContainer> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
files/src/main/java/de/nbscloud/files/form/RenameForm.java
Normal file
37
files/src/main/java/de/nbscloud/files/form/RenameForm.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nbscloud.files.task;
|
||||
|
||||
public class OrphanedThumbnailCleanup {
|
||||
}
|
||||
@@ -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
|
||||
29
files/src/main/resources/config/files-application.properties
Normal file
29
files/src/main/resources/config/files-application.properties
Normal file
@@ -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
|
||||
26
files/src/main/resources/i18n/files_messages.properties
Normal file
26
files/src/main/resources/i18n/files_messages.properties
Normal file
@@ -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
|
||||
@@ -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
|
||||
39
files/src/main/resources/static/css/files_gallery.css
Normal file
39
files/src/main/resources/static/css/files_gallery.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
170
files/src/main/resources/static/css/files_main.css
Normal file
170
files/src/main/resources/static/css/files_main.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
118
files/src/main/resources/templates/files/filesIndex.html
Normal file
118
files/src/main/resources/templates/files/filesIndex.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="#{nbscloud.files.index.title} + ${currentLocation}"/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
|
||||
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/files_main.css}"/>
|
||||
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<div th:replace="includes/header :: header"/>
|
||||
<div class="messageContainer" th:each="shareInfo : ${shareInfo}">
|
||||
<div th:if="${!shareInfo.isEmpty()}" class="shareMessage message">
|
||||
<span th:text="#{'nbscloud.files.share-message'}"/>
|
||||
<a th:href="@{${shareInfo}}" th:text="${shareInfo}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messageContainer" th:each="error : ${errors}">
|
||||
<div th:if="${!errors.isEmpty()}" class="errorMessage message">
|
||||
<span th:text="${error}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messageContainer" th:each="infoMessage : ${infoMessages}">
|
||||
<div th:if="${!infoMessages.isEmpty()}" class="infoMessage message">
|
||||
<span th:text="#{${infoMessage}}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div th:replace="files/includes/menu :: menu"/>
|
||||
<div id="content-container">
|
||||
<table id="files-content-table">
|
||||
<tr>
|
||||
<th id="type-column" class="no-mobile"/>
|
||||
<th id="name-column">
|
||||
<a nowrap th:if="${sortOrder != T(de.nbscloud.files.FileSystemService.SortOrder).NATURAL}"
|
||||
th:href="@{''(sortOrder=NATURAL)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.filename}"/>
|
||||
<a nowrap th:if="${sortOrder == T(de.nbscloud.files.FileSystemService.SortOrder).NATURAL}"
|
||||
th:href="@{''(sortOrder=NATURAL_REVERSE)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.filename}"/>
|
||||
</th>
|
||||
<th id="size-column" class="no-mobile">
|
||||
<a nowrap th:if="${sortOrder != T(de.nbscloud.files.FileSystemService.SortOrder).SIZE_DESC}"
|
||||
th:href="@{''(sortOrder=SIZE_DESC)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.size}"/>
|
||||
<a nowrap th:if="${sortOrder == T(de.nbscloud.files.FileSystemService.SortOrder).SIZE_DESC}"
|
||||
th:href="@{''(sortOrder=SIZE_ASC)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.size}"/>
|
||||
</th>
|
||||
<th id="lastmod-column" class="no-mobile">
|
||||
<a nowrap th:if="${sortOrder != T(de.nbscloud.files.FileSystemService.SortOrder).LAST_MOD_DESC}"
|
||||
th:href="@{''(sortOrder=LAST_MOD_DESC)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.lastmodified}"/>
|
||||
<a nowrap th:if="${sortOrder == T(de.nbscloud.files.FileSystemService.SortOrder).LAST_MOD_DESC}"
|
||||
th:href="@{''(sortOrder=LAST_MOD_ASC)}"
|
||||
th:text="#{nbscloud.files.files-content-table.table-header.lastmodified}"/>
|
||||
</th>
|
||||
<th nowrap id="actions-column" th:text="#{nbscloud.files.files-content-table.table-header.actions}"/>
|
||||
</tr>
|
||||
<tr th:each="fileEntry : ${content}">
|
||||
<td th:if="${fileEntry.directory}" class="no-mobile">d</td>
|
||||
<td th:if="${!fileEntry.directory}" class="no-mobile">-</td>
|
||||
<td th:if="${fileEntry.directory}">
|
||||
<a th:if="${fileEntry.name != '..'}" th:href="@{/files/browse/} + ${fileEntry.path}"
|
||||
th:text="${fileEntry.name}"/>
|
||||
<a th:if="${fileEntry.name == '..'}" th:href="@{/files/browse/} + ${fileEntry.path}"
|
||||
th:text="${fileEntry.name}"/>
|
||||
</td>
|
||||
<td th:if="${!fileEntry.directory && @filesFormatter.needsTruncate(fileEntry.name)}"
|
||||
th:text="${@filesFormatter.truncateFilename(fileEntry.name)}"
|
||||
th:title="${fileEntry.name}"/>
|
||||
<td th:if="${!fileEntry.directory && !@filesFormatter.needsTruncate(fileEntry.name)}"
|
||||
th:text="${fileEntry.name}"/>
|
||||
<td nowrap th:text="${@filesFormatter.formatSize(fileEntry.size)}"
|
||||
class="files-table-padded-col files-table-text-align-right no-mobile"/>
|
||||
<td nowrap th:text="${#temporals.format(fileEntry.lastModified, 'SHORT')}"
|
||||
class="files-table-padded-col files-table-text-align-right no-mobile"/>
|
||||
<td nowrap>
|
||||
<details th:if="${fileEntry.name != '..'}" class="files-table-text-align-right">
|
||||
<summary th:text="#{nbscloud.show-actions}"/>
|
||||
<div id="files-content-table-show-actions-detail-container">
|
||||
<div id="files-content-table-show-actions-detail-container-rename">
|
||||
<a th:href="@{/files/rename(filename=${fileEntry.name})}"
|
||||
th:text="#{nbscloud.files-content-table.table.actions.rename}"/>
|
||||
</div>
|
||||
<div id="files-content-table-show-actions-detail-container-delete">
|
||||
<a th:href="@{/files/delete(filename=${fileEntry.name})}"
|
||||
th:text="#{nbscloud.files-content-table.table.actions.delete}"/>
|
||||
</div>
|
||||
<div th:if="${!fileEntry.directory}"
|
||||
id="files-content-table-show-actions-detail-container-download">
|
||||
<a th:href="@{/files/download(filename=${fileEntry.name})}"
|
||||
th:text="#{nbscloud.files-content-table.table.actions.download}"/>
|
||||
</div>
|
||||
<div th:if="${!fileEntry.directory}"
|
||||
id="files-content-table-show-actions-detail-container-preview">
|
||||
<a th:href="@{/files/preview(filename=${fileEntry.name})}"
|
||||
th:text="#{nbscloud.files-content-table.table.actions.preview}"/>
|
||||
</div>
|
||||
<div th:if="${!fileEntry.directory}"
|
||||
id="files-content-table-show-actions-detail-container-share">
|
||||
<a th:href="@{/files/share(filename=${fileEntry.name})}"
|
||||
th:text="#{nbscloud.files-content-table.table.actions.share}"/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div th:if="${fileEntry.name == '..'}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div th:replace="includes/footer :: footer"/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
24
files/src/main/resources/templates/files/gallery.html
Normal file
24
files/src/main/resources/templates/files/gallery.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title th:text="#{nbscloud.files.gallery.title}"/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
|
||||
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/files_main.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/files_gallery.css}"/>
|
||||
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<div th:replace="includes/header :: header"/>
|
||||
<div id="gallery-container">
|
||||
<img class="galleryImage" th:each="file : ${files}" th:src="@{/files/preview(filename=${file})}">
|
||||
</div>
|
||||
<div th:replace="includes/footer :: footer"/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
35
files/src/main/resources/templates/files/includes/menu.html
Normal file
35
files/src/main/resources/templates/files/includes/menu.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<div id="menu-container" th:fragment="menu">
|
||||
<div class="menu-spacer"></div>
|
||||
<div id="upload-container">
|
||||
<form method="POST" action="#" th:action="@{/files/upload}" enctype="multipart/form-data">
|
||||
<!-- JavaScript required unfortunately -->
|
||||
<input id="upload-container-file-input" type="file" name="files" onchange="this.form.submit()"
|
||||
style="display: none;" multiple>
|
||||
<span id="upload-container-span-input" class="icon files-menu-icon"
|
||||
onclick="document.getElementById('upload-container-file-input').click();"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="menu-spacer"></div>
|
||||
<div id="create-dir-container">
|
||||
<details>
|
||||
<summary>
|
||||
<span id="create-dir-container-span-input" class="icon files-menu-icon"></span>
|
||||
<div class="create-dir-container-details-modal-overlay"></div>
|
||||
</summary>
|
||||
<div class="create-dir-container-details-modal">
|
||||
<div class="create-dir-container-details-modal-content">
|
||||
<form method="POST" action="#" th:action="@{/files/createDir}" enctype="multipart/form-data">
|
||||
<input id="create-dir-container-dir-name" type="text" name="dirName" />
|
||||
<input type="submit" value="Create"/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="menu-spacer"></div>
|
||||
<div>
|
||||
<a th:href="@{/files/gallery}">
|
||||
<span class="icon files-menu-icon"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
34
files/src/main/resources/templates/files/rename.html
Normal file
34
files/src/main/resources/templates/files/rename.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="#{nbscloud.files.rename.title} + ${filename}"/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
|
||||
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/files_main.css}"/>
|
||||
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<div th:replace="includes/header :: header"/>
|
||||
<form id="rename-form" action="#" th:action="@{/files/doRename}" th:object="${form}"
|
||||
method="post" enctype="multipart/form-data">
|
||||
<label for="filename" th:text="#{nbscloud.files.rename.label.filename}"/>
|
||||
<input type="text" id="filename" th:field="*{filename}"/>
|
||||
<label for="targetDir" th:text="#{nbscloud.files.rename.label.targetDir}"/>
|
||||
<!-- We can't use th:field here because of th:selected, they don't work together -->
|
||||
<select size="25" id="targetDir" name="targetDir">
|
||||
<option th:each="targetDir : ${targetDirs}"
|
||||
th:value="${targetDir}"
|
||||
th:text="${targetDir}"
|
||||
th:selected="${targetDir.equals(currentLocation)}"/>
|
||||
</select>
|
||||
<input type="hidden" id="originalPath" th:field="*{originalFilename}"/>
|
||||
<input type="submit" th:value="#{nbscloud.files.rename.submit}" />
|
||||
</form>
|
||||
<div th:replace="includes/footer :: footer"/>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user