From 1819861326bc822b4ea02d0122ce13f3d58b636a Mon Sep 17 00:00:00 2001 From: RhiobeT Date: Fri, 17 Apr 2026 23:32:35 +0200 Subject: [PATCH] Create path package --- .gitignore | 2 + pom.xml | 3 +- .../rhiobet/lalafin/path/FileSystemPath.java | 88 ++++++++++++ .../lalafin/path/InvalidPathException.java | 7 + .../java/sh/rhiobet/lalafin/path/Path.java | 15 ++ .../sh/rhiobet/lalafin/path/PathAccessor.java | 13 ++ .../sh/rhiobet/lalafin/path/PathFactory.java | 44 ++++++ .../sh/rhiobet/lalafin/path/PathPlugin.java | 10 ++ .../sh/rhiobet/lalafin/path/ZipEntryPath.java | 132 ++++++++++++++++++ .../thumbnail/ThumbnailPathPlugin.java | 71 ++++++++++ 10 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/main/java/sh/rhiobet/lalafin/path/FileSystemPath.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/InvalidPathException.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/Path.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/PathAccessor.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/PathFactory.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/PathPlugin.java create mode 100644 src/main/java/sh/rhiobet/lalafin/path/ZipEntryPath.java create mode 100644 src/main/java/sh/rhiobet/lalafin/thumbnail/ThumbnailPathPlugin.java diff --git a/.gitignore b/.gitignore index 0cb9f41..a6ff3f3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ release.properties application.yaml build.sh *log + +shell.nix diff --git a/pom.xml b/pom.xml index a435496..72a752b 100644 --- a/pom.xml +++ b/pom.xml @@ -8,8 +8,7 @@ 3.12.1 true - 17 - 17 + 21 UTF-8 UTF-8 3.30.2 diff --git a/src/main/java/sh/rhiobet/lalafin/path/FileSystemPath.java b/src/main/java/sh/rhiobet/lalafin/path/FileSystemPath.java new file mode 100644 index 0000000..d85a6da --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/FileSystemPath.java @@ -0,0 +1,88 @@ +package sh.rhiobet.lalafin.path; + +import jakarta.ws.rs.core.PathSegment; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +import org.jboss.resteasy.reactive.common.util.Encode; + +public final class FileSystemPath implements Path { + private java.nio.file.Path rootFolderPath; + + List segments; + java.nio.file.Path absolutePath; + + FileSystemPath(List names, java.nio.file.Path rootFolderPath) { + this.segments = names.stream().map(n -> n.getPath()).toList(); + this.rootFolderPath = rootFolderPath; + this.absolutePath = getAbsolutePath(); + } + + FileSystemPath(FileSystemPath parent, String fileName) { + this.segments = Stream.concat(parent.segments.stream(), Stream.of(fileName)).toList(); + this.rootFolderPath = parent.rootFolderPath; + this.absolutePath = getAbsolutePath(); + } + + @Override + public String getURI() { + return this.segments.stream() + .map(s -> "/" + Encode.encodePathSegment(s)) + .reduce("", String::concat); + } + + @Override + public String getFilename() { + return this.segments.getLast(); + } + + @Override + public long getSize() { + try { + return Files.size(this.absolutePath); + } catch (IOException ignored) { + return 0; + } + } + + @Override + public boolean exists() { + return Files.exists(this.absolutePath); + } + + @Override + public boolean canHaveChildren() { + return Files.isDirectory(this.absolutePath) || this.segments.getLast().endsWith(".zip"); + } + + @Override + public List getChildren() throws IOException { + if (this.segments.getLast().endsWith(".zip")) { + try (ZipFile zipFile = new ZipFile(this.absolutePath.toFile())) { + return zipFile.stream() + .map(e -> (Path) new ZipEntryPath(this, e.getName())) + .toList(); + } + } else if (Files.isDirectory(this.absolutePath)){ + return Files.list(this.absolutePath).sorted() + .map(f -> (Path) new FileSystemPath(this, f.getFileName().toString())) + .toList(); + } else { + return List.of(); + } + } + + @Override + public InputStream getInputStream() throws IOException { + return Files.newInputStream(this.absolutePath); + } + + private java.nio.file.Path getAbsolutePath() { + return this.rootFolderPath.resolve("file").resolve(String.join("/", this.segments)); + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/InvalidPathException.java b/src/main/java/sh/rhiobet/lalafin/path/InvalidPathException.java new file mode 100644 index 0000000..079d330 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/InvalidPathException.java @@ -0,0 +1,7 @@ +package sh.rhiobet.lalafin.path; + +public class InvalidPathException extends Exception { + public InvalidPathException(String message) { + super(message); + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/Path.java b/src/main/java/sh/rhiobet/lalafin/path/Path.java new file mode 100644 index 0000000..bdb6b7a --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/Path.java @@ -0,0 +1,15 @@ +package sh.rhiobet.lalafin.path; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +sealed public interface Path permits FileSystemPath, ZipEntryPath { + String getURI(); + String getFilename(); + long getSize(); + boolean exists(); + boolean canHaveChildren(); + List getChildren() throws IOException; + InputStream getInputStream() throws IOException; +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/PathAccessor.java b/src/main/java/sh/rhiobet/lalafin/path/PathAccessor.java new file mode 100644 index 0000000..b4e4db5 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/PathAccessor.java @@ -0,0 +1,13 @@ +package sh.rhiobet.lalafin.path; + +public abstract class PathAccessor { + protected java.nio.file.Path getAbsolutePath(Path path) { + switch (path) { + case FileSystemPath fsp: + return fsp.absolutePath; + + case ZipEntryPath zep: + return zep.zipFilePath.absolutePath; + } + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/PathFactory.java b/src/main/java/sh/rhiobet/lalafin/path/PathFactory.java new file mode 100644 index 0000000..4ccc754 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/PathFactory.java @@ -0,0 +1,44 @@ +package sh.rhiobet.lalafin.path; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.PathSegment; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.List; + +import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration; + +@ApplicationScoped +public class PathFactory { + @Inject + FileApiConfiguration fileApiConfiguration; + + public Path toPath(List names) throws InvalidPathException { + if (hasEncodedPathSeparator(names)) { + throw new InvalidPathException("Some path segments contain illegal characters."); + } else { + java.nio.file.Path rootFolderPath = Paths.get(this.fileApiConfiguration.directory()); + + if (names.size() > 1 && names.get(names.size() - 2).getPath().endsWith(".zip")) { + return new ZipEntryPath(names, rootFolderPath); + } else { + return new FileSystemPath(names, rootFolderPath); + } + } + } + + private boolean hasEncodedPathSeparator(List names) { + return names.stream().anyMatch(s -> { + String current = s.getPath(); + while (true) { + String decoded = URLDecoder.decode(current, StandardCharsets.UTF_8); + if (decoded.equals(current)) break; + current = decoded; + } + return current.contains("/") || current.contains("\0") || current.equals("..") || current.equals("."); + }); + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/PathPlugin.java b/src/main/java/sh/rhiobet/lalafin/path/PathPlugin.java new file mode 100644 index 0000000..81cf587 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/PathPlugin.java @@ -0,0 +1,10 @@ +package sh.rhiobet.lalafin.path; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Optional; + +public interface PathPlugin { + Optional resolveURI(Path path); + OutputStream getOutputStream(Path path) throws IOException; +} diff --git a/src/main/java/sh/rhiobet/lalafin/path/ZipEntryPath.java b/src/main/java/sh/rhiobet/lalafin/path/ZipEntryPath.java new file mode 100644 index 0000000..68b0f8e --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/path/ZipEntryPath.java @@ -0,0 +1,132 @@ +package sh.rhiobet.lalafin.path; + +import jakarta.ws.rs.core.PathSegment; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +import org.jboss.resteasy.reactive.common.util.Encode; + +public final class ZipEntryPath implements Path { + FileSystemPath zipFilePath; + List segments; + + ZipEntryPath(List names, java.nio.file.Path rootFolderPath) { + this(new FileSystemPath(names.subList(0, names.size() - 1), rootFolderPath), + names.getLast().getPath()); + } + + ZipEntryPath(FileSystemPath parent, String entryName) { + this.segments = Stream.concat(parent.segments.stream(), Stream.of(entryName)).toList(); + this.zipFilePath = parent; + } + + @Override + public String getURI() { + return this.segments.stream() + .map(s -> "/" + Encode.encodePathSegment(s)) + .reduce("", String::concat); + } + + @Override + public String getFilename() { + return this.segments.getLast(); + } + + @Override + public long getSize() { + try (ZipFile zipFile = new ZipFile(this.zipFilePath.absolutePath.toFile())) { + return zipFile.getEntry(this.segments.getLast()).getSize(); + } catch (IOException ignored) { + return 0; + } + } + + @Override + public boolean exists() { + try (ZipFile zipFile = new ZipFile(this.zipFilePath.absolutePath.toFile())) { + return zipFile.getEntry(segments.getLast()) != null; + } catch (IOException ignored) { + return false; + } + } + + @Override + public boolean canHaveChildren() { + return false; + } + + @Override + public List getChildren() throws IOException { + return List.of(); + } + + @Override + public InputStream getInputStream() throws IOException { + ZipFile zipFile = new ZipFile(this.zipFilePath.absolutePath.toFile()); + return new ZipEntryInputStream(zipFile, + zipFile.getInputStream(zipFile.getEntry(this.segments.getLast()))); + } +} + +class ZipEntryInputStream extends InputStream { + private final ZipFile zipFile; + private final InputStream is; + + public ZipEntryInputStream(ZipFile zipFile, InputStream is) { + this.zipFile = zipFile; + this.is = is; + } + + @Override + public int read() throws IOException { + return this.is.read(); + } + + @Override + public int read(byte[] buf) throws IOException { + return this.is.read(buf); + } + + @Override + public int read(byte[] buf, int off, int len) throws IOException { + return this.is.read(buf, off, len); + } + + @Override + public byte[] readAllBytes() throws IOException { + return this.is.readAllBytes(); + } + + @Override + public byte[] readNBytes(int len) throws IOException { + return this.is.readNBytes(len); + } + + @Override + public int readNBytes(byte[] buf, int off, int len) throws IOException { + return this.is.readNBytes(buf, off, len); + } + + @Override + public long skip(long n) throws IOException { + return this.is.skip(n); + } + + @Override + public int available() throws IOException { + return this.is.available(); + } + + @Override + public void close() throws IOException { + try { + this.is.close(); + } finally { + this.zipFile.close(); + } + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/thumbnail/ThumbnailPathPlugin.java b/src/main/java/sh/rhiobet/lalafin/thumbnail/ThumbnailPathPlugin.java new file mode 100644 index 0000000..cc61226 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/thumbnail/ThumbnailPathPlugin.java @@ -0,0 +1,71 @@ +package sh.rhiobet.lalafin.thumbnail; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration; +import sh.rhiobet.lalafin.path.FileSystemPath; +import sh.rhiobet.lalafin.path.Path; +import sh.rhiobet.lalafin.path.PathAccessor; +import sh.rhiobet.lalafin.path.PathPlugin; +import sh.rhiobet.lalafin.path.ZipEntryPath; + +@ApplicationScoped +@Named("thumbnail") +public class ThumbnailPathPlugin extends PathAccessor implements PathPlugin { + @Inject + FileApiConfiguration fileApiConfiguration; + + @Override + public Optional resolveURI(Path path) { + switch (path) { + case FileSystemPath fsp: + Optional thumbnailAbsolutePath = getThumbnailPath(path); + + if (thumbnailAbsolutePath.isPresent()) { + java.nio.file.Path rootFolderPath = Paths.get(this.fileApiConfiguration.directory()); + return Optional.of(rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get()) + .toUri().getRawPath()); + } else { + return Optional.empty(); + } + + case ZipEntryPath zep: + return Optional.empty(); + } + } + + @Override + public OutputStream getOutputStream(Path path) throws IOException { + switch (path) { + case FileSystemPath fsp: + Optional thumbnailAbsolutePath = getThumbnailPath(path); + if (thumbnailAbsolutePath.isPresent()) { + Files.delete(thumbnailAbsolutePath.get()); + } + java.nio.file.Path newThumbnailAbsolutePath = + getAbsolutePath(path).getParent().resolve(".thumbnails").resolve(path.getFilename()); + Files.createDirectories(newThumbnailAbsolutePath.getParent()); + return Files.newOutputStream(newThumbnailAbsolutePath); + + case ZipEntryPath zep: + throw new IOException("A zip file is not a valid location to store a thumbnail."); + } + } + + private Optional getThumbnailPath(Path path) { + try { + return Files.list(getAbsolutePath(path).getParent().resolve(".thumbnails")) + .filter(f -> f.getFileName().toString().matches("^" + path.getFilename() + "(\\.[^.]+)*$")) + .findFirst(); + } catch (IOException ignored) { + return Optional.empty(); + } + } +}