Compare commits

...

6 Commits

Author SHA1 Message Date
a4c0a4702a Typos 2026-04-18 21:40:07 +02:00
8c0bb2ce4a Refactor accessors 2026-04-18 21:32:34 +02:00
5924ca5647 More fixes in zip entries for path 2026-04-18 20:41:03 +02:00
3cc928ebc8 Fix zip entry names in path 2026-04-18 20:24:27 +02:00
46b6f4867e Small fixes to path module 2026-04-18 19:50:23 +02:00
1819861326 Create path package 2026-04-17 23:32:35 +02:00
15 changed files with 549 additions and 2 deletions

2
.gitignore vendored
View File

@@ -38,3 +38,5 @@ release.properties
application.yaml
build.sh
*log
shell.nix

View File

@@ -8,8 +8,7 @@
<properties>
<compiler-plugin.version>3.12.1</compiler-plugin.version>
<maven.compiler.parameters>true</maven.compiler.parameters>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus-plugin.version>3.30.2</quarkus-plugin.version>

View File

@@ -0,0 +1,7 @@
package sh.rhiobet.lalafin.access;
import sh.rhiobet.lalafin.path.Path;
public interface AccessService {
boolean checkAccess(Path path);
}

View File

@@ -0,0 +1,48 @@
package sh.rhiobet.lalafin.advent;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.regex.Pattern;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.access.AccessService;
import sh.rhiobet.lalafin.path.Path;
@ApplicationScoped
@Named("advent")
public class AdventAccessService implements AccessService {
@Inject
AdventConfiguration adventConfiguration;
@Override
public boolean checkAccess(Path path) {
String pathUri = path.getURI();
Optional<AdventConfiguration.AdventEvent> matchingAdvent = adventConfiguration.events().stream()
.filter(e -> pathUri.startsWith(e.path()))
.findFirst();
if (matchingAdvent.isEmpty()) {
return true;
}
String dayString = pathUri.replaceAll(
"^" + Pattern.quote(matchingAdvent.get().path()) + "/?([^.]+)\\.?[^.]*$",
"$1");
try {
int day = Integer.parseInt(dayString);
LocalDateTime now = LocalDateTime.now();
return ((now.getYear() > matchingAdvent.get().year())
|| (now.getYear() == matchingAdvent.get().year()
&& now.getMonthValue() > matchingAdvent.get().month())
|| (now.getYear() == matchingAdvent.get().year()
&& now.getMonthValue() == matchingAdvent.get().month()
&& now.getDayOfMonth() >= day));
} catch (NumberFormatException ignored) {
return true;
}
}
}

View File

@@ -0,0 +1,15 @@
package sh.rhiobet.lalafin.advent;
import java.util.List;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "advent")
public interface AdventConfiguration {
public List<AdventEvent> events();
public static interface AdventEvent {
public String path();
public int year();
public int month();
}
}

View File

@@ -0,0 +1,17 @@
package sh.rhiobet.lalafin.file;
import java.util.List;
import java.util.Optional;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "file")
public interface FileConfiguration {
public String directory();
public Optional<List<String>> ignored();
public List<Route> routes();
public static interface Route {
public String path();
public Optional<List<String>> roles();
}
}

View File

@@ -0,0 +1,36 @@
package sh.rhiobet.lalafin.file;
import java.util.List;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.access.AccessService;
import sh.rhiobet.lalafin.path.Path;
@ApplicationScoped
@Named("file/role")
public class FileRoleAccessService implements AccessService {
@Inject
SecurityIdentity securityIdentity;
@Inject
FileConfiguration fileConfiguration;
public boolean checkAccess(Path path) {
String pathUri = path.getURI();
List<FileConfiguration.Route> matchingRoutes = fileConfiguration.routes().stream()
.filter(r -> pathUri.startsWith(r.path()))
.toList();
if (matchingRoutes.isEmpty()) {
return false;
}
return matchingRoutes.stream()
.allMatch(r -> r.roles().isEmpty()
|| this.securityIdentity.getRoles().containsAll(r.roles().get()));
}
}

View File

@@ -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<String> segments;
java.nio.file.Path absolutePath;
FileSystemPath(List<PathSegment> 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<Path> 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));
}
}

View File

@@ -0,0 +1,7 @@
package sh.rhiobet.lalafin.path;
public class InvalidPathException extends Exception {
public InvalidPathException(String message) {
super(message);
}
}

View File

@@ -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<Path> getChildren() throws IOException;
InputStream getInputStream() throws IOException;
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,55 @@
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<PathSegment> 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());
int zipIndex = -1;
for (int i = 0; i < names.size(); i++) {
if (names.get(i).getPath().endsWith(".zip")) {
zipIndex = i;
break;
}
}
if (zipIndex >= 0 && zipIndex < names.size() - 1) {
FileSystemPath zipFilePath =
new FileSystemPath(names.subList(0, zipIndex + 1), rootFolderPath);
return new ZipEntryPath(zipFilePath, names.subList(zipIndex + 1, names.size()));
} else {
return new FileSystemPath(names, rootFolderPath);
}
}
}
private boolean hasEncodedPathSeparator(List<PathSegment> 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(".");
});
}
}

View File

@@ -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<String> resolveURI(Path path);
OutputStream getOutputStream(Path path) throws IOException;
}

View File

@@ -0,0 +1,162 @@
package sh.rhiobet.lalafin.path;
import jakarta.ws.rs.core.PathSegment;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.jboss.resteasy.reactive.common.util.Encode;
public final class ZipEntryPath implements Path {
private long size = 0;
private boolean exists = false;
FileSystemPath zipFilePath;
List<String> segments;
String entryName;
ZipEntryPath(FileSystemPath parent, List<PathSegment> entryName) {
this.zipFilePath = parent;
this.segments = entryName.stream().map(PathSegment::getPath).toList();
this.entryName = this.segments.stream().collect(Collectors.joining("/"));
}
ZipEntryPath(FileSystemPath parent, String entryName) {
this.zipFilePath = parent;
this.segments = Arrays.asList(entryName.split("/"));
this.entryName = entryName;
}
@Override
public String getURI() {
return this.zipFilePath.getURI() + this.segments.stream()
.map(s -> "/" + Encode.encodePathSegment(s))
.reduce("", String::concat);
}
@Override
public String getFilename() {
return this.segments.getLast();
}
@Override
public long getSize() {
if (this.size == 0) {
fetchEntryData();
}
return this.size;
}
@Override
public boolean exists() {
if (!this.exists) {
fetchEntryData();
}
return this.exists;
}
@Override
public boolean canHaveChildren() {
return false;
}
@Override
public List<Path> getChildren() throws IOException {
return List.of();
}
@Override
public InputStream getInputStream() throws IOException {
if (this.zipFilePath.exists()) {
ZipFile zipFile = new ZipFile(this.zipFilePath.absolutePath.toFile());
ZipEntry zipEntry = zipFile.getEntry(this.entryName);
if (zipEntry != null) {
return new ZipEntryInputStream(zipFile, zipFile.getInputStream(zipEntry));
} else {
zipFile.close();
throw new IOException("Zip entry does not exist.");
}
} else {
throw new IOException("Zip file does not exist.");
}
}
private void fetchEntryData() {
if (!this.zipFilePath.exists()) {
return;
}
try (ZipFile zipFile = new ZipFile(this.zipFilePath.absolutePath.toFile())) {
ZipEntry zipEntry = zipFile.getEntry(this.entryName);
this.exists = zipEntry != null;
if (this.exists) {
this.size = zipEntry.getSize();
}
} catch (IOException ignored) { }
}
private 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();
}
}
}
}

View File

@@ -0,0 +1,73 @@
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 java.util.regex.Pattern;
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<String> resolveURI(Path path) {
switch (path) {
case FileSystemPath fsp:
Optional<java.nio.file.Path> 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<java.nio.file.Path> 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<java.nio.file.Path> getThumbnailPath(Path path) {
try {
return Files.list(getAbsolutePath(path).getParent().resolve(".thumbnails"))
.filter(f -> f.getFileName().toString()
.matches("^" + Pattern.quote(path.getFilename()) + "(\\.[^.]+)*$"))
.findFirst();
} catch (IOException ignored) {
return Optional.empty();
}
}
}