Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions backend/src/main/java/org/booklore/service/ArchiveService.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
Expand Down Expand Up @@ -71,6 +73,7 @@ private Stream<Entry> streamEntriesFromRar(Path path) throws IOException {
private Stream<Entry> streamEntriesFrom7z(Path path) throws IOException {
try (var sevenZFile = new SevenZFile.Builder().setPath(path).get()) {
return StreamSupport.stream(sevenZFile.getEntries().spliterator(), false)
.filter(e -> !e.isDirectory())
.map(entry -> new Entry(entry.getName(), entry.getSize()))
.toList()
.stream();
Expand Down Expand Up @@ -247,15 +250,15 @@ static final class LimitReachedException extends IOException {
}
}

public long extractEntryToPath(Path path, String entryName, Path outputPath) throws IOException {
public void extractEntryToPath(Path path, String entryName, Path outputPath) throws IOException {
ReentrantLock lock = getFileLock(path);
lock.lock();

boolean hasCreatedFile = false;
try (OutputStream outputStream = Files.newOutputStream(outputPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
hasCreatedFile = true;

return transferEntryTo(path, entryName, outputStream);
transferEntryTo(path, entryName, outputStream);
} catch (Exception e) {
if (hasCreatedFile) {
try {
Expand All @@ -270,4 +273,36 @@ public long extractEntryToPath(Path path, String entryName, Path outputPath) thr
lock.unlock();
}
}

public List<Path> extractToDirectory(Path path, Path outputPath) throws IOException {
return extractToDirectory(path, outputPath, null);
}

public List<Path> extractToDirectory(Path path, Path outputPath, Predicate<Entry> predicate) throws IOException {
List<Path> extractedPaths = new ArrayList<>();

for (var entry : getEntries(path)) {
Comment thread
imnotjames marked this conversation as resolved.
if (predicate != null && !predicate.test(entry)) {
continue;
}

Path entryPath = outputPath.resolve(entry.name).normalize();
if (!entryPath.startsWith(outputPath)) {
log.warn("Archive entry outside target directory: {}", entry.name);
throw new IOException("Archive entry outside target directory");
}

if (Files.exists(entryPath)) {
log.warn("Entry already exists, skipping: {}", entry.name);
continue;
}

Files.createDirectories(entryPath.getParent());
extractEntryToPath(path, entry.name, entryPath);

extractedPaths.add(entryPath);
}

return extractedPaths;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,42 +185,28 @@ private Configuration initializeFreemarkerConfiguration() {
}

private List<Path> extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException {
List<Path> imagePaths = new ArrayList<>();

for (ArchiveService.Entry entry : archiveService.getEntries(cbxFile.toPath())) {
if (!isImageFile(entry.name())) {
continue;
}

validateImageSize(entry.name(), entry.size());

try {
Path outputPath = extractedImagesDir.resolve(extractFileName(entry.name()));
var imagePaths = archiveService.extractToDirectory(
cbxFile.toPath(),
extractedImagesDir,
(entry) -> {
if (!isImageFile(entry.name())) {
return false;
}

archiveService.extractEntryToPath(cbxFile.toPath(), entry.name(), outputPath);
if (entry.size() > MAX_IMAGE_SIZE_BYTES) {
log.warn("Image too large, skipping: {} ({})", entry.name(), entry.size());
return false;
}

imagePaths.add(outputPath);
} catch (Exception e) {
log.warn("Error extracting image {}: {}", entry.name(), e.getMessage());
}
}
return true;
}
);

log.debug("Found {} image entries in CBR file", imagePaths.size());
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
return imagePaths;
}

private String extractFileName(String entryPath) {
return Path.of(entryPath).getFileName().toString();
}

private void validateImageSize(String imageName, long size) throws IOException {
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new IOException(String.format("Image '%s' exceeds maximum size limit: %d bytes (max: %d bytes)",
imageName, size, MAX_IMAGE_SIZE_BYTES));
}
}

private boolean isImageFile(String fileName) {
if (shouldIgnoreEntry(fileName)) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.booklore.service.appsettings.AppSettingService;
import org.booklore.util.MimeDetector;
import org.booklore.util.SecureXmlUtils;
import org.booklore.util.epub.EpubContentReader;
import org.booklore.util.epub.EpubContentWriter;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.w3c.dom.Document;
Expand All @@ -35,7 +37,6 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
Expand All @@ -45,9 +46,6 @@
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import java.util.function.Predicate;

@Slf4j
Expand Down Expand Up @@ -76,16 +74,12 @@ public void saveMetadataToFile(File epubFile, BookMetadataEntity metadata, Strin
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("epub_edit_" + UUID.randomUUID());
extractZipToDirectory(epubFile, tempDir);
archiveService.extractToDirectory(epubFile.toPath(), tempDir);

File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
log.warn("Could not locate OPF file in EPUB");
return;
}
Path opfPath = findOpfPath(tempDir);

DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document opfDoc = builder.parse(opfFile);
Document opfDoc = builder.parse(opfPath.toFile());

Element metadataElement = getOrCreateMetadataElement(opfDoc);
final String DC_NS = "http://purl.org/dc/elements/1.1/";
Expand Down Expand Up @@ -236,10 +230,10 @@ public void saveMetadataToFile(File epubFile, BookMetadataEntity metadata, Strin
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfPath.toFile()));

File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
createEpubZipFromDirectory(tempDir, tempEpub.toPath());
EpubContentWriter.createEpubFromDirectory(tempDir, tempEpub.toPath());

if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");
Expand Down Expand Up @@ -440,16 +434,12 @@ private void replaceCoverImageInternal(BookEntity bookEntity, byte[] coverData,
File epubFile = new File(bookEntity.getFullFilePath().toUri());
tempDir = Files.createTempDirectory("epub_cover_" + UUID.randomUUID());

extractZipToDirectory(epubFile, tempDir);
archiveService.extractToDirectory(epubFile.toPath(), tempDir);

File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
log.warn("OPF file not found in EPUB: {}", epubFile.getName());
return;
}
Path opfPath = findOpfPath(tempDir);

DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document opfDoc = builder.parse(opfFile);
Document opfDoc = builder.parse(opfPath.toFile());

applyCoverImageToEpub(tempDir, opfDoc, coverData);

Expand All @@ -458,10 +448,10 @@ private void replaceCoverImageInternal(BookEntity bookEntity, byte[] coverData,
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfPath.toFile()));

File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
createEpubZipFromDirectory(tempDir, tempEpub.toPath());
EpubContentWriter.createEpubFromDirectory(tempDir, tempEpub.toPath());

if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");
Expand Down Expand Up @@ -651,36 +641,7 @@ private void applyCoverImageToEpub(Path tempDir, Document opfDoc, byte[] coverDa
}

private Path findOpfPath(Path tempDir) throws IOException, ParserConfigurationException, SAXException {
Path containerXml = tempDir.resolve("META-INF/container.xml");
if (!Files.exists(containerXml)) {
throw new IOException("container.xml not found at expected location: " + containerXml);
}

DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false);
Document containerDoc = builder.parse(containerXml.toFile());
Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0);
if (rootfile == null) {
throw new IOException("No <rootfile> found in container.xml");
}

String opfPath = ((Element) rootfile).getAttribute("full-path");
if (opfPath.isBlank()) {
throw new IOException("Missing or empty 'full-path' attribute in <rootfile>");
}

return tempDir.resolve(opfPath).normalize();
}

private File findOpfFile(File rootDir) {
File[] matches = rootDir.listFiles(path -> path.isFile() && path.getName().endsWith(".opf"));
if (matches != null && matches.length > 0) return matches[0];
for (File file : Objects.requireNonNull(rootDir.listFiles())) {
if (file.isDirectory()) {
File child = findOpfFile(file);
if (child != null) return child;
}
}
return null;
return EpubContentReader.findOPFInExtractedEpub(tempDir);
Comment thread
imnotjames marked this conversation as resolved.
}

private byte[] loadImage(String pathOrUrl) {
Expand All @@ -692,73 +653,6 @@ private byte[] loadImage(String pathOrUrl) {
}
}

private void extractZipToDirectory(File zipSource, Path targetDir) throws IOException {
Path zipPath = zipSource.toPath();

if (!Files.isRegularFile(zipPath) || !Files.isReadable(zipPath)) {
throw new IOException("Target is not a readable regular file.");
}

for (var name : archiveService.getEntryNames(zipPath)) {
Path entryPath = targetDir.resolve(name).normalize();
if (!entryPath.startsWith(targetDir)) {
throw new IOException("ZIP entry outside target directory: " + name);
}

if (Files.exists(entryPath)) {
log.warn("EPUB Entry already exists, skipping: {}", entryPath);
continue;
}

Files.createDirectories(entryPath.getParent());
archiveService.extractEntryToPath(zipPath, name, entryPath);
}
}

private void createEpubZipFromDirectory(Path sourceDir, Path targetZip) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) {
// EPUB spec requires mimetype to be the first entry in the ZIP, uncompressed (STORED)
Path mimetypeFile = sourceDir.resolve("mimetype");
if (Files.exists(mimetypeFile)) {
byte[] mimetypeData = Files.readAllBytes(mimetypeFile);
ZipEntry mimetypeEntry = new ZipEntry("mimetype");
mimetypeEntry.setMethod(ZipEntry.STORED);
mimetypeEntry.setSize(mimetypeData.length);
mimetypeEntry.setCompressedSize(mimetypeData.length);
CRC32 crc = new CRC32();
crc.update(mimetypeData);
mimetypeEntry.setCrc(crc.getValue());
zos.putNextEntry(mimetypeEntry);
zos.write(mimetypeData);
zos.closeEntry();
} else {
log.warn("EPUB mimetype file not found in extracted directory — output may be spec-invalid");
}

try (var pathStream = Files.walk(sourceDir)) {
pathStream
.filter(path -> !path.equals(sourceDir))
.filter(path -> !path.equals(mimetypeFile))
.sorted()
.forEach(path -> {
try {
String relativePath = sourceDir.relativize(path).toString().replace(File.separatorChar, '/');
if (Files.isDirectory(path)) {
zos.putNextEntry(new ZipEntry(relativePath + "/"));
zos.closeEntry();
} else {
zos.putNextEntry(new ZipEntry(relativePath));
Files.copy(path, zos);
zos.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
}

private void removeMetaByName(Element metadataElement, String name) {
NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta");
for (int i = metas.getLength() - 1; i >= 0; i--) {
Expand Down
Loading
Loading