diff --git a/locales/en-US.json b/locales/en-US.json index 3230cef..6cb1cd0 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -15,7 +15,9 @@ "MSG_CREATE_SEPARATOR": " Creating separator in {0}", "MSG_GENERATE_META": " Generating meta.ini file", "MSG_ADDON_DELETE_OLD_VERSION": " Deleting contents of the previous version", - "MSG_DOWNLOADING_ARCHIVE": " Downloading archive...", + "MSG_ADDON_ALREADY_UP_TO_DATE": " Installed version matches archive — skipping reinstall", + "MSG_HASH_MISMATCH_SUMMARY": "\nHash mismatches detected for the following mods:", + "MSG_HASH_MISMATCH_ENTRY": " {0}: expected {1}, got {2}", "MSG_READ_SETUP" : " Reading setup instructions", "MSG_COPY_TO": " Copying {0} ==> {1}", "MSG_COPY": " Copying {0}", @@ -33,5 +35,9 @@ "ERR_INSTALLATION_FAILED": "An error occurred during installation: {0}", "DLG_RESUME_TITLE": "Resume Installation", - "DLG_RESUME_MESSAGE": "Previous installation was interrupted at \"{0}\". Resume from there?" + "DLG_RESUME_MESSAGE": "Previous installation was interrupted at \"{0}\". Resume from there?", + + "DLG_HASH_MISMATCH_TITLE": "Hash Mismatch", + "DLG_HASH_MISMATCH_UPDATE_TITLE": "Update Hashes", + "DLG_HASH_MISMATCH_UPDATE_MESSAGE": "Would you like to update the mismatched hashes in modlist.yaml with the actual values?" } diff --git a/src/main/java/de/tosox/zonerelay/application/InstallCoordinator.java b/src/main/java/de/tosox/zonerelay/application/InstallCoordinator.java index 54fa12c..9bbb9f7 100644 --- a/src/main/java/de/tosox/zonerelay/application/InstallCoordinator.java +++ b/src/main/java/de/tosox/zonerelay/application/InstallCoordinator.java @@ -4,22 +4,31 @@ import com.google.inject.Singleton; import com.google.inject.name.Named; import de.tosox.zonerelay.domain.model.EntryType; +import de.tosox.zonerelay.domain.model.HashMismatch; import de.tosox.zonerelay.domain.model.Mod; import de.tosox.zonerelay.domain.model.ModEntry; import de.tosox.zonerelay.domain.model.ModlistConfig; import de.tosox.zonerelay.domain.port.*; +import de.tosox.zonerelay.infrastructure.download.DownloadsManifestStore; +import de.tosox.zonerelay.infrastructure.download.ManifestEntry; +import de.tosox.zonerelay.infrastructure.persistence.ModlistHashUpdater; import de.tosox.zonerelay.shared.config.AppPaths; +import de.tosox.zonerelay.shared.config.ArchiveCleanupStrategy; +import de.tosox.zonerelay.shared.config.UserSettings; import de.tosox.zonerelay.shared.i18n.Localizer; import de.tosox.zonerelay.shared.logging.Logger; import de.tosox.zonerelay.shared.progress.ProgressListener; import lombok.Setter; - import org.apache.commons.io.FileUtils; +import javax.swing.*; import java.io.File; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; @Singleton public class InstallCoordinator { @@ -33,6 +42,9 @@ public class InstallCoordinator { private final ShortcutCreator shortcutCreator; private final InstallProgressStore progressStore; private final AppPaths paths; + private final UserSettings userSettings; + private final DownloadsManifestStore manifestStore; + private final ModlistHashUpdater modlistHashUpdater; private final AtomicBoolean isInstalling = new AtomicBoolean(false); @@ -47,7 +59,9 @@ public InstallCoordinator(@Named("file") Logger fileLogger, @Named("ui") Logger Localizer localizer, List installers, ArchiveDownloader archiveDownloader, ProfileSetup profileSetup, SplashImageCopier splashImageCopier, ShortcutCreator shortcutCreator, - InstallProgressStore progressStore, AppPaths paths) { + InstallProgressStore progressStore, AppPaths paths, + UserSettings userSettings, DownloadsManifestStore manifestStore, + ModlistHashUpdater modlistHashUpdater) { this.fileLogger = fileLogger; this.uiLogger = uiLogger; this.localizer = localizer; @@ -58,6 +72,9 @@ public InstallCoordinator(@Named("file") Logger fileLogger, @Named("ui") Logger this.shortcutCreator = shortcutCreator; this.progressStore = progressStore; this.paths = paths; + this.userSettings = userSettings; + this.manifestStore = manifestStore; + this.modlistHashUpdater = modlistHashUpdater; } public void startInstallation(ModlistConfig config, boolean fullInstall, String resumeFromId) { @@ -87,13 +104,14 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r int totalMods = config.getMods().size() + config.getPatches().size() + 1; AtomicInteger completedMods = new AtomicInteger(0); AtomicBoolean resumePointFound = new AtomicBoolean(resumeFromId == null); + List hashMismatches = new ArrayList<>(); uiLogger.info("\n================================================================="); uiLogger.info(localizer.translate("MSG_STARTING_INSTALLATION")); uiLogger.info("================================================================="); - installEntries(config.getMods(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound); - installEntries(config.getPatches(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound); - installEntries(config.getSeparators(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound); + installEntries(config.getMods(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches); + installEntries(config.getPatches(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches); + installEntries(config.getSeparators(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches); uiLogger.info("\n================================================================="); uiLogger.info(localizer.translate("MSG_INSTALLATION_MO2_SETUP")); @@ -107,6 +125,10 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r progressStore.clear(); FileUtils.deleteQuietly(paths.getTempDir().toFile()); + if (!hashMismatches.isEmpty()) { + presentHashMismatchSummary(hashMismatches); + } + uiLogger.info(localizer.translate("MSG_COMPLETE_INSTALLATION")); fileLogger.info("Installation completed successfully"); @@ -115,7 +137,8 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r private void installEntries(List entries, boolean fullInstall, int totalMods, AtomicInteger completedMods, - String resumeFromId, AtomicBoolean resumePointFound) throws Exception { + String resumeFromId, AtomicBoolean resumePointFound, + List hashMismatches) throws Exception { if (entries == null || entries.isEmpty()) { return; } @@ -135,17 +158,41 @@ private void installEntries(List entries, boolean fullInstal uiLogger.info(localizer.translate("MSG_TITLE_CONFIGENTRY", entry.getName())); fileLogger.info("Installing entry: %s", entry.getId()); - File archive = null; + File archive; if (entry instanceof Mod mod) { - uiLogger.info(localizer.translate("MSG_DOWNLOADING_ARCHIVE")); - archive = archiveDownloader.download(mod.getUrl(), paths.getDownloadsDir().toFile(), currentProgressListener); - } + ManifestEntry previousEntry = manifestStore.getManifest().getEntry(mod.getId()); - ModInstaller installer = installers.stream() - .filter(i -> i.supports(entry)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No installer for entry type: " + entry.getType())); - installer.install(entry, archive, currentProgressListener); + DownloadResult result = archiveDownloader.download(mod.getUrl(), mod.getId(), mod.getHash(), + paths.getDownloadsDir().toFile(), currentProgressListener); + archive = result.archive(); + + String installedHash = previousEntry != null ? previousEntry.installedHash() : null; + if (installedHash != null && installedHash.equalsIgnoreCase(result.computedHash())) { + uiLogger.info(localizer.translate("MSG_ADDON_ALREADY_UP_TO_DATE")); + fileLogger.info("Installed hash matches archive, skipping reinstall: %s", mod.getId()); + if (entry.getType() != EntryType.SEPARATOR) { + totalProgressListener.onProgressUpdate(completedMods.incrementAndGet(), totalMods); + } + continue; + } + + if (mod.hasHash()) { + if (!mod.getHash().equalsIgnoreCase(result.computedHash())) { + hashMismatches.add(new HashMismatch(mod.getName(), mod.getId(), mod.getHash(), result.computedHash())); + fileLogger.warn("Hash mismatch for %s: expected=%s actual=%s", + mod.getName(), mod.getHash(), result.computedHash()); + } else { + fileLogger.info("Hash OK for %s", mod.getName()); + } + } + + findInstaller(entry).install(entry, archive, currentProgressListener); + + manifestStore.recordInstall(mod.getId(), result.computedHash()); + applyArchiveCleanup(result, previousEntry); + } else { + findInstaller(entry).install(entry, null, currentProgressListener); + } if (entry.getType() != EntryType.SEPARATOR) { totalProgressListener.onProgressUpdate(completedMods.incrementAndGet(), totalMods); @@ -153,6 +200,60 @@ private void installEntries(List entries, boolean fullInstal } } + private ModInstaller findInstaller(ModEntry entry) { + return installers.stream() + .filter(i -> i.supports(entry)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No installer for entry type: " + entry.getType())); + } + + private void applyArchiveCleanup(DownloadResult result, ManifestEntry previousEntry) { + ArchiveCleanupStrategy strategy = userSettings.getArchiveCleanupStrategy(); + File currentArchive = result.archive(); + + switch (strategy) { + case DELETE_ALL -> { + FileUtils.deleteQuietly(currentArchive); + fileLogger.info("DELETE_ALL: removed archive %s", currentArchive.getName()); + } + case KEEP_LATEST_ONLY -> { + if (previousEntry != null && !previousEntry.filename().equals(currentArchive.getName())) { + File oldArchive = new File(currentArchive.getParentFile(), previousEntry.filename()); + if (oldArchive.isFile()) { + FileUtils.deleteQuietly(oldArchive); + fileLogger.info("KEEP_LATEST_ONLY: removed old archive %s", previousEntry.filename()); + } + } + } + case KEEP_ALL -> { /* do nothing */ } + } + } + + private void presentHashMismatchSummary(List mismatches) { + StringBuilder sb = new StringBuilder(localizer.translate("MSG_HASH_MISMATCH_SUMMARY")); + for (HashMismatch m : mismatches) { + sb.append("\n").append(localizer.translate("MSG_HASH_MISMATCH_ENTRY", + m.modName(), m.expectedHash(), m.actualHash())); + } + String message = sb.toString(); + uiLogger.warn(message); + fileLogger.warn(message); + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog(null, message, + localizer.translate("DLG_HASH_MISMATCH_TITLE"), JOptionPane.WARNING_MESSAGE); + int choice = JOptionPane.showConfirmDialog(null, + localizer.translate("DLG_HASH_MISMATCH_UPDATE_MESSAGE"), + localizer.translate("DLG_HASH_MISMATCH_UPDATE_TITLE"), + JOptionPane.YES_NO_OPTION); + if (choice == JOptionPane.YES_OPTION) { + Map updates = mismatches.stream() + .collect(Collectors.toMap(HashMismatch::modId, HashMismatch::actualHash)); + new Thread(() -> modlistHashUpdater.updateHashes(paths.getModlistYaml(), updates), + "modlist-hash-update").start(); + } + }); + } + private void setupMo2Environment(ModlistConfig config) { uiLogger.info(localizer.translate("MSG_CREATE_CUSTOM_PROFILE")); fileLogger.info("Setting up MO2 profile"); diff --git a/src/main/java/de/tosox/zonerelay/domain/model/HashMismatch.java b/src/main/java/de/tosox/zonerelay/domain/model/HashMismatch.java new file mode 100644 index 0000000..e742296 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/domain/model/HashMismatch.java @@ -0,0 +1,3 @@ +package de.tosox.zonerelay.domain.model; + +public record HashMismatch(String modName, String modId, String expectedHash, String actualHash) {} diff --git a/src/main/java/de/tosox/zonerelay/domain/model/Mod.java b/src/main/java/de/tosox/zonerelay/domain/model/Mod.java index d61c793..39aaea6 100644 --- a/src/main/java/de/tosox/zonerelay/domain/model/Mod.java +++ b/src/main/java/de/tosox/zonerelay/domain/model/Mod.java @@ -10,18 +10,25 @@ public class Mod extends ModEntry { private final String url; private final List setup; + private final String hash; @JsonCreator public Mod(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("url") String url, + @JsonProperty("hash") String hash, @JsonProperty("setup") List setup) { - this(id, EntryType.MOD, name, url, setup); + this(id, EntryType.MOD, name, url, hash, setup); } - protected Mod(String id, EntryType type, String name, String url, List setup) { + protected Mod(String id, EntryType type, String name, String url, String hash, List setup) { super(id, type, name); this.url = url; + this.hash = hash; this.setup = setup != null ? setup : List.of(); } + + public boolean hasHash() { + return hash != null && !hash.isBlank(); + } } diff --git a/src/main/java/de/tosox/zonerelay/domain/model/Patch.java b/src/main/java/de/tosox/zonerelay/domain/model/Patch.java index c6bc489..86d1e28 100644 --- a/src/main/java/de/tosox/zonerelay/domain/model/Patch.java +++ b/src/main/java/de/tosox/zonerelay/domain/model/Patch.java @@ -10,7 +10,8 @@ public class Patch extends Mod { public Patch(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("url") String url, + @JsonProperty("hash") String hash, @JsonProperty("setup") List setup) { - super(id, EntryType.PATCH, name, url, setup); + super(id, EntryType.PATCH, name, url, hash, setup); } } diff --git a/src/main/java/de/tosox/zonerelay/domain/port/ArchiveDownloader.java b/src/main/java/de/tosox/zonerelay/domain/port/ArchiveDownloader.java index 8b0d0b9..1418988 100644 --- a/src/main/java/de/tosox/zonerelay/domain/port/ArchiveDownloader.java +++ b/src/main/java/de/tosox/zonerelay/domain/port/ArchiveDownloader.java @@ -5,5 +5,5 @@ import java.io.File; public interface ArchiveDownloader { - File download(String url, File destination, ProgressListener listener) throws Exception; + DownloadResult download(String url, String modId, String declaredHash, File destination, ProgressListener listener) throws Exception; } diff --git a/src/main/java/de/tosox/zonerelay/domain/port/DownloadResult.java b/src/main/java/de/tosox/zonerelay/domain/port/DownloadResult.java new file mode 100644 index 0000000..c46ea61 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/domain/port/DownloadResult.java @@ -0,0 +1,5 @@ +package de.tosox.zonerelay.domain.port; + +import java.io.File; + +public record DownloadResult(File archive, String computedHash) {} diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadManifest.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadManifest.java new file mode 100644 index 0000000..1fe5b49 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadManifest.java @@ -0,0 +1,30 @@ +package de.tosox.zonerelay.infrastructure.download; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +@Getter +public class DownloadManifest { + private final Map mods; + + public DownloadManifest() { + this.mods = new HashMap<>(); + } + + @JsonCreator + public DownloadManifest(@JsonProperty("mods") Map mods) { + this.mods = mods != null ? new HashMap<>(mods) : new HashMap<>(); + } + + public ManifestEntry getEntry(String modId) { + return mods.get(modId); + } + + public void putEntry(String modId, ManifestEntry entry) { + mods.put(modId, entry); + } +} diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadsManifestStore.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadsManifestStore.java new file mode 100644 index 0000000..14b4f59 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/DownloadsManifestStore.java @@ -0,0 +1,67 @@ +package de.tosox.zonerelay.infrastructure.download; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.google.inject.name.Named; +import de.tosox.zonerelay.shared.config.AppPaths; +import de.tosox.zonerelay.shared.logging.Logger; +import lombok.Getter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Singleton +public class DownloadsManifestStore { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Logger logger; + private final Path manifestPath; + + @Getter + private final DownloadManifest manifest; + + @Inject + public DownloadsManifestStore(@Named("file") Logger logger, AppPaths paths) { + this.logger = logger; + this.manifestPath = paths.getDownloadsDir().resolve("manifest.json"); + this.manifest = load(); + } + + public synchronized void recordDownload(String modId, String url, String filename) { + ManifestEntry existing = manifest.getEntry(modId); + String installedHash = existing != null ? existing.installedHash() : null; + manifest.putEntry(modId, new ManifestEntry(url, filename, installedHash)); + persist(); + } + + public synchronized void recordInstall(String modId, String installedHash) { + ManifestEntry existing = manifest.getEntry(modId); + if (existing != null) { + manifest.putEntry(modId, new ManifestEntry(existing.url(), existing.filename(), installedHash)); + persist(); + } + } + + private DownloadManifest load() { + if (!Files.exists(manifestPath)) { + return new DownloadManifest(); + } + try { + return MAPPER.readValue(manifestPath.toFile(), DownloadManifest.class); + } catch (IOException e) { + logger.warn("Failed to load downloads manifest, starting fresh: %s", e.getMessage()); + return new DownloadManifest(); + } + } + + private void persist() { + try { + Files.createDirectories(manifestPath.getParent()); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + } catch (IOException e) { + logger.error("Failed to persist downloads manifest: %s", e.getMessage()); + } + } +} diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/HttpArchiveDownloader.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/HttpArchiveDownloader.java index 6aa101a..5ba587e 100644 --- a/src/main/java/de/tosox/zonerelay/infrastructure/download/HttpArchiveDownloader.java +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/HttpArchiveDownloader.java @@ -4,10 +4,12 @@ import com.google.inject.Singleton; import com.google.inject.name.Named; import de.tosox.zonerelay.domain.port.ArchiveDownloader; +import de.tosox.zonerelay.domain.port.DownloadResult; import de.tosox.zonerelay.infrastructure.download.source.UrlSource; import de.tosox.zonerelay.shared.logging.Logger; import de.tosox.zonerelay.shared.progress.ProgressInputStream; import de.tosox.zonerelay.shared.progress.ProgressListener; +import de.tosox.zonerelay.shared.util.HashUtils; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; @@ -17,6 +19,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.util.List; +import java.util.Optional; @Singleton public class HttpArchiveDownloader implements ArchiveDownloader { @@ -24,33 +27,35 @@ public class HttpArchiveDownloader implements ArchiveDownloader { private final HttpClient httpClient; private final List urlSources; private final FilenameResolver filenameResolver; + private final DownloadsManifestStore manifestStore; @Inject public HttpArchiveDownloader(@Named("file") Logger logger, HttpClient httpClient, - List urlSources, FilenameResolver filenameResolver) { + List urlSources, FilenameResolver filenameResolver, + DownloadsManifestStore manifestStore) { this.logger = logger; this.httpClient = httpClient; this.urlSources = urlSources; this.filenameResolver = filenameResolver; + this.manifestStore = manifestStore; } @Override - public File download(String url, File destination, ProgressListener listener) throws Exception { - // TODO: Filename resolving shouldn't be needed to check if the archive is already downloaded - String resolvedUrl = resolveUrl(url); + public DownloadResult download(String url, String modId, String declaredHash, File destination, ProgressListener listener) throws Exception { + ResolveResult resolved = resolveUrl(url); - String filename = filenameResolver.resolve(resolvedUrl); - File archive = new File(destination, filename); - - if (archive.isFile()) { - logger.info("Archive already downloaded, skipping: %s", archive.getPath()); - return archive; + Optional cached = tryServeFromCache(url, modId, declaredHash, destination, resolved); + if (cached.isPresent()) { + return cached.get(); } + String filename = filenameResolver.resolve(resolved.url()); + File archive = new File(destination, filename); + logger.info("Downloading %s", url); Request request = new Request.Builder() - .url(resolvedUrl) + .url(resolved.url()) .build(); try (Response response = httpClient.execute(request)) { @@ -72,11 +77,44 @@ public File download(String url, File destination, ProgressListener listener) th throw e; } + String computedHash = HashUtils.md5(archive); + manifestStore.recordDownload(modId, url, filename); + logger.info("Downloaded to %s", archive.getPath()); - return archive; + return new DownloadResult(archive, computedHash); + } + + private Optional tryServeFromCache(String url, String modId, String declaredHash, + File destination, ResolveResult resolved) throws IOException { + ManifestEntry cached = manifestStore.getManifest().getEntry(modId); + if (cached == null || !cached.url().equals(url)) { + return Optional.empty(); + } + + File cachedArchive = new File(destination, cached.filename()); + if (!cachedArchive.isFile()) { + return Optional.empty(); + } + + if (resolved.hasHash()) { + String installedHash = cached.installedHash(); + if (installedHash != null && installedHash.equalsIgnoreCase(resolved.hash())) { + logger.info("Archive unchanged on server (scraped hash match), skipping: %s", cachedArchive.getPath()); + return Optional.of(new DownloadResult(cachedArchive, installedHash)); + } + } else if (declaredHash != null) { + String cachedFileHash = HashUtils.md5(cachedArchive); + if (declaredHash.equalsIgnoreCase(cachedFileHash)) { + logger.info("Archive already downloaded (declared hash match), skipping: %s", cachedArchive.getPath()); + return Optional.of(new DownloadResult(cachedArchive, cachedFileHash)); + } + logger.info("Cached archive does not match declared hash, re-downloading: %s", cachedArchive.getPath()); + } + + return Optional.empty(); } - private String resolveUrl(String url) throws Exception { + private ResolveResult resolveUrl(String url) throws Exception { for (UrlSource source : urlSources) { if (source.supports(url)) { return source.resolve(url); diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/ManifestEntry.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/ManifestEntry.java new file mode 100644 index 0000000..6bef803 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/ManifestEntry.java @@ -0,0 +1,16 @@ +package de.tosox.zonerelay.infrastructure.download; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ManifestEntry(String url, String filename, String installedHash) { + @JsonCreator + public ManifestEntry( + @JsonProperty("url") String url, + @JsonProperty("filename") String filename, + @JsonProperty("installedHash") String installedHash) { + this.url = url; + this.filename = filename; + this.installedHash = installedHash; + } +} diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/ResolveResult.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/ResolveResult.java new file mode 100644 index 0000000..44ff9bc --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/ResolveResult.java @@ -0,0 +1,15 @@ +package de.tosox.zonerelay.infrastructure.download; + +public record ResolveResult(String url, String hash) { + public static ResolveResult of(String url) { + return new ResolveResult(url, null); + } + + public static ResolveResult of(String url, String hash) { + return new ResolveResult(url, hash); + } + + public boolean hasHash() { + return hash != null && !hash.isBlank(); + } +} diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/DirectUrlSource.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/DirectUrlSource.java index db21f83..c35cd1c 100644 --- a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/DirectUrlSource.java +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/DirectUrlSource.java @@ -1,5 +1,7 @@ package de.tosox.zonerelay.infrastructure.download.source; +import de.tosox.zonerelay.infrastructure.download.ResolveResult; + public class DirectUrlSource implements UrlSource { @Override public boolean supports(String url) { @@ -7,7 +9,7 @@ public boolean supports(String url) { } @Override - public String resolve(String url) { - return url; + public ResolveResult resolve(String url) { + return ResolveResult.of(url); } } diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/ModDbUrlSource.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/ModDbUrlSource.java index f582614..ef26ed9 100644 --- a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/ModDbUrlSource.java +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/ModDbUrlSource.java @@ -4,6 +4,7 @@ import com.google.inject.Singleton; import com.google.inject.name.Named; import de.tosox.zonerelay.infrastructure.download.HttpClient; +import de.tosox.zonerelay.infrastructure.download.ResolveResult; import de.tosox.zonerelay.shared.logging.Logger; import okhttp3.Request; import okhttp3.Response; @@ -33,7 +34,7 @@ public boolean supports(String url) { } @Override - public String resolve(String url) throws Exception { + public ResolveResult resolve(String url) throws Exception { Document addonPage = fetchPage(url); Element downloadElem = addonPage.getElementById("downloadmirrorstoggle"); if (downloadElem == null) { @@ -51,7 +52,27 @@ public String resolve(String url) throws Exception { } String relDownloadLink = downloadLinkElement.attr("href"); - return MODDB_BASE + relDownloadLink; + String resolvedUrl = MODDB_BASE + relDownloadLink; + + String hash = scrapeHash(addonPage); + if (hash != null) { + logger.info("Scraped ModDB MD5 hash: %s", hash); + } + + return ResolveResult.of(resolvedUrl, hash); + } + + private String scrapeHash(Document downloadPage) { + Element h5 = downloadPage.selectFirst("h5:contains(MD5 Hash)"); + if (h5 == null) { + return null; + } + Element span = h5.nextElementSibling(); + if (span == null) { + return null; + } + String hash = span.text().trim().toLowerCase(); + return hash.isEmpty() ? null : hash; } private Document fetchPage(String url) throws IOException { diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/UrlSource.java b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/UrlSource.java index f1ecbaa..0074191 100644 --- a/src/main/java/de/tosox/zonerelay/infrastructure/download/source/UrlSource.java +++ b/src/main/java/de/tosox/zonerelay/infrastructure/download/source/UrlSource.java @@ -1,6 +1,8 @@ package de.tosox.zonerelay.infrastructure.download.source; +import de.tosox.zonerelay.infrastructure.download.ResolveResult; + public interface UrlSource { boolean supports(String url); - String resolve(String url) throws Exception; + ResolveResult resolve(String url) throws Exception; } diff --git a/src/main/java/de/tosox/zonerelay/infrastructure/persistence/ModlistHashUpdater.java b/src/main/java/de/tosox/zonerelay/infrastructure/persistence/ModlistHashUpdater.java new file mode 100644 index 0000000..5e88e68 --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/infrastructure/persistence/ModlistHashUpdater.java @@ -0,0 +1,53 @@ +package de.tosox.zonerelay.infrastructure.persistence; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.google.inject.name.Named; +import de.tosox.zonerelay.shared.logging.Logger; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +@Singleton +public class ModlistHashUpdater { + private static final ObjectMapper MAPPER = new ObjectMapper( + YAMLFactory.builder() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build()); + + private final Logger logger; + + @Inject + public ModlistHashUpdater(@Named("file") Logger logger) { + this.logger = logger; + } + + public void updateHashes(Path modlistPath, Map idToHash) { + try { + JsonNode root = MAPPER.readTree(modlistPath.toFile()); + for (String listKey : List.of("mods", "patches")) { + JsonNode list = root.get(listKey); + if (list == null) { + continue; + } + for (JsonNode node : list) { + String id = node.path("id").asText(null); + if (id != null && idToHash.containsKey(id)) { + ((ObjectNode) node).put("hash", idToHash.get(id)); + } + } + } + MAPPER.writerWithDefaultPrettyPrinter().writeValue(modlistPath.toFile(), root); + logger.info("Updated %d hash(es) in modlist.yaml", idToHash.size()); + } catch (IOException e) { + logger.error("Failed to update modlist.yaml hashes: %s", e.getMessage()); + } + } +} diff --git a/src/main/java/de/tosox/zonerelay/shared/config/ArchiveCleanupStrategy.java b/src/main/java/de/tosox/zonerelay/shared/config/ArchiveCleanupStrategy.java new file mode 100644 index 0000000..83354bb --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/shared/config/ArchiveCleanupStrategy.java @@ -0,0 +1,7 @@ +package de.tosox.zonerelay.shared.config; + +public enum ArchiveCleanupStrategy { + KEEP_ALL, + KEEP_LATEST_ONLY, + DELETE_ALL +} diff --git a/src/main/java/de/tosox/zonerelay/shared/config/UserSettings.java b/src/main/java/de/tosox/zonerelay/shared/config/UserSettings.java index 326693f..b736c19 100644 --- a/src/main/java/de/tosox/zonerelay/shared/config/UserSettings.java +++ b/src/main/java/de/tosox/zonerelay/shared/config/UserSettings.java @@ -11,21 +11,26 @@ public class UserSettings { private static final String DEFAULT_LANGUAGE = "en-US"; private static final LogLevel DEFAULT_LOGLEVEL = LogLevel.INFO; + private static final ArchiveCleanupStrategy DEFAULT_CLEANUP = ArchiveCleanupStrategy.KEEP_LATEST_ONLY; private final String language; private final LogLevel logLevel; + private final ArchiveCleanupStrategy archiveCleanupStrategy; public UserSettings() { this.language = DEFAULT_LANGUAGE; this.logLevel = DEFAULT_LOGLEVEL; + this.archiveCleanupStrategy = DEFAULT_CLEANUP; } @JsonCreator public UserSettings( @JsonProperty("language") String language, - @JsonProperty("logLevel") LogLevel logLevel) { + @JsonProperty("logLevel") LogLevel logLevel, + @JsonProperty("archiveCleanupStrategy") ArchiveCleanupStrategy archiveCleanupStrategy) { this.language = language != null ? language : DEFAULT_LANGUAGE; this.logLevel = logLevel != null ? logLevel : DEFAULT_LOGLEVEL; + this.archiveCleanupStrategy = archiveCleanupStrategy != null ? archiveCleanupStrategy : DEFAULT_CLEANUP; } public static UserSettings defaults() { diff --git a/src/main/java/de/tosox/zonerelay/shared/util/HashUtils.java b/src/main/java/de/tosox/zonerelay/shared/util/HashUtils.java new file mode 100644 index 0000000..4955f4d --- /dev/null +++ b/src/main/java/de/tosox/zonerelay/shared/util/HashUtils.java @@ -0,0 +1,32 @@ +package de.tosox.zonerelay.shared.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class HashUtils { + private HashUtils() {} + + public static String md5(File file) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = fis.read(buffer)) != -1) { + digest.update(buffer, 0, n); + } + } + byte[] bytes = digest.digest(); + StringBuilder sb = new StringBuilder(32); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 not available", e); + } + } +}