Compare commits

...

2 Commits

Author SHA1 Message Date
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
21 changed files with 353 additions and 56 deletions

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

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

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.advent; package sh.rhiobet.lalafin.advent.access;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@@ -7,13 +7,13 @@ import java.util.regex.Pattern;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Named; import jakarta.inject.Named;
import sh.rhiobet.lalafin.access.AccessService; import sh.rhiobet.lalafin.advent.configuration.AdventConfiguration;
import sh.rhiobet.lalafin.path.Path; import sh.rhiobet.lalafin.core.access.AccessService;
import sh.rhiobet.lalafin.core.path.model.Path;
@ApplicationScoped @ApplicationScoped
@Named("advent") @Named("advent")
public class AdventAccessService implements AccessService { public class AdventAccessService implements AccessService {
@Inject @Inject
AdventConfiguration adventConfiguration; AdventConfiguration adventConfiguration;

View File

@@ -1,9 +1,9 @@
package sh.rhiobet.lalafin.advent; package sh.rhiobet.lalafin.advent.configuration;
import java.util.List; import java.util.List;
import io.smallrye.config.ConfigMapping; import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "advent") @ConfigMapping(prefix = "lalafin.advent")
public interface AdventConfiguration { public interface AdventConfiguration {
public List<AdventEvent> events(); public List<AdventEvent> events();

View File

@@ -0,0 +1,68 @@
package sh.rhiobet.lalafin.advent.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.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.api.configuration.FileApiConfiguration;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.model.PathAccessor;
import sh.rhiobet.lalafin.core.path.plugin.PathPlugin;
@Decorator
public class AdventThumbnailPathPlugin extends PathAccessor implements PathPlugin {
@Inject
FileApiConfiguration fileApiConfiguration;
@Inject
@Named("file/thumbnail")
@Delegate
PathPlugin thumbnailPathPlugin;
@Inject
AdventAccessService adventAccessService;
@Override
public Optional<String> resolveURI(Path path) {
if (this.adventAccessService.checkAccess(path)) {
return this.thumbnailPathPlugin.resolveURI(path);
} else {
Optional<java.nio.file.Path> thumbnailAbsolutePath = getDefaultThumbnailPath(path);
if (thumbnailAbsolutePath.isPresent()) {
java.nio.file.Path rootFolderPath = Paths.get(this.fileApiConfiguration.directory());
return Optional.of(
"/" + rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get())
.toString());
} else {
return Optional.empty();
}
}
}
@Override
public OutputStream getOutputStream(Path path) throws IOException {
if (this.adventAccessService.checkAccess(path)) {
return this.thumbnailPathPlugin.getOutputStream(path);
} else {
throw new IOException("You don't yet have access to this resource.");
}
}
private Optional<java.nio.file.Path> getDefaultThumbnailPath(Path path) {
try {
return Files.list(getAbsolutePath(path).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

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

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.model;
import jakarta.ws.rs.core.PathSegment; import jakarta.ws.rs.core.PathSegment;
@@ -31,6 +31,9 @@ public final class FileSystemPath implements Path {
@Override @Override
public String getURI() { public String getURI() {
if (this.segments.isEmpty()) {
return "/";
}
return this.segments.stream() return this.segments.stream()
.map(s -> "/" + Encode.encodePathSegment(s)) .map(s -> "/" + Encode.encodePathSegment(s))
.reduce("", String::concat); .reduce("", String::concat);
@@ -38,16 +41,16 @@ public final class FileSystemPath implements Path {
@Override @Override
public String getFilename() { public String getFilename() {
return this.segments.getLast(); return this.segments.isEmpty() ? "/" : this.segments.getLast();
} }
@Override @Override
public long getSize() { public long getSize() {
try { try {
return Files.size(this.absolutePath); return Files.size(this.absolutePath);
} catch (IOException ignored) { } catch (IOException ignored) {
return 0; return 0;
} }
} }
@Override @Override
@@ -62,7 +65,7 @@ public final class FileSystemPath implements Path {
@Override @Override
public List<Path> getChildren() throws IOException { public List<Path> getChildren() throws IOException {
if (this.segments.getLast().endsWith(".zip")) { if (!this.segments.isEmpty() && this.segments.getLast().endsWith(".zip")) {
try (ZipFile zipFile = new ZipFile(this.absolutePath.toFile())) { try (ZipFile zipFile = new ZipFile(this.absolutePath.toFile())) {
return zipFile.stream() return zipFile.stream()
.map(e -> (Path) new ZipEntryPath(this, e.getName())) .map(e -> (Path) new ZipEntryPath(this, e.getName()))

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.model;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.model;
public abstract class PathAccessor { public abstract class PathAccessor {
protected java.nio.file.Path getAbsolutePath(Path path) { protected java.nio.file.Path getAbsolutePath(Path path) {

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.model;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -10,6 +10,7 @@ import java.nio.file.Paths;
import java.util.List; import java.util.List;
import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration; import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration;
import sh.rhiobet.lalafin.core.path.exception.InvalidPathException;
@ApplicationScoped @ApplicationScoped
public class PathFactory { public class PathFactory {

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.model;
import jakarta.ws.rs.core.PathSegment; import jakarta.ws.rs.core.PathSegment;

View File

@@ -1,9 +1,11 @@
package sh.rhiobet.lalafin.path; package sh.rhiobet.lalafin.core.path.plugin;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.Optional; import java.util.Optional;
import sh.rhiobet.lalafin.core.path.model.Path;
public interface PathPlugin { public interface PathPlugin {
Optional<String> resolveURI(Path path); Optional<String> resolveURI(Path path);
OutputStream getOutputStream(Path path) throws IOException; OutputStream getOutputStream(Path path) throws IOException;

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.file; package sh.rhiobet.lalafin.file.access;
import java.util.List; import java.util.List;
@@ -6,8 +6,9 @@ import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Named; import jakarta.inject.Named;
import sh.rhiobet.lalafin.access.AccessService; import sh.rhiobet.lalafin.core.access.AccessService;
import sh.rhiobet.lalafin.path.Path; import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
@ApplicationScoped @ApplicationScoped
@Named("file/role") @Named("file/role")

View File

@@ -1,10 +1,10 @@
package sh.rhiobet.lalafin.file; package sh.rhiobet.lalafin.file.configuration;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import io.smallrye.config.ConfigMapping; import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "file") @ConfigMapping(prefix = "lalafin.file")
public interface FileConfiguration { public interface FileConfiguration {
public String directory(); public String directory();
public Optional<List<String>> ignored(); public Optional<List<String>> ignored();

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) {
this(filename, "", directUrl, "");
}
}

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,89 @@
package sh.rhiobet.lalafin.file.model;
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.api.internal.redis.FileTokenProvider;
import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.core.path.plugin.PathPlugin;
import sh.rhiobet.lalafin.file.ThumbnailService;
import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
@ApplicationScoped
public class FileMetadataService {
@Inject
LalafinConfiguration lalafinConfiguration;
@Inject
FileConfiguration fileConfiguration;
@Inject
ThumbnailService thumbnailService;
@Inject
@Named("file/thumbnail")
PathPlugin thumbnailPathPlugin;
public FileInfoBase getInfo(Path filePath, FileTokenProvider fileTokenProvider) {
if (filePath.exists()) {
Optional<String> thumbUrl = this.thumbnailPathPlugin.resolveURI(filePath);
if (filePath.canHaveChildren()) {
FolderInfo folderInfo = new FolderInfo(
filePath.getFilename(),
thumbUrl.isPresent() ? "/file" + thumbUrl.get() : "",
"/file" + filePath.getURI(),
""
);
try {
for (Path child : filePath.getChildren()) {
if (child.getFilename().startsWith(".")) {
continue;
}
if (fileConfiguration.ignored().isPresent()) {
for (String ignoreString : fileConfiguration.ignored().get()) {
if (child.getFilename().endsWith(ignoreString)) {
continue;
}
}
}
Optional<String> childThumbUrl = this.thumbnailPathPlugin.resolveURI(child);
FileInfoBase contentInfo;
if (child.canHaveChildren()) {
contentInfo = new FolderInfo(child.getFilename(), "/file" + child.getURI());
} else {
contentInfo = new FileInfo(child.getFilename(), "/file" + child.getURI());
}
if (childThumbUrl.isPresent()) {
contentInfo.thumbnailUrl = "/file" + childThumbUrl.get();
}
folderInfo.content.add(contentInfo);
}
} catch (IOException e) {
e.printStackTrace();
}
return folderInfo;
} else {
FileInfo fileInfo = new FileInfo(
filePath.getFilename(),
thumbUrl.isPresent() ? "/file" + thumbUrl.get() : "",
"/file" + filePath.getURI(),
""
);
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,63 @@
package sh.rhiobet.lalafin.file.rest;
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.model.FileInfoBase;
import sh.rhiobet.lalafin.file.model.FileMetadataService;
@Authenticated
@Path("/v1/api/private/file")
public class FilePrivateAPI {
@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) {
sh.rhiobet.lalafin.core.path.model.Path path;
try {
path = this.pathFactory.toPath(names);
} 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, null);
if (fileInfo == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(fileInfo).build();
}
}

View File

@@ -1,4 +1,4 @@
package sh.rhiobet.lalafin.thumbnail; package sh.rhiobet.lalafin.file.thumbnail;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
@@ -10,29 +10,30 @@ import java.util.regex.Pattern;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Named; import jakarta.inject.Named;
import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration; import sh.rhiobet.lalafin.file.configuration.FileConfiguration;
import sh.rhiobet.lalafin.path.FileSystemPath; import sh.rhiobet.lalafin.core.path.model.FileSystemPath;
import sh.rhiobet.lalafin.path.Path; import sh.rhiobet.lalafin.core.path.model.Path;
import sh.rhiobet.lalafin.path.PathAccessor; import sh.rhiobet.lalafin.core.path.model.PathAccessor;
import sh.rhiobet.lalafin.path.PathPlugin; import sh.rhiobet.lalafin.core.path.model.ZipEntryPath;
import sh.rhiobet.lalafin.path.ZipEntryPath; import sh.rhiobet.lalafin.core.path.plugin.PathPlugin;
@ApplicationScoped @ApplicationScoped
@Named("thumbnail") @Named("file/thumbnail")
public class ThumbnailPathPlugin extends PathAccessor implements PathPlugin { public class FileThumbnailPathPlugin extends PathAccessor implements PathPlugin {
@Inject @Inject
FileApiConfiguration fileApiConfiguration; FileConfiguration fileConfiguration;
@Override @Override
public Optional<String> resolveURI(Path path) { public Optional<String> resolveURI(Path path) {
switch (path) { switch (path) {
case FileSystemPath fsp: case FileSystemPath fsp:
Optional<java.nio.file.Path> thumbnailAbsolutePath = getThumbnailPath(path); Optional<java.nio.file.Path> thumbnailAbsolutePath = getThumbnailPath(path);
if (thumbnailAbsolutePath.isPresent()) { if (thumbnailAbsolutePath.isPresent()) {
java.nio.file.Path rootFolderPath = Paths.get(this.fileApiConfiguration.directory()); java.nio.file.Path rootFolderPath = Paths.get(this.fileConfiguration.directory());
return Optional.of(rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get()) return Optional.of(
.toUri().getRawPath()); "/" + rootFolderPath.resolve("file").relativize(thumbnailAbsolutePath.get())
.toString());
} else { } else {
return Optional.empty(); return Optional.empty();
} }
@@ -40,10 +41,10 @@ public class ThumbnailPathPlugin extends PathAccessor implements PathPlugin {
case ZipEntryPath zep: case ZipEntryPath zep:
return Optional.empty(); return Optional.empty();
} }
} }
@Override @Override
public OutputStream getOutputStream(Path path) throws IOException { public OutputStream getOutputStream(Path path) throws IOException {
switch (path) { switch (path) {
case FileSystemPath fsp: case FileSystemPath fsp:
Optional<java.nio.file.Path> thumbnailAbsolutePath = getThumbnailPath(path); Optional<java.nio.file.Path> thumbnailAbsolutePath = getThumbnailPath(path);
@@ -58,16 +59,16 @@ public class ThumbnailPathPlugin extends PathAccessor implements PathPlugin {
case ZipEntryPath zep: case ZipEntryPath zep:
throw new IOException("A zip file is not a valid location to store a thumbnail."); throw new IOException("A zip file is not a valid location to store a thumbnail.");
} }
} }
private Optional<java.nio.file.Path> getThumbnailPath(Path path) { private Optional<java.nio.file.Path> getThumbnailPath(Path path) {
try { try {
return Files.list(getAbsolutePath(path).getParent().resolve(".thumbnails")) return Files.list(getAbsolutePath(path).getParent().resolve(".thumbnails"))
.filter(f -> f.getFileName().toString() .filter(f -> f.getFileName().toString()
.matches("^" + Pattern.quote(path.getFilename()) + "(\\.[^.]+)*$")) .matches("^" + Pattern.quote(path.getFilename()) + "(\\.[^.]+)*$"))
.findFirst(); .findFirst();
} catch (IOException ignored) { } catch (IOException ignored) {
return Optional.empty(); return Optional.empty();
} }
} }
} }