Compare commits

..

15 Commits

Author SHA1 Message Date
1a0c810214 Add public token generation 2026-04-26 20:02:19 +02:00
1c6b127543 Add cache for performance improvements + small fixes 2026-04-26 18:20:07 +02:00
a60df48774 Implement default thumbnail generator 2026-04-25 12:35:19 +02:00
23db8681d3 Small fixes 2026-04-22 13:26:26 +02:00
c7ee90cbaa Set overall structure 2026-04-21 18:07:15 +02:00
5e453dad51 Partially reimplement private file API 2026-04-21 17:32:37 +02:00
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
50f4ededfb Fix typo 2026-04-12 20:41:50 +02:00
d47de0609c Improve zip resources handling 2026-04-12 20:37:55 +02:00
765f486938 Small fixes in HTTP server 2026-04-12 20:37:17 +02:00
35 changed files with 1298 additions and 30 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import io.smallrye.config.ConfigMapping; import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "lalafin") @ConfigMapping(prefix = "lalafin.core")
public interface LalafinConfiguration { public interface LalafinConfiguration {
public String base_url(); public String base_url();

View File

@@ -0,0 +1,57 @@
package sh.rhiobet.lalafin.advent.access;
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.advent.configuration.AdventConfiguration;
import sh.rhiobet.lalafin.core.access.AccessService;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
@ApplicationScoped
@Named("advent/access")
public class AdventAccessService implements AccessService {
@Inject
AdventConfiguration adventConfiguration;
@Inject
@Named("file/resolver")
PathURIResolver fileURIResolver;
@Override
public boolean checkAccess(Path path) {
Optional<String> pathUri = this.fileURIResolver.resolve(path);
if (pathUri.isEmpty()) {
return false;
}
Optional<AdventConfiguration.AdventEvent> matchingAdvent = adventConfiguration.events().stream()
.filter(e -> pathUri.get().startsWith(e.path()))
.findFirst();
if (matchingAdvent.isEmpty()) {
return true;
}
String dayString = pathUri.get().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.configuration;
import java.util.List;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "lalafin.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,60 @@
package sh.rhiobet.lalafin.advent.resolver;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Optional;
import jakarta.annotation.Priority;
import jakarta.decorator.Decorator;
import jakarta.decorator.Delegate;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.advent.access.AdventAccessService;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.model.PathAccessor;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
@Decorator
@Priority(0)
public class AdventThumbnailPathURIResolver extends PathAccessor implements PathURIResolver {
@Inject
FileConfiguration fileConfiguration;
@Inject
@Named("file/resolver/thumbnail")
@Delegate
PathURIResolver thumbnailURIResolver;
@Inject
AdventAccessService adventAccessService;
@Override
public Optional<String> resolve(Path path) {
if (this.adventAccessService.checkAccess(path)) {
return this.thumbnailURIResolver.resolve(path);
} else {
Optional<java.nio.file.Path> thumbnailAbsolutePath = getDefaultThumbnailPath(path);
if (thumbnailAbsolutePath.isPresent()) {
java.nio.file.Path rootFolderPath = Paths.get(this.fileConfiguration.directory());
return Optional.of(
"/" + rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get())
.toString());
} else {
return Optional.empty();
}
}
}
private Optional<java.nio.file.Path> getDefaultThumbnailPath(Path path) {
try {
return Files.list(getAbsolutePath(path).get().getParent().resolve(".thumbnails"))
.filter(f -> f.getFileName().toString().matches("^00(\\.[^.]+)*$"))
.findFirst();
} catch (IOException ignored) {
return Optional.empty();
}
}
}

View File

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

View File

@@ -0,0 +1,28 @@
package sh.rhiobet.lalafin.core.cache;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import jakarta.enterprise.context.Dependent;
@Dependent
public class Cache<K, V> {
private final Map<K, V> cache = new HashMap<>();
public V computeIfAbsent(K key, ThrowingFunction<K, V> loader) throws IOException {
if (!cache.containsKey(key)) {
cache.put(key, loader.apply(key));
}
return cache.get(key);
}
public V get(K key) {
return this.cache.get(key);
}
public void invalidate(K key) {
cache.remove(key);
}
}

View File

@@ -0,0 +1,8 @@
package sh.rhiobet.lalafin.core.cache;
import java.io.IOException;
@FunctionalInterface
public interface ThrowingFunction<K, V> {
V apply(K key) throws IOException;
}

View File

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

View File

@@ -0,0 +1,79 @@
package sh.rhiobet.lalafin.core.path.model;
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;
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 getFilename() {
return this.segments.isEmpty() ? "/" : 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.isEmpty() && 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,14 @@
package sh.rhiobet.lalafin.core.path.model;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
sealed public interface Path permits FileSystemPath, ZipEntryPath {
String getFilename();
long getSize();
boolean exists();
boolean canHaveChildren();
List<Path> getChildren() throws IOException;
InputStream getInputStream() throws IOException;
}

View File

@@ -0,0 +1,30 @@
package sh.rhiobet.lalafin.core.path.model;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public abstract class PathAccessor {
protected Optional<java.nio.file.Path> getAbsolutePath(Path path) {
switch (path) {
case FileSystemPath fsp:
return Optional.of(fsp.absolutePath);
case ZipEntryPath zep:
return Optional.of(zep.zipFilePath.absolutePath);
}
}
protected List<String> getSegments(Path path) {
switch (path) {
case FileSystemPath fsp:
return Collections.unmodifiableList(fsp.segments);
case ZipEntryPath zep:
return Stream.concat(zep.zipFilePath.segments.stream(), zep.segments.stream())
.collect(Collectors.toUnmodifiableList());
}
}
}

View File

@@ -0,0 +1,49 @@
package sh.rhiobet.lalafin.core.path.model;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.PathSegment;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import sh.rhiobet.lalafin.core.path.exception.InvalidPathException;
@ApplicationScoped
public class PathFactory {
public Path toPath(List<PathSegment> names, java.nio.file.Path rootFolderPath)
throws InvalidPathException {
if (hasEncodedPathSeparator(names)) {
throw new InvalidPathException("Some path segments contain illegal characters.");
} else {
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,153 @@
package sh.rhiobet.lalafin.core.path.model;
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;
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 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,9 @@
package sh.rhiobet.lalafin.core.path.resolver;
import java.util.Optional;
import sh.rhiobet.lalafin.core.path.model.Path;
public interface PathURIResolver {
Optional<String> resolve(Path path);
}

View File

@@ -0,0 +1,18 @@
package sh.rhiobet.lalafin.core.thumbnail;
import java.nio.file.Path;
import java.util.Map;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import sh.rhiobet.lalafin.core.cache.Cache;
@RequestScoped
public class ThumbnailCacheManager {
@Inject
private Cache<Path, Map<String, Path>> thumbnailCache;
public Cache<Path, Map<String, Path>> getCache() {
return this.thumbnailCache;
}
}

View File

@@ -0,0 +1,9 @@
package sh.rhiobet.lalafin.core.thumbnail;
import java.io.IOException;
import sh.rhiobet.lalafin.core.path.model.Path;
public interface ThumbnailGenerator {
void generate(Path path) throws IOException;
}

View File

@@ -0,0 +1,56 @@
package sh.rhiobet.lalafin.core.thumbnail;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import jakarta.inject.Inject;
import sh.rhiobet.lalafin.core.path.model.FileSystemPath;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.model.PathAccessor;
import sh.rhiobet.lalafin.core.path.model.ZipEntryPath;
public abstract class ThumbnailPathAccessor extends PathAccessor {
@Inject
protected ThumbnailCacheManager thumbnailCacheManager;
@Override
protected Optional<java.nio.file.Path> getAbsolutePath(Path path) {
try {
switch (path) {
case FileSystemPath fsp:
java.nio.file.Path thumbnailsFolder = getThumbnailRootPath(path);
Map<String, java.nio.file.Path> thumbnails =
this.thumbnailCacheManager.getCache().computeIfAbsent(thumbnailsFolder, p -> {
if (Files.exists(thumbnailsFolder)) {
return Files.list(thumbnailsFolder)
.collect(Collectors.toMap(
f -> f.getFileName().toString().replaceAll("\\.[^.]+$", ""),
f -> f,
(a, b) -> a,
HashMap::new
));
} else {
return new HashMap<>();
}
});
return Optional.of(thumbnails.getOrDefault(path.getFilename(),
thumbnailsFolder.resolve(path.getFilename() + ".png")));
case ZipEntryPath zep:
return Optional.empty();
}
} catch (IOException ignored) {
return Optional.empty();
}
}
protected java.nio.file.Path getThumbnailRootPath(Path path) {
return super.getAbsolutePath(path).get().getParent().resolve(".thumbnails");
}
}

View File

@@ -0,0 +1,42 @@
package sh.rhiobet.lalafin.core.util;
public class MimeType {
public static String getMimeType(String filename) {
String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
switch (extension) {
case "3gp":
return "video/3gpp";
case "avi":
return "video/x-msvideo";
case "flac":
return "audio/x-flac";
case "flv":
return "video/x-flv";
case "html":
return "text/html";
case "jpg":
return "image/jpeg";
case "mkv":
return "video/x-matroska";
case "mp3":
return "audio/mp3";
case "mp4":
return "video/mp4";
case "png":
return "image/png";
case "ts":
return "video/MP2T";
case "wav":
return "audio/x-wav";
case "webm":
return "video/webm";
case "wmv":
return "video/x-ms-wmv";
case "zip":
return "application/zip";
default:
return "application/octet-stream";
}
}
}

View File

@@ -224,8 +224,7 @@ public class FileInfoService {
archiveInfo.viewUrl = "/view" + requestedUri + "/1"; archiveInfo.viewUrl = "/view" + requestedUri + "/1";
Path zipPath = Paths.get(fileApiConfiguration.directory() + "/file/" + requestedPath); Path zipPath = Paths.get(fileApiConfiguration.directory() + "/file/" + requestedPath);
try { try (ZipFile zipFile = new ZipFile(zipPath.toFile())) {
ZipFile zipFile = new ZipFile(zipPath.toFile());
zipFile.stream().filter(e -> !e.isDirectory()).forEach(e -> { zipFile.stream().filter(e -> !e.isDirectory()).forEach(e -> {
String zipEntryName = e.getName(); String zipEntryName = e.getName();
String zipEntryNameUri = String zipEntryNameUri =

View File

@@ -3,6 +3,7 @@ package sh.rhiobet.lalafin.file;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.channels.Channels; import java.nio.channels.Channels;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -73,8 +74,9 @@ public class FileServeService {
} }
response = Response.ok(path.toFile()); response = Response.ok(path.toFile());
channel.position(rangeStart);
response.entity(new FileServeInputStream(Channels.newInputStream(channel), response.entity(new FileServeInputStream(Channels.newInputStream(channel),
rangeStart, rangeEnd)); rangeEnd + 1 - rangeStart));
response.header("Content-Length", response.header("Content-Length",
Long.toString(rangeEnd + 1 - rangeStart)); Long.toString(rangeEnd + 1 - rangeStart));
@@ -87,8 +89,8 @@ public class FileServeService {
channel.close(); channel.close();
} }
response.header("Accept-Ranges", "bytes"); response.header("Accept-Ranges", "bytes");
response.header("Content-Disposition", response.header("Content-Disposition", "inline; filename*=UTF-8''" +
"inline; filename=\"" + fileInfo.filename + "\""); URLEncoder.encode(fileInfo.filename, StandardCharsets.UTF_8).replace("+", "%20"));
response.header("Content-Type", FileHelper.getMimeType(fileInfo.filename)); response.header("Content-Type", FileHelper.getMimeType(fileInfo.filename));
if (path.toString().contains("/.thumbnails/")) { if (path.toString().contains("/.thumbnails/")) {
response.header("Cache-Control", "max-age=604800"); response.header("Cache-Control", "max-age=604800");
@@ -110,17 +112,19 @@ public class FileServeService {
ZipEntry zipEntry = zipFile.getEntry(name); ZipEntry zipEntry = zipFile.getEntry(name);
if (zipEntry == null) { if (zipEntry == null) {
zipFile.close();
throw new IOException(); throw new IOException();
} }
ResponseBuilder response; ResponseBuilder response;
long fileSize = zipEntry.getSize(); long fileSize = zipEntry.getSize();
response = Response.ok(zipFile.getInputStream(zipEntry)); response = Response.ok(new ZipEntryInputStream(zipFile, zipFile.getInputStream(zipEntry)));
response.header("Content-Length", Long.toString(fileSize)); response.header("Content-Length", Long.toString(fileSize));
response.header("Accept-Ranges", "bytes"); response.header("Accept-Ranges", "bytes");
response.header("Content-Disposition", "inline; filename=\"" + name + "\""); response.header("Content-Disposition", "inline; filename*=UTF-8''" +
URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"));
response.header("Content-Type", FileHelper.getMimeType(name)); response.header("Content-Type", FileHelper.getMimeType(name));
return response.build(); return response.build();
} catch (IOException e) { } catch (IOException e) {
@@ -133,22 +137,14 @@ class FileServeInputStream extends InputStream {
private InputStream is; private InputStream is;
private long remaining; private long remaining;
public FileServeInputStream(InputStream is, long startRange, long endRange) throws IOException { public FileServeInputStream(InputStream is, long remaining) throws IOException {
this.is = is; this.is = is;
try { this.remaining = remaining;
this.is.skip(startRange);
} catch (IOException e) {
try {
this.is.close();
} catch (Exception ignored) {}
throw e;
}
this.remaining = endRange + 1 - startRange;
} }
@Override @Override
public int available() throws IOException { public int available() throws IOException {
return this.is.available(); return (int) Math.min(this.is.available(), this.remaining);
} }
@Override @Override
@@ -170,27 +166,37 @@ class FileServeInputStream extends InputStream {
@Override @Override
public byte[] readAllBytes() throws IOException { public byte[] readAllBytes() throws IOException {
return this.is.readAllBytes(); byte[] bytes = this.is.readNBytes((int) this.remaining);
this.remaining -= bytes.length;
return bytes;
} }
@Override @Override
public byte[] readNBytes(int arg0) throws IOException { public byte[] readNBytes(int len) throws IOException {
return this.is.readNBytes(arg0); byte[] bytes = this.is.readNBytes((int) Math.min(len, this.remaining));
this.remaining -= bytes.length;
return bytes;
} }
@Override @Override
public int readNBytes(byte[] arg0, int arg1, int arg2) throws IOException { public int readNBytes(byte[] buf, int off, int len) throws IOException {
return this.is.readNBytes(arg0, arg1, arg2); int read = this.is.readNBytes(buf, off, (int) Math.min(len, this.remaining));
this.remaining -= read;
return read;
} }
@Override @Override
public long skip(long arg0) throws IOException { public long skip(long n) throws IOException {
return this.is.skip(arg0); long skipped = this.is.skip(Math.min(n, this.remaining));
this.remaining -= skipped;
return skipped;
} }
@Override @Override
public void skipNBytes(long arg0) throws IOException { public void skipNBytes(long n) throws IOException {
this.is.skipNBytes(arg0); long toSkip = Math.min(n, this.remaining);
this.is.skipNBytes(toSkip);
this.remaining -= toSkip;
} }
@Override @Override
@@ -216,3 +222,62 @@ class FileServeInputStream extends InputStream {
} }
} }
} }
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[] buffer) throws IOException {
return this.is.read(buffer);
}
@Override
public int read(byte[] buffer, int off, int len) throws IOException {
return this.is.read(buffer, 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,47 @@
package sh.rhiobet.lalafin.file.access;
import java.util.List;
import java.util.Optional;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.core.access.AccessService;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
@ApplicationScoped
@Named("file/access/role")
public class FileRoleAccessService implements AccessService {
@Inject
SecurityIdentity securityIdentity;
@Inject
FileConfiguration fileConfiguration;
@Inject
@Named("file/resolver")
PathURIResolver fileURIResolver;
public boolean checkAccess(Path path) {
Optional<String> pathUri = this.fileURIResolver.resolve(path);
if (pathUri.isEmpty()) {
return false;
}
List<FileConfiguration.Route> matchingRoutes = fileConfiguration.routes().stream()
.filter(r -> pathUri.get().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,20 @@
package sh.rhiobet.lalafin.file.access;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public class FileToken {
public String user;
public long timestamp;
public String ip;
public String file;
public FileToken() {}
public FileToken(String user, long timestamp, String ip, String file) {
this.user = user;
this.timestamp = timestamp;
this.ip = ip;
this.file = file;
}
}

View File

@@ -0,0 +1,34 @@
package sh.rhiobet.lalafin.file.access;
import java.util.UUID;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.security.identity.SecurityIdentity;
import io.vertx.core.http.HttpServerRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class FileTokenProvider {
@Inject
RedisDataSource redisDataSource;
@Inject
SecurityIdentity securityIdentity;
@Inject
HttpServerRequest request;
public String getFileToken(String fileUri) {
FileToken token = new FileToken(this.securityIdentity.getPrincipal().getName(),
System.currentTimeMillis(), this.request.remoteAddress().host(), fileUri);
String uniqueID = UUID.randomUUID().toString();
this.redisDataSource.value(FileToken.class).set(
"fileToken-" + uniqueID, token, new SetArgs().pxAt(System.currentTimeMillis() + 86400000));
return uniqueID;
}
}

View File

@@ -0,0 +1,17 @@
package sh.rhiobet.lalafin.file.configuration;
import java.util.List;
import java.util.Optional;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "lalafin.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,17 @@
package sh.rhiobet.lalafin.file.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public class FileInfo extends FileInfoBase {
public String publicApiUrl;
public FileInfo(String filename, String thumbnailUrl, String directUrl, String publicApiUrl) {
super(filename, "file", thumbnailUrl, directUrl);
this.publicApiUrl = publicApiUrl;
}
public FileInfo(String filename, String directUrl, String publicApiUrl) {
this(filename, "", directUrl, publicApiUrl);
}
}

View File

@@ -0,0 +1,30 @@
package sh.rhiobet.lalafin.file.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public abstract class FileInfoBase implements Comparable<FileInfoBase> {
public String filename;
public String type;
public String thumbnailUrl;
public String directUrl;
public String viewUrl;
public FileInfoBase(String filename, String type, String thumbnailUrl, String directUrl,
String viewUrl) {
this.filename = filename;
this.type = type;
this.thumbnailUrl = thumbnailUrl;
this.directUrl = directUrl;
this.viewUrl = viewUrl;
}
public FileInfoBase(String filename, String type, String thumbnailUrl, String directUrl) {
this(filename, type, thumbnailUrl, directUrl, "");
}
@Override
public int compareTo(FileInfoBase f) {
return this.filename.compareToIgnoreCase(f.filename);
}
}

View File

@@ -0,0 +1,122 @@
package sh.rhiobet.lalafin.file.model;
import io.quarkus.logging.Log;
import java.io.IOException;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.LalafinConfiguration;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
import sh.rhiobet.lalafin.core.thumbnail.ThumbnailGenerator;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
@ApplicationScoped
public class FileMetadataService {
@Inject
LalafinConfiguration lalafinConfiguration;
@Inject
FileConfiguration fileConfiguration;
@Inject
@Named("file/resolver")
PathURIResolver fileURIResolver;
@Inject
@Named("file/resolver/thumbnail")
PathURIResolver fileThumbnailURIResolver;
@Inject
@Named("file/resolver/token")
PathURIResolver fileTokenURIResolver;
@Inject
@Named("file/thumbnail")
ThumbnailGenerator fileThumbnailGenerator;
public FileInfoBase getInfo(Path filePath) {
if (filePath.exists()) {
Optional<String> fileUrl = this.fileURIResolver.resolve(filePath);
Optional<String> thumbUrl = this.fileThumbnailURIResolver.resolve(filePath);
if (thumbUrl.isEmpty()) {
try {
this.fileThumbnailGenerator.generate(filePath);
thumbUrl = this.fileThumbnailURIResolver.resolve(filePath);
} catch (IOException e) {
Log.warn("Failed to generate thumbnail for " + filePath + ".", e);
}
}
if (filePath.canHaveChildren()) {
FolderInfo folderInfo = new FolderInfo(
filePath.getFilename(),
thumbUrl.isPresent() ? thumbUrl.get() : "",
fileUrl.isPresent() ? fileUrl.get() : "",
""
);
try {
for (Path child : filePath.getChildren()) {
if (child.getFilename().startsWith(".") ||
fileConfiguration.ignored().isPresent() &&
fileConfiguration.ignored().get().stream()
.anyMatch(i -> child.getFilename().endsWith(i)))
{
continue;
}
Optional<String> childFileUrl = this.fileURIResolver.resolve(child);
Optional<String> childThumbUrl = this.fileThumbnailURIResolver.resolve(child);
if (childThumbUrl.isEmpty()) {
try {
this.fileThumbnailGenerator.generate(child);
childThumbUrl = this.fileThumbnailURIResolver.resolve(child);
} catch (IOException e) {
Log.warn("Failed to generate thumbnail for " + child + ".", e);
}
}
FileInfoBase contentInfo;
if (child.canHaveChildren()) {
contentInfo = new FolderInfo(child.getFilename(),
childFileUrl.isPresent() ? childFileUrl.get() : "");
} else {
Optional<String> childTokenUrl = this.fileTokenURIResolver.resolve(child);
contentInfo = new FileInfo(
child.getFilename(),
childFileUrl.isPresent() ? childFileUrl.get() : "",
childTokenUrl.isPresent() ? childTokenUrl.get() : ""
);
}
if (childThumbUrl.isPresent()) {
contentInfo.thumbnailUrl = childThumbUrl.get();
}
folderInfo.content.add(contentInfo);
}
} catch (IOException e) {
Log.warn("Failed to walk through children of " + filePath + ".", e);
}
return folderInfo;
} else {
Optional<String> tokenUrl = this.fileTokenURIResolver.resolve(filePath);
FileInfo fileInfo = new FileInfo(
filePath.getFilename(),
thumbUrl.isPresent() ? thumbUrl.get() : "",
fileUrl.isPresent() ? fileUrl.get() : "",
tokenUrl.isPresent() ? tokenUrl.get() : ""
);
return fileInfo;
}
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
package sh.rhiobet.lalafin.file.model;
import java.util.Set;
import java.util.TreeSet;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class FolderInfo extends FileInfoBase {
public Set<FileInfoBase> content;
public FileInfo playlist;
public FolderInfo(String filename, String thumbnailUrl, String directUrl, String viewUrl) {
super(filename, "folder", thumbnailUrl, directUrl, viewUrl);
this.content = new TreeSet<>();
}
public FolderInfo(String filename, String directUrl) {
this(filename, "", directUrl, "");
}
}

View File

@@ -0,0 +1,30 @@
package sh.rhiobet.lalafin.file.resolver;
import java.util.List;
import java.util.Optional;
import org.jboss.resteasy.reactive.common.util.Encode;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.model.PathAccessor;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
@ApplicationScoped
@Named("file/resolver")
public class FilePathURIResolver extends PathAccessor implements PathURIResolver {
@Inject
FileConfiguration fileConfiguration;
@Override
public Optional<String> resolve(Path path) {
List<String> segments = getSegments(path);
return Optional.of(segments.stream()
.map(s -> "/" + Encode.encodePathSegment(s))
.reduce("/file", String::concat));
}
}

View File

@@ -0,0 +1,43 @@
package sh.rhiobet.lalafin.file.resolver;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Optional;
import org.jboss.resteasy.reactive.common.util.Encode;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
import sh.rhiobet.lalafin.core.path.model.FileSystemPath;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.model.ZipEntryPath;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
import sh.rhiobet.lalafin.core.thumbnail.ThumbnailPathAccessor;
@RequestScoped
@Named("file/resolver/thumbnail")
public class FileThumbnailPathURIResolver extends ThumbnailPathAccessor implements PathURIResolver {
@Inject
FileConfiguration fileConfiguration;
@Override
public Optional<String> resolve(Path path) {
switch (path) {
case FileSystemPath fsp:
Optional<java.nio.file.Path> thumbnailAbsolutePath = getAbsolutePath(path);
if (thumbnailAbsolutePath.isPresent() && Files.exists(thumbnailAbsolutePath.get())) {
java.nio.file.Path rootFolderPath = Paths.get(this.fileConfiguration.directory());
return Optional.of("/file/" + Encode.encodePath(
rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get()).toString()));
} else {
return Optional.empty();
}
case ZipEntryPath zep:
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,36 @@
package sh.rhiobet.lalafin.file.resolver;
import java.util.Optional;
import org.jboss.resteasy.reactive.common.util.Encode;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.resolver.PathURIResolver;
import sh.rhiobet.lalafin.file.access.FileTokenProvider;
@ApplicationScoped
@Named("file/resolver/token")
public class FileTokenURIResolver implements PathURIResolver {
@Inject
@Named("file/resolver")
PathURIResolver fileURIResolver;
@Inject
FileTokenProvider fileTokenProvider;
@Override
public Optional<String> resolve(Path path) {
Optional<String> fileURI = this.fileURIResolver.resolve(path);
if (fileURI.isPresent()) {
return Optional.of(
"/v1/api/public/file/token/" + this.fileTokenProvider.getFileToken(fileURI.get()) + "/"
+ Encode.encodePathSegment(path.getFilename()));
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,70 @@
package sh.rhiobet.lalafin.file.rest;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.PathSegment;
import jakarta.ws.rs.core.Response;
import io.quarkus.security.Authenticated;
import sh.rhiobet.lalafin.core.access.AccessService;
import sh.rhiobet.lalafin.core.path.exception.InvalidPathException;
import sh.rhiobet.lalafin.core.path.model.PathFactory;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
import sh.rhiobet.lalafin.file.model.FileInfoBase;
import sh.rhiobet.lalafin.file.model.FileMetadataService;
@Authenticated
@Path("/v1/api/private/file")
public class FilePrivateAPI {
@Inject
FileConfiguration fileConfiguration;
@Inject
FileMetadataService fileMetadataService;
@Inject
Instance<AccessService> accessServices;
@Inject
PathFactory pathFactory;
@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public Response getFileInfo() {
return this.getFileInfo(new ArrayList<>());
}
@GET
@Path("/{names: .+}")
@Produces(MediaType.APPLICATION_JSON)
public Response getFileInfo(List<PathSegment> names) {
java.nio.file.Path rootFolderPath = Paths.get(this.fileConfiguration.directory());
sh.rhiobet.lalafin.core.path.model.Path path;
try {
path = this.pathFactory.toPath(names, rootFolderPath);
} catch (InvalidPathException ignored) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
if (accessServices.stream().anyMatch((a -> !a.checkAccess(path)))) {
return Response.status(Response.Status.FORBIDDEN).build();
}
FileInfoBase fileInfo = this.fileMetadataService.getInfo(path);
if (fileInfo == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(fileInfo).build();
}
}

View File

@@ -0,0 +1,74 @@
package sh.rhiobet.lalafin.file.thumbnail;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Map;
import java.util.Optional;
import org.im4java.core.ConvertCmd;
import org.im4java.core.IM4JavaException;
import org.im4java.core.IMOperation;
import org.im4java.process.Pipe;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.thumbnail.ThumbnailGenerator;
import sh.rhiobet.lalafin.core.thumbnail.ThumbnailPathAccessor;
import sh.rhiobet.lalafin.core.util.MimeType;
@ApplicationScoped
@Named("file/thumbnail")
public class FileThumbnailGenerator extends ThumbnailPathAccessor implements ThumbnailGenerator {
@Inject
FileConfiguration fileConfiguration;
@Override
public void generate(Path path) throws IOException {
Optional<java.nio.file.Path> thumbnailAbsolutePath = getAbsolutePath(path);
if (!path.canHaveChildren() || thumbnailAbsolutePath.isEmpty()) {
return;
}
if (Files.exists(thumbnailAbsolutePath.get())) {
Files.delete(thumbnailAbsolutePath.get());
}
Files.createDirectories(thumbnailAbsolutePath.get().getParent());
for (Path childPath : path.getChildren()) {
String mimetype = MimeType.getMimeType(childPath.getFilename());
if (mimetype.startsWith("image/")) {
String extension = childPath.getFilename()
.substring(childPath.getFilename().lastIndexOf('.') + 1).toLowerCase();
ConvertCmd convert = new ConvertCmd();
convert.getCommand().clear();
convert.getCommand().push("magick");
IMOperation op = new IMOperation();
try (InputStream pathInputStream = childPath.getInputStream()) {
Pipe inputPipe = new Pipe(pathInputStream, null);
op.addImage(extension + ":-");
op.resize(200, null);
op.addImage(thumbnailAbsolutePath.get().toString());
convert.setInputProvider(inputPipe);
convert.run(op);
Map<String, java.nio.file.Path> thumbnailCache =
this.thumbnailCacheManager.getCache().get(getThumbnailRootPath(path));
thumbnailCache.put(path.getFilename(), thumbnailAbsolutePath.get());
return;
} catch (InterruptedException | IM4JavaException e) {
throw new IOException("Error when generating thumbnail with IM4J.", e);
}
}
}
}
}